"""ハコブくんの短い動き(ショートの冒頭用)。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")