"""ハコブくんの短い動き（ショートの冒頭用）。FVの病室のコマ（g_05.png）の上で、体を動かし、顔とリングを描き替える。
使い方: python3 anim_kit.py [パターン名 ...]   （名前なしで全部）→ ハコブくん_{名前}.mp4
パターン: 大あわて / 落ちこむ / 固まる / 大喜び / なぐさめる
"""
import os, sys, subprocess
import numpy as np, cv2
from PIL import Image
import hakobu as hk

hk.BODY = "new"          # 頭まで欠けていない体（cut_new.png）。古い体（FVのコマの切り抜き）は頭の上が平らに切れて見えるので使わない
FPS, SUB = 30, 3         # SUB: 速く動く間の動きのぶれ（1コマに3回分を重ねる）

# ================= 顔の形を足す =================
_orig_eye = hk.eye_mask
def eye_mask(m, kind, box, side, dy):
    x0, y0, x1, y1 = box[0], box[1] + dy, box[2], box[3] + dy
    cx, cy, w, h = (x0 + x1) / 2, (y0 + y1) / 2, x1 - x0, y1 - y0
    if kind == "none":
        return
    if kind.startswith("text:") or kind == "line":
        if side > 0:
            return                                    # 画面全体に1回だけ描く
        L, R = hk.V_EYE_LEFT[0] - 14, hk.V_EYE_RIGHT[2] + 14
        T_, B_ = hk.V_EYE_LEFT[1] - 8, hk.V_MOUTH[3] + 4
        if kind == "line":
            ym = (T_ + B_) / 2
            hk._thick_line(m, [(L - 6, ym), (R + 6, ym)], 3.0); return
        rows = kind[5:].split("|")
        from PIL import ImageDraw, ImageFont
        Wd, Hd = int((R - L) * hk.SS), int((B_ - T_) * hk.SS)
        size = int(min(Hd / (len(rows) * 1.25), Wd / max(len(r) for r in rows) * 0.98))
        fnt = ImageFont.truetype("/usr/share/fonts/opentype/noto/NotoSansCJK-Black.ttc", size, index=0)
        im = Image.new("L", (Wd, Hd), 0); d = ImageDraw.Draw(im)
        y = (Hd - len(rows) * size * 1.2) / 2
        for r in rows:
            d.text(((Wd - fnt.getlength(r)) / 2, y), r, font=fnt, fill=255); y += size * 1.2
        im = np.array(im)[:, ::-1]                    # 顔の層はあとで左右反転されるので、先に反転しておく
        X0, Y0 = hk._p(L, T_)
        sub = m[Y0:Y0 + Hd, X0:X0 + Wd]
        np.maximum(sub, im[:sub.shape[0], :sub.shape[1]], out=sub); return
    if kind == "dot":
        cv2.circle(m, hk._p(cx, cy + 2), int(4.2 * hk.SS), 255, -1, cv2.LINE_AA); return
    if kind == "down":   # 下を向いた目（低く短い）
        a, b = hk._p(cx - w / 2, cy + h * 0.05), hk._p(cx + w / 2, cy + h * 0.5)
        im = Image.fromarray(m); from PIL import ImageDraw
        ImageDraw.Draw(im).rounded_rectangle([a, b], radius=w / 2 * hk.SS, fill=255); m[:] = np.array(im); return
    if kind == "x":      # ×の目
        r = w * 0.55
        hk._thick_line(m, [(cx - r, cy - r), (cx + r, cy + r)], 4.5)
        hk._thick_line(m, [(cx - r, cy + r), (cx + r, cy - r)], 4.5); return
    if kind == "heart":
        t = np.linspace(0, 2 * np.pi, 80)
        s = w * 0.05
        xs = cx + s * 16 * np.sin(t) ** 3
        ys = cy - s * (13 * np.cos(t) - 5 * np.cos(2 * t) - 2 * np.cos(3 * t) - np.cos(4 * t)) + 1
        cv2.fillPoly(m, [np.array([hk._p(x, y) for x, y in zip(xs, ys)], np.int32)], 255, cv2.LINE_AA); return
    return _orig_eye(m, kind, box, side, dy)
hk.eye_mask = eye_mask

_orig_mouth = hk.mouth_mask
def mouth_mask(kind):
    w, h = (hk.FX1 - hk.FX0) * hk.SS, (hk.FY1 - hk.FY0) * hk.SS
    if kind == "none":
        return np.zeros((h, w), np.uint8)
    if kind.startswith("wave"):
        m = np.zeros((h, w), np.uint8)
        ph = 0 if kind == "wave" else 1
        x0, x1 = hk.V_MOUTH[0] - 4, hk.V_MOUTH[2] + 4
        cy = (hk.V_MOUTH[1] + hk.V_MOUTH[3]) / 2 - 4
        pts = [(x0 + (x1 - x0) * i / 6, cy + (5 if (i + ph) % 2 else -5)) for i in range(7)]
        hk._thick_line(m, pts, 4.0); return m
    if kind.startswith("frown_tear"):   # への字の口＋涙（涙の下がり具合を名前の数字で）
        m = _orig_mouth("frown").copy()
        k = int(kind[len("frown_tear"):])
        ex = (hk.V_EYE_LEFT[0] + hk.V_EYE_LEFT[2]) / 2 - 2
        ey = hk.V_EYE_LEFT[3] + 4 + k
        r = 4.2
        cv2.circle(m, hk._p(ex, ey), int(r * hk.SS), 255, -1, cv2.LINE_AA)
        tri = np.array([hk._p(ex - r * 0.95, ey - 1), hk._p(ex + r * 0.95, ey - 1), hk._p(ex, ey - 9)], np.int32)
        cv2.fillPoly(m, [tri], 255, cv2.LINE_AA); return m
    return _orig_mouth(kind)
hk.mouth_mask = mouth_mask

# ================= 体と背景の準備 =================
bot = hk.Hakobu(mode="full")

def _clean_wheels(img):
    """タイヤのまわりだけ、切り抜きの白いふち（元の白い背景の残り）を消す。ほかの部分は触らない"""
    a = np.array(img)
    ys_, _ = np.nonzero(a[..., 3] > 10)
    y0 = int(ys_.max() - 0.13 * (ys_.max() - ys_.min()))        # タイヤのある高さ（体の下 13%）
    lum = a[..., :3].astype(np.float32).mean(2)
    reg = np.zeros(lum.shape, bool); reg[y0:] = True
    dark = (lum < 110) & (a[..., 3] > 128) & reg                  # タイヤと台座の黒
    near_dark = cv2.dilate(dark.astype(np.uint8), np.ones((11, 11), np.uint8)) > 0
    near_out = cv2.dilate((a[..., 3] < 20).astype(np.uint8), np.ones((9, 9), np.uint8)) > 0
    edge = reg & near_dark & near_out & (a[..., 3] > 0)
    light = ((lum > 120) & (a[..., 3] > 0)).astype(np.uint8)
    body_light = cv2.dilate(cv2.morphologyEx(light, cv2.MORPH_OPEN, np.ones((9, 9), np.uint8)), np.ones((3, 3), np.uint8)) > 0
    halo = edge & (light > 0) & ~body_light                       # 細い白っぽいふちだけ消す（太い白は体なので残す）
    a[halo, 3] = 0
    soft = edge & ~halo & (a[..., 3] < 250)                       # 半透明のふち → 色をタイヤの黒に寄せる
    a[soft, :3] = np.minimum(a[soft, :3], 55)
    return Image.fromarray(a)
bot.body = _clean_wheels(bot.body)
bg = np.array(Image.open(os.path.join(hk.ASSET_DIR, "g_05.png")).convert("RGB"))
Hh, Ww = bg.shape[:2]
# 体を画面に置く: ディスプレイの幅を古い体（180px）にそろえ、足もとを y=905・中心を x=470 に（FVのロボットと同じ立ち位置）
_ba = np.array(bot.body)[..., 3]; _bys, _bxs = np.nonzero(_ba > 10)
_sys, _sxs = np.nonzero(bot.screen_mask)
BSC = 180.0 / (_sxs.max() - _sxs.min())
BOX = 470 - BSC * (_bxs.min() + _bxs.max()) / 2
BOY = 905 - BSC * _bys.max()
def to_canvas(arr):
    """体の座標で描いた RGBA を、画面（Ww×Hh）の立ち位置へ"""
    return cv2.warpAffine(arr, np.float32([[BSC, 0, BOX], [0, BSC, BOY]]), (Ww, Hh), flags=cv2.INTER_LINEAR, borderValue=(0, 0, 0, 0))
body = Image.fromarray(to_canvas(np.array(bot.body)))
_a = np.array(body)[..., 3]
_hole = cv2.dilate((_a > 10).astype(np.uint8), np.ones((5, 5), np.uint8), 2)
plate = cv2.cvtColor(cv2.inpaint(cv2.cvtColor(bg, cv2.COLOR_RGB2BGR), _hole, 9, cv2.INPAINT_TELEA), cv2.COLOR_BGR2RGB)
plate = np.where(_hole[..., None] > 0, plate, bg).astype(np.float32)
_ys, _xs = np.where(_a > 10)
PIVOT = (float(_xs.mean()), float(_ys.max()))   # 足もと
# 跳ぶとき用: 足もとの影まで消した床と、高さで変わる影
_band = np.zeros_like(_hole)
_x0, _x1, _yb = int(_xs.min()) - 40, int(_xs.max()) + 40, int(_ys.max())
_g = bg.astype(int).mean(2)
_ref = np.median(_g[_yb + 40:_yb + 90, _x0:_x1])
_band[_yb - 120:_yb + 45, _x0:_x1] = (_g[_yb - 120:_yb + 45, _x0:_x1] < _ref - 12)
_mask2 = cv2.dilate(np.maximum(_hole, _band), np.ones((9, 9), np.uint8), 2)
plate_clean = cv2.cvtColor(cv2.inpaint(cv2.cvtColor(bg, cv2.COLOR_RGB2BGR), _mask2, 25, cv2.INPAINT_TELEA), cv2.COLOR_BGR2RGB)
plate_clean = cv2.GaussianBlur(plate_clean, (0, 0), 3) * (_mask2[..., None] > 0) + bg * (_mask2[..., None] == 0)
plate_clean = plate_clean.astype(np.float32)
SHADOW_W = (_xs.max() - _xs.min()) * 0.62

_e = os.path.join(hk.ASSET_DIR, "bg_empty.png")   # ロボットを消した病室（LaMa で埋めた）
plate_empty = np.array(Image.open(_e).convert("RGB")).astype(np.float32) if os.path.exists(_e) else plate_clean

def shadow(height, x=0.0, widen=1.0, y=0.0):
    """足もとの影（跳ぶほど小さく薄く）。返り値: 暗くする量 0〜1 の画像"""
    k = 1 / (1 + max(height, 0) / 60)
    m = np.zeros((Hh, Ww), np.float32)
    cv2.ellipse(m, (int(PIVOT[0] - 8 + x), int(PIVOT[1] - 6 + y)), (int(SHADOW_W * k * widen), int(20 * k)), 0, 0, 360, 1.0, -1, cv2.LINE_AA)
    return cv2.GaussianBlur(m, (0, 0), 9) * 0.55 * k
fx0, fy0 = bot.fbox[0], bot.fbox[1]
_cache = {}

_pk = {}
def _pumpkin(w, h):
    if (w, h) not in _pk:
        from pumpkin import pumpkin
        _pk[(w, h)] = pumpkin(w, h)
    return _pk[(w, h)]

TINT = [(1.0, 1.0, 1.0)]
HAT = [None]          # 頭にかぶせる物（"pumpkin"）。パターンの hat で決める

