-- 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 -- Maps under a CANOPY: not outdoor -- there is no sky to paint and no sun -- or moon to see, so the shadow rig stays the mod's fixed noon light, -- which is all that ever filtered through the leaves -- but not a sealed -- room either: night still FALLS in them. Of everything the clock does, -- exactly one thing reaches a canopy map: the hour's tint. DayNight.CANOPY = { VIRIDIAN_FOREST = true } function DayNight.isCanopy(map) return (map and map.id and DayNight.CANOPY[map.id]) and true or false end -- How lit the WINDOWS are, 0..1 -- the lamps behind the glass, not the sky. -- They come on through dusk (a lit window against a sunset is half the point -- of having either), burn all night, and are mostly out again by dawn: -- people wake before it is bright, they do not read at sunrise. local LAMPS = { night = 1, violet = 1, dusk = 0.7, dawn = 0.25 } function DayNight.windowLight(t) local mix = DayNight.mix(t or DayNight.time()) local lit = 0 for name, w in pairs(mix) do lit = lit + (LAMPS[name] or 0) * w end return lit 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