@byloth/core
Version:
An unopinionated collection of useful functions and classes that I use widely in all my projects. 🔧
266 lines (238 loc) • 8.06 kB
text/typescript
import type { Interval } from "../../core/types.js";
import { isBrowser } from "../../helpers.js";
import Publisher from "../callbacks/publisher.js";
import { FatalErrorException, RuntimeException } from "../exceptions/index.js";
interface GameLoopEventsMap
{
start: () => void;
stop: () => void;
}
/**
* A class representing a {@link https://en.wikipedia.org/wiki/Video_game_programming#Game_structure|game loop} pattern
* that allows to run a function at a specific frame rate.
*
* In a browser environment, it uses the native {@link requestAnimationFrame}
* function to run the callback at the refresh rate of the screen.
* In a non-browser environment, however, it uses the {@link setInterval}
* function to run the callback at the specified fixed interval of time.
*
* Every time the callback is executed, it receives the
* elapsed time since the start of the game loop.
* It's also possible to subscribe to the `start` & `stop` events to receive notifications when they occur.
*
* ---
*
* @example
* ```ts
* const loop = new GameLoop((elapsedTime: number) =>
* {
* console.log(`The game loop has been running for ${elapsedTime}ms.`);
* });
*
* loop.onStart(() => console.log("The game loop has started."));
* loop.onStop(() => console.log("The game loop has stopped."));
*
* loop.start();
* ```
*/
export default class GameLoop
{
/**
* The handle of the interval or the animation frame, depending on the environment.
* It's used to stop the game loop when the {@link GameLoop._stop} method is called.
*/
protected _handle?: number | Interval;
/**
* The time when the game loop has started.
* In addition to indicating the {@link https://en.wikipedia.org/wiki/Unix_time|Unix timestamp}
* of the start of the game loop, it's also used to calculate the elapsed time.
*
* This protected property is the only one that can be modified directly by the derived classes.
* If you're looking for the public and readonly property, use the {@link GameLoop.startTime} getter instead.
*/
protected _startTime: number;
/**
* The time when the game loop has started.
* In addition to indicating the {@link https://en.wikipedia.org/wiki/Unix_time|Unix timestamp}
* of the start of the game loop, it's also used to calculate the elapsed time.
*/
public get startTime(): number
{
return this._startTime;
}
/**
* A flag indicating whether the game loop is currently running or not.
*
* This protected property is the only one that can be modified directly by the derived classes.
* If you're looking for the public and readonly property, use the {@link GameLoop.isRunning} getter instead.
*/
protected _isRunning: boolean;
/**
* A flag indicating whether the game loop is currently running or not.
*/
public get isRunning(): boolean
{
return this._isRunning;
}
/**
* The elapsed time since the start of the game loop.
* It's calculated as the difference between the current time and the {@link GameLoop.startTime}.
*/
public get elapsedTime(): number
{
return performance.now() - this._startTime;
}
/**
* The {@link Publisher} object that will be used to publish the events of the game loop.
*/
protected readonly _publisher: Publisher<GameLoopEventsMap>;
/**
* The internal method actually responsible for starting the game loop.
*
* Depending on the current environment, it could use the
* {@link requestAnimationFrame} or the {@link setInterval} function.
*/
protected readonly _start: () => void;
/**
* The internal method actually responsible for stopping the game loop.
*
* Depending on the current environment, it could use the
* {@link cancelAnimationFrame} or the {@link clearInterval} function.
*/
protected readonly _stop: () => void;
/**
* Initializes a new instance of the {@link GameLoop} class.
*
* ---
*
* @example
* ```ts
* const loop = new GameLoop((elapsedTime: number) => { [...] });
* ```
*
* ---
*
* @param callback The function that will be executed at each iteration of the game loop.
* @param msIfNotBrowser
* The interval in milliseconds that will be used if the current environment isn't a browser. Default is `40`.
*/
public constructor(callback: FrameRequestCallback, msIfNotBrowser = 40)
{
this._startTime = 0;
this._isRunning = false;
if (isBrowser)
{
this._start = () =>
{
callback(this.elapsedTime);
this._handle = window.requestAnimationFrame(this._start);
};
this._stop = () => window.cancelAnimationFrame(this._handle as number);
}
else
{
// eslint-disable-next-line no-console
console.warn(
"Not a browser environment detected. " +
`Using setInterval@${msIfNotBrowser}ms instead of requestAnimationFrame...`
);
this._start = () =>
{
this._handle = setInterval(() => callback(this.elapsedTime), msIfNotBrowser);
};
this._stop = () => clearInterval(this._handle as Interval);
}
this._publisher = new Publisher();
}
/**
* Starts the execution of the game loop.
*
* If the game loop is already running, a {@link RuntimeException} will be thrown.
*
* ---
*
* @example
* ```ts
* loop.onStart(() => { [...] }); // This callback will be executed.
* loop.start();
* ```
*
* ---
*
* @param elapsedTime The elapsed time to set as default when the game loop starts. Default is `0`.
*/
public start(elapsedTime = 0): void
{
if (this._isRunning) { throw new RuntimeException("The game loop has already been started."); }
this._startTime = performance.now() - elapsedTime;
this._start();
this._isRunning = true;
this._publisher.publish("start");
}
/**
* Stops the execution of the game loop.
*
* If the game loop hasn't yet started, a {@link RuntimeException} will be thrown.
*
* ---
*
* @example
* ```ts
* loop.onStop(() => { [...] }); // This callback will be executed.
* loop.stop();
* ```
*/
public stop(): void
{
if (!(this._isRunning))
{
throw new RuntimeException("The game loop had already stopped or hadn't yet started.");
}
if (!(this._handle)) { throw new FatalErrorException(); }
this._stop();
this._handle = undefined;
this._isRunning = false;
this._publisher.publish("stop");
}
/**
* Subscribes to the `start` event of the game loop.
*
* ---
*
* @example
* ```ts
* loop.onStart(() => console.log("The game loop has started."));
* ```
*
* ---
*
* @param callback The function that will be executed when the game loop starts.
*
* @returns A function that can be used to unsubscribe from the event.
*/
public onStart(callback: () => void): () => void
{
return this._publisher.subscribe("start", callback);
}
/**
* Subscribes to the `stop` event of the game loop.
*
* ---
*
* @example
* ```ts
* loop.onStop(() => console.log("The game loop has stopped."));
* ```
*
* ---
*
* @param callback The function that will be executed when the game loop stops.
*
* @returns A function that can be used to unsubscribe from the event.
*/
public onStop(callback: () => void): () => void
{
return this._publisher.subscribe("stop", callback);
}
public readonly [Symbol.toStringTag]: string = "GameLoop";
}