// s14 · Experiment 2: the directed hero hops on a floor drawn as an animation timeline.
// The playhead tracks the hop's own frames (shown at 1/3 speed), and each principle label lights
// on the exact hop frame from hero3d's HOP timing. Then one late take: the playhead rewinds, the
// hero replays the landing 6 frames late, and the squash label lights first, as a ghost; the hero
// touches down 6 frames after it.
import { createHero, heroPose, HOP } from "../hero3d.js";
export default function build(root, k) {
const { make, place, show, cueIn, check, box, overlap, sfx, clamp, lerp, easeInOut, C, win } = k;
const S = "s14";
const snap = (t) => Math.round(t * 60) / 60;
// ---------- timing (hop frames, at 1/3 speed) ----------
const R = 20; // hop frames per second of video: 60 fps / 3
const LATE = 6; // the late take runs the hero 6 frames behind the playhead
const P = [ // label, pinned frame, row (1 is nearest the ruler)
{ key: "anticipation", f: HOP.anticipation[1], row: 2 }, // f4: deepest crouch, still on the floor
{ key: "stretch", f: HOP.push[1], row: 1 }, // f8: scaleY 1.08 in the air
{ key: "arc", f: HOP.apex, row: 3 }, // f21: apex of the arc
{ key: "squash", f: HOP.squash[0], row: 2 }, // f38: scaleY 0.86 on the floor
{ key: "overlap", f: 44, row: 1 }, // f44: tilt peaks while the squash still recovers
{ key: "follow-through", f: HOP.settle, row: 3 }, // f65: everything settled
];
const SQ = P[3];
let SQ_CHIP = null;
const T0 = snap(cueIn(k.id, "follow through") - HOP.settle / R); // f65 lands on "follow-through"
const TEND = T0 + HOP.settle / R;
const TG = snap(cueIn(k.id, "late")); // the ghost lights on "late"
const REW = 0.7, F_TAKE = 28, F_END = 70;
const TT = TG - (SQ.f - F_TAKE) / R, TR = TT - REW; // the take plays at the same 1/3 speed
const TLAND = TT + (HOP.contact + LATE - F_TAKE) / R; // the late touchdown
const tLight = (f) => T0 + f / R;
// Playhead frame fp and hero frame fh at time t (continuous, for motion and blur), snapped to
// 1e-6 so an integer frame is exact (41.9999999 would leave the hero 1e-13 above the floor).
const exact = (x) => Math.round(x * 1e6) / 1e6;
function frames(t) {
const { fp, fh } = rawFrames(t);
return { fp: exact(fp), fh: exact(fh) };
}
function rawFrames(t) {
if (t < T0) return { fp: 0, fh: 0 };
if (t < TR) { const f = Math.min((t - T0) * R, HOP.settle); return { fp: f, fh: f }; }
if (t < TT) { const e = easeInOut(clamp((t - TR) / REW)); return { fp: lerp(HOP.settle, F_TAKE, e), fh: lerp(HOP.settle, F_TAKE - LATE, e) }; }
const fp = Math.min(F_TAKE + (t - TT) * R, F_END);
return { fp, fh: fp - LATE };
}
// Labels and the frame readout change per output frame, so every subframe of a frame agrees.
const quant = (t) => Math.ceil(t * 60 - 1e-6) / 60;
const lit = (t, f) => frames(quant(t)).fp >= f - 1e-6;
const stateAt = (t) => ({ hop: t - frames(t).fh / 60 });
// ---------- the 3D studio ----------
const canvas = place(make("canvas", "", "", root), 0, 0);
const hero = createHero(canvas, { variant: "directed", width: 1080, height: 992, seed: 14, framing: { fill: 262 / 992, center: [0.5, 0.41] } });
hero.render(win.cutIn, {});
const V3 = hero.camera.position.constructor;
const v = new V3();
const proj = (x, y, z) => { v.set(x, y, z).project(hero.camera); return [(v.x + 1) * 540, (1 - v.y) * 496]; };
const VP = proj(0, 0, -400); // where floor lines running away from the camera meet
const YR = Math.round(proj(0, 0, 0.62)[1]); // the ruler lies on the floor in front of the hero
// ---------- the floor timeline ----------
const FMAX = 70, X0 = 90, X1 = 990;
const X = (f) => X0 + (f / FMAX) * (X1 - X0);
const NS = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(NS, "svg");
svg.setAttribute("width", 1080); svg.setAttribute("height", 992);
Object.assign(svg.style, { position: "absolute", left: "0px", top: "0px", overflow: "visible" });
root.appendChild(svg);
const el = (tag, attrs, parent = svg) => {
const n = document.createElementNS(NS, tag);
for (const [a, b] of Object.entries(attrs)) n.setAttribute(a, b);
parent.appendChild(n);
return n;
};
// Ruler line and frame ticks, leaning toward the vanishing point so they lie on the floor.
el("line", { x1: X0, y1: YR, x2: X1, y2: YR, stroke: C.ink, "stroke-width": 2.5, "stroke-linecap": "round" });
for (let f = 0; f <= FMAX; f++) {
const x = X(f), len = f % 10 === 0 ? 26 : f % 5 === 0 ? 17 : 10;
const dx = VP[0] - x, dy = VP[1] - YR, d = Math.hypot(dx, dy);
el("line", { x1: x, y1: YR, x2: x + (dx / d) * len, y2: YR + (dy / d) * len, stroke: f % 10 === 0 ? C.ink : C.muted,
"stroke-width": f % 10 === 0 ? 2.5 : 1.6, "stroke-linecap": "round" });
}
// The dotted arc: the hero's centre, frame by frame, through the flight (f6-f36).
const arcFrames = [];
for (let f = HOP.liftoff; f <= HOP.contact; f++) arcFrames.push(f);
const dots = arcFrames.map(() => el("circle", { r: 4.5, fill: C.accent, opacity: 0 }));
const centre = (p) => [p.x, p.y + 0.5 * p.sy, 0];
// Pinned frames on the ruler, and the late take's gap.
const pins = P.map((p) => el("circle", { cx: X(p.f), cy: YR, r: 7, fill: C.accent, opacity: 0 }));
const gap = el("line", { x1: X(SQ.f), y1: YR, x2: X(SQ.f + LATE), y2: YR, stroke: C.red, "stroke-width": 7, "stroke-linecap": "round", opacity: 0 });
// Labels: chips below the ruler, each on a stem from its frame.
const ROW = (r) => YR + 72 + (r - 1) * 64;
const chips = P.map((p, i) => {
const stem = make("div", "", "", root, { position: "absolute", left: `${X(p.f) - 1.5}px`, top: `${YR}px`, width: "3px",
height: `${ROW(p.row) - YR}px`, background: C.faint, borderRadius: "2px" });
const chip = make("div", "chip", `f${p.f}${p.key}`, root,
{ position: "absolute", top: `${ROW(p.row)}px`, fontSize: "28px", padding: "8px 18px", gap: "10px" });
if (p.f === HOP.settle) chip.style.right = `${1080 - (X(p.f) + 26)}px`;
else chip.style.left = `${p.f === HOP.anticipation[1] ? X0 : X(p.f) - 26}px`;
chip.dataset.label = p.key;
return { ...p, i, stem, chip };
});
SQ_CHIP = chips[3];
const lateNote = make("div", "chip red", `6 flate`, root,
{ position: "absolute", left: "762px", top: `${ROW(SQ.row)}px`, fontSize: "28px", padding: "8px 18px" });
// Playhead: a line across the ruler and a frame tag beneath it.
const head = make("div", "", "", root, { position: "absolute", top: `${YR - 30}px`, width: "4px", height: "38px", background: C.accent, borderRadius: "2px" });
const tag = make("div", "mono", "f0", root, { position: "absolute", top: `${YR + 12}px`, width: "92px", height: "46px", lineHeight: "46px",
textAlign: "center", fontSize: "28px", fontWeight: "600", color: "#fff", background: C.accent, borderRadius: "23px" });
tag.id = "s14-tag";
const legend = place(make("div", "small", `65 frames · shown at 1/3 speed`, root,
{ fontSize: "28px" }), X0, 36);
// An invisible marker on the hero's centre, for the speed graph and the checks.
const track = make("div", "", "", root, { position: "absolute", width: "2px", height: "2px" });
track.id = "s14-hero";
// ---------- cues ----------
const tChips = cueIn(k.id, "classic");
const tLegend = cueIn(k.id, "character");
const tNote = TG + 0.15; // secondary: 150 ms after the ghost
sfx(tLight(HOP.contact) + 1 / 60, "tap"); // touchdown
sfx(TG + 1 / 60, "pop"); // the ghost label lights
sfx(TLAND + 1 / 60, "tap"); // the late touchdown, 6 frames after its label
// ---------- seek ----------
const seek = (t) => {
const st = stateAt(t);
hero.render(t, st);
const { fp, fh } = frames(t);
const fq = frames(quant(t)).fp;
// Hero centre on screen.
const pose = hero.pose(t, st);
const [hx, hy] = proj(...centre(pose));
track.style.left = `${(hx - 1).toFixed(2)}px`;
track.style.top = `${(hy - 1).toFixed(2)}px`;
// Dotted arc: traced up to the hero's frame in the first pass, then kept; hidden near the hero.
const traced = t < T0 ? -1 : t < TR ? fh : HOP.contact;
arcFrames.forEach((f, i) => {
const p = heroPose(f / 60, { hop: 0 });
const c = centre(p);
const near = Math.hypot(c[0] - pose.x, c[1] - (pose.y + 0.5 * pose.sy)) < 0.64;
const [x, y] = proj(...c);
dots[i].setAttribute("cx", x.toFixed(2));
dots[i].setAttribute("cy", y.toFixed(2));
dots[i].setAttribute("opacity", f <= traced + 1e-6 && !near ? "0.85" : "0");
});
// Playhead.
head.style.left = `${(X(fp) - 2).toFixed(2)}px`;
tag.style.left = `${(X(fp) - 46).toFixed(2)}px`;
tag.textContent = `f${Math.floor(fq + 1e-6)}`;
// Labels: enter as a list on "classic principles", light when the playhead reaches their frame.
// In the late take only the squash label resets. The others keep their light but go quiet:
// a white chip with accent text (6:1 contrast, so they stay readable), stems and pins at 40%.
const late = t >= TR;
const dim = 1 - 0.6 * easeInOut(clamp((t - TR) / 0.4));
chips.forEach((c) => {
const isSq = c === SQ_CHIP;
const on = late && !isSq ? true : lit(t, c.f);
const ghost = on && isSq && t >= TT;
const quiet = late && !isSq;
show(c.chip, t, tChips + c.i * 0.06, Infinity, { dur: 0.5, dy: 12 });
c.stem.style.opacity = (show(c.stem, t, tChips + c.i * 0.06, Infinity, { dur: 0.5, dy: 0 }) * (isSq ? 1 : dim)).toFixed(4);
c.chip.dataset.lit = on ? (ghost ? "ghost" : "1") : "0";
Object.assign(c.chip.style, ghost
? { background: C.paper, color: C.red, boxShadow: "none", outline: `2.5px dashed ${C.red}`, outlineOffset: "-2.5px" }
: quiet ? { background: C.paper, color: C.accent, boxShadow: `inset 0 0 0 2px ${C.accentSoft}`, outline: "none" }
: on ? { background: C.accent, color: "#fff", boxShadow: "none", outline: "none" }
: { background: C.paper, color: C.muted, boxShadow: "", outline: "none" });
c.stem.style.background = ghost ? C.red : on ? C.accent : C.faint;
pins[c.i].setAttribute("opacity", on ? (isSq ? 1 : dim).toFixed(3) : "0");
pins[c.i].setAttribute("fill", ghost ? C.red : C.accent);
});
gap.setAttribute("opacity", show(lateNote, t, tNote, Infinity, { dur: 0.45, dy: 8 }).toFixed(3));
show(legend, t, tLegend, Infinity, { dur: 0.5, dy: 10 });
};
// ---------- checks ----------
const poseAt = (t) => hero.pose(t, stateAt(t));
const tagAt = (f) => {
const b = box(tag);
return Math.abs(b.cx - X(f)) < 1 && tag.textContent === `f${f}` ? true : `playhead at ${b.cx.toFixed(1)} "${tag.textContent}", want ${X(f).toFixed(1)} "f${f}"`;
};
const lights = (c, cond) => (t) => {
if (c.chip.dataset.lit !== "1") return `${c.key} not lit (${c.chip.dataset.lit})`;
if (lit(t - 1 / 60, c.f)) return `${c.key} already lit on the frame before`;
const pt = tagAt(c.f);
if (pt !== true) return pt;
if (Math.abs(frames(t).fh - c.f) > 1e-6) return `hero on f${frames(t).fh}, label on f${c.f}`;
return cond(poseAt(t));
};
const [cA, cS, cArc, cSq, cO, cF] = chips;
check(`${S}: anticipation lights at f4 on the crouch (scaleY <= 0.9, still on the floor)`, tLight(cA.f), lights(cA,
(p) => (p.sy <= 0.9 && p.y === 0 && Math.abs(p.x + 0.6) < 1e-9) || `sy ${p.sy.toFixed(3)} y ${p.y} x ${p.x}`));
check(`${S}: stretch lights at f8 in the air (scaleY >= 1.05)`, tLight(cS.f), lights(cS,
(p) => (p.sy >= 1.05 && p.y > 0.1) || `sy ${p.sy.toFixed(3)} y ${p.y.toFixed(3)}`));
check(`${S}: arc lights at f21, the apex`, tLight(cArc.f), lights(cArc, (p) => {
const y = (f) => heroPose(f / 60, { hop: 0 }).y;
return (Math.abs(p.y - HOP.height) < 1e-9 && y(20) < p.y && y(22) < p.y) || `y ${p.y.toFixed(4)}`;
}));
check(`${S}: squash lights at f38 on the floor (scaleY <= 0.9)`, tLight(cSq.f), lights(cSq,
(p) => (p.sy <= 0.9 && p.y === 0) || `sy ${p.sy.toFixed(3)} y ${p.y}`));
check(`${S}: overlap lights at f44, tilt at its peak while the squash still recovers`, tLight(cO.f), lights(cO, (p) => {
const end = heroPose(5, { hop: 0 }).x;
return (p.tilt >= 3.5 && p.sy < 0.95 && Math.abs(p.x - end) < 0.002) || `tilt ${p.tilt.toFixed(2)} sy ${p.sy.toFixed(3)}`;
}));
check(`${S}: follow-through lights at f65, settled, and the hero holds still until the rewind`, tLight(cF.f), lights(cF, (p) => {
for (let tt = tLight(cF.f); tt < TR; tt += 1 / 60) {
const q = poseAt(tt);
if (Math.abs(q.sy - 1) >= 0.003 || Math.abs(q.tilt) >= 0.15) return `moving at ${tt.toFixed(3)}: sy ${q.sy.toFixed(4)} tilt ${q.tilt.toFixed(3)}`;
}
return (Math.abs(p.sy - 1) < 0.003 && Math.abs(p.tilt) < 0.15) || `sy ${p.sy.toFixed(4)} tilt ${p.tilt.toFixed(3)}`;
}));
check(`${S}: hero rests on f0 until the hop`, T0, () => {
const p = poseAt(T0 - 1 / 60);
return (p.sy === 1 && p.y === 0 && tag.textContent === "f0") || `sy ${p.sy} tag ${tag.textContent}`;
});
check(`${S}: late take, the squash label lights as a ghost while the hero is still in the air`, TG, () => {
const p = poseAt(TG);
if (cSq.chip.dataset.lit !== "ghost") return `squash label ${cSq.chip.dataset.lit}`;
if (lit(TG - 1 / 60, cSq.f)) return "ghost lit a frame early";
if (tagAt(cSq.f) !== true) return tagAt(cSq.f);
return (p.y > 0.3 && Math.abs(frames(TG).fh - (cSq.f - LATE)) < 1e-6) || `hero y ${p.y.toFixed(3)} on f${frames(TG).fh}`;
});
check(`${S}: late take, the hero touches down 6 frames after the ghost (playhead f42) and squashes on f44`, [TLAND - 3 / 60, TLAND + 7 / 60], (t) => {
const p = poseAt(t), fp = frames(t).fp;
// "On the floor" within 1e-9 heights: hero3d recomputes the frame as (t - hop) * 60, which can
// leave 1e-13 of float error (about 3e-11 px).
if (fp < HOP.contact + LATE - 1e-6) return p.y > 1e-9 || `on the floor early at playhead f${fp.toFixed(2)}`;
if (p.y > 1e-9) return `still in the air at playhead f${fp.toFixed(2)}: y ${p.y}`;
if (Math.abs(fp - (SQ.f + LATE)) < 1e-6) return p.sy <= 0.9 || `no squash at playhead f44: sy ${p.sy.toFixed(3)}`;
return true;
});
check(`${S}: hero holds still from the end of the late take to the cut`, [TT + (F_END - F_TAKE) / R, win.cutOut - 1 / 60], (t) => {
const p = poseAt(t);
return (Math.abs(p.sy - 1) < 0.003 && Math.abs(p.tilt) < 0.2 && p.y === 0) || `sy ${p.sy.toFixed(4)} tilt ${p.tilt.toFixed(3)}`;
});
check(`${S}: labels, note, playhead tag and legend never overlap and stay on the stage`, [TG + 1, win.cutOut - 1 / 60], () => {
const items = [...chips.map((c) => c.chip), lateNote, tag, legend];
for (let i = 0; i < items.length; i++) {
const b = box(items[i]);
if (b.x < 40 || b.x + b.w > 1040 || b.y + b.h > 128 + 980) return `${items[i].textContent} off the stage: ${JSON.stringify(b)}`;
for (let j = i + 1; j < items.length; j++) if (overlap(items[i], items[j]) > 0) return `${items[i].textContent} overlaps ${items[j].textContent}`;
}
for (const c of chips) for (const d of [...chips.map((x) => x.chip), lateNote]) if (d !== c.chip && overlap(c.stem, d) > 0) return `${c.key} stem crosses ${d.textContent}`;
return true;
});
check(`${S}: every label sits below the ruler, clear of the hero's path`, TEND, () => {
const floor = proj(0, 0, 0.35)[1] + 128;
for (const c of chips) if (box(c.chip).y < floor) return `${c.key} above the floor line`;
return true;
});
return seek;
}