-- The DIORAMA modes: Kanto as a model you can pick up.
--
-- STANDARD VR presents whatever rung the player is on -- the orbit rungs
-- become a tabletop, 1ST stands you inside the world (see lib/VR.lua).
-- DIORAMA is a different promise, and it is one promise rather than a
-- ladder: the world is ALWAYS the model on the table, seen from outside,
-- and what the headset adds is that the model is a THING IN THE ROOM --
-- grab it, turn it, set it down somewhere else, decide how much of it you
-- want to be holding.
--
-- Two pieces make that read, and this file owns both.
--
--   THE VIEWPORT. Everything outside an invisible BOX centred on the view
--   is simply not drawn -- the Final Fantasy Tactics read, a square slab
--   of the world sitting in the air rather than a map running off to a
--   horizon. A square cut with a HARD edge, because a flat world is a
--   thing with sides and the sides are what say so.
--
--   V-CURVE is what changes its shape. With the bend on, the world is not
--   flat any more -- it is a little globe curling away over its own
--   horizon -- and a square cut through a globe is a lie about what is
--   being looked at. So the box becomes a BALL, and its rim becomes a
--   GRADIENT that dissolves into the sky rather than an edge that
--   guillotines it. One click of the left stick (which throws V-CURVE --
--   see lib/VR) swaps between the two readings of the same model.
--
--   A staged fight ignores both and cuts a vertical PILLAR about the
--   arena, which lifts the fight out of the map as a floating disc.
--
--   (A BASE was built under all this once -- the ground extruded a tile
--   deep, cut to the viewport's shape, wearing Mt Moon's cave floor down
--   its sides -- and it was REMOVED at the user's request. The cut ends at
--   the ground plane now; don't put a plinth back under it.)
--
--   THE GRIP. Squeeze one and the model follows that hand through the
--   room; squeeze both and it turns with them and the viewport resizes
--   to whatever you open your hands to. All of it is arithmetic on the
--   XR-to-world mapping lib/VRRig already had (an anchor, a yaw and a
--   scale), so nothing about the world's own geometry knows this is
--   happening.
--
-- DIORAMA-MR is the same mode with the background keyed pure green, for
-- a mixed-reality capture that composites the model into the room the
-- player is actually standing in.
--
-- Nothing here reaches the flat screen: every field is set by lib/VR for
-- the length of one headset frame and cleared with the session.

-- the mod namespace (see main.lua): V.require loads a sibling module
local V = ...

local Diorama = {}

-- ------- the viewport
--
-- The half-size at rest, as a fraction of the view height the flat screen
-- frames. Sized off the VIEW rather than fixed in world pixels so the zoom
-- rows keep meaning what they mean -- a zoomed-in rung frames less world
-- and gets a smaller model, exactly as it frames a smaller picture.
--
-- The BOX takes half of it, so the square is exactly the view the standard
-- rung would have shown, edge to edge. The BALL takes rather more: a ball
-- inscribed in that square holds noticeably less world (its corners are
-- the four biggest pieces of it), and the point of the V-CURVE throw is to
-- see the same model curl, not to lose a quarter of it.
Diorama.BOX_FRAC = 0.5
Diorama.BALL_FRAC = 0.62

-- How far the grips may open and close it, as a multiplier on that.
Diorama.SCALE_MIN = 0.3
Diorama.SCALE_MAX = 4

-- The rim under V-CURVE, as a fraction of the radius: where the world
-- starts fading and where it has finished. Wide enough to read as a
-- dissolve rather than an edge, narrow enough that the middle of the model
-- is solid. The BOX has no fade at all -- see fadeFor.
Diorama.FADE_FRAC = 0.16

-- The staged fight's disc: the arena's own half-length plus an apron, in
-- world pixels (a map cell is 16). The two mons stand three cells apart,
-- so this is a disc about seven cells across -- the fight, the ground it
-- is fought on, and nothing else.
Diorama.ARENA_APRON = 32

-- ------- what the live frame is
--
-- All three set by lib/VR for the length of one headset frame, and by
-- nothing else. `on` is the whole mode's gate; VoxelScene reads it once
-- per frame and every diorama-shaped thing hangs off that read.
Diorama.on = false
Diorama.keyed = false
Diorama.cull = nil        -- { x, y, z, r, invFade, kind }

-- Chroma green, and PURE green deliberately: a keyer wants the one colour
-- nothing in the picture can accidentally be, and no palette this mod can
-- paint the world in reaches 0,255,0.
Diorama.KEY_COLOR = { 0, 1, 0 }

-- ------- what the grips have done to it
--
-- Kept across frames (this is where the model IS, as far as the player is
-- concerned) and cleared only when the session ends. `offset` is in LOCAL
-- metres and rides the mapping's anchor, `yaw` turns the mapping, `zoom`
-- multiplies the viewport's radius.
Diorama.offset = { 0, 0, 0 }
Diorama.yaw = 0
Diorama.zoom = 1

function Diorama.reset()
  Diorama.on, Diorama.keyed, Diorama.cull = false, false, nil
  Diorama.offset = { 0, 0, 0 }
  Diorama.yaw, Diorama.zoom = 0, 1
  Diorama.release()
end

-- ------- which way a staged fight lies on the table
--
-- The disc's bearing while a battle is up: the player's own hand-turn, with
-- the ARENA's quarter turn taken back out of it.
--
-- An arena may be laid down any of the four ways (BattleArena's `turn`), and
-- the promise that field makes everywhere else is that turning it changes the
-- GROUND under the fight and never the fight itself -- the two Pokemon land
-- on the same marks, seen the same way round. Every other camera keeps that
-- promise by construction: the flat shot and the standard VR mount are both
-- built from BattleCam's eye, which turns with the arena, so the composition
-- follows it round.
--
-- This one is not built from that eye. It is a disc of map lifted onto the
-- table, and its bearing is the arena's bearing in the WORLD -- so a fight
-- staged on a turned arena arrived on the table lying across the head that
-- was looking at it, while the same fight on an unturned one faced properly.
-- Same fight, same composition everywhere else, sideways here.
--
-- So the turn comes back out. Subtracted, matching the sign the standard
-- mount already lands on: its yaw is atan2 of (eye - focus), and rotating
-- that pair by +turn takes the bearing to (bearing - turn). One rule, two
-- seats.
--
-- The hand-turn stays on top of it, because that is the player moving the
-- model and is theirs to keep.
function Diorama.battleYaw(arena)
  local turn = (arena and arena.turn) or 0
  if turn == 0 then return Diorama.yaw end
  local yaw = Diorama.yaw - math.rad(turn)
  -- kept in (-pi, pi] like the grips leave it, so nothing downstream has to
  -- care which way round it came
  return (yaw + math.pi) % (2 * math.pi) - math.pi
end

-- Open a diorama frame. `mode` is VR.mode()'s answer; anything that is
-- not a diorama mode closes it.
function Diorama.begin(mode)
  Diorama.on = (mode == "diorama" or mode == "diorama-mr")
  Diorama.keyed = Diorama.on and mode == "diorama-mr"
  if not Diorama.on then Diorama.cull = nil end
  return Diorama.on
end

function Diorama.stop()
  Diorama.on, Diorama.keyed, Diorama.cull = false, false, nil
end

-- What the world's background must be cleared to, or nil to leave the sky
-- alone. Only ever a colour in DIORAMA-MR, and only while a frame is open.
function Diorama.keyColor()
  if not (Diorama.on and Diorama.keyed) then return nil end
  return Diorama.KEY_COLOR
end

-- ------- the viewport, as the shaders take it
--
-- `kind` is the shader's own switch: 0 no cut, 1 the box, 2 the ball, 3
-- the fight's pillar. `invFade` is one over the fade band in world pixels,
-- so the rim is a single multiply out there -- and a hard edge is simply a
-- band under a pixel wide, which costs the shader no branch of its own.
Diorama.BOX = 1
Diorama.BALL = 2
Diorama.PILLAR = 3

-- The half-size the viewport stands at right now, for a view `vh` world
-- pixels tall -- the flat framing this rung would have shown -- and for
-- the shape it is currently in.
function Diorama.radius(vh, curved)
  local frac = curved and Diorama.BALL_FRAC or Diorama.BOX_FRAC
  return math.max(24, (vh or 288) * frac * Diorama.zoom)
end

-- Whether the world is BENT right now, which is the whole of what decides
-- the viewport's shape: a square cut suits a flat slab of map, and a
-- curved world rolling away over its own horizon wants a ball with a
-- dissolve. Asked of the row rather than remembered, so the V-CURVE the
-- stick click throws (and the "7" key, and the OPTIONS row) all reach it.
function Diorama.curved()
  local ok, on = pcall(function()
    return V.require("WorldCurve").active()
  end)
  return ok and on or false
end

-- The fade band for a cut of half-size `r`: the curve's dissolve, or a
-- hard edge (band 0) for the box.
function Diorama.fadeFor(r, curved)
  if not curved then return 0 end
  return math.max(1, r * Diorama.FADE_FRAC)
end

local function volume(kind, x, y, z, r, fade)
  return { x = x, y = y, z = z, r = r,
           -- a zero band is a hard edge: half a pixel of ramp, which is
           -- one pixel of antialiasing rather than a stair
           invFade = 1 / math.max(fade or 0, 0.5), kind = kind }
end

-- The viewport this frame, centred on the world point the model is pinned
-- by: the BOX ordinarily, and the BALL while the world is curved.
function Diorama.viewport(cx, cy, vh)
  local curved = Diorama.curved()
  local r = Diorama.radius(vh, curved)
  Diorama.cull = volume(curved and Diorama.BALL or Diorama.BOX,
                        cx, 0, cy, r, Diorama.fadeFor(r, curved))
  return Diorama.cull
end

-- The staged fight's disc: a vertical pillar about the arena's midpoint,
-- wide enough for both mons and their apron. Vertical means UNBOUNDED --
-- a tree standing on the disc keeps all of its height, which is what
-- makes the cut read as the ground having been lifted out rather than as
-- the world having been sliced through at eye level.
function Diorama.pillar(arena)
  if not (arena and arena.mid) then return nil end
  local mx, mz = arena.mid[1], arena.mid[2]
  local r = Diorama.ARENA_APRON
  if arena.player and arena.enemy then
    local dx = arena.player[1] - mx
    local dz = arena.player[2] - mz
    r = r + math.sqrt(dx * dx + dz * dz)
  end
  -- Round whatever the curve is doing -- a fight is a disc, and a square
  -- arena tile floating in the air is not the picture -- and ALWAYS
  -- dissolved at the rim, curve or no curve. The box's hard edge is there
  -- to say "this is a flat slab of map with sides"; a fight is a thing
  -- lifted out of the world and hanging in the air, and a hard edge on it
  -- reads as a cookie cutter rather than as a piece of ground.
  Diorama.cull = volume(Diorama.PILLAR, mx, 0, mz, r,
                        Diorama.fadeFor(r, true))
  return Diorama.cull
end

-- ------- the grips
--
-- One hand carries the model; two turn it and open the viewport. The
-- gesture is measured as a DELTA per frame rather than from where the
-- squeeze started, so letting go and taking hold again never snaps
-- anything -- the model simply stops following and starts again.

Diorama.GRIP = 0.6              -- squeezed past this counts as holding on
Diorama.SPREAD_MIN = 0.08       -- hands closer than this give no scale

local lastOne = nil             -- the carrying hand's position, last frame
local lastMid = nil             -- both hands' midpoint
local lastAngle = nil           -- and the bearing of the line between them
local lastSpread = nil          -- and its length

local function clearGrab()
  lastOne, lastMid, lastAngle, lastSpread = nil, nil, nil, nil
end

Diorama.releaseGrab = clearGrab

-- Advance the grab from this frame's controller state (lib/VRXR's table:
-- gripL/gripR in 0..1, handl/handr as { pos, quat } when tracked).
-- Returns true while the model is being held.
function Diorama.gesture(ctl)
  if not ctl then
    clearGrab()
    return false
  end
  local gl, gr = ctl.gripL or 0, ctl.gripR or 0
  local hl = (gl > Diorama.GRIP) and ctl.handl or nil
  local hr = (gr > Diorama.GRIP) and ctl.handr or nil

  if hl and hr then
    lastOne = nil
    local lp, rp = hl.pos, hr.pos
    local mid = { (lp[1] + rp[1]) / 2, (lp[2] + rp[2]) / 2,
                  (lp[3] + rp[3]) / 2 }
    local dx, dy, dz = rp[1] - lp[1], rp[2] - lp[2], rp[3] - lp[3]
    local spread = math.sqrt(dx * dx + dy * dy + dz * dz)
    -- the bearing of the line between the hands, in the same convention
    -- the mapping's yaw turns through (see VRRig.eyeCamera): atan2 of the
    -- x component over the z one, so a hand-over-hand turn and the model's
    -- turn are the same number
    local angle = math.atan2(dx, dz)
    if lastMid then
      for i = 1, 3 do
        Diorama.offset[i] = Diorama.offset[i] + (mid[i] - lastMid[i])
      end
    end
    if lastAngle then
      local d = (angle - lastAngle + math.pi) % (2 * math.pi) - math.pi
      Diorama.yaw = (Diorama.yaw + d + math.pi) % (2 * math.pi) - math.pi
    end
    if lastSpread and lastSpread > Diorama.SPREAD_MIN
       and spread > Diorama.SPREAD_MIN then
      Diorama.zoom = math.max(Diorama.SCALE_MIN,
                       math.min(Diorama.SCALE_MAX,
                                Diorama.zoom * (spread / lastSpread)))
    end
    lastMid, lastAngle, lastSpread = mid, angle, spread
    return true
  end

  lastMid, lastAngle, lastSpread = nil, nil, nil
  local one = hl or hr
  if one then
    if lastOne then
      for i = 1, 3 do
        Diorama.offset[i] = Diorama.offset[i] + (one.pos[i] - lastOne[i])
      end
    end
    lastOne = { one.pos[1], one.pos[2], one.pos[3] }
    return true
  end
  lastOne = nil
  return false
end

-- Nothing here owns a GPU object any more (the base did, and it is gone --
-- see the header), so this is only the grab's own hand-to-hand state: a
-- window resize or a hot reload should not leave the model following a
-- delta measured against a frame that no longer exists.
function Diorama.release()
  clearGrab()
end

Diorama.invalidate = Diorama.release

return Diorama
