--# DayNight
-- Day/night cycle. One clock drives three things:
--   sun rotation           -- elevation and azimuth, so the
--                             shadows swing across the day
--   light and ambient colour -- the part you actually see
--   sky cubemap            -- hard swap, taken at the point
--                             where the light is already
--                             darkest and bluest
--
-- There is no sun disc. Both sky textures have their own
-- painted sun that does not move, so a drawn one would just
-- disagree with it. What sells the cycle is shadow direction
-- and the colour of the light.
--
-- Nothing here is persisted. Time of day is presentation
-- only and deliberately stays out of WorldState: a save that
-- carried a clock would mean migrating every existing world
-- for something that costs nothing to re-derive.

DayNight = {}

-- Seconds of real time for one full cycle at DaySpeed 1.
DayNight.BASE_DAY = 240

-- 0 is midnight, 0.25 sunrise, 0.5 noon, 0.75 sunset.
DayNight.t = 0.30

DayNight.MAX_ELEV = 72
DayNight.SWAP_ELEV = -6

-- The light never comes from below the horizon. Past sunset
-- the sun's own elevation is negative, and a directional
-- light pointing up lights the undersides of everything --
-- ground included, which reads as broken rather than as
-- night. The floor turns it into a low moon instead, and the
-- azimuth keeps turning so the moon still moves.
DayNight.MIN_PITCH = 4

DayNight.sunnySky = nil
DayNight.nightSky = nil
DayNight.isNight = nil
DayNight.light = nil
DayNight.lightOk = false

-- Keyed on sun elevation, not on time, so the same table
-- covers sunrise and sunset without being written twice.
-- Must stay sorted ascending -- sample() walks it in order.
DayNight.KEYS = {
  {e = -90, sun = color(52, 62, 104), amb = color(26, 30, 46)},
  {e = -12, sun = color(52, 62, 104), amb = color(26, 30, 46)},
  {e = -4, sun = color(96, 84, 120), amb = color(48, 50, 72)},
  {e = 0, sun = color(226, 118, 62), amb = color(84, 66, 68)},
  {e = 8, sun = color(255, 168, 92), amb = color(112, 98, 92)},
  {e = 20, sun = color(255, 232, 198), amb = color(127, 127, 127)},
  {e = 90, sun = color(255, 248, 232), amb = color(138, 138, 134)}
}

function DayNight.cubeMap(a)
  local tex = nil
  local ok, err = pcall(function()
    tex = craft.cubeTexture(json.decode(readText(a)))
  end)
  if not ok then
    print("DayNight: sky load failed: "..tostring(err))
  end
  return tex
end

-- The light component, if it can be reached. Guarded because
-- whether scene.sun exposes its light this way is not
-- something this project's source confirms, and a wrong
-- guess here would take the whole cycle down rather than
-- just the tint half of it.
function DayNight.findLight()
  local l = nil
  pcall(function() l = scene.sun:get(craft.light) end)
  return l
end

-- Probes once instead of pcalling a write every frame.
function DayNight.testLight()
  local l = DayNight.light
  if not l then return false end
  return pcall(function() l.color = color(255, 248, 232, 255) end)
end

function DayNight.setup()
  DayNight.sunnySky = DayNight.cubeMap(asset.builtin.Environments.Sunny)
  DayNight.nightSky = DayNight.cubeMap(asset.builtin.Environments.Night)
  scene.sky.active = true
  DayNight.light = DayNight.findLight()
  DayNight.lightOk = DayNight.testLight()
  if not DayNight.lightOk then
    print("DayNight: sun colour unavailable, ambient only")
  end
  DayNight.apply()
end

-- Below the horizon before dawn and after dusk, peaking at
-- MAX_ELEV at noon.
function DayNight.elevation()
  return -DayNight.MAX_ELEV * math.cos(DayNight.t * 2 * math.pi)
end

function DayNight.mix(a, b, f)
  return color(a.r + (b.r - a.r) * f,
  a.g + (b.g - a.g) * f,
  a.b + (b.b - a.b) * f, 255)
end

function DayNight.sample(e)
  local k = DayNight.KEYS
  for i = 2, #k do
    if e <= k[i].e then
      local a, b = k[i - 1], k[i]
      local span = b.e - a.e
      local f = 0
      if span > 0 then f = (e - a.e) / span end
      return DayNight.mix(a.sun, b.sun, f),
      DayNight.mix(a.amb, b.amb, f)
    end
  end
  local last = k[#k]
  return last.sun, last.amb
end

-- Only on a change: assigning envMap every frame would hand
-- the renderer a new texture sixty times a second for no
-- reason.
function DayNight.setSky(night)
  if night == DayNight.isNight then return end
  local tex = night and DayNight.nightSky or DayNight.sunnySky
  if not tex then return end
  DayNight.isNight = night
  pcall(function() scene.sky.material.envMap = tex end)
end

function DayNight.apply()
  local e = DayNight.elevation()
  local sunC, ambC = DayNight.sample(e)
  
  local pitch = math.max(e, DayNight.MIN_PITCH)
  scene.sun.rotation = quat.eulerAngles(pitch,
  125 + DayNight.t * 360, 0)
  
  scene.ambientColor = ambC
  if DayNight.lightOk then DayNight.light.color = sunC end
  
  DayNight.setSky(e < DayNight.SWAP_ELEV)
end

-- Speed 0 freezes the clock where it stands, which is the
-- useful setting for looking at one particular hour.
function DayNight.update(dt)
  local speed = tonumber(DayNightCycling) or 1
  if speed > 0 then
    DayNight.t = (DayNight.t + dt * speed / DayNight.BASE_DAY) % 1
  end
  DayNight.apply()
end
