Files
HexTrails/labs/exit-timing/index.html
T
cabinwandClaude Opus 5 a250baba53 fix(motion): stop a long departure from playing for seventeen seconds
Exit timing was linear in path length with no ceiling, so the longest route in
the shipped set animated for 17.4s and 19% of all departures ran past two
seconds. MOTION already carried exitMaxTravelMs and exitLongMaxMs for this;
nothing read them.

Below the knee nothing changes — every piece still cruises at the same speed,
which is the property the old test protected and the reason motion reads as
physical. Past it the piece accelerates rather than taking longer, approaching
a ceiling. Length is still legible: longer routes still play longer, just with
a compressing tail.

The test now states the rule it is protecting instead of "no duration cap":
constant speed below the knee, a bounded and monotone tail above it.

labs/exit-timing plays the same real route side by side, uncapped against a
candidate setting, with knee and ceiling on sliders — the numbers say 3.5s but
only watching it says whether that is right. Defaults are provisional at
2.4s / 3.2s pending that call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 16:41:06 +08:00

228 lines
10 KiB
HTML

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>离场演出时长 · 对比台</title>
<style>
:root{
--ground:#EEF1F4; --surface:#FFF; --inset:#E3E8ED;
--ink:#141E28; --ink2:#48596A; --ink3:#6B7A88;
--rule:#D3DAE1; --rail:#435F78; --good:#236E64; --bad:#B23A26;
--track:#5A7286; --token:#E0603F;
color-scheme:light;
}
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){
--ground:#0F161C; --surface:#141D25; --inset:#0A1015;
--ink:#E4EAEF; --ink2:#AEBDC9; --ink3:#8494A2;
--rule:#28343F; --rail:#8CB0CC; --good:#4FC7B7; --bad:#E4836C;
--track:#8FA6BA; --token:#FF8A66;
color-scheme:dark;
}}
*{box-sizing:border-box}
body{margin:0;background:var(--ground);color:var(--ink);
font-family:"PingFang SC","Hiragino Sans GB",ui-sans-serif,system-ui,-apple-system,sans-serif;line-height:1.7}
.wrap{max-width:1000px;margin:0 auto;padding:36px 24px 80px}
code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-variant-numeric:tabular-nums}
h1{font-size:27px;margin:0 0 10px;letter-spacing:-.02em}
.lede{color:var(--ink2);font-size:14.5px;margin:0 0 24px;max-width:68ch}
.panel{background:var(--surface);border:1px solid var(--rule);border-radius:12px;padding:16px 18px;margin:0 0 22px}
.ctrls{display:flex;gap:18px;flex-wrap:wrap;align-items:flex-end}
.fld{display:flex;flex-direction:column;gap:4px}
.fld label{font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--ink3)}
.fld input[type=range]{width:190px;accent-color:var(--rail)}
.fld .v{font-family:ui-monospace,monospace;font-size:16px;font-weight:600}
button{font:14px system-ui;color:#fff;background:var(--rail);border:0;border-radius:9px;padding:9px 18px;cursor:pointer}
button.ghost{background:transparent;color:var(--rail);border:1px solid var(--rail)}
button:disabled{opacity:.45;cursor:default}
.presets{display:flex;gap:8px;flex-wrap:wrap}
.presets button{font-size:12.5px;padding:6px 12px;background:var(--inset);color:var(--ink2)}
.presets button.on{background:var(--rail);color:#fff}
.row{background:var(--surface);border:1px solid var(--rule);border-radius:12px;padding:14px 16px;margin:0 0 14px}
.row h3{margin:0 0 2px;font-size:14.5px}
.row .meta{font-size:12px;color:var(--ink3);margin-bottom:10px}
.pair{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.cell{text-align:center}
.cell .cap{font-size:11.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--ink3);margin-bottom:4px}
.cell .ms{font-family:ui-monospace,monospace;font-size:19px;font-weight:600;margin-top:2px}
.cell.now .ms{color:var(--bad)} .cell.next .ms{color:var(--good)}
canvas{width:100%;height:120px;display:block;background:var(--inset);border-radius:8px}
.note{font-size:13px;color:var(--ink2);margin-top:16px}
@media(max-width:640px){.pair{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="wrap">
<h1>离场演出时长 · 对比台</h1>
<p class="lede">同一条真实路线,左边是<b>现状(不封顶,恒定速度)</b>,右边是<b>候选设置</b>。两边同时播放,直接看哪个手感对。路线取自当前关卡集,按长度挑了四条有代表性的。</p>
<div class="panel">
<div class="ctrls">
<div class="fld">
<label>拐点 knee:低于这个时长完全不变</label>
<input id="knee" type="range" min="600" max="4000" step="100" value="2400">
<span class="v mono" id="knee-v">2.4s</span>
</div>
<div class="fld">
<label>上限 max:再长也不超过</label>
<input id="max" type="range" min="1200" max="6000" step="100" value="3200">
<span class="v mono" id="max-v">3.2s</span>
</div>
<button id="play">▶ 同步播放</button>
<button id="again" class="ghost">重播</button>
</div>
<div class="presets" id="presets" style="margin-top:14px">
<button data-k="800" data-m="1700">紧凑 0.8 / 1.7</button>
<button data-k="1600" data-m="2400">偏快 1.6 / 2.4</button>
<button data-k="2400" data-m="3200" class="on">中等 2.4 / 3.2</button>
<button data-k="3000" data-m="4200">宽松 3.0 / 4.2</button>
</div>
</div>
<div id="rows"></div>
<p class="note" id="summary"></p>
</div>
<script type="module">
import { trace, rotateBoard } from '../../src/core.js';
import { routePoints } from '../../src/geometry.js';
import { exitTiming, travelProgress } from '../../src/exit-animation.js';
const $ = id => document.getElementById(id);
const rowsEl = $('rows');
let routes = [], anims = [];
// Pick four real routes spanning the length range, so the comparison is about
// content the game actually ships rather than a synthetic straight line.
async function loadRoutes() {
const res = await fetch('../../levels/prototypes/d5.json?t=' + Date.now());
const raw = await res.json();
const levels = Array.isArray(raw) ? raw : raw.levels;
const all = [];
for (const level of levels) {
for (let turn = 0; turn < 6; turn++) {
const board = rotateBoard(level, turn);
for (const tile of board.tiles) {
const r = trace(board, tile, [], true);
if (r.status !== 'exit') continue;
const t = exitTiming(r, { knee: Infinity, maxMs: Infinity });
all.push({ route: r, distance: t.distance, uncapped: t.duration, level: level.id });
}
}
}
all.sort((a, b) => a.distance - b.distance);
const at = p => all[Math.min(all.length - 1, Math.round(p * (all.length - 1)))];
return [
{ label: '很短(贴边就走)', ...at(0.10) },
{ label: '中等', ...at(0.60) },
{ label: '长', ...at(0.93) },
{ label: '最长的那一档', ...at(0.999) },
];
}
function draw(canvas, route, progress) {
const dpr = Math.min(devicePixelRatio || 1, 2), rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * dpr); canvas.height = Math.round(rect.height * dpr);
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
const pts = routePoints({ ...route, end: null }, { size: 1, ox: 0, oy: 0 });
if (pts.length < 2) return;
const xs = pts.map(p => p.x), ys = pts.map(p => p.y);
const minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys);
const pad = 14;
const scale = Math.min((rect.width - pad * 2) / Math.max(1e-6, maxX - minX), (rect.height - pad * 2) / Math.max(1e-6, maxY - minY));
const X = x => pad + (x - minX) * scale + (rect.width - pad * 2 - (maxX - minX) * scale) / 2;
const Y = y => pad + (y - minY) * scale + (rect.height - pad * 2 - (maxY - minY) * scale) / 2;
const css = getComputedStyle(document.documentElement);
ctx.strokeStyle = css.getPropertyValue('--track').trim();
ctx.lineWidth = 2.5; ctx.lineJoin = 'round'; ctx.lineCap = 'round';
ctx.beginPath(); ctx.moveTo(X(pts[0].x), Y(pts[0].y));
for (const p of pts.slice(1)) ctx.lineTo(X(p.x), Y(p.y));
ctx.stroke();
// Position along the polyline by arc length, matching the real motion.
const segs = pts.slice(1).map((p, i) => Math.hypot(p.x - pts[i].x, p.y - pts[i].y));
const total = segs.reduce((a, b) => a + b, 0);
let want = progress * total, i = 0;
while (i < segs.length && want > segs[i]) { want -= segs[i]; i++; }
const a = pts[Math.min(i, pts.length - 1)], b = pts[Math.min(i + 1, pts.length - 1)];
const f = segs[i] ? want / segs[i] : 0;
const px = X(a.x + (b.x - a.x) * f), py = Y(a.y + (b.y - a.y) * f);
ctx.fillStyle = css.getPropertyValue('--token').trim();
ctx.beginPath(); ctx.arc(px, py, 7, 0, Math.PI * 2); ctx.fill();
}
function settings() {
return { knee: Number($('knee').value), maxMs: Number($('max').value) };
}
function render() {
const s = settings();
rowsEl.innerHTML = routes.map((r, i) => {
const now = exitTiming(r.route, { knee: Infinity, maxMs: Infinity });
const next = exitTiming(r.route, s);
return `<div class="row">
<h3>${r.label}</h3>
<div class="meta mono">路径距离 ${r.distance.toFixed(1)} · 来自 ${r.level}</div>
<div class="pair">
<div class="cell now"><div class="cap">现状 · 不封顶</div><canvas id="c-now-${i}"></canvas><div class="ms">${(now.duration / 1000).toFixed(2)}s</div></div>
<div class="cell next"><div class="cap">候选设置</div><canvas id="c-next-${i}"></canvas><div class="ms">${(next.duration / 1000).toFixed(2)}s</div></div>
</div>
</div>`;
}).join('');
for (let i = 0; i < routes.length; i++) {
draw($(`c-now-${i}`), routes[i].route, 0);
draw($(`c-next-${i}`), routes[i].route, 0);
}
const s2 = settings();
const worst = routes[routes.length - 1];
$('summary').textContent = `当前设置:拐点 ${(s2.knee / 1000).toFixed(1)}s、上限 ${(s2.maxMs / 1000).toFixed(1)}s。`
+ `最长那条路线从 ${(exitTiming(worst.route, { knee: Infinity, maxMs: Infinity }).duration / 1000).toFixed(1)}s 压到 `
+ `${(exitTiming(worst.route, s2).duration / 1000).toFixed(1)}s。低于拐点的路线完全不受影响,手感和现在一样。`;
}
function play() {
anims.forEach(id => cancelAnimationFrame(id));
anims = [];
const s = settings();
const start = performance.now();
routes.forEach((r, i) => {
const now = exitTiming(r.route, { knee: Infinity, maxMs: Infinity });
const next = exitTiming(r.route, s);
const step = () => {
const t = performance.now() - start;
draw($(`c-now-${i}`), r.route, Math.min(1, travelProgress(Math.min(1, t / now.travelMs), now.travelMs)));
draw($(`c-next-${i}`), r.route, Math.min(1, travelProgress(Math.min(1, t / next.travelMs), next.travelMs)));
if (t < Math.max(now.travelMs, next.travelMs)) anims.push(requestAnimationFrame(step));
};
anims.push(requestAnimationFrame(step));
});
}
for (const id of ['knee', 'max']) {
$(id).oninput = () => {
$(`${id}-v`).textContent = (Number($(id).value) / 1000).toFixed(1) + 's';
document.querySelectorAll('#presets button').forEach(b => b.classList.remove('on'));
render();
};
}
$('presets').onclick = e => {
const b = e.target.closest('button'); if (!b) return;
$('knee').value = b.dataset.k; $('max').value = b.dataset.m;
$('knee-v').textContent = (b.dataset.k / 1000).toFixed(1) + 's';
$('max-v').textContent = (b.dataset.m / 1000).toFixed(1) + 's';
document.querySelectorAll('#presets button').forEach(x => x.classList.remove('on'));
b.classList.add('on');
render();
};
$('play').onclick = play;
$('again').onclick = play;
addEventListener('resize', render);
routes = await loadRoutes();
render();
</script>
</body>
</html>