DramaticShapeVoxelMod archival mirror · read-only
284172db7b7a1dcd1b53ee34b2e8baef5d1d6a9c

add clock sync option

DramaticShape · 2026-07-29 · parent 6d27d91411

add clock sync option

Changed files

CHANGELOG.md                  | 17 ++++++++++-------
 README.md                     | 14 +++++++++-----
 lib/DayNight.lua              | 36 ++++++++++++++++++++++++++++++------
 main.lua                      |  5 +++--
 tests/dramatic_shape_test.lua | 42 +++++++++++++++++++++++++++++++++++++-----
 5 files changed, 89 insertions(+), 25 deletions(-)

Diff

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 74eda1e..0ab9c2e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -57,13 +57,16 @@
   horizon above the frame, so the arena keeps exactly the flat sky it had.
 
 - **A day/night cycle**, on a new **DAYTIME** options row: `DAY`, `NIGHT`,
-  `DUSK`, `DAWN`, `CYCLE`. One twenty-minute clock underneath all five -- ten
-  minutes of sun, ten of moon -- where the four named settings are PINS on
-  that dial (noon, mid-night, sunset, sunrise) and `CYCLE` lets it run,
-  picking up from whichever pin the player was just looking at. Everything is
-  a pure function of the clock, so the pinned DUSK is exactly the running
-  cycle stopped at sunset. Setting **VOXEL** to `FULL` switches DAYTIME to
-  `CYCLE` along with the rest of the preset.
+  `DUSK`, `DAWN`, `SYNC`, `CYCLE`. One twenty-minute clock underneath all of
+  them -- ten minutes of sun, ten of moon -- where the four named settings
+  are PINS on that dial (noon, mid-night, sunset, sunrise), `CYCLE` lets it
+  run, picking up from whichever pin or SYNC sky the player was just looking
+  at, and `SYNC` -- the DEFAULT -- lays the machine's own clock onto the
+  dial: local noon is the DAY pin, midnight is NIGHT, six and eighteen the
+  twilights, an hour of the real day is fifty seconds of dial. Everything is a pure function of
+  the clock, so the pinned DUSK is exactly the running cycle stopped at
+  sunset. Setting **VOXEL** to `FULL` switches DAYTIME to `CYCLE` along with
+  the rest of the preset.
 
   **The sun and the moon are in the sky**, and their positions are honest:
   the disc is the light's own direction projected through the same matrix the
diff --git a/README.md b/README.md
index aa0e410..2b15712 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@ menu.
 | `6`, or the **T-SHIFT** options row | OFF → 1 → 2 → 3 → OFF (miniature blur) |
 | `7`, or the **V-CURVE** options row | OFF → 1 → 2 → 3 — bend the world over the horizon |
 | `8`, or the **3D-BTL** options row | ON / OFF — fight on the map instead of on a white field |
-| the **DAYTIME** options row | DAY / NIGHT / DUSK / DAWN / CYCLE — what time it is outdoors |
+| the **DAYTIME** options row | DAY / NIGHT / DUSK / DAWN / SYNC / CYCLE — what time it is outdoors |
 
 **3D-BTL** is on by default and is independent of **VOXEL**: battles draw
 on the world whether or not the free-roam camera is pitched over.
@@ -71,10 +71,14 @@ to the clock:
 
 The **DAYTIME** row is one twenty-minute clock — ten minutes of sun, ten of
 moon. `DAY`, `NIGHT`, `DUSK` and `DAWN` are pins on that dial (noon,
-mid-night, sunset, sunrise) and `CYCLE` lets it run, picking up from
-whichever pin was showing. Everything is a pure function of the clock, so
-the pinned DUSK is exactly the running cycle stopped at sunset. Setting
-**VOXEL** to `FULL` switches DAYTIME to `CYCLE` with the rest of the preset.
+mid-night, sunset, sunrise), `CYCLE` lets it run, picking up from whichever
+pin (or SYNC sky) was showing, and `SYNC` — the default — lays your
+machine's own clock onto the dial: local noon is the DAY pin, midnight is
+NIGHT, six and eighteen the twilights, so out of the box Kanto's evening
+falls when yours does.
+Everything is a pure function of the clock, so the pinned DUSK is exactly
+the running cycle stopped at sunset. Setting **VOXEL** to `FULL` switches
+DAYTIME to `CYCLE` with the rest of the preset.
 
 The **sun and moon hang in the sky**, projected through the same matrix the
 geometry is drawn with, so each stands over the point on the horizon its
