// The on-screen robot. Consumes logical degrees (same tables as the real // robot) and maps them to cartoon 2D transforms. All motion goes through // tweenTo/playFrames — both are last-wins (starting a new one cancels the // previous) and abort-aware. Rounded pose values are mirrored to data-* // attributes on the root: that is the e2e assertion surface. import type { LogicalPoseDeg, PoseTargetDeg, PuppetFrame } from "../robot/adapter"; import { INIT_LOGICAL_POSE, clampPose } from "../robot/poses"; import { buildPuppetSvg, type PuppetSvgParts } from "./svg"; // Cartoon mapping constants (px or ° of screen effect per logical degree). const HEAD_TX_PER_YAW = 0.9; const EYES_TX_PER_YAW = 0.45; const HEAD_TY_PER_PITCH = 0.85; const HEAD_ROT_PER_ROLL = -0.8; // roll+ = robot's right ear down = viewer-left dip (CCW) const BODY_ROT_PER_YAW = 0.18; const STRIPE_TX_PER_YAW = 0.55; const ANTENNA_TOUCH_LATCH_MS = 1500; const FRAME_LEAD_IN_MS = 400; function easeInOutCubic(u: number): number { return u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2; } export type MotionOutcome = "done" | "aborted"; export class Puppet { private parts: PuppetSvgParts; private current: LogicalPoseDeg = { ...INIT_LOGICAL_POSE }; private activeToken = 0; private touchedUntil = 0; private audioEl: HTMLAudioElement | null = null; constructor(host: HTMLElement) { this.parts = buildPuppetSvg(); host.appendChild(this.parts.svg); this.wireAntennaTaps(); this.applyPose(this.current); } get pose(): LogicalPoseDeg { return { ...this.current }; } get element(): SVGSVGElement { return this.parts.svg; } antennaTouched(): boolean { return performance.now() < this.touchedUntil; } /** Set the pose immediately (also used per-frame by tweens). */ setPose(pose: LogicalPoseDeg): void { this.current = clampPose(pose); this.applyPose(this.current); } /** Smoothly move to `target` over `ms`. Last-wins; freezes on abort. */ tweenTo(target: PoseTargetDeg, ms: number, signal?: AbortSignal): Promise { const token = ++this.activeToken; const from = { ...this.current }; const to = clampPose({ ...from, ...target }); if (ms <= 0) { this.setPose(to); return Promise.resolve("done"); } return new Promise((resolve) => { const t0 = performance.now(); const tick = (): void => { if (token !== this.activeToken || signal?.aborted) { resolve("aborted"); return; } const u = Math.min(1, (performance.now() - t0) / ms); const e = easeInOutCubic(u); this.setPose({ headRoll: from.headRoll + (to.headRoll - from.headRoll) * e, headPitch: from.headPitch + (to.headPitch - from.headPitch) * e, headYaw: from.headYaw + (to.headYaw - from.headYaw) * e, bodyYaw: from.bodyYaw + (to.bodyYaw - from.bodyYaw) * e, antennaRight: from.antennaRight + (to.antennaRight - from.antennaRight) * e, antennaLeft: from.antennaLeft + (to.antennaLeft - from.antennaLeft) * e, }); if (u >= 1) resolve("done"); else requestAnimationFrame(tick); }; requestAnimationFrame(tick); }); } /** * Replay an emotion trajectory: lead-in tween to frame 0, optional * audio, then lerp through the recorded frames on the rAF clock. */ async playFrames( frames: readonly PuppetFrame[], audioUrl: string | null, signal?: AbortSignal, ): Promise { if (frames.length === 0) return "done"; const first = frames[0]!; const lead = await this.tweenTo(first, FRAME_LEAD_IN_MS, signal); if (lead === "aborted") return "aborted"; const token = ++this.activeToken; this.parts.svg.setAttribute("data-anim", "emotion"); if (audioUrl) { this.audioEl = new Audio(audioUrl); // Best-effort: browsers that can't decode the container (Safari/ogg) // reject play() — the motion still runs, just silently. this.audioEl.play().catch(() => {}); } const outcome = await new Promise((resolve) => { const t0 = performance.now(); const lastT = frames[frames.length - 1]!.t; let idx = 0; const tick = (): void => { if (token !== this.activeToken || signal?.aborted) { resolve("aborted"); return; } const t = (performance.now() - t0) / 1000; while (idx < frames.length - 2 && frames[idx + 1]!.t <= t) idx++; const a = frames[idx]!; const b = frames[Math.min(idx + 1, frames.length - 1)]!; const span = b.t - a.t; const u = span > 0 ? Math.min(1, Math.max(0, (t - a.t) / span)) : 1; this.setPose({ headRoll: a.headRoll + (b.headRoll - a.headRoll) * u, headPitch: a.headPitch + (b.headPitch - a.headPitch) * u, headYaw: a.headYaw + (b.headYaw - a.headYaw) * u, bodyYaw: a.bodyYaw + (b.bodyYaw - a.bodyYaw) * u, antennaRight: a.antennaRight + (b.antennaRight - a.antennaRight) * u, antennaLeft: a.antennaLeft + (b.antennaLeft - a.antennaLeft) * u, }); if (t >= lastT) resolve("done"); else requestAnimationFrame(tick); }; requestAnimationFrame(tick); }); this.stopAudio(); this.parts.svg.setAttribute("data-anim", "idle"); return outcome; } /** Cancel any active tween/frame replay (pose freezes) and audio. */ cancelActive(): void { this.activeToken++; this.stopAudio(); this.parts.svg.setAttribute("data-anim", "idle"); } private stopAudio(): void { if (this.audioEl) { this.audioEl.pause(); this.audioEl.src = ""; this.audioEl = null; } } private wireAntennaTaps(): void { for (const g of [this.parts.antRight, this.parts.antLeft]) { g.addEventListener("pointerdown", (ev) => { ev.preventDefault(); this.touchedUntil = performance.now() + ANTENNA_TOUCH_LATCH_MS; this.parts.svg.setAttribute("data-ant-touched", "1"); g.classList.remove("pp-ant-tapped"); // Force a reflow so re-adding the class restarts the CSS wiggle. void (g as unknown as HTMLElement).getBoundingClientRect(); g.classList.add("pp-ant-tapped"); setTimeout(() => { if (!this.antennaTouched()) this.parts.svg.setAttribute("data-ant-touched", "0"); }, ANTENNA_TOUCH_LATCH_MS + 50); }); } } private applyPose(p: LogicalPoseDeg): void { const { body, stripe, head, eyes, antRight, antLeft, svg } = this.parts; body.setAttribute("transform", `rotate(${(p.bodyYaw * BODY_ROT_PER_YAW).toFixed(2)} 120 240)`); stripe.setAttribute("transform", `translate(${(p.bodyYaw * STRIPE_TX_PER_YAW).toFixed(2)} 0)`); head.setAttribute( "transform", `translate(${(p.headYaw * HEAD_TX_PER_YAW).toFixed(2)} ${(p.headPitch * HEAD_TY_PER_PITCH).toFixed(2)}) ` + `rotate(${(p.headRoll * HEAD_ROT_PER_ROLL).toFixed(2)} 120 150)`, ); eyes.setAttribute("transform", `translate(${(p.headYaw * EYES_TX_PER_YAW).toFixed(2)} 0)`); antRight.setAttribute("transform", `rotate(${p.antennaRight.toFixed(2)} 86 64)`); antLeft.setAttribute("transform", `rotate(${p.antennaLeft.toFixed(2)} 154 64)`); svg.setAttribute("data-head-roll", String(Math.round(p.headRoll))); svg.setAttribute("data-head-pitch", String(Math.round(p.headPitch))); svg.setAttribute("data-head-yaw", String(Math.round(p.headYaw))); svg.setAttribute("data-body-yaw", String(Math.round(p.bodyYaw))); svg.setAttribute("data-ant-r", String(Math.round(p.antennaRight))); svg.setAttribute("data-ant-l", String(Math.round(p.antennaLeft))); } }