def robot_rgba(eyes, brows, mouth, ring):
    k = (eyes, brows, mouth, round(ring, 2), TINT[0], HAT[0])
    if k not in _cache:
        b = bot.body.copy()                           # 体の座標で描いてから、to_canvas で画面へ
        if TINT[0] != (1.0, 1.0, 1.0):                # 背景の明るさ・色に合わせて体を染める（顔とリングは光るので染めない）
            ba = np.array(b).astype(np.float32); ba[..., :3] *= np.array(TINT[0], np.float32)
            b = Image.fromarray(np.clip(ba, 0, 255).astype(np.uint8))
        bot._ring(b, ring)
        if eyes[0] == "orange" or HAT[0] == "pumpkin":  # かぼちゃを頭にかぶった（画面がふさがって全面オレンジ）
            sw = _sxs.max() - _sxs.min(); pk = _pumpkin(int(2.33 * sw), int(2.33 * sw * 300 / 420))
            px = (_sxs.min() + _sxs.max()) / 2 + 0.27 * sw - pk.width / 2
            py = _sys.max() + 0.29 * sw - pk.height
            b.alpha_composite(pk, (int(px), int(py)))
        else:
            gl, la = bot._face_layers(eyes, brows, mouth, 0.0)
            b.alpha_composite(gl, (fx0, fy0)); b.alpha_composite(la, (fx0, fy0))
        arr = np.array(b).astype(np.float32)
        arr = to_canvas(arr)
        arr[..., :3] *= arr[..., 3:4] / 255
        _cache[k] = arr
        if len(_cache) > 400:
            _cache.pop(next(iter(_cache)))
    return _cache[k]

LEFT, RIGHT = float(_xs.min()), float(_xs.max())   # 体の左端・右端（元の位置）

def _rs(rot, sx, sy, about):
    c, s = np.cos(np.radians(rot)), np.sin(np.radians(rot))
    A = np.array([[c * sx, s * sy], [-s * sx, c * sy]])
    p = np.array(about)
    M = np.eye(3); M[:2, :2] = A; M[:2, 2] = p - A @ p
    return M

def place(arr, dx=0, dy=0, rot=0, sx=1, sy=1, flip=False, fall=0, fall_side=-1, s=1.0, **_):
    """足もとを中心に、(左右反転して) 回して・伸び縮みさせて・ずらす。
    fall: 倒れる角度。fall_side=-1 なら体の左下の角、+1 なら右下の角を中心に倒れる"""
    M = _rs(rot, s * sx * (-1 if flip else 1), s * sy, PIVOT)
    T = np.eye(3); T[:2, 2] = (dx, dy)
    M = T @ M
    if fall:
        half = (RIGHT - LEFT) / 2
        corner = (PIVOT[0] + dx + fall_side * half * 0.92 * s, PIVOT[1] + dy)
        M = _rs(-fall_side * fall, 1, 1, corner) @ M
    return cv2.warpAffine(arr, M[:2].astype(np.float32), (Ww, Hh), flags=cv2.INTER_LINEAR, borderValue=(0, 0, 0, 0))

def ease(u):
    u = np.clip(u, 0, 1); return u * u * (3 - 2 * u)

# ================= パターン =================
# 各パターン: dur, motion(t)->dict, face(t)->(eyes, brows, mouth), ring(t), blur(t), dim(t)
class Panic:            # 大あわて
    dur = 5.0
    A, P, S = 0.12, 0.35, 3.9
    def motion(self, t):
        if t < self.A: return {}
        if t < self.S:
            r = min(1, (t - self.A) / 0.25)
            drift = 22 * np.sin(2 * np.pi * 0.9 * (t - self.A))
            return dict(dx=r * (9 * np.sin(2 * np.pi * 7.3 * t) + 4 * np.sin(2 * np.pi * 11.1 * t + 1)) - drift * 0.6,
                        dy=-r * abs(np.sin(2 * np.pi * 3.6 * t)) * 7 + drift * 0.25,
                        rot=r * (2.4 * np.sin(2 * np.pi * 7.3 * t + 0.6) + 0.8 * np.sin(2 * np.pi * 13 * t)),
                        sx=1 + 0.035 * np.sin(2 * np.pi * 0.9 * (t - self.A)), sy=1 + 0.035 * np.sin(2 * np.pi * 0.9 * (t - self.A)))
        u = t - self.S; k = np.exp(-u * 18) * np.cos(2 * np.pi * 6 * u)
        return dict(dx=6 * k, rot=1.5 * k)
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        if t < self.P: return ("big", "big"), None, "o"
        if t < self.S: return ("chev", "chev"), None, ("wave" if int((t - self.P) * 9) % 2 == 0 else "wave2")
        return (("blink", "blink") if 4.55 < t < 4.65 else ("dot", "dot")), None, "flat"
    def ring(self, t):
        if self.A <= t < self.S: return 1.0 if int(t * 12) % 2 == 0 else 0.15
        if t >= self.S: return 0.15 + 0.25 * min(1, (t - self.S) / 0.8)
        return 0.4
    def blur(self, t): return self.A <= t < self.S + 0.1
    def dim(self, t): return 1.0

class Sad:              # 落ちこむ: しょんぼり沈んで、前に傾き、涙がひとつぶ。部屋が少し暗くなる
    dur = 5.0
    def motion(self, t):
        u = ease((t - 0.5) / 1.6)
        sigh = 0.006 * np.sin(2 * np.pi * 0.45 * t) * u          # ゆっくりした息
        return dict(rot=-4.5 * u, sy=1 - 0.045 * u + sigh, sx=1 + 0.012 * u, dx=-6 * u)
    def face(self, t):
        if t < 0.35: return ("open", "open"), None, "mid"
        if t < 1.3: return ("open", "open"), "trouble", "frown"
        if t < 2.2: return ("down", "down"), "trouble", "frown"
        k = int(min(1, (t - 2.2) / 1.6) ** 1.6 * 40)            # 涙がゆっくり流れる
        if t > 4.1: return ("down", "down"), "trouble", "frown"
        return ("down", "down"), "trouble", f"frown_tear{k}"
    def ring(self, t): return 0.4 * (1 - ease((t - 0.5) / 2.0)) + 0.02
    def blur(self, t): return False
    def dim(self, t): return 1 - 0.18 * ease((t - 0.6) / 2.2)

class Freeze:           # 固まる（仕事で失敗）: 調子よく進む → ガン！とぶつかる → 画面がチラつく → ×の目で傾いたまま固まる
    dur = 5.0
    HIT = 0.9
    def motion(self, t):
        if t < self.HIT:
            u = t / self.HIT
            return dict(dx=-14 * u, dy=4 * u, sx=1 + 0.02 * u, sy=1 + 0.02 * u,
                        rot=0.6 * np.sin(2 * np.pi * 4 * t))        # るんるん進む
        u = t - self.HIT
        bounce = 16 * np.exp(-u * 14) * np.cos(2 * np.pi * 7 * u)  # ぶつかって跳ね返る
        lean = 7.5 * ease((u - 0.9) / 0.5)                            # そのまま、ゆっくり後ろへ傾く
        g = 0
        if 0.1 < u < 0.9 and int(u * 30) % 4 == 0: g = 10 * (1 if int(u * 60) % 2 else -1)   # チラつき
        return dict(dx=-14 + bounce + g, dy=4, sx=1.02, sy=1.02 - 0.03 * np.exp(-u * 10), rot=lean + 3 * np.exp(-u * 12) * np.sin(2 * np.pi * 9 * u))
    def face(self, t):
        if t < self.HIT: return ("happy", "happy"), None, "closed"
        u = t - self.HIT
        if u < 0.9:
            fl = int(u * 30) % 5
            return [(("big", "big"), None, "o"), (("none", "none"), None, "none"), (("x", "x"), None, "o"),
                    (("none", "none"), None, "none"), (("big", "big"), None, "o")][fl]
        return ("x", "x"), None, "o"
    def ring(self, t):
        if t < self.HIT: return 0.6
        u = t - self.HIT
        if u < 0.9: return 1.0 if int(u * 20) % 2 else 0.0
        return 0.0 if int(u * 2.2) % 2 else 0.2                    # ゆっくり点いたり消えたり
    def blur(self, t): return t >= self.HIT and t < self.HIT + 0.5
    def dim(self, t): return 1.0

class Joy:              # 大喜び: ぴょんぴょん跳ねる（縮んで→伸びて→着地）
    dur = 5.0
    clean = True
    JUMP = 70                                        # 跳ぶ高さ（px）
    def motion(self, t):
        if t < 0.15: return {}
        if t > 4.2:
            u = t - 4.2; k = np.exp(-u * 10)
            return dict(sy=1 - 0.05 * k * np.cos(2 * np.pi * 4 * u), sx=1 + 0.03 * k * np.cos(2 * np.pi * 4 * u))
        T = 0.58                                     # 1回の跳ねる長さ
        u = ((t - 0.15) % T) / T
        n = int((t - 0.15) / T)
        side = 1 if n % 2 else -1
        if u < 0.18:                                 # しゃがむ
            v = np.sin(np.pi * u / 0.18)
            return dict(sy=1 - 0.08 * v, sx=1 + 0.05 * v)
        v = (u - 0.18) / 0.82                        # 跳ぶ
        h = 4 * v * (1 - v)
        return dict(dy=-self.JUMP * h, sy=1 + 0.05 * h, sx=1 - 0.03 * h, rot=side * 3.5 * np.sin(np.pi * v), dx=side * 8 * np.sin(np.pi * v))
    def face(self, t):
        if t < 0.15: return ("open", "open"), None, "mid"
        if t > 4.3: return ("happy", "happy"), None, "closed"
        return ("chev", "chev"), None, ("tri" if int(t * 6) % 2 == 0 else "large")
    def ring(self, t): return 0.5 + 0.5 * (0.5 + 0.5 * np.sin(2 * np.pi * 3 * t)) if t < 4.3 else 0.6
    def blur(self, t): return 0.15 < t < 4.4
    def dim(self, t): return 1.0

class Comfort:          # なぐさめる: ゆっくり近づいて、2回うなずき、目がハートに
    dur = 5.0
    def motion(self, t):
        u = ease(t / 1.6)                                   # 手前に近づく
        nod = 0
        for c in (2.0, 2.9):                                # うなずき2回
            v = (t - c) / 0.6
            if 0 < v < 1: nod += np.sin(np.pi * v)
        sway = 1.2 * np.sin(2 * np.pi * 0.5 * t)            # ゆらゆら
        return dict(dx=-30 * u, dy=14 * u + 6 * nod, sx=1 + 0.06 * u, sy=1 + 0.06 * u - 0.035 * nod, rot=sway - 2.5 * nod)
    def face(self, t):
        if t < 0.4: return ("open", "open"), None, "mid"
        if t < 3.6: return ("happy", "happy"), None, "closed"
        return ("heart", "heart"), None, "closed"
    def ring(self, t): return 0.35 + 0.35 * (0.5 + 0.5 * np.sin(2 * np.pi * 0.6 * t))
    def blur(self, t): return False
    def dim(self, t): return 1.0

def _shake(t, t0, amp, decay=9.0, hz=23.0, ph=0.0):
    if t < t0: return 0.0
    v = t - t0
    return amp * np.exp(-v * decay) * np.sin(2 * np.pi * hz * v + ph)

