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;
}
}
}