UNPKG

tiny-essentials

Version:

Collection of small, essential scripts designed to be used across various projects. These simple utilities are crafted for speed, ease of use, and versatility.

1,233 lines (1,232 loc) 48.2 kB
/** * Represents a mapping of weather type names to their selected values. * Each key is the name of a weather type, and the value is either: * - a string containing the selected weather configuration name, or * - `null` if no selection has been made. * * @typedef {Record<string, string|null>} SelectedWeather */ /** * Callback used to dynamically calculate weather probabilities. * * @callback WeatherCallback * @param {Object} configs - Contextual information about the current time and weather state. * @param {number} configs.hour - Current in-game hour (0–23). * @param {number} configs.minute - Current in-game minute (0–59). * @param {number} configs.currentMinutes - Minutes since midnight (0–1439). * @param {boolean} configs.isDay - Whether it's currently daytime. * @param {string} configs.season - Current season. * @param {SelectedWeather} configs.weather - Current active weather types or nulls if none. * @returns {number} Probability weight for the weather type. */ /** * Represents the complete set of weather configurations. * * @typedef {Object} WeatherCfgs * @property {WeatherCfg} default - Default weather configuration applied when no specific condition matches. * @property {WeatherCfg} day - Weather configuration used during daytime hours. * @property {WeatherCfg} night - Weather configuration used during nighttime hours. * @property {Record<string, WeatherCfg>} hours - Specific weather configurations mapped by hour (e.g., `"06": WeatherCfg`). * @property {Record<string, WeatherCfg>} seasons - Specific weather configurations mapped by season name. */ /** * A weather configuration object mapping weather types to probability weights * or dynamic WeatherCallback functions. * * @typedef {Record<string, number | WeatherCallback>} WeatherCfg */ /** * Weather data with resolved numeric probability values. * * @typedef {Record<string, number>} WeatherData */ /** * Represents the data for a moon's current phase. * * @typedef {Object} MoonData * @property {string} name - The name of the moon. * @property {number} phaseIndex - The current phase index (zero-based) within the moon's cycle. * @property {string} phaseName - The descriptive name of the current phase. * @property {number} cycleLength - The total number of phases/days in the moon's full cycle. */ /** * Represents the raw data structure for a moon's cycle. * * @typedef {Object} MoonRaw * @property {string} name - The name of the moon. * @property {number} cycleLength - Total number of phases in the moon's cycle. * @property {number} currentPhase - The current phase index (0-based). * @property {string[]} [phaseNames] - Optional list of names for each phase in the cycle. */ /** * TinyDayNightCycle is a lightweight and flexible JavaScript library designed to simulate day and * night cycles along with seasonal changes, moons, weather patterns, and in-game time tracking. * It allows you to manage and customize time progression, including hours, * minutes, and seconds, as well as date transitions with support for variable month lengths and years. * * This system also supports dynamic weather changes, moon phases, * and seasonal adjustments, making it ideal for game development, simulations, * or any interactive experience that requires realistic or custom time and environmental cycles. * * With easy-to-use methods and configurable settings, TinyDayNightCycle provides * you with precise control over the flow of time and weather in your application. * * This class provides: * - Customizable day/night cycle with variable sunrise/sunset. * - Calendar system with adjustable month lengths. * - Dynamic weather with multiple configurable probability layers. * - Multi-moon phase tracking. * * Time and date calculations are independent from the real world and can run at any speed. */ class TinyDayNightCycle { /** * Whether to automatically adjust `daySize`, `hourSize`, and `minuteSize` when one of them changes. * @type {boolean} */ #autoSizeAdjuste = true; /** * Number of in-game seconds representing a full day. * @type {number} */ #daySize = 86400; /** * Number of in-game seconds representing a full hour. * @type {number} */ #hourSize = 3600; /** * Number of in-game seconds representing a full minute. * @type {number} */ #minuteSize = 60; /** * Stores season configurations, where each season name maps to an array of numbers. * The number array represent month list. * * @type {Map<string, number[]>} */ #seasons = new Map(); /** * Array of tracked moons with independent phases. * @type {MoonRaw[]} */ #moons = []; /** * @type {number} Hour of the day start (0–23). */ #dayStart; /** * @type {number} Hour of the night start (0–23). */ #nightStart; /** * @type {SelectedWeather} Currently active weather type. */ #weather = { main: null }; /** * @type {number} Current time in seconds since midnight (0–86399). */ #currentSeconds = 0; /** * @type {number} Current time in minutes since midnight (0–1439). */ #currentMinutes = 0; /** * @type {number} Current time in hours since midnight (0–24). */ #currentHours = 0; /** * @type {string} Current season. */ #currentSeason = ''; /** * Current day of the month. * @type {number} */ #currentDay = 1; /** * Current month number. * @type {number} */ #currentMonth = 1; /** * Current year count. * @type {number} */ #currentYear = 1; /** * Whether this instance has been destroyed. * @type {boolean} */ #isDestroyed = false; /** * Number of days in each month. Keys are month numbers. * Can be customized to any structure. * @type {number[]} */ #monthDays = [31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31, 31]; /** * Weather configuration layers: * - `default`: Global fallback probabilities. * - `day` / `night`: Applied depending on time of day. * - `hours`: Specific time ranges (e.g. "06:00-09:00"). * - `seasons`: Seasonal weather overrides. */ #weatherConfig = { /** * General fallback probabilities * @type {WeatherCfg} */ default: {}, /** * Daytime probabilities * @type {WeatherCfg} */ day: {}, /** * Nighttime probabilities * @type {WeatherCfg} */ night: {}, /** * Specific hours { "06:00-09:00": {...} } * @type {Record<string, WeatherCfg>} */ hours: {}, /** * Seasonal configs { summer: {...}, winter: {...} } * @type {Record<string, WeatherCfg>} */ seasons: {}, }; /** * Duration range for each weather type in minutes. * @type {{min: number, max: number}} */ #weatherDuration = { min: 60, max: 180 }; // in minutes /** * Minutes remaining until the current weather changes. * @type {number} */ #weatherTimeLeft = 0; /** * Gets the number of in-game seconds representing a full day. * @returns {number} */ get daySize() { return this.#daySize; } /** * Gets whether automatic size adjustment is enabled. * @returns {boolean} */ get autoSizeAdjuste() { return this.#autoSizeAdjuste; } /** * Sets whether automatic size adjustment is enabled. * @param {boolean} value - `true` to keep the proportional relation between day, hour, and minute sizes; `false` to disable auto-adjustment. * @throws {TypeError} If `value` is not a boolean. */ set autoSizeAdjuste(value) { if (typeof value !== 'boolean') throw new TypeError('autoSizeAdjuste must be a boolean.'); this.#autoSizeAdjuste = value; } /** * Sets the number of in-game seconds representing a full day. * Keeps the proportion based on the current `hourSize` and `minuteSize` if `autoSizeAdjuste` is enabled. * @param {number} value - Positive finite number representing seconds in a full day. * @throws {Error} If `value` is not a positive finite number. */ set daySize(value) { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) throw new Error('daySize must be a positive finite number.'); const hourRatio = this.#hourSize / this.#daySize; const minuteRatio = this.#minuteSize / this.#daySize; this.#daySize = value; if (this.#autoSizeAdjuste) { this.#hourSize = value * hourRatio; this.#minuteSize = value * minuteRatio; } } /** * Gets the number of in-game seconds representing a full hour. * @returns {number} */ get hourSize() { return this.#hourSize; } /** * Sets the number of in-game seconds representing a full hour. * Keeps the proportion based on the current `daySize` and `minuteSize` if `autoSizeAdjuste` is enabled. * @param {number} value - Positive finite number representing seconds in a full hour. * @throws {Error} If `value` is not a positive finite number. */ set hourSize(value) { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) throw new Error('hourSize must be a positive finite number.'); const dayRatio = this.#daySize / this.#hourSize; const minuteRatio = this.#minuteSize / this.#hourSize; this.#hourSize = value; if (this.#autoSizeAdjuste) { this.#daySize = value * dayRatio; this.#minuteSize = value * minuteRatio; } } /** * Gets the number of in-game seconds representing a full minute. * @returns {number} */ get minuteSize() { return this.#minuteSize; } /** * Sets the number of in-game seconds representing a full minute. * Keeps the proportion based on the current `daySize` and `hourSize` if `autoSizeAdjuste` is enabled. * @param {number} value - Positive finite number representing seconds in a full minute. * @throws {Error} If `value` is not a positive finite number. */ set minuteSize(value) { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) throw new Error('minuteSize must be a positive finite number.'); const hourRatio = this.#hourSize / this.#minuteSize; const dayRatio = this.#daySize / this.#minuteSize; this.#minuteSize = value; if (this.#autoSizeAdjuste) { this.#hourSize = value * hourRatio; this.#daySize = value * dayRatio; } } /** * Indicates whether this instance has been destroyed. * @type {boolean} */ get isDestroyed() { return this.#isDestroyed; } /** * Gets the current time in seconds since midnight. * @returns {number} Current seconds (0 to 86399). */ get currentSeconds() { return this.#currentSeconds; } /** * Sets the current time in seconds since midnight. * Also updates the currentMinutes property accordingly. * @param {number} value - Current seconds since midnight (0 to 86399). * @throws {TypeError} If the value is not a number. * @throws {RangeError} If the value is outside the valid range. */ set currentSeconds(value) { this._checkDestroyed(); if (typeof value !== 'number' || !Number.isFinite(value)) throw new TypeError(`currentSeconds must be a finite number, received ${typeof value}`); if (value < 0 || value >= this.#daySize) throw new RangeError(`currentSeconds must be between 0 and 86399, received ${value}`); this.#currentSeconds = Math.floor(value); this.#currentMinutes = Math.floor(value / this.#minuteSize); this.#currentHours = Math.floor(value / this.#hourSize); } /** * Gets the current time in minutes since midnight. * @returns {number} Current minutes (0 to 1439). */ get currentMinutes() { return this.#currentMinutes; } /** * Sets the current time in minutes since midnight. * Also updates the currentSeconds property accordingly (seconds set to zero). * @param {number} value - Current minutes since midnight (0 to 1439). * @throws {TypeError} If the value is not a number. * @throws {RangeError} If the value is outside the valid range. */ set currentMinutes(value) { this._checkDestroyed(); if (typeof value !== 'number' || !Number.isFinite(value)) throw new TypeError(`currentMinutes must be a finite number, received ${typeof value}`); if (value < 0 || value >= 1440) throw new RangeError(`currentMinutes must be between 0 and 1439, received ${value}`); this.#currentMinutes = Math.floor(value); this.#currentHours = Math.floor(value / this.#minuteSize); this.#currentSeconds = Math.floor(value * this.#minuteSize); } /** * Gets the current time in hours since midnight. * May be a decimal value (e.g., 14.5 = 14:30). * @returns {number} Current hours (0 to less than 24). */ get currentHours() { return this.#currentHours; } /** * Sets the current time in hours since midnight. * Accepts decimal numbers to specify partial hours. * Also updates currentMinutes and currentSeconds accordingly. * @param {number} value - Current hours since midnight (0 to less than 24). * @throws {TypeError} If the value is not a finite number. * @throws {RangeError} If the value is outside the valid range. */ set currentHours(value) { this._checkDestroyed(); if (typeof value !== 'number' || !Number.isFinite(value)) throw new TypeError(`currentHours must be a finite number, received ${typeof value}`); if (value < 0 || value >= 24) throw new RangeError(`currentHours must be between 0 and less than 24, received ${value}`); this.#currentHours = Math.floor(value); this.#currentMinutes = Math.floor(value * this.#minuteSize); this.#currentSeconds = Math.floor(value * this.#hourSize); } /** * Returns all moons with their current phase details. * @returns {MoonData[]} Array of moons including their name, current phase index, phase name, and cycle length. */ get moons() { return this.#moons.map((moon) => this.getMoon(moon)); } /** * Returns a list of all season names currently configured. * @returns {string[]} Array of season names. */ get seasons() { return Array.from(this.#seasons.keys()); } /** @returns {number} Hour at which day starts (0-23). */ get dayStart() { return this.#dayStart; } /** @returns {number} Hour at which night starts (0-23). */ get nightStart() { return this.#nightStart; } /** @returns {SelectedWeather} Currently active weather types or nulls if none. */ get weather() { return { ...this.#weather }; } /** @returns {string} Currently active season name. */ get currentSeason() { return this.#currentSeason; } /** @returns {number} Current day of the month. */ get currentDay() { return this.#currentDay; } /** @returns {number} Current month number. */ get currentMonth() { return this.#currentMonth; } /** @returns {number} Current year count. */ get currentYear() { return this.#currentYear; } /** * Returns a shallow copy of the mapping of month numbers to days. * Can be customized for non-standard calendar systems. * @returns {number[]} Object mapping month number to days. */ get monthDays() { return [...this.#monthDays]; } /** * Returns the configured range of weather durations in minutes. * @returns {{min: number, max: number}} Object with min and max weather duration. */ get weatherDuration() { return { ...this.#weatherDuration }; } /** @returns {number} Minutes left until current weather expires. */ get weatherTimeLeft() { return this.#weatherTimeLeft; } /** * Sets the hour at which day starts. * @param {number} value Hour (0-23). * @throws {TypeError} If value is not a number. */ set dayStart(value) { this._checkDestroyed(); if (typeof value !== 'number' || value < 1) throw new TypeError(`dayStart must be a positive non-zero number, received ${typeof value}`); this.#dayStart = value; } /** * Sets the hour at which night starts. * @param {number} value Hour (0-23). * @throws {TypeError} If value is not a number. */ set nightStart(value) { this._checkDestroyed(); if (typeof value !== 'number' || value < 1) throw new TypeError(`nightStart must be a positive non-zero number, received ${typeof value}`); this.#nightStart = value; } /** * Sets the current weather type. * @param {SelectedWeather} value Weather type strings or nulls for no weather. * @throws {TypeError} If value is not a string or null. */ set weather(value) { this._checkDestroyed(); if (typeof value !== 'object' || value === null || Array.isArray(value) || !Object.values(value).every((name) => typeof name === 'string')) throw new TypeError(`weather must be a object with strings, received ${typeof value}`); this.#weather = value; } /** * Sets the current season. * Must be one of the configured seasons. * @param {string} value Season name. * @throws {TypeError} If value is not a string or not a configured season. */ set currentSeason(value) { this._checkDestroyed(); if (typeof value !== 'string' || !this.#seasons.has(value)) { throw new TypeError(`currentSeason must be one of ${Array.from(this.#seasons) .map((v) => v[0]) .join(', ')}, received ${value}`); } this.#currentSeason = value; } /** * Sets the current day of the month. * @param {number} value Day number. * @throws {TypeError} If value is not a number. */ set currentDay(value) { this._checkDestroyed(); if (typeof value !== 'number' || value < 1 || value > this.#monthDays[this.#currentMonth - 1]) throw new TypeError(`currentDay must be a valid day number, received ${typeof value}`); this.#currentDay = value; } /** * Sets the current month. * @param {number} value Month number. * @throws {TypeError} If value is not a number. */ set currentMonth(value) { this._checkDestroyed(); if (typeof value !== 'number' || typeof this.#monthDays[value - 1] !== 'number') throw new TypeError(`currentMonth must be a valid month number, received ${typeof value}`); this.#currentMonth = value; } /** * Sets the current year. * @param {number} value Year count. * @throws {TypeError} If value is not a number. */ set currentYear(value) { this._checkDestroyed(); if (typeof value !== 'number' || value < 1) throw new TypeError(`currentYear must be a positive number non-zero, received ${typeof value}`); this.#currentYear = value; } /** * Sets a custom configuration for the number of days in each month. * This allows for non-standard calendar systems. * @param {number[]} value - An object where keys are month numbers and values are the number of days. */ set monthDays(value) { this._checkDestroyed(); if (!Array.isArray(value)) throw new TypeError(`monthDays must be a array`); if (!value.every((n) => typeof n === 'number')) throw new TypeError(`monthDays must have number values`); this.#monthDays = [...value]; } /** * Sets the weather duration range in minutes. * @param {{min: number, max: number}} value Object with min and max durations. * @throws {TypeError} If value or its min/max are invalid. */ set weatherDuration(value) { this._checkDestroyed(); if (typeof value !== 'object' || value === null) throw new TypeError(`weatherDuration must be a non-null object`); if (typeof value.min !== 'number' || typeof value.max !== 'number') throw new TypeError(`weatherDuration.min and weatherDuration.max must be numbers`); this.#weatherDuration = { ...value }; } /** * Sets the remaining time for current weather in minutes. * @param {number} value Minutes remaining. * @throws {TypeError} If value is not a number. */ set weatherTimeLeft(value) { this._checkDestroyed(); if (typeof value !== 'number') throw new TypeError(`weatherTimeLeft must be a number, received ${typeof value}`); this.#weatherTimeLeft = value; } /** * Returns the entire weather configuration object. * Includes default, day, night, hours, and seasons settings. * @returns {WeatherCfgs} Deep copy of the weather configuration. */ get weatherConfig() { /** @type {Record<string, WeatherCfg>} */ const hours = {}; /** @type {Record<string, WeatherCfg>} */ const seasons = {}; for (const name in this.#weatherConfig.hours) hours[name] = { ...this.#weatherConfig.hours[name] }; for (const name in this.#weatherConfig.seasons) seasons[name] = { ...this.#weatherConfig.seasons[name] }; return { default: { ...this.#weatherConfig.default }, day: { ...this.#weatherConfig.day }, night: { ...this.#weatherConfig.night }, hours, seasons, }; } /** * Sets the entire weather configuration object. * Accepts default, day, night, hours, and seasons settings. * @param {WeatherCfgs} config - The new weather configuration. * @throws {TypeError} If the provided value is not a valid object or contains invalid data types. */ set weatherConfig(config) { this._checkDestroyed(); if (typeof config !== 'object' || config === null) throw new TypeError(`weatherConfig must be a non-null object, received ${config}`); const requiredKeys = ['default', 'day', 'night', 'hours', 'seasons']; for (const key of requiredKeys) { if (!(key in config)) throw new TypeError(`weatherConfig is missing required property "${key}".`); } /** @type {Record<string, WeatherCfg>} */ const hours = {}; /** @type {Record<string, WeatherCfg>} */ const seasons = {}; /** * @param {WeatherCfg} cfg * @param {string} label */ const validateWeatherCfg = (cfg, label) => { if (typeof cfg !== 'object' || cfg === null) throw new TypeError(`${label} must be an object, received ${cfg}`); for (const prop in cfg) { const value = cfg[prop]; const type = typeof value; if (!(type === 'number' || type === 'function')) throw new TypeError(`${label}["${prop}"] must be a number or function, received ${type}`); } }; for (const name in config.hours) { validateWeatherCfg(config.hours[name], `hours["${name}"]`); hours[name] = { ...config.hours[name] }; } for (const name in config.seasons) { validateWeatherCfg(config.seasons[name], `seasons["${name}"]`); seasons[name] = { ...config.seasons[name] }; } /** * @param {WeatherCfg} src * @param {string} label */ const copyCfg = (src, label) => { validateWeatherCfg(src, label); return { ...src }; }; this.#weatherConfig = { default: copyCfg(config.default, 'default'), day: copyCfg(config.day, 'day'), night: copyCfg(config.night, 'night'), hours, seasons, }; } /** * @param {number} dayStart - Hour of the day start (0-23) * @param {number} nightStart - Hour of the night start (0-23) */ constructor(dayStart = 6, nightStart = 18) { this.#dayStart = dayStart; this.#nightStart = nightStart; } /** --------------------- SEASON SYSTEM --------------------- */ /** * Adds or updates a season with the provided numeric values. * * @param {string} name - The name of the season to add or update. * @param {number[]} values - An array of month values associated with the season. * @throws {TypeError} If `name` is not a string or `values` is not an array of numbers. */ addSeason(name, values) { this._checkDestroyed(); if (typeof name !== 'string') throw new TypeError(`Season name must be a string, received ${typeof name}`); if (!Array.isArray(values) || !values.every((v) => typeof v === 'number')) throw new TypeError(`Season values must be an array of numbers`); this.#seasons.set(name, values); } /** * Removes a season from the collection. * * @param {string} name - The name of the season to remove. * @throws {TypeError} If `name` is not a string. */ removeSeason(name) { this._checkDestroyed(); if (typeof name !== 'string') throw new TypeError(`Season name must be a string, received ${typeof name}`); this.#seasons.delete(name); if (this.#currentSeason === name) this.#currentSeason = ''; } /** * Checks whether a season exists in the collection. * * @param {string} name - The name of the season to check. * @returns {boolean} `true` if the season exists, otherwise `false`. * @throws {TypeError} If `name` is not a string. */ hasSeason(name) { if (typeof name !== 'string') throw new TypeError(`Season name must be a string, received ${typeof name}`); return this.#seasons.has(name); } /** * Retrieves the mouth values associated with a season. * * @param {string} name - The name of the season to retrieve. * @returns {number[]} A copy of the month values for the specified season. * @throws {TypeError} If `name` is not a string. * @throws {Error} If the season does not exist. */ getSeason(name) { if (typeof name !== 'string') throw new TypeError(`Season name must be a string, received ${typeof name}`); const result = this.#seasons.get(name); if (!result) throw new Error(`Season "${name}" not found`); return [...result]; } /** --------------------- TIME SYSTEM --------------------- */ /** * Sets the internal time directly. * @param {Object} settings * @param {number} [settings.hour=0] - 0 to 23 * @param {number} [settings.minute=0] - 0 to 59 * @param {number} [settings.second=0] - 0 to 59 */ setTime({ hour = 0, minute = 0, second = 0 }) { this._checkDestroyed(); this.currentSeconds = (hour * this.#hourSize + minute * this.#minuteSize + second) % this.#daySize; } /** * Adds time to the current clock. * @param {Object} settings * @param {number} [settings.hours=0] * @param {number} [settings.minutes=0] * @param {number} [settings.seconds=0] */ addTime({ hours = 0, minutes = 0, seconds = 0 }) { this._checkDestroyed(); let total = this.currentSeconds + hours * this.#hourSize + minutes * this.#minuteSize + seconds; while (total >= this.#daySize) { total -= this.#daySize; this.nextDay(); } while (total < 0) { total += this.#daySize; this.prevDay(); } this.currentSeconds = total; this.updateWeatherTimer((hours * this.#hourSize + minutes * this.#minuteSize + seconds) / this.#minuteSize); // in minutes for compatibility } /** * Returns the current time as both an object and a formatted string, * using the in-game time scale. * * @param {Object} [settings={}] - Optional configuration for time calculation. * @param {number} [settings.hourSize=this.#hourSize] - Number of in-game seconds representing an hour. * @param {number} [settings.minuteSize=this.#minuteSize] - Number of in-game seconds representing a minute. * @param {boolean} [settings.withSeconds=false] - Whether to include seconds in the formatted string. * @returns {{ * hour: number, * minute: number, * second: number, * formatted: string * }} An object containing the hour, minute, second, and the formatted time string. */ getTime({ withSeconds = false, hourSize = this.#hourSize, minuteSize = this.#minuteSize } = {}) { const hour = Math.floor(this.currentSeconds / hourSize); const minute = Math.floor((this.currentSeconds % hourSize) / minuteSize); const second = this.currentSeconds % minuteSize; return { hour, minute, second, formatted: `${hour.toString().padStart(2, '0')}:` + `${minute.toString().padStart(2, '0')}` + `${withSeconds ? `:${second.toString().padStart(2, '0')}` : ''}`, }; } /** * Determines whether the current time is considered day. * Day is defined as the period between `dayStart` and `nightStart`. * @returns {boolean} True if current time is day, false otherwise. */ isDay() { if (this.#dayStart < this.#nightStart) { return (this.#currentMinutes >= this.#dayStart * this.#minuteSize && this.#currentMinutes < this.#nightStart * this.#minuteSize); } else { return (this.#currentMinutes >= this.#dayStart * this.#minuteSize || this.#currentMinutes < this.#nightStart * this.#minuteSize); } } /** * Calculates the number of minutes until the next day period starts. * @returns {number} Minutes until day start. */ minutesUntilDay() { return this.timeUntil(this.#dayStart, 'minutes'); } /** * Calculates the number of seconds until the next day period starts. * @returns {number} Seconds until day start. */ secondsUntilDay() { return this.timeUntil(this.#dayStart, 'seconds'); } /** * Calculates the number of hours until the next day period starts. * @returns {number} Hours until day start. */ hoursUntilDay() { return this.timeUntil(this.#dayStart, 'hours'); } /** * Calculates the number of minutes until the next night period starts. * @returns {number} Minutes until night start. */ minutesUntilNight() { return this.timeUntil(this.#nightStart, 'minutes'); } /** * Calculates the number of seconds until the next night period starts. * @returns {number} Seconds until night start. */ secondsUntilNight() { return this.timeUntil(this.#nightStart, 'seconds'); } /** * Calculates the number of hours until the next night period starts. * @returns {number} Hours until night start. */ hoursUntilNight() { return this.timeUntil(this.#nightStart, 'hours'); } /** * Helper method to calculate time until a specified hour. * Works in seconds internally for higher precision. * Wraps around if target hour is earlier than current time. * Supports returning time in minutes, seconds, or hours. * * @param {number} targetHour - The target hour (0–23). * @param {'minutes'|'seconds'|'hours'} unit - The unit to return. * @returns {number} Time until target hour, in the specified unit. */ timeUntil(targetHour, unit) { this._checkDestroyed(); const targetSeconds = targetHour * this.#hourSize; // 1 hour = 3600 seconds let diffSeconds = targetSeconds - this.#currentSeconds; if (diffSeconds <= 0) diffSeconds += this.#daySize; // 24h = 86400 seconds (wrap to next day) switch (unit) { case 'minutes': return diffSeconds / this.#minuteSize; case 'hours': return diffSeconds / this.#hourSize; case 'seconds': default: return diffSeconds; } } /** * Instantly sets the current time to the start of the specified phase. * @param {"day"|"night"} phase - The phase to set the time to. */ setTo(phase) { this._checkDestroyed(); if (phase === 'day') this.setTime({ hour: this.#dayStart }); else if (phase === 'night') this.setTime({ hour: this.#nightStart }); } /** --------------------- DAY/MONTH/YEAR SYSTEM --------------------- */ /** * Advances the current date by one day. * Automatically wraps months and years based on the configured month days. * Also updates the season and advances moon phases by 1. * @param {number} [amount=1] - Number of days to move forward. */ nextDay(amount = 1) { this._checkDestroyed(); for (let i = 0; i < amount; i++) { this.#currentDay++; const monthDays = this.#monthDays[this.#currentMonth - 1]; if (Number.isNaN(monthDays) || !Number.isFinite(monthDays)) throw new Error('Invalid month day count: monthDays must be a finite number.'); if (this.#currentDay > monthDays) { this.#currentDay = 1; this.#currentMonth++; if (this.#currentMonth > this.#monthDays.length) { this.#currentMonth = 1; this.#currentYear++; } } this.updateSeason(); this.advanceMoons(1); } } /** * Moves the current date backward by one day. * Automatically wraps months and years based on the configured month days. * Also updates the season and rewinds moon phases by 1. * @param {number} [amount=1] - Number of days to move backward. */ prevDay(amount = 1) { this._checkDestroyed(); for (let i = 0; i < amount; i++) { this.#currentDay--; if (this.#currentDay < 1) { this.#currentMonth--; if (this.#currentMonth < 1) { this.#currentMonth = this.#monthDays.length; this.#currentYear--; } this.#currentDay = this.#monthDays[this.#currentMonth - 1] || 30; } this.updateSeason(); this.rewindMoons(1); } } /** * Updates the current season based on the month. */ updateSeason() { this._checkDestroyed(); this.#seasons.forEach((seasonMonths, name) => { if (seasonMonths.includes(this.#currentMonth)) this.#currentSeason = name; }); } /** --------------------- WEATHER SYSTEM --------------------- */ /** * Sets the weather configuration. * @param {WeatherCfgs} config * An object defining default probabilities, time-based probabilities, day/night differences, and seasonal probabilities. */ setWeatherConfig(config) { this._checkDestroyed(); this.#weatherConfig = { ...this.#weatherConfig, ...config }; } /** * Sets the minimum and maximum duration for any weather type. * @param {number} minMinutes - Minimum duration in minutes. * @param {number} maxMinutes - Maximum duration in minutes. */ setWeatherDuration(minMinutes, maxMinutes) { this._checkDestroyed(); this.#weatherDuration.min = minMinutes; this.#weatherDuration.max = maxMinutes; } /** * Updates the remaining time for the current weather. * Automatically selects a new weather type if time runs out. * @param {number} minutesPassed - Number of in-game minutes passed since last update. */ updateWeatherTimer(minutesPassed) { this._checkDestroyed(); this.#weatherTimeLeft -= minutesPassed; if (this.#weatherTimeLeft <= 0) { this.chooseNewWeather(); } } /** * Forces the weather to a specific type, optionally for a given duration. * @param {Object} settings - Configuration object. * @param {string} [settings.where='main'] - The target weather zone or location key. * @param {string|null} settings.type - The weather type (e.g., "sunny", "rain", "storm"). * @param {number|null} [settings.duration=null] - Duration in minutes. If null, a random duration is used. */ forceWeather({ where = 'main', type, duration = null }) { this._checkDestroyed(); const weather = this.#weather; weather[where] = type; this.weather = weather; this.#weatherTimeLeft = duration ?? this._randomInRange(this.#weatherDuration.min, this.#weatherDuration.max); } /** * Chooses a new weather type based on configured probabilities. * Probabilities can come from: * - Default settings * - Hour-based ranges * - Day/night differences * - Seasonal settings * - Custom overrides passed as parameter * If a value is a function, it will be executed with contextual data and must return a number. * @param {Object} [settings={}] - Configuration object. * @param {string} [settings.where='main'] - The target weather zone or location key. * @param {WeatherCfg} [settings.customWeather] - Optional weather probability overrides. * @returns {string|null} - The selected weather type, or null if none selected. */ chooseNewWeather({ customWeather, where = 'main' } = {}) { this._checkDestroyed(); /** @type {WeatherData} */ let probabilities = {}; /** * Helper: Add probability or initialize * @param {WeatherCfg} source */ const addProbabilities = (source) => { for (const [key, value] of Object.entries(source)) { let resolvedValue = value; // If it's a function, call it if (typeof resolvedValue === 'function') { resolvedValue = resolvedValue({ hour: Math.floor(this.#currentMinutes / this.#minuteSize), minute: this.#currentMinutes % this.#minuteSize, currentMinutes: this.#currentMinutes, isDay: this.isDay(), season: this.#currentSeason, weather: this.#weather, }); } // Only add if it's a number if (typeof resolvedValue === 'number' && !Number.isNaN(resolvedValue)) { probabilities[key] = (probabilities[key] || 0) + resolvedValue; } } }; // 1. Default fallback addProbabilities(this.#weatherConfig.default || {}); // 2. Specific hours for (const range in this.#weatherConfig.hours) { const [start, end] = range.split('-').map((t) => { const [h, m] = t.split(':').map(Number); return h * this.#minuteSize + (m || 0); }); const current = this.#currentMinutes; const inRange = start <= end ? current >= start && current <= end : current >= start || current <= end; if (inRange) { addProbabilities(this.#weatherConfig.hours[range]); } } // 3. Day/Night if (this.isDay() && this.#weatherConfig.day) { addProbabilities(this.#weatherConfig.day); } else if (!this.isDay() && this.#weatherConfig.night) { addProbabilities(this.#weatherConfig.night); } // 4. Seasonal if (this.#weatherConfig.seasons?.[this.#currentSeason]) { addProbabilities(this.#weatherConfig.seasons[this.#currentSeason]); } // 5. Custom weather passed directly if (customWeather && typeof customWeather === 'object') { addProbabilities(customWeather); } // Pick random based on final probabilities const entries = Object.entries(probabilities).filter(([, prob]) => prob > 0); if (!entries.length) { this.#weather[where] = null; return null; } const total = entries.reduce((sum, [, prob]) => sum + prob, 0); let rand = Math.random() * total; for (const [type, prob] of entries) { if (rand < prob) { this.#weather[where] = type; this.#weatherTimeLeft = this._randomInRange(this.#weatherDuration.min, this.#weatherDuration.max); return type; } rand -= prob; } this.#weather[where] = null; return null; } /** * Returns a random integer between min and max, inclusive. * @param {number} min - Minimum value. * @param {number} max - Maximum value. * @returns {number} - A random integer between min and max. */ _randomInRange(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } /** --------------------- MOON SYSTEM --------------------- */ /** * Add a new moon to the system. * @param {string} name - Name of the moon. * @param {number} cycleLength - Number of days in cycle. * @param {string[]} phaseNames - Optional list of phase names. * @param {number} [startingPhase=0] - Initial phase. * @returns {number} - The moon index. */ addMoon(name, cycleLength, phaseNames, startingPhase = 0) { this._checkDestroyed(); const length = Math.max(1, cycleLength); this.#moons.push({ name, cycleLength: length, currentPhase: ((startingPhase % length) + length) % length, phaseNames, }); return this.#moons.length - 1; } /** * Remove a moon by name. * @param {string} name */ removeMoon(name) { this._checkDestroyed(); this.#moons = this.#moons.filter((m) => m.name !== name); } /** * Advance all moons in a number of days. * @param {number} days */ advanceMoons(days = 1) { for (const index in this.#moons) this.advanceMoon(parseInt(index), days); } /** * Retrocede all moons in a number of days. * @param {number} days */ rewindMoons(days = 1) { for (const index in this.#moons) this.rewindMoon(parseInt(index), days); } /** * Advances the phase of a single moon by a number of days. * @param {number} moonIndex - The index of the moon to advance. * @param {number} days - Number of days to advance. Defaults to 1. * @throws {RangeError} If the moonIndex is invalid. */ advanceMoon(moonIndex, days = 1) { this._checkDestroyed(); const moon = this.#moons[moonIndex]; if (!moon) throw new RangeError(`No moon found at index ${moonIndex}`); moon.currentPhase = (moon.currentPhase + days) % moon.cycleLength; } /** * Rewinds the phase of a single moon by a number of days. * @param {number} moonIndex - The index of the moon to rewind. * @param {number} days - Number of days to rewind. Defaults to 1. * @throws {RangeError} If the moonIndex is invalid. */ rewindMoon(moonIndex, days = 1) { this._checkDestroyed(); const moon = this.#moons[moonIndex]; if (!moon) throw new RangeError(`No moon found at index ${moonIndex}`); moon.currentPhase = (moon.currentPhase - days + moon.cycleLength) % moon.cycleLength; } /** * Checks if a moon exists at the specified index. * * @param {number} index - The index of the moon to check. * @returns {boolean} `true` if a moon exists at the given index, otherwise `false`. */ moonExists(index) { if (!this.#moons[index]) return false; return true; } /** * Retrieves the moon with its current phase details. * * @param {number|MoonRaw} index - The moon index in the internal collection or a `MoonRaw` object. * @returns {MoonData} The moon including its name, current phase index, phase name, and cycle length. * @throws {TypeError} If `index` is neither a number nor a valid `MoonRaw` object. * @throws {RangeError} If `index` is a number but no moon exists at that position. * @throws {Error} If `index` is a `MoonRaw` object but required properties are missing or invalid. */ getMoon(index) { let moon; if (typeof index === 'number') { moon = this.#moons[index]; if (!moon) throw new RangeError(`No moon found at index ${index}`); } else if (index && typeof index === 'object' && typeof index.name === 'string' && typeof index.cycleLength === 'number' && typeof index.currentPhase === 'number') moon = index; else throw new TypeError(`Invalid moon reference. Expected a number index or a MoonRaw object, received ${typeof index}`); return { name: moon.name, phaseIndex: moon.currentPhase, phaseName: moon.phaseNames ? (moon.phaseNames[moon.currentPhase] ?? String(moon.currentPhase)) : String(moon.currentPhase), cycleLength: moon.cycleLength, }; } /** * Checks if the instance has been destroyed and throws an error if so. * @private * @throws {Error} If the instance has already been destroyed. */ _checkDestroyed() { if (this.#isDestroyed) throw new Error('This instance has been destroyed and can no longer be used.'); } /** * Destroys all stored data and marks the instance as unusable. * Clears all internal maps, arrays, and resets primitive values to defaults. * Once destroyed, any further method calls should throw an error or be ignored. */ destroy() { if (this.#isDestroyed) return; // Clear collections this.#seasons.clear(); this.#moons.length = 0; // Reset numbers this.#dayStart = 0; this.#nightStart = 0; this.#currentSeconds = 0; this.#currentMinutes = 0; this.#currentHours = 0; this.#currentDay = 1; this.#currentMonth = 1; this.#currentYear = 1; this.#weatherTimeLeft = 0; // Reset strings this.#currentSeason = ''; // Reset weather this.#weather = { main: null }; // Reset month days this.#monthDays = []; // Reset weather configuration this.#weatherConfig.default = {}; this.#weatherConfig.day = {}; this.#weatherConfig.night = {}; this.#weatherConfig.hours = {}; this.#weatherConfig.seasons = {}; // Reset duration range this.#weatherDuration = { min: 0, max: 0 }; // Mark as destroyed this.#isDestroyed = true; } } export default TinyDayNightCycle;