reachy-blocks / src /puppet /puppet.ts
Claude
feat: SVG puppet, VirtualRobotAdapter, pose tables, test harness
6b2de60 unverified
Raw
History Blame Contribute Delete
7.76 kB
// 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)));
}
}