import { ArchiveScene } from "/src/scene.ts"; import { selectionWave as baselineWave } from "./baseline-motion.ts"; import { selectionWave as currentWave } from "/src/motion.ts"; import { columnFiles } from "/src/data.ts"; import { wrap } from "/src/archive-loop.ts"; const $ = (s) => document.querySelector(s), duration = 192, dt = 1 / 60, startTime = 8; const scenes = [ new ArchiveScene($("#before .scene"), currentWave, false), new ArchiveScene($("#after .scene"), currentWave, true), ]; const draws = scenes.map((s) => s.composer.render.bind(s.composer)); // Seek by simulating at 60 Hz; only the requested frame is drawn. scenes.forEach((s) => { s.composer.render = () => {}; }); const fields = [ "lift", "rail", "shoulder", "laneFocus", "columnCamera", "selectedCell", "coordinateOrigin", "pulses", "pendingPulse", "rotation", "targetRotation", "returnY", "detail", "targetDetail", "reveal", "targetReveal", "last", "clock", "idleGain", "pulseGain", "lastInteraction", "scanTime", "scanBlend", "selectedSlot", "looping", "canInspect", "clearance", ]; let saved = [], frame = 0, index = 0, events = [], samples = [], maxFrame = 0, playing = false, ready = false, lastWall = 0, accumulator = 0; const files = columnFiles(2); const schedules = { next: [[1, 1]], previous: [[1, -1]], repeat: [ [1, 1], [19, 1], [37, 1], [55, 1], ], reverse: [ [1, 1], [25, -1], ], }; function snapshot(s) { return { state: Object.fromEntries(fields.map((k) => [k, structuredClone(s[k])])), camera: s.camera.position.clone(), aim: s.cameraAim.clone(), fov: s.camera.fov, }; } function reset() { scenes.forEach((s, i) => { s.setMode("hidden"); for (const key of fields) s[key] = structuredClone(saved[i].state[key]); s.camera.position.copy(saved[i].camera); s.cameraAim.copy(saved[i].aim); s.camera.fov = saved[i].fov; s.camera.lookAt(s.cameraAim); s.camera.updateProjectionMatrix(); s.camera.updateMatrixWorld(); s.drawLabel(0); s.pointer.set(0, 0); }); frame = 0; index = 0; } function advance() { frame++; const action = events.find(([f]) => f === frame); if (action) { const direction = action[1]; index = files[wrap(files.indexOf(index) + direction, files.length)]; scenes.forEach((s) => s.select(index, { axis: "row", direction })); } scenes.forEach((s) => s.update(startTime + frame * dt)); } function setPlaying(value) { playing = value; accumulator = 0; lastWall = 0; $("#play").textContent = value ? "暂停" : "同步播放"; } function seek(target) { target = Math.max(0, Math.min(duration, target)); if (target <= frame) reset(); while (frame < target) advance(); // The first frame is the checkpoint, prior to the first input. if (target === 0) { scenes.forEach((s) => s.update(startTime)); reset(); } render(); } function render() { scenes.forEach((s, i) => { draws[i](); const [x, y] = s.projectCard(-2.5, 3.7), view = $(["#before", "#after"][i]); const zoom = $("#zoom").checked ? 1.7 : 1; const px = (x / 1920 - 0.34) * zoom + 0.34, py = (y / 1080 - 0.3) * zoom + 0.3; const marker = view.querySelector(".marker"); marker.style.left = `${px * 100}%`; marker.style.top = `${py * 100}%`; $(["#before-phase", "#after-phase"][i]).textContent = s.pendingPulse ? "档案抬起中" : s.pulses.length ? "波浪传播中" : "就位"; }); $("#timeline").value = String(frame); $("#stamp").textContent = `${(frame * dt).toFixed(2)} / ${(duration * dt).toFixed(2)} 秒`; const delta = scenes[1].model.position.y - scenes[0].model.position.y; const replayError = Math.max( Math.abs(scenes[0].model.position.y - samples[frame].before), Math.abs(scenes[1].model.position.y - samples[frame].after), ); $("#check").textContent = replayError < 1e-7 ? "同步回放校验通过" : "回放校验异常"; $("#check").dataset.replayError = String(replayError); $("#difference").textContent = `${delta >= 0 ? "+" : ""}${delta.toFixed(3)} · ${((Math.abs(delta) / 3.7) * 100).toFixed(1)}%`; const max = samples[maxFrame].after - samples[maxFrame].before; $("#explanation").textContent = Math.abs(delta) < 0.001 ? `此刻两版几乎重合。最大高度差出现在 ${(maxFrame * dt).toFixed(2)} 秒,为卡片高度的 ${((Math.abs(max) / 3.7) * 100).toFixed(1)}%。` : `右侧选中档案比左侧${delta >= 0 ? "高" : "低"} ${Math.abs(delta).toFixed(3)} 个场景单位,占卡片高度的 ${((Math.abs(delta) / 3.7) * 100).toFixed(1)}%。观察右侧先抬起档案,再带动周围起伏的顺序。`; const x = 56 + (frame / duration) * 1044; $("#cursor").setAttribute("x1", x); $("#cursor").setAttribute("x2", x); } function chart() { // The zero checkpoint still owns the old file. Plot from the first input so // changing ownership is not misrepresented as a downward motion of one file. const origin = samples[1].before, values = samples .slice(1) .flatMap((p) => [p.before - origin, p.after - origin]); const min = Math.floor((Math.min(...values) - 0.03) * 10) / 10, max = Math.ceil((Math.max(...values) + 0.03) * 10) / 10; const x = (f) => 56 + (f / duration) * 1044, y = (v) => 154 - ((v - origin - min) / (max - min)) * 130; const path = (key) => samples .slice(1) .map( (p, f) => `${f ? "L" : "M"}${x(f + 1).toFixed(2)},${y(p[key]).toFixed(2)}`, ) .join(" "); let markup = ""; for (let i = 0; i <= 4; i++) { const v = min + ((max - min) * i) / 4, py = y(v + origin); markup += `${v.toFixed(2)}`; } for (let i = 0; i <= duration; i += 30) markup += `${(i * dt).toFixed(1)}s`; for (const [f] of events) markup += ``; markup += ``; $("#chart").innerHTML = markup; } function prepare() { setPlaying(false); events = schedules[$("#scenario").value]; scenes[0].selectionPulse = $("#baseline").value === "original" ? baselineWave : currentWave; reset(); // The checkpoint has already been rendered into the selected model. scenes.forEach((s) => s.update(startTime)); reset(); samples = [ { before: scenes[0].model.position.y, after: scenes[1].model.position.y }, ]; while (frame < duration) { advance(); samples.push({ before: scenes[0].model.position.y, after: scenes[1].model.position.y, }); } maxFrame = samples.reduce( (best, p, f) => Math.abs(p.after - p.before) > Math.abs(samples[best].after - samples[best].before) ? f : best, 0, ); chart(); seek(0); } $("#play").onclick = () => { if (frame === duration) seek(0); setPlaying(!playing); }; $("#restart").onclick = () => { seek(0); setPlaying(true); }; $("#prev").onclick = () => { setPlaying(false); seek(frame - 1); }; $("#next").onclick = () => { setPlaying(false); seek(frame + 1); }; $("#peak").onclick = () => { setPlaying(false); seek(maxFrame); }; $("#timeline").oninput = (e) => { setPlaying(false); seek(Number(e.target.value)); }; $("#scenario").onchange = () => { if (ready) { prepare(); setPlaying(true); } }; $("#baseline").onchange = () => { if (ready) { prepare(); setPlaying(true); } }; $("#zoom").onchange = () => { document .querySelectorAll(".view") .forEach((v) => v.classList.toggle("zoomed", $("#zoom").checked)); if (ready) render(); }; $("#chart").onclick = (e) => { if (!ready) return; const rect = $("#chart").getBoundingClientRect(); setPlaying(false); seek( Math.round( ((((e.clientX - rect.left) / rect.width) * 1120 - 56) / 1044) * duration, ), ); }; function resize() { scenes.forEach((s) => { s.resize(); // These are real-size comparison panels, not the scaled 1920px app stage. const ratio = Math.min(devicePixelRatio, 1.5); s.renderer.setPixelRatio(ratio); s.composer.setPixelRatio(ratio); }); if (ready) render(); } addEventListener("resize", resize); document.addEventListener("keydown", (e) => { if (!ready || ["INPUT", "SELECT", "BUTTON", "A"].includes(e.target.tagName)) return; if (e.code === "Space") { e.preventDefault(); $("#play").click(); } if (e.code === "ArrowLeft" || e.code === "ArrowRight") { e.preventDefault(); setPlaying(false); seek(frame + (e.code === "ArrowLeft" ? -1 : 1)); } }); function tick(wall) { if (playing && ready && !document.hidden) { if (lastWall) accumulator += Math.min((wall - lastWall) / 1000, 0.1) * Number($("#speed").value); const count = Math.floor(accumulator / dt); if (count) { accumulator -= count * dt; let target = frame + count; if (target > duration) { if ($("#loop").checked) { reset(); target %= duration; } else { target = duration; setPlaying(false); } } seek(target); } } lastWall = wall; requestAnimationFrame(tick); } try { await document.fonts.ready; await Promise.all(scenes.map((s) => s.load())); scenes.forEach((s) => { s.setMode("archive"); s.select(0); for (let f = 1; f <= 480; f++) s.update(f * dt); }); // Start both runs with the exact same resting state and no residual pulses. scenes.forEach((s) => { s.pulses = []; s.idleGain = 0; s.lastInteraction = startTime; s.update(startTime); }); saved = scenes.map(snapshot); resize(); ready = true; document .querySelectorAll(".transport button,#timeline") .forEach((el) => (el.disabled = false)); prepare(); setPlaying(true); requestAnimationFrame(tick); } catch (error) { $("#explanation").textContent = `场景加载失败:${error.message}`; $("#play").textContent = "加载失败"; console.error(error); }