manager.js

export const DEFAULT_UPDATE_INTERVAL = 40;

/**
 * Internal superclass for managing sound and graphics.
 * @class
 */
class Manager {
    /**
     * @constructor
     * @param {Object} options
     */
    constructor(options = {}) {
        this.onError = options.onError;
        /** @type {Object.<string, Array.<Function>>} */
        this.timers = {};
    }

    withErrorHandler(fn) {
        return (...args) => {
            try {
                fn?.(...args);
            } catch (e) {
                if (typeof this.onError === 'function') {
                    this.onError(e);
                } else {
                    throw e;
                }
            }
        };
    }

    /**
     * Set a timer.
     * A timer can be set a few different ways based on the parameters `data` and `name`.
     * By default, calling `setTimer(fn)` will create a timer with the name of the function.
     * This means that you can't create two timers for the same function, because they'll have
     * the same name. To get around this, you can create a new name and pass it as the
     * fourth parameter to the `setTimer` function:
     * @example
     * const timerID = Randomizer.nextInt(1000);
     * setTimer(() => {
     *   console.log('50 milliseconds has elapsed');
     * }, 50, {}, timerID);
     * //...
     * stopTimer(timerID);
     *
     * @private
     * @param {function} fn - The function to be executed on the timer.
     * @param {number} interval - The time interval for the function.
     * @param {object} data - Any arguments to be passed into `fn`.
     * @param {string} name - The name of the timer.
     */
    setTimer(fn, interval, data, name) {
        interval = interval ?? DEFAULT_UPDATE_INTERVAL;
        name = name ?? fn.name;
        // this is an immediately invoked function expression:
        // it creates a function, then immediately calls it. this is done to create a new closure,
        // which is used to keep variables encapsulated in the scope.
        // the one variable being returned from this expression
        // is a function that we call "stop" that will cause the timer to stop updating.
        const stop = (() => {
            let shouldUpdate = true;
            let lastUpdate = Date.now();
            const timer = () => {
                if (!shouldUpdate) {
                    return;
                }
                const now = Date.now();
                if (now - lastUpdate > interval) {
                    fn(data);
                    lastUpdate = now;
                }
                requestAnimationFrame(timer);
            };
            requestAnimationFrame(timer);

            return () => {
                shouldUpdate = false;
            };
        })();

        if (this.timers[name]) {
            this.timers[name].push(stop);
        } else {
            this.timers[name] = [stop];
        }
    }

    /**
     * Remove a timer associated with a function.
     * @param {function} fn - Function whose timer is removed.
     * note 'fn' may also be the name of the function.
     */
    stopTimer(fn) {
        const name = typeof fn === 'function' ? fn.name : fn;
        this.timers[name]?.forEach(stopper => stopper());
        this.timers[name] = [];
    }

    /**
     * Stop all timers.
     */
    stopAllTimers() {
        Object.keys(this.timers).map(name => {
            this.stopTimer(name);
        });
    }
}

export default Manager;