# room.py -- the studio on the badge. A 320x240 one-point-perspective view of the same room
# the website shows, driven by the same deterministic simulation (ported from the JS in
# site/wes/index.php) so the badge on your desk and the browser agree on where everyone is.
#
# The backdrop (walls, shelves, neon, desk, door) is drawn once into an image. Each frame only
# the rectangles that held characters last frame are restored from it before redrawing.

import math
import json
import gc
from picovector import color, shape, vec2

W, H = 320, 240
VP_X, VP_Y, NEAR_Y, CEIL, K = 160, 120, 240, 240, 1.695      # back wall lands at 37% scale
WALK = (-0.82, 0.82, 0.06, 0.9)                              # x0, x1, z0, z1
DOOR = (0.14, 0.30, 120)                                     # z0, z1, height (near px)
DESK = (-0.5, 0.5, 0.58, 0.09, 32)                           # x0, x1, z, depth, height
SEATS = ((-0.28, 0.74), (0.28, 0.74), (0.76, 0.34), (0.76, 0.5), (-0.74, 0.42), (-0.74, 0.56))
AUTO_P, AUTO_WANDER, AUTO_WALK = 120, 45, 5     # auto mood: wander, walk to a chair, sit
ENTRY_S = 3
WALK_SPEED, Z_WEIGHT, BLEND_S = 0.45, 2.4, 2.5  # manual control, mirrored in the JS
# the machines: (game, label, stand x, stand z, facing). geometry is in build_backdrop
STATIONS = (("pinball", "pinball", -0.78, 0.80, -1), ("runner", "WES RUN", 0.62, 0.80, 1), ("echo", "ECHO", 0.80, 0.80, 1))
GAME_LABELS = {g: l for g, l, _x, _z, _d in STATIONS}
ACT_S = 90
M32 = 0xffffffff

_deps = {}
_bd = None
_sprites = {}             # (lookjson, R, idx, d, mood) -> image, bounded
_spr_order = []
SPR_MAX = 48
SPR_N = 8
_last_rects = []
_desk_rect = None

def setup(**deps):
    """deps: draw_guy, guy_height, draw_bubble, font_small, font_big, image, screen"""
    _deps.update(deps)

def sc(z):
    return 1.0 / (1.0 + z * K)

def proj(x, z, h=0.0):
    s = sc(z)
    return (VP_X + x * 160.0 * s, VP_Y + (NEAR_Y - VP_Y) * s - h * s, s)

# ---------------------------------------------------------------- simulation (mirrors the JS)
def hash32(a, b):
    h = (a ^ 0x9e3779b9) & M32
    h = ((h ^ ((b + 0x7f4a7c15) & M32)) * 0x85ebca6b) & M32
    h ^= h >> 13
    h = (h * 0xc2b2ae35) & M32
    h ^= h >> 16
    return h & M32

def seed_of(sid):
    s = 0
    for ch in sid:
        s = (s * 31 + ord(ch)) & M32
    return s

def waypoint(seed, n):
    if n <= 0:
        return (-0.86, (DOOR[0] + DOOR[1]) / 2)
    h = hash32(seed, n)
    x = WALK[0] + (h & 0xffff) / 65535 * (WALK[1] - WALK[0])
    z = WALK[2] + ((h >> 16) & 0xffff) / 65535 * (WALK[3] - WALK[2])
    if DESK[0] - 0.12 < x < DESK[1] + 0.12 and DESK[2] - 0.1 < z < DESK[2] + DESK[3] + 0.1:
        z = DESK[2] - 0.14 if (h & 1) else DESK[2] + DESK[3] + 0.12
    return (x, z)

def ease(f):
    return 2 * f * f if f < 0.5 else 1 - ((-2 * f + 2) ** 2) / 2

def in_desk(x, z):
    return DESK[0] - 0.12 < x < DESK[1] + 0.12 and DESK[2] - 0.1 < z < DESK[2] + DESK[3] + 0.1

def station_near(x, z):
    """The machine he is standing at, if any."""
    for st in STATIONS:
        if abs(x - st[2]) < 0.09 and abs(z - st[3]) < 0.09:
            return st
    return None