class Rampage:          # 暴走してこける: 右へ突進→壁にゴン→左へ突進→壁にガン！→ぐらぐら→あおむけに倒れる
    dur = 6.2
    empty = True
    sub = 4
    S = 0.88                                   # 走りながら少し奥へ下がる（倒れても画面に収まる大きさ）
    T_R0, T_R1 = 0.25, 0.95                   # 右へ突進
    T_FLIP = 1.3
    T_L0, T_L1 = 1.45, 2.05                    # 左へ突進
    T_F0, T_F1 = 2.5, 2.85                     # 倒れる
    FALL_SHIFT = 0                             # 倒れながら横へずれる量（倒れた体が画面に収まるように）
    def __init__(self):
        S = self.S
        self.dxR = Ww - (PIVOT[0] + (PIVOT[0] - LEFT) * S) - 4      # 反転すると右端は元の左端の鏡
        self.dxL = -(PIVOT[0] - (PIVOT[0] - LEFT) * S) + 2
    def scale(self, t):
        u = ease((t - self.T_R0) / (self.T_R1 - self.T_R0))
        sc = 1 - (1 - self.S) * u
        return sc, -(1 - sc) * 300
    def motion(self, t):
        sc, dep = self.scale(t)
        if t < self.T_R0:                      # 身構える
            v = np.sin(np.pi * t / self.T_R0)
            return dict(sy=1 - 0.08 * v, sx=1 + 0.05 * v, flip=t > 0.12)
        if t < self.T_R1:                      # 右へ突進（だんだん速く）
            u = (t - self.T_R0) / (self.T_R1 - self.T_R0)
            return dict(dx=self.dxR * u * u, dy=dep - abs(np.sin(2 * np.pi * 8 * t)) * 8, rot=-8 * min(1, u * 3),
                        sx=1.07, sy=0.95, flip=True, s=sc)
        if t < self.T_L0:                      # 右の壁にゴン → はね返る → 向きを変える
            v = t - self.T_R1
            back = 45 * ease(v / 0.3)
            squash = np.exp(-v * 14)
            flip = t < self.T_FLIP
            ant = np.sin(np.pi * np.clip((t - self.T_FLIP) / (self.T_L0 - self.T_FLIP), 0, 1))
            return dict(dx=self.dxR - back, dy=dep, rot=4 * squash * np.cos(2 * np.pi * 6 * v),
                        sx=1 - 0.16 * squash + 0.05 * ant, sy=1 + 0.06 * squash - 0.08 * ant, flip=flip, s=sc)
        if t < self.T_L1:                      # 左へ突進（さっきより速い）
            u = (t - self.T_L0) / (self.T_L1 - self.T_L0)
            x0 = self.dxR - 45
            return dict(dx=x0 + (self.dxL - x0) * u * u, dy=dep - abs(np.sin(2 * np.pi * 9 * t)) * 10,
                        rot=9 * min(1, u * 3), sx=1.1, sy=0.94, s=sc)
        if t < self.T_F0:                      # 左の壁にガン！ → ぐらぐら
            v = t - self.T_L1
            squash = np.exp(-v * 12)
            tee = 7 * np.sin(2 * np.pi * 2.6 * v) * min(1, v / 0.25) - 5 * ease(v / (self.T_F0 - self.T_L1))
            return dict(dx=self.dxL + 6 * ease(v / 0.2), dy=dep, rot=tee, sx=1 - 0.2 * squash, sy=1 + 0.08 * squash, s=sc)
        x = self.dxL + 6
        if t < self.T_F1:                      # あおむけに倒れる（右下の角を中心に）
            u = (t - self.T_F0) / (self.T_F1 - self.T_F0)
            return dict(dx=x + self.FALL_SHIFT * ease((u - 0.35) / 0.65), dy=dep, fall=5 + 85 * u * u, fall_side=1, s=sc)
        w = t - self.T_F1                      # ドスン → 小さくはねて止まる
        f = 90 - 9 * np.exp(-w * 8) * abs(np.sin(2 * np.pi * 2.2 * w))
        if 4.55 < t < 4.75: f += 2.5 * np.sin(2 * np.pi * 12 * (t - 4.55))   # ピクッ
        return dict(dx=x + self.FALL_SHIFT, dy=dep, fall=f, fall_side=1, s=sc)
    def cam(self, t):
        x = _shake(t, self.T_R1, 9) + _shake(t, self.T_L1, 24, 7) + _shake(t, self.T_F1, 4, 8, 17, 1)
        y = _shake(t, self.T_F1, 16, 8, 19) + _shake(t, self.T_L1, 6, 9, 29, 1)
        return x, y
    def face(self, t):
        if t < self.T_R0: return ("big", "big"), None, "o"
        if self.T_R1 <= t < self.T_R1 + 0.22: return ("x", "x"), None, "o"
        if self.T_L1 <= t < self.T_L1 + 0.25: return ("x", "x"), None, "o"
        if self.T_L1 + 0.25 <= t < self.T_F0: return ("dot", "dot"), None, "wave"
        if self.T_F0 <= t < self.T_F1: return ("big", "big"), None, "o"
        if t >= self.T_F1: return ("x", "x"), None, ("wave" if int(t * 3) % 2 else "wave2")
        return ("chev", "chev"), None, ("wave" if int(t * 9) % 2 == 0 else "wave2")
    def ring(self, t):
        if t < self.T_F1: return 1.0 if int(t * 12) % 2 == 0 else 0.15
        if 4.55 < t < 4.75: return 1.0
        return 0.35 if int(t * 1.5) % 2 else 0.05
    def blur(self, t): return t < self.T_F1 + 0.3
    def dim(self, t): return 1.0

class PanicBig:         # 大あわて（大げさ版）: 左右に走り回り、跳ねて、ぴたっと止まる
    dur = 5.0
    empty = True
    sub = 4
    A, P, S = 0.1, 0.3, 3.95
    AMP = 165                                  # 左右に走る幅（px）
    def x(self, t):
        u = t - self.P
        return self.AMP * np.sin(2 * np.pi * 0.62 * u) * min(1, u / 0.3)
    def motion(self, t):
        if t < self.A: return {}
        if t < self.P:
            v = np.sin(np.pi * (t - self.A) / (self.P - self.A))
            return dict(sy=1 - 0.1 * v, sx=1 + 0.06 * v, dy=-25 * v)
        if t < self.S:
            x = self.x(t); vel = (self.x(t + 0.01) - x) / 0.01
            return dict(dx=x + 14 * np.sin(2 * np.pi * 7.3 * t),
                        dy=-abs(np.sin(2 * np.pi * 3.4 * t)) * 38,
                        rot=-vel * 0.025 + 5 * np.sin(2 * np.pi * 7.3 * t + 0.6),
                        sx=1 + 0.06 * abs(np.sin(2 * np.pi * 3.4 * t)), sy=1 - 0.05 * abs(np.sin(2 * np.pi * 3.4 * t + 1.2)),
                        flip=vel > 0)
        x = self.x(self.S); u = t - self.S; k = np.exp(-u * 16) * np.cos(2 * np.pi * 6 * u)
        return dict(dx=x + 14 * k, rot=4 * k, sy=1 - 0.08 * np.exp(-u * 12))
    def cam(self, t):
        if self.P <= t < self.S: return 3 * np.sin(2 * np.pi * 19 * t), 3 * np.sin(2 * np.pi * 23 * t)
        return _shake(t, self.S, 7, 10), 0.0
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        if t < self.P + 0.1: return ("big", "big"), None, "o"
        if t < self.S: return ("chev", "chev"), None, ("wave" if int(t * 10) % 2 == 0 else "wave2")
        return (("blink", "blink") if 4.55 < t < 4.65 else ("dot", "dot")), None, "flat"
    def ring(self, t):
        if self.A <= t < self.S: return 1.0 if int(t * 14) % 2 == 0 else 0.1
        return 0.15 + 0.25 * min(1, max(0, t - self.S) / 0.8)
    def blur(self, t): return self.A <= t < self.S + 0.15
    def dim(self, t): return 1.0

class Summon:           # 呼ばれた！: ピコン→その場で空回り→カメラの方へ突進→キキーッと止まって「オ呼ビデスカ？」
    dur = 5.1
    empty = True
    sub = 4
    A, V0, R0, R1, K = 1.0, 2.0, 2.55, 3.0, 3.4   # 「運ぶ」で反応（1.0秒）→ 言い終わるのを待って「ハイッ」→ 空回り（2.0）→ 突進
    S1 = 1.33
    talk = (3.55, 4.23)            # 「オ呼ビデスカ？」を言う間（声を取り込んだら合わせる）
    def motion(self, t):
        if t < self.A:
            tw = 7 * np.sin(np.pi * (t - 0.35) / 0.2) if 0.35 <= t < 0.55 else 0   # 「箱」でピクッ
            return dict(sh_y=0, dy=-tw, sh_h=tw, sy=1 + 0.006 * np.sin(2 * np.pi * 1.2 * t))
        if t < self.R0:
            v = t - self.A
            hop = 22 * np.sin(np.pi * min(v / 0.25, 1)) if v < 0.25 else 0
            crouch = ease((t - self.V0) / 0.2) if t >= self.V0 else 0
            vib = 5 * np.sin(2 * np.pi * 24 * t) * crouch
            return dict(dx=vib, dy=-hop, sy=1 - 0.05 * crouch + (0.04 if v < 0.12 else 0), sx=1 + 0.03 * crouch,
                        sh_y=0, sh_h=hop)
        if t < self.R1:
            u = (t - self.R0) / (self.R1 - self.R0)
            k = u * u
            sc = 1 + (self.S1 - 1) * k
            return dict(dx=-20 * k, dy=110 * k, s=sc, sy=1.05, sx=0.97, sh_y=110 * k)
        v = t - self.R1
        over = 0.05 * np.exp(-v * 9) * np.cos(2 * np.pi * 3 * v)
        rot = 4.5 * np.exp(-v * 7) * np.sin(2 * np.pi * 4 * v)
        bob = 4 * np.sin(2 * np.pi * 1.6 * (t - self.K)) if t > self.K else 0
        return dict(dx=-20, dy=110 - bob, s=self.S1 + over, rot=rot, sh_y=110)
    def cam(self, t):
        return _shake(t, self.R1, 10, 9, 21), _shake(t, self.R1, 6, 9, 27, 1)
    def face(self, t):
        if 0.35 <= t < 0.7: return ("big", "big"), None, "mid"     # 「箱」でピクッ
        if t < self.A: return ("open", "open"), None, "mid"
        if t < self.A + 0.25: return ("big", "big"), None, "o"
        if t < self.V0: return ("big", "big"), None, "mid"          # 言い終わるのを待つ
        if t < self.R1: return ("chev", "chev"), None, "large"
        a, b = self.talk
        if a <= t < b:
            return ("open", "open"), None, ("large" if int((t - a) * 9) % 2 == 0 else "small")
        return ("open", "open"), None, "mid"
    def ring(self, t):
        if self.A <= t < self.A + 0.3: return 1.0
        if self.A + 0.3 <= t < self.V0: return 0.8
        if self.V0 <= t < self.R1 + 0.2: return 1.0 if int(t * 14) % 2 == 0 else 0.3
        return 0.55 + 0.2 * np.sin(2 * np.pi * 1.2 * t)
    def blur(self, t): return (self.A <= t < self.A + 0.25) or (self.V0 <= t < self.R1 + 0.25)
    def dim(self, t): return 1.0

class KensuJoy:         # 件数と聞いて大喜び: 話を聞いている → 「件数」で目がハート → ぴょんぴょん
    dur = 2.7
    empty = True
    sub = 3
    A = 1.55                       # 「件数」と聞こえる時刻（声を取り込んだら合わせる）
    def motion(self, t):
        if t < self.A:
            return dict(sh_y=0, sy=1 + 0.006 * np.sin(2 * np.pi * 1.2 * t))
        v = t - self.A
        T = 0.42
        u = (v % T) / T
        h = 4 * u * (1 - u)
        sq = np.exp(-((u - 0.0) / 0.12) ** 2) + np.exp(-((u - 1.0) / 0.12) ** 2)
        return dict(dy=-38 * h, sh_y=0, sh_h=38 * h, sy=1 + 0.05 * h - 0.06 * sq, sx=1 - 0.03 * h + 0.04 * sq)
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        return ("heart", "heart"), None, "tri"
    def ring(self, t):
        if t < self.A: return 0.4
        return 1.0 if int(t * 10) % 2 == 0 else 0.4
    def blur(self, t): return t >= self.A
    def dim(self, t): return 1.0

class Shuttle:          # 1錠ずつ全力で往復: 「1件目、配送シマス！」→ 左右に10往復 → 得意げに止まる
    dur = 5.3
    empty = True
    sub = 5
    talk = (0.12, 1.92)            # 「1件目、配送シマス！」を言う間
    T0, TRIP, N = 2.0, 0.28, 10   # 往復の始まり・1回の時間・回数
    L, R = -250.0, 170.0
    def pos(self, t):
        """(x, 進む向き>0なら右, 動いているか)"""
        if t < self.T0: return 0.0, 0, False
        k = int((t - self.T0) / self.TRIP)
        if k >= self.N:
            end = self.R if (self.N % 2 == 1) else self.L
            return end, 0, False
        u = (t - self.T0 - k * self.TRIP) / self.TRIP
        a = 0.0 if k == 0 else (self.R if k % 2 == 1 else self.L)
        b = self.R if k % 2 == 0 else self.L
        if k == 0: a = 0.0
        return a + (b - a) * ease(u), (1 if b > a else -1), True
    def motion(self, t):
        x, d, moving = self.pos(t)
        if t < self.T0:
            return dict(sh_y=0, dy=-4 * abs(np.sin(2 * np.pi * 2 * t)), sh_h=0)
        if moving:
            return dict(dx=x, flip=d > 0, sx=1.08, sy=0.95, dy=-10 * abs(np.sin(np.pi * ((t - self.T0) % self.TRIP) / self.TRIP)),
                        rot=-3 * d, sh_y=0)
        v = t - (self.T0 + self.N * self.TRIP)
        k = np.exp(-v * 10) * np.cos(2 * np.pi * 4 * v)
        return dict(dx=x, sx=1 + 0.06 * k, sy=1 - 0.06 * k, rot=3 * k, sh_y=0)
    def face(self, t):
        a, b = self.talk
        if a <= t < b: return ("chev", "chev"), None, ("large" if int((t - a) * 9) % 2 == 0 else "small")
        if t < self.T0: return ("chev", "chev"), None, "tri"
        if t < self.T0 + self.N * self.TRIP: return ("chev", "chev"), None, "large"
        return ("happy", "happy"), None, "closed"
    def ring(self, t):
        if t < self.T0: return 0.6
        return 1.0 if int(t * 14) % 2 == 0 else 0.3
    def blur(self, t): return self.T0 <= t < self.T0 + self.N * self.TRIP + 0.1
    def dim(self, t): return 1.0

