#!/usr/bin/env python3
# ============================================================================
#  GAME KING FIT  —  channel-grid firmware for the Game King
#  KingdomCom Ltd / British Innovation Electrical  ·  RTR-E2 certified
#
#  A Wii-menu-style launcher: grid of channels, glove pointer, launches
#  Python games as titles. Pointer is driven by a Nintendo Joy-Con's gyro
#  (via joycon-python/hidapi) with mouse / touchscreen / gamepad fallback,
#  so the 8BitDo pads and the 7" touch panel still work.
#
#  Optional dep:  pip install joycon-python hidapi
#  Required dep: pip install pygame
#
#  Flags:  --fullscreen   --windowed   --selftest   --skipwarning
# ============================================================================

import os
import sys
import json
import math
import time
import array
import random
import getpass
import datetime
import hashlib
import tempfile
import threading
import subprocess
import urllib.request

SELFTEST = "--selftest" in sys.argv
if SELFTEST:
    os.environ.setdefault("SDL_VIDEODRIVER", "dummy")
    os.environ.setdefault("SDL_AUDIODRIVER", "dummy")

import pygame

# ---------------------------------------------------------------- joy-con lib
HAVE_JOYCON = False
try:
    from pyjoycon import JoyCon, get_R_id, get_L_id  # joycon-python
    HAVE_JOYCON = True
except Exception:
    HAVE_JOYCON = False

# ------------------------------------------------------------ evdev (kernel)
HAVE_EVDEV = False
try:
    import evdev
    from evdev import ecodes
    HAVE_EVDEV = True
except Exception:
    HAVE_EVDEV = False

VERSION = "GK-FIT 0.6.1"

# yaw axis, pitch axis. Which body axis is "up/down" depends on the grip and
# on whether it's a left or right Joy-Con, so this is user-cyclable.
PLAYER_COLORS = [(70, 130, 235), (222, 72, 62), (66, 186, 108), (243, 196, 56)]
PLAYER_MAX = 4

GYRO_MAPS = [("z", "y"), ("z", "x"), ("x", "y"), ("x", "z"),
             ("y", "z"), ("y", "x")]
CODENAME = "Waggle Practice"

# ---------------------------------------------------------------- paths
HERE = os.path.dirname(os.path.abspath(__file__))
GAME_DIRS = [os.path.join(HERE, "games"),
             os.path.expanduser("~/gameking/games")]
MEDIA_ROOTS = ["/media/%s" % getpass.getuser(), "/media", "/mnt"]
CONF_PATH = os.path.expanduser("~/.gameking_fit.json")
LOG_PATH = os.path.expanduser("~/.gameking_fit_board.json")

DEFAULT_CONF = {
    "sens": 1.0,          # pointer sensitivity multiplier
    "invert_x": False,
    "invert_y": False,
    "swap_axes": False,
    "gyro_map": 0,        # which gyro axes drive yaw / pitch
    "cal": None,          # five-point screen calibration, see S_CALIB
    "update_url": "",     # manifest URL, e.g. http://brainpad:8080/gkfit.json
    "auto_update": True,
    "pad_layout": "nintendo",   # 'nintendo' or 'xbox' button indexing
    "clock_24h": True,
    "sound": True,
    "chipset": False,     # dev overlay
}


def load_conf():
    c = dict(DEFAULT_CONF)
    try:
        with open(CONF_PATH) as f:
            c.update(json.load(f))
    except Exception:
        pass
    return c


def save_conf(c):
    try:
        with open(CONF_PATH, "w") as f:
            json.dump(c, f, indent=1)
    except Exception:
        pass


def load_board():
    try:
        with open(LOG_PATH) as f:
            return json.load(f)
    except Exception:
        return []


def log_play(title):
    board = load_board()
    board.insert(0, {"t": time.time(), "title": title})
    try:
        with open(LOG_PATH, "w") as f:
            json.dump(board[:60], f)
    except Exception:
        pass



# ============================================================================
#  SYSTEM UPDATE  —  the console phones home, like the original Game King
#
#  Point conf["update_url"] at a manifest served by anything (brainpad, a
#  static host, a USB-mounted file:// path):
#
#  { "version": "GK-FIT 0.7.0",
#    "notes": "Adds the Bowling patch",
#    "files": [ {"path": "gameking_fit.py",
#                "url": "http://brainpad:8080/gkfit/gameking_fit.py",
#                "sha256": "..."} ] }
#
#  Checks run on a background thread so a dead server never stalls the menu.
#  Nothing is written without the user pressing Install, every file is
#  checksum-verified before it lands, and the old copy is kept as .bak.
# ============================================================================
UPDATE_TIMEOUT = 6.0


def version_tuple(v):
    nums = []
    cur = ""
    for ch in str(v):
        if ch.isdigit():
            cur += ch
        elif cur:
            nums.append(int(cur))
            cur = ""
    if cur:
        nums.append(int(cur))
    return tuple(nums) if nums else (0,)


class Updater:
    def __init__(self, conf, install_dir):
        self.conf = conf
        self.dir = install_dir
        self.manifest = None
        self.state = "idle"      # idle | checking | available | current
                                 # | downloading | done | error
        self.message = ""
        self.progress = 0.0
        self._thread = None

    @property
    def available(self):
        return self.state == "available"

    def check_async(self):
        url = (self.conf.get("update_url") or "").strip()
        if not url or self.state in ("checking", "downloading"):
            return
        self.state = "checking"
        self._thread = threading.Thread(target=self._check, args=(url,),
                                        daemon=True)
        self._thread.start()

    def _fetch(self, url, binary=False):
        with urllib.request.urlopen(url, timeout=UPDATE_TIMEOUT) as r:
            data = r.read()
        return data if binary else data.decode("utf-8", "replace")

    def _check(self, url):
        try:
            man = json.loads(self._fetch(url))
            remote = version_tuple(man.get("version", "0"))
            if remote > version_tuple(VERSION):
                self.manifest = man
                self.message = man.get("notes", "") or "A new version is ready."
                self.state = "available"
            else:
                self.state = "current"
                self.message = "This console is up to date."
        except Exception as e:
            self.state = "error"
            self.message = "Couldn't reach the update server (%s)" % (
                type(e).__name__)

    def install_async(self):
        if self.state != "available" or not self.manifest:
            return
        self.state = "downloading"
        self.progress = 0.0
        self._thread = threading.Thread(target=self._install, daemon=True)
        self._thread.start()

    def _safe_path(self, rel):
        rel = str(rel).replace("\\", "/").lstrip("/")
        if ".." in rel.split("/"):
            raise ValueError("bad path in manifest")
        full = os.path.abspath(os.path.join(self.dir, rel))
        if not full.startswith(os.path.abspath(self.dir) + os.sep):
            raise ValueError("path escapes install dir")
        return full

    def _install(self):
        files = self.manifest.get("files") or []
        staged = []
        try:
            for i, f in enumerate(files):
                dest = self._safe_path(f["path"])
                blob = self._fetch(f["url"], binary=True)
                want = (f.get("sha256") or "").lower()
                if want:
                    got = hashlib.sha256(blob).hexdigest()
                    if got != want:
                        raise ValueError("checksum mismatch on %s" % f["path"])
                fd, tmp = tempfile.mkstemp(dir=self.dir, suffix=".part")
                with os.fdopen(fd, "wb") as fh:
                    fh.write(blob)
                staged.append((tmp, dest))
                self.progress = (i + 1) / max(1, len(files))
            # everything downloaded and verified - now swap them in
            for tmp, dest in staged:
                os.makedirs(os.path.dirname(dest), exist_ok=True)
                if os.path.exists(dest):
                    try:
                        os.replace(dest, dest + ".bak")
                    except Exception:
                        pass
                os.replace(tmp, dest)
                try:
                    os.chmod(dest, 0o755)
                except Exception:
                    pass
            self.state = "done"
            self.message = "Update installed. Restart the console to use it."
        except Exception as e:
            for tmp, _ in staged:
                try:
                    os.unlink(tmp)
                except Exception:
                    pass
            self.state = "error"
            self.message = "Update failed: %s" % e


# ============================================================================
#  AUDIO — little synthesized blips, no numpy required
# ============================================================================
SOUND_OK = False
SND = {}


def _tone(freq=880.0, ms=60, vol=0.35, sweep=1.0, kind="sine",
          rate=22050, attack_ms=2):
    n = int(rate * ms / 1000)
    buf = array.array("h")
    atk = max(1, int(rate * attack_ms / 1000))
    for i in range(n):
        t = i / rate
        f = freq * (sweep ** (i / max(1, n)))
        if kind == "noise":
            v = random.uniform(-1.0, 1.0)
        elif kind == "square":
            v = 1.0 if math.sin(2 * math.pi * f * t) >= 0 else -1.0
        else:
            v = math.sin(2 * math.pi * f * t)
        env = min(1.0, i / atk) * min(1.0, (n - i) / (n * 0.7))
        buf.append(int(v * vol * env * 32767))
    return buf


def _concat(*bufs):
    out = array.array("h")
    for b in bufs:
        out.extend(b)
    return out


def init_audio():
    global SOUND_OK
    try:
        pygame.mixer.pre_init(22050, -16, 1, 512)
        pygame.mixer.init()
        SND["hover"] = pygame.mixer.Sound(buffer=_tone(920, 35, 0.18).tobytes())
        SND["click"] = pygame.mixer.Sound(buffer=_tone(1240, 70, 0.30, sweep=0.72).tobytes())
        SND["back"] = pygame.mixer.Sound(buffer=_tone(560, 80, 0.26, sweep=0.85).tobytes())
        SND["page"] = pygame.mixer.Sound(buffer=_tone(500, 90, 0.14, kind="noise").tobytes())
        SND["deny"] = pygame.mixer.Sound(buffer=_tone(180, 140, 0.30, kind="square", sweep=0.9).tobytes())
        SND["chime"] = pygame.mixer.Sound(buffer=_concat(
            _tone(523, 110, 0.24), _tone(659, 110, 0.24),
            _tone(784, 110, 0.24), _tone(1046, 260, 0.26)).tobytes())
        SOUND_OK = True
    except Exception:
        SOUND_OK = False


def play(name, conf):
    if SOUND_OK and conf.get("sound", True) and name in SND:
        try:
            SND[name].play()
        except Exception:
            pass


# ============================================================================
#  INPUT — Joy-Con gyro pointer, with pad / mouse / touch fallback
# ============================================================================
GYRO_DPS = 0.06103  # raw joy-con gyro units -> degrees/second (approx)

# left joy-con held vertically: remap its buttons onto right-hand names
# A left Joy-Con held like a remote has its d-pad where the face buttons are
# on a right one, so the two answer to the same names:
#   right / A = a      up / X = x      left / Y = y      down / B = b
LEFT_AS_RIGHT = {"right": "a", "up": "x", "left": "y", "down": "b",
                 "l": "r", "zl": "zr"}

EVDEV_BTNS = {}
if HAVE_EVDEV:
    EVDEV_BTNS = {
        ecodes.BTN_EAST: "a", ecodes.BTN_SOUTH: "b",
        ecodes.BTN_NORTH: "x", ecodes.BTN_WEST: "y",
        ecodes.BTN_MODE: "home", ecodes.BTN_START: "plus",
        ecodes.BTN_SELECT: "minus",
        ecodes.BTN_TR: "r", ecodes.BTN_TR2: "zr",
        ecodes.BTN_TL: "r", ecodes.BTN_TL2: "zr",
        ecodes.BTN_THUMBR: "r-stick", ecodes.BTN_THUMBL: "l-stick",
        ecodes.BTN_DPAD_RIGHT: "a", ecodes.BTN_DPAD_UP: "x",
        ecodes.BTN_DPAD_LEFT: "y", ecodes.BTN_DPAD_DOWN: "b",
    }


