-- ---------------------------------------------------------------------------
--  Scripted cameras for the pre-spawn screens.
--
--  One camera object is reused throughout. Everything is driven from a single
--  render thread that eases the live values towards target values, so a change
--  requested by the UI never snaps.
-- ---------------------------------------------------------------------------

Cam = {}

local cam           = nil
local mode          = 'off'      -- 'off' | 'shots' | 'orbit' | 'fly'
local renderThread  = false

-- live and target orbit values
local orbit = {
  angle = 180.0, targetAngle = 180.0,
  radius = 1.6,  targetRadius = 1.6,
  height = 0.65, targetHeight = 0.65,
  focus = nil,                       -- entity to orbit
  pitch = 0.0,  targetPitch = 0.0,
}

local shots = { list = {}, index = 0, startedAt = 0, duration = 22000 }
local fly = { from = nil, to = nil, lookFrom = nil, lookTo = nil, startedAt = 0, duration = 3000, done = nil }

local function ensureCam()
  if cam and DoesCamExist(cam) then return cam end
  cam = CreateCam('DEFAULT_SCRIPTED_CAMERA', true)
  SetCamActive(cam, true)
  RenderScriptCams(true, false, 0, true, true)
  return cam
end

local function lerp(a, b, t) return a + (b - a) * t end

local function vlerp(a, b, t)
  return vector3(lerp(a.x, b.x, t), lerp(a.y, b.y, t), lerp(a.z, b.z, t))
end

-- smoothstep, so fly-throughs start and end at rest
local function ease(t)
  t = math.max(0.0, math.min(1.0, t))
  return t * t * (3.0 - 2.0 * t)
end

local function startRenderThread()
  if renderThread then return end
  renderThread = true

  CreateThread(function()
    while mode ~= 'off' do
      local c = ensureCam()

      if mode == 'shots' then
        local shot = shots.list[shots.index]
        if shot then
          local t = (GetGameTimer() - shots.startedAt) / shots.duration
          if t >= 1.0 then
            shots.index = (shots.index % #shots.list) + 1
            shots.startedAt = GetGameTimer()
            shot = shots.list[shots.index]
            t = 0.0
          end
          -- a slow linear dolly reads as a helicopter, not a spline
          local pos = vlerp(shot.from, shot.to, t)
          SetCamCoord(c, pos.x, pos.y, pos.z)
          PointCamAtCoord(c, shot.look.x, shot.look.y, shot.look.z)
        end

      elseif mode == 'orbit' then
        orbit.angle  = lerp(orbit.angle,  orbit.targetAngle,  0.14)
        orbit.radius = lerp(orbit.radius, orbit.targetRadius, 0.10)
        orbit.height = lerp(orbit.height, orbit.targetHeight, 0.10)
        orbit.pitch  = lerp(orbit.pitch,  orbit.targetPitch,  0.10)

        local ent = orbit.focus
        if ent and DoesEntityExist(ent) then
          local base = GetEntityCoords(ent)
          local rad = math.rad(orbit.angle)
          local px = base.x + math.sin(rad) * orbit.radius
          local py = base.y + math.cos(rad) * orbit.radius
          local pz = base.z + orbit.height
          SetCamCoord(c, px, py, pz)
          PointCamAtCoord(c, base.x, base.y, base.z + orbit.height - orbit.pitch)
        end

      elseif mode == 'fly' then
        local t = ease((GetGameTimer() - fly.startedAt) / fly.duration)
        local pos = vlerp(fly.from, fly.to, t)
        local look = vlerp(fly.lookFrom, fly.lookTo, t)
        SetCamCoord(c, pos.x, pos.y, pos.z)
        PointCamAtCoord(c, look.x, look.y, look.z)
        if t >= 1.0 then
          mode = 'idle'
          if fly.done then
            local fn = fly.done
            fly.done = nil
            fn()
          end
        end
      end

      Wait(0)
    end
    renderThread = false
  end)
end

--- Slow establishing shots over the city, cycled forever.
function Cam.Shots(list)
  shots.list = list
  shots.index = 1
  shots.startedAt = GetGameTimer()
  mode = 'shots'
  ensureCam()
  SetCamFov(cam, 42.0)
  startRenderThread()
end

--- Orbit an entity. `snap` places the camera immediately instead of easing in.
function Cam.Orbit(entity, radius, height, angle, snap)
  orbit.focus = entity
  orbit.targetRadius = radius or orbit.targetRadius
  orbit.targetHeight = height or orbit.targetHeight
  if angle then orbit.targetAngle = angle end
  if snap then
    orbit.radius = orbit.targetRadius
    orbit.height = orbit.targetHeight
    orbit.angle = orbit.targetAngle
  end
  mode = 'orbit'
  ensureCam()
  SetCamFov(cam, 34.0)
  startRenderThread()
end

--- Framing presets used by the creator's section tabs.
function Cam.OrbitFrame(radius, height, pitch)
  orbit.targetRadius = radius
  orbit.targetHeight = height
  orbit.targetPitch = pitch or 0.0
end

function Cam.Nudge(deltaDegrees)
  orbit.targetAngle = orbit.targetAngle + deltaDegrees
end

function Cam.SetAngle(deg)
  orbit.targetAngle = deg
end

function Cam.Angle() return orbit.targetAngle end

--- Fly from wherever we are to a point looking at a target, then call `done`.
function Cam.FlyTo(fromPos, fromLook, toPos, toLook, duration, done)
  fly.from = fromPos
  fly.to = toPos
  fly.lookFrom = fromLook
  fly.lookTo = toLook
  fly.duration = duration or 3000
  fly.startedAt = GetGameTimer()
  fly.done = done
  mode = 'fly'
  ensureCam()
  startRenderThread()
end

function Cam.SetFov(fov)
  if cam and DoesCamExist(cam) then SetCamFov(cam, fov + 0.0) end
end

function Cam.Position()
  if cam and DoesCamExist(cam) then return GetCamCoord(cam) end
  return GetEntityCoords(PlayerPedId())
end

--- Hand control back to the gameplay camera. With a duration the engine
--- interpolates between the two, which hides the cut entirely.
function Cam.Release(duration)
  mode = 'off'
  duration = duration or 0
  RenderScriptCams(false, duration > 0, duration, true, true)
  if cam and DoesCamExist(cam) then
    DestroyCam(cam, true)
  end
  cam = nil
end