class Blind:            # 第81話: かぼちゃで画面が全面オレンジ → 前が見えずにフラフラ、ぐるぐる
    dur = 5.0
    empty = True
    sub = 4
    A = 0.25
    def motion(self, t):
        if t < self.A: return dict(sh_y=0)
        v = t - self.A
        dx = 120 * np.sin(2 * np.pi * 0.37 * v) + 35 * np.sin(2 * np.pi * 1.3 * v + 1)
        rot = 7 * np.sin(2 * np.pi * 1.1 * v) + 3 * np.sin(2 * np.pi * 3.3 * v)
        dy = -abs(np.sin(2 * np.pi * 1.6 * v)) * 10
        vel = 120 * 2 * np.pi * 0.37 * np.cos(2 * np.pi * 0.37 * v)
        return dict(dx=dx, dy=dy, rot=rot, flip=vel > 0, sh_y=0)
    def face(self, t): return ("orange", "orange"), None, "mid"
    def ring(self, t): return 0.6 + 0.4 * np.sin(2 * np.pi * 2 * t)
    def blur(self, t): return t >= self.A
    def dim(self, t): return 1.0

class Reverse:          # 第85話: 「取り消し！」→ ピーピーとバックで遠ざかる
    dur = 4.6
    empty = True
    sub = 3
    A, B0, B1 = 0.9, 1.3, 3.6       # 「取り消し」で反応・バック開始・止まる
    def motion(self, t):
        if t < self.A: return dict(sh_y=0, dx=-30 * ease(t / 0.6), s=1.0)
        if t < self.B0:
            v = (t - self.A) / (self.B0 - self.A)
            return dict(dx=-30, sh_y=0, sy=1 + 0.04 * np.sin(np.pi * v), dy=-10 * np.sin(np.pi * v))
        u = ease((t - self.B0) / (self.B1 - self.B0))
        sc = 1 - 0.32 * u
        jig = 3 * np.sin(2 * np.pi * 9 * t) * (1 - abs(2 * u - 1))
        return dict(dx=-30 + 140 * u + jig, dy=-170 * u, s=sc, sh_y=-170 * u)
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        if t < self.B0: return ("big", "big"), None, "o"
        if t < self.B1: return ("open", "open"), None, ("small" if int(t * 6) % 2 else "mid")
        return ("happy", "happy"), None, "closed"
    def ring(self, t):
        if self.B0 <= t < self.B1: return 1.0 if int(t * 4) % 2 == 0 else 0.1    # ピー、ピー
        return 0.4
    def blur(self, t): return self.B0 <= t < self.B1
    def dim(self, t): return 1.0

class Sigh:             # 第82話: ため息をまねる → どんどん長いため息
    dur = 5.2
    empty = False
    S1, S2 = (0.6, 1.8), (2.2, 4.8)   # 1回目・2回目（長い）のため息
    def _one(self, t, a, b):
        if not (a <= t < b): return 0.0, 0.0
        u = (t - a) / (b - a)
        inhale = np.sin(np.pi * min(u / 0.3, 1)) if u < 0.3 else 0
        exhale = ease((u - 0.25) / 0.75)
        return inhale, exhale
    def motion(self, t):
        i1, e1 = self._one(t, *self.S1); i2, e2 = self._one(t, *self.S2)
        slump = max(e1 * 0.5, e2 * 1.0, 0.5 if t >= self.S1[1] else 0)
        if t >= self.S2[1]: slump = 1.0
        return dict(sy=1 + 0.03 * max(i1, i2) - 0.06 * slump, sx=1 + 0.02 * slump, rot=-3 * slump,
                    dy=-8 * max(i1, i2))
    def face(self, t):
        if t < self.S1[0]: return ("open", "open"), None, "mid"
        if t < self.S2[0]: return ("half", "half"), "trouble", "frown"
        return ("down", "down"), "trouble", ("o" if self.S2[0] + 0.5 < t < self.S2[1] - 0.3 else "frown")
    def ring(self, t): return 0.45 * (1 - ease((t - 0.6) / 4.0)) + 0.03
    def blur(self, t): return False
    def dim(self, t): return 1 - 0.12 * ease((t - 2.2) / 2.5)

class Petted:           # 第20話: 撫でられてにこにこ → 画面に「撫デテ下サイ（強制）」と出して動かない
    dur = 5.0
    empty = False
    TX = 2.3                          # 文字が出る時刻
    def motion(self, t):
        if t < self.TX:
            return dict(sy=1 - 0.025 * (0.5 + 0.5 * np.sin(2 * np.pi * 1.6 * t)), rot=1.5 * np.sin(2 * np.pi * 0.8 * t))
        v = t - self.TX
        return dict(sy=1 + 0.03 * np.exp(-v * 10) * np.cos(2 * np.pi * 5 * v))
    def face(self, t):
        if t < 0.3: return ("open", "open"), None, "mid"
        if t < self.TX: return ("happy", "happy"), None, "closed"
        return ("text:撫デテ下サイ|（強制）", "text:撫デテ下サイ|（強制）"), None, "none"
    def ring(self, t):
        if t < self.TX: return 0.5 + 0.3 * np.sin(2 * np.pi * 1.6 * t)
        return 1.0 if int((t - self.TX) * 3) % 2 == 0 else 0.4
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Follow:           # 第88話: 「ついてきて」→ トコトコついてくる（手前へ）
    dur = 4.8
    empty = True
    sub = 2
    A, STEP, N = 1.2, 0.36, 9
    def motion(self, t):
        if t < self.A: return dict(sh_y=0)
        k = min(int((t - self.A) / self.STEP), self.N)
        u = 0 if k >= self.N else ((t - self.A) % self.STEP) / self.STEP
        prog = (k + ease(u)) / self.N
        sc = 1 + 0.12 * prog                 # 頭も足も、ショートの映像の窓（y172〜937）に収まる大きさまで
        hop = 10 * np.sin(np.pi * u) if k < self.N else 0
        sway = 2.5 * np.sin(np.pi * (k + u)) if k < self.N else 0
        return dict(dx=-25 * prog, dy=22 * prog - hop, s=sc, rot=sway, sh_y=22 * prog, sh_h=hop)
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        return ("happy", "happy"), None, "closed"
    def ring(self, t): return 0.5 + 0.3 * np.sin(2 * np.pi * 1.4 * t)
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Reboot:           # 第83話: 確認しすぎて固まる → 画面が横線 → 再起動「ハジメマシテ」
    dur = 5.0
    empty = False
    F0, L0, B0 = 1.4, 1.9, 3.0        # 固まる・横線・再起動
    def motion(self, t):
        if t < self.F0: return dict(sy=1 + 0.01 * np.sin(2 * np.pi * 3 * t))
        if t < self.L0:
            g = 8 * (1 if int(t * 40) % 2 else -1) if int(t * 15) % 3 == 0 else 0
            return dict(dx=g)
        if t < self.B0: return {}
        v = t - self.B0
        return dict(dy=-18 * np.exp(-v * 6) * abs(np.sin(2 * np.pi * 2 * v)))
    def face(self, t):
        if t < self.F0: return ("open", "open"), None, ("small" if int(t * 8) % 2 else "mid")
        if t < self.L0: return (("big", "big"), None, "o") if int(t * 20) % 3 else (("none", "none"), None, "none")
        if t < self.B0: return ("line", "line"), None, "none"
        if t < self.B0 + 0.4: return ("none", "none"), None, "none"
        if t < self.B0 + 0.55: return ("blink", "blink"), None, "closed"
        return ("open", "open"), None, "mid"
    def ring(self, t):
        if t < self.F0: return 0.5
        if t < self.L0: return 1.0 if int(t * 20) % 2 else 0.0
        if t < self.B0: return 0.0
        return min(1.0, (t - self.B0) * 1.5) * 0.6
    def blur(self, t): return self.F0 <= t < self.L0
    def dim(self, t): return 1.0

class Nod:              # 第87話: 「声は小さくね」にうなずく（2回）
    dur = 3.2
    empty = False
    NODS = (1.2, 1.75)
    def motion(self, t):
        nod = 0
        for c in self.NODS:
            v = (t - c) / 0.45
            if 0 < v < 1: nod += np.sin(np.pi * v)
        return dict(dy=7 * nod, sy=1 - 0.035 * nod, rot=-1.5 * nod)
    def face(self, t):
        if t < self.NODS[0] - 0.1: return ("open", "open"), None, "mid"
        return ("happy", "happy"), None, "closed"
    def ring(self, t): return 0.4
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Bow:              # 第43話: 何度もお辞儀（謝りすぎ）
    dur = 5.0
    empty = False
    T0, P = 0.5, 0.55
    def motion(self, t):
        if t < self.T0: return {}
        u = ((t - self.T0) % self.P) / self.P
        b = np.sin(np.pi * u) ** 1.5
        return dict(dy=14 * b, sy=1 - 0.06 * b, rot=-2 * b)
    def face(self, t):
        if t < self.T0: return ("open", "open"), "trouble", "frown"
        return ("calm", "calm"), "trouble", "frown"
    def ring(self, t): return 0.25
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Seq:              # 動きをつなぐ: parts = [(始まる秒, パターン, そのパターンの何秒目から)]
    def __init__(self, parts, dur):
        self.parts, self.dur = parts, dur
        self.empty = any(getattr(p, "empty", False) for _, p, _ in parts)
        self.sub = max(getattr(p, "sub", SUB) for _, p, _ in parts)
    def _at(self, t):
        t0, p, skip = [x for x in self.parts if x[0] <= t][-1] if t >= self.parts[0][0] else self.parts[0]
        return p, t - t0 + skip
    def motion(self, t): p, u = self._at(t); return p.motion(u)
    def face(self, t): p, u = self._at(t); return p.face(u)
    def ring(self, t): p, u = self._at(t); return p.ring(u)
    def blur(self, t): p, u = self._at(t); return p.blur(u)
    def dim(self, t): p, u = self._at(t); return p.dim(u)
    def cam(self, t):
        p, u = self._at(t)
        return p.cam(u) if hasattr(p, "cam") else (0.0, 0.0)

def _with(cls, **kw):
    o = cls()
    for k, v in kw.items(): setattr(o, k, v)
    return o

class PumpkinRampage(Rampage):   # 第81話: かぼちゃで前が見えず、走り回って壁にぶつかり、あおむけにこける
    hat = "pumpkin"
    dur = 6.4
    S = 0.8                                    # かぼちゃのぶん大きいので、少し小さく
    FALL_SHIFT = -200                          # かぼちゃのぶん長いので、左へずらして画面に収める
    PAD = 90                                   # かぼちゃが体より横に出るぶん、壁（画面の端）の手前で止まる
    def __init__(self):
        super().__init__()
        self.dxR -= self.PAD; self.dxL += self.PAD

def SighSad():                   # 第82話: ため息をまねる → 2回目で、しょんぼり沈んで涙
    return Seq([(0.0, _with(Sigh, S1=(1.25, 2.2), S2=(2.5, 4.6)), 0.0), (2.5, Sad(), 0.4)], 5.2)