_CLAIMED = set()          # evdev paths already owned by a pointer


class EvdevJoyCon:
    """Joy-Con via the in-kernel hid_nintendo driver (kernel >= 5.16).
    Gyro/accel come from the '... Joy-Con IMU' evdev node; buttons from any
    Nintendo-named node it can read (joycond's virtual pads included).
    The kernel reports gyro with resolution counts-per-(deg/s) and accel
    with counts-per-g, so we divide by absinfo resolution and get real
    units. Rescans every few seconds, so pairing after launch is fine."""

    backend = "evdev"
    RESCAN = 3.0
    CAL_STILL = 2.5          # deg/s spread allowed during a calibration window

    def __init__(self, player=0):
        self.player = player
        self.uniq = None
        self.imu = None
        self.btns = []
        self.side = None
        self.g = {"x": 0.0, "y": 0.0, "z": 0.0}      # deg/s, latest sample
        self.acc = [0.0, 0.0, 1.0]                    # g
        self.res = {"g": 14247.0, "a": 4096.0}
        self.bias = [0.0, 0.0, 0.0]
        self._cal_acc = [0.0, 0.0, 0.0]
        self._cal_n = 0
        self.calibrating = 0
        self.cal_frames = 70
        self.cal_status = ""
        self._cal_lo = [0.0, 0.0, 0.0]
        self._cal_hi = [0.0, 0.0, 0.0]
        self._cal_tries = 0
        self._hat = {"x": 0, "y": 0}
        self._next_scan = 0.0
        # --- absolute aim state (degrees, relative to the centre pose) ---
        self.yaw = 0.0
        self.pitch = 0.0
        self.free = False         # True while the calibration wizard runs
        self.sm_x = None          # smoothed screen position
        self.sm_y = None
        self._last_ts = None      # timestamp of previous IMU sample
        self._still = 0.0         # seconds the controller has sat still
        self._samples = 0         # IMU samples consumed this frame (debug)
        self._scan()

    @property
    def ok(self):
        return self.imu is not None

    def recenter(self):
        """Zero the aim. Instant, always safe, never touches the bias -
        so pointing straight ahead again costs nothing and can't go wrong."""
        self.yaw = 0.0
        self.pitch = 0.0
        self.sm_x = None
        self.sm_y = None

    def start_calibration(self, frames=70, tries=3):
        """Learn the zero-rate bias. Only accepts a window where the pad was
        actually still - a wobbly sample is thrown away and retried instead
        of being baked in."""
        self._cal_lo = [9e9, 9e9, 9e9]
        self._cal_hi = [-9e9, -9e9, -9e9]
        self._cal_acc = [0.0, 0.0, 0.0]
        self._cal_n = 0
        self._cal_tries = tries
        self.calibrating = frames
        self.cal_frames = frames
        self.cal_status = "working"
        self.recenter()
        self._last_ts = None

    def _open(self, path):
        d = evdev.InputDevice(path)
        try:
            os.set_blocking(d.fd, False)
        except Exception:
            pass
        return d

    def _scan(self):
        """Claim one controller: its IMU node plus the button node(s) that
        share its uniq (the controller MAC). Two passes, so buttons land in
        the same sweep as the IMU rather than three seconds later."""
        self._next_scan = time.time() + self.RESCAN
        try:
            paths = evdev.list_devices()
        except Exception:
            paths = []

        imu_cands, btn_cands = [], []
        have = {d.path for d in self.btns}
        for p in paths:
            if p in have or p in _CLAIMED:
                continue
            try:
                d = self._open(p)
            except Exception:
                continue
            name = d.name or ""
            if "Joy-Con" not in name and "Nintendo" not in name:
                try:
                    d.close()
                except Exception:
                    pass
                continue
            (imu_cands if "IMU" in name else btn_cands).append(d)

        # ---- pass 1: take an IMU if we haven't got one
        if self.imu is None and imu_cands:
            imu_cands.sort(key=lambda d: 0 if "Right" in (d.name or "") else 1)
            self.imu = imu_cands.pop(0)
            _CLAIMED.add(self.imu.path)
            name = self.imu.name or ""
            self.side = "R" if "Right" in name else ("L" if "Left" in name else "?")
            try:
                self.uniq = self.imu.uniq or None
            except Exception:
                self.uniq = None

            def _res(code, dflt):
                try:
                    r = float(self.imu.absinfo(code).resolution)
                    return r if r > 0 else dflt
                except Exception:
                    return dflt
            self.res = {"g": _res(ecodes.ABS_RX, 14247.0),
                        "a": _res(ecodes.ABS_X, 4096.0)}
            self.start_calibration()

        # ---- pass 2: buttons. Same uniq wins; otherwise, if we own an IMU
        # and still have no buttons, take an unclaimed one rather than sit
        # there deaf (joycond's virtual pads report no uniq at all).
        for d in list(btn_cands):
            try:
                du = d.uniq or None
            except Exception:
                du = None
            if self.uniq and du and du == self.uniq:
                _CLAIMED.add(d.path)
                self.btns.append(d)
                btn_cands.remove(d)
        if self.imu is not None and not self.btns:
            for d in list(btn_cands):
                try:
                    du = d.uniq or None
                except Exception:
                    du = None
                if du and self.uniq and du != self.uniq:
                    continue          # definitely someone else's pad
                _CLAIMED.add(d.path)
                self.btns.append(d)
                btn_cands.remove(d)
                break
        for d in imu_cands + btn_cands:
            try:
                d.close()
            except Exception:
                pass

    def _drop(self):
        """Controller went away - give its devices back to the pool."""
        if self.imu is not None:
            _CLAIMED.discard(self.imu.path)
            try:
                self.imu.close()
            except Exception:
                pass
        for d in list(self.btns):
            _CLAIMED.discard(d.path)
            try:
                d.close()
            except Exception:
                pass
        self.btns = []
        self.imu = None
        self.side = None
        self.uniq = None
        self._last_ts = None
        self.sm_x = None
        self.sm_y = None

    def _read_buttons(self):
        pressed = set()
        dead = []
        for d in self.btns:
            try:
                while True:
                    e = d.read_one()
                    if e is None:
                        break
                    if e.type == ecodes.EV_KEY and e.value == 1:
                        name = EVDEV_BTNS.get(e.code)
                        if name:
                            pressed.add(name)
                    elif e.type == ecodes.EV_ABS and e.code == ecodes.ABS_HAT0X:
                        if e.value < 0 and self._hat["x"] >= 0:
                            pressed.add("y")        # left  = Y
                        if e.value > 0 and self._hat["x"] <= 0:
                            pressed.add("a")        # right = A
                        self._hat["x"] = e.value
                    elif e.type == ecodes.EV_ABS and e.code == ecodes.ABS_HAT0Y:
                        if e.value < 0 and self._hat["y"] >= 0:
                            pressed.add("x")        # up    = X
                        if e.value > 0 and self._hat["y"] <= 0:
                            pressed.add("b")        # down  = B
                        self._hat["y"] = e.value
            except (OSError, IOError):
                dead.append(d)
            except Exception:
                pass
        for d in dead:
            _CLAIMED.discard(d.path)
            try:
                d.close()
            except Exception:
                pass
            self.btns.remove(d)
        return pressed

    def poll(self, dt, conf, screen_w):
        """Integrate EVERY IMU sample (the kernel batches ~3 per report at
        ~200-400 Hz; sampling once a frame threw most of the motion away and
        made the pointer coarse). Angles are absolute relative to the centre
        pose, so aiming at the same spot puts the glove in the same place."""
        if self.imu is None:
            if time.time() >= self._next_scan:
                self._scan()
            return 0.0, 0.0, self._read_buttons(), None

        samples = []
        try:
            while True:
                e = self.imu.read_one()
                if e is None:
                    break
                if e.type == ecodes.EV_ABS:
                    if e.code == ecodes.ABS_RX:
                        self.g["x"] = e.value / self.res["g"]
                    elif e.code == ecodes.ABS_RY:
                        self.g["y"] = e.value / self.res["g"]
                    elif e.code == ecodes.ABS_RZ:
                        self.g["z"] = e.value / self.res["g"]
                    elif e.code == ecodes.ABS_X:
                        self.acc[0] = e.value / self.res["a"]
                    elif e.code == ecodes.ABS_Y:
                        self.acc[1] = e.value / self.res["a"]
                    elif e.code == ecodes.ABS_Z:
                        self.acc[2] = e.value / self.res["a"]
                elif e.type == ecodes.EV_SYN and e.code == ecodes.SYN_REPORT:
                    ts = e.timestamp()
                    if self._last_ts is None:
                        sdt = 0.0
                    else:
                        sdt = ts - self._last_ts
                        if not (0.0 < sdt < 0.1):     # clock jump / long gap
                            sdt = 0.005
                    self._last_ts = ts
                    samples.append((self.g["x"], self.g["y"], self.g["z"], sdt))
        except (OSError, IOError):
            try:
                self.imu.close()
            except Exception:
                pass
            self.imu = None
            self.side = None
            self._last_ts = None
            return 0.0, 0.0, set(), None
        except Exception:
            pass

        self._samples = len(samples)

        # ---- calibration: average the raw rate while the user holds it still
        if self.calibrating > 0:
            for gx, gy, gz, _ in samples:
                for i, v in enumerate((gx, gy, gz)):
                    self._cal_acc[i] += v
                    self._cal_lo[i] = min(self._cal_lo[i], v)
                    self._cal_hi[i] = max(self._cal_hi[i], v)
                self._cal_n += 1
            self.calibrating -= 1
            if self.calibrating <= 0:
                if self._cal_n < 8:
                    self.cal_status = "nodata"
                else:
                    spread = max(self._cal_hi[i] - self._cal_lo[i]
                                 for i in range(3))
                    if spread <= self.CAL_STILL:
                        self.bias = [a / self._cal_n for a in self._cal_acc]
                        self.cal_status = "ok"
                    elif self._cal_tries > 1:
                        # it moved - bin the sample and have another go
                        self.start_calibration(self.cal_frames,
                                               self._cal_tries - 1)
                        self.cal_status = "moving"
                    else:
                        self.cal_status = "gaveup"
                self.recenter()
            return 0.0, 0.0, self._read_buttons(), tuple(self.acc)

        # ---- integrate each sample at its own timestep
        peak = 0.0
        for gx, gy, gz, sdt in samples:
            gx -= self.bias[0]
            gy -= self.bias[1]
            gz -= self.bias[2]
            axes = {"x": gx, "y": gy, "z": gz}
            ya, pa = GYRO_MAPS[conf.get("gyro_map", 0) % len(GYRO_MAPS)]
            yaw_rate, pitch_rate = axes[ya], axes[pa]
            if conf["swap_axes"]:
                yaw_rate, pitch_rate = pitch_rate, yaw_rate
            peak = max(peak, abs(yaw_rate), abs(pitch_rate))
            # tiny deadband only - real motion is never thrown away
            if abs(yaw_rate) > 0.35:
                self.yaw += yaw_rate * sdt
            if abs(pitch_rate) > 0.35:
                self.pitch += pitch_rate * sdt

        # ---- zero-rate drift correction: while it sits still, learn the bias
        if samples:
            if peak < 2.2:
                self._still += dt
                if self._still > 0.25:
                    raw = [sum(s[i] for s in samples) / len(samples)
                           for i in range(3)]
                    # faster while it's clearly resting, so a duff calibration
                    # heals itself within a second or two of putting it down
                    k = min(1.0, dt * (2.2 if self._still > 1.0 else 0.9))
                    for i in range(3):
                        self.bias[i] += (raw[i] - self.bias[i]) * k
                    self.cal_status = "ok"
            else:
                self._still = 0.0

        # ---- map absolute angles onto the screen
        screen_h = getattr(self, "screen_h", screen_w * 9.0 / 16.0)
        cal = conf.get("cal")
        if self.free:
            # wizard running: let the angles run wild, we're measuring them
            tx, ty = screen_w * 0.5, screen_h * 0.5
        elif cal:
            # five-point calibration: the user showed us where the corners
            # are, so sign (invert) and scale both come straight out of it
            sens = max(0.3, conf["sens"])
            sx = cal["sx"] * sens
            sy = cal["sy"] * sens
            tx = screen_w * 0.5 + (self.yaw - cal["yc"]) * sx
            ty = screen_h * 0.5 + (self.pitch - cal["pc"]) * sy
            # keep the angles inside the mapped area so it can't wind away
            if abs(sx) > 1e-6:
                lim = (screen_w * 0.62) / abs(sx)
                self.yaw = max(cal["yc"] - lim, min(cal["yc"] + lim, self.yaw))
            if abs(sy) > 1e-6:
                lim = (screen_h * 0.62) / abs(sy)
                self.pitch = max(cal["pc"] - lim, min(cal["pc"] + lim, self.pitch))
            tx = max(0.0, min(screen_w - 1.0, tx))
            ty = max(0.0, min(screen_h - 1.0, ty))
        else:
            span = max(12.0, 46.0 / max(0.3, conf["sens"]))
            half = span * 0.5
            self.yaw = max(-half, min(half, self.yaw))
            self.pitch = max(-half, min(half, self.pitch))
            px_per_deg = screen_w / span
            tx = screen_w * 0.5 - self.yaw * px_per_deg
            ty = screen_h * 0.5 - self.pitch * px_per_deg
            if conf["invert_x"]:
                tx = screen_w - tx
            if conf["invert_y"]:
                ty = screen_h - ty

        # ---- light smoothing to take the shake off without adding lag
        if self.sm_x is None:
            self.sm_x, self.sm_y = tx, ty
        k = min(1.0, dt * 26.0)
        prev_x, prev_y = self.sm_x, self.sm_y
        self.sm_x += (tx - self.sm_x) * k
        self.sm_y += (ty - self.sm_y) * k

        return (self.sm_x - prev_x, self.sm_y - prev_y,
                self._read_buttons(), tuple(self.acc))