diff --git a/lib/DayNight.lua b/lib/DayNight.lua
index 086f3ff..f561c57 100644
--- a/lib/DayNight.lua
+++ b/lib/DayNight.lua
@@ -63,11 +63,14 @@ DayNight.T = { dawn = 0, day = 300, dusk = 600, night = 900 }
 DayNight.KEY = "daytime"
 DayNight.LABEL = "DAYTIME"
 
--- "day" first: an unset or unreadable value is DAY, per the row's contract
--- (ModSetting values[1] is the default)
+-- "sync" first: an unset or unreadable value follows the machine's own
+-- clock, per the row's contract (ModSetting values[1] is the default).
+-- "cycle" stays LAST: the FULL preset reaches for it by position.
 DayNight.setting = ModSetting.new(DayNight.KEY, DayNight.LABEL,
-                                  { "day", "night", "dusk", "dawn", "cycle" },
-                                  { "DAY", "NIGHT", "DUSK", "DAWN", "CYCLE" })
+                                  { "sync", "day", "night", "dusk",
+                                    "dawn", "cycle" },
+                                  { "SYNC", "DAY", "NIGHT", "DUSK",
+                                    "DAWN", "CYCLE" })
 
 DayNight.clock = DayNight.T.day     -- the running cycle's own position
 
@@ -311,10 +314,28 @@ local function mode()
   return DayNight.setting:get() or "day"
 end
 
--- The effective time: the pin, or the running clock under CYCLE.
+-- Where SYNC reads the real clock: local hours, 0..24 with the minutes as
+-- fraction. A named seam rather than a bare os.date call, so the suite can
+-- hand it a fixed hour.
+function DayNight.hours()
+  local d = os.date("*t")
+  return d.hour + d.min / 60 + d.sec / 3600
+end
+
+-- SYNC: the machine's own time of day laid onto the dial. Local noon is
+-- the DAY pin, midnight the NIGHT pin, six and eighteen the twilights --
+-- an hour of the real day is fifty seconds of dial, and Kanto's evening
+-- falls when the player's does.
+function DayNight.syncTime()
+  return ((DayNight.hours() - 6) * (DayNight.CYCLE / 24)) % DayNight.CYCLE
+end
+
+-- The effective time: the pin, the running clock under CYCLE, or the wall
+-- clock under SYNC.
 function DayNight.time()
   local m = mode()
   if m == "cycle" then return DayNight.clock end
+  if m == "sync" then return DayNight.syncTime() end
   return DayNight.T[m] or DayNight.T.day
 end
 
@@ -326,8 +347,11 @@ end
 function DayNight.update(dt)
   local m = mode()
   if m ~= lastMode then
-    if m == "cycle" and DayNight.T[lastMode] then
+    if m == "cycle" then
+      -- from a pin, its time; from SYNC, wherever the real sky already was
       DayNight.clock = DayNight.T[lastMode]
+                       or (lastMode == "sync" and DayNight.syncTime())
+                       or DayNight.clock
     end
     lastMode = m
   end
