mirror of
https://github.com/DramaticShape/DramaticShapeVoxelMod.git
synced 2026-08-12 12:00:52 +02:00
add day night cycle
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user