class JoyConPointer:
    """Reads one Joy-Con (right preferred) and integrates its gyro into
    pointer motion, Wiimote style. Hold it like a TV remote, buttons up."""

    backend = "hidapi"

    def recenter(self):
        pass

    def __init__(self):
        self.jc = None
        self.side = None
        self.bias = [0.0, 0.0, 0.0]
        self._cal_acc = [0.0, 0.0, 0.0]
        self._cal_n = 0
        self.calibrating = 0        # frames left of calibration
        self.prev = {}
        if not HAVE_JOYCON:
            return
        for side, getter in (("R", get_R_id), ("L", get_L_id)):
            try:
                ids = getter()
                if ids and ids[0] is not None:
                    self.jc = JoyCon(*ids)
                    self.side = side
                    break
            except Exception:
                self.jc = None
        if self.jc:
            self.start_calibration()

    @property
    def ok(self):
        return self.jc is not None

    def start_calibration(self, frames=70):
        self._cal_acc = [0.0, 0.0, 0.0]
        self._cal_n = 0
        self.calibrating = frames

    def _status(self):
        try:
            return self.jc.get_status()
        except Exception:
            return None

    def poll(self, dt, conf, screen_w):
        """Returns (dx, dy, pressed:set, accel:(x,y,z)|None)."""
        if not self.jc:
            return 0.0, 0.0, set(), None
        st = self._status()
        if not st:
            return 0.0, 0.0, set(), None

        g = st.get("gyro", {}) or {}
        gx = float(g.get("x", 0)); gy = float(g.get("y", 0)); gz = float(g.get("z", 0))

        if self.calibrating > 0:
            self._cal_acc[0] += gx; self._cal_acc[1] += gy; self._cal_acc[2] += gz
            self._cal_n += 1
            self.calibrating -= 1
            if self.calibrating == 0 and self._cal_n:
                self.bias = [a / self._cal_n for a in self._cal_acc]
            dx = dy = 0.0
        else:
            gx -= self.bias[0]; gy -= self.bias[1]; gz -= self.bias[2]
            # remote grip: body z ~ world-up -> yaw, body x ~ across -> pitch
            yaw = gz * GYRO_DPS
            pitch = gx * GYRO_DPS
            if abs(yaw) < 1.2:
                yaw = 0.0
            if abs(pitch) < 1.2:
                pitch = 0.0
            if conf["swap_axes"]:
                yaw, pitch = pitch, yaw
            px_per_deg = screen_w / 42.0 * conf["sens"]
            dx = -yaw * dt * px_per_deg
            dy = -pitch * dt * px_per_deg
            if conf["invert_x"]:
                dx = -dx
            if conf["invert_y"]:
                dy = -dy

        pressed = set()
        try:
            btns = st.get("buttons", {})
            flat = {}
            flat.update(btns.get("shared", {}))
            if self.side == "R":
                flat.update(btns.get("right", {}))
            else:
                for k, v in (btns.get("left", {}) or {}).items():
                    flat[LEFT_AS_RIGHT.get(k, k)] = v
            for name, val in flat.items():
                if val and not self.prev.get(name):
                    pressed.add(name)
            self.prev = flat
        except Exception:
            pass

        accel = None
        try:
            a = st.get("accel", {}) or {}
            accel = (float(a.get("x", 0)) / 4096.0,
                     float(a.get("y", 0)) / 4096.0,
                     float(a.get("z", 0)) / 4096.0)
        except Exception:
            accel = None
        return dx, dy, pressed, accel


class PadFallback:
    """8BitDo & friends via pygame.joystick: stick nudges the pointer,
    A/B honour the Nintendo/Xbox layout setting (letter badges, not shapes)."""

    def __init__(self):
        self.js = None
        try:
            pygame.joystick.init()
            if pygame.joystick.get_count() > 0:
                self.js = pygame.joystick.Joystick(0)
                self.js.init()
        except Exception:
            self.js = None
        self.prev = {}

    def poll(self, dt, conf, screen_w):
        if not self.js:
            return 0.0, 0.0, set()
        try:
            ax = self.js.get_axis(0)
            ay = self.js.get_axis(1)
        except Exception:
            ax = ay = 0.0
        dead = 0.22
        ax = 0.0 if abs(ax) < dead else ax
        ay = 0.0 if abs(ay) < dead else ay
        speed = screen_w * 0.9 * dt
        dx, dy = ax * speed, ay * speed

        if conf["pad_layout"] == "nintendo":
            a_i, b_i = 1, 0
        else:
            a_i, b_i = 0, 1
        mapping = {"a": a_i, "b": b_i, "y": 3, "x": 2,
                   "home": 8, "plus": 7, "minus": 6, "r": 5}
        pressed = set()
        for name, idx in mapping.items():
            try:
                v = self.js.get_button(idx)
            except Exception:
                v = 0
            if v and not self.prev.get(name):
                pressed.add(name)
            self.prev[name] = v
        return dx, dy, pressed


# ============================================================================
#  CHANNELS
# ============================================================================
class Channel:
    def __init__(self, kind, title, path=None):
        self.kind = kind        # game | cart | fit | settings | board | info | empty
        self.title = title
        self.path = path
        self.banner = None      # rendered lazily
        self.rect = pygame.Rect(0, 0, 0, 0)
        self.hover = 0.0


def find_games():
    seen = set()
    out = []
    for d in GAME_DIRS:
        if not os.path.isdir(d):
            continue
        try:
            names = sorted(os.listdir(d))
        except Exception:
            continue
        for n in names:
            p = os.path.join(d, n)
            title = None
            if n.endswith(".py") and not n.startswith("_"):
                title = os.path.splitext(n)[0]
            elif os.path.isdir(p):
                for entry in ("main.py", n + ".py", "game.py"):
                    ep = os.path.join(p, entry)
                    if os.path.isfile(ep):
                        p = ep
                        title = n
                        break
            if title and title.lower() not in seen:
                seen.add(title.lower())
                out.append(Channel("game", title.replace("_", " ").title(), p))
    return out


def find_cart():
    """Removable media labelled GAMEKING* -> first .py inside is the cart."""
    for root in MEDIA_ROOTS:
        if not os.path.isdir(root):
            continue
        try:
            vols = os.listdir(root)
        except Exception:
            continue
        for v in vols:
            if not v.upper().startswith("GAMEKING"):
                continue
            vp = os.path.join(root, v)
            try:
                cand = sorted(f for f in os.listdir(vp) if f.endswith(".py"))
            except Exception:
                continue
            for pref in ("autorun.py",):
                if pref in cand:
                    return os.path.join(vp, pref), v
            if cand:
                return os.path.join(vp, cand[0]), v
    return None, None


def build_channels():
    chans = [Channel("cart", "KDS Slot"),
             Channel("fit", "GK Fit"),
             Channel("board", "King Board"),
             Channel("settings", "Settings"),
             Channel("info", "Game King")]
    chans += find_games()
    per_page = 12
    while len(chans) % per_page:
        chans.append(Channel("empty", ""))
    return chans


# ============================================================================
#  DRAWING
# ============================================================================
def hue_color(name):
    random.seed(hash(name) & 0xFFFF)
    h = random.random()
    r = int(140 + 90 * abs(math.sin(h * 6.28)))
    g = int(140 + 90 * abs(math.sin(h * 6.28 + 2.1)))
    b = int(150 + 90 * abs(math.sin(h * 6.28 + 4.2)))
    return (r, g, b)


def vgrad(surf, rect, top, bottom):
    x, y, w, h = rect
    for i in range(h):
        t = i / max(1, h - 1)
        c = [int(top[j] + (bottom[j] - top[j]) * t) for j in range(3)]
        pygame.draw.line(surf, c, (x, y + i), (x + w - 1, y + i))


