add day night cycle

This commit is contained in:
DramaticShape
2026-07-29 21:42:34 -04:00
parent 6d7a93dbad
commit f841378330
10 changed files with 1117 additions and 38 deletions
+58
View File
@@ -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
+51 -5
View File
@@ -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
+18 -2
View File
@@ -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
+437
View File
@@ -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
+128 -20
View File
@@ -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
+48 -2
View File
@@ -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).
+23 -1
View File
@@ -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
+45
View File
@@ -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
+97
View File
@@ -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
+212 -8
View File
@@ -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()