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()