--# Joystick
-- Fixed direction ring on one side, an action button
-- mirrored to the other. The ring is direction only -- jump
-- moved off the centre press onto its own button, because a
-- control that means "jump" at rest and "walk" a few pixels
-- later fires jump on the way to every walk.
--
-- One button slot, three occupants, chosen by self.mode:
--   walk        -> a single jump button
--   fly / swim  -> an up/down pair
-- Player writes mode every frame; nothing here decides it.
-- Each button holds its own touch id so the stick and a
-- button work at once with two thumbs.
--
-- Flying rise: holding the up button always rises. Beyond
-- that, only a long press on the stick while it stays
-- centered (no drift past the deadzone) counts as rising --
-- see pressStart/pressMoved/isLongPressRising below. Simply
-- touching the stick, or steering with it, no longer lifts
-- the player.

Joystick = class()

Joystick.OUTER_R = 65
Joystick.KNOB_R  = 25
Joystick.RING_W  = 4
Joystick.CENTER_DEADZONE = 12
Joystick.SIDE_MARGIN   = 24
Joystick.BOTTOM_MARGIN = 40
Joystick.CATCH_MULT = 1.4

Joystick.BTN_R = 34
Joystick.BTN_VGAP = 14

-- Seconds the stick must be held centered before a touch on
-- it counts as "rising" in flying mode.
Joystick.LONG_PRESS_TIME = 0.35

Joystick.FLY_GLYPH = "\226\156\147"                  -- checkmark
Joystick.UP_GLYPH = "\240\159\148\188"                -- up button (up triangle icon)
Joystick.DOWN_GLYPH = "\240\159\148\189"              -- down button (down triangle icon)
Joystick.JUMP_GLYPH = "\226\143\186\239\184\143"      -- record button (filled circle)

function Joystick:init()
  self.maxOffset = Joystick.OUTER_R - Joystick.KNOB_R
  self.touchId = nil
  self.offsetX, self.offsetY = 0, 0
  self.flying = false
  self.onDoubleTap = nil
  self.mode = "walk"
  self.upId, self.downId, self.jumpId = nil, nil, nil
  self.rising, self.descending, self.jumping = false, false, false
  -- when the current stick touch began, and whether it has
  -- ever drifted past the deadzone since then
  self.pressStart = nil
  self.pressMoved = false
  self:layout()
end

function Joystick:bottomInset()
  local sa = layout and layout.safeArea
  return (sa and sa.bottom) or 0
end

function Joystick:onLeft()
  local v = 0
  pcall(function()
    v = math.floor(tonumber(readLocalData("JoyLeft", 0)) or 0)
  end)
  return v == 1
end

-- The button mirrors the stick across the screen centre, so
-- the side toggle moves both and they can never collide. Each
-- side's own safe-area inset is added before the cosmetic
-- margin, so in portrait the controls sit right against the
-- safe boundary rather than floating with extra room past it.
function Joystick:layout()
  local sa = layout and layout.safeArea
  local left = self:onLeft()
  
  local stickInset = 0
  if sa then
    stickInset = left and (sa.left or 0) or (sa.right or 0)
  end
  local m = Joystick.SIDE_MARGIN + stickInset + Joystick.OUTER_R
  if left then self.cx = m else self.cx = WIDTH - m end
  self.cy = Joystick.BOTTOM_MARGIN + self:bottomInset() + Joystick.OUTER_R
  
  local r = Joystick.BTN_R
  local btnInset = 0
  if sa then
    btnInset = left and (sa.right or 0) or (sa.left or 0)
  end
  local bx
  if left then
    bx = WIDTH - (Joystick.SIDE_MARGIN + btnInset + r)
  else
    bx = Joystick.SIDE_MARGIN + btnInset + r
  end
  local pairGap = r + Joystick.BTN_VGAP * 0.5
  
  self.upBtn = {x = bx, y = self.cy + pairGap, r = r}
  self.downBtn = {x = bx, y = self.cy - pairGap, r = r}
  self.jumpBtn = {x = bx, y = self.cy, r = r}
  
  self.laidOutW, self.laidOutH = WIDTH, HEIGHT
end

function Joystick:checkOrientation()
  if self.laidOutW ~= WIDTH or self.laidOutH ~= HEIGHT then
    self:layout()
  end
end

function Joystick:usesPair()
  return self.mode == "fly" or self.mode == "swim"
end

function Joystick:magnitude()
  return math.sqrt(self.offsetX * self.offsetX
  + self.offsetY * self.offsetY)
end

function Joystick:ratio()
  if not self.touchId then return 0, 0 end
  if self:magnitude() < Joystick.CENTER_DEADZONE then return 0, 0 end
  return self.offsetX / self.maxOffset, self.offsetY / self.maxOffset
end

function Joystick:isPressingCenter()
  if not self.touchId then return false end
  return self:magnitude() < Joystick.CENTER_DEADZONE
end

-- True only once the stick's current touch has sat inside the
-- deadzone, without ever straying past it, for at least
-- LONG_PRESS_TIME. This is the only thing (besides the up
-- button) that rises in flying mode -- steering the stick, or
-- just tapping it, does not.
function Joystick:isLongPressRising()
  if not self.touchId or self.pressMoved then return false end
  if not self.pressStart then return false end
  return (ElapsedTime - self.pressStart) >= Joystick.LONG_PRESS_TIME