def walk_at(av, el):
    """Manual control: where he is `el` seconds after the move was posted. Returns
    (x, z, walking, from, to, sitting, dir)."""
    px, pz, tx, tz = av["px"], av["pz"], av["tx"], av["tz"]
    dx, dz = tx - px, tz - pz
    dist = math.sqrt(dx * dx + dz * dz * Z_WEIGHT * Z_WEIGHT)
    dur = dist / WALK_SPEED
    f = 1.0 if dur <= 0 else max(0.0, min(1.0, el / dur))
    d = (1 if dx > 0 else -1) if abs(dx) > 0.001 else (-1 if tx < 0 else 1)
    return (px + dx * f, pz + dz * f, f < 1, (px, pz), (tx, tz), False, d)

def walk_el(av, now_i, now_f):
    """Seconds since the move was posted, kept exact: the server sends pt as int seconds
    and pf as the fraction, because a 32-bit float cannot hold a Unix time with millis."""
    return (now_i - av["pt"]) + (now_f - (av.get("pf") or 0.0))

def _seg(m):
    return 3 if m == "zoom" else (16 if m == "chill" else 7)

def _wf(m):
    return 0.7 if m == "zoom" else (0.25 if m == "chill" else 0.6)

def simulate(av, now_i, now_f, present_s):
    """now_i: server time as an exact int, now_f: fraction of a second. MicroPython floats
    are 32-bit, so a Unix timestamp as a float only has ~128 s resolution; keep it split."""
    seed = seed_of(av["id"])
    t = (now_i - av["entered"]) + now_f
    left = av.get("left")
    if left is None and av["seen"] + present_s < now_i:
        left = av["seen"] + present_s
    mood = av.get("mood") or "wander"

    def wander_at(u, SEG, WF, n0=1):
        n = n0 + int(u // SEG)
        f = (u % SEG) / SEG
        a, b = waypoint(seed, n), waypoint(seed, n + 1)
        if f < WF:
            e = ease(f / WF)
            return (a[0] + (b[0] - a[0]) * e, a[1] + (b[1] - a[1]) * e, True, a, b, False)
        return (b[0], b[1], False, a, b, False)

    manual = av.get("pt") is not None and av.get("tx") is not None

    def pos_at(tt, ti, tf):
        # tt: seconds since he entered; (ti, tf) the same absolute moment as int + fraction.
        # returns (x, z, walking, from, to, sitting[, dir])
        if tt < ENTRY_S:
            a, b = waypoint(seed, 0), waypoint(seed, 1)
            f = ease(max(0.0, tt) / ENTRY_S)
            return (a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, True, a, b, False)
        if mood == "walk" and manual:
            return walk_at(av, walk_el(av, ti, tf))
        p = auto_at(tt)
        if manual and av.get("mt") is not None:
            elb = (ti - av["mt"]) + tf
            if 0 <= elb < BLEND_S:          # blend out of manual control into the clockwork
                a = walk_at(av, walk_el(av, av["mt"], 0.0))
                e = ease(elb / BLEND_S)
                return (a[0] + (p[0] - a[0]) * e, a[1] + (p[1] - a[1]) * e, True, (a[0], a[1]), (p[0], p[1]), False)
        return p

    def auto_at(tt):
        if mood in ("dance", "sleep"):
            b = waypoint(seed, 1)
            return (b[0], b[1], False, b, b, False)
        u = tt - ENTRY_S
        if mood == "auto":
            c = int(u // AUTO_P)
            ph = u % AUTO_P
            seat = SEATS[hash32(seed, 4242) % len(SEATS)]
            n0 = 1 + c * 8
            if ph < AUTO_WANDER:
                return wander_at(ph, 7, 0.6, n0)
            at = wander_at(AUTO_WANDER, 7, 0.6, n0)
            if ph < AUTO_WANDER + AUTO_WALK:
                e = ease((ph - AUTO_WANDER) / AUTO_WALK)
                return (at[0] + (seat[0] - at[0]) * e, at[1] + (seat[1] - at[1]) * e, True, (at[0], at[1]), seat, False)
            return (seat[0], seat[1], False, (at[0], at[1]), seat, True)
        return wander_at(u, _seg(mood), _wf(mood))

    alpha, gone, leaving = 1.0, False, False
    if left is not None:
        tl = (now_i - left) + now_f
        at = pos_at(max(0.0, float(left - av["entered"])), left, 0.0)
        door = waypoint(seed, 0)
        f = min(1.0, tl / 3)
        e = ease(f)
        p = (at[0] + (door[0] - at[0]) * e, at[1] + (door[1] - at[1]) * e, f < 1, (at[0], at[1]), door, False)
        leaving = True
        if tl > 3:
            alpha = max(0.0, 1 - (tl - 3) / 0.6)
        if tl > 3.8:
            gone = True
    else:
        p = pos_at(t, now_i, now_f)
    if len(p) > 6:
        d = p[6]
    elif abs(p[4][0] - p[3][0]) < 0.001:
        d = -1 if p[0] < 0 else 1
    else:
        d = 1 if p[4][0] >= p[3][0] else -1
    sx, sy, s = proj(p[0], p[1])
    return {"x": sx, "y": sy, "z": p[1], "s": s, "dir": d, "walking": p[2], "alpha": alpha, "sitting": p[5],
            "gone": gone, "R": 9.5 * s, "leaving": leaving, "entering": t < ENTRY_S and left is None, "t": t,
            "rx": p[0], "rz": p[1]}

def room_pos(av, now_i, now_f, present_s):
    """Where he is right now in room coordinates (for the badge's own manual control)."""
    sm = simulate(av, now_i, now_f, present_s)
    return (sm["rx"], sm["rz"])

def playing(av, now_i):
    act = av.get("act")
    if act and av.get("act_t") is not None and now_i - av["act_t"] < ACT_S:
        return act
    return None

def ambient(av, now_i):
    say = av.get("sayings") or []
    if not say:
        return None
    b = now_i // 20
    h = hash32(seed_of(av["id"]), b ^ 0x5bd1e995)
    if h % 3 != 0:
        return None
    start = b * 20 + ((h >> 16) % 12)
    if now_i < start or now_i > start + 6:
        return None
    return say[(h >> 8) % len(say)]

# ---------------------------------------------------------------- backdrop
def _quad(img, a, b, c, d):
    img.shape(shape.custom([vec2(a[0], a[1]), vec2(b[0], b[1]), vec2(c[0], c[1]), vec2(d[0], d[1])]))

def _wall_rect(img, x0, x1, h0, h1):
    _quad(img, proj(x0, 1, h1), proj(x1, 1, h1), proj(x1, 1, h0), proj(x0, 1, h0))

def _side_rect(img, x, z0, z1, h0, h1):
    _quad(img, proj(x, z0, h1), proj(x, z1, h1), proj(x, z1, h0), proj(x, z0, h0))

def _box(img, x0, x1, z0, z1, h, top, front, side):
    img.pen = top
    _quad(img, proj(x0, z1, h), proj(x1, z1, h), proj(x1, z0, h), proj(x0, z0, h))
    img.pen = front
    _quad(img, proj(x0, z0, h), proj(x1, z0, h), proj(x1, z0, 0), proj(x0, z0, 0))
    cx = (x0 + x1) / 2
    img.pen = side
    if cx > 0.02:
        _quad(img, proj(x0, z0, h), proj(x0, z1, h), proj(x0, z1, 0), proj(x0, z0, 0))
    elif cx < -0.02:
        _quad(img, proj(x1, z0, h), proj(x1, z1, h), proj(x1, z1, 0), proj(x1, z0, 0))

def _glow(img, x, y, r, rgb, a0=120):
    for i in range(4, 0, -1):
        img.pen = color.rgb(rgb[0], rgb[1], rgb[2], int(a0 * (5 - i) / 8))
        img.circle(int(x), int(y), int(r * i / 4))

def _cubby_item(img, kind, x0, x1, h0, h1):
    cx = (x0 + x1) / 2
    if kind == 0:       # plant
        img.pen = color.rgb(160, 95, 50)
        _wall_rect(img, cx - 0.035, cx + 0.035, h0, h0 + 14)
        img.pen = color.rgb(70, 150, 80)
        for dx, dh, r in ((-0.03, 20, 5), (0.03, 24, 6), (0, 34, 5)):
            p = proj(cx + dx, 1, h0 + dh)
            img.circle(int(p[0]), int(p[1]), max(1, int(r * p[2] * 1.3)))
    elif kind == 1:     # plush (orange, not pink)
        p = proj(cx, 1, h0 + 18)
        img.pen = color.rgb(255, 150, 60)
        img.circle(int(p[0]), int(p[1]), max(2, int(13 * p[2])))
        img.circle(int(p[0] - 9 * p[2]), int(p[1] - 11 * p[2]), max(1, int(5 * p[2])))
        img.circle(int(p[0] + 9 * p[2]), int(p[1] - 11 * p[2]), max(1, int(5 * p[2])))
    elif kind == 2:     # gold plaque
        img.pen = color.rgb(200, 165, 70)
        _wall_rect(img, cx - 0.07, cx + 0.07, h0 + 8, h0 + 38)
        img.pen = color.rgb(120, 95, 30)
        _wall_rect(img, cx - 0.05, cx + 0.05, h0 + 14, h0 + 32)
    elif kind == 3:     # radio
        img.pen = color.rgb(200, 90, 40)
        _wall_rect(img, cx - 0.08, cx + 0.08, h0, h0 + 28)
        img.pen = color.rgb(230, 195, 140)
        _wall_rect(img, cx - 0.06, cx + 0.02, h0 + 10, h0 + 22)
    elif kind == 4:     # books
        cols = ((230, 170, 50), (120, 120, 130), (240, 120, 40), (200, 200, 200), (240, 200, 70))
        for i in range(5):
            img.pen = color.rgb(*cols[i])
            _wall_rect(img, x0 + 0.012 + i * 0.03, x0 + 0.038 + i * 0.03, h0, h0 + 24 + (i * 37) % 10)
    elif kind == 5:     # lamp
        p = proj(cx, 1, h0 + 20)
        _glow(img, p[0], p[1], 34 * p[2], (255, 170, 70), 160)
        img.pen = color.rgb(230, 180, 100)
        _wall_rect(img, cx - 0.03, cx + 0.03, h0 + 12, h0 + 30)
        img.pen = color.rgb(90, 60, 35)
        _wall_rect(img, cx - 0.012, cx + 0.012, h0, h0 + 13)
    elif kind == 6:     # gold record
        p = proj(cx, 1, h0 + 24)
        img.pen = color.rgb(20, 20, 22)
        img.circle(int(p[0]), int(p[1]), max(2, int(17 * p[2])))
        img.pen = color.rgb(212, 165, 58)
        img.circle(int(p[0]), int(p[1]), max(1, int(6 * p[2])))
    else:               # trophy
        img.pen = color.rgb(217, 178, 74)
        _wall_rect(img, cx - 0.02, cx + 0.02, h0 + 16, h0 + 40)
        _wall_rect(img, cx - 0.05, cx + 0.05, h0, h0 + 9)
        p = proj(cx, 1, h0 + 42)
        img.circle(int(p[0]), int(p[1]), max(1, int(5 * p[2])))

CUBBY_KINDS = ((0, 4, 5, 1, 6, 2), (7, 3, 0, 0, 5, 4), (2, 5, 1, 6, 3, 7))

def build_backdrop():
    """Draw the studio once. ~0.5 s, happens the first time the room view opens."""
    global _bd, _desk_rect
    image = _deps["image"]
    img = image(W, H)
    img.antialias = img.X2
    img.pen = color.rgb(12, 12, 14)
    img.clear()
    img.pen = color.rgb(22, 22, 26)                                  # ceiling
    _quad(img, proj(-1, 1, CEIL), proj(1, 1, CEIL), proj(1, 0, CEIL), proj(-1, 0, CEIL))
    img.pen = color.rgb(28, 29, 34)                                  # back wall
    _wall_rect(img, -1, 1, 0, CEIL)
    img.pen = color.rgb(30, 26, 24)                                  # left wall
    _side_rect(img, -1, 0, 1, 0, CEIL)
    img.pen = color.rgb(26, 26, 30)                                  # right wall
    _side_rect(img, 1, 0, 1, 0, CEIL)
    pl = proj(-1, 0.55, 105)                                         # warm washes on both walls
    _glow(img, pl[0], pl[1], 90, (255, 150, 50), 90)
    pr = proj(1, 0.7, 80)
    _glow(img, pr[0], pr[1], 80, (255, 150, 60), 70)
    img.pen = color.rgb(44, 32, 23)                                  # floor
    _quad(img, proj(-1, 0), proj(1, 0), proj(1, 1), proj(-1, 1))
    img.pen = color.rgb(28, 20, 14)
    for i in range(-10, 11):
        a, b = proj(i / 10, 0), proj(i / 10, 1)
        img.line(int(a[0]), int(a[1]), int(b[0]), int(b[1]))
    img.pen = color.rgb(36, 36, 40)                                  # rug: charcoal, yellow trim
    _quad(img, proj(-0.58, 0.2), proj(0.58, 0.2), proj(0.58, 0.5), proj(-0.58, 0.5))
    img.pen = color.rgb(210, 170, 50)
    _quad(img, proj(-0.5, 0.24), proj(0.5, 0.24), proj(0.5, 0.46), proj(-0.5, 0.46))
    img.pen = color.rgb(40, 40, 44)
    _quad(img, proj(-0.46, 0.26), proj(0.46, 0.26), proj(0.46, 0.44), proj(-0.46, 0.44))
    img.pen = color.rgb(38, 42, 48)                                  # shelving unit
    _wall_rect(img, -0.96, 0.96, 0, 180)
    cols, rows, cw, rh = 6, 3, 1.92 / 6, 60
    for r in range(rows):
        for c in range(cols):
            x0, x1, h0, h1 = -0.96 + c * cw, -0.96 + (c + 1) * cw, r * rh, (r + 1) * rh
            mid = r == 1 and 2 <= c <= 3
            img.pen = color.rgb(14, 14, 18) if mid else color.rgb(18, 19, 24)
            _wall_rect(img, x0 + 0.01, x1 - 0.01, h0 + 3, h1 - 3)
            if not mid:
                _cubby_item(img, CUBBY_KINDS[r][c], x0 + 0.012, x1 - 0.012, h0 + 3, h1 - 3)
    np_ = proj(0, 1, 90)                                             # neon
    _glow(img, np_[0], np_[1], 40, (255, 220, 60), 110)
    img.font = _deps["font_big"]
    t = "HOT TIPS"
    tw = img.measure_text(t)[0]
    img.pen = color.rgb(255, 225, 90)
    img.text(t, int(np_[0] - tw / 2), int(np_[1] - 7))
    img.pen = color.rgb(255, 255, 230, 150)
    img.text(t, int(np_[0] - tw / 2), int(np_[1] - 7))
    cl = proj(1, 0.62, 170)                                          # clock on the right wall
    img.pen = color.rgb(202, 162, 74)
    img.circle(int(cl[0]), int(cl[1]), max(2, int(14 * cl[2])))
    img.pen = color.rgb(120, 95, 30)
    img.circle(int(cl[0]), int(cl[1]), max(1, int(10 * cl[2])))
    img.pen = color.rgb(42, 45, 51)                                  # acoustic panels
    for z in (0.36, 0.5):
        _side_rect(img, 1, z, z + 0.1, 132, 168)
    img.pen = color.rgb(138, 90, 48)                                 # hanging plant, left
    _side_rect(img, -1, 0.6, 0.62, 152, CEIL)
    img.pen = color.rgb(63, 156, 86)
    for dz, dh, r in ((0, -12, 9), (0.03, -20, 7), (-0.03, -22, 7), (0.02, -34, 6)):
        p = proj(-1, 0.61 + dz, 152 + dh)
        img.circle(int(p[0]), int(p[1]), max(1, int(r * p[2] * 1.2)))
    _box(img, -1, -0.86, 0.68, 0.82, 38, color.rgb(42, 43, 49), color.rgb(28, 29, 34), color.rgb(34, 35, 41))   # synth stand
    # the machines: pinball in the back-left corner, two arcade cabinets on the right wall
    x0, x1, z0, z1 = -0.96, -0.72, 0.86, 1.0
    _box(img, x0, x1, z0, z1, 70, color.rgb(58, 58, 68), color.rgb(38, 38, 46), color.rgb(48, 48, 58))
    img.pen = color.rgb(202, 160, 48)
    _quad(img, proj(x0 + 0.01, z1 - 0.01, 72), proj(x1 - 0.01, z1 - 0.01, 72), proj(x1 - 0.01, z0 + 0.01, 66), proj(x0 + 0.01, z0 + 0.01, 66))
    img.pen = color.rgb(26, 26, 32)
    _quad(img, proj(x0 + 0.03, z1 - 0.03, 73), proj(x1 - 0.03, z1 - 0.03, 73), proj(x1 - 0.03, z0 + 0.03, 67), proj(x0 + 0.03, z0 + 0.03, 67))
    img.pen = color.rgb(255, 154, 46)
    for i in range(5):
        p = proj(x0 + 0.045 + i * 0.045, z0 + 0.05 + (i % 2) * 0.04, 69)
        img.circle(int(p[0]), int(p[1]), max(1, int(4 * p[2])))
    img.pen = color.rgb(43, 43, 51)
    _wall_rect(img, x0, x1, 72, 170)
    img.pen = color.rgb(138, 106, 32)
    _wall_rect(img, x0 + 0.015, x1 - 0.015, 85, 160)
    bp = proj((x0 + x1) / 2, z1, 125)
    img.font = _deps["font_small"]
    tw = img.measure_text("PINBALL")[0]
    img.pen = color.rgb(255, 211, 107)
    img.text("PINBALL", int(bp[0] - tw / 2), int(bp[1] - 4))
    for x0, x1, label in ((0.52, 0.74, "WES RUN"), (0.76, 0.98, "ECHO")):   # cabinets against the back wall
        z0, z1 = 0.88, 1.0
        _box(img, x0, x1, z0, z1, 165, color.rgb(42, 42, 50), color.rgb(58, 58, 68), color.rgb(48, 48, 56))
        img.pen = color.rgb(202, 160, 48)
        _quad(img, proj(x0 + 0.015, z0, 163), proj(x1 - 0.015, z0, 163), proj(x1 - 0.015, z0, 140), proj(x0 + 0.015, z0, 140))
        img.pen = color.rgb(16, 20, 24)
        _quad(img, proj(x0 + 0.02, z0, 132), proj(x1 - 0.02, z0, 132), proj(x1 - 0.02, z0, 94), proj(x0 + 0.02, z0, 94))
        img.pen = color.rgb(27, 42, 58)
        for i in range(3):
            p = proj(x0 + 0.05 + i * 0.05, z0, 104 + i * 8)
            img.rectangle(int(p[0] - 3 * p[2]), int(p[1] - 2 * p[2]), max(2, int(6 * p[2])), max(1, int(4 * p[2])))
        img.pen = color.rgb(48, 48, 56)
        _quad(img, proj(x0 + 0.01, z0 - 0.05, 88), proj(x1 - 0.01, z0 - 0.05, 88), proj(x1 - 0.01, z0, 92), proj(x0 + 0.01, z0, 92))
        img.pen = color.rgb(38, 38, 44)
        _quad(img, proj(x0 + 0.01, z0 - 0.05, 88), proj(x1 - 0.01, z0 - 0.05, 88), proj(x1 - 0.01, z0 - 0.05, 80), proj(x0 + 0.01, z0 - 0.05, 80))
        for i, c in enumerate(((225, 60, 60), (255, 210, 63), (255, 154, 46))):
            p = proj(x0 + 0.05 + i * 0.055, z0 - 0.03, 89)
            img.pen = color.rgb(*c)
            img.circle(int(p[0]), int(p[1]), max(1, int(4 * p[2])))
        img.pen = color.rgb(255, 210, 63)
        _quad(img, proj(x0 + 0.02, z0, 18), proj(x1 - 0.02, z0, 18), proj(x1 - 0.02, z0, 14), proj(x0 + 0.02, z0, 14))
        mp = proj((x0 + x1) / 2, z0, 151)
        img.font = _deps["font_small"]
        tw = img.measure_text(label)[0]
        img.pen = color.rgb(20, 18, 8)
        img.text(label, int(mp[0] - tw / 2), int(mp[1] - 4))
    # door on the left wall (closed; the site animates it, the badge keeps it simple)
    img.pen = color.rgb(74, 53, 34)
    _side_rect(img, -1, DOOR[0] - 0.012, DOOR[1] + 0.012, 0, DOOR[2] + 6)
    img.pen = color.rgb(46, 32, 22)
    _side_rect(img, -1, DOOR[0], DOOR[1], 0, DOOR[2])
    k = proj(-1, DOOR[0] + (DOOR[1] - DOOR[0]) * 0.12, DOOR[2] * 0.45)
    img.pen = color.rgb(255, 211, 107)
    img.circle(int(k[0]), int(k[1]), 2)
    for x, z in ((-0.45, 0.3), (0.45, 0.3), (-0.45, 0.7), (0.45, 0.7)):   # ceiling lights
        p = proj(x, z, CEIL - 2)
        _glow(img, p[0], p[1], 22 * p[2], (255, 225, 170), 110)
        img.pen = color.rgb(246, 231, 192)
        img.circle(int(p[0]), int(p[1]), max(1, int(3 * p[2])))
    # chairs: two host chairs behind the desk, two stools on the right, a bench on the left
    for sx_, sz_ in SEATS[:2]:
        img.pen = color.rgb(40, 40, 46)
        _quad(img, proj(sx_ - 0.11, sz_ + 0.05, 36), proj(sx_ + 0.11, sz_ + 0.05, 36), proj(sx_ + 0.11, sz_ + 0.05, 8), proj(sx_ - 0.11, sz_ + 0.05, 8))
        img.pen = color.rgb(210, 170, 50)
        _quad(img, proj(sx_ - 0.08, sz_ + 0.05, 32), proj(sx_ + 0.08, sz_ + 0.05, 32), proj(sx_ + 0.08, sz_ + 0.05, 20), proj(sx_ - 0.08, sz_ + 0.05, 20))
    for sx_, sz_ in SEATS[2:4]:
        _box(img, sx_ - 0.05, sx_ + 0.05, sz_ - 0.03, sz_ + 0.03, 6, color.rgb(210, 170, 50), color.rgb(60, 60, 66), color.rgb(50, 50, 56))
    _box(img, -0.8, -0.68, 0.36, 0.62, 6, color.rgb(70, 70, 78), color.rgb(44, 44, 50), color.rgb(52, 52, 58))
    img.pen = color.rgb(52, 52, 58)
    _side_rect(img, -0.8, 0.36, 0.62, 6, 24)
    # the desk, mics, laptop
    _box(img, DESK[0], DESK[1], DESK[2], DESK[2] + DESK[3], DESK[4], color.rgb(138, 90, 54), color.rgb(90, 58, 34), color.rgb(106, 68, 40))
    for mx, gold in ((-0.3, True), (0.3, False)):
        b = proj(mx, DESK[2] + 0.02, DESK[4])
        top = proj(mx + (0.08 if mx < 0 else -0.08), DESK[2] - 0.02, DESK[4] + 60)
        img.pen = color.rgb(34, 34, 34)
        img.shape(shape.line(b[0], b[1], b[0], b[1] - 24 * b[2], 2))
        img.shape(shape.line(b[0], b[1] - 24 * b[2], top[0], top[1], 2))
        img.pen = color.rgb(212, 175, 55) if gold else color.rgb(150, 155, 150)
        img.circle(int(top[0]), int(top[1] + 6 * top[2]), max(2, int(6 * top[2])))
    img.pen = color.rgb(42, 45, 54)
    _quad(img, proj(-0.09, DESK[2] + 0.02, DESK[4] + 1), proj(0.09, DESK[2] + 0.02, DESK[4] + 1), proj(0.09, DESK[2] + 0.06, DESK[4] + 1), proj(-0.09, DESK[2] + 0.06, DESK[4] + 1))
    img.pen = color.rgb(13, 16, 32)
    _quad(img, proj(-0.09, DESK[2] + 0.06, DESK[4] + 1), proj(0.09, DESK[2] + 0.06, DESK[4] + 1), proj(0.085, DESK[2] + 0.065, DESK[4] + 25), proj(-0.085, DESK[2] + 0.065, DESK[4] + 25))
    img.pen = color.rgb(255, 210, 60)
    for i in range(3):
        p = proj(-0.07, DESK[2] + 0.065, DESK[4] + 6 + i * 6)
        img.rectangle(int(p[0]), int(p[1]), int((8 + (i * 7) % 6) * p[2] * 1.5), 1)
    d0 = proj(DESK[0], DESK[2], DESK[4] + 60)
    d1 = proj(DESK[1], DESK[2] + DESK[3], 0)
    _desk_rect = (int(d0[0]) - 6, int(d0[1]), int(d1[0] - d0[0]) + 12, int(d1[1] - d0[1]) + 2)
    _bd = img
    gc.collect()
    return img

def backdrop():
    return _bd or build_backdrop()

# ---------------------------------------------------------------- sprites
def sprite(look, R, idx, d, mood):
    key = (json.dumps(look), R, idx, d, mood)
    img = _sprites.get(key)
    if img is None:
        image = _deps["image"]
        w, h = int(R * 5.2) + 2, int(R * 6.6) + 2
        img = image(w, h)
        img.antialias = img.X2
        img.pen = color.rgb(0, 0, 0, 0)
        img.clear()
        ph = idx / SPR_N
        walking = mood in ("wander", "zoom")
        _deps["draw_guy"](img, w / 2, h - R * 0.5, R, look, ph if walking else 0.0, d, walking, mood, ph * 2 * math.pi / 6)
        if len(_spr_order) >= SPR_MAX:
            old = _spr_order.pop(0)
            _sprites.pop(old, None)
        _sprites[key] = img
        _spr_order.append(key)
    return img

def _restore(screen, r):
    x, y, w, h = r
    x0, y0 = max(0, x), max(0, y)
    x1, y1 = min(W, x + w), min(H, y + h)
    if x1 > x0 and y1 > y0:
        screen.blit(_bd.window(x0, y0, x1 - x0, y1 - y0), vec2(x0, y0))

def _union(a, b):
    if a is None:
        return b
    x0, y0 = min(a[0], b[0]), min(a[1], b[1])
    x1, y1 = max(a[0] + a[2], b[0] + b[2]), max(a[1] + a[3], b[1] + b[3])
    return (x0, y0, x1 - x0, y1 - y0)

def draw(screen, avatars, chat, now_i, now_f, present_s, me_id=None):
    """One frame of the room on top of the backdrop. Call after the backdrop is on screen.
    now_i/now_f: server time as int seconds + fraction (see simulate)."""
    global _last_rects
    bd = backdrop()
    for r in _last_rects:
        _restore(screen, r)
    _last_rects = []
    sims = []
    for av in avatars:
        try:
            sm = simulate(av, now_i, now_f, present_s)
        except Exception:
            continue
        if not sm["gone"]:
            sims.append((av, sm))
    sims.sort(key=lambda p: -p[1]["z"])                 # far first
    desk_z = DESK[2] + DESK[3] / 2
    desk_done = False
    guy_height = _deps["guy_height"]
    font_small = _deps["font_small"]
    recent = {}
    for c in chat:
        if now_i - c.get("t", 0) < 7:
            w_ = c.get("who")
            if w_ not in recent or recent[w_]["t"] < c["t"]:
                recent[w_] = c
    for av, sm in sims:
        if not desk_done and sm["z"] < desk_z:
            _restore(screen, _desk_rect)
            desk_done = True
        R = max(3, int(sm["R"] + 0.5))
        mood = av.get("mood") or "wander"
        walking_mood = "wander" if sm["walking"] else ("sit" if sm["sitting"] else ("wander" if mood in ("zoom", "auto", "walk") else mood))
        phase = (((now_i % 100) + now_f) * (3.2 if mood == "zoom" else 1.6)) % 1.0
        idx = int(phase * SPR_N) % SPR_N if sm["walking"] else 0
        img = sprite(av["look"], R, idx, sm["dir"], walking_mood)
        x0, y0 = int(sm["x"] - img.width / 2), int(sm["y"] + R * 0.5 - img.height)
        img.alpha = int(255 * sm["alpha"])
        screen.blit(img, vec2(x0, y0))
        img.alpha = 255
        rect = (x0, y0, img.width, img.height)
        screen.font = font_small
        name = av.get("name", "?")
        tw = screen.measure_text(name)[0]
        nx, ny = int(sm["x"] - tw / 2), int(sm["y"] + 2)
        screen.pen = color.rgb(255, 210, 60) if av.get("id") == me_id else color.rgb(230, 230, 230, 200)
        screen.text(name, nx, ny)
        rect = _union(rect, (nx - 1, ny, int(tw) + 2, 10))
        if av.get("id") in recent:
            line = recent[av["id"]]["text"]
        else:
            act = playing(av, now_i)
            line = ("playing " + GAME_LABELS.get(act, act)) if act and (now_i - av["act_t"]) % 20 < 4 else ambient(av, now_i)
        if line:
            br = _deps["draw_bubble"](screen, line, sm["x"] + R * 0.3, sm["y"] - guy_height(R, av["look"]) - 3, 120, font_small)
            if br:
                rect = _union(rect, (int(br[0]) - 1, int(br[1]) - 1, int(br[2]) + 2, int(br[3]) + int(sm["y"] - br[1]) + 2))
        _last_rects.append(rect)
    if not desk_done:
        _restore(screen, _desk_rect)
    return len(sims)

def reset():
    """Forget dirty rectangles (call after a full backdrop blit)."""
    global _last_rects
    _last_rects = []
