// s20 When things break, and the homework. // At the cut: two real frames from this skill's walkthrough example, just before and just after the // moment its Invite button broke (f1110 and f1122, the review stills that caught the empty gray box), // and below them the render contract at 45%, its three rules and an empty fourth slot. // On "compare" an arrow draws between the frames: "describe the difference". On "after it" the // difference pins as a red note. On "Every fix" a green fix chip. Hero move: on "joins the contract" // the chip flies into the empty slot on an arc (spring ζ 0.82) and the contract comes up to full. // On "The homework" the frames leave (ease-in, 40% shorter than the entrances), the contract steps // back, and the homework card arrives; its three boxes tick green on "make one", "two notes" and // "post every prompt". export default async function build(root, k) { const { make, place, show, cueIn, note, spring01, easeOut, easeIn, p01, lerp, check, sfx, box, overlap, C } = k; const { cutIn, cutOut } = k.win; const FPS = 60; const tCompare = cueIn(k.id, "compare"); const tAfter = cueIn(k.id, "after it"); const tEvery = cueIn(k.id, "Every fix"); const tJoins = cueIn(k.id, "joins the contract"); const tHome = cueIn(k.id, "The homework"); // Each tick lands on a frame (the first frame at or after its cue), so its sound can follow by exactly one. const tTicks = [cueIn(k.id, "make one"), cueIn(k.id, "two notes"), cueIn(k.id, "post every prompt")].map((t) => Math.ceil(t * FPS - 1e-6) / FPS); const FLY = { duration: 1.3, damping: 0.82 }; // the hero move: about 0.9 s of travel const DIM = 0.45; // ================= phase 1: the two frames ================= const top = make("div", "", "", root, { position: "absolute", left: "0px", top: "0px", width: "1080px", height: "560px" }); const FW = 420, FH = 269, FY = 76, FX = [80, 580]; // Crop of the 1920x1080 still: x 56–97%, y 16–62%: the Invite button, the box it grows into, the pointer. const SRC = ["assets/wt-f1110.jpg", "assets/wt-f1122.jpg"]; const FR = [1110, 1122]; // 18.50 s and 18.70 s at 60 fps const CROP = { x0: 0.56, x1: 0.97, y0: 0.16 }; const IW = FW / (CROP.x1 - CROP.x0), IH = IW * 1080 / 1920; const imgs = []; FX.forEach((x, i) => { const lab = place(make("div", "", `f${FR[i]}  ${i ? "just after" : "just before"}`, top, { fontSize: "30px" }), x, 24); const frame = place(make("div", "", "", top, { overflow: "hidden", borderRadius: "18px", boxShadow: "var(--shadow)", background: C.paper }), x, FY, FW, FH); const img = make("img", "", "", frame, { position: "absolute", left: `${(-CROP.x0 * IW).toFixed(2)}px`, top: `${(-CROP.y0 * IH).toFixed(2)}px`, width: `${IW.toFixed(2)}px`, height: `${IH.toFixed(2)}px` }); img.src = SRC[i]; imgs.push({ lab, frame, img }); }); // The arrow between them, 2.5 px ink, and its instruction under the gap. const SVGNS = "http://www.w3.org/2000/svg"; const arrow = document.createElementNS(SVGNS, "svg"); arrow.setAttribute("width", "60"); arrow.setAttribute("height", "40"); Object.assign(arrow.style, { position: "absolute", left: "510px", top: `${FY + FH / 2 - 20}px`, overflow: "visible" }); arrow.innerHTML = ``; top.appendChild(arrow); const arrowPath = arrow.firstElementChild; const describe = make("div", "", "describe the difference", top, { position: "absolute", left: "0px", width: "1080px", top: `${FY + FH + 18}px`, textAlign: "center", fontSize: "30px", fontWeight: "600" }); const diff = place(note(top, FR[1], "the label is gone and the box is empty gray", "red"), 0, FY + FH + 76); Object.assign(diff.style, { fontSize: "28px" }); // ================= the contract ================= const CY = 568, CH = 390; const contract = place(make("div", "card", "", root), 80, CY, 920, CH); place(make("div", "h2", "Render contract", contract), 40, 30); const RULES = ["No clocks or timers", "Seeded randomness", "Frames 0 to N−1"]; const ROW0 = 112, ROWH = 64; RULES.forEach((r, i) => { place(make("div", "mono", String(i + 1), contract, { fontSize: "30px", color: C.faint, fontWeight: "600" }), 40, ROW0 + i * ROWH + 2); place(make("div", "body", r, contract), 84, ROW0 + i * ROWH); }); place(make("div", "mono", "4", contract, { fontSize: "30px", color: C.faint, fontWeight: "600" }), 40, ROW0 + 3 * ROWH + 6); const SLOT_X = 84, SLOT_Y = ROW0 + 3 * ROWH - 4; const slot = place(make("div", "", "", contract, { border: `2.5px dashed ${C.faint}`, borderRadius: "999px" }), SLOT_X, SLOT_Y, 760, 54); // ================= the fix chip: appears under the note, then flies into slot 4 ================= const fix = make("div", "chip green", `fixa shape keeps its content while it changes`, root, { position: "absolute", fontSize: "28px", left: "0px", top: "0px" }); fix.dataset.track = "s20-fix"; // ================= phase 2: the homework ================= const home = place(make("div", "card", "", root), 80, 40, 920, 470); place(make("div", "h2", "The homework", home), 40, 32); place(make("div", "", "Experiment 3 is yours", home, { fontSize: "30px", color: C.muted, fontWeight: "500" }), 40, 92); const ITEMS = ["Make one", "Write 2 notes with frame numbers", "Post every prompt"]; const items = ITEMS.map((txt, i) => { const row = place(make("div", "", "", home), 40, 168 + i * 96, 840, 64); const boxEl = place(make("div", "", "", row, { border: `2.5px solid ${C.ink}`, borderRadius: "14px" }), 0, 4, 56, 56); boxEl.innerHTML = ``; const label = place(make("div", "", txt, row, { fontSize: "40px", fontWeight: "600", letterSpacing: "-0.01em", whiteSpace: "nowrap" }), 84, 6); return { row, boxEl, tick: boxEl.querySelector("path"), label }; }); tTicks.forEach((t) => sfx(t + 1 / FPS, "toggle")); // each box turns green on its cue frame; its sound one frame later await document.fonts.ready; await Promise.all(imgs.map(({ img }) => img.decode())); // Fix chip: its start under the note and its mark in slot 4 (stage coordinates). const fixW = fix.offsetWidth, fixH = fix.offsetHeight; diff.style.left = `${((1080 - diff.offsetWidth) / 2).toFixed(2)}px`; // centred under the instruction const FIX0 = { x: (1080 - fixW) / 2, y: FY + FH + 150 }; const FIX1 = { x: 80 + SLOT_X + (760 - fixW) / 2, y: CY + SLOT_Y + (54 - fixH) / 2 }; const flyAt = (t) => spring01(t, tJoins, FLY); let flySettle = 0; const travel = Math.hypot(FIX1.x - FIX0.x, FIX1.y - FIX0.y); for (let f = 0; f < 240; f++) if (travel * Math.abs(1 - spring01(f / FPS, 0, FLY)) > 0.5) flySettle = f + 1; const tLanded = tJoins + flySettle / FPS; // No sound on the chip's landing: it settles softly into its slot, and the scene's three sounds // (the limit) belong to the three ticks. // ---- checks ---- const EXIT = 0.3; check("s20 cut is a finished picture: both real frames decoded, labelled, the contract behind", cutIn, () => (imgs.every(({ img }) => img.complete && img.naturalWidth === 1920) && getComputedStyle(contract).visibility !== "hidden") || "missing"); check(`s20 fix chip lands in slot 4 (±0.5 px) by f${Math.round(tLanded * FPS)} and stays`, [tLanded, cutOut - 1 / FPS], () => { const b = box(fix), s = box(slot); const dx = b.cx - s.cx, dy = b.cy - s.cy; return (Math.abs(dx) < 0.5 && Math.abs(dy) < 0.5) || `off by ${dx.toFixed(2)}, ${dy.toFixed(2)}`; }); tTicks.forEach((tt, i) => check(`s20 box ${i + 1} ticks on its cue, not before`, [tt - 2 / FPS, tt + 0.5], (t) => { const green = items[i].boxEl.style.background === "rgb(31, 138, 91)"; return t < tt ? (!green || "green before its cue") : t >= tt + 0.2 ? (green || "not green") : true; })); check("s20 the frames and the homework card are never on screen together", [tHome - 1 / FPS, tHome + EXIT + 0.6], () => !(Number(top.style.opacity) > 0.01 && Number(home.style.opacity) > 0.01) || `frames ${top.style.opacity} card ${home.style.opacity}`); check("s20 phase 1 notes, frames and chip never overlap", [cutIn, tJoins], () => { const els = [imgs[0].frame, imgs[1].frame, describe, diff, fix, contract]; for (let i = 0; i < els.length; i++) for (let j = i + 1; j < els.length; j++) if (overlap(els[i], els[j]) > 0) return `${i} x ${j}`; return true; }); check("s20 the homework card clears the contract and the stage edges", [tHome, cutOut - 1 / FPS], () => { const b = box(home), c = box(contract); return (overlap(home, contract) === 0 && b.y >= 128 && c.y + c.h <= 1120) || "overlap or outside"; }); check("s20 everything stays inside the stage", [cutIn, cutOut - 1 / FPS], () => [imgs[0].frame, imgs[1].frame, describe, diff, fix, contract, home].every((el) => { if (getComputedStyle(el).visibility === "hidden") return true; const b = box(el); return b.x >= 0 && b.x + b.w <= 1080 && b.y >= 128 && b.y + b.h <= 1120; }) || "outside"); return (t) => { // Phase 1. The frames and their labels are on screen from the cut. const a = easeOut(p01(t, tCompare, 0.5)); arrowPath.setAttribute("stroke-dashoffset", (1 - a).toFixed(4)); arrow.style.visibility = a > 0 ? "visible" : "hidden"; show(describe, t, tCompare + 0.3, Infinity, { dur: 0.5, dy: 10 }); show(diff, t, tAfter, Infinity, { dur: 0.45, dx: -16, dy: 0 }); // The frames and notes leave on "The homework": ease-in, 0.3 s. const out = easeIn(p01(t, tHome, EXIT)); top.style.opacity = (1 - out).toFixed(4); top.style.transform = `translateY(${(-14 * out).toFixed(2)}px)`; top.style.visibility = out >= 1 ? "hidden" : "visible"; // The contract: 45% until the fix joins it, full while it lands, back to 45% for the homework. const up = easeOut(p01(t, tJoins + 0.3, 0.5)), back = easeOut(p01(t, tHome, 0.4)); contract.style.opacity = (DIM + (1 - DIM) * up - (1 - DIM) * back * up).toFixed(4); slot.style.opacity = (1 - easeOut(p01(t, tJoins + 0.5, 0.3))).toFixed(4); // The fix chip: enters under the note, then flies to slot 4 on an arc (it bows 90 px right). const vin = show(fix, t, tEvery, Infinity, { dur: 0.45, dx: -16, dy: 0 }); const s = flyAt(t); const x = lerp(FIX0.x, FIX1.x, s) + 90 * Math.sin(Math.PI * Math.min(1, Math.max(0, s))); const y = lerp(FIX0.y, FIX1.y, s); fix.style.left = `${x.toFixed(2)}px`; fix.style.top = `${y.toFixed(2)}px`; // After landing it belongs to the contract, so it steps back with it. fix.style.opacity = (vin * (1 - (1 - DIM) * back * up)).toFixed(4); // Phase 2: the homework card, its rows 60 ms apart, then the ticks. // It enters only once the frames have left, so the two never overlap legibly. show(home, t, tHome + EXIT, Infinity, { dur: 0.5, dy: 16 }); items.forEach((it, i) => { show(it.row, t, tHome + EXIT + 0.1 + 0.06 * i, Infinity, { dur: 0.45, dy: 10 }); const g = t >= tTicks[i]; it.boxEl.style.background = g ? C.green : "transparent"; it.boxEl.style.borderColor = g ? C.green : C.ink; it.tick.setAttribute("stroke-dashoffset", (1 - easeOut(p01(t, tTicks[i], 0.18))).toFixed(4)); const pop = g ? 1 + 0.08 * Math.sin(Math.PI * Math.min(1, (t - tTicks[i]) * FPS / 6)) : 1; it.boxEl.style.transform = `scale(${pop.toFixed(4)})`; }); }; }