diff --git a/main.lua b/main.lua
index dd28deb..301fc5a 100644
--- a/main.lua
+++ b/main.lua
@@ -321,8 +321,9 @@ local SETTINGS = {
     .. "shot over the shoulder with a slow parallax drift." },
   { DayNight.setting,
     "What time it is outdoors: pin the sky to DAY, NIGHT, DUSK or DAWN, "
-    .. "or let CYCLE run it -- ten minutes of sun, ten of moon, with the "
-    .. "shadows, the sky and the light following." },
+    .. "let CYCLE run it -- ten minutes of sun, ten of moon, with the "
+    .. "shadows, the sky and the light following -- or SYNC it to the "
+    .. "clock on the wall, so Kanto's evening falls when yours does." },
 }
 
 local schema = {}
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index e4a0c28..f3caeb0 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -297,8 +297,8 @@ T.eq(#hookedRows, 5, "the options hook added a row per setting")
 local grid, curve, battles = hookedRows[2], hookedRows[3], hookedRows[4]
 local daytime = hookedRows[5]
 T.eq(daytime.label, "DAYTIME", "the day/night row carries its label")
-T.eq(daytime.value(), "DAY",
-  "and starts pinned to DAY -- no time set means it is day")
+T.eq(daytime.value(), "SYNC",
+  "and defaults to SYNC -- no value set follows the clock on the wall")
 T.eq(grid.label, "V-GRID", "the grid row carries its label")
 T.eq(grid.value(), "OFF", "the grid starts off")
 T.eq(curve.label, "V-CURVE", "the curve row carries its label")
@@ -1055,6 +1055,11 @@ local skyFor = VoxelScene._skyFor
 local skyStrength = VoxelScene._skyStrength
 T.check(type(skyFor) == "function", "the scene exposes its sky resolve")
 
+-- pinned to DAY for every sky assertion below: the row ships defaulting to
+-- SYNC -- the machine's own clock -- which would hand these tests whatever
+-- palette the hour of the test run happened to be
+run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight").setting:sync("day")
+
 local outside = { def = { id = "PALLET_TOWN", tileset = "OVERWORLD" } }
 local inside = { def = { id = "REDS_HOUSE_1F", tileset = "HOUSE" } }
 local TOP = math.rad(Voxel.ANGLES_DEG[Voxel.MAX_LEVEL + 1])
@@ -1928,7 +1933,10 @@ local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D")
 local Voxel = run.loader.exports.DRAMATIC_SHAPE.lib.require("VoxelState")
 
 -- the dial and its pins
-T.eq(DayNight.setting:get(), "day", "no time set means DAY: the default pin")
+T.eq(DayNight.setting.values[1], "sync",
+  "no value set means SYNC: values[1] is the row's contract default, and "
+  .. "it follows the machine's clock")
+DayNight.setting:sync("day")
 T.eq(DayNight.time(), 300, "and DAY is noon on the dial")
 local PINS = { day = 300, night = 900, dusk = 600, dawn = 0 }
 for name, t in pairs(PINS) do
@@ -2087,6 +2095,30 @@ modApi.save:set(DayNight.SAVE_KEY, nil)
 DayNight.restore()
 T.eq(DayNight.clock, 300, "a save with no clock in it starts at day")
 
+-- SYNC lays the machine's own clock onto the dial: local noon is the DAY
+-- pin, midnight is NIGHT, six and eighteen the twilights
+local hoursWas = DayNight.hours
+DayNight.setting:sync("sync")
+DayNight.hours = function() return 12 end
+T.eq(DayNight.time(), 300, "local noon is the DAY pin")
+DayNight.hours = function() return 18 end
+T.eq(DayNight.time(), 600, "six in the evening is DUSK")
+DayNight.hours = function() return 0 end
+T.eq(DayNight.time(), 900, "midnight is mid-night")
+DayNight.hours = function() return 6.5 end
+T.check(math.abs(DayNight.time() - 25) < 1e-9,
+  "half past six in the morning is just after dawn")
+-- and stepping from SYNC onto CYCLE picks up from the sky already showing
+DayNight.update(0)
+DayNight.setting:sync("cycle")
+DayNight.update(0)
+T.check(math.abs(DayNight.clock - 25) < 1e-9,
+  "CYCLE picks up from wherever SYNC's sky already was")
+DayNight.hours = hoursWas
+T.eq(DayNight.setting.values[#DayNight.setting.values], "cycle",
+  "cycle stays the ladder's last rung -- the FULL preset reaches for it "
+  .. "by position")
+
 -- arriving at FULL sets the clock going
 local Game = require("src.core.Game")
 local hadSave = Game.save
@@ -2098,8 +2130,8 @@ T.eq(DayNight.setting:get(), "cycle",
   "FULL switches DAYTIME to CYCLE -- the diorama gets its running sky")
 Game.save = hadSave
 
--- put the room back the way it was found
-DayNight.setting:sync("day")
+-- put the room back the way it was found (SYNC is the shipped default)
+DayNight.setting:sync("sync")
 DayNight.clock = 300
 DayNight.applyRig(false)
 Voxel3D.tint = { 1, 1, 1 }