def JoyPetted():                 # 第20話: 撫でられてぴょんぴょん喜ぶ → 画面に「撫デテ下サイ（強制）」
    return Seq([(0.0, _with(Joy, JUMP=45), 0.0), (2.4, _with(Petted, TX=0.0), 0.0)], 5.8)

def PanicBow():                  # 第43話: インシデントで大あわて → 何度もお辞儀
    return Seq([(0.0, _with(PanicBig, AMP=110), 0.0), (1.1, Bow(), 0.5)], 5.0)

PATTERNS = {"大あわて": Panic, "落ちこむ": Sad, "固まる": Freeze, "大喜び": Joy, "なぐさめる": Comfort,
            "暴走してこける": Rampage, "大あわて_大げさ": PanicBig, "呼ばれた": Summon, "件数で大喜び": KensuJoy, "往復": Shuttle,
            "かぼちゃ": Blind, "バック": Reverse, "ため息": Sigh, "撫でて": Petted, "ついてくる": Follow,
            "再起動": Reboot, "うなずく": Nod, "お辞儀": Bow,
            "かぼちゃで暴走": PumpkinRampage, "ため息で落ちこむ": SighSad, "喜んで撫でて": JoyPetted, "あわてて謝る": PanicBow}

# ================= 2026-10-20 追加の10話 =================
def T(*rows):                    # 画面に字を出す顔
    s = "text:" + "|".join(rows); return (s, s), None, "none"
def _talk(t, a, b, eyes=("open", "open"), brows=None):
    if a <= t < b: return eyes, brows, ("large" if int((t - a) * 9) % 2 == 0 else "small")
    return None
def _approach(t, t1=1.0, dist=230.0):   # 右から走ってきて止まる（最初の1秒を動きにする）
    if t >= t1 + 0.4:
        return {}
    if t >= t1:
        v = t - t1; k = np.exp(-v * 12) * np.cos(2 * np.pi * 5 * v)
        return dict(sx=1 + 0.05 * k, sy=1 - 0.05 * k, rot=2.5 * k)
    u = t / t1
    return dict(dx=dist * (1 - ease(u)), dy=-6 * abs(np.sin(2 * np.pi * 4 * t)), rot=-3 * (1 - u), sx=1.05, sy=0.97)
def _top():
    return float(_ys.min())

class Tidy:             # 第24話: 「障害物を検知」→「整理整頓 起動」→ 机の上を左右に全力で片付ける → 得意げ
    dur = 8.6
    empty = True
    sub = 5
    A, B, S0, TRIP, N = 3.2, 5.3, 7.15, 0.2, 6
    L, R = -230.0, 150.0
    def pos(self, t):
        if t < self.S0: return 0.0, 0, False
        k = int((t - self.S0) / self.TRIP)
        if k >= self.N: return (self.R if self.N % 2 == 1 else self.L), 0, False
        u = (t - self.S0 - k * self.TRIP) / self.TRIP
        a = 0.0 if k == 0 else (self.R if k % 2 == 1 else self.L)
        b = self.R if k % 2 == 0 else self.L
        return a + (b - a) * ease(u), (1 if b > a else -1), True
    def motion(self, t):
        x, d, mv = self.pos(t)
        if t < self.A:                               # 巡回: 右から入ってきて、左右を見る
            m = dict(_approach(t, 1.6, 300)); m["sh_y"] = 0
            if 2.0 < t < 2.9: m["flip"] = True
            return m
        if t < self.S0:
            v = t - self.B
            hop = 18 * np.sin(np.pi * min(v / 0.25, 1)) if 0 <= v < 0.25 else 0
            return dict(sh_y=0, dy=-hop, sh_h=hop, dx=4 * np.sin(2 * np.pi * 20 * t) if t >= self.B + 0.3 else 0)
        if mv:
            return dict(dx=x, flip=d > 0, sx=1.08, sy=0.95, rot=-4 * d, sh_y=0,
                        dy=-10 * abs(np.sin(np.pi * ((t - self.S0) % self.TRIP) / self.TRIP)))
        v = t - (self.S0 + self.N * self.TRIP); k = np.exp(-v * 10) * np.cos(2 * np.pi * 4 * v)
        return dict(dx=x, sx=1 + 0.06 * k, sy=1 - 0.06 * k, rot=3 * k, sh_y=0)
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        if t < self.B: return T("障害物", "検知") if int((t - self.A) * 4) % 2 == 0 else (("big", "big"), None, "o")
        if t < self.S0 + self.N * self.TRIP: return T("整理整頓", "起動")
        return ("happy", "happy"), None, "closed"
    def ring(self, t):
        if t < self.A: return 0.4
        return 1.0 if int(t * 12) % 2 == 0 else 0.2
    def blur(self, t): return self.S0 <= t < self.S0 + self.N * self.TRIP + 0.1
    def dim(self, t): return 1.0

class Spray:            # 第22話: 画面に「感染対策 強化月間」→ シュッ！シュッ！と消毒を吹きながら前へ
    dur = 5.6
    empty = True
    SP = (2.6, 3.2, 3.7, 4.1, 4.45, 4.75)   # 吹く時刻
    def _k(self, t):
        k = 0.0
        for s in self.SP:
            if s <= t < s + 0.3: k = max(k, np.exp(-(t - s) * 14))
        return k
    def motion(self, t):
        if t < 1.2: return dict(_approach(t, 1.0, 260), sh_y=0)
        u = ease((t - 2.5) / 2.6)
        k = self._k(t)
        return dict(dx=-30 * u + 10 * k, dy=40 * u, s=1 + 0.08 * u, sy=1 - 0.05 * k, sx=1 + 0.03 * k, rot=-2.5 * k, sh_y=40 * u)
    def face(self, t):
        if t < 2.5: return T("感染対策", "強化月間")
        return T("感染対策", "強化月間") if int(t * 6) % 2 == 0 else (("chev", "chev"), None, "tri")
    def ring(self, t): return 1.0 if self._k(t) > 0.3 else 0.5
    def blur(self, t): return False
    def dim(self, t): return 1.0
    def fx(self, t, frame, mo):        # 霧: 頭の横の吹き出し口から、前（左上）へ広がる水色の点
        rng = np.random.default_rng(7)
        s = mo.get("s", 1)
        nx = PIVOT[0] + mo.get("dx", 0) - (PIVOT[0] - LEFT) * s * 0.92
        ny = PIVOT[1] + mo.get("dy", 0) - (PIVOT[1] - _top()) * s * 0.7
        for sp in self.SP:
            v = t - sp
            if not (0 <= v < 0.75): continue
            n = 700
            ang = np.radians(rng.uniform(185, 228, n)); dist = rng.uniform(0.0, 1, n) ** 0.8 * (90 + 520 * min(v / 0.35, 1))
            xs = nx + np.cos(ang) * dist; ys = ny + np.sin(ang) * dist
            a = (1 - v / 0.75) * 0.95
            m = np.zeros((Hh, Ww), np.float32)
            for x, y, r in zip(xs, ys, rng.uniform(1.5, 5.0, n)):
                if 0 <= x < Ww and 0 <= y < Hh:
                    cv2.circle(m, (int(x), int(y)), int(r), 1.0, -1, cv2.LINE_AA)
            m = (cv2.GaussianBlur(m, (0, 0), 1.2) * a)[..., None]
            frame = frame * (1 - m) + np.array([110, 170, 230], np.float32) * m
        return frame

class Flee:             # 第86話: 手招きされるたびに後ずさり → 「なんで逃げるの！」→ ズギュン！と奥へ逃げる
    dur = 4.7
    empty = True
    sub = 4
    B = (1.25, 2.0)                # 手招きに合わせて1歩ずつ下がる
    Z0, Z1 = 3.25, 3.7             # ズギュン
    def motion(self, t):
        back = sum(ease((t - b) / 0.35) for b in self.B)
        sc = 1 - 0.06 * back; y = -45 * back; x = 25 * back
        if t < 0.9: return dict(_approach(t, 0.8, 200), sh_y=0)
        if t < self.Z0:
            tr = 3 * np.sin(2 * np.pi * 14 * t) if t > self.B[0] else 0
            return dict(dx=x + tr, dy=y, s=sc, sh_y=y)
        u = ease((t - self.Z0) / (self.Z1 - self.Z0))
        return dict(dx=x + 230 * u, dy=y - 190 * u, s=sc * (1 - 0.6 * u), rot=-6 * (1 - u), sh_y=y - 190 * u, flip=True)
    def face(self, t):
        if t < self.B[0]: return ("open", "open"), None, "mid"
        if t < self.B[0] + 0.25: return ("big", "big"), None, "o"
        if t < self.Z0: return ("dot", "dot"), None, "flat"
        return ("chev", "chev"), None, "o"
    def ring(self, t): return 1.0 if t >= self.Z0 and int(t * 14) % 2 == 0 else 0.4
    def blur(self, t): return self.Z0 <= t < self.Z1 + 0.1
    def dim(self, t): return 1.0

class FaceAuth:         # 第19話: 「顔認証を開始します」→ スキャン → ERROR！でガタガタ
    dur = 5.0
    empty = False
    S, E = 2.3, 4.0                # スキャンの始まり・エラー
    def motion(self, t):
        if t < 1.4: return _approach(t, 1.0, 230)
        if t < self.E: return dict(sy=1 + 0.005 * np.sin(2 * np.pi * 1.4 * t))
        v = t - self.E
        return dict(dx=9 * np.exp(-v * 3) * np.sin(2 * np.pi * 17 * v), rot=2.5 * np.exp(-v * 3) * np.sin(2 * np.pi * 11 * v))
    def cam(self, t): return _shake(t, self.E, 14, 6), _shake(t, self.E, 6, 7, 27, 1)
    def face(self, t):
        if t < self.S: return ("open", "open"), None, "mid"
        if t < self.E:
            d = "・" * (1 + int((t - self.S) * 3) % 3)
            return T("顔認証", "スキャン" + d)
        return T("ERROR") if int((t - self.E) * 5) % 2 == 0 else T("不正", "アクセス？")
    def ring(self, t):
        if t < self.S: return 0.4
        if t < self.E: return 0.5 + 0.5 * np.sin(2 * np.pi * 3 * t)
        return 1.0 if int(t * 10) % 2 == 0 else 0.0
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Sleep:            # 第48話: 画面が真っ暗で動かない → ユサユサ揺さぶられる → 「省電力中デシタ」
    dur = 6.2
    empty = False
    Y0, Y1, W = 0.25, 2.4, 2.75    # 揺さぶる・起きる
    def motion(self, t):
        if self.Y0 <= t < self.Y1:
            r = min(1, (t - self.Y0) / 0.15)
            return dict(rot=r * 6 * np.sin(2 * np.pi * 3.2 * (t - self.Y0)), dx=r * 10 * np.sin(2 * np.pi * 3.2 * (t - self.Y0)))
        if t >= self.Y1:
            v = t - self.Y1
            return dict(rot=3 * np.exp(-v * 6) * np.sin(2 * np.pi * 3.2 * v))
        return {}
    def face(self, t):
        if t < self.W: return ("none", "none"), None, "none"
        if t < self.W + 0.25: return ("blink", "blink"), None, "closed"
        return T("省電力中", "デシタ")
    def ring(self, t): return 0.0 if t < self.W else min(1, (t - self.W) * 2) * 0.6
    def blur(self, t): return self.Y0 <= t < self.Y1
    def dim(self, t): return 1.0

