Files
trail-into-darkness/Packages/com.jovian.calendar/Runtime/WorldClock.cs
2026-04-12 19:14:37 +02:00

215 lines
7.5 KiB
C#

namespace Jovian.Calendar {
/// <summary>
/// Core calendar clock. Feed it a 0-1 normalized day float each tick.
/// Internally accumulates world-minutes and rolls them into the calendar.
/// </summary>
public class WorldClock {
/// <summary>The calendar configuration driving this clock.</summary>
public CalendarSettings Settings { get; }
/// <summary>The current world date and time.</summary>
public WorldDateTime Now { get; private set; }
/// <summary>
/// Total in-world minutes elapsed since the start date.
/// This is the single source of truth. WorldDateTime is derived from it.
/// </summary>
public long TotalElapsedMinutes { get; private set; }
/// <summary>The normalized time (0-1) from the last tick.</summary>
public float LastNormalizedTime { get; private set; }
private float prevNormalized;
private bool initialized;
private float fractionalMinutes;
private readonly long startOffsetMinutes;
/// <summary>
/// Creates a new world clock from the given calendar settings.
/// Validates settings and initializes to the configured start date.
/// </summary>
public WorldClock(CalendarSettings settings) {
settings.Validate();
Settings = settings;
startOffsetMinutes = ComputeMinuteOffset(
settings, settings.startYear, settings.startMonth,
settings.startDay, settings.startHour, settings.startMinute
);
TotalElapsedMinutes = 0;
prevNormalized = -1f;
initialized = false;
Now = MakeDateTime(startOffsetMinutes);
}
/// <summary>
/// Call every frame with the current normalized day progress (0..1).
/// The clock detects the delta since last tick and advances time accordingly.
/// Handles wrap-around (1 to 0 = new day crossing).
/// </summary>
public void Tick(float normalizedTime) {
normalizedTime = Clamp01(normalizedTime);
LastNormalizedTime = normalizedTime;
if(!initialized) {
prevNormalized = normalizedTime;
initialized = true;
return;
}
var delta = normalizedTime - prevNormalized;
if(delta < 0f) {
delta += 1f;
}
if(delta < 1e-7f) {
prevNormalized = normalizedTime;
return;
}
var minutesAdvanced = delta * Settings.MinutesPerDay;
fractionalMinutes += minutesAdvanced;
var wholeMinutes = (int)fractionalMinutes;
if(wholeMinutes > 0) {
fractionalMinutes -= wholeMinutes;
TotalElapsedMinutes += wholeMinutes;
Now = MakeDateTime(startOffsetMinutes + TotalElapsedMinutes);
}
prevNormalized = normalizedTime;
}
/// <summary>
/// Manually advance the clock by a number of in-world minutes.
/// Useful for sleep/rest skips, cutscenes, etc.
/// </summary>
public void AdvanceMinutes(int minutes) {
if(minutes <= 0) {
return;
}
TotalElapsedMinutes += minutes;
Now = MakeDateTime(startOffsetMinutes + TotalElapsedMinutes);
var dayFraction = (float)((Now.hour * Settings.minutesPerHour) + Now.minute) / Settings.MinutesPerDay;
prevNormalized = dayFraction;
}
/// <summary>
/// Converts a total-minutes-from-epoch value into a WorldDateTime.
/// </summary>
private WorldDateTime MakeDateTime(long totalMinutes) {
if(totalMinutes < 0) {
totalMinutes = 0;
}
var minutesPerDay = Settings.MinutesPerDay;
var minutesPerHour = Settings.minutesPerHour;
var totalDays = totalMinutes / minutesPerDay;
var remainderMinutes = (int)(totalMinutes % minutesPerDay);
var hour = remainderMinutes / minutesPerHour;
var minute = remainderMinutes % minutesPerHour;
var daysInYear = Settings.TotalDaysInYear;
var year = (int)(totalDays / daysInYear);
var remainingDays = (int)(totalDays % daysInYear);
var month = 0;
for(var m = 0; m < Settings.daysPerMonth.Length; m++) {
if(remainingDays < Settings.daysPerMonth[m]) {
month = m;
break;
}
remainingDays -= Settings.daysPerMonth[m];
if(m == Settings.daysPerMonth.Length - 1) {
month = m;
break;
}
}
return new WorldDateTime {
year = year,
month = month,
day = remainingDays,
hour = hour,
minute = minute
};
}
/// <summary>
/// Inverse of MakeDateTime: given a calendar date, produce total minutes from epoch.
/// </summary>
private static long ComputeMinuteOffset(
CalendarSettings s, int year, int month, int day, int hour, int minute) {
var totalDays = (long)year * s.TotalDaysInYear;
for(var m = 0; m < month; m++) {
totalDays += s.daysPerMonth[m];
}
totalDays += day;
var totalMinutes = totalDays * s.MinutesPerDay;
totalMinutes += (long)hour * s.minutesPerHour;
totalMinutes += minute;
return totalMinutes;
}
/// <summary>
/// Get the current day-of-week (0-indexed), or -1 if weeks are disabled.
/// </summary>
public int DayOfWeek {
get {
if(Settings.daysPerWeek <= 0) {
return -1;
}
var totalDays = (startOffsetMinutes + TotalElapsedMinutes) / Settings.MinutesPerDay;
return (int)(totalDays % Settings.daysPerWeek);
}
}
/// <summary>
/// Normalized time of day as 0-1 float, derived from the current WorldDateTime.
/// Useful for lighting, skybox lerp, etc.
/// </summary>
public float NormalizedTimeOfDay {
get {
float currentMinute = (Now.hour * Settings.minutesPerHour) + Now.minute;
return currentMinute / Settings.MinutesPerDay;
}
}
/// <summary>Returns the name of the current month from settings.</summary>
public string GetMonthName() {
var month = Now.month;
if(Settings.monthNames == null || month < 0 || month >= Settings.monthNames.Length) {
return "???";
}
return Settings.monthNames[month];
}
/// <summary>Returns a full display string with named month and ordinal day.</summary>
public string FullStringNamed() {
var displayDay = Now.day;
var postFix = displayDay switch {
1 => "st",
2 => "nd",
3 => "rd",
_ => "th"
};
var year = Now.year;
var timeString = Now.TimeString;
return $"{timeString}, {displayDay}{postFix} day of {GetMonthName()}, \nyear {year}";
}
private static float Clamp01(float v) {
return v < 0f ? 0f : v > 1f ? 1f : v;
}
}
}