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,390 lines (1,238 loc) • 44.7 kB
JavaScript
'use strict';
/**
* 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;
}
}
module.exports = TinyDayNightCycle;