DramaticShapeVoxelMod archival mirror · read-only
lib/DayNight.lua

DayNight.lua

lib/DayNight.lua19.5 KB1006443be50e5cbc
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499
-- 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"

-- "sync" first: an unset or unreadable value follows the machine's own
-- clock, per the row's contract (ModSetting values[1] is the default) --
-- and forceSync below reaches for it by the same position.
DayNight.setting = ModSetting.new(DayNight.KEY, DayNight.LABEL,
                                  { "sync", "day", "night", "dusk",
                                    "dawn", "cycle" },
                                  { "SYNC", "DAY", "NIGHT", "DUSK",
                                    "DAWN", "CYCLE" })

-- The one writer for the FULL pin. While VOXEL sits on FULL the DAYTIME
-- row is off the menu with the rest of the rows the preset owns, and the
-- value is held HERE at SYNC -- the diorama preset's sky follows the clock
-- on the wall, whatever was chosen before. Called from every path that can
-- arrive at or act under FULL (main.lua: the preset itself, the rows hook,
-- the manager's options_changed), mirroring OverworldBattle.forceOG.
function DayNight.forceSync(game)
  if DayNight.setting:get() ~= "sync" then
    DayNight.setting:setIndex(1, game)
  end
end

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

-- Where SYNC reads the real clock: local hours, 0..24 with the minutes as
-- fraction. A named seam rather than a bare os.date call, so the suite can
-- hand it a fixed hour.
function DayNight.hours()
  local d = os.date("*t")
  return d.hour + d.min / 60 + d.sec / 3600
end

-- SYNC: the machine's own time of day laid onto the dial. Local noon is
-- the DAY pin, midnight the NIGHT pin, six and eighteen the twilights --
-- an hour of the real day is fifty seconds of dial, and Kanto's evening
-- falls when the player's does.
function DayNight.syncTime()
  return ((DayNight.hours() - 6) * (DayNight.CYCLE / 24)) % DayNight.CYCLE
end

-- The effective time: the pin, the running clock under CYCLE, or the wall
-- clock under SYNC.
function DayNight.time()
  local m = mode()
  if m == "cycle" then return DayNight.clock end
  if m == "sync" then return DayNight.syncTime() 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" then
      -- from a pin, its time; from SYNC, wherever the real sky already was
      DayNight.clock = DayNight.T[lastMode]
                       or (lastMode == "sync" and DayNight.syncTime())
                       or DayNight.clock
    end
    lastMode = m
  end
  if m == "cycle" and dt and dt > 0 then
    DayNight.clock = (DayNight.clock + dt) % DayNight.CYCLE
  end
end

-- The clock the RIG runs on: quantised, so the shadow map redraws a few
-- times a minute as the sun crawls rather than every frame.
DayNight.STEP = 2

function DayNight.rigTime()
  local t = DayNight.time()
  return math.floor(t / DayNight.STEP) * DayNight.STEP
end

-- ------- what the frame reads

-- Point the shared light rig at the clock -- or at noon, indoors. This
-- writes the same fields everything already reads (ShadowMap.KX/KZ for the
-- sun pass and its frustum, Voxel3D.SHADOW_* for the decal fallback and the
-- sunDark uniform), so no draw path changes to follow the sun; they follow
-- the rig, and the rig follows the clock.
function DayNight.applyRig(outdoor)
  local ShadowMap = V.require("ShadowMap")
  local Voxel3D = V.require("Voxel3D")
  local t = outdoor and DayNight.rigTime() or DayNight.T.day
  local kx, kz, moon = DayNight.shearAt(t)
  ShadowMap.KX, ShadowMap.KZ = kx, kz
  Voxel3D.SHADOW_KX, Voxel3D.SHADOW_KZ = kx, kz
  local base = moon and DayNight.ALPHA_MOON or DayNight.ALPHA_SUN
  Voxel3D.SHADOW_ALPHA = base * DayNight.strengthAt(t)
  return t
end

-- How much of a pass's OWN shadow weight the hour leaves it, 0..1 -- for a
-- caller that sets its own alpha (the battle arena) and should still lose
-- its shadows to a sunset.
function DayNight.shadowScale(outdoor, t)
  if not outdoor then return 1 end
  t = t or DayNight.rigTime()
  local _, _, moon = DayNight.bodyAt(t)
  local s = DayNight.strengthAt(t)
  return moon and s * (DayNight.ALPHA_MOON / DayNight.ALPHA_SUN) or s
end

-- The disc to hang in the sky, or nil when the body is set or behind the
-- camera's half of the sky. Returns a direction for the PLACEMENT arc --
-- true bearing, squashed elevation (see ELEV_SQUASH) -- plus which body it
-- is; the caller projects it through its own camera.
function DayNight.body(t)
  t = t or DayNight.time()
  local th, el, moon = DayNight.bodyAt(t)
  if el < -2 then return nil end
  local e = math.rad(el * DayNight.ELEV_SQUASH)
  local b = math.rad(th)
  return {
    dx = math.cos(b) * math.cos(e),
    dy = math.sin(e),
    dz = math.sin(b) * math.cos(e),
    moon = moon,
  }
end

-- Maps under a CANOPY: not outdoor -- there is no sky to paint and no sun
-- or moon to see, so the shadow rig stays the mod's fixed noon light,
-- which is all that ever filtered through the leaves -- but not a sealed
-- room either: night still FALLS in them. Of everything the clock does,
-- exactly one thing reaches a canopy map: the hour's tint.
DayNight.CANOPY = { VIRIDIAN_FOREST = true }

function DayNight.isCanopy(map)
  return (map and map.id and DayNight.CANOPY[map.id]) and true or false
end

-- How lit the WINDOWS are, 0..1 -- the lamps behind the glass, not the sky.
-- They come on through dusk (a lit window against a sunset is half the point
-- of having either), burn all night, and are mostly out again by dawn:
-- people wake before it is bright, they do not read at sunrise.
local LAMPS = { night = 1, violet = 1, dusk = 0.7, dawn = 0.25 }

function DayNight.windowLight(t)
  local mix = DayNight.mix(t or DayNight.time())
  local lit = 0
  for name, w in pairs(mix) do
    lit = lit + (LAMPS[name] or 0) * w
  end
  return lit
end

-- The period name for the engine's world.tod hook (map.palette ctx.tod,
-- music.select): the dominant phase, in the vocabulary day/night mods use.
local TOD = { day = "DAY", golden = "DAY", night = "NIGHT",
              violet = "NIGHT", dawn = "MORNING", dusk = "EVENING" }

function DayNight.tod(t)
  local mix = DayNight.mix(t or DayNight.time())
  local best, bestW = "day", -1
  for name, w in pairs(mix) do
    if w > bestW then best, bestW = name, w end
  end
  return TOD[best] or "DAY"
end

-- ------- persistence
--
-- The clock rides the SAVE SLOT, not the options file: what time it is in
-- Kanto is a fact about that journey, like where the player is standing.
-- mod.save is the loader's per-mod bucket in save.modData, which persists
-- with the slot on its own -- writing the value is all there is to do.

DayNight.SAVE_KEY = "clock"

function DayNight.store()
  local saveApi = V.mod and V.mod.save
  if not (saveApi and saveApi.set) then return end
  pcall(saveApi.set, saveApi, DayNight.SAVE_KEY, DayNight.clock)
end

function DayNight.restore()
  local saveApi = V.mod and V.mod.save
  local stored = nil
  if saveApi and saveApi.get then
    local ok, got = pcall(saveApi.get, saveApi, DayNight.SAVE_KEY)
    if ok then stored = got end
  end
  -- no time set: it is day (the requirement, verbatim)
  DayNight.clock = type(stored) == "number"
                   and stored % DayNight.CYCLE or DayNight.T.day
end

return DayNight