feat(motion): raise the cruising speed, and make the show gates follow it
Pieces still read as slow. Cruising speed goes from 120ms per unit to 85: the median departure across the shipped fifty drops 970ms → 763ms and a level's whole animation budget 15.6s → 12.7s, about 18% off. The longest departures do not move — the cap holds them at 3.46s — so if those still drag, the dial is the cap, which was settled separately and is left alone. The gates that guarantee a level opens with something worth watching are stated in milliseconds, and they were picked while a piece crossed a unit in 120ms. Held fixed while the speed changes, they stop meaning what they meant: this change alone made 20 of the 50 shipped levels "fail" an opening gate every one of them had passed, purely from the unscaled number. Scaled to the dial, none fail — the set is unchanged, and the alarm was an artifact of the constant. So the cruise threshold and the opening floor now derive from the speed, naming the speed they were calibrated at. exitTiming takes msPerUnit the way it already took knee and maxMs, and the comparison lab gets a speed slider, reads the levels actually shipping rather than the prototype sweep they were selected from, and totals every route in the campaign instead of the four on screen — those four skew long, where the cap flattens any speed change to 6%. The test that pins constant cruising speed now reads the dial from the token instead of hard-coding 120, so tuning motion no longer looks like a broken guarantee. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+42
-18
@@ -54,10 +54,15 @@ canvas{width:100%;height:120px;display:block;background:var(--inset);border-radi
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>离场演出时长 · 对比台</h1>
|
||||
<p class="lede">同一条真实路线,左边是<b>现状(不封顶,恒定速度)</b>,右边是<b>候选设置</b>。两边同时播放,直接看哪个手感对。路线取自当前关卡集,按长度挑了四条有代表性的。</p>
|
||||
<p class="lede">同一条真实路线,左边是<b>线上已发布的设定</b>,右边是<b>候选设定</b>。两边同时播放,直接看哪个手感对。路线取自当前关卡集,按长度挑了四条有代表性的。<br>封顶(拐点 / 上限)之前已经定过,这一轮要定的是<b>巡航速度</b>——它决定 90% 的中短路线播多久。</p>
|
||||
|
||||
<div class="panel">
|
||||
<div class="ctrls">
|
||||
<div class="fld">
|
||||
<label>巡航速度:每单位距离多少毫秒,越小越快</label>
|
||||
<input id="speed" type="range" min="50" max="140" step="5" value="85">
|
||||
<span class="v mono" id="speed-v">85ms</span>
|
||||
</div>
|
||||
<div class="fld">
|
||||
<label>拐点 knee:低于这个时长完全不变</label>
|
||||
<input id="knee" type="range" min="600" max="4000" step="100" value="2400">
|
||||
@@ -72,10 +77,11 @@ canvas{width:100%;height:120px;display:block;background:var(--inset);border-radi
|
||||
<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>
|
||||
<button data-s="120">改之前 120ms</button>
|
||||
<button data-s="100">100ms</button>
|
||||
<button data-s="85" class="on">现在 85ms</button>
|
||||
<button data-s="70">70ms</button>
|
||||
<button data-s="60">很快 60ms</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,8 +101,10 @@ 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.
|
||||
let population = [];
|
||||
async function loadRoutes() {
|
||||
const res = await fetch('../../levels/prototypes/d5.json?t=' + Date.now());
|
||||
// The levels being shipped, not the prototype sweep they were selected from.
|
||||
const res = await fetch('../../levels/campaign-50.json?t=' + Date.now());
|
||||
const raw = await res.json();
|
||||
const levels = Array.isArray(raw) ? raw : raw.levels;
|
||||
const all = [];
|
||||
@@ -112,6 +120,10 @@ async function loadRoutes() {
|
||||
}
|
||||
}
|
||||
all.sort((a, b) => a.distance - b.distance);
|
||||
// The four rows are picked to be representative, not to be a sample you can
|
||||
// average: they skew long, and long routes are cap-bound, so a speed change
|
||||
// barely moves their total. The summary totals every route instead.
|
||||
population = all;
|
||||
const at = p => all[Math.min(all.length - 1, Math.round(p * (all.length - 1)))];
|
||||
return [
|
||||
{ label: '很短(贴边就走)', ...at(0.10) },
|
||||
@@ -154,20 +166,23 @@ function draw(canvas, route, progress) {
|
||||
}
|
||||
|
||||
function settings() {
|
||||
return { knee: Number($('knee').value), maxMs: Number($('max').value) };
|
||||
return { msPerUnit: Number($('speed').value), knee: Number($('knee').value), maxMs: Number($('max').value) };
|
||||
}
|
||||
// The left column is what players have right now: every token at its shipped
|
||||
// value, which is what exitTiming uses when given no overrides at all.
|
||||
const SHIPPED = {};
|
||||
|
||||
function render() {
|
||||
const s = settings();
|
||||
rowsEl.innerHTML = routes.map((r, i) => {
|
||||
const now = exitTiming(r.route, { knee: Infinity, maxMs: Infinity });
|
||||
const now = exitTiming(r.route, SHIPPED);
|
||||
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 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('');
|
||||
@@ -177,9 +192,14 @@ function render() {
|
||||
}
|
||||
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。低于拐点的路线完全不受影响,手感和现在一样。`;
|
||||
const sum = opts => population.reduce((n, r) => n + exitTiming(r.route, opts).duration, 0);
|
||||
const total = sum(s2), totalNow = sum(SHIPPED);
|
||||
const med = opts => { const v = population.map(r => exitTiming(r.route, opts).duration).sort((a, b) => a - b); return v[Math.floor(v.length / 2)]; };
|
||||
$('summary').textContent = `候选:巡航 ${s2.msPerUnit}ms/单位、拐点 ${(s2.knee / 1000).toFixed(1)}s、上限 ${(s2.maxMs / 1000).toFixed(1)}s。`
|
||||
+ `按全部 ${population.length} 条可离场路线算:中位单次 ${med(SHIPPED).toFixed(0)}ms → ${med(s2).toFixed(0)}ms,`
|
||||
+ `总时长 ${total < totalNow ? '快 ' + Math.round(100 - 100 * total / totalNow) + '%' : total > totalNow ? '慢 ' + Math.round(100 * total / totalNow - 100) + '%' : '一样'}。`
|
||||
+ `最长那条 ${(exitTiming(worst.route, SHIPPED).duration / 1000).toFixed(2)}s → ${(exitTiming(worst.route, s2).duration / 1000).toFixed(2)}s`
|
||||
+ `${exitTiming(worst.route, s2).capped ? '(被上限锁住,调速度动不了它,要动就改上限)' : ''}。`;
|
||||
}
|
||||
|
||||
function play() {
|
||||
@@ -188,7 +208,7 @@ function play() {
|
||||
const s = settings();
|
||||
const start = performance.now();
|
||||
routes.forEach((r, i) => {
|
||||
const now = exitTiming(r.route, { knee: Infinity, maxMs: Infinity });
|
||||
const now = exitTiming(r.route, SHIPPED);
|
||||
const next = exitTiming(r.route, s);
|
||||
const step = () => {
|
||||
const t = performance.now() - start;
|
||||
@@ -207,11 +227,15 @@ for (const id of ['knee', 'max']) {
|
||||
render();
|
||||
};
|
||||
}
|
||||
$('speed').oninput = () => {
|
||||
$('speed-v').textContent = $('speed').value + 'ms';
|
||||
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';
|
||||
const b = e.target.closest('button'); if (!b || !b.dataset.s) return;
|
||||
$('speed').value = b.dataset.s;
|
||||
$('speed-v').textContent = b.dataset.s + 'ms';
|
||||
document.querySelectorAll('#presets button').forEach(x => x.classList.remove('on'));
|
||||
b.classList.add('on');
|
||||
render();
|
||||
|
||||
Reference in New Issue
Block a user