end

local function dist2(ax, ay, bx, by)
  local dx, dy = ax - bx, ay - by
  return dx * dx + dy * dy
end

local function hitCircle(c, x, y)
  return c and dist2(x, y, c.x, c.y) <= c.r * c.r
end

function Joystick:clearHeld(id)
  if self.upId == id then self.upId = nil self.rising = false end
  if self.downId == id then self.downId = nil self.descending = false end
  if self.jumpId == id then self.jumpId = nil self.jumping = false end
end

-- Held state, not edge state: Player reads these every frame,
-- so holding jump bunny-hops and holding up climbs.
function Joystick:touchedButtons(t)
  if t.id == self.upId or t.id == self.downId
  or t.id == self.jumpId then
    if t.state == ENDED or t.state == CANCELLED then
      self:clearHeld(t.id)
    end
    return true
  end
  
  if t.state ~= BEGAN then return false end
  
  if self:usesPair() then
    if hitCircle(self.upBtn, t.x, t.y) then
      self.upId, self.rising = t.id, true
      return true
    end
    if hitCircle(self.downBtn, t.x, t.y) then
      self.downId, self.descending = t.id, true
      return true
    end
    return false
  end
  
  if hitCircle(self.jumpBtn, t.x, t.y) then
    self.jumpId, self.jumping = t.id, true
    return true
  end
  
  return false
end

-- Offset is always measured from the ring's fixed center,
-- never from wherever the touch first landed.
function Joystick:touched(t)
  if self:touchedButtons(t) then return true end
  
  if t.state == BEGAN then
    if self.touchId then return false end
    local catch = Joystick.OUTER_R * Joystick.CATCH_MULT
    if dist2(t.x, t.y, self.cx, self.cy) > catch * catch then
      return false
    end
    
    if t.tapCount == 2 and self.onDoubleTap then
      self.onDoubleTap()
    end
    
    self.touchId = t.id
    self:setOffset(t.x, t.y)
    self.pressStart = ElapsedTime
    self.pressMoved = false
    return true
  end
  
  if t.id ~= self.touchId then return false end
  
  if t.state == MOVING then
    self:setOffset(t.x, t.y)
    if self:magnitude() >= Joystick.CENTER_DEADZONE then
      self.pressMoved = true
    end
    return true
  end
  
  if t.state == ENDED or t.state == CANCELLED then
    self.touchId = nil
    self.offsetX, self.offsetY = 0, 0
    self.pressStart = nil
    self.pressMoved = false
    return true
  end
  
  return false
end

function Joystick:setOffset(tx, ty)
  local dx, dy = tx - self.cx, ty - self.cy
  local len = math.sqrt(dx * dx + dy * dy)
  if len > self.maxOffset then
    local k = self.maxOffset / len
    dx, dy = dx * k, dy * k
  end
  self.offsetX, self.offsetY = dx, dy
end

-- Noticeably see-through in both states, so the world isn't
-- fully hidden under a control the thumb isn't currently on.
function Joystick:drawBtn(b, glyph, on, tintCol)
  pushStyle()
  noStroke()
  if on then
    fill(tintCol.r, tintCol.g, tintCol.b, 175)
  else
    fill(40, 40, 48, 110)
  end
  ellipse(b.x, b.y, b.r * 2)
  noFill()
  stroke(255, 255, 255, 110)
  strokeWidth(2)
  ellipse(b.x, b.y, b.r * 2)
  noStroke()
  fill(255, 255, 255, 220)
  font("HelveticaNeue-Bold")
  fontSize(b.r * 0.9)
  textMode(CENTER)
  textAlign(CENTER)
  textWrapWidth(0)
  text(glyph, b.x, b.y - b.r * 0.06)
  popStyle()
end

function Joystick:draw()
  self:checkOrientation()
  
  pushStyle()
  noFill()
  stroke(255, 255, 255, 120)
  strokeWidth(Joystick.RING_W)
  ellipse(self.cx, self.cy, Joystick.OUTER_R * 2)
  
  noStroke()
  fill(40, 110, 220, 200)
  ellipse(self.cx, self.cy, Joystick.KNOB_R * 1.7)
  
  local kx = self.cx + self.offsetX
  local ky = self.cy + self.offsetY
  if self.flying then
    fill(120, 190, 255, 200)
  else
    fill(235, 235, 235, 180)
  end
  ellipse(kx, ky, Joystick.KNOB_R * 2)
  
  -- Dark ink on the light knob: white on pale blue is
  -- invisible over bright terrain.
  if self.flying then
    fill(20, 40, 70, 255)
    font("HelveticaNeue-Bold")
    fontSize(Joystick.KNOB_R * 1.15)
    textMode(CENTER)
    textAlign(CENTER)
    textWrapWidth(0)
    text(Joystick.FLY_GLYPH, kx, ky - Joystick.KNOB_R * 0.06)
  end
  popStyle()
  
  local blue = color(70, 170, 235)
  local green = color(90, 190, 110)
  
  if self:usesPair() then
    self:drawBtn(self.upBtn, Joystick.UP_GLYPH, self.rising, blue)
    self:drawBtn(self.downBtn, Joystick.DOWN_GLYPH, self.descending, blue)
  else
    self:drawBtn(self.jumpBtn, Joystick.JUMP_GLYPH, self.jumping, green)
  end
end