class UI:
    def __init__(self, screen, conf):
        self.s = screen
        self.W, self.H = screen.get_size()
        self.conf = conf
        self.f_big = pygame.font.Font(None, int(self.H * 0.10))
        self.f_med = pygame.font.Font(None, int(self.H * 0.055))
        self.f_sml = pygame.font.Font(None, int(self.H * 0.036))
        self.f_tin = pygame.font.Font(None, int(self.H * 0.027))
        self.f_clock = pygame.font.Font(None, int(self.H * 0.115))
        self.gloves = {}
        self.bg = self.make_bg()
        self.t = 0.0

    # -- static art -----------------------------------------------------
    def make_bg(self):
        bg = pygame.Surface((self.W, self.H))
        vgrad(bg, (0, 0, self.W, self.H), (238, 242, 247), (210, 218, 228))
        dot = (200, 208, 219)
        step = max(24, self.W // 40)
        for yy in range(step, self.H, step):
            for xx in range(step, self.W, step):
                pygame.draw.circle(bg, dot, (xx, yy), 1)
        vgrad(bg, (0, 0, self.W, int(self.H * 0.055)), (190, 199, 212), (238, 242, 247))
        return bg

    def glove(self, player=0):
        if player in self.gloves:
            return self.gloves[player]
        g = self.make_glove(player)
        self.gloves[player] = g
        return g

    def make_glove(self, player=0):
        g = pygame.Surface((52, 56), pygame.SRCALPHA)
        outline = (40, 45, 60)
        white = (250, 250, 252)
        blue = PLAYER_COLORS[player % len(PLAYER_COLORS)]
        # shadowless glove: capsule finger + fist + cuff
        def capsule(surf, a, b, r, col):
            pygame.draw.line(surf, col, a, b, r * 2)
            pygame.draw.circle(surf, col, a, r)
            pygame.draw.circle(surf, col, b, r)
        capsule(g, (10, 8), (26, 30), 8, outline)
        pygame.draw.circle(g, outline, (30, 36), 14)
        capsule(g, (10, 8), (26, 30), 6, white)
        pygame.draw.circle(g, white, (30, 36), 12)
        pygame.draw.rect(g, blue, (20, 44, 22, 9), border_radius=4)
        pygame.draw.rect(g, outline, (20, 44, 22, 9), 2, border_radius=4)
        tag = self.tiny_tag(str(player + 1), blue)
        g.blit(tag, (38, 30))
        return g

    def tiny_tag(self, txt, col):
        f = pygame.font.Font(None, 20)
        t = f.render(txt, True, (255, 255, 255))
        w = t.get_width() + 8
        s = pygame.Surface((w, 18), pygame.SRCALPHA)
        pygame.draw.rect(s, col, (0, 0, w, 18), border_radius=5)
        s.blit(t, (4, 2))
        return s

    # -- helpers --------------------------------------------------------
    def text(self, font, txt, col, pos, center=False, shadow=None):
        t = font.render(txt, True, col)
        r = t.get_rect()
        if center:
            r.center = pos
        else:
            r.topleft = pos
        if shadow:
            ts = font.render(txt, True, shadow)
            self.s.blit(ts, (r.x + 2, r.y + 2))
        self.s.blit(t, r)
        return r

    def button(self, rect, label, pointer, hot=False, small=False):
        r = pygame.Rect(rect)
        over = r.collidepoint(pointer)
        base = (120, 190, 240) if (over or hot) else (235, 238, 243)
        pygame.draw.rect(self.s, (150, 158, 170), r.move(0, 3), border_radius=r.h // 2)
        pygame.draw.rect(self.s, base, r, border_radius=r.h // 2)
        pygame.draw.rect(self.s, (110, 120, 135), r, 2, border_radius=r.h // 2)
        f = self.f_sml if small else self.f_med
        self.text(f, label, (45, 52, 66), r.center, center=True)
        return over

    def badge(self, letter, pos):
        r = pygame.Rect(pos[0], pos[1], 26, 26)
        pygame.draw.circle(self.s, (60, 66, 80), r.center, 13)
        pygame.draw.circle(self.s, (245, 245, 245), r.center, 13, 2)
        self.text(self.f_tin, letter, (245, 245, 245), r.center, center=True)
        return r.w

    def banner_for(self, ch):
        if ch.banner:
            return ch.banner
        w, h = 320, 180
        b = pygame.Surface((w, h))
        if ch.kind == "game" and ch.path:
            png = os.path.splitext(ch.path)[0] + ".png"
            if os.path.isfile(png):
                try:
                    img = pygame.image.load(png)
                    ch.banner = pygame.transform.smoothscale(img, (w, h))
                    return ch.banner
                except Exception:
                    pass
        col = {"cart": (108, 118, 132), "fit": (120, 200, 140),
               "settings": (170, 176, 188), "board": (235, 180, 90),
               "info": (90, 120, 210)}.get(ch.kind, hue_color(ch.title))
        dark = tuple(max(0, c - 60) for c in col)
        vgrad(b, (0, 0, w, h), col, dark)
        for i in range(4):
            y = 30 + i * 38
            pygame.draw.line(b, tuple(min(255, c + 25) for c in col), (0, y), (w, y), 2)
        f = pygame.font.Font(None, 44)
        label = {"cart": "KDS  SLOT", "fit": "GK  FIT", "board": "KING BOARD",
                 "settings": "SETTINGS", "info": "GAME KING"}.get(ch.kind, ch.title.upper())
        t = f.render(label[:16], True, (250, 250, 250))
        b.blit(t, t.get_rect(center=(w // 2, h // 2)))
        if ch.kind == "game":
            f2 = pygame.font.Font(None, 24)
            t2 = f2.render("GamePak", True, (245, 245, 245))
            b.blit(t2, (10, h - 28))
        ch.banner = b
        return b


# ============================================================================
#  MAIN APP
# ============================================================================
(S_WARN, S_MENU, S_ZOOM, S_CHANNEL, S_SETTINGS, S_FIT, S_BOARD, S_INFO,
 S_CALIB, S_UPDATE) = range(10)

# where each calibration point sits on screen, in fractions
CAL_POINTS = [("the CENTRE of the screen", 0.50, 0.50),
              ("the TOP-RIGHT corner", 0.94, 0.08),
              ("the TOP-LEFT corner", 0.06, 0.08),
              ("the BOTTOM-LEFT corner", 0.06, 0.92),
              ("the BOTTOM-RIGHT corner", 0.94, 0.92)]


class App:
    def __init__(self):
        pygame.init()
        init_audio()
        self.fullscreen = ("--fullscreen" in sys.argv
                           and "--windowed" not in sys.argv)
        flags = pygame.FULLSCREEN if self.fullscreen else 0
        size = (0, 0) if self.fullscreen else (1280, 720)
        self.screen = pygame.display.set_mode(size, flags)
        pygame.display.set_caption("GAME KING FIT")
        pygame.mouse.set_visible(False)
        self.conf = load_conf()
        self.ui = UI(self.screen, self.conf)
        self.W, self.H = self.ui.W, self.ui.H
        self.clock = pygame.time.Clock()

        self.pointers = []
        if HAVE_EVDEV:
            for i in range(PLAYER_MAX):
                p = EvdevJoyCon(player=i)
                p.screen_h = self.ui.H
                self.pointers.append(p)
        self.jcp = self.pointers[0] if self.pointers else None
        if (self.jcp is None or not self.jcp.ok) and HAVE_JOYCON:
            alt = JoyConPointer()
            if alt.ok:
                self.jcp = alt
                self.pointers = [alt]
        if self.jcp is None:
            self.jcp = JoyConPointer()
            self.pointers = [self.jcp]
        self.jcp.screen_h = self.ui.H
        self.active = 0
        self.pad = PadFallback()
        self.pointer = [self.W / 2, self.H / 2]
        self.mouse_ts = 0.0

        self.channels = build_channels()
        self.bar_gear = pygame.Rect(0, 0, 0, 0)
        self.bar_mail = pygame.Rect(0, 0, 0, 0)
        self.page = 0
        self.pages = max(1, len(self.channels) // 12)
        self.page_x = 0.0            # smooth slide
        self.state = S_WARN if "--skipwarning" not in sys.argv else S_MENU
        self.zoom_t = 0.0
        self.zoom_ch = None
        self.zoom_src = None
        self.active_ch = None
        self.home_open = False
        self.hover_ch = None
        self.fit = {"phase": 0, "t": 0.0, "wobble": 0.0, "age": None}
        self.cal = {"step": -1, "pts": [], "done": False, "flash": 0.0}
        self.updater = Updater(self.conf, HERE)
        if self.conf.get("auto_update", True) and not SELFTEST:
            self.updater.check_async()
        self._update_told = False
        self.toast = None            # (text, until)
        self.cal_watch = False
        self.running = True
        self.frame = 0
        self.fps = 0.0

    # ------------------------------------------------------------- layout
    def grid_rect(self, i_on_page, page_offset_px):
        cols, rows = 4, 3
        gx = self.W * 0.045
        gy = self.H * 0.075
        gw = self.W - gx * 2
        gh = self.H * 0.62
        cw = gw / cols
        chh = gh / rows
        c = i_on_page % cols
        r = i_on_page // cols
        pad_x = cw * 0.06
        pad_y = chh * 0.10
        x = gx + c * cw + pad_x + page_offset_px
        y = gy + r * chh + pad_y
        return pygame.Rect(int(x), int(y), int(cw - pad_x * 2), int(chh - pad_y * 2))

    # ------------------------------------------------------------- input
    def gather_input(self, dt):
        pressed = set()
        clicks = []                       # (x, y)
        for e in pygame.event.get():
            if e.type == pygame.QUIT:
                self.running = False
            elif e.type == pygame.MOUSEMOTION:
                self.pointer = list(e.pos)
                self.mouse_ts = time.time()
            elif e.type == pygame.MOUSEBUTTONDOWN and e.button == 1:
                self.pointer = list(e.pos)
                clicks.append(tuple(e.pos))
            elif e.type == pygame.FINGERDOWN:
                p = (e.x * self.W, e.y * self.H)
                self.pointer = list(p)
                clicks.append(p)
            elif e.type == pygame.KEYDOWN:
                k = e.key
                if k == pygame.K_ESCAPE:
                    pressed.add("home" if self.state == S_MENU and not self.home_open else "b")
                elif k in (pygame.K_RETURN, pygame.K_SPACE):
                    clicks.append(tuple(self.pointer))
                elif k == pygame.K_F1:
                    self.conf["chipset"] = not self.conf["chipset"]
                elif k == pygame.K_F5:
                    pressed.add("y")
                elif k == pygame.K_LEFT:
                    pressed.add("minus")
                elif k == pygame.K_RIGHT:
                    pressed.add("plus")

        pdx, pdy, pbtn = self.pad.poll(dt, self.conf, self.W)
        # if evdev owns a Joy-Con, SDL is looking at the SAME device - taking
        # both would fire every press twice, with two different mappings
        if not any(getattr(p, "ok", False) and getattr(p, "backend", "") == "evdev"
                   for p in self.pointers):
            pressed |= pbtn
        else:
            pdx = pdy = 0.0
        self.accel = None
        dx = dy = 0.0
        for i, p in enumerate(self.pointers):
            before = (getattr(p, "sm_x", None), getattr(p, "sm_y", None))
            pdx_, pdy_, jbtn, accel = p.poll(dt, self.conf, self.W)
            if i == 0:
                dx, dy = pdx_, pdy_
            if accel is not None and (i == self.active or self.accel is None):
                self.accel = accel
            moved = (before[0] is not None
                     and getattr(p, "sm_x", None) is not None
                     and (abs(p.sm_x - before[0]) + abs(p.sm_y - before[1])) > 4)
            if jbtn or moved:
                if p.ok:
                    self.active = i
            if jbtn:
                if i == 0:
                    pressed |= jbtn
                else:
                    # another player's press acts at THEIR cursor
                    if getattr(p, "sm_x", None) is not None:
                        self.pointer = [p.sm_x, p.sm_y]
                    pressed |= jbtn

        # gyro/pad only steer when the mouse isn't actively in charge
        if time.time() - self.mouse_ts > 0.15:
            lead = self.pointers[self.active] if self.pointers else self.jcp
            if not lead.ok:
                lead = self.jcp
            abs_x = getattr(lead, "sm_x", None)
            if abs_x is not None and lead.ok and not lead.calibrating:
                # absolute pointing: aim at a spot, the glove goes to that spot
                self.pointer[0] = abs_x + pdx
                self.pointer[1] = lead.sm_y + pdy
            else:
                self.pointer[0] += dx + pdx
                self.pointer[1] += dy + pdy
        self.pointer[0] = max(0, min(self.W - 1, self.pointer[0]))
        self.pointer[1] = max(0, min(self.H - 1, self.pointer[1]))

        if "a" in pressed:
            clicks.append(tuple(self.pointer))
        if "y" in pressed or "r-stick" in pressed or "l-stick" in pressed:
            self.pointer = [self.W / 2, self.H / 2]
            for p in self.pointers:
                if hasattr(p, "recenter"):
                    p.recenter()
                else:
                    p.start_calibration(50)
            self.toast = ("Recentred", time.time() + 1.2)
        if "home" in pressed:
            self.home_open = not self.home_open
            play("click", self.conf)
            pressed.discard("home")
        return pressed, clicks

    # ------------------------------------------------------------- launch
    def launch(self, ch):
        if not ch.path or not os.path.isfile(ch.path):
            play("deny", self.conf)
            self.toast = ("No Pak inserted.", time.time() + 2.0)
            return
        play("chime", self.conf)
        log_play(ch.title)
        save_conf(self.conf)
        try:
            pygame.display.iconify()
        except Exception:
            pass
        env = dict(os.environ)
        env["GKFIT"] = "1"
        env["GKFIT_FULLSCREEN"] = "1" if self.fullscreen else "0"
        env["GKFIT_W"] = str(self.W)
        env["GKFIT_H"] = str(self.H)
        try:
            subprocess.run([sys.executable, ch.path],
                           cwd=os.path.dirname(ch.path), env=env)
        except Exception:
            pass
        # come back exactly as we were
        flags = pygame.FULLSCREEN if self.fullscreen else 0
        size = (self.W, self.H)
        self.screen = pygame.display.set_mode(size, flags)
        pygame.mouse.set_visible(False)
        pygame.event.clear()
        self.state = S_MENU
        for p in self.pointers:
            p.start_calibration(50)

    # ------------------------------------------------------------- states
    def update(self, dt):
        self.ui.t += dt
        pressed, clicks = self.gather_input(dt)

        if (not self._update_told and self.updater.available
                and self.state == S_MENU):
            self._update_told = True
            play("chime", self.conf)
            self.toast = ("System update available - see Settings",
                          time.time() + 3.5)

        if self.cal_watch:
            live = [p for p in self.pointers if getattr(p, "ok", False)]
            if live and not any(p.calibrating for p in live):
                self.cal_watch = False
                st = live[0].cal_status
                msg = {"ok": "Calibrated.",
                       "moving": "It moved - trying again.",
                       "gaveup": "Couldn't get a still reading. Put it on the "
                                 "desk and hit Recalibrate.",
                       "nodata": "No gyro data."}.get(st, "")
                if msg:
                    self.toast = (msg, time.time() + 2.6)

        if self.home_open:
            self.handle_home(clicks)
            return

        if self.state == S_WARN:
            if clicks or "a" in pressed or self.ui.t > 12:
                play("chime", self.conf)
                self.state = S_MENU
            return

        if self.state == S_MENU:
            self.update_menu(dt, pressed, clicks)
        elif self.state == S_ZOOM:
            self.zoom_t += dt / 0.32
            if self.zoom_t >= 1.0:
                self.zoom_t = 1.0
                self.state = S_CHANNEL
        elif self.state == S_CHANNEL:
            self.update_channel(pressed, clicks)
        elif self.state == S_SETTINGS:
            self.update_settings(pressed, clicks)
        elif self.state == S_CALIB:
            self.update_calib(dt, pressed)
        elif self.state == S_FIT:
            self.update_fit(dt, pressed, clicks)
        elif self.state == S_UPDATE:
            self.update_update(pressed, clicks)
        elif self.state in (S_BOARD, S_INFO):
            if "b" in pressed or self.back_hit(clicks):
                play("back", self.conf)
                self.state = S_MENU

    def update_menu(self, dt, pressed, clicks):
        if "plus" in pressed or "r" in pressed or "zr" in pressed:
            if self.page < self.pages - 1:
                self.page += 1
                play("page", self.conf)
        if "minus" in pressed:
            if self.page > 0:
                self.page -= 1
                play("page", self.conf)
        target = -self.page * self.W
        self.page_x += (target - self.page_x) * min(1, dt * 10)

        # page arrows
        arrow_l = pygame.Rect(0, int(self.H * 0.3), int(self.W * 0.035), int(self.H * 0.25))
        arrow_r = pygame.Rect(self.W - int(self.W * 0.035), int(self.H * 0.3),
                              int(self.W * 0.035), int(self.H * 0.25))
        hov = None
        for gi, ch in enumerate(self.channels):
            pg = gi // 12
            ch.rect = self.grid_rect(gi % 12, self.page_x + pg * self.W)
            if ch.kind != "empty" and ch.rect.collidepoint(self.pointer):
                hov = ch
            ch.hover += ((1.0 if ch is hov else 0.0) - ch.hover) * min(1, dt * 12)
        if hov is not self.hover_ch:
            if hov:
                play("hover", self.conf)
            self.hover_ch = hov

        for cx, cy in clicks:
            if arrow_l.collidepoint((cx, cy)) and self.page > 0:
                self.page -= 1
                play("page", self.conf)
            elif arrow_r.collidepoint((cx, cy)) and self.page < self.pages - 1:
                self.page += 1
                play("page", self.conf)
            elif self.bar_gear.collidepoint((cx, cy)):
                play("click", self.conf)
                self.state = S_SETTINGS
            elif self.bar_mail.collidepoint((cx, cy)):
                play("click", self.conf)
                self.state = S_BOARD
            elif hov and hov.rect.collidepoint((cx, cy)):
                play("click", self.conf)
                self.zoom_ch = hov
                self.zoom_src = hov.rect.copy()
                self.zoom_t = 0.0
                self.state = S_ZOOM

    def update_channel(self, pressed, clicks):
        ch = self.zoom_ch
        if "b" in pressed:
            play("back", self.conf)
            self.state = S_MENU
            return
        start_r = pygame.Rect(int(self.W * 0.56), int(self.H * 0.80),
                              int(self.W * 0.18), int(self.H * 0.09))
        back_r = pygame.Rect(int(self.W * 0.26), int(self.H * 0.80),
                             int(self.W * 0.18), int(self.H * 0.09))
        for c in clicks:
            if start_r.collidepoint(c):
                if ch.kind == "game":
                    self.launch(ch)
                elif ch.kind == "cart":
                    p, vol = find_cart()
                    if p:
                        cart = Channel("game", vol or "KDS Disk", p)
                        self.launch(cart)
                    else:
                        play("deny", self.conf)
                        self.toast = ("Insert a KDS disk or GamePak.", time.time() + 2.2)
                elif ch.kind == "settings":
                    self.state = S_SETTINGS
                elif ch.kind == "fit":
                    self.fit = {"phase": 0, "t": 0.0, "wobble": 0.0, "age": None}
                    self.state = S_FIT
                elif ch.kind == "board":
                    self.state = S_BOARD
                elif ch.kind == "info":
                    self.state = S_INFO
            elif back_r.collidepoint(c):
                play("back", self.conf)
                self.state = S_MENU
        self._chan_btns = (start_r, back_r)

    def update_settings(self, pressed, clicks):
        if "b" in pressed:
            play("back", self.conf)
            save_conf(self.conf)
            self.state = S_MENU
            return
        for c in clicks:
            for key, r in getattr(self, "_set_rects", {}).items():
                if r.collidepoint(c):
                    play("click", self.conf)
                    if key == "sens-":
                        self.conf["sens"] = round(max(0.3, self.conf["sens"] - 0.1), 2)
                    elif key == "sens+":
                        self.conf["sens"] = round(min(3.0, self.conf["sens"] + 0.1), 2)
                    elif key == "gyro_map":
                        self.conf["gyro_map"] = (self.conf.get("gyro_map", 0)
                                                 + 1) % len(GYRO_MAPS)
                        for p in self.pointers:
                            p.start_calibration(40)
                    elif key == "pad_layout":
                        self.conf[key] = "xbox" if self.conf[key] == "nintendo" else "nintendo"
                    elif key == "recal":
                        self.start_calib()
                    elif key == "clearcal":
                        self.conf["cal"] = None
                        for p in self.pointers:
                            p.recenter()
                        save_conf(self.conf)
                        self.toast = ("Calibration cleared", time.time() + 1.8)
                    elif key == "update":
                        if self.updater.state in ("idle", "current", "error"):
                            self.updater.check_async()
                        self.state = S_UPDATE
                    elif key == "back":
                        save_conf(self.conf)
                        self.state = S_MENU
                    else:
                        self.conf[key] = not self.conf[key]

    def start_calib(self):
        self.cal = {"step": -1, "pts": [], "done": False, "flash": 0.0}
        for p in self.pointers:
            p.free = True
            p.recenter()
            if hasattr(p, "start_calibration"):
                p.start_calibration(60)     # bias, while they read the intro
        self.state = S_CALIB

    def end_calib(self, save=True):
        for p in self.pointers:
            p.free = False
            if not save:
                p.recenter()          # cancelled: back to a clean slate
        if save:
            save_conf(self.conf)
        self.state = S_MENU

    def calib_pointer(self):
        """The pad doing the calibrating - whoever's live and leading."""
        if self.pointers:
            p = self.pointers[min(self.active, len(self.pointers) - 1)]
            if getattr(p, "ok", False):
                return p
            for q in self.pointers:
                if getattr(q, "ok", False):
                    return q
        return None

    def update_calib(self, dt, pressed):
        c = self.cal
        c["flash"] = max(0.0, c["flash"] - dt)
        if "b" in pressed:
            self.end_calib(save=False)
            return
        p = self.calib_pointer()

        if c["done"]:
            if pressed:
                self.end_calib(save=True)
            return

        if "a" not in pressed:
            return

        if c["step"] < 0:                    # intro -> first point
            c["step"] = 0
            c["flash"] = 0.25
            return

        if p is None:                        # no gyro: nothing to measure
            self.toast = ("No Joy-Con gyro to calibrate.", time.time() + 2.2)
            self.end_calib(save=False)
            return

        c["pts"].append((p.yaw, p.pitch))
        c["flash"] = 0.25
        c["step"] += 1

        if c["step"] >= len(CAL_POINTS):
            yc, pc = c["pts"][0]
            tr, tl, bl, br = c["pts"][1], c["pts"][2], c["pts"][3], c["pts"][4]
            right = (tr[0] + br[0]) / 2.0
            left = (tl[0] + bl[0]) / 2.0
            top = (tr[1] + tl[1]) / 2.0
            bottom = (bl[1] + br[1]) / 2.0
            dx = (right - left) / 2.0        # yaw change from centre to edge
            dy = (bottom - top) / 2.0
            if abs(dx) < 0.8 or abs(dy) < 0.8:
                c["done"] = True
                c["bad"] = True
                return
            # sx maps yaw offset -> pixels; its SIGN carries the inversion
            sx = (self.W * 0.5) / ((right - yc) if abs(right - yc) > 0.5
                                   else dx)
            sy = (self.H * 0.5) / ((bottom - pc) if abs(bottom - pc) > 0.5
                                   else dy)
            # a sane pad sweeps the screen in maybe 8-90 degrees; anything
            # outside that came from a duff reading, so don't save it
            if not (4.0 < abs(self.W * 0.5 / sx) < 120.0
                    and 2.0 < abs(self.H * 0.5 / sy) < 120.0):
                c["done"] = True
                c["bad"] = True
                return
            self.conf["cal"] = {"yc": 0.0, "pc": 0.0, "sx": sx, "sy": sy}
            # rebase the live angles so "centre" is zero from here on, WITHOUT
            # zeroing them - the pad hasn't moved back to the middle
            for q in self.pointers:
                q.free = False
                q.yaw -= yc
                q.pitch -= pc
                q.sm_x = None
                q.sm_y = None
            c["done"] = True
            c["bad"] = False
            save_conf(self.conf)

    def draw_calib(self):
        ui = self.ui
        self.s.fill((22, 28, 44))
        for i in range(0, self.W, 64):
            pygame.draw.line(self.s, (28, 36, 56), (i, 0), (i, self.H))
        for i in range(0, self.H, 64):
            pygame.draw.line(self.s, (28, 36, 56), (0, i), (self.W, i))
        c = self.cal

        if c["done"]:
            if c.get("bad"):
                ui.text(ui.f_med, "That didn't take.", (250, 190, 90),
                        (self.W // 2, int(self.H * 0.40)), center=True)
                ui.text(ui.f_sml,
                        "The five points were too close together - aim at the",
                        (200, 210, 225), (self.W // 2, int(self.H * 0.50)),
                        center=True)
                ui.text(ui.f_sml, "actual corners and try again.",
                        (200, 210, 225), (self.W // 2, int(self.H * 0.56)),
                        center=True)
            else:
                ui.text(ui.f_med, "Calibration complete", (120, 230, 150),
                        (self.W // 2, int(self.H * 0.42)), center=True)
            ui.text(ui.f_sml, "Press any button to go back to the menu.",
                    (200, 210, 225), (self.W // 2, int(self.H * 0.66)),
                    center=True)
            return

        if c["step"] < 0:
            ui.text(ui.f_med, "Pointer Calibration", (240, 244, 250),
                    (self.W // 2, int(self.H * 0.30)), center=True)
            for i, ln in enumerate((
                    "You'll aim at five spots: the centre, then each corner.",
                    "Hold the Joy-Con the way you'll actually play,",
                    "point at each marker, and press A.",
                    "",
                    "Press  A  to begin.       B  cancels.")):
                ui.text(ui.f_sml, ln, (196, 208, 224),
                        (self.W // 2, int(self.H * (0.44 + i * 0.06))),
                        center=True)
            return

        label, fx, fy = CAL_POINTS[c["step"]]
        tx, ty = int(self.W * fx), int(self.H * fy)
        # every point placed so far, dimmed
        for i, (_, px_, py_) in enumerate(CAL_POINTS):
            if i >= c["step"]:
                continue
            pygame.draw.circle(self.s, (70, 120, 90),
                               (int(self.W * px_), int(self.H * py_)), 16, 3)
        pulse = 1.0 + 0.16 * math.sin(self.ui.t * 6)
        r = int(34 * pulse)
        flash = c["flash"] > 0
        col = (250, 250, 250) if flash else (250, 210, 90)
        pygame.draw.circle(self.s, col, (tx, ty), r, 5)
        pygame.draw.circle(self.s, col, (tx, ty), 6)
        pygame.draw.line(self.s, col, (tx - r - 16, ty), (tx - r + 4, ty), 4)
        pygame.draw.line(self.s, col, (tx + r - 4, ty), (tx + r + 16, ty), 4)
        pygame.draw.line(self.s, col, (tx, ty - r - 16), (tx, ty - r + 4), 4)
        pygame.draw.line(self.s, col, (tx, ty + r - 4), (tx, ty + r + 16), 4)

        ui.text(ui.f_med, "Point at %s" % label, (240, 244, 250),
                (self.W // 2, int(self.H * 0.40)), center=True)
        ui.text(ui.f_sml, "then press  A", (196, 208, 224),
                (self.W // 2, int(self.H * 0.48)), center=True)
        ui.text(ui.f_tin, "Step %d of %d        B  cancel"
                % (c["step"] + 1, len(CAL_POINTS)), (150, 165, 190),
                (self.W // 2, int(self.H * 0.90)), center=True)
        p = self.calib_pointer()
        if p is None:
            ui.text(ui.f_sml, "No Joy-Con gyro detected.", (250, 150, 130),
                    (self.W // 2, int(self.H * 0.58)), center=True)
        elif p.calibrating:
            ui.text(ui.f_tin, "settling...", (150, 165, 190),
                    (self.W // 2, int(self.H * 0.56)), center=True)

    def update_update(self, pressed, clicks):
        u = self.updater
        go = pygame.Rect(int(self.W * 0.55), int(self.H * 0.78),
                         int(self.W * 0.19), int(self.H * 0.10))
        back = pygame.Rect(int(self.W * 0.26), int(self.H * 0.78),
                           int(self.W * 0.19), int(self.H * 0.10))
        self._upd_btns = (go, back)
        if "b" in pressed:
            self.state = S_SETTINGS
            return
        for c in list(clicks) + ([tuple(self.pointer)] if "a" in pressed else []):
            if back.collidepoint(c):
                play("back", self.conf)
                self.state = S_SETTINGS
            elif go.collidepoint(c):
                play("click", self.conf)
                if u.state == "available":
                    u.install_async()
                elif u.state in ("idle", "current", "error"):
                    u.check_async()
                elif u.state == "done":
                    self.running = False

    def draw_update(self):
        ui = self.ui
        u = self.updater
        ui.text(ui.f_med, "System Update", (55, 62, 76),
                (self.W // 2, int(self.H * 0.10)), center=True)
        ui.text(ui.f_sml, "Installed:  %s" % VERSION, (110, 118, 132),
                (self.W // 2, int(self.H * 0.20)), center=True)
        url = (self.conf.get("update_url") or "").strip()
        if not url:
            for i, ln in enumerate((
                    "No update server set.",
                    "Put a manifest URL in ~/.gameking_fit.json as",
                    '"update_url": "http://brainpad:8080/gkfit.json"')):
                ui.text(ui.f_sml, ln, (120, 128, 145),
                        (self.W // 2, int(self.H * (0.34 + i * 0.07))),
                        center=True)
        else:
            head = {"checking": "Contacting the server...",
                    "available": "Version %s is available"
                                 % (u.manifest or {}).get("version", "?"),
                    "current": "This console is up to date",
                    "downloading": "Installing...",
                    "done": "Update installed",
                    "error": "Update server unreachable"}.get(
                        u.state, "Press Check to look for updates")
            ui.text(ui.f_med, head, (55, 62, 76),
                    (self.W // 2, int(self.H * 0.32)), center=True)
            if u.message:
                for i, ln in enumerate(str(u.message).split("\n")[:4]):
                    ui.text(ui.f_sml, ln[:70], (110, 118, 132),
                            (self.W // 2, int(self.H * (0.42 + i * 0.06))),
                            center=True)
            if u.state == "downloading":
                bw = int(self.W * 0.5)
                bx = self.W // 2 - bw // 2
                by = int(self.H * 0.62)
                pygame.draw.rect(self.s, (215, 220, 228), (bx, by, bw, 24),
                                 border_radius=12)
                pygame.draw.rect(self.s, (110, 190, 240),
                                 (bx, by, int(bw * u.progress), 24),
                                 border_radius=12)
        go, back = getattr(self, "_upd_btns",
                           (pygame.Rect(int(self.W * 0.55), int(self.H * 0.78),
                                        int(self.W * 0.19), int(self.H * 0.10)),
                            pygame.Rect(int(self.W * 0.26), int(self.H * 0.78),
                                        int(self.W * 0.19), int(self.H * 0.10))))
        label = {"available": "Install", "done": "Quit", "downloading": "..."}\
            .get(u.state, "Check")
        ui.button(back, "Back", self.pointer)
        ui.button(go, label, self.pointer)
        ui.badge("B", (back.x - 34, back.centery - 13))
        ui.badge("A", (go.x - 34, go.centery - 13))

    def update_fit(self, dt, pressed, clicks):
        f = self.fit
        if "b" in pressed or self.back_hit(clicks):
            play("back", self.conf)
            self.state = S_MENU
            return
        if f["phase"] == 0:
            for c in clicks:
                if self._fit_go.collidepoint(c):
                    play("click", self.conf)
                    f["phase"] = 1
                    f["t"] = 0.0
                    f["wobble"] = 0.0
        elif f["phase"] == 1:
            f["t"] += dt
            ox, oy = self.fit_bubble_offset()
            f["wobble"] += math.hypot(ox, oy) * dt
            if f["t"] >= 6.0:
                age = int(20 + min(70, f["wobble"] * 140))
                f["age"] = age
                f["phase"] = 2
                play("chime", self.conf)

    def fit_bubble_offset(self):
        """(-1..1, -1..1) bubble offset from accel, or a mouse-demo fallback."""
        a = getattr(self, "accel", None)
        if a and math.hypot(*a) > 0.3:
            ax, ay, az = a
            n = max(0.25, math.hypot(ax, ay, az))
            return max(-1, min(1, ax / n * 2.2)), max(-1, min(1, ay / n * 2.2))
        cx = (self.pointer[0] / self.W - 0.5) * 2
        cy = (self.pointer[1] / self.H - 0.5) * 2
        return cx, cy

    def handle_home(self, clicks):
        for c in clicks:
            for key, r in getattr(self, "_home_rects", {}).items():
                if r.collidepoint(c):
                    play("click", self.conf)
                    if key == "cont":
                        self.home_open = False
                    elif key == "recal":
                        self.home_open = False
                        self.start_calib()
                    elif key == "sleep":
                        try:
                            subprocess.Popen(["systemctl", "suspend"])
                        except Exception:
                            pass
                        self.home_open = False
                    elif key == "quit":
                        self.running = False

    def back_hit(self, clicks):
        r = getattr(self, "_back_r", None)
        return bool(r and any(r.collidepoint(c) for c in clicks))

    # ------------------------------------------------------------- drawing
    def draw(self):
        ui = self.ui
        self.s = self.screen
        self.s.blit(ui.bg, (0, 0))

        if self.state == S_WARN:
            self.draw_warning()
        elif self.state in (S_MENU, S_ZOOM):
            self.draw_menu()
            if self.state == S_ZOOM:
                self.draw_zoom()
        elif self.state == S_CHANNEL:
            self.draw_channel()
        elif self.state == S_SETTINGS:
            self.draw_settings()
        elif self.state == S_CALIB:
            self.draw_calib()
        elif self.state == S_FIT:
            self.draw_fit()
        elif self.state == S_UPDATE:
            self.draw_update()
        elif self.state == S_BOARD:
            self.draw_board()
        elif self.state == S_INFO:
            self.draw_info()

        if self.home_open:
            self.draw_home()
        if self.toast and time.time() < self.toast[1]:
            t = self.toast[0]
            r = pygame.Rect(0, 0, int(self.W * 0.5), int(self.H * 0.08))
            r.center = (self.W // 2, int(self.H * 0.12))
            pygame.draw.rect(self.s, (50, 56, 70), r, border_radius=14)
            ui.text(ui.f_sml, t, (245, 245, 245), r.center, center=True)
        if self.conf["chipset"]:
            self.draw_chipset()

        # pointers (drawn last) - one glove per controller, in player colours
        sway = math.sin(self.ui.t * 2.2) * 2
        lead = self.active if self.pointers else 0
        for i, p in enumerate(self.pointers):
            if i == lead:
                continue
            if not p.ok or getattr(p, "sm_x", None) is None:
                continue
            gx, gy = int(p.sm_x), int(p.sm_y)
            if p.calibrating:
                pygame.draw.circle(self.s, PLAYER_COLORS[i % 4], (gx, gy), 14, 3)
            self.s.blit(ui.glove(getattr(p, "player", i)),
                        (gx - 12, gy - 6 + sway))
        px, py = int(self.pointer[0]), int(self.pointer[1])
        lead_p = self.pointers[lead] if self.pointers else self.jcp
        if getattr(lead_p, "ok", False) and lead_p.calibrating:
            pygame.draw.circle(self.s, PLAYER_COLORS[lead % 4], (px, py), 14, 3)
        self.s.blit(ui.glove(getattr(lead_p, "player", 0)), (px - 12, py - 6 + sway))
        pygame.display.flip()

    def draw_warning(self):
        ui = self.ui
        self.s.fill((10, 10, 12))
        panel = pygame.Rect(int(self.W * 0.08), int(self.H * 0.08),
                            int(self.W * 0.84), int(self.H * 0.84))
        pygame.draw.rect(self.s, (245, 245, 245), panel, border_radius=8)
        pygame.draw.rect(self.s, (120, 120, 120), panel, 3, border_radius=8)
        y = panel.y + int(self.H * 0.07)
        ui.text(ui.f_big, "! WARNING - GET FIT", (180, 30, 30),
                (self.W // 2, y), center=True)
        lines = [
            "Before play begins, fasten any wrist strap you can find",
            "and clear the room of lamps, pets, and relatives.",
            "",
            "The Game King accepts no liability for televisions",
            "struck by an enthusiastic Joy-Con.",
            "",
            "Rest the pointer remote on something while it calibrates.",
        ]
        yy = y + int(self.H * 0.12)
        for ln in lines:
            ui.text(ui.f_sml, ln, (40, 40, 44), (self.W // 2, yy), center=True)
            yy += int(self.H * 0.055)
        cal = self.jcp.ok and self.jcp.calibrating
        if cal:
            msg = "Calibrating pointer..."
        elif self.jcp.ok and self.jcp.cal_status == "gaveup":
            msg = "Still twitchy - set it down, then press A."
        else:
            msg = "Press  A  to continue."
        blink = int(self.ui.t * 2) % 2 == 0
        if blink or cal:
            ui.text(ui.f_med, msg, (60, 60, 70),
                    (self.W // 2, panel.bottom - int(self.H * 0.12)), center=True)

    def draw_menu(self):
        ui = self.ui
        for ch in self.channels:
            if not (-ch.rect.w < ch.rect.x < self.W):
                continue
            r = ch.rect
            if ch.hover > 0.01:
                grow = 1.0 + 0.07 * ch.hover * (1 + 0.15 * math.sin(self.ui.t * 9))
                r = r.inflate(int(r.w * (grow - 1)), int(r.h * (grow - 1)))
            pygame.draw.rect(self.s, (168, 176, 188), r.move(0, 4), border_radius=16)
            if ch.kind == "empty":
                pygame.draw.rect(self.s, (216, 222, 230), r, border_radius=16)
                pygame.draw.rect(self.s, (188, 195, 206), r, 3, border_radius=16)
            else:
                b = ui.banner_for(ch)
                bs = pygame.transform.smoothscale(b, (r.w, r.h))
                self.s.blit(bs, r)
                pygame.draw.rect(self.s, (255, 255, 255), r, 4, border_radius=16)
                pygame.draw.rect(self.s, (150, 158, 170), r, 1, border_radius=16)
        # page arrows
        if self.page > 0:
            pygame.draw.polygon(self.s, (140, 150, 165),
                                [(int(self.W * 0.028), int(self.H * 0.42)),
                                 (int(self.W * 0.006), int(self.H * 0.46)),
                                 (int(self.W * 0.028), int(self.H * 0.50))])
        if self.page < self.pages - 1:
            pygame.draw.polygon(self.s, (140, 150, 165),
                                [(int(self.W * 0.972), int(self.H * 0.42)),
                                 (int(self.W * 0.994), int(self.H * 0.46)),
                                 (int(self.W * 0.972), int(self.H * 0.50))])
        self.draw_bar()
        if self.hover_ch:
            ui.text(ui.f_sml, self.hover_ch.title, (70, 78, 92),
                    (self.W // 2, int(self.H * 0.055)), center=True)

    def draw_bar(self):
        ui = self.ui
        bar_h = int(self.H * 0.215)
        bar = pygame.Rect(-20, self.H - bar_h, self.W + 40, bar_h + 20)
        pygame.draw.rect(self.s, (205, 212, 222), bar, border_radius=26)
        pygame.draw.rect(self.s, (168, 176, 188), bar, 3, border_radius=26)
        pygame.draw.line(self.s, (232, 236, 242), (0, bar.y + 4), (self.W, bar.y + 4), 3)

        now = datetime.datetime.now()
        fmt = "%H:%M" if self.conf["clock_24h"] else "%I:%M"
        hhmm = now.strftime(fmt)
        if int(time.time()) % 2 == 0:
            hhmm = hhmm.replace(":", " ")
        ui.text(ui.f_clock, hhmm, (110, 118, 132),
                (self.W // 2, bar.y + int(bar_h * 0.38)), center=True,
                shadow=(228, 232, 238))
        ui.text(ui.f_sml, now.strftime("%a %d/%m"), (130, 138, 152),
                (self.W // 2, bar.y + int(bar_h * 0.75)), center=True)

        rad = int(bar_h * 0.34)
        self.bar_gear = pygame.Rect(0, 0, rad * 2, rad * 2)
        self.bar_gear.center = (int(self.W * 0.12), bar.y + bar_h // 2)
        self.bar_mail = pygame.Rect(0, 0, rad * 2, rad * 2)
        self.bar_mail.center = (int(self.W * 0.88), bar.y + bar_h // 2)
        for r, ic in ((self.bar_gear, "gear"), (self.bar_mail, "mail")):
            over = r.collidepoint(self.pointer)
            pygame.draw.circle(self.s, (150, 158, 170), (r.centerx, r.centery + 3), rad)
            pygame.draw.circle(self.s, (128, 190, 235) if over else (238, 241, 246),
                               r.center, rad)
            pygame.draw.circle(self.s, (120, 128, 142), r.center, rad, 3)
            if ic == "gear":
                for k in range(8):
                    a = k * math.pi / 4 + self.ui.t * 0.4
                    x1 = r.centerx + math.cos(a) * rad * 0.42
                    y1 = r.centery + math.sin(a) * rad * 0.42
                    x2 = r.centerx + math.cos(a) * rad * 0.66
                    y2 = r.centery + math.sin(a) * rad * 0.66
                    pygame.draw.line(self.s, (90, 98, 112), (x1, y1), (x2, y2), 5)
                pygame.draw.circle(self.s, (90, 98, 112), r.center, int(rad * 0.30), 4)
            else:
                m = pygame.Rect(0, 0, int(rad * 1.1), int(rad * 0.75))
                m.center = r.center
                pygame.draw.rect(self.s, (90, 98, 112), m, 3, border_radius=4)
                pygame.draw.lines(self.s, (90, 98, 112), False,
                                  [(m.left + 2, m.top + 2), m.center,
                                   (m.right - 2, m.top + 2)], 3)

    def draw_zoom(self):
        t = self.zoom_t
        e = 1 - (1 - t) ** 3
        src = self.zoom_src
        dst = pygame.Rect(0, 0, self.W, self.H)
        r = pygame.Rect(
            int(src.x + (dst.x - src.x) * e),
            int(src.y + (dst.y - src.y) * e),
            int(src.w + (dst.w - src.w) * e),
            int(src.h + (dst.h - src.h) * e))
        b = self.ui.banner_for(self.zoom_ch)
        self.s.blit(pygame.transform.smoothscale(b, (r.w, r.h)), r)
        pygame.draw.rect(self.s, (255, 255, 255), r, 5, border_radius=8)

    def draw_channel(self):
        ui = self.ui
        ch = self.zoom_ch
        b = ui.banner_for(ch)
        big = pygame.Rect(int(self.W * 0.18), int(self.H * 0.10),
                          int(self.W * 0.64), int(self.H * 0.48))
        self.s.blit(pygame.transform.smoothscale(b, (big.w, big.h)), big)
        pygame.draw.rect(self.s, (255, 255, 255), big, 5, border_radius=10)
        sub = {"cart": "Reads GAMEKING-labelled disks & Paks",
               "fit": "Daily balance test. Very scientific.",
               "board": "Play history & messages",
               "settings": "Pointer, pads, clock, sound",
               "info": VERSION + "  ·  " + CODENAME}.get(ch.kind, "GamePak title")
        ui.text(ui.f_med, ch.title, (55, 62, 76), (self.W // 2, int(self.H * 0.66)), center=True)
        ui.text(ui.f_sml, sub, (110, 118, 132), (self.W // 2, int(self.H * 0.72)), center=True)
        start_r, back_r = getattr(self, "_chan_btns",
                                  (pygame.Rect(int(self.W * 0.56), int(self.H * 0.80),
                                               int(self.W * 0.18), int(self.H * 0.09)),
                                   pygame.Rect(int(self.W * 0.26), int(self.H * 0.80),
                                               int(self.W * 0.18), int(self.H * 0.09))))
        ui.button(back_r, "Back", self.pointer)
        ui.button(start_r, "Start", self.pointer)
        ui.badge("B", (back_r.x - 34, back_r.centery - 13))
        ui.badge("A", (start_r.x - 34, start_r.centery - 13))

    def draw_settings(self):
        ui = self.ui
        ui.text(ui.f_med, "Game King Settings", (55, 62, 76),
                (self.W // 2, int(self.H * 0.08)), center=True)
        rows = [
            ("sens", "Pointer Sensitivity", "%.1f" % self.conf["sens"]),
            ("invert_x", "Invert Pointer X", "On" if self.conf["invert_x"] else "Off"),
            ("invert_y", "Invert Pointer Y", "On" if self.conf["invert_y"] else "Off"),
            ("gyro_map", "Gyro Axes", "%d: %s/%s" % (
                (self.conf.get("gyro_map", 0) % len(GYRO_MAPS)) + 1,
                GYRO_MAPS[self.conf.get("gyro_map", 0) % len(GYRO_MAPS)][0].upper(),
                GYRO_MAPS[self.conf.get("gyro_map", 0) % len(GYRO_MAPS)][1].upper())),
            ("pad_layout", "Pad Layout", self.conf["pad_layout"].title()),
            ("clock_24h", "24-Hour Clock", "On" if self.conf["clock_24h"] else "Off"),
            ("sound", "Sound", "On" if self.conf["sound"] else "Off"),
            ("chipset", "Chipset Overlay", "On" if self.conf["chipset"] else "Off"),
            ("recal", "Calibrate Pointer", ""),
            ("clearcal", "Clear Calibration",
             "set" if self.conf.get("cal") else "none"),
            ("update", "System Update", {
                "available": "READY", "checking": "checking...",
                "downloading": "installing...", "done": "restart needed",
                "error": "unreachable", "current": "up to date",
            }.get(self.updater.state, "check")),
            ("back", "Save & Back", ""),
        ]
        self._set_rects = {}
        rw = int(self.W * 0.40)
        rh = int(self.H * 0.085)
        x0 = int(self.W * 0.065)
        x1 = int(self.W * 0.535)
        y0 = int(self.H * 0.15)
        for i, (key, label, val) in enumerate(rows):
            col = i % 2
            row = i // 2
            r = pygame.Rect(x1 if col else x0, y0 + row * int(rh * 1.22), rw, rh)
            txt = label if not val else "%s:  %s" % (label, val)
            if key == "sens":
                minus = pygame.Rect(r.right - rh * 2 - 8, r.y, rh, rh)
                plus = pygame.Rect(r.right - rh, r.y, rh, rh)
                ui.button(pygame.Rect(r.x, r.y, r.w - rh * 2 - 16, rh), txt,
                          self.pointer, small=True)
                ui.button(minus, "-", self.pointer, small=True)
                ui.button(plus, "+", self.pointer, small=True)
                self._set_rects["sens-"] = minus
                self._set_rects["sens+"] = plus
            else:
                ui.button(r, txt, self.pointer, small=True)
                self._set_rects[key] = r
        live = [p for p in self.pointers if getattr(p, "ok", False)]
        if live:
            who = "   ".join("P%d: Joy-Con (%s)" % (getattr(p, "player", i) + 1,
                                                    p.side)
                             for i, p in enumerate(live))
        else:
            who = "No Joy-Con - mouse / touch / pad pointer active"
        if live:
            widths = []
            for i, p in enumerate(live):
                lab = "P%d: Joy-Con (%s)" % (getattr(p, "player", i) + 1, p.side)
                widths.append((lab, ui.f_tin.size(lab + "   ")[0],
                               PLAYER_COLORS[getattr(p, "player", i) % 4]))
            total = sum(w for _, w, _ in widths)
            x = self.W // 2 - total // 2
            for lab, w, col in widths:
                ui.text(ui.f_tin, lab, col, (x, int(self.H * 0.925)))
                x += w
        else:
            ui.text(ui.f_tin, who, (110, 118, 132),
                    (self.W // 2, int(self.H * 0.93)), center=True)

    def draw_fit(self):
        ui = self.ui
        f = self.fit
        ui.text(ui.f_med, "GK FIT  -  Balance Test", (55, 110, 70),
                (self.W // 2, int(self.H * 0.09)), center=True)
        cx, cy = self.W // 2, int(self.H * 0.48)
        R = int(self.H * 0.28)
        pygame.draw.circle(self.s, (225, 232, 226), (cx, cy), R)
        pygame.draw.circle(self.s, (120, 170, 130), (cx, cy), R, 4)
        pygame.draw.circle(self.s, (120, 170, 130), (cx, cy), int(R * 0.25), 2)
        ox, oy = self.fit_bubble_offset()
        bx = cx + int(ox * R * 0.8)
        by = cy + int(oy * R * 0.8)
        pygame.draw.circle(self.s, (90, 160, 235), (bx, by), int(R * 0.12))
        pygame.draw.circle(self.s, (50, 90, 150), (bx, by), int(R * 0.12), 3)

        self._back_r = pygame.Rect(int(self.W * 0.05), int(self.H * 0.86),
                                   int(self.W * 0.16), int(self.H * 0.08))
        ui.button(self._back_r, "Back", self.pointer, small=True)
        self._fit_go = pygame.Rect(int(self.W * 0.79), int(self.H * 0.86),
                                   int(self.W * 0.16), int(self.H * 0.08))
        if f["phase"] == 0:
            ui.button(self._fit_go, "Begin", self.pointer, small=True)
            ui.text(ui.f_sml, "Hold the Joy-Con flat. Keep the bubble centred for 6 seconds.",
                    (90, 98, 112), (self.W // 2, int(self.H * 0.80)), center=True)
        elif f["phase"] == 1:
            ui.text(ui.f_med, "%.1f" % max(0, 6.0 - f["t"]), (55, 110, 70),
                    (cx, cy - R - 30), center=True)
        else:
            ui.text(ui.f_med, "Your Game King Fit Age:  %d" % f["age"],
                    (55, 62, 76), (self.W // 2, int(self.H * 0.80)), center=True)
            quip = "Certified Partridge." if f["age"] < 35 else \
                   "Acceptable for a Littleprosessor." if f["age"] < 60 else \
                   "Please consult the KDS manual."
            ui.text(ui.f_sml, quip, (110, 118, 132),
                    (self.W // 2, int(self.H * 0.86)), center=True)
            ui.button(self._fit_go, "Again", self.pointer, small=True)

    def draw_board(self):
        ui = self.ui
        ui.text(ui.f_med, "King Board", (150, 110, 40),
                (self.W // 2, int(self.H * 0.08)), center=True)
        board = load_board()
        y = int(self.H * 0.17)
        today = datetime.date.today().strftime("%A %d %B")
        ui.text(ui.f_sml, today + "  -  no mail. Imagine that.", (110, 118, 132),
                (self.W // 2, y), center=True)
        y += int(self.H * 0.07)
        if not board:
            ui.text(ui.f_sml, "No play history yet. Get launching.", (130, 138, 152),
                    (self.W // 2, y + 30), center=True)
        for entry in board[:8]:
            ts = datetime.datetime.fromtimestamp(entry["t"]).strftime("%d/%m %H:%M")
            r = pygame.Rect(int(self.W * 0.18), y, int(self.W * 0.64), int(self.H * 0.065))
            pygame.draw.rect(self.s, (248, 244, 230), r, border_radius=10)
            pygame.draw.rect(self.s, (210, 195, 150), r, 2, border_radius=10)
            ui.text(ui.f_tin, "%s   played  %s" % (ts, entry["title"]),
                    (95, 85, 55), (r.x + 16, r.centery - 9))
            y += int(self.H * 0.078)
        self._back_r = pygame.Rect(int(self.W * 0.05), int(self.H * 0.86),
                                   int(self.W * 0.16), int(self.H * 0.08))
        ui.button(self._back_r, "Back", self.pointer, small=True)

    def draw_info(self):
        ui = self.ui
        ui.text(ui.f_big, "GAME KING", (80, 90, 200),
                (self.W // 2, int(self.H * 0.16)), center=True)
        lines = [
            VERSION + '  "' + CODENAME + '"',
            "KingdomCom Ltd  /  British Innovation Electrical",
            "Littleprosessor 7AFF3-2  -  a TCF MacroMicro design",
            "RTR-E2 certified  -  Grassland Republic",
            "",
            "Media: GDS Disk  -  GamePak  -  GKS Partridge",
            "%d titles installed  -  %d page(s)" % (
                sum(1 for c in self.channels if c.kind == "game"), self.pages),
        ]
        y = int(self.H * 0.30)
        for ln in lines:
            ui.text(ui.f_sml, ln, (70, 78, 92), (self.W // 2, y), center=True)
            y += int(self.H * 0.065)
        self._back_r = pygame.Rect(int(self.W * 0.05), int(self.H * 0.86),
                                   int(self.W * 0.16), int(self.H * 0.08))
        ui.button(self._back_r, "Back", self.pointer, small=True)

    def draw_home(self):
        ui = self.ui
        veil = pygame.Surface((self.W, self.H), pygame.SRCALPHA)
        veil.fill((15, 20, 40, 170))
        self.s.blit(veil, (0, 0))
        panel = pygame.Rect(0, 0, int(self.W * 0.44), int(self.H * 0.62))
        panel.center = (self.W // 2, self.H // 2)
        pygame.draw.rect(self.s, (235, 238, 244), panel, border_radius=20)
        pygame.draw.rect(self.s, (90, 100, 120), panel, 3, border_radius=20)
        ui.text(ui.f_med, "HOME Menu", (55, 62, 76),
                (panel.centerx, panel.y + int(self.H * 0.07)), center=True)
        labels = [("cont", "Continue"), ("recal", "Calibrate Pointer"),
                  ("sleep", "Sleep"), ("quit", "Quit to Desktop")]
        self._home_rects = {}
        y = panel.y + int(self.H * 0.14)
        for key, lab in labels:
            r = pygame.Rect(0, 0, int(panel.w * 0.8), int(self.H * 0.085))
            r.centerx = panel.centerx
            r.y = y
            ui.button(r, lab, self.pointer, small=True)
            self._home_rects[key] = r
            y += int(self.H * 0.108)

    def draw_chipset(self):
        ui = self.ui
        col = (30, 200, 90)
        L = [
            ["%s '%s'" % (VERSION, CODENAME),
             "STATE %d  PAGE %d/%d" % (self.state, self.page + 1, self.pages),
             "FPS %5.1f  FRM %d" % (self.fps, self.frame)],
            ["PTR %4d,%4d" % (self.pointer[0], self.pointer[1]),
             "PADS %d/%d  ACT P%d" % (
                 sum(1 for p in self.pointers if getattr(p, "ok", False)),
                 len(self.pointers), self.active + 1),
             "JC:%s/%s CAL:%d" % (self.jcp.side or "--",
                                  getattr(self.jcp, "backend", "--")[:2],
                                  self.jcp.calibrating),
             "AIM %+6.1f %+6.1f  SMP %d" % (
                 getattr(self.jcp, "yaw", 0.0), getattr(self.jcp, "pitch", 0.0),
                 getattr(self.jcp, "_samples", 0)),
             "SENS %.1f %s%s%s" % (self.conf["sens"],
                                   "IX" if self.conf["invert_x"] else "..",
                                   "IY" if self.conf["invert_y"] else "..",
                                   "SW" if self.conf["swap_axes"] else "..")],
            ["CH %d  GAMES %d" % (len(self.channels),
                                  sum(1 for c in self.channels if c.kind == "game")),
             "SND %s  PAD %s" % ("OK" if SOUND_OK else "--",
                                 "OK" if self.pad.js else "--"),
             time.strftime("%H:%M:%S")],
            ["LITTLEPROSESSOR 7AFF3-2",
             "MONKING PILLOW LINEAGE",
             "BUS OK  RTR-E2"],
        ]
        corners = [(8, 8), (self.W - 8, 8), (8, self.H - 8), (self.W - 8, self.H - 8)]
        for ci, block in enumerate(L):
            x, y = corners[ci]
            right = ci in (1, 3)
            bottom = ci in (2, 3)
            lines = block
            for li, ln in enumerate(lines):
                t = ui.f_tin.render(ln, True, col)
                tx = x - t.get_width() if right else x
                ty = (y - (len(lines) - li) * 20) if bottom else (y + li * 20)
                self.s.blit(t, (tx, ty))

    # ------------------------------------------------------------- loop
    def run(self):
        script = 0
        while self.running:
            dt = min(0.05, self.clock.tick(60) / 1000.0)
            self.fps = self.clock.get_fps()
            self.frame += 1
            self.update(dt)
            self.draw()

            if SELFTEST:
                script += 1
                seq = {30: S_MENU, 60: S_SETTINGS, 90: S_FIT, 120: S_BOARD,
                       150: S_INFO, 175: S_MENU}
                if script in seq:
                    self.state = seq[script]
                    if self.state == S_FIT:
                        self.fit = {"phase": 1, "t": 0.0, "wobble": 0.0, "age": None}
                if script == 100:
                    self.home_open = True
                if script == 110:
                    self.home_open = False
                if script == 62:
                    self.conf["chipset"] = True
                self.pointer = [(script * 9) % self.W, (script * 5) % self.H]
                if script == 45 and self.channels:
                    self.zoom_ch = self.channels[0]
                    self.zoom_src = self.channels[0].rect.copy() or pygame.Rect(10, 10, 100, 60)
                    self.zoom_t = 0.0
                    self.state = S_ZOOM
                if script >= 200:
                    print("SELFTEST OK  (%d channels, %d pages)"
                          % (len(self.channels), self.pages))
                    self.running = False
        save_conf(self.conf)
        pygame.quit()


if __name__ == "__main__":
    App().run()
