namespace Jovian.Calendar { /// /// Core calendar clock. Feed it a 0-1 normalized day float each tick. /// Internally accumulates world-minutes and rolls them into the calendar. /// public class WorldClock { /// The calendar configuration driving this clock. public CalendarSettings Settings { get; } /// The current world date and time. public WorldDateTime Now { get; private set; } /// /// Total in-world minutes elapsed since the start date. /// This is the single source of truth. WorldDateTime is derived from it. /// public long TotalElapsedMinutes { get; private set; } /// The normalized time (0-1) from the last tick. public float LastNormalizedTime { get; private set; } private float prevNormalized; private bool initialized; private float fractionalMinutes; private readonly long startOffsetMinutes; /// /// Creates a new world clock from the given calendar settings. /// Validates settings and initializes to the configured start date. /// 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); } /// /// 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). /// 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; } /// /// Manually advance the clock by a number of in-world minutes. /// Useful for sleep/rest skips, cutscenes, etc. /// 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; } /// /// Converts a total-minutes-from-epoch value into a WorldDateTime. /// 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 }; } /// /// Inverse of MakeDateTime: given a calendar date, produce total minutes from epoch. /// 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; } /// /// Get the current day-of-week (0-indexed), or -1 if weeks are disabled. /// public int DayOfWeek { get { if(Settings.daysPerWeek <= 0) { return -1; } var totalDays = (startOffsetMinutes + TotalElapsedMinutes) / Settings.MinutesPerDay; return (int)(totalDays % Settings.daysPerWeek); } } /// /// Normalized time of day as 0-1 float, derived from the current WorldDateTime. /// Useful for lighting, skybox lerp, etc. /// public float NormalizedTimeOfDay { get { float currentMinute = (Now.hour * Settings.minutesPerHour) + Now.minute; return currentMinute / Settings.MinutesPerDay; } } /// Returns the name of the current month from settings. public string GetMonthName() { var month = Now.month; if(Settings.monthNames == null || month < 0 || month >= Settings.monthNames.Length) { return "???"; } return Settings.monthNames[month]; } /// Returns a full display string with named month and ordinal day. 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; } } }