class Slot:             # 第29話: 「いいなぁ…」→ 画面がスロットのようにクルクル → ジャラン！「ハワイ旅行」でぴょん
    dur = 5.0
    empty = False
    S0, S1 = 2.9, 4.4
    WORDS = ["温泉", "焼肉", "休日", "ケーキ", "昇給", "南の島", "温泉", "焼肉"]
    def motion(self, t):
        if t < self.S0: return _approach(t, 1.2, 260)
        if t < self.S1: return dict(dx=3 * np.sin(2 * np.pi * 16 * t), sy=1 + 0.01 * np.sin(2 * np.pi * 8 * t))
        v = t - self.S1
        h = 4 * (v / 0.45) * (1 - v / 0.45) if v < 0.45 else 0
        k = np.exp(-(v - 0.45) * 10) * np.cos(2 * np.pi * 4 * (v - 0.45)) if v >= 0.45 else 0
        return dict(dy=-60 * h, sy=1 + 0.05 * h - 0.05 * k, sx=1 + 0.03 * k)
    def face(self, t):
        if t < self.S0: return ("half", "half"), None, "mid"
        if t < self.S1:
            sp = 4 + 10 * (1 - (t - self.S0) / (self.S1 - self.S0))   # だんだん遅く
            return T(self.WORDS[int((t - self.S0) * sp) % len(self.WORDS)])
        return T("ハワイ", "旅行")
    def ring(self, t):
        if t < self.S0: return 0.3
        return 1.0 if int(t * 12) % 2 == 0 else 0.3
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Torn:             # 第41話: 左右から同時に呼ばれ、あっち・こっちを向く → だんだん速く → 真ん中で「算出中…」
    dur = 6.3
    empty = False
    F = (0.35, 1.5, 2.1, 2.5, 2.8, 3.05, 3.25, 3.42, 3.56)   # 向きを変える時刻
    STOP = 3.72
    def motion(self, t):
        n = sum(1 for f in self.F if t >= f)
        if t < self.STOP:
            side = 1 if n % 2 == 1 else -1
            last = max([f for f in self.F if t >= f] or [0])
            k = np.exp(-(t - last) * 12)
            return dict(flip=(n % 2 == 1), dx=side * (14 + 10 * k), rot=-side * 3 * k, sy=1 - 0.03 * k)
        v = t - self.STOP; k = np.exp(-v * 9) * np.cos(2 * np.pi * 5 * v)
        return dict(rot=3 * k, sy=1 - 0.05 * np.exp(-v * 9))
    def face(self, t):
        if t < self.F[0]: return ("open", "open"), None, "mid"
        if t < self.STOP: return ("big", "big"), None, "o"
        d = "・" * (1 + int((t - self.STOP) * 3) % 3)
        return T("優先順位", "算出中" + d)
    def ring(self, t):
        if t >= self.STOP: return 0.5 + 0.5 * np.sin(2 * np.pi * 2 * t)
        return 0.6
    def blur(self, t): return self.F[3] <= t < self.STOP
    def dim(self, t): return 1.0

