add day night cycle
DramaticShape · 2026-07-29 ·
parent 6d7a93dbad
add day night cycle
Changed files
CHANGELOG.md | 58 +++++++++
README.md | 56 +++++++-
lib/BattleScene.lua | 20 ++-
lib/DayNight.lua | 437 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
lib/Sky.lua | 148 ++++++++++++++++++---
lib/Voxel3D.lua | 50 ++++++-
lib/VoxelScene.lua | 24 +++-
main.lua | 45 +++++++
tests/daynight_shots.lua | 97 ++++++++++++++
tests/dramatic_shape_test.lua | 220 +++++++++++++++++++++++++++++--
10 files changed, 1117 insertions(+), 38 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f26651c..7898802 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -56,6 +56,64 @@
The overworld only. A battle is a staged shot whose placed camera has the
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.
+
+ **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
+ geometry is drawn with, so it stands over the point on the horizon its
+ shadows point away from, at every pitch, window shape and zoom. The sun's
+ noon is this mod's existing sun to the digit -- southeast, 45 degrees up,
+ overhead behind the north-facing camera and correctly out of frame -- and
+ its arc swings north at both ends, so the disc stands IN frame through dawn
+ and dusk, rising half-set on the horizon. The moon arcs the northern sky
+ all night, due north (screen centre) at mid-night, with scaled crater
+ cells. Both are cell art on the sky's own dither grid, sized by the frame
+ (a celestial body's apparent size is an angle, so zooming the ground does
+ not swell it), and both are SCISSORED to the sky's region: the horizon
+ point is where a setting body disappears -- it never hangs under the map.
+
+ **The sky follows the clock.** Phase palettes -- the daytime blues,
+ gold-to-violet dawn, a hotter gold-to-indigo dusk, moonlit navy -- six
+ bands each now, blended along the dial and re-quantised onto the 5-bit
+ lattice, so every mixed frame is still a colour the hardware could show.
+ The blends bend through designed WAYPOINTS rather than straight across --
+ a golden hour on the way into dusk, a violet civil twilight either side of
+ the night -- because day's blue and dusk's gold are near-complements, and
+ a straight lerp between complements bottoms out in dishwater grey. Through the twilights a posterised, checker-dithered GLOW
+ warms the bands around the low sun -- painted light, not an airbrush. The
+ blends are 75 seconds wide either side of each twilight and the pins land
+ on their phase palette unmixed.
+
+ **The shadows follow the sun and the moon.** The shear every shadow is
+ thrown by (direction opposite the body's bearing, length its elevation's
+ cotangent, clamped at twice the caster's height) comes off the clock, the
+ shadow map's signature carries it, and the light's press fades out over the
+ last twelve degrees before the horizon -- so sunset hands off to moonrise
+ through a soft shadowless gap, and moonlight presses at about two-thirds
+ the sun's weight. The scene shader also multiplies every surface by the
+ hour's tint: neutral at noon, warm at the twilights, dim blue at night.
+
+ **Outdoors only**, by the same `Map.isOutdoor` test the sky already rests
+ on: indoors keeps the noon rig, the neutral tint and no sky -- a cave at
+ midnight is exactly as dark as a cave at noon. A battle staged on an
+ outdoor map fights under the hour: the night sky behind the arena, the
+ tint on the mons, the sunset taking the arena's shadows with it; an indoor
+ arena is untouched. The engine's own `world.tod` hook is answered
+ (`MORNING`/`DAY`/`EVENING`/`NIGHT`), so palette or music packs keyed to the
+ period ride this clock for free.
+
+ **The clock rides the save slot.** On the engine's `save.writing` event the
+ cycle's time is written into the mod's own save-file bucket
+ (`save.modData.DRAMATIC_SHAPE`), and read back when a save is opened. A
+ save with no clock in it starts at day.
+
- **A fade out of a battle, where there used to be a hard cut.** The engine
wipes INTO a fight with one of the original's eight transitions and cuts
straight out of it: `BattleState:finish` pops itself and the map is simply
diff --git a/README.md b/README.md
index 695444a..e81bc3e 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +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 |
**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.
@@ -58,11 +59,56 @@ everything else here, so GRAY gets four greys and CLASSIC four greens.
Overworld only — a battle's placed camera looks up from under the horizon and
keeps its flat sky.
-Tunables live at the top of `lib/Sky.lua`: `Sky.PALETTE` (lightest first; its
-length is the band count), `Sky.DITHER`, `Sky.DITHER_START` (how far down each
-band the checker begins — lower is a wider blend, `1` switches it off), and
-`Sky.SPAN` (how much of the frame the bands cover on the rungs whose horizon is
-off-screen).
+Tunables live at the top of `lib/Sky.lua` — `Sky.DITHER`, `Sky.DITHER_START`
+(how far down each band the checker begins — lower is a wider blend, `1`
+switches it off), `Sky.SPAN` (how much of the frame the bands cover on the
+rungs whose horizon is off-screen), and `Sky.DISC_FRAC` (the sun and moon's
+size, as a fraction of the frame) — and the palettes live in `lib/DayNight.lua`
+(`DayNight.PALETTES`, one per phase, lightest first), because the sky belongs
+to the clock:
+
+## What time it is
+
+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.
+
+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
+shadows point away from at any pitch, window shape or zoom. Noon is this
+mod's existing sun to the digit — southeast, 45° up, overhead behind the
+north-facing camera and correctly out of frame — and the arc swings north at
+both ends, so the disc stands in frame through dawn and dusk, half-set on
+the horizon line. The moon walks the northern sky all night, due north at
+mid-night. Both are cell art on the sky's own grid, sized by the frame, and
+scissored to the sky's region: a setting body slips below the horizon point
+and is gone — never under the map.
+
+The **sky, the shadows and the light follow**. Six-band phase palettes
+blend along the dial and re-quantise onto the 5-bit lattice — the evening
+bending through a golden-hour waypoint and both edges of the night through
+a violet civil twilight, because a straight lerp between complements
+bottoms out in grey — and a dithered, posterised glow warms the bands
+around the low sun through the twilights.
+The shadow shear comes off the clock (opposite the body's bearing,
+cotangent-long, clamped at twice the caster's height), fading out over the
+last twelve degrees before the horizon so sunset hands off to moonrise
+through a soft shadowless gap; moonlight presses at about two-thirds the
+sun's weight. The scene shader multiplies every surface by the hour's tint —
+neutral at noon, warm at the twilights, dim blue under the moon.
+
+**Outdoors only.** Indoors keeps the noon rig, the neutral tint and no sky:
+a cave at midnight is exactly as dark as a cave at noon. A battle staged on
+an outdoor map fights under the hour — night void behind the arena, tinted
+mons, sunset taking the arena's shadows with it — and an indoor arena is
+untouched. The engine's `world.tod` hook is answered
+(`MORNING`/`DAY`/`EVENING`/`NIGHT`), so palette or music packs keyed to the
+period ride this clock for free. In `CYCLE`, the time is written into the
+mod's own bucket in the save file when the game saves, and read back when
+the save is opened; a save with no clock in it starts at day.
While a battle can be staged on the map — **3D-BTL** on, or **VOXEL** on
`FULL`, which owns that row — the engine's **BATTLE LAYOUT** row is set to
diff --git a/lib/BattleScene.lua b/lib/BattleScene.lua
index 50d8f3e..5acc21b 100644
--- a/lib/BattleScene.lua
+++ b/lib/BattleScene.lua
@@ -41,7 +41,9 @@ local VoxelScene = V.require("VoxelScene")
local BattleCam = V.require("BattleCam")
local BattleBillboard = V.require("BattleBillboard")
local VoxelGrid = V.require("VoxelGrid")
+local DayNight = V.require("DayNight")
local PaletteFX = require("src.render.PaletteFX")
+local Map = require("src.world.Map")
local BattleScene = {}
@@ -215,7 +217,11 @@ BattleScene.monCards = monCards
local function shadowSignature(state, arena, terrain, nbMesh, token)
local host = arena.map or state.map
local parts = { "battle", host.id, arena.x, arena.y, arena.shape,
- tostring(terrain), tostring(token or 0) }
+ tostring(terrain), tostring(token or 0),
+ -- the cycle keeps running through a fight, and an arena lit
+ -- from somewhere new must be re-cast from there
+ math.floor(ShadowMap.KX * 128),
+ math.floor(ShadowMap.KZ * 128) }
for i = 1, #nbMesh do parts[#parts + 1] = tostring(nbMesh[i]) end
return table.concat(parts, ",")
end
@@ -298,6 +304,13 @@ function BattleScene.render(state, arena, textures, token)
local host = arena.map or state.map
local neighbors = (host == state.map) and (state.neighbors or {}) or {}
+ -- the hour's light reaches the arena exactly as it reaches free-roam: the
+ -- shared rig follows the clock on an outdoor floor and stays at noon on an
+ -- indoor one, and the same tint multiplies the staged shot
+ local outdoor = host.def and Map.isOutdoor(host.def) or false
+ DayNight.applyRig(outdoor)
+ Voxel3D.tint = DayNight.tint(outdoor)
+
-- shares the free-roam mode's request/evict bookkeeping, so a battle warms
-- exactly the meshes walking around would have and nothing extra
local terrain, nbMesh = prefetchArena(state, host)
@@ -342,9 +355,12 @@ function BattleScene.render(state, arena, textures, token)
Voxel3D.camera = cam
-- the sun is turned up for the arena and put back afterwards, so the
- -- free-roam world it shares this module with keeps its own weight
+ -- free-roam world it shares this module with keeps its own weight -- and
+ -- the hour still has the last word: a sunset fades the arena's shadows
+ -- out and the moon presses more softly, exactly as it does outside
local sunWas = Voxel3D.SHADOW_ALPHA
Voxel3D.SHADOW_ALPHA = BattleScene.SHADOW_ALPHA
+ * DayNight.shadowScale(outdoor)
-- and the wireframe is ON for a battle whatever the V-GRID row says. The
-- arena is a staged shot rather than the world being walked through, and
-- the seams are what make it read as built rather than photographed. Forced
diff --git a/lib/DayNight.lua b/lib/DayNight.lua
new file mode 100644
index 0000000..b327eaa
--- /dev/null
+++ b/lib/DayNight.lua
@@ -0,0 +1,437 @@
+-- Voxel world mode: the day/night cycle -- one clock, and everything the
+-- frame asks it.
+--
+-- THE CLOCK is twenty minutes around: ten of day, ten of night. The DAYTIME
+-- row either PINS it -- DAY, NIGHT, DUSK and DAWN are fixed times on that
+-- dial, not separate looks -- or lets it run (CYCLE), in which case the pin
+-- the player left is where the cycle picks up. Everything below is a pure
+-- function of the clock, so the pinned settings and the running cycle can
+-- never drift apart: DUSK is simply the cycle stopped at sunset.
+--
+-- THE SUN's noon is this mod's existing sun, exactly: shear (-0.85, -0.55),
+-- hanging in the southeast about 45 degrees up. That is the DAY setting and
+-- the default, so a player who never touches the row sees the mod they
+-- already had. From there the arc swings NORTH at both ends -- rising 70
+-- degrees north of east, setting the mirror of that -- because the camera
+-- looks north and the northern sky is the only sky it ever frames: a sun
+-- that rose due east would light the world for ten minutes without once
+-- being seen. Swung north, the disc stands in frame through dawn and dusk
+-- (the hours worth looking at) and passes overhead-behind-the-camera
+-- through midday, which is where a noon sun belongs.
+--
+-- THE MOON arcs entirely through the northern sky -- rising northeast, due
+-- north at mid-night, setting northwest -- so it hangs over the diorama all
+-- night and the pinned NIGHT setting puts it dead centre. Its shadows fall
+-- softly south, away from it, at about two-thirds the sun's weight.
+--
+-- SHADOWS are the shear the light throws: direction opposite the body's
+-- bearing, length its elevation's cotangent (clamped -- a rising sun throws
+-- a long shadow, not an infinite one), strength fading to nothing over the
+-- last twelve degrees before the horizon so the handoff between sun and
+-- moon is a soft gap rather than a snap. Face shading (Voxel3D.FACE_SHADE)
+-- deliberately stays the noon bake: it is a subtle angle term baked into
+-- every mesh, and rebaking the world's geometry per phase buys less than
+-- the cast shadows, the sky and the tint already say.
+--
+-- OUTDOOR ONLY. Indoors keeps the noon rig, the untinted world and no sky:
+-- a cave at midnight is exactly as dark as a cave at noon, which is what a
+-- room with no windows looks like. Map.isOutdoor is the same test the sky
+-- already rests on; the caller passes its answer in (applyRig/tint).
+--
+-- Persistence: the running cycle's clock is written into the mod's own
+-- save-file bucket (save.modData.DRAMATIC_SHAPE, via mod.save) on the
+-- engine's save.writing event, and read back on save.loaded/created. A save
+-- with no clock in it starts at noon.
+
+-- the mod namespace (see main.lua): V.require loads a sibling module
+local V = ...
+
+local ModSetting = V.require("ModSetting")
+local PaletteFX = require("src.render.PaletteFX")
+
+local DayNight = {}
+
+-- ------- the dial
+
+DayNight.CYCLE = 1200 -- seconds around the whole dial
+DayNight.DAY_LEN = 600 -- the sun's half; the moon has the rest
+DayNight.BLEND = 75 -- seconds of palette blend either side of a twilight
+
+-- where the pinned settings stop the clock
+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)
+DayNight.setting = ModSetting.new(DayNight.KEY, DayNight.LABEL,
+ { "day", "night", "dusk", "dawn", "cycle" },
+ { "DAY", "NIGHT", "DUSK", "DAWN", "CYCLE" })
+
+DayNight.clock = DayNight.T.day -- the running cycle's own position
+
+-- ------- the two arcs
+--
+-- Bearings in DEGREES from east toward south (the world's +X is east, +Z
+-- south), elevations in degrees up from the ground plane.
+
+-- noon IS the existing sun: shear (-0.85, -0.55) hangs it at
+-- atan2(0.55, 0.85) south of east, atan(1/hypot) = 44.65 degrees up
+local NOON_KX, NOON_KZ = -0.85, -0.55
+local TH_NOON = math.deg(math.atan2(-NOON_KZ, -NOON_KX))
+local EL_NOON = math.deg(math.atan(1 / math.sqrt(NOON_KX * NOON_KX
+ + NOON_KZ * NOON_KZ)))
+
+local TH_RISE, TH_SET = -70, 250 -- north of east / north of west
+local TH_MRISE, TH_MMID, TH_MSET = -20, -90, -160
+local EL_MOON = 40
+
+DayNight.K_MAX = 2.0 -- shear clamp: a shadow at most twice its height
+DayNight.ALPHA_SUN = 0.40 -- the existing midday shadow weight
+DayNight.ALPHA_MOON = 0.26 -- moonlight is a softer press
+DayNight.FADE_DEG = 12 -- shadows fade out over the last degrees of a rise/set
+
+-- disc PLACEMENT only: the true elevation would put the noon sun far above
+-- any frame, so the arc the discs ride is squashed toward the horizon. The
+-- shadows always use the true elevation.
+DayNight.ELEV_SQUASH = 0.14
+
+-- three-point arc: a at s=0, b at s=0.5, c at s=1
+local function arc(a, b, c, s)
+ if s < 0.5 then return a + (b - a) * 2 * s end
+ return b + (c - b) * (2 * s - 1)
+end
+
+-- The body lighting the world at clock `t`: bearing and elevation in
+-- degrees, and whether it is the moon. The t == DAY_LEN boundary belongs to
+-- the SUN, so the pinned DUSK setting is the sun half-set in the northwest,
+-- not the moon rising.
+function DayNight.bodyAt(t)
+ t = t % DayNight.CYCLE
+ if t <= DayNight.DAY_LEN then
+ local s = t / DayNight.DAY_LEN
+ return arc(TH_RISE, TH_NOON, TH_SET, s),
+ EL_NOON * math.sin(math.pi * s), false
+ end
+ local s = (t - DayNight.DAY_LEN) / (DayNight.CYCLE - DayNight.DAY_LEN)
+ return arc(TH_MRISE, TH_MMID, TH_MSET, s),
+ EL_MOON * math.sin(math.pi * s), true
+end
+
+-- The shadow shear that body throws: drift per pixel of height, opposite
+-- the bearing, cot(elevation) long, clamped.
+function DayNight.shearAt(t)
+ local th, el, moon = DayNight.bodyAt(t)
+ if el < 0.5 then el = 0.5 end
+ local k = math.min(DayNight.K_MAX, 1 / math.tan(math.rad(el)))
+ return -math.cos(math.rad(th)) * k, -math.sin(math.rad(th)) * k, moon
+end
+
+-- How much shadow the light can press right now, 0..1 of the body's own
+-- weight: full up high, gone at the horizon, so sunset hands off to
+-- moonrise through a soft shadowless gap instead of snapping.
+function DayNight.strengthAt(t)
+ local _, el = DayNight.bodyAt(t)
+ local s = el / DayNight.FADE_DEG
+ if s < 0 then return 0 end
+ return s < 1 and s or 1
+end
+
+-- ------- the palettes
+--
+-- Sky bands, lightest FIRST (the horizon end), exactly the shape Sky.bands
+-- reads. Six bands, not four: twilight is the whole show here, and six rungs
+-- of it is what keeps a sunset reading as a gradient rather than as stripes.
+-- Every channel is a multiple of 8 -- the 5-bit GBC lattice -- including
+-- after blending, which re-quantises onto it.
+-- `golden` and `violet` are not pins -- they are WAYPOINTS the blends pass
+-- through. Day's blue horizon and dusk's gold one are near-complements, and
+-- a straight lerp between complements bottoms out in grey: mid-transition
+-- the whole sky went the colour of dishwater, and gold-to-navy did the same
+-- on the far side of sunset. So the evening bends through a golden hour
+-- (horizon warming, zenith still blue -- late afternoon), and both edges of
+-- the night bend through a violet civil twilight (rose horizon under a
+-- violet sky -- the real colour of that half hour).
+DayNight.PALETTES = {
+ day = { { 184, 216, 248 }, { 144, 192, 248 }, { 104, 160, 240 },
+ { 72, 128, 224 }, { 48, 96, 200 }, { 40, 72, 168 } },
+ golden = { { 248, 216, 144 }, { 232, 184, 136 }, { 176, 152, 168 },
+ { 120, 128, 192 }, { 80, 104, 184 }, { 56, 80, 152 } },
+ dawn = { { 248, 216, 152 }, { 248, 176, 136 }, { 232, 136, 144 },
+ { 176, 104, 168 }, { 112, 80, 168 }, { 64, 64, 136 } },
+ dusk = { { 248, 200, 112 }, { 248, 152, 96 }, { 232, 104, 96 },
+ { 184, 80, 136 }, { 120, 64, 152 }, { 56, 48, 120 } },
+ violet = { { 200, 136, 160 }, { 152, 104, 160 }, { 112, 80, 152 },
+ { 72, 56, 128 }, { 40, 40, 96 }, { 16, 24, 64 } },
+ night = { { 88, 104, 160 }, { 64, 80, 136 }, { 48, 56, 112 },
+ { 32, 40, 88 }, { 16, 24, 64 }, { 8, 8, 40 } },
+}
+
+-- what the world's own colours are multiplied by, per phase (0..255)
+DayNight.TINTS = {
+ day = { 255, 255, 255 },
+ golden = { 255, 232, 208 },
+ dawn = { 255, 216, 192 },
+ dusk = { 255, 192, 168 },
+ violet = { 184, 160, 200 },
+ night = { 120, 136, 192 },
+}
+
+-- the twilight glow around the low sun, and the discs' own four-shade
+-- palettes (lightest first, so a display mode transforms them like any
+-- other palette)
+DayNight.GLOWS = { dawn = { 248, 232, 176 }, dusk = { 248, 224, 168 } }
+DayNight.SUN_COLORS = { { 248, 240, 200 }, { 248, 208, 96 },
+ { 248, 144, 80 }, { 216, 96, 64 } }
+DayNight.MOON_COLORS = { { 240, 244, 248 }, { 224, 232, 240 },
+ { 168, 184, 208 }, { 120, 136, 168 } }
+
+-- The dial as keyframes: a repeated name is a plateau, a change is a
+-- BLEND-wide ramp. Laid out so DUSK and DAWN proper land exactly on their
+-- pinned times, and so the evening approaches dusk THROUGH the golden-hour
+-- waypoint rather than straight across the grey between blue and gold. The
+-- morning side needs no waypoint of its own: dawn's pinks into day's blues
+-- share a family and blend clean.
+local DIAL
+local function dial()
+ if DIAL then return DIAL end
+ local B, D, C = DayNight.BLEND, DayNight.DAY_LEN, DayNight.CYCLE
+ DIAL = {
+ { 0, "dawn" }, { B, "day" },
+ { D - 2 * B, "day" }, { D - B, "golden" }, { D, "dusk" },
+ { D + B / 2, "violet" }, { D + B, "night" },
+ { C - B, "night" }, { C - B / 2, "violet" }, { C, "dawn" },
+ }
+ return DIAL
+end
+
+-- Phase weights at clock `t`, off the dial above.
+function DayNight.mix(t)
+ t = t % DayNight.CYCLE
+ local d = dial()
+ for i = 1, #d - 1 do
+ local a, b = d[i], d[i + 1]
+ if t >= a[1] and t < b[1] then
+ if a[2] == b[2] then return { [a[2]] = 1 } end
+ local u = (t - a[1]) / (b[1] - a[1])
+ return { [a[2]] = 1 - u, [b[2]] = u }
+ end
+ end
+ return { dawn = 1 }
+end
+
+-- back onto the 5-bit lattice after any blend
+local function q8(v)
+ v = math.floor(v / 8 + 0.5) * 8
+ if v < 0 then return 0 end
+ return v > 248 and 248 or v
+end
+
+local function blend3(key, mix, fallback)
+ local r, g, b = 0, 0, 0
+ for name, w in pairs(mix) do
+ local c = key[name] or fallback
+ r = r + c[1] * w
+ g = g + c[2] * w
+ b = b + c[3] * w
+ end
+ return { q8(r), q8(g), q8(b) }
+end
+
+-- The sky palette for clock `t`, blended between the phase palettes and
+-- re-quantised to the lattice. Memoised per whole second: the answer only
+-- moves as the cycle runs, and the cycle moves it slowly.
+local palCache = { key = nil, pal = nil }
+
+function DayNight.palette(t)
+ t = t or DayNight.time()
+ local key = math.floor(t % DayNight.CYCLE)
+ if palCache.key == key then return palCache.pal end
+ local mix = DayNight.mix(t)
+ local pal = {}
+ for i = 1, #DayNight.PALETTES.day do
+ local r, g, b = 0, 0, 0
+ for name, w in pairs(mix) do
+ local c = DayNight.PALETTES[name][i]
+ r = r + c[1] * w
+ g = g + c[2] * w
+ b = b + c[3] * w
+ end
+ pal[i] = { q8(r), q8(g), q8(b) }
+ end
+ palCache.key, palCache.pal = key, pal
+ return pal
+end
+
+-- The world tint for clock `t`, {r, g, b} in 0..1. Neutral indoors -- the
+-- caller answers for where it is standing (see the header).
+local tintCache = { key = nil, tint = nil }
+local NEUTRAL = { 1, 1, 1 }
+
+function DayNight.tint(outdoor, t)
+ if not outdoor then return NEUTRAL end
+ t = t or DayNight.time()
+ local key = math.floor(t % DayNight.CYCLE)
+ if tintCache.key ~= key then
+ -- NOT re-quantised: this is a light level the shader multiplies by, not
+ -- a palette colour, and the lattice's 248 ceiling would make even noon
+ -- fractionally dim
+ local mix = DayNight.mix(t)
+ local r, g, b = 0, 0, 0
+ for name, w in pairs(mix) do
+ local c = DayNight.TINTS[name] or DayNight.TINTS.day
+ r = r + c[1] * w
+ g = g + c[2] * w
+ b = b + c[3] * w
+ end
+ tintCache.key = key
+ tintCache.tint = { r / 255, g / 255, b / 255 }
+ end
+ return tintCache.tint
+end
+
+-- The twilight glow: how strongly (0..1) and in what colour the sky warms
+-- around the low sun. Only the SUN glows -- a moonrise is silver, not gold.
+function DayNight.glow(t)
+ t = t or DayNight.time()
+ local _, _, moon = DayNight.bodyAt(t)
+ if moon then return 0, nil end
+ local mix = DayNight.mix(t)
+ local amt = (mix.dawn or 0) + (mix.dusk or 0)
+ if amt <= 0 then return 0, nil end
+ return amt, blend3(DayNight.GLOWS, mix, DayNight.GLOWS.dusk)
+end
+
+-- ------- the clock itself
+
+local lastMode = nil
+
+local function mode()
+ return DayNight.setting:get() or "day"
+end
+
+-- The effective time: the pin, or the running clock under CYCLE.
+function DayNight.time()
+ local m = mode()
+ if m == "cycle" then return DayNight.clock end
+ return DayNight.T[m] or DayNight.T.day
+end
+
+-- Advance the cycle. Runs every frame from the voxel pipeline's update hook
+-- (which ticks through battles and menus too, so night falls during a long
+-- fight exactly as it does on a walk). Stepping ONTO cycle picks up from
+-- the pin the player was just looking at: DUSK then CYCLE rolls on into
+-- night rather than teleporting the sky.
+function DayNight.update(dt)
+ local m = mode()
+ if m ~= lastMode then
+ if m == "cycle" and DayNight.T[lastMode] then
+ DayNight.clock = DayNight.T[lastMode]
+ end
+ lastMode = m
+ end
+ if m == "cycle" and dt and dt > 0 then
+ DayNight.clock = (DayNight.clock + dt) % DayNight.CYCLE
+ end
+end
+
+-- The clock the RIG runs on: quantised, so the shadow map redraws a few
+-- times a minute as the sun crawls rather than every frame.
+DayNight.STEP = 2
+
+function DayNight.rigTime()
+ local t = DayNight.time()
+ return math.floor(t / DayNight.STEP) * DayNight.STEP
+end
+
+-- ------- what the frame reads
+
+-- Point the shared light rig at the clock -- or at noon, indoors. This
+-- writes the same fields everything already reads (ShadowMap.KX/KZ for the
+-- sun pass and its frustum, Voxel3D.SHADOW_* for the decal fallback and the
+-- sunDark uniform), so no draw path changes to follow the sun; they follow
+-- the rig, and the rig follows the clock.
+function DayNight.applyRig(outdoor)
+ local ShadowMap = V.require("ShadowMap")
+ local Voxel3D = V.require("Voxel3D")
+ local t = outdoor and DayNight.rigTime() or DayNight.T.day
+ local kx, kz, moon = DayNight.shearAt(t)
+ ShadowMap.KX, ShadowMap.KZ = kx, kz
+ Voxel3D.SHADOW_KX, Voxel3D.SHADOW_KZ = kx, kz
+ local base = moon and DayNight.ALPHA_MOON or DayNight.ALPHA_SUN
+ Voxel3D.SHADOW_ALPHA = base * DayNight.strengthAt(t)
+ return t
+end
+
+-- How much of a pass's OWN shadow weight the hour leaves it, 0..1 -- for a
+-- caller that sets its own alpha (the battle arena) and should still lose
+-- its shadows to a sunset.
+function DayNight.shadowScale(outdoor, t)
+ if not outdoor then return 1 end
+ t = t or DayNight.rigTime()
+ local _, _, moon = DayNight.bodyAt(t)
+ local s = DayNight.strengthAt(t)
+ return moon and s * (DayNight.ALPHA_MOON / DayNight.ALPHA_SUN) or s
+end
+
+-- The disc to hang in the sky, or nil when the body is set or behind the
+-- camera's half of the sky. Returns a direction for the PLACEMENT arc --
+-- true bearing, squashed elevation (see ELEV_SQUASH) -- plus which body it
+-- is; the caller projects it through its own camera.
+function DayNight.body(t)
+ t = t or DayNight.time()
+ local th, el, moon = DayNight.bodyAt(t)
+ if el < -2 then return nil end
+ local e = math.rad(el * DayNight.ELEV_SQUASH)
+ local b = math.rad(th)
+ return {
+ dx = math.cos(b) * math.cos(e),
+ dy = math.sin(e),
+ dz = math.sin(b) * math.cos(e),
+ moon = moon,
+ }
+end
+
+-- The period name for the engine's world.tod hook (map.palette ctx.tod,
+-- music.select): the dominant phase, in the vocabulary day/night mods use.
+local TOD = { day = "DAY", golden = "DAY", night = "NIGHT",
+ violet = "NIGHT", dawn = "MORNING", dusk = "EVENING" }
+
+function DayNight.tod(t)
+ local mix = DayNight.mix(t or DayNight.time())
+ local best, bestW = "day", -1
+ for name, w in pairs(mix) do
+ if w > bestW then best, bestW = name, w end
+ end
+ return TOD[best] or "DAY"
+end
+
+-- ------- persistence
+--
+-- The clock rides the SAVE SLOT, not the options file: what time it is in
+-- Kanto is a fact about that journey, like where the player is standing.
+-- mod.save is the loader's per-mod bucket in save.modData, which persists
+-- with the slot on its own -- writing the value is all there is to do.
+
+DayNight.SAVE_KEY = "clock"
+
+function DayNight.store()
+ local saveApi = V.mod and V.mod.save
+ if not (saveApi and saveApi.set) then return end
+ pcall(saveApi.set, saveApi, DayNight.SAVE_KEY, DayNight.clock)
+end
+
+function DayNight.restore()
+ local saveApi = V.mod and V.mod.save
+ local stored = nil
+ if saveApi and saveApi.get then
+ local ok, got = pcall(saveApi.get, saveApi, DayNight.SAVE_KEY)
+ if ok then stored = got end
+ end
+ -- no time set: it is day (the requirement, verbatim)
+ DayNight.clock = type(stored) == "number"
+ and stored % DayNight.CYCLE or DayNight.T.day
+end
+
+return DayNight
diff --git a/lib/Sky.lua b/lib/Sky.lua
index 600a51a..221e386 100644
--- a/lib/Sky.lua
+++ b/lib/Sky.lua
@@ -32,33 +32,33 @@
-- PALETTE ORDER, which is easy to get wrong. Stored LIGHTEST FIRST, because that
-- is shade order: a display mode transforms a four-colour palette by replacing it
-- outright (PaletteFX.effectiveColors hands back GRAYS or CLASSIC), and those are
--- written light to dark. So the sky reads the list backwards -- shade 4 overhead,
--- shade 1 at the horizon -- and GRAY gets four greys the right way up for
+-- written light to dark. So the sky reads the list backwards -- deepest shade
+-- overhead, shade 1 at the horizon -- and GRAY gets greys the right way up for
-- nothing.
+--
+-- WHAT TIME IT IS decides the colours. The palette itself lives in DayNight
+-- (four phase palettes, blended along the clock and re-quantised to the
+-- lattice), and this file paints whatever the clock says: blue at noon, gold
+-- and violet through the twilights -- warmed further around the low sun by a
+-- dithered GLOW -- and deep navy under the moon. The sun and moon themselves
+-- hang here too: cell-art discs on the same grid as the dither, scissored to
+-- the sky's own region so a setting body slips below the horizon point and is
+-- gone, never wandering under the map.
-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...
+local DayNight = V.require("DayNight")
local PaletteFX = require("src.render.PaletteFX")
local unpack = table.unpack or unpack
local Sky = {}
--- Lightest first. Every channel is a multiple of 8, which is where a five-bit
--- GBC channel lands: these are colours the hardware could actually have shown,
--- not blues picked off a 24-bit colour wheel.
-Sky.PALETTE = {
- { 144, 192, 248 }, -- shade 1: pale, at the horizon
- { 104, 160, 240 },
- { 72, 128, 224 },
- { 48, 96, 200 }, -- shade 4: deep, overhead
-}
-
--- The shader carries a fixed-size array, because a GLSL uniform array is a fixed
--- size; four is also what a display mode has to give (it substitutes a palette),
--- so this is the ceiling on the palette above rather than an arbitrary cap.
-Sky.MAX_BANDS = 4
+-- The shader carries a fixed-size array, because a GLSL uniform array is a
+-- fixed size; eight leaves headroom over DayNight's six-band phase palettes
+-- without paying for more.
+Sky.MAX_BANDS = 8
-- The checkerboard between bands. DITHER_START is how far down a band it begins,
-- as a fraction of that band: lower is a wider blend, and 1 switches it off. 0.6
@@ -87,8 +87,9 @@ Sky.SPAN = 0.23
local cache = { bands = nil, key = {} }
function Sky.bands()
- local shades = PaletteFX.effectiveColors(Sky.PALETTE) or Sky.PALETTE
- local n = math.min(#shades, #Sky.PALETTE, Sky.MAX_BANDS)
+ local pal = DayNight.palette()
+ local shades = PaletteFX.effectiveColors(pal) or pal
+ local n = math.min(#shades, #pal, Sky.MAX_BANDS)
local key, k = cache.key, 0
local same = cache.bands ~= nil and #cache.bands == n
for i = 1, n do
@@ -111,6 +112,14 @@ function Sky.bands()
return bands
end
+-- The hour's haze -- the palest band, in 0..1 -- which is both the sky's
+-- bottom edge and the right flat fill for any outdoor void that wants to
+-- match the clock without painting bands (the battle arena's backdrop).
+function Sky.haze()
+ local bands = Sky.bands()
+ return bands and bands[#bands] or nil
+end
+
-- Put the sky onto a flat descriptor: the bands to paint, plus the flat fill
-- replaced by the palest of them. That fill is what the caller CLEARS to, so
-- making it the bottom band's own colour means the haze below the sky and the
@@ -156,6 +165,10 @@ uniform float edge; // the sky's bottom, in canvas pixels
uniform float cell; // the diorama's pixel size, in canvas pixels
uniform float start; // where the checker begins inside a band
uniform float alpha;
+uniform float glowAmt; // twilight warmth around the low sun; 0 = none
+uniform vec2 glowPos; // the sun disc, in canvas pixels
+uniform float glowInvR; // 1 / the glow's reach
+uniform vec3 glowColor;
// Indexed through a loop counter, which every GLSL ES compiler accepts for a
// uniform array; a bare bands[idx] is not portable.
@@ -174,10 +187,22 @@ vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
float base = floor(pos);
int idx = int(base);
vec3 c = bandAt(idx);
+ float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0);
if (idx < count - 1 && (pos - base) > start) {
- float parity = mod(floor(sc.x / cell) + floor(sc.y / cell), 2.0);
if (parity < 0.5) { c = bandAt(idx + 1); }
}
+ // The sunset's warmth, radiating from the disc: posterised to a few rungs
+ // and checker-dithered between them -- the same 8-bit move as the bands,
+ // so the glow reads as painted light rather than as a smooth airbrush --
+ // and measured cell-to-cell, so its rings ride the diorama's own grid.
+ if (glowAmt > 0.0) {
+ vec2 cc = (floor(sc / cell) + 0.5) * cell;
+ float d = length(cc - glowPos) * glowInvR;
+ float g = glowAmt * pow(clamp(1.0 - d, 0.0, 1.0), 2.0);
+ float lvl = floor(g * 4.0);
+ if (g * 4.0 - lvl > 0.5 && parity < 0.5) { lvl += 1.0; }
+ c = mix(c, glowColor, min(lvl / 3.0, 1.0) * 0.65);
+ }
return vec4(c, alpha);
}
]]
@@ -226,16 +251,88 @@ local function paintFlat(w, h, bands, edge, alpha, cell)
end
end
+-- ------- the discs
+--
+-- The sun and moon, as cell art: a circle of whole diorama cells with a
+-- lighter core, a dithered rim, and -- for the moon -- a few fixed crater
+-- cells. Drawn as plain rectangles on the same grid as the sky's own dither,
+-- through the same display-mode transform as every palette here, and
+-- SCISSORED to the sky's region: the horizon point is where a setting body
+-- disappears, so it can never hang under the map at a high pitch.
+--
+-- SIZED BY THE FRAME, not by the world: a celestial body's apparent size is
+-- an angle, so zooming the ground in and out must not swell and shrink the
+-- sun with it. The radius is a fraction of the frame height, converted to
+-- whole cells so the disc still sits on the diorama's grid -- chunky cells
+-- up close, fine ones at survey zoom, the same size body either way.
+Sky.DISC_FRAC = 0.030 -- disc radius, as a fraction of the frame height
+Sky.DISC_MIN = 3 -- but never fewer cells than this across a radius
+
+-- crater centres as fractions of the radius, so they ride any disc size
+local MOON_CRATERS = { { -0.4, -0.2 }, { 0.2, 0.45 }, { 0.5, -0.4 },
+ { -0.15, 0.7 }, { 0.05, 0.05 } }
+
+local function paintDisc(body, edge, cell, w, h)
+ local g = love.graphics
+ if not (body and body.y and g.setScissor) then return end
+ local src = body.moon and DayNight.MOON_COLORS or DayNight.SUN_COLORS
+ local shades = PaletteFX.effectiveColors(src) or src
+ local twilight = (body.glowAmt or 0) > 0.25 and not body.moon
+ local r = math.max(Sky.DISC_MIN,
+ math.floor(h * Sky.DISC_FRAC / cell + 0.5))
+ -- the low sun looms: the classic sunset exaggeration, and it reads
+ if twilight then r = r + math.max(1, math.floor(r * 0.4)) end
+ -- snap the centre to the cell grid, like everything else in this sky
+ local bx = math.floor(body.x / cell) * cell + cell / 2
+ local by = math.floor(body.y / cell) * cell + cell / 2
+ if by - r * cell > edge then return end -- wholly below the horizon point
+ local core = shades[1]
+ local main = shades[twilight and 3 or 2]
+ local sx, sy, sw, sh = g.getScissor()
+ g.setScissor(0, 0, math.ceil(w), math.floor(edge))
+ local craterR = math.max(1, math.floor(r / 5))
+ for dy = -r, r do
+ for dx = -r, r do
+ local d = math.sqrt(dx * dx + dy * dy)
+ if d <= r + 0.1 then
+ local c = d <= r * 0.5 and core or main
+ -- dithered rim: the outer ring keeps only one parity of its cells
+ local keep = d <= r - 0.9 or (dx + dy) % 2 == 0
+ if body.moon then
+ for _, cr in ipairs(MOON_CRATERS) do
+ local cdx = dx - math.floor(cr[1] * r + 0.5)
+ local cdy = dy - math.floor(cr[2] * r + 0.5)
+ if cdx * cdx + cdy * cdy <= craterR * craterR then
+ c = shades[3]
+ end
+ end
+ end
+ if keep then
+ g.setColor(c[1] / 255, c[2] / 255, c[3] / 255, 1)
+ g.rectangle("fill", bx + dx * cell - cell / 2,
+ by + dy * cell - cell / 2, cell, cell)
+ end
+ end
+ end
+ end
+ if sx then g.setScissor(sx, sy, sw, sh) else g.setScissor() end
+ g.setColor(1, 1, 1, 1)
+end
+
-- Paint the sky into the bound canvas, filling it from the top edge down to
-- `horizonY` (or to SPAN of the frame when the horizon is out of it).
--
-- `cell` is the diorama's pixel size in canvas pixels -- the pass's own
-- pixels-per-world-pixel, handed in every frame so a zoom lands immediately.
--
+-- `body` is the sun or moon to hang, already projected to canvas pixels by
+-- the caller's own camera (Voxel3D.skyBody), with the twilight glow riding
+-- along; nil hangs nothing and warms nothing.
+--
-- Returns false when there is nothing to paint, in which case the caller's flat
-- fill is the whole sky. That fill is the palest band, so a frame that declines
-- this looks like a hazy day rather than like a bug.
-function Sky.paint(w, h, sky, horizonY, cell)
+function Sky.paint(w, h, sky, horizonY, cell, body)
local bands = sky and sky.bands
if not (bands and bands[1]) then return false end
if not (w and h and w > 0 and h > 0) then return false end
@@ -259,6 +356,7 @@ function Sky.paint(w, h, sky, horizonY, cell)
if g.getBlendMode then blend, blendAlpha = g.getBlendMode() end
if g.setBlendMode then g.setBlendMode("alpha") end
+ local glowAmt = body and not body.moon and (body.glowAmt or 0) or 0
local sh = getShader()
if sh then
local sent = pcall(function()
@@ -271,6 +369,13 @@ function Sky.paint(w, h, sky, horizonY, cell)
sh:send("cell", cell)
sh:send("start", Sky.DITHER and Sky.DITHER_START or 2)
sh:send("alpha", alpha)
+ sh:send("glowAmt", glowAmt)
+ if glowAmt > 0 then
+ local gc = body.glowColor or { 248, 224, 168 }
+ sh:send("glowPos", { body.x, body.y })
+ sh:send("glowInvR", 1 / math.max(1, w * 0.55))
+ sh:send("glowColor", { gc[1] / 255, gc[2] / 255, gc[3] / 255 })
+ end
end)
if sent then
g.setShader(sh)
@@ -282,6 +387,9 @@ function Sky.paint(w, h, sky, horizonY, cell)
end
end
if not sh then paintFlat(w, h, bands, edge, alpha, cell) end
+ -- the disc goes over the glow, under nothing: plain rectangles, so it is
+ -- there whether or not the shader built
+ paintDisc(body, math.min(h, edge), cell, w, h)
g.setColor(1, 1, 1, 1)
if g.setBlendMode and blend then g.setBlendMode(blend, blendAlpha) end
diff --git a/lib/Voxel3D.lua b/lib/Voxel3D.lua
index 61951ea..3ec50e4 100644
--- a/lib/Voxel3D.lua
+++ b/lib/Voxel3D.lua
@@ -31,6 +31,7 @@ local ShadowMap = V.require("ShadowMap")
local VoxelGrid = V.require("VoxelGrid")
local WorldCurve = V.require("WorldCurve")
local Sky = V.require("Sky")
+local DayNight = V.require("DayNight")
local Voxel3D = {}
@@ -201,6 +202,7 @@ local SHADER = [[
uniform vec3 ghostColor; // the flat silhouette colour
uniform float ghost; // 0 = shade normally, 1 = flatten to it
+ uniform vec3 dayTint; // the hour's light on the world; 1,1,1 = noon
vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {
vec4 p = Texel(tex, tc);
@@ -208,7 +210,9 @@ local SHADER = [[
// blending keeps those texels out of the depth buffer, so a model never
// carves a transparent hole out of whatever stands behind it
if (p.a < 0.5) discard;
- vec3 rgb = p.rgb * vShade * sunlight(vSun);
+ // the hour's tint multiplies like the sun terms do: it is LIGHT, the
+ // same warm or moonlit cast on every surface, not a palette swap
+ vec3 rgb = p.rgb * vShade * sunlight(vSun) * dayTint;
#ifdef VOXEL_GRID
// darken what is there rather than painting a colour, so a seam across
// dark grass and one across a white roof each stay in their own palette
@@ -427,6 +431,43 @@ function Voxel3D.horizonY(h)
return (y / w * 0.5 + 0.5) * h
end
+-- ------- the hour's light
+--
+-- What the scene shader multiplies every surface by (see dayTint in the
+-- shader). Set per pass by whoever knows what map is being drawn --
+-- VoxelScene for free-roam, BattleScene for the arena -- because "is this
+-- outdoors" is the map's question, not this pass's. Neutral until somebody
+-- answers it, so a caller that never does draws exactly what it always drew.
+Voxel3D.tint = { 1, 1, 1 }
+
+-- The sun or moon disc's place on this camera's canvas, or nil when the
+-- body is set, on the southern half of the sky, or behind the camera.
+--
+-- The direction comes from DayNight (true bearing, squashed elevation) and
+-- goes through the SAME matrix the geometry is drawn with, as a point at
+-- infinity -- exactly how horizonY finds the vanishing line. So the disc's
+-- azimuth is honest: it stands over the point on the horizon its shadows
+-- point away from, at every pitch, fov, window shape and zoom.
+--
+-- Must run after beginScene has set Voxel3D.vp for this frame's camera.
+function Voxel3D.skyBody(w, h)
+ local m = Voxel3D.vp
+ local b = m and DayNight.body()
+ if not b then return nil end
+ local x = m[1] * b.dx + m[2] * b.dy + m[3] * b.dz
+ local y = m[5] * b.dx + m[6] * b.dy + m[7] * b.dz
+ local ww = m[13] * b.dx + m[14] * b.dy + m[15] * b.dz
+ if ww <= 1e-6 then return nil end
+ local amt, color = DayNight.glow()
+ return {
+ x = (x / ww * 0.5 + 0.5) * w,
+ y = (y / ww * 0.5 + 0.5) * h,
+ moon = b.moon,
+ glowAmt = amt,
+ glowColor = color,
+ }
+end
+
-- ----------------------------------------------------------------- scene --
-- Begin the 3D pass into a `w` x `h` pixel canvas centred on world
@@ -480,7 +521,10 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
-- w / vw is this frame's pixels per WORLD pixel, which is the size a diorama
-- pixel is on screen: the sky's dither grid is cut to that, so its squares
-- are the same size as the world's own and follow every resize and zoom.
- Sky.paint(w, h, sky, Voxel3D.horizonY(h), w / math.max(1, vw or w))
+ -- The banded sky also hangs the hour's sun or moon (skyBody projects it
+ -- through this very camera); a flat sky has no bands and hangs nothing.
+ Sky.paint(w, h, sky, Voxel3D.horizonY(h), w / math.max(1, vw or w),
+ sky.bands and Voxel3D.skyBody(w, h) or nil)
else
love.graphics.clear(0, 0, 0, 0, true, true)
end
@@ -514,6 +558,8 @@ function Voxel3D.beginScene(w, h, cx, cy, vw, vh, sky, slot)
-- start out flattening everything it drew.
pcall(sh.send, sh, "ghost", 0)
pcall(sh.send, sh, "ghostColor", Voxel3D.GHOST_COLOR)
+ -- the hour's light, as the caller last set it (see Voxel3D.tint)
+ pcall(sh.send, sh, "dayTint", Voxel3D.tint or { 1, 1, 1 })
-- the curved world bends about the camera's focus, so the horizon keeps
-- a fixed distance ahead of the player rather than sitting on the map.
-- A placed camera may decline it outright (Voxel3D.camera.curve = 0).
diff --git a/lib/VoxelScene.lua b/lib/VoxelScene.lua
index 9a1d783..b0178d7 100644
--- a/lib/VoxelScene.lua
+++ b/lib/VoxelScene.lua
@@ -21,6 +21,7 @@ local TileShape = V.require("TileShape")
local TerrainAtlas = V.require("TerrainAtlas")
local Voxel = V.require("VoxelState")
local Sky = V.require("Sky")
+local DayNight = V.require("DayNight")
local PaletteFX = require("src.render.PaletteFX")
local Map = require("src.world.Map")
@@ -121,7 +122,14 @@ end
function VoxelScene.skyColor(map, t)
if not (map and map.def and Map.isOutdoor(map.def)) then return nil end
if not t or t <= 0 then return nil end
- return VoxelScene.skyShade(SKY_SHADE, t)
+ local sky = VoxelScene.skyShade(SKY_SHADE, t)
+ -- outdoors the flat fill follows the CLOCK: it becomes the hour's haze --
+ -- gold at dusk, navy at night -- so a battle staged on the map at
+ -- midnight is under a midnight void, not a noon one. Free-roam is
+ -- unchanged by this: Sky.dress overwrites the fill with the same value.
+ local haze = Sky.haze()
+ if haze then sky[1], sky[2], sky[3] = haze[1], haze[2], haze[3] end
+ return sky
end
-- The free-roam sky: the flat one above, dressed with the banded gradient
@@ -470,6 +478,12 @@ local function shadowSignature(terrain, nbMesh, posed, cx, cy, vw, vh)
-- standing perfectly still
put(vw); put(vh)
put(math.floor((V.require("VoxelState").angle or 0) * 512))
+ -- the sun itself: the cycle swings the shear as the clock runs, and a map
+ -- lit from somewhere new must be redrawn from there too. Quantised by the
+ -- rig's own step (DayNight.rigTime), so a running cycle redraws the map a
+ -- few times a minute rather than every frame.
+ put(math.floor(ShadowMap.KX * 128))
+ put(math.floor(ShadowMap.KZ * 128))
put(tostring(terrain))
for i = 1, #nbMesh do put(tostring(nbMesh[i])) end
for _, p in ipairs(posed) do
@@ -547,6 +561,14 @@ function VoxelScene.render(state, w, h, vw, vh, paletteFor)
local cam = state.camera
local cx, cy = cam.x + vw / 2, cam.y + vh / 2
+ -- the hour's light, before anything is cast or drawn: point the shared
+ -- rig at the clock (or at noon, indoors -- a cave at midnight is exactly
+ -- as dark as a cave at noon) and set the tint the scene shader multiplies
+ -- every surface by
+ local outdoor = state.map.def and Map.isOutdoor(state.map.def) or false
+ DayNight.applyRig(outdoor)
+ Voxel3D.tint = DayNight.tint(outdoor)
+
local function atlasFor(map)
return TerrainAtlas.forMap(map, modeColors(paletteFor, map))
end
diff --git a/main.lua b/main.lua
index 274aec2..d48149e 100644
--- a/main.lua
+++ b/main.lua
@@ -79,6 +79,7 @@ local VoxelGrid = V.require("VoxelGrid")
local WorldCurve = V.require("WorldCurve")
local OverworldBattle = V.require("OverworldBattle")
local BattleExit = V.require("BattleExit")
+local DayNight = V.require("DayNight")
-- Forward declaration: the voxel pipeline's update hook (registered below)
-- calls this, and it is defined further down with the settings it drives.
@@ -158,6 +159,11 @@ mod.content.render_pipelines:register("voxel", {
-- would fight anyone who changed one deliberately.
applyFull(level)
Voxel.update(dt, level)
+ -- the day/night clock, on the same always-running tick: Pipelines.update
+ -- runs whatever the level, so time passes with the mode off, through
+ -- battles and menus, and a CYCLE evening falls mid-fight exactly as it
+ -- would mid-walk
+ DayNight.update(dt)
-- The overworld battle rides this hook rather than owning a pipeline of
-- its own, because it owns no pass of the FRAME: it draws under a battle
-- screen the engine composites, which is not a stage the registry has.
@@ -287,6 +293,10 @@ applyFull = function(level)
-- is solved against (OverworldBattle.forceOG); FULL has just switched staged
-- fights on, so the layout follows them.
OverworldBattle.forceOG(Game)
+ -- FULL is the whole diorama, and a diorama with a running sky is more of
+ -- one: the clock is set going (CYCLE is the ladder's last rung). Set, not
+ -- held, like everything else here -- the player can pin it back afterwards.
+ DayNight.setting:setIndex(#DayNight.setting.values, Game)
if Game.writeOptions then pcall(Game.writeOptions, Game) end
end
@@ -309,6 +319,10 @@ local SETTINGS = {
{ OverworldBattle.setting,
"Fight on the map: the battle draws over the nearest clear ground, "
.. "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." },
}
local schema = {}
@@ -690,6 +704,37 @@ mod.content.transitions:register(BattleExit.ID, {
BattleExit.install()
+-- ------- what time it is
+--
+-- The cycle's clock rides the SAVE SLOT (save.modData, via mod.save): what
+-- time it is in Kanto is a fact about that journey, like where the player is
+-- standing. Written on the engine's save.writing event -- the moment before
+-- the bytes hit disk -- and read back whenever a save is opened or begun. A
+-- save with no clock in it starts at day; that is DayNight.restore's
+-- fallback, and also the DAYTIME row's own default.
+mod.events:on("save.writing", function()
+ DayNight.store()
+end)
+
+mod.events:on("save.loaded", function()
+ DayNight.restore()
+end)
+
+mod.events:on("save.created", function()
+ DayNight.restore()
+end)
+
+-- The engine's own time-of-day seam. OverworldState:timeOfDay() is an
+-- eternal "DAY" until a mod answers here; answering it hands the period to
+-- the map.palette hook (ctx.tod) and music.select, so a palette or music
+-- pack keyed to night works with this mod's clock for free. next() first: a
+-- mod loaded before this one that already moved the time keeps its answer.
+mod.hooks:wrap("world.tod", function(next, tod, ctx)
+ local out = next(tod, ctx)
+ if out ~= tod then return out end
+ return DayNight.tod()
+end)
+
mod.exports.version = "1.1.1"
-- exposed so a companion mod can pin its own tiles' shapes or read the
-- camera without reaching into this mod's file layout
diff --git a/tests/daynight_shots.lua b/tests/daynight_shots.lua
new file mode 100644
index 0000000..90e841e
--- /dev/null
+++ b/tests/daynight_shots.lua
@@ -0,0 +1,97 @@
+-- Driver: screenshot the day/night cycle.
+--
+-- One vantage (Pallet Town, VOXEL 75) through every pinned phase, two
+-- mid-blend moments of the running cycle, a battle staged under the night
+-- sky, and a room at midnight -- which must look exactly like a room at
+-- noon, because indoors the clock does not reach.
+--
+-- SHOT_DIR=.scratchpad/daynightcycle POKEPORT_DRIVER=mods/DramaticShapeVoxelMod/tests/daynight_shots.lua love .
+return function(game)
+ local U = dofile("tests/drivers/util.lua")
+ local DIR = os.getenv("SHOT_DIR") or ".scratchpad/daynightcycle"
+ local Pokemon = require("src.pokemon.Pokemon")
+ local BattleState = require("src.battle.BattleState")
+
+ local exports = game.mods and game.mods.exports
+ local lib = exports and exports.DRAMATIC_SHAPE and exports.DRAMATIC_SHAPE.lib
+ if not lib then
+ U.log("DRAMATIC_SHAPE is not loaded -- enable it and run again")
+ return
+ end
+ local DayNight = lib.require("DayNight")
+
+ local function setTime(value, clock)
+ DayNight.setting:sync(value)
+ if clock then DayNight.clock = clock end
+ DayNight.update(0)
+ end
+
+ U.teleport(game, "PALLET_TOWN", 12, 10, "up")
+ -- straight to the top rung, whatever the persisted options say: pressing
+ -- the key would walk the ladder RELATIVE to wherever a previous run left it
+ local Pipelines = require("src.render.Pipelines")
+ Pipelines.setLevel("voxel", 5)
+ U.wait(150) -- let the camera ease over and the meshes land
+
+ -- ------- the four pins, one vantage
+ for _, phase in ipairs({ "day", "dawn", "dusk", "night" }) do
+ setTime(phase)
+ U.wait(30) -- a couple of rig steps + palette settle
+ U.shot(game, ("%s/10_pin_%s.png"):format(DIR, phase))
+ end
+
+ -- ------- the running cycle, mid-blend
+ setTime("cycle", 560) -- day leaning into dusk
+ U.wait(10)
+ U.shot(game, DIR .. "/20_cycle_day_into_dusk.png")
+ setTime("cycle", 645) -- dusk falling into night
+ U.wait(10)
+ U.shot(game, DIR .. "/21_cycle_dusk_into_night.png")
+
+ -- ------- golden hour on open ground, long shadows
+ U.teleport(game, "ROUTE_1", 8, 12, "up")
+ U.wait(120)
+ setTime("cycle", 555) -- low western sun, shadows long and eastward
+ U.wait(30)
+ U.shot(game, DIR .. "/30_route1_low_sun.png")
+
+ -- ------- a fight staged under the night sky
+ setTime("night")
+ game.save.party = game.save.party or {}
+ if #game.save.party == 0 then
+ game.save.party[1] = Pokemon.new(game.data, "CHARIZARD", 45)
+ end
+ -- any real trainer class: the dataset's ids vary by merge, so take the
+ -- first one in stable order rather than guessing a name
+ local classes = {}
+ for id, rec in pairs(game.data.trainers) do
+ if type(id) == "string" and id:sub(1, 1) ~= "_"
+ and type(rec) == "table" and rec.parties and rec.parties[1] then
+ classes[#classes + 1] = id
+ end
+ end
+ table.sort(classes)
+ local battle = classes[1] and BattleState.newTrainer(game, classes[1], 1)
+ if battle then
+ battle.onFinish = function() end
+ game.overworld:pushBattle(battle)
+ U.wait(70)
+ for _ = 1, 14 do U.tap(game, "a"); U.wait(8) end
+ U.shot(game, DIR .. "/40_night_battle.png")
+ while game.stack:top() and game.stack:top() ~= game.overworld do
+ game.stack:pop()
+ end
+ U.wait(10)
+ end
+
+ -- ------- and a room, where midnight must change nothing
+ setTime("night")
+ if game.data.maps.REDS_HOUSE_1F then
+ U.teleport(game, "REDS_HOUSE_1F", 4, 4, "down")
+ U.wait(90)
+ U.shot(game, DIR .. "/50_indoor_at_night.png")
+ end
+
+ setTime("day")
+ U.log("done -- " .. DIR)
+end
diff --git a/tests/dramatic_shape_test.lua b/tests/dramatic_shape_test.lua
index dd0a5ad..2b59176 100644
--- a/tests/dramatic_shape_test.lua
+++ b/tests/dramatic_shape_test.lua
@@ -293,8 +293,12 @@ end
Pipelines.setLevel("voxel", 2)
local hookedRows = Runtime.call("ui.options.rows", function(_, r) return r end,
{ data = Data }, { { id = "text_speed" } })
-T.eq(#hookedRows, 4, "the options hook added a row per setting")
+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(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")
@@ -1129,21 +1133,25 @@ T.check(skyRGB("gbc_inv")[3] ~= blue[3],
do
local Sky = run.loader.exports.DRAMATIC_SHAPE.lib.require("Sky")
local Voxel3D = run.loader.exports.DRAMATIC_SHAPE.lib.require("Voxel3D")
+local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight")
local function luma(c) return 0.299 * c[1] + 0.587 * c[2] + 0.114 * c[3] end
--- the palette is the band list: add a colour and the sky gains a band
-for i, c in ipairs(Sky.PALETTE) do
+-- the palette is the band list, and it belongs to the CLOCK now: DayNight
+-- owns one per phase and blends between them. The row defaults to DAY, so
+-- what the sky paints here is the day palette -- still every inch a GBC one.
+local dayPal = DayNight.PALETTES.day
+for i, c in ipairs(dayPal) do
T.check(c[1] % 8 == 0 and c[2] % 8 == 0 and c[3] % 8 == 0,
"palette entry " .. i .. " is a colour a Game Boy Color could show -- five "
.. "bits a channel, so every one is a multiple of 8")
T.check(c[3] > c[1], "and it is blue: more blue than red")
end
-T.eq(#Sky.PALETTE, 4, "four of them, which is one GBC background palette")
+T.eq(#dayPal, 6, "six of them: twilight needs the rungs, and day matches")
Voxel.angle = TOP
local skyGrad = skyRGB("gbc")
-T.eq(#(skyGrad.bands or {}), #Sky.PALETTE,
+T.eq(#(skyGrad.bands or {}), #dayPal,
"the sky arrives with one band per palette entry")
for i, band in ipairs(skyGrad.bands) do
@@ -1155,7 +1163,7 @@ for i, band in ipairs(skyGrad.bands) do
end
-- read backwards out of the palette, which is stored in shade order (lightest
-- first) so a display mode's own four colours drop straight in
-local deepest = Sky.PALETTE[#Sky.PALETTE]
+local deepest = dayPal[#dayPal]
T.check(math.abs(skyGrad.bands[1][1] - deepest[1] / 255) < 1e-9,
"the top band is the palette's deep rung, unmixed")
@@ -1174,8 +1182,10 @@ T.eq(skyGrad[4], 1, "and the tween strength still rides on the descriptor")
-- frame, so there would be no gradient to see from down there anyway.
local flat = VoxelScene.skyColor(outside, 1)
T.eq(flat.bands, nil, "a battle's arena sky is the flat fill, not the gradient")
-T.check(luma(flat) < luma(palest),
- "and it is the ramp's own sky rung, unchanged: not the gradient's pale end")
+T.check(math.abs(flat[1] - palest[1]) < 1e-9
+ and math.abs(flat[3] - palest[3]) < 1e-9,
+ "and it is the hour's haze, so a staged fight stands under the same sky "
+ .. "free-roam does -- navy at midnight, gold at dusk")
-- the same call, the same numbers, and the same TABLE: the bands are memoised
-- per display mode, so a frame that paints the sky allocates nothing to do it
@@ -1902,6 +1912,200 @@ T.eq(Exit.veil(), nil, "and a fade popped from under itself veils nothing")
Exit.modeOn = realModeOn
end
+-- ------- the day/night cycle
+--
+-- One twenty-minute clock, and everything is a pure function of it: the
+-- pinned DAYTIME settings are fixed times on the dial, CYCLE lets it run,
+-- and the sun, the moon, the shadows, the sky and the tint all read the same
+-- number. What is checked here is the dial itself, the noon-exactness pledge
+-- (DAY is the mod's existing sun, to the digit), the arcs' visibility (the
+-- camera looks north, so the discs must actually cross the northern sky),
+-- and the clock's ride through the save file.
+do
+local DayNight = run.loader.exports.DRAMATIC_SHAPE.lib.require("DayNight")
+local ShadowMap = run.loader.exports.DRAMATIC_SHAPE.lib.require("ShadowMap")
+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.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
+ DayNight.setting:sync(name)
+ T.eq(DayNight.time(), t, name .. " pins the clock to " .. t)
+end
+
+-- CYCLE picks up from the pin the player was just looking at
+DayNight.setting:sync("dusk")
+DayNight.update(0)
+DayNight.setting:sync("cycle")
+DayNight.update(0)
+T.eq(DayNight.clock, 600, "stepping onto CYCLE picks up from the pin: dusk")
+DayNight.update(30)
+T.check(math.abs(DayNight.time() - 630) < 1e-9, "and the clock then runs")
+DayNight.clock = 1195
+DayNight.update(10)
+T.check(math.abs(DayNight.clock - 5) < 1e-9,
+ "the dial wraps at twenty minutes, back into dawn")
+
+-- the sun: noon is the mod's existing sun, exactly
+local kx, kz, moon = DayNight.shearAt(300)
+T.check(not moon, "noon is the sun's")
+T.check(math.abs(kx - (-0.85)) < 1e-9 and math.abs(kz - (-0.55)) < 1e-9,
+ ("DAY throws the shadows the mod always threw: (%.4f, %.4f)"):format(kx, kz))
+local kx0, kz0 = DayNight.shearAt(0)
+T.check(math.abs(math.sqrt(kx0 * kx0 + kz0 * kz0) - DayNight.K_MAX) < 1e-9,
+ "a rising sun throws a LONG shadow, clamped -- never an infinite one")
+T.eq(DayNight.strengthAt(0), 0, "and at the horizon it presses nothing")
+T.eq(DayNight.strengthAt(300), 1, "at noon it presses in full")
+
+-- the moon: due north at mid-night, pressing softly south
+local mkx, mkz, mmoon = DayNight.shearAt(900)
+T.check(mmoon, "mid-night is the moon's")
+T.check(math.abs(mkx) < 1e-9, "due north: no east-west drift at all")
+T.check(math.abs(mkz - 1 / math.tan(math.rad(40))) < 1e-9,
+ "shadows fall south, away from it, cot(40) long")
+
+-- the palettes: pins land on their phase palette unmixed, blends stay on
+-- the 5-bit lattice
+local function palEq(a, b)
+ for i = 1, #a do
+ for ch = 1, 3 do if a[i][ch] ~= b[i][ch] then return false end end
+ end
+ return #a == #b
+end
+T.check(palEq(DayNight.palette(300), DayNight.PALETTES.day),
+ "noon paints the day palette, unmixed")
+T.check(palEq(DayNight.palette(600), DayNight.PALETTES.dusk),
+ "the dusk pin paints dusk proper -- the blend is centred on it, not over it")
+T.check(palEq(DayNight.palette(0), DayNight.PALETTES.dawn),
+ "and dawn's pin paints dawn")
+local mid = DayNight.palette(562) -- halfway through day -> dusk
+for i, c in ipairs(mid) do
+ T.check(c[1] % 8 == 0 and c[2] % 8 == 0 and c[3] % 8 == 0,
+ "blended band " .. i .. " is re-quantised onto the GBC lattice")
+end
+T.check(mid[1][3] < DayNight.PALETTES.day[1][3]
+ and mid[1][3] > DayNight.PALETTES.dusk[1][3],
+ "and sits between the two phases it blends")
+-- day's blue horizon and dusk's gold one are near-complements, and a
+-- straight lerp between complements bottoms out in grey -- so the evening
+-- path bends through the golden-hour waypoint, and halfway down the blend
+-- the horizon band must already be WARM
+T.check(mid[1][1] > mid[1][3],
+ "mid-evening the horizon is gold, not the grey between blue and gold")
+-- and the far side of sunset bends through violet the same way: halfway
+-- from dusk to night the horizon is rose, not the taupe between gold and navy
+local ev = DayNight.palette(645)[1]
+T.check(ev[1] > ev[2] and ev[3] > ev[2],
+ "mid-fall of night the horizon is violet-rose, not grey")
+T.eq(DayNight.palette(300), DayNight.palette(300.4),
+ "the palette is memoised within the second, not rebuilt per frame")
+
+-- the tint: noon is neutral, night is dim and blue, indoors is always noon
+local tn = DayNight.tint(true, 900)
+T.check(tn[1] < 1 and tn[3] > tn[1], "night light is dim and leans blue")
+T.eq(DayNight.tint(true, 300)[1], 1, "noon multiplies by one")
+T.eq(DayNight.tint(false, 900)[1], 1,
+ "a cave at midnight is exactly as dark as a cave at noon: neutral indoors")
+
+-- the twilight glow: gold at the sun's horizons, never for the moon
+local amt, gc = DayNight.glow(600)
+T.check(math.abs(amt - 1) < 1e-9, "dusk glows in full")
+T.check(gc[1] > gc[3], "and warm: more red than blue")
+T.eq((DayNight.glow(300)), 0, "noon does not glow")
+T.eq((DayNight.glow(900)), 0, "and the moon rises silver, not gold")
+
+-- the discs cross the sky the camera can actually see
+Voxel.angle = math.rad(75)
+Voxel3D.camera = nil
+Voxel3D.vp = Voxel3D.viewProjection(0, 0, 320, 288)
+local horizon = Voxel3D.horizonY(288)
+
+DayNight.setting:sync("night")
+local mb = Voxel3D.skyBody(320, 288)
+T.check(mb and mb.moon, "at the NIGHT pin the moon is in frame")
+T.check(math.abs(mb.x - 160) < 8,
+ ("due north is screen centre: got x %.1f"):format(mb.x))
+T.check(mb.y > 0 and mb.y < horizon,
+ ("hanging above the horizon point: y %.1f vs %.1f"):format(mb.y, horizon))
+
+DayNight.setting:sync("day")
+T.eq(Voxel3D.skyBody(320, 288), nil,
+ "the noon sun is overhead behind the camera -- correctly not in frame")
+
+DayNight.setting:sync("dawn")
+local db = Voxel3D.skyBody(320, 288)
+T.check(db and not db.moon, "at the DAWN pin the rising sun is in frame")
+T.check(db.x > 160 and db.x < 320,
+ ("north of east is screen right: got x %.1f"):format(db.x))
+T.check(math.abs(db.y - horizon) < 2,
+ "standing on the horizon point, half-risen")
+T.check(db.glowAmt > 0.9, "and wrapped in the dawn glow")
+
+DayNight.setting:sync("dusk")
+local sb = Voxel3D.skyBody(320, 288)
+T.check(sb and sb.x < 160, "the DUSK sun sets screen LEFT -- north of west")
+
+-- the rig: outdoor follows the clock, indoor is pinned to noon
+DayNight.setting:sync("night")
+DayNight.applyRig(true)
+T.check(math.abs(ShadowMap.KX - mkx) < 1e-9
+ and math.abs(ShadowMap.KZ - mkz) < 1e-9,
+ "outdoors at night the sun pass is lit by the moon")
+T.check(math.abs(Voxel3D.SHADOW_ALPHA - DayNight.ALPHA_MOON) < 1e-9,
+ "at the moon's own softer weight")
+DayNight.applyRig(false)
+T.check(math.abs(ShadowMap.KX - (-0.85)) < 1e-9
+ and math.abs(ShadowMap.KZ - (-0.55)) < 1e-9,
+ "indoors the rig stays the mod's noon sun, whatever the clock says")
+T.check(math.abs(Voxel3D.SHADOW_ALPHA - DayNight.ALPHA_SUN) < 1e-9,
+ "at the weight it always had")
+T.eq(DayNight.shadowScale(false), 1, "an indoor arena keeps its full shadows")
+T.check(math.abs(DayNight.shadowScale(true, 900)
+ - DayNight.ALPHA_MOON / DayNight.ALPHA_SUN) < 1e-9,
+ "an outdoor arena under the moon presses at the moon's ratio")
+T.check(DayNight.shadowScale(true, 600) < 1e-9,
+ "and a sunset takes the arena's shadows with it")
+
+-- the engine's own vocabulary, for map.palette and music.select
+T.eq(DayNight.tod(300), "DAY", "noon is DAY")
+T.eq(DayNight.tod(900), "NIGHT", "mid-night is NIGHT")
+T.eq(DayNight.tod(0), "MORNING", "dawn is MORNING")
+T.eq(DayNight.tod(600), "EVENING", "dusk is EVENING")
+
+-- the clock rides the save slot
+local modApi = run.loader.exports.DRAMATIC_SHAPE.lib.mod
+DayNight.setting:sync("cycle")
+DayNight.clock = 777
+DayNight.store()
+DayNight.clock = 5
+DayNight.restore()
+T.eq(DayNight.clock, 777, "the clock survives the round trip through mod.save")
+modApi.save:set(DayNight.SAVE_KEY, nil)
+DayNight.restore()
+T.eq(DayNight.clock, 300, "a save with no clock in it starts at day")
+
+-- arriving at FULL sets the clock going
+local Game = require("src.core.Game")
+local hadSave = Game.save
+Game.save = { options = {} }
+DayNight.setting:sync("day")
+defs.voxel.update(0, 2) -- any rung that is not FULL
+defs.voxel.update(0, 1) -- and the arrival
+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")
+DayNight.clock = 300
+DayNight.applyRig(false)
+Voxel3D.tint = { 1, 1, 1 }
+Voxel3D.vp = nil
+end
+
Pipelines.reset()
run.release()