Added a custom calendar system

This commit is contained in:
Sebastian Bularca
2026-04-12 19:14:37 +02:00
parent 3428f168f9
commit 0e00e798fd
27 changed files with 790 additions and 22 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 361fc968822691543a13717f28a81b18
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,16 @@
{
"name": "Jovian.Calendar.Editor",
"rootNamespace": "Jovian.Calendar.Editor",
"references": [
"Jovian.Calendar"
],
"includePlatforms": ["Editor"],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: f267cb10ab07ff54584ed67eb6e56e65
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,151 @@
# Jovian Calendar
A configurable in-game calendar and world clock system for Unity. Supports custom day lengths, hours, minutes, months with variable lengths, named months, week tracking, and a serializable date-time struct. No external dependencies.
## Requirements
- Unity 2022.3 or later
- No additional package dependencies
## Quick Start
### 1. Create a CalendarSettings asset
In the Unity Editor, go to **Assets > Create > Jovian > Calendar > Calendar Settings**.
Configure:
- **Seconds Per Full Day**: How many real-time seconds equal one full in-game day (the 0-1 cycle)
- **Hours Per Day**: In-world hours per day (e.g. 24)
- **Minutes Per Hour**: In-world minutes per hour (e.g. 60)
- **Days Per Month**: Array defining the length of each month. Array length = months per year
- **Month Names**: Display names for each month (must match Days Per Month length)
- **Days Per Week**: Optional week length (0 to disable)
- **Start Date**: Starting year, month, day, hour, minute for the clock
### 2. Create and tick a WorldClock
```csharp
using Jovian.Calendar;
// Create the clock from settings
var clock = new WorldClock(calendarSettings);
// Each frame, feed it a 0-1 normalized day progress
clock.Tick(normalizedDayTime);
// Query the current date and time
WorldDateTime now = clock.Now;
Debug.Log($"Year {now.year}, {clock.GetMonthName()} {now.DisplayDay}, {now.TimeString}");
```
### 3. Display the date
```csharp
// Individual fields
int year = clock.Now.year;
int month = clock.Now.DisplayMonth; // 1-indexed
int day = clock.Now.DisplayDay; // 1-indexed
string time = clock.Now.TimeString; // "HH:MM"
// Full display string
string full = clock.FullStringNamed();
// e.g. "14:30, 5th day of Frostmoon, year 3"
```
## CalendarSettings
ScriptableObject defining calendar rules. Create via **Assets > Create > Jovian > Calendar > Calendar Settings**.
| Field | Type | Description |
|---|---|---|
| `secondsPerFullDay` | float | Real-time seconds for one full 0-1 day cycle |
| `hoursPerDay` | int | In-world hours per day |
| `minutesPerHour` | int | In-world minutes per hour |
| `daysPerMonth` | int[] | Days in each month. Array length = months per year |
| `monthNames` | string[] | Display name per month. Must match daysPerMonth length |
| `daysPerWeek` | int | Days per week (0 to disable week tracking) |
| `startYear` | int | Starting year |
| `startMonth` | int | Starting month (0-indexed) |
| `startDay` | int | Starting day (0-indexed) |
| `startHour` | int | Starting hour |
| `startMinute` | int | Starting minute |
**Computed properties:**
- `MonthsPerYear` -- derived from daysPerMonth array length
- `TotalDaysInYear` -- sum of all daysPerMonth entries
- `MinutesPerDay` -- hoursPerDay * minutesPerHour
## WorldClock
The core clock class. Feed it a normalized 0-1 day progress each tick. It accumulates world-minutes internally, handling day wrap-around and fractional minute accumulation across frames.
```csharp
var clock = new WorldClock(settings);
// Tick with normalized time (0 = midnight, 0.5 = noon, 1 = next midnight)
clock.Tick(normalizedTime);
// Manual time skip (sleep, rest, cutscene)
clock.AdvanceMinutes(480); // skip 8 hours
// Queries
WorldDateTime now = clock.Now;
long elapsed = clock.TotalElapsedMinutes;
float normalized = clock.NormalizedTimeOfDay; // 0-1 for lighting/skybox
int weekday = clock.DayOfWeek; // 0-indexed, -1 if disabled
string monthName = clock.GetMonthName();
string display = clock.FullStringNamed();
```
## WorldDateTime
A serializable struct representing a point in calendar time. Implements `IEquatable<WorldDateTime>` and `IComparable<WorldDateTime>` for collections and sorting.
```csharp
WorldDateTime dt = clock.Now;
// 0-indexed fields
dt.year; dt.month; dt.day; dt.hour; dt.minute;
// 1-indexed display properties
dt.DisplayDay; // day + 1
dt.DisplayMonth; // month + 1
// Formatting
dt.TimeString; // "14:30"
dt.DateString; // "5/3/1"
dt.FullString; // "5/3/1 14:30"
// Comparison
if(dateA < dateB) { ... }
if(dateA == dateB) { ... }
var sorted = dates.OrderBy(d => d);
```
## Integration Example
```csharp
// In your game state initialization:
var calendarSettings = Addressables.LoadAssetAsync<CalendarSettings>("CalendarSettings").WaitForCompletion();
var worldClock = new WorldClock(calendarSettings);
// In your time handler's Tick():
float normalizedTime = localTime / dayLength;
worldClock.Tick(normalizedTime);
// Update UI:
dayText.text = $"Day {worldClock.Now.DisplayDay}, {worldClock.GetMonthName()}";
// Use NormalizedTimeOfDay for visual effects:
float t = worldClock.NormalizedTimeOfDay;
skyboxMaterial.SetFloat("_Blend", t);
directionalLight.intensity = sunCurve.Evaluate(t);
```
## API Reference
| Type | Description |
|---|---|
| `CalendarSettings` | ScriptableObject defining calendar rules (day length, months, weeks, start date) |
| `WorldClock` | Core clock class. Tick with normalized time, query WorldDateTime |
| `WorldDateTime` | Serializable struct for date-time values. IEquatable, IComparable, operator overloads |

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3b2f99c9462058d47ae5f4d667d063d4
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5171b66295e446142a89186f5d91bb16
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,98 @@
using System;
using UnityEngine;
namespace Jovian.Calendar {
/// <summary>
/// ScriptableObject defining the rules of an in-game calendar: day length, hours, minutes,
/// month structure, month names, week length, and starting date. Create via
/// Assets > Create > Jovian > Calendar > Calendar Settings.
/// </summary>
[CreateAssetMenu(fileName = "CalendarSettings", menuName = "Jovian/Calendar/Calendar Settings")]
public class CalendarSettings : ScriptableObject {
/// <summary>How many real-time seconds one full 0 to 1 float cycle represents.</summary>
public float secondsPerFullDay;
/// <summary>How many in-world hours per day (e.g. 24, or 16 for a shorter day).</summary>
public int hoursPerDay;
/// <summary>How many in-world minutes per hour (e.g. 60).</summary>
public int minutesPerHour;
/// <summary>
/// Days in each month, in order. Length of this array defines months per year.
/// E.g. { 30, 28, 31 } = 3 months with different lengths.
/// </summary>
public int[] daysPerMonth;
/// <summary>
/// Custom month names. Must match daysPerMonth.Length.
/// </summary>
public string[] monthNames;
/// <summary>Optional: days per week for week-tracking. 0 to disable.</summary>
public int daysPerWeek;
// --- Starting date ---
public int startYear;
public int startMonth; // 0-indexed
public int startDay; // 0-indexed
public int startHour;
public int startMinute;
/// <summary>Number of months in a year, derived from daysPerMonth array length.</summary>
public int MonthsPerYear => daysPerMonth?.Length ?? 0;
/// <summary>Total days in one full year.</summary>
public int TotalDaysInYear {
get {
var total = 0;
if(daysPerMonth == null) {
return 0;
}
foreach(var t in daysPerMonth) {
total += t;
}
return total;
}
}
/// <summary>Total in-world minutes in a single day.</summary>
public int MinutesPerDay => hoursPerDay * minutesPerHour;
/// <summary>Validates all calendar parameters. Throws ArgumentException on invalid configuration.</summary>
public void Validate() {
if(secondsPerFullDay <= 0f) {
throw new ArgumentException("SecondsPerFullDay must be > 0");
}
if(hoursPerDay <= 0) {
throw new ArgumentException("HoursPerDay must be > 0");
}
if(minutesPerHour <= 0) {
throw new ArgumentException("MinutesPerHour must be > 0");
}
if(daysPerMonth == null || daysPerMonth.Length == 0) {
throw new ArgumentException("DaysPerMonth must have at least one entry");
}
if(monthNames == null || monthNames.Length != daysPerMonth.Length) {
throw new ArgumentException("MonthNames length must match DaysPerMonth length");
}
for(var i = 0; i < daysPerMonth.Length; i++) {
if(daysPerMonth[i] <= 0) {
throw new ArgumentException($"DaysPerMonth[{i}] must be > 0");
}
}
if(startMonth < 0 || startMonth >= daysPerMonth.Length) {
throw new ArgumentException("StartMonth out of range");
}
if(startDay < 0 || startDay >= daysPerMonth[startMonth]) {
throw new ArgumentException("StartDay out of range for StartMonth");
}
if(startHour < 0 || startHour >= hoursPerDay) {
throw new ArgumentException("StartHour out of range");
}
if(startMinute < 0 || startMinute >= minutesPerHour) {
throw new ArgumentException("StartMinute out of range");
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e3a6df85a820d0a4db469ae8a20ea773

View File

@@ -0,0 +1,14 @@
{
"name": "Jovian.Calendar",
"rootNamespace": "Jovian.Calendar",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 237923931c0f9cf4ea0d30f11d58dda7
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,214 @@
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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5121873037b1b704dbee5b3f060332d9

View File

@@ -0,0 +1,92 @@
using System;
namespace Jovian.Calendar {
/// <summary>
/// Represents a point in world-calendar time. Display-friendly.
/// All fields are 0-indexed internally; display properties are 1-indexed where expected.
/// </summary>
[Serializable]
public struct WorldDateTime : IEquatable<WorldDateTime>, IComparable<WorldDateTime> {
public int year;
public int month; // 0-indexed
public int day; // 0-indexed
public int hour;
public int minute;
/// <summary>1-indexed day for display.</summary>
public int DisplayDay => day + 1;
/// <summary>1-indexed month for display.</summary>
public int DisplayMonth => month + 1;
/// <summary>Returns "HH:MM" style time string.</summary>
public string TimeString => $"{hour:D2}:{minute:D2}";
/// <summary>Returns "Day/Month/Year" display string (1-indexed).</summary>
public string DateString => $"{DisplayDay}/{DisplayMonth}/{year}";
/// <summary>Returns full "Day/Month/Year HH:MM".</summary>
public string FullString => $"{DateString} {TimeString}";
public bool Equals(WorldDateTime other) {
return year == other.year && month == other.month && day == other.day
&& hour == other.hour && minute == other.minute;
}
public int CompareTo(WorldDateTime other) {
var c = year.CompareTo(other.year);
if(c != 0) {
return c;
}
c = month.CompareTo(other.month);
if(c != 0) {
return c;
}
c = day.CompareTo(other.day);
if(c != 0) {
return c;
}
c = hour.CompareTo(other.hour);
if(c != 0) {
return c;
}
return minute.CompareTo(other.minute);
}
public override bool Equals(object obj) {
return obj is WorldDateTime other && Equals(other);
}
public override int GetHashCode() {
return HashCode.Combine(year, month, day, hour, minute);
}
public override string ToString() {
return FullString;
}
public static bool operator ==(WorldDateTime a, WorldDateTime b) {
return a.Equals(b);
}
public static bool operator !=(WorldDateTime a, WorldDateTime b) {
return !a.Equals(b);
}
public static bool operator <(WorldDateTime a, WorldDateTime b) {
return a.CompareTo(b) < 0;
}
public static bool operator >(WorldDateTime a, WorldDateTime b) {
return a.CompareTo(b) > 0;
}
public static bool operator <=(WorldDateTime a, WorldDateTime b) {
return a.CompareTo(b) <= 0;
}
public static bool operator >=(WorldDateTime a, WorldDateTime b) {
return a.CompareTo(b) >= 0;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 14218276a99cfd94d805fff6fbbb621e

View File

@@ -0,0 +1,16 @@
{
"name": "com.jovian.calendar",
"version": "0.1.0",
"displayName": "Jovian Calendar",
"description": "A configurable in-game calendar and world clock system with custom months, day lengths, week tracking, and serializable date-time.",
"unity": "2022.3",
"keywords": [
"calendar",
"time",
"clock",
"date"
],
"author": {
"name": "Jovian"
}
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: f75f1da22b70e43499179c6452e92709
PackageManifestImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant: