UNPKG

@byloth/core

Version:

An unopinionated collection of useful functions and classes that I use widely in all my projects. 🔧

277 lines (247 loc) • 8.62 kB
import { TimeUnit } from "../../utils/date.js"; import Publisher from "../callbacks/publisher.js"; import { FatalErrorException, RangeException, RuntimeException } from "../exceptions/index.js"; import { DeferredPromise, SmartPromise } from "../promises/index.js"; import type { Callback } from "../types.js"; import GameLoop from "./game-loop.js"; interface CountdownEventsMap { start: () => void; stop: (reason: unknown) => void; tick: (remainingTime: number) => void; expire: () => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: Callback<any[], any>; } /** * A class representing a countdown. * * It can be started, stopped, when running it ticks at a specific frame rate and it expires when the time's up. * It's possible to subscribe to these events to receive notifications when they occur. * * --- * * @example * ```ts * const countdown = new Countdown(10_000); * * countdown.onStart(() => console.log("The countdown has started.")); * countdown.onTick((remainingTime) => console.log(`The countdown has ${remainingTime}ms remaining.`)); * countdown.onStop((reason) => console.log(`The countdown has stopped because of ${reason}.`)); * countdown.onExpire(() => console.log("The countdown has expired.")); * * countdown.start(); * ``` */ export default class Countdown extends GameLoop { /** * The {@link Publisher} object that will be used to publish the events of the countdown. */ declare protected readonly _publisher: Publisher<CountdownEventsMap>; /** * The total duration of the countdown in milliseconds. * * 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 Countdown.duration} getter instead. */ protected _duration: number; /** * The total duration of the countdown in milliseconds. */ public get duration(): number { return this._duration; } /** * The remaining time of the countdown in milliseconds. * It's calculated as the difference between the total duration and the elapsed time. */ public get remainingTime(): number { return this._duration - this.elapsedTime; } /** * The {@link DeferredPromise} that will be resolved or rejected when the countdown expires or stops. */ protected _deferrer?: DeferredPromise<void>; /** * Initializes a new instance of the {@link Countdown} class. * * --- * * @example * ```ts * const countdown = new Countdown(10_000); * ``` * * --- * * @param duration * The total duration of the countdown in milliseconds. * * @param msIfNotBrowser * The interval in milliseconds at which the countdown will tick if the environment is not a browser. * `TimeUnit.Second` by default. */ public constructor(duration: number, msIfNotBrowser: number = TimeUnit.Second) { const callback = () => { const remainingTime = this.remainingTime; if (remainingTime <= 0) { this._deferrerStop(); this._publisher.publish("tick", 0); this._publisher.publish("expire"); } else { this._publisher.publish("tick", remainingTime); } }; super(callback, msIfNotBrowser); this._duration = duration; } /** * The internal method actually responsible for stopping the * countdown and resolving or rejecting the {@link Countdown._deferrer} promise. * * --- * * @param reason * The reason why the countdown has stopped. * * - If it's `undefined`, the promise will be resolved. * - If it's a value, the promise will be rejected with that value. */ protected _deferrerStop(reason?: unknown): void { if (!(this._isRunning)) { throw new RuntimeException("The countdown hadn't yet started."); } if (!(this._deferrer)) { throw new FatalErrorException(); } this._stop(); this._handle = undefined; this._isRunning = false; if (reason !== undefined) { this._deferrer.reject(reason); } else { this._deferrer.resolve(); } this._deferrer = undefined; } /** * Starts the execution of the countdown. * * If the countdown is already running, a {@link RuntimeException} will be thrown. * * --- * * @example * ```ts * countdown.onStart(() => { [...] }); // This callback will be executed. * countdown.start(); * ``` * * --- * * @param remainingTime * The remaining time to set as default when the countdown starts. * Default is the {@link Countdown.duration} itself. * * @returns A {@link SmartPromise} that will be resolved or rejected when the countdown expires or stops. */ public override start(remainingTime: number = this.duration): SmartPromise<void> { if (this._isRunning) { throw new RuntimeException("The countdown had already stopped or hadn't yet started."); } if (this._deferrer) { throw new FatalErrorException(); } this._deferrer = new DeferredPromise(); super.start(this.duration - remainingTime); this._publisher.publish("start"); return this._deferrer; } /** * Stops the execution of the countdown. * * If the countdown hasn't yet started, a {@link RuntimeException} will be thrown. * * --- * * @example * ```ts * countdown.onStop(() => { [...] }); // This callback will be executed. * countdown.stop(); * ``` * * --- * * @param reason * The reason why the countdown has stopped. * * - If it's `undefined`, the promise will be resolved. * - If it's a value, the promise will be rejected with that value. */ public override stop(reason?: unknown): void { // TODO: Once solved Issues #6 & #10, make the `reason` parameter required. // - https://github.com/Byloth/core/issues/6 // - https://github.com/Byloth/core/issues/10 // this._deferrerStop(reason); this._publisher.publish("stop", reason); } /** * Subscribes to the `expire` event of the countdown. * * --- * * @example * ```ts * countdown.onExpire(() => { [...] }); // This callback will be executed once the countdown has expired. * countdown.start(); * ``` * * --- * * @param callback The callback that will be executed when the countdown expires. * * @returns A function that can be used to unsubscribe from the event. */ public onExpire(callback: () => void): () => void { return this._publisher.subscribe("expire", callback); } /** * Subscribes to the `tick` event of the countdown. * * --- * * @example * ```ts * countdown.onTick((remainingTime) => { [...] }); // This callback will be executed. * countdown.start(); * ``` * * --- * * @param callback The callback that will be executed when the countdown ticks. * @param tickStep * The minimum time in milliseconds that must pass from the previous execution of the callback to the next one. * * - If it's a positive number, the callback will be executed only if the * time passed from the previous execution is greater than this number. * - If it's `0`, the callback will be executed every tick without even checking for the time. * - If it's a negative number, a {@link RangeException} will be thrown. * * @returns A function that can be used to unsubscribe from the event. */ public onTick(callback: (remainingTime: number) => void, tickStep = 0): () => void { if (tickStep < 0) { throw new RangeException("The tick step must be a non-negative number."); } if (tickStep === 0) { return this._publisher.subscribe("tick", callback); } let lastTick = this.remainingTime; return this._publisher.subscribe("tick", (remainingTime: number) => { if ((lastTick - remainingTime) < tickStep) { return; } callback(remainingTime); lastTick = remainingTime; }); } public override readonly [Symbol.toStringTag]: string = "Countdown"; }