Make your game engine
INFO
This guide is designed only for developers with a good experience with JavaScript. If you are not a developer, you can skip this page and use the default engine.
One of the main goals of Pixi’VN is to provide basic utilities for developing video games. The engine is designed to be modular and flexible, allowing developers to use only the parts they need for their specific projects. This modularity also makes it easier to maintain and update the engine over time.
This is exactly what makes the possibility of creating your own game engine using the pixi-vn
package.
The first step is to create a new project. You can find more information on how to create a new project starting from a template here. The "Game Engine" template is a simple project that contains the basic structure of a game engine. By default, it exports the basic functionality of pixi-vn while removing anything unused from the package.
INFO
A special thread was created to share your idea or ask for help during the development of your Game Engine: Creating a game engine from pixi-vn
The second step is to understand that Pixi’VN is divided into the following independent "modules". This means that you can replace one "module" without having to worry about modifying the others. To do this, you just need to modify GameUnifier
which has the function of connecting the modile headlights to each other, as explained below.
It is also important to understand that by default the engine saves the current state of the game at each step, to give the player the possibility to go back. To do this the following functions are defined:
GameUnifier.getCurrentGameStepState
: This function returns the current state of the game step.GameUnifier.restoreGameStepState
: This function restores the state of the game step.Game.exportGameState
: This function returns the current state of the game. (Optional)Game.restoreGameState
: This function restores the state of the game. (Optional)
Storage
Replacing the base storage of Pixi’VN is a really easy operation.
For example in our case we will replace it with cacheable
, here is what we should change:
import { CacheableMemory } from "cacheable";
const storage = new CacheableMemory();
const flagStorage = new CacheableMemory();
export namespace Game {
export async function initialize(
element: HTMLElement,
options: Partial<ApplicationOptions> & { width: number; height: number },
devtoolsOptions?: Devtools
) {
GameUnifier.init({
getCurrentGameStepState: () => {
return {
// ...
storage: storage.export(),
storage: {
main: createExportableElement([...storage.items]),
flags: createExportableElement([...flagStorage.items]),
},
};
},
restoreGameStepState: async (state, navigate) => {
// ...
storage.restore(state.storage);
storage.setMany(state.storage.main);
flagStorage.setMany(state.storage.flags);
},
// storage
getVariable: (key) => storage.getVariable(key),
setVariable: (key, value) => storage.setVariable(key, value),
removeVariable: (key) => storage.removeVariable(key),
getFlag: (key) => storage.getFlag(key),
setFlag: (name, value) => storage.setFlag(name, value),
// onLabelClosing is called when the label is closed, it can use for example to clear the temporary variables in the storage //
onLabelClosing: (openedLabelsNumber) => StorageManagerStatic.clearOldTempVariables(openedLabelsNumber),
getVariable: (key) => storage.get(key),
setVariable: (key, value) => storage.set(key, value),
removeVariable: (key) => storage.delete(key),
getFlag: (key) => flagStorage.get(key) ?? false,
setFlag: (name, value) => flagStorage.set(name, value),
});
// ...
}
export function clear() {
// ...
storage.clear();
storage.clear(); // new class //
flagStorage.clear();
}
export function exportGameState(): GameState {
return {
// ...
storageData: storage.export(),
storageData: {
main: createExportableElement([...storage.items]),
flags: createExportableElement([...flagStorage.items]),
},
};
}
export async function restoreGameState(data: GameState, navigate: (path: string) => void) {
// ...
storage.restore(data.storageData);
storage.clear();
storage.setMany(data.storageData.main);
flagStorage.clear();
flagStorage.setMany(data.storageData.flags);
}
}
import { CacheableItem } from "cacheable";
export default interface GameState {
engine_version: string;
step: NarrationGameState;
storageData: StorageGameState;
storageData: {
main: CacheableItem[];
flags: CacheableItem[];
};
canvasData: CanvasGameState;
soundData: SoundGameState;
historyData: HistoryGameState;
path: string;
}
Renderer
Even the canvas is a module that can be replaced. The default canvas is a simple canvas that uses the pixi.js
library. You can replace it with any other canvas library you want.
You are not forced to use a WebGL-based 2D renderer. You can use any other renderer you want. For example, you can use a 3D renderer or a React-based renderer. You can also use a combination of different renderers. The only requirement is that the renderer state must be saved and restored at each step.
Since I have no experience on rendering libraries other than pixi.js
, my example will be based on a semi-realistic class.
const canvas = new Canvas();
export namespace Game {
export async function initialize(
options: CanvasOptions,
) {
GameUnifier.init({
getCurrentGameStepState: () => {
return {
// ...
canvas: canvas.export(),
};
},
restoreGameStepState: async (state, navigate) => {
// ...
await canvas.restore(state.canvas);
},
// This function is called when the step is completed, it can be used to force the completion of the animations //
onGoNextEnd: async () => {
canvas.forceCompletionOfAnimations();
},
});
return await canvas.init(options);
}
export function clear() {
// ...
canvas.clear();
}
export function exportGameState(): GameState {
return {
// ...
canvasData: canvas.export(),
};
}
export async function restoreGameState(data: GameState, navigate: (path: string) => void) {
// ...
await canvas.restore(data.canvasData);
}
}
import { CanvasGameState } from "./canvas";
export default interface GameState {
engine_version: string;
stepData: NarrationGameState;
storageData: StorageGameState;
canvasData: CanvasState;
soundData: SoundGameState;
historyData: HistoryGameState;
path: string;
}
Sound
The sound module is a module that can be replaced. The default sound module is a simple sound module that uses the PixiJS Sound
library. You can replace it with any other sound library you want.
For example in our case we will replace it with howler.js
, here is what we should change:
( The implementation of howler was done by AI, so it may not be perfect. )
import { Howl, Howler } from "howler";
export function exportHowlerState() {
const state = Howler._howls.map((howl) => ({
src: howl._src,
volume: howl.volume(),
rate: howl.rate(),
loop: howl.loop(),
playing: howl.playing(),
seek: howl.seek(),
}));
return state;
}
export async function restoreHowlerState(state: Array<any>) {
state.forEach((soundData) => {
const sound = new Howl({
src: soundData.src,
volume: soundData.volume,
rate: soundData.rate,
loop: soundData.loop,
});
if (soundData.playing) {
sound.seek(soundData.seek);
sound.play();
}
});
}
export namespace Game {
export async function initialize(
element: HTMLElement,
options: Partial<ApplicationOptions> & { width: number; height: number },
devtoolsOptions?: Devtools
) {
GameUnifier.init({
getCurrentGameStepState: () => {
return {
// ...
sound: sound.export(),
sound: exportHowlerState(),
};
},
restoreGameStepState: async (state, navigate) => {
// ...
sound.restore(state.sound);
await restoreHowlerState(state.soundData);
},
});
// ...
}
export function clear() {
sound.clear();
Howler._howls.forEach((howl) => howl.unload());
// ...
}
export function exportGameState(): GameState {
return {
// ...
soundData: sound.export(),
soundData: exportHowlerState(),
};
}
export async function restoreGameState(data: GameState, navigate: (path: string) => void) {
// ...
sound.restore(data.soundData);
await restoreHowlerState(data.soundData);
}
}
import { CacheableItem } from "cacheable";
export default interface GameState {
engine_version: string;
stepData: NarrationGameState;
storageData: StorageGameState;
canvasData: CanvasGameState;
soundData: SoundGameState;
soundData: Array<any>;
historyData: HistoryGameState;
path: string;
}
Narration
Replacing the entire narration module is possible but not recommended as it is the strong point of Pixi’VN.
Since it has been handled in a generic way, you can implement your own Label
(the key element of narration) by extending LabelAbstract
.
The LabelAbstract
class is a generic class that can be used to create your own labels. It is a simple class that contains the basic functionality of a label. You can extend this class and add your own functionality to it.
Here's an example:
import { LabelAbstract, LabelProps, StepLabelType } from "@drincs/pixi-vn";
import sha1 from "crypto-js/sha1";
export default class Label<T extends {} = {}> extends LabelAbstract<Label<T>, T> {
public get stepCount(): number {
return this.steps.length;
}
public getStepById(stepId: number): StepLabelType<T> | undefined {
return this.steps[stepId];
}
/**
* @param id is the id of the label
* @param steps is the list of steps that the label will perform
* @param props is the properties of the label
*/
constructor(id: string, steps: StepLabelType<T>[] | (() => StepLabelType<T>[]), props?: LabelProps<Label<T>>) {
super(id, props);
this._steps = steps;
}
private _steps: StepLabelType<T>[] | (() => StepLabelType<T>[]);
/**
* Get the steps of the label.
*/
public get steps(): StepLabelType<T>[] {
if (typeof this._steps === "function") {
return this._steps();
}
return this._steps;
}
public getStepSha(index: number): string {
if (index < 0 || index >= this.steps.length) {
console.warn("stepSha not found, setting to ERROR");
return "error";
}
try {
let step = this.steps[index];
let sha1String = sha1(step.toString().toLocaleLowerCase());
return sha1String.toString();
} catch (e) {
console.warn("stepSha not found, setting to ERROR", e);
return "error";
}
}
}
import { LabelProps, RegisteredLabels, StepLabelType } from "@drincs/pixi-vn";
import Label from "../classes/Label";
/**
* Creates a new label and registers it in the system.
* **This function must be called at least once at system startup to register the label, otherwise the system cannot be used.**
* @param id The id of the label, it must be unique
* @param steps The steps of the label
* @param props The properties of the label
* @returns The created label
*/
export function newLabel<T extends {} = {}>(
id: string,
steps: StepLabelType<T>[] | (() => StepLabelType<T>[]),
props?: Omit<LabelProps<Label<T>>, "choiseIndex">
): Label<T> {
if (RegisteredLabels.get(id)) {
console.info(`Label ${id} already exists, it will be overwritten`);
}
let label = new Label<T>(id, steps, props);
RegisteredLabels.add(label);
return label;
}
History
The history module is a module that can be replaced. The default history module is a simple history module that uses the deep-diff
library. You can replace it with any other history library you want.
Here's an example:
import HistoryManager, { HistoryManagerStatic } from "./classes/HistoryManager";
const stepHistory = new HistoryManager();
export namespace Game {
export async function initialize(
element: HTMLElement,
options: Partial<ApplicationOptions> & { width: number; height: number },
devtoolsOptions?: Devtools
) {
GameUnifier.init({
restoreGameStepState: async (state, navigate) => {
HistoryManagerStatic.originalStepData = state;
// ...
},
addHistoryItem: (historyInfo, opstions) => {
return stepHistory.add(historyInfo, opstions);
},
// ...
});
// ...
}
export function clear() {
// ...
stepHistory.clear();
}
export function exportGameState(): GameState {
return {
// ...
historyData: stepHistory.export(),
};
}
export async function restoreGameState(data: GameState, navigate: (path: string) => void) {
stepHistory.restore(data.historyData);
// ...
}
}
import { GameStepStateData, GameUnifier, HistoryGameState, HistoryStep } from "@drincs/pixi-vn";
import { diff } from "deep-diff";
export class HistoryManagerStatic {
static _stepsHistory: HistoryStep[] = [];
static stepLimitSaved: number = 20;
static get lastHistoryStep(): HistoryStep | null {
if (HistoryManagerStatic._stepsHistory.length > 0) {
return HistoryManagerStatic._stepsHistory[HistoryManagerStatic._stepsHistory.length - 1];
}
return null;
}
static originalStepData: GameStepStateData | undefined = undefined;
}
/**
* This class is a class that manages the steps and labels of the game.
*/
export default class HistoryManager {
get stepsHistory() {
return HistoryManagerStatic._stepsHistory;
}
add(
historyInfo: HistoryInfo = {},
opstions: {
ignoreSameStep?: boolean;
} = {}
) {
const originalStepData = HistoryManagerStatic.originalStepData;
const { ignoreSameStep } = opstions;
const currentStepData: GameStepStateData = GameUnifier.currentGameStepState;
if (!ignoreSameStep && this.isSameStep(originalStepData, currentStepData)) {
return;
}
let data = diff(originalStepData, currentStepData);
this.stepsHistory.push({
...(historyInfo as Omit<HistoryStep, "diff">),
diff: data,
});
HistoryManagerStatic.originalStepData = currentStepData;
}
private isSameStep(originalState: GameStepStateData, newState: GameStepStateData) {
if (originalState.openedLabels.length === newState.openedLabels.length) {
try {
let lastStepDataOpenedLabelsString = JSON.stringify(originalState.openedLabels);
let historyStepOpenedLabelsString = JSON.stringify(newState.openedLabels);
if (
lastStepDataOpenedLabelsString === historyStepOpenedLabelsString &&
originalState.path === newState.path &&
originalState.labelIndex === newState.labelIndex
) {
return true;
}
} catch (e) {
console.error("Error comparing opened labels", e);
return true;
}
}
return false;
}
public clear() {
HistoryManagerStatic._stepsHistory = [];
HistoryManagerStatic.originalStepData = undefined;
}
/* Export and Import Methods */
public export(): HistoryGameState {
return {
stepsHistory: this.stepsHistory,
originalStepData: HistoryManagerStatic.originalStepData,
};
}
public async restore(data: object) {
this.clear();
HistoryManagerStatic._stepsHistory = (data as HistoryGameState)["stepsHistory"];
HistoryManagerStatic.originalStepData = (data as HistoryGameState)["originalStepData"];
}
/* Options Methods */
async goBack() {
// TODO To be implemented
}
get narrativeHistory() {
// TODO To be implemented
}
}