Spaces:
Running
Running
| // 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 <svg> 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<MotionOutcome> { | |
| 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<MotionOutcome> { | |
| 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<MotionOutcome>((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))); | |
| } | |
| } | |