class Dither:           # 第67話: 曲がり角で清掃ロボと鉢合わせ → 同時に下がり、同時に出る（譲り合いのくり返し）
    dur = 6.4
    empty = True
    sub = 3
    CYC = ((0.7, 1.5), (1.9, 2.6), (2.95, 3.5), (3.8, 4.25), (4.5, 4.85))   # (前に出る, 下がる)
    def _x(self, t):
        x = -40.0                     # 下がった位置
        for a, b in self.CYC:
            if t >= a: x = -40 + 70 * ease((t - a) / 0.25)
            if t >= b: x = 30 - 70 * ease((t - b) / 0.25)
        return x
    def motion(self, t):
        x = self._x(t)
        stop = 5.1
        jit = 3 * np.sin(2 * np.pi * 10 * t) if t > stop else 0
        return dict(dx=x + jit - 110, flip=True, sh_y=0)
    def face(self, t):
        if t < 0.7: return ("open", "open"), None, "mid"
        for a, b in self.CYC:
            if a <= t < a + 0.35: return ("big", "big"), None, "o"
        if t > 5.1: return ("dot", "dot"), "trouble", "wave"
        return ("open", "open"), "trouble", "flat"
    def ring(self, t): return 0.5
    def blur(self, t): return False
    def dim(self, t): return 1.0
    def fx(self, t, frame, mo):        # 右から清掃ロボ（黒い箱）: ハコブくんと鏡うつしに出たり下がったり
        x = self._x(t)
        cx = int(Ww - 140 - x * 1.0)
        top, bot = int(PIVOT[1] - 400), int(PIVOT[1] - 4)
        w = 230
        cv2.ellipse(frame, (cx, bot + 2), (int(w * 0.55), 18), 0, 0, 360, (0, 0, 0), -1, cv2.LINE_AA)
        frame[:] = frame
        ov = frame.copy()
        cv2.rectangle(ov, (cx - w // 2, top + 40), (cx + w // 2, bot - 30), (52, 54, 58), -1)
        cv2.ellipse(ov, (cx, top + 40), (w // 2, 40), 0, 180, 360, (60, 62, 66), -1, cv2.LINE_AA)
        cv2.rectangle(ov, (cx - w // 2 + 8, top + 40), (cx + w // 2 - 8, top + 52), (70, 72, 76), -1)
        cv2.rectangle(ov, (cx - 16, top - 30), (cx + 16, top + 8), (40, 42, 45), -1)
        cv2.rectangle(ov, (cx - 80, top + 95), (cx + 20, top + 145), (25, 25, 28), -1)
        cv2.circle(ov, (cx - 30, top + 120), 17, (140, 150, 160), -1, cv2.LINE_AA)
        cv2.circle(ov, (cx - 30, top + 120), 9, (20, 20, 22), -1, cv2.LINE_AA)
        cv2.rectangle(ov, (cx - w // 2, bot - 40), (cx + w // 2, bot - 18), (30, 30, 33), -1)
        for wx in (cx - w // 2 + 50, cx + w // 2 - 50):
            cv2.circle(ov, (wx, bot - 14), 22, (18, 18, 20), -1, cv2.LINE_AA)
        if t > 5.1:                                   # 汗
            for i, (ox, oy) in enumerate(((-w // 2 - 20, top + 40), (-w // 2 - 40, top + 80))):
                cv2.ellipse(ov, (cx + ox, oy), (6, 11), 0, 0, 360, (120, 190, 255), -1, cv2.LINE_AA)
        return ov

class GhostBow:         # 第21話: 誰もいない壁（左）に向かって、にこにこお辞儀
    dur = 6.9
    empty = False
    T0, P = 0.25, 0.95
    def motion(self, t):
        if t < self.T0: return {}
        u = ((t - self.T0) % self.P) / self.P
        b = np.sin(np.pi * u) ** 1.5
        return dict(dy=10 * b, sy=1 - 0.05 * b, rot=4 * b)
    def face(self, t):
        if t < self.T0: return ("open", "open"), None, "mid"
        return ("happy", "happy"), None, "closed"
    def ring(self, t): return 0.35 + 0.15 * np.sin(2 * np.pi * 0.8 * t)
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Transform:        # 第18話: 「最短ルート 算出中」→「変形モード？」でガタガタ・プシュー → フリーズ（×で傾く）
    dur = 7.3
    empty = False
    C0, V0, F0 = 2.9, 4.5, 6.6
    def motion(self, t):
        if t < 1.4: return _approach(t, 1.1, 250)
        if t < self.V0: return dict(sy=1 + 0.006 * np.sin(2 * np.pi * 1.2 * t))
        if t < self.F0:
            r = min(1, (t - self.V0) / 0.4)
            return dict(dx=r * 8 * np.sin(2 * np.pi * 19 * t), dy=-r * abs(np.sin(2 * np.pi * 6 * t)) * 10,
                        sy=1 + r * 0.05 * np.sin(2 * np.pi * 4 * t), sx=1 - r * 0.03 * np.sin(2 * np.pi * 4 * t))
        v = t - self.F0
        return dict(rot=7 * ease(v / 0.5), sy=1 - 0.02 * np.exp(-v * 10))
    def cam(self, t): return _shake(t, self.F0, 10, 8), 0.0
    def face(self, t):
        if t < self.C0: return ("open", "open"), None, "mid"
        if t < self.V0:
            d = "・" * (1 + int((t - self.C0) * 3) % 3)
            return T("最短ルート", "算出中" + d)
        if t < self.F0: return T("変形モード", "展開準備") if int(t * 4) % 2 == 0 else T("変形モード", "？")
        return T("システム", "フリーズ") if int((t - self.F0) * 2) % 2 == 0 else (("x", "x"), None, "o")
    def ring(self, t):
        if t < self.V0: return 0.5 + 0.5 * np.sin(2 * np.pi * 2 * t)
        if t < self.F0: return 1.0 if int(t * 12) % 2 == 0 else 0.1
        return 0.0
    def blur(self, t): return self.V0 + 0.4 <= t < self.F0
    def dim(self, t): return 1.0

PATTERNS.update({"片付け": Tidy, "消毒": Spray, "逃げる": Flee, "顔認証": FaceAuth, "省電力": Sleep, "スロット": Slot,
                 "あっちこっち": Torn, "譲り合い": Dither, "壁にお辞儀": GhostBow, "変形": Transform})

# ================= 2026-10-24 追加の20話 =================
_prev_eye = hk.eye_mask
def _eye24(m, kind, box, side, dy):
    if kind == "cyc":                 # 第9話: 外装を外した中の、光る目が1つ（画面の真ん中に大きく）
        if side > 0: return
        cx = (hk.V_EYE_LEFT[0] + hk.V_EYE_RIGHT[2]) / 2
        cy = (hk.V_EYE_LEFT[1] + hk.V_MOUTH[3]) / 2
        cv2.circle(m, hk._p(cx, cy), int(34 * hk.SS), 255, int(5 * hk.SS), cv2.LINE_AA)
        cv2.circle(m, hk._p(cx, cy), int(17 * hk.SS), 255, -1, cv2.LINE_AA)
        return
    return _prev_eye(m, kind, box, side, dy)
hk.eye_mask = _eye24

def _dots(t, t0):
    return "・" * (1 + int((t - t0) * 3) % 3)
def _settle(t, t0, amp=1.0):
    if t < t0: return {}
    v = t - t0; k = np.exp(-v * 10) * np.cos(2 * np.pi * 4 * v)
    return dict(sx=1 + 0.06 * k * amp, sy=1 - 0.06 * k * amp, rot=3 * k * amp)
def _hop(t, t0, h=60, d=0.45):
    v = t - t0
    if v < 0: return {}
    if v < d:
        u = v / d; hh = 4 * u * (1 - u)
        return dict(dy=-h * hh, sy=1 + 0.05 * hh, sx=1 - 0.03 * hh, sh_h=h * hh)
    return _settle(t, t0 + d)

class Announce:         # 第7話: 走ってきて止まり、画面に「ピンポンパンポーン！」→ 音に合わせて体ごと跳ねる → 「回収シマシタ」
    dur = 6.0
    empty = False
    A = 1.6                                    # アナウンスの始まり（ハコブの声の頭）
    NOTES = (0.0, 0.32, 0.64, 0.96)            # ピン・ポン・パン・ポーン（A からの秒）
    B = 3.3                                    # 「検便容器ヲ…」に替わる
    def motion(self, t):
        if t < 1.3: return _approach(t, 1.0, 240)
        m = {}
        for n in self.NOTES:
            v = t - self.A - n
            if 0 <= v < 0.3:
                k = np.sin(np.pi * v / 0.3)
                m = dict(dy=-26 * k, sy=1 + 0.06 * k, sx=1 - 0.04 * k)
        if t >= self.B:
            m = dict(rot=2.0 * np.sin(2 * np.pi * 3 * t), dy=-6 * abs(np.sin(2 * np.pi * 6 * t)))
        return m
    def cam(self, t):
        return sum(_shake(t, self.A + n, 7, 9) for n in self.NOTES), 0.0
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        if t < self.B: return T("ピンポン", "パンポーン！")
        return T("検便容器ヲ", "回収シマシタ") if int(t * 4) % 3 else (("chev", "chev"), None, "tri")
    def ring(self, t): return 1.0 if t >= self.A and int(t * 10) % 2 == 0 else 0.4
    def blur(self, t): return False
    def dim(self, t): return 1.0
    def fx(self, t, frame, mo):                # 頭の上から出る音の波（弧が広がる）
        if t < self.A: return frame
        cx = PIVOT[0] + mo.get("dx", 0); cy = _top() + 70 + mo.get("dy", 0)
        for i in range(3):
            ph = ((t - self.A) * 1.6 + i / 3) % 1
            r = int(120 + 260 * ph); a = (1 - ph) * 0.8
            ov = frame.copy()
            for st, en in ((200, 250), (290, 340)):
                cv2.ellipse(ov, (int(cx), int(cy)), (r, r), 0, st, en, (196, 71, 31), 9, cv2.LINE_AA)
            frame = cv2.addWeighted(frame, 1 - a, ov, a, 0)
        return frame

class Glare:            # 第9話: 薄暗い機材室。外装の取れた体の画面に光る目が1つ → じわっと寄る → ギョロッとこっちを向く
    dur = 4.6
    empty = False
    G = 2.6                                    # ギョロ
    tint = (0.42, 0.44, 0.5)
    def motion(self, t):
        u = ease(t / self.G)
        m = dict(s=1 + 0.10 * u, dy=12 * u, sh_y=12 * u)
        if t >= self.G:
            v = t - self.G
            m.update(dx=6 * np.exp(-v * 8) * np.sin(2 * np.pi * 9 * v))
        return m
    def cam(self, t): return _shake(t, self.G, 8, 7), _shake(t, self.G, 4, 8, 27, 1)
    def face(self, t):
        if t < 0.8: return ("none", "none"), None, "none"
        return ("cyc", "cyc"), None, "none"
    def ring(self, t): return 0.0 if t < self.G else 0.25
    def blur(self, t): return False
    def dim(self, t):
        if t < 0.8: return 0.55
        return 0.62 + (0.12 if t >= self.G and int((t - self.G) * 14) % 2 == 0 and t < self.G + 0.5 else 0)

class Ikebo:            # 第17話: 呼ばれてゆっくり近づく → 半目のキメ顔で斜めに止まる（キラーン）
    dur = 5.0
    empty = True
    K = 3.0                                    # 決める
    def motion(self, t):
        if t < self.K:
            u = ease(t / self.K)
            return dict(dx=140 * (1 - u), dy=24 * u, s=1 + 0.05 * u, sh_y=24 * u, rot=-1.5 * np.sin(np.pi * u))
        v = t - self.K
        return dict(dy=24, s=1.05, sh_y=24, rot=-4 * ease(v / 0.35), sx=1 + 0.03 * np.exp(-v * 8))
    def face(self, t):
        if t < self.K: return ("half", "half"), None, "flat"
        return ("half", "half"), None, "flat"
    def ring(self, t): return 0.3 + 0.7 * (t >= self.K and (t - self.K) < 0.5)
    def blur(self, t): return False
    def dim(self, t): return 0.9 if t < self.K else 0.9 + 0.1 * np.exp(-(t - self.K) * 4)
    def fx(self, t, frame, mo):                # キラーン（星が光る）
        v = t - self.K - 0.15
        if not (0 <= v < 0.9): return frame
        a = np.sin(np.pi * v / 0.9)
        cx = PIVOT[0] + mo.get("dx", 0) + 150; cy = _top() + 10 + mo.get("dy", 0)
        ov = frame.copy()
        L = int(70 * a) + 4
        for ang in (0, 90):
            dx_, dy_ = int(L * np.cos(np.radians(ang))), int(L * np.sin(np.radians(ang)))
            cv2.line(ov, (int(cx - dx_), int(cy - dy_)), (int(cx + dx_), int(cy + dy_)), (255, 250, 220), 6, cv2.LINE_AA)
        cv2.circle(ov, (int(cx), int(cy)), int(10 * a) + 2, (255, 255, 240), -1, cv2.LINE_AA)
        return cv2.addWeighted(frame, 1 - a, ov, a, 0)

class Ninja:            # 第23話: 目を閉じたまま音もなく、奥から手前へすーっと滑ってくる → ぴたっと止まる
    dur = 4.6
    empty = True
    S = 3.1                                    # 止まる
    def motion(self, t):
        u = ease(min(t / self.S, 1))
        s = 0.68 + 0.36 * u
        y = -150 * (1 - u) + 22 * u
        return dict(dx=-160 * (1 - u), dy=y, s=s, sh_y=y)
    def face(self, t):
        if t < self.S + 0.9: return ("calm", "calm"), None, "none"
        return T("忍者モード", "解除シマス")
    def ring(self, t): return 0.0 if t < self.S + 0.9 else 0.5
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Menu:             # 第46話: 画面のメニューが ポチ・ポチ と深くなっていく → ピーンポーン（ナースコール）で目が丸く
    dur = 6.0
    empty = False
    P0, STEP = 1.4, 0.55                        # 1回目のポチと間かく
    ROWS = [("設定", "＞運用"), ("運用", "＞出力"), ("出力", "＞音声"), ("音声", "＞音量"), ("音量", "＞時間帯別…")]
    C = 4.4                                     # ナースコール
    def motion(self, t):
        if t < 1.2: return _approach(t, 1.0, 230)
        for i in range(len(self.ROWS)):
            v = t - self.P0 - i * self.STEP
            if 0 <= v < 0.18: return dict(dx=-5 * np.sin(np.pi * v / 0.18), sy=1 - 0.02 * np.sin(np.pi * v / 0.18))
        if t >= self.C: return _settle(t, self.C, 0.8)
        return {}
    def face(self, t):
        if t < self.P0: return T("設定")
        if t < self.C:
            i = min(int((t - self.P0) / self.STEP), len(self.ROWS) - 1)
            return T(*self.ROWS[i])
        if t < self.C + 0.6: return ("big", "big"), None, "o"
        return ("dot", "dot"), None, "flat"
    def ring(self, t): return 1.0 if t >= self.C and int(t * 8) % 2 == 0 else 0.4
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Wobble:           # 第47話: 画面のすみに小さく「右車輪のメンテナンス推奨」→ 進むたびに カタッ と右に傾く
    dur = 5.0
    empty = True
    K = (1.6, 2.6, 3.4, 4.0)                    # カタッ
    def motion(self, t):
        x = 150 * (1 - ease(t / 4.4))
        r = 0.0
        for k in self.K:
            v = t - k
            if v >= 0: r += 9 * np.exp(-v * 7) * np.cos(2 * np.pi * 5 * v)
        return dict(dx=x, rot=-r - 1.5, dy=-3 * abs(np.sin(2 * np.pi * 3 * t)), sh_y=0)
    def face(self, t):
        for k in self.K:
            if k <= t < k + 0.25: return ("big", "big"), None, "o"
        return T("右車輪の", "メンテナンス推奨")
    def ring(self, t): return 0.5
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Diag:             # 第51話: 「目悪くなった？」→ 画面「自己診断中」→「全機能 正常」で得意げにぴょん
    dur = 5.4
    empty = False
    S, R = 2.2, 3.6
    def motion(self, t):
        if t < 1.2: return _approach(t, 1.0, 230)
        if t < self.R: return dict(sy=1 + 0.006 * np.sin(2 * np.pi * 1.4 * t))
        return _hop(t, self.R, 55)
    def face(self, t):
        if t < self.S: return ("open", "open"), None, "mid"
        if t < self.R: return T("自己診断", "実行中" + _dots(t, self.S))
        return T("全機能", "正常") if int((t - self.R) * 3) % 3 else (("happy", "happy"), None, "closed")
    def ring(self, t): return 0.5 + 0.5 * np.sin(2 * np.pi * 2 * t)
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Slot40(Slot):     # 第52話: 画面の数字がクルクル → ジャラン！「夜勤 +40%」
    WORDS = ["+12%", "+25%", "+7%", "+33%", "+18%", "+29%", "+3%", "+36%"]
    def face(self, t):
        if t < self.S0: return ("open", "open"), None, "mid"
        if t < self.S1:
            sp = 4 + 10 * (1 - (t - self.S0) / (self.S1 - self.S0))
            return T("今月ノ夜勤", self.WORDS[int((t - self.S0) * sp) % len(self.WORDS)])
        return T("今月ノ夜勤", "+40%")

class StopGo:           # 第60話: 廊下を元気に行ったり来たり → ピタッ！と止まって動かない
    dur = 4.6
    empty = True
    sub = 4
    P = 2.9                                     # ピタッ
    def x(self, t): return 150 * np.sin(2 * np.pi * 0.55 * t)
    def motion(self, t):
        if t < self.P:
            x = self.x(t); vel = self.x(t + 0.01) - x
            return dict(dx=x, flip=vel > 0, sx=1.05, sy=0.97, rot=-3 * np.sign(vel), dy=-6 * abs(np.sin(2 * np.pi * 4 * t)), sh_y=0)
        x = self.x(self.P); vel = self.x(self.P) - self.x(self.P - 0.01)
        m = _settle(t, self.P, 1.6); m.update(dx=x, flip=vel > 0, sh_y=0); return m
    def cam(self, t): return _shake(t, self.P, 10, 8), 0.0
    def face(self, t):
        if t < self.P: return ("happy", "happy"), None, "closed"
        return ("open", "open"), None, "mid"
    def ring(self, t): return 0.6
    def blur(self, t): return t < self.P
    def dim(self, t): return 1.0

class Recheck:          # 第62話: 「了解シマシタ」で即ダッシュ → 途中で止まって「？」→ 汗をかいて戻ってくる
    dur = 6.0
    empty = True
    sub = 4
    D0, D1, Q, B0, B1 = 1.0, 1.7, 2.0, 3.0, 4.0
    def motion(self, t):
        if t < 1.2: return dict(_approach(t, 1.0, 230), sh_y=0)
        if t < self.D0: return dict(sh_y=0, sy=1 + 0.005 * np.sin(2 * np.pi * 1.3 * t))
        if t < self.D1:
            u = (t - self.D0) / (self.D1 - self.D0)
            return dict(dx=-260 * u * u, dy=-80 * u, s=1 - 0.2 * u, sx=1.08, sy=0.95, rot=6, sh_y=-80 * u)
        if t < self.B0:
            m = _settle(t, self.D1, 1.2); m.update(dx=-260, dy=-80, s=0.8, sh_y=-80); return m
        if t < self.B1:
            u = ease((t - self.B0) / (self.B1 - self.B0))
            return dict(dx=-260 * (1 - u), dy=-80 * (1 - u), s=0.8 + 0.2 * u, flip=True, sh_y=-80 * (1 - u),
                        rot=-3 * np.sin(np.pi * u))
        return dict(sh_y=0)
    def face(self, t):
        if t < self.D0: return ("happy", "happy"), None, "closed"
        if t < self.Q: return ("chev", "chev"), None, "tri"
        if t < self.B0: return T("？")
        return ("dot", "dot"), "trouble", "wave"
    def ring(self, t): return 1.0 if self.D0 <= t < self.D1 and int(t * 12) % 2 == 0 else 0.4
    def blur(self, t): return self.D0 <= t < self.D1 + 0.1
    def dim(self, t): return 1.0

class Reroute(Torn):    # 第63話: 通行止めの前で、あっち・こっちを向いて「経路再計算」をくり返す
    def face(self, t):
        if t < self.F[0]: return ("open", "open"), None, "mid"
        if t < self.STOP:                      # 反転している間は字が鏡文字になるので、目だけ
            n = sum(1 for f in self.F if t >= f)
            return T("経路", "再計算") if n % 2 == 0 else (("big", "big"), None, "o")
        return T("経路", "再計算" + _dots(t, self.STOP))

class Auth:             # 第65話: 道を聞かれて、画面に鍵マーク「職員認証ガ必要デス」→ 首をふるように左右にゆれる
    dur = 4.8
    empty = False
    A = 2.6
    def motion(self, t):
        if t < 1.3: return _approach(t, 1.0, 230)
        if t < self.A: return {}
        v = t - self.A
        return dict(rot=4 * np.sin(2 * np.pi * 2.2 * v) * np.exp(-v * 0.8), dx=5 * np.sin(2 * np.pi * 2.2 * v) * np.exp(-v * 0.8))
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        return T("職員認証ガ", "必要デス") if int((t - self.A) * 2.5) % 3 else T("認証", "エラー")
    def ring(self, t): return 1.0 if t >= self.A and int(t * 6) % 2 == 0 else 0.4
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Leave:            # 第71話: 「コチラデス」→ ズギュン！と奥へ走り去り、廊下の奥で小さくなる
    dur = 4.8
    empty = True
    sub = 4
    Z0, Z1 = 1.5, 2.3
    def motion(self, t):
        if t < 1.1: return dict(_approach(t, 0.9, 200), sh_y=0)
        if t < self.Z0: return dict(sh_y=0)
        u = ease((t - self.Z0) / (self.Z1 - self.Z0)) if t < self.Z1 else 1.0
        y = -230 * u
        return dict(dx=-120 * u, dy=y, s=1 - 0.72 * u, sh_y=y, flip=False, rot=-4 * (1 - u) * (t < self.Z1))
    def face(self, t):
        if t < self.Z0: return ("happy", "happy"), None, "closed"
        return ("chev", "chev"), None, "tri"
    def ring(self, t): return 1.0 if t >= self.Z0 and int(t * 12) % 2 == 0 else 0.5
    def blur(self, t): return self.Z0 <= t < self.Z1 + 0.1
    def dim(self, t): return 1.0

class Lead(Follow):     # 第72話: 画面に「目的地 405号室」→ 話しかけられても「経路ヲ継続シマス」でまっすぐ進む
    A = 2.2                                     # 画面の字が替わる時刻（歩き出しは WALK から）
    WALK = 0.15
    def motion(self, t):
        a, self.A = self.A, self.WALK
        try: return Follow.motion(self, t)
        finally: self.A = a
    def face(self, t):
        if t < self.A: return T("目的地", "405号室")
        return T("経路ヲ", "継続シマス")

class Pudding:          # 第84話: 「プリンを持ってきてくれ。至急だ」→「！」→ 空回り → 手前へ突進 → キキーッ「プリンデス」
    dur = 6.0
    empty = True
    sub = 4
    H, D0, D1 = 2.6, 3.2, 3.9                   # 聞こえた・突進
    def motion(self, t):
        if t < self.H: return dict(sh_y=0, sy=1 + 0.005 * np.sin(2 * np.pi * 1.3 * t))
        if t < self.D0:
            v = t - self.H
            return dict(dx=6 * np.sin(2 * np.pi * 18 * v), dy=-abs(np.sin(2 * np.pi * 9 * v)) * 6, sh_y=0)
        if t < self.D1:
            u = ease((t - self.D0) / (self.D1 - self.D0))
            return dict(dx=-30 * u, dy=24 * u, s=1 + 0.05 * u, sx=1.06, sy=0.96, sh_y=24 * u)
        m = _settle(t, self.D1, 1.4); m.update(dx=-30, dy=24, s=1.05, sh_y=24); return m
    def cam(self, t): return _shake(t, self.D1, 9, 8), 0.0
    def face(self, t):
        if t < self.H: return ("open", "open"), None, "mid"
        if t < self.D1: return ("big", "big"), None, "o"
        return T("ゴ注文ノ", "プリンデス") if int((t - self.D1) * 2.5) % 3 else (("happy", "happy"), None, "closed")
    def ring(self, t): return 1.0 if t >= self.H and int(t * 12) % 2 == 0 else 0.4
    def blur(self, t): return self.D0 <= t < self.D1 + 0.1
    def dim(self, t): return 1.0

class LowPri:           # 第89話: 遠慮がちなお願いを最後まで聞く →「優先度：低」→ のんびり反対へ去っていく
    dur = 6.6
    empty = True
    A, G0 = 4.4, 5.0
    def motion(self, t):
        if t < 1.2: return dict(_approach(t, 1.0, 230), sh_y=0)
        if t < self.A: return dict(sh_y=0, rot=1.2 * np.sin(2 * np.pi * 0.4 * t))
        if t < self.G0: return dict(_settle(t, self.A, 0.5), sh_y=0)
        u = ease((t - self.G0) / 1.6)
        return dict(dx=220 * u, dy=-60 * u, s=1 - 0.15 * u, flip=True, sh_y=-60 * u, rot=1.5 * np.sin(2 * np.pi * 2 * t))
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        if t < self.G0: return T("優先度", "低")
        return ("happy", "happy"), None, "closed"
    def ring(self, t): return 0.4
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Relay:            # 第90話: 走ってくる →「伝言 要約中」→「指示ニ 誤リアリ」でピカピカ・ガタッ
    dur = 5.0
    empty = False
    S, E = 1.4, 2.6
    def motion(self, t):
        if t < 1.3: return _approach(t, 1.0, 240)
        if t < self.E: return {}
        v = t - self.E
        return dict(dx=8 * np.exp(-v * 4) * np.sin(2 * np.pi * 15 * v), dy=-10 * np.exp(-v * 5))
    def cam(self, t): return _shake(t, self.E, 12, 6), 0.0
    def face(self, t):
        if t < self.S: return ("open", "open"), None, "mid"
        if t < self.E: return T("伝言", "要約中" + _dots(t, self.S))
        return T("指示ニ", "誤リアリ") if int((t - self.E) * 4) % 2 == 0 else T("！")
    def ring(self, t): return 1.0 if t >= self.E and int(t * 10) % 2 == 0 else 0.4
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Pulse:            # 第91話: 画面に心拍の数字 → ドクンドクンに合わせて体がはずむ →「1.8倍」
    dur = 5.0
    empty = False
    S, R = 1.4, 2.6
    BPM = 150
    def _beat(self, t):
        if t < self.S: return 0.0
        ph = ((t - self.S) * self.BPM / 60) % 1
        return np.exp(-ph * 14)
    def motion(self, t):
        if t < 1.3: return _approach(t, 1.0, 230)
        b = self._beat(t)
        return dict(sy=1 + 0.04 * b, sx=1 + 0.025 * b, dy=-8 * b)
    def face(self, t):
        if t < self.S: return ("open", "open"), None, "mid"
        if t < self.R: return T("心拍数", "測定中" + _dots(t, self.S))
        return T("平常時ノ", "1.8倍") if self._beat(t) < 0.5 else T("心拍数", "1.8倍")
    def ring(self, t): return 0.2 + 0.8 * self._beat(t)
    def blur(self, t): return False
    def dim(self, t): return 1.0

class NoErase:          # 第92話: 耳打ちに近寄る → 首をふるように左右にゆれて「記録ハ 消セマセン」
    dur = 5.0
    empty = True
    A = 2.8
    def motion(self, t):
        u = ease(min(t / 1.6, 1))
        m = dict(dx=60 * u, dy=16 * u, s=1 + 0.03 * u, sh_y=16 * u, rot=-5 * u)
        if t >= self.A:
            v = t - self.A
            m.update(rot=-5 * (1 - ease(v / 0.3)) + 4 * np.sin(2 * np.pi * 2.0 * v) * np.exp(-v * 0.6))
        return m
    def face(self, t):
        if t < self.A: return ("open", "open"), None, "mid"
        return T("記録ハ", "消セマセン")
    def ring(self, t): return 0.5
    def blur(self, t): return False
    def dim(self, t): return 1.0

class Guard:            # 第98話: 夜の当直室の前で見張る。時計が進み、空が明ける →「オハヨウゴザイマス」でぴょん
    dur = 6.0
    empty = False
    S, M = 1.6, 4.4                             # 時計が進み始める・朝
    HOURS = ["0:00", "1:00", "2:00", "3:00", "4:00", "5:00", "6:00"]
    def motion(self, t):
        if t < 1.2: return _approach(t, 1.0, 230)
        if t < self.M: return dict(sy=1 + 0.004 * np.sin(2 * np.pi * 0.7 * t))
        return _hop(t, self.M + 0.3, 45)
    def face(self, t):
        if t < self.S: return T("待機中")
        if t < self.M:
            i = min(int((t - self.S) / (self.M - self.S) * len(self.HOURS)), len(self.HOURS) - 1)
            return T("待機中", self.HOURS[i])
        return T("オハヨウ", "ゴザイマス")
    def ring(self, t): return 0.15 if t < self.M else 0.6
    def blur(self, t): return False
    def dim(self, t): return 0.55 + 0.45 * ease((t - self.S) / (self.M - self.S + 0.3))

PATTERNS.update({"アナウンス": Announce, "ギョロ": Glare, "イケボ": Ikebo, "忍者": Ninja, "メニュー": Menu, "カタッ": Wobble,
                 "自己診断": Diag, "夜勤スロット": Slot40, "ピタッ": StopGo, "確信度": Recheck, "経路再計算": Reroute,
                 "職員認証": Auth, "置いてけぼり": Leave, "案内": Lead, "プリン": Pudding, "優先度低": LowPri, "伝言": Relay,
                 "心拍": Pulse, "消せない": NoErase, "見張り": Guard})

WIN = (172 + 6, 937 - 4)   # ショートで使う映像の範囲（mkshort の OP_CROP）。頭の上と足もとに少し余白
CUT = []

def render(pat, out):
    n = int(FPS * pat.dur)
    TINT[0] = tuple(getattr(pat, "tint", (1.0, 1.0, 1.0)))
    HAT[0] = getattr(pat, "hat", None)
    BG = None
    if getattr(pat, "bg", None):                      # 話ごとの背景（_ショート専用/背景/第NN話.png）
        BG = np.array(Image.open(pat.bg).convert("RGB").resize((Ww, Hh), Image.LANCZOS)).astype(np.float32)
    p = subprocess.Popen(["ffmpeg", "-v", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{Ww}x{Hh}",
                          "-r", str(FPS), "-i", "-", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", out],
                         stdin=subprocess.PIPE)
    for i in range(n):
        t = i / FPS
        eyes, brows, mouth = pat.face(t)
        arr = robot_rgba(eyes, brows, mouth, float(np.clip(pat.ring(t), 0, 1)))
        k = getattr(pat, "sub", SUB) if pat.blur(t) else 1
        acc = np.zeros((Hh, Ww, 4), np.float32)
        f0 = pat.motion(t).get("flip", False)        # 向きはコマごとに1つ（ぶれの中で向きが混ざらないように）
        for s in range(k):
            mo_s = dict(pat.motion(t + (s - (k - 1) / 2) / FPS / k)); mo_s["flip"] = f0
            acc += place(arr, **mo_s)
        acc /= k
        al = acc[..., 3:4] / 255
        ys_, xs_ = np.nonzero(acc[..., 3] > 40)            # 体がショートの映像の窓（y172〜937）からはみ出していないか
        if len(ys_) and (ys_.min() < WIN[0] or ys_.max() > WIN[1] or xs_.min() <= 1 or xs_.max() >= Ww - 2):
            CUT.append((round(t, 2), int(ys_.min()), int(ys_.max())))
        mo = pat.motion(t)
        if BG is not None or getattr(pat, "empty", False):
            fl = mo.get("fall", 0)
            if "sh_y" in mo:      # 手前に来る動き: 影は足もとについてくる
                sh = shadow(mo.get("sh_h", 0), mo.get("dx", 0), mo.get("s", 1), mo["sh_y"])
            else:
                sh = shadow(-mo.get("dy", 0), mo.get("dx", 0) + mo.get("fall_side", -1) * fl * 2.2, 1 + fl / 90 * 0.7)
            base = (BG if BG is not None else plate_empty) * (1 - sh[..., None])
        elif getattr(pat, "clean", False):
            base = plate_clean * (1 - shadow(-mo.get("dy", 0))[..., None])
        else:
            base = plate
        frame = (base * (1 - al) + acc[..., :3]) * pat.dim(t)
        if hasattr(pat, "fx"):                        # 体の上に描き足す（霧・相手のロボットなど。2026-10-20）
            frame = pat.fx(t, np.ascontiguousarray(frame, np.float32), mo)
        if hasattr(pat, "cam"):                       # 画面ごと揺らす（ぶつかったとき）
            cx, cy = pat.cam(t)
            if cx or cy:
                frame = cv2.warpAffine(frame, np.float32([[1, 0, cx], [0, 1, cy]]), (Ww, Hh), borderMode=cv2.BORDER_REFLECT)
        p.stdin.write(np.clip(frame, 0, 255).astype(np.uint8).tobytes())
    p.stdin.close(); p.wait()
    print("書き出し:", out)
    if CUT:
        print(f"  ！体が映像の窓からはみ出す（頭・足が切れる）: {len(CUT)}コマ 例 {CUT[:3]}（秒, 上端, 下端）")
    CUT.clear()

if __name__ == "__main__":
    names = sys.argv[1:] or list(PATTERNS)
    for arg in names:                 # 「名前:dur=6.5,TX=2.0」で時刻を上書きできる（声の長さに合わせる）
        nm, _, kv = arg.partition(":")
        pat = PATTERNS[nm]()
        for item in filter(None, kv.split(",")):
            k, v = item.split("=")
            if k == "bg": setattr(pat, k, v)                       # 背景の画像のパス（「,」「=」を含めない）
            elif "/" in v: setattr(pat, k, tuple(float(x) for x in v.split("/")))
            else: setattr(pat, k, float(v))
        render(pat, f"ハコブくん_{nm}.mp4")
