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>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1>离场演出时长 · 对比台</h1>
|
<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="panel">
|
||||||
<div class="ctrls">
|
<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">
|
<div class="fld">
|
||||||
<label>拐点 knee:低于这个时长完全不变</label>
|
<label>拐点 knee:低于这个时长完全不变</label>
|
||||||
<input id="knee" type="range" min="600" max="4000" step="100" value="2400">
|
<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>
|
<button id="again" class="ghost">重播</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="presets" id="presets" style="margin-top:14px">
|
<div class="presets" id="presets" style="margin-top:14px">
|
||||||
<button data-k="800" data-m="1700">紧凑 0.8 / 1.7</button>
|
<button data-s="120">改之前 120ms</button>
|
||||||
<button data-k="1600" data-m="2400">偏快 1.6 / 2.4</button>
|
<button data-s="100">100ms</button>
|
||||||
<button data-k="2400" data-m="3200" class="on">中等 2.4 / 3.2</button>
|
<button data-s="85" class="on">现在 85ms</button>
|
||||||
<button data-k="3000" data-m="4200">宽松 3.0 / 4.2</button>
|
<button data-s="70">70ms</button>
|
||||||
|
<button data-s="60">很快 60ms</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -95,8 +101,10 @@ let routes = [], anims = [];
|
|||||||
|
|
||||||
// Pick four real routes spanning the length range, so the comparison is about
|
// Pick four real routes spanning the length range, so the comparison is about
|
||||||
// content the game actually ships rather than a synthetic straight line.
|
// content the game actually ships rather than a synthetic straight line.
|
||||||
|
let population = [];
|
||||||
async function loadRoutes() {
|
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 raw = await res.json();
|
||||||
const levels = Array.isArray(raw) ? raw : raw.levels;
|
const levels = Array.isArray(raw) ? raw : raw.levels;
|
||||||
const all = [];
|
const all = [];
|
||||||
@@ -112,6 +120,10 @@ async function loadRoutes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
all.sort((a, b) => a.distance - b.distance);
|
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)))];
|
const at = p => all[Math.min(all.length - 1, Math.round(p * (all.length - 1)))];
|
||||||
return [
|
return [
|
||||||
{ label: '很短(贴边就走)', ...at(0.10) },
|
{ label: '很短(贴边就走)', ...at(0.10) },
|
||||||
@@ -154,20 +166,23 @@ function draw(canvas, route, progress) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function settings() {
|
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() {
|
function render() {
|
||||||
const s = settings();
|
const s = settings();
|
||||||
rowsEl.innerHTML = routes.map((r, i) => {
|
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);
|
const next = exitTiming(r.route, s);
|
||||||
return `<div class="row">
|
return `<div class="row">
|
||||||
<h3>${r.label}</h3>
|
<h3>${r.label}</h3>
|
||||||
<div class="meta mono">路径距离 ${r.distance.toFixed(1)} · 来自 ${r.level}</div>
|
<div class="meta mono">路径距离 ${r.distance.toFixed(1)} · 来自 ${r.level}</div>
|
||||||
<div class="pair">
|
<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 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 next"><div class="cap">候选设定</div><canvas id="c-next-${i}"></canvas><div class="ms">${(next.duration / 1000).toFixed(2)}s</div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
@@ -177,9 +192,14 @@ function render() {
|
|||||||
}
|
}
|
||||||
const s2 = settings();
|
const s2 = settings();
|
||||||
const worst = routes[routes.length - 1];
|
const worst = routes[routes.length - 1];
|
||||||
$('summary').textContent = `当前设置:拐点 ${(s2.knee / 1000).toFixed(1)}s、上限 ${(s2.maxMs / 1000).toFixed(1)}s。`
|
const sum = opts => population.reduce((n, r) => n + exitTiming(r.route, opts).duration, 0);
|
||||||
+ `最长那条路线从 ${(exitTiming(worst.route, { knee: Infinity, maxMs: Infinity }).duration / 1000).toFixed(1)}s 压到 `
|
const total = sum(s2), totalNow = sum(SHIPPED);
|
||||||
+ `${(exitTiming(worst.route, s2).duration / 1000).toFixed(1)}s。低于拐点的路线完全不受影响,手感和现在一样。`;
|
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() {
|
function play() {
|
||||||
@@ -188,7 +208,7 @@ function play() {
|
|||||||
const s = settings();
|
const s = settings();
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
routes.forEach((r, i) => {
|
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 next = exitTiming(r.route, s);
|
||||||
const step = () => {
|
const step = () => {
|
||||||
const t = performance.now() - start;
|
const t = performance.now() - start;
|
||||||
@@ -207,11 +227,15 @@ for (const id of ['knee', 'max']) {
|
|||||||
render();
|
render();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
$('speed').oninput = () => {
|
||||||
|
$('speed-v').textContent = $('speed').value + 'ms';
|
||||||
|
document.querySelectorAll('#presets button').forEach(b => b.classList.remove('on'));
|
||||||
|
render();
|
||||||
|
};
|
||||||
$('presets').onclick = e => {
|
$('presets').onclick = e => {
|
||||||
const b = e.target.closest('button'); if (!b) return;
|
const b = e.target.closest('button'); if (!b || !b.dataset.s) return;
|
||||||
$('knee').value = b.dataset.k; $('max').value = b.dataset.m;
|
$('speed').value = b.dataset.s;
|
||||||
$('knee-v').textContent = (b.dataset.k / 1000).toFixed(1) + 's';
|
$('speed-v').textContent = b.dataset.s + 'ms';
|
||||||
$('max-v').textContent = (b.dataset.m / 1000).toFixed(1) + 's';
|
|
||||||
document.querySelectorAll('#presets button').forEach(x => x.classList.remove('on'));
|
document.querySelectorAll('#presets button').forEach(x => x.classList.remove('on'));
|
||||||
b.classList.add('on');
|
b.classList.add('on');
|
||||||
render();
|
render();
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
|
|||||||
import { dirname, resolve } from 'node:path';
|
import { dirname, resolve } from 'node:path';
|
||||||
import { Game, random } from '../src/core.js';
|
import { Game, random } from '../src/core.js';
|
||||||
import { createRouteBoard, ROUTE_FAMILIES } from '../src/route-grammar.js';
|
import { createRouteBoard, ROUTE_FAMILIES } from '../src/route-grammar.js';
|
||||||
import { createDeepLock, growDeepLevel, deepLevelStats, deepLevelKey, deadTurnAudit, contrastStats, showStats } from '../src/deep-lock.js';
|
import { createDeepLock, growDeepLevel, deepLevelStats, deepLevelKey, deadTurnAudit, contrastStats, showStats, openingShowFloor } from '../src/deep-lock.js';
|
||||||
|
|
||||||
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, v] = a.replace(/^--/, '').split('='); return [k, v ?? true]; }));
|
const args = Object.fromEntries(process.argv.slice(2).map(a => { const [k, v] = a.replace(/^--/, '').split('='); return [k, v ?? true]; }));
|
||||||
const SEEDS = Number(args.seeds ?? 8), TARGET = Number(args.target ?? 12), KEEP = Number(args.keep ?? 18);
|
const SEEDS = Number(args.seeds ?? 8), TARGET = Number(args.target ?? 12), KEEP = Number(args.keep ?? 18);
|
||||||
@@ -84,7 +84,10 @@ const grownEnough = rows.filter(r => r.stats.pieces >= MIN_PIECES);
|
|||||||
// an agent give up. It rejects exactly the levels every model stalled on.
|
// an agent give up. It rejects exactly the levels every model stalled on.
|
||||||
const MIN_RELEASE = Number(args.minRelease ?? 2);
|
const MIN_RELEASE = Number(args.minRelease ?? 2);
|
||||||
const MIN_CONTRAST = Number(args.minContrast ?? 12);
|
const MIN_CONTRAST = Number(args.minContrast ?? 12);
|
||||||
const MIN_OPENING_SHOW = Number(args.minOpeningShow ?? 800);
|
// Expressed at the speed it was calibrated at (120ms per unit) and scaled to
|
||||||
|
// whatever the speed dial is now, so re-tuning motion does not quietly reject
|
||||||
|
// levels that were fine. See openingShowFloor in deep-lock.
|
||||||
|
const MIN_OPENING_SHOW = openingShowFloor(Number(args.minOpeningShow ?? 800));
|
||||||
const MIN_OPENING_CHOICES = Number(args.minOpeningChoices ?? 3);
|
const MIN_OPENING_CHOICES = Number(args.minOpeningChoices ?? 3);
|
||||||
const MIN_CRUISES = Number(args.minCruises ?? 3);
|
const MIN_CRUISES = Number(args.minCruises ?? 3);
|
||||||
// Rotation count is deliberately not capped: turning is free and it is the
|
// Rotation count is deliberately not capped: turning is free and it is the
|
||||||
@@ -116,7 +119,7 @@ console.log(`\n【门槛】长成 ${grownEnough.length} → 首转双向 ${g1.le
|
|||||||
const g6 = g5.filter(r => r.contrast.contrast >= MIN_CONTRAST);
|
const g6 = g5.filter(r => r.contrast.contrast >= MIN_CONTRAST);
|
||||||
const g7 = g6.filter(r => r.show.openingChoices >= MIN_OPENING_CHOICES);
|
const g7 = g6.filter(r => r.show.openingChoices >= MIN_OPENING_CHOICES);
|
||||||
const g8 = g7.filter(r => r.show.openingBestMs >= MIN_OPENING_SHOW);
|
const g8 = g7.filter(r => r.show.openingBestMs >= MIN_OPENING_SHOW);
|
||||||
console.log(` → 开局可点≥${MIN_OPENING_CHOICES} ${g7.length} → 开局演出≥${(MIN_OPENING_SHOW / 1000).toFixed(1)}s ${g8.length} → 长巡游≥${MIN_CRUISES} ${playable.length}`);
|
console.log(` → 开局可点≥${MIN_OPENING_CHOICES} ${g7.length} → 开局演出≥${(MIN_OPENING_SHOW / 1000).toFixed(2)}s ${g8.length} → 长巡游≥${MIN_CRUISES} ${playable.length}`);
|
||||||
console.log(`【候选池】旋转深度 ${JSON.stringify(playable.reduce((m, r) => (m[r.stats.rotations] = (m[r.stats.rotations] || 0) + 1, m), {}))}`);
|
console.log(`【候选池】旋转深度 ${JSON.stringify(playable.reduce((m, r) => (m[r.stats.rotations] = (m[r.stats.rotations] || 0) + 1, m), {}))}`);
|
||||||
console.log(` 反差 中位 ${med(playable.map(r => r.contrast.contrast)).toFixed(1)} · 家族 ${JSON.stringify(playable.reduce((m, r) => (m[r.family] = (m[r.family] || 0) + 1, m), {}))}`);
|
console.log(` 反差 中位 ${med(playable.map(r => r.contrast.contrast)).toFixed(1)} · 家族 ${JSON.stringify(playable.reduce((m, r) => (m[r.family] = (m[r.family] || 0) + 1, m), {}))}`);
|
||||||
const KEEP_ALL = String(args.keep) === 'all';
|
const KEEP_ALL = String(args.keep) === 'all';
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export const ART_DIRECTIONS=Object.freeze([
|
|||||||
[[0,2,5,3,1,8,6],[1,0,2,6,3,9,7],[3,6,1,0,4,10,2],[6,0,3,5,2,11,8],[2,5,0,6,1,9,3]],
|
[[0,2,5,3,1,8,6],[1,0,2,6,3,9,7],[3,6,1,0,4,10,2],[6,0,3,5,2,11,8],[2,5,0,6,1,9,3]],
|
||||||
['晨雾蓝灰世界色配奶白卡片,靛蓝、松石与琥珀领色。','杏仁暖砂世界色,朱砂与靛蓝领色,像午后的纸面。','苔原灰绿世界色,紫藤与玫红提亮。','暮砂玫瑰灰世界色,玫红与靛蓝对照。','夜航深蓝灰世界色,奶白卡片像被灯照亮的图。']),
|
['晨雾蓝灰世界色配奶白卡片,靛蓝、松石与琥珀领色。','杏仁暖砂世界色,朱砂与靛蓝领色,像午后的纸面。','苔原灰绿世界色,紫藤与玫红提亮。','暮砂玫瑰灰世界色,玫红与靛蓝对照。','夜航深蓝灰世界色,奶白卡片像被灯照亮的图。']),
|
||||||
pigments:makePigments(['#2F5FBF','#C4432F','#1C8378','#7A4EB8','#4A8235','#A8690C','#BF3A6B','#4A6B8A','#B0572F','#357358','#8A5C92','#5A6D7C'],['靛蓝','朱砂','松石','紫藤','苔绿','琥珀','玫红','钢蓝','赤陶','墨绿','藕紫','铁灰']).map(p=>({...p,arrow:'#FFFFFF'}))},
|
pigments:makePigments(['#2F5FBF','#C4432F','#1C8378','#7A4EB8','#4A8235','#A8690C','#BF3A6B','#4A6B8A','#B0572F','#357358','#8A5C92','#5A6D7C'],['靛蓝','朱砂','松石','紫藤','苔绿','琥珀','玫红','钢蓝','赤陶','墨绿','藕紫','铁灰']).map(p=>({...p,arrow:'#FFFFFF'}))},
|
||||||
{id:'macaron',code:'07',name:'马卡龙',en:'MACARON',tag:'参考 Meowdoku',style:'macaron',accent:'#E07416',paper:'#FBF6F0',journeyWorlds:true,layout:{rotation:'under-board',rim:.40,pad:1.1},motion:{intro:720,pop:true,celebrate:720},hud:{level:'关卡',moves:'待解',tutorial:'入门'},
|
{id:'macaron',code:'07',name:'马卡龙',en:'MACARON',tag:'参考 Meowdoku',style:'macaron',accent:'#E07416',paper:'#FBF6F0',journeyWorlds:true,layout:{rotation:'under-board',rim:.40,pad:.3},motion:{intro:720,pop:true,celebrate:720},hud:{level:'关卡',moves:'待解',tutorial:'入门'},
|
||||||
description:'奶油色世界上托一张白色软托盘,六角格像带白缝的软糖砖,藕紫轨道压在糖砖上。棋子是糖果色圆角六角,白色贴纸边,粗圆头白箭头。反馈用果糖色说话:路线点亮、糖砖出场、离场撒星。',
|
description:'奶油色世界上托一张白色软托盘,六角格像带白缝的软糖砖,藕紫轨道压在糖砖上。棋子是糖果色圆角六角,白色贴纸边,粗圆头白箭头。反馈用果糖色说话:路线点亮、糖砖出场、离场撒星。',
|
||||||
principles:['白托盘加白缝,格与格靠留白分开,不画格线','十二色统一白色粗箭头,与白缝同一套白','状态用颜色和弹跳表达:路线点亮、碰撞橘红、出场从左上角一波弹开'],
|
principles:['白托盘加白缝,格与格靠留白分开,不画格线','十二色统一白色粗箭头,与白缝同一套白','状态用颜色和弹跳表达:路线点亮、碰撞橘红、出场从左上角一波弹开'],
|
||||||
route:{blocked:'#F0663A',hint:'#F7C948'},
|
route:{blocked:'#F0663A',hint:'#F7C948'},
|
||||||
|
|||||||
+14
-1
@@ -1,5 +1,6 @@
|
|||||||
import { Game, available, key, opposite, random, rotateBoard, solveRotations, trace, validateLevel } from './core.js';
|
import { Game, available, key, opposite, random, rotateBoard, solveRotations, trace, validateLevel } from './core.js';
|
||||||
import { exitTiming } from './exit-animation.js';
|
import { exitTiming } from './exit-animation.js';
|
||||||
|
import { MOTION } from './design-tokens.js';
|
||||||
|
|
||||||
const positionKey = tile => `${tile.q},${tile.r}`;
|
const positionKey = tile => `${tile.q},${tile.r}`;
|
||||||
const countBends = route => route.steps.filter(step => step.enter !== null && opposite(step.enter) !== step.exit).length;
|
const countBends = route => route.steps.filter(step => step.enter !== null && opposite(step.enter) !== step.exit).length;
|
||||||
@@ -262,6 +263,18 @@ export function legibility(level) {
|
|||||||
// route and leaving. So the show is measurable: how long each departure plays,
|
// route and leaving. So the show is measurable: how long each departure plays,
|
||||||
// whether the level opens with one, and — the failure the design brief names
|
// whether the level opens with one, and — the failure the design brief names
|
||||||
// outright — how many actions can pass with nothing happening at all.
|
// outright — how many actions can pass with nothing happening at all.
|
||||||
|
// What counts as a cruise, and what counts as an opening worth watching, were
|
||||||
|
// both picked in milliseconds while a piece crossed one unit in 120ms. Speed is
|
||||||
|
// a tuning dial, so holding those milliseconds fixed while the dial moves does
|
||||||
|
// not hold the experience fixed — it silently demands longer routes. Raising
|
||||||
|
// the speed once made 20 of 50 shipped levels "fail" an opening gate they had
|
||||||
|
// passed, purely from the unscaled number. They scale with the dial instead.
|
||||||
|
const SHOW_CALIBRATED_AT = 120;
|
||||||
|
const showScale = () => MOTION.exitMsPerUnit / SHOW_CALIBRATED_AT;
|
||||||
|
export const CRUISE_MS_AT_120 = 900;
|
||||||
|
const CRUISE_MS = CRUISE_MS_AT_120 * showScale();
|
||||||
|
export const openingShowFloor = (msAt120 = 800) => msAt120 * showScale();
|
||||||
|
|
||||||
export function showStats(level) {
|
export function showStats(level) {
|
||||||
const solution = solveRotations(level);
|
const solution = solveRotations(level);
|
||||||
if (!solution.solvable || !solution.actions) return null;
|
if (!solution.solvable || !solution.actions) return null;
|
||||||
@@ -295,7 +308,7 @@ export function showStats(level) {
|
|||||||
medianMs: Math.round(sorted[Math.floor(sorted.length / 2)]),
|
medianMs: Math.round(sorted[Math.floor(sorted.length / 2)]),
|
||||||
longestMs: Math.round(sorted[sorted.length - 1]),
|
longestMs: Math.round(sorted[sorted.length - 1]),
|
||||||
// A cruise: long enough that watching it is the point, not a blink.
|
// A cruise: long enough that watching it is the point, not a blink.
|
||||||
cruises: shows.filter(ms => ms >= 900).length,
|
cruises: shows.filter(ms => ms >= CRUISE_MS).length,
|
||||||
firstShowMs: firstShow ? Math.round(firstShow.ms) : null,
|
firstShowMs: firstShow ? Math.round(firstShow.ms) : null,
|
||||||
firstShowAfter: firstShow ? firstShow.afterActions : null,
|
firstShowAfter: firstShow ? firstShow.afterActions : null,
|
||||||
maxDryActions: maxDry,
|
maxDryActions: maxDry,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const SURFACES = Object.freeze({
|
|||||||
disabled: 'disabled', tileBlue: 'tileBlue', tilePink: 'tilePink', tileYellow: 'tileYellow', tilePurple: 'tilePurple',
|
disabled: 'disabled', tileBlue: 'tileBlue', tilePink: 'tilePink', tileYellow: 'tileYellow', tilePurple: 'tilePurple',
|
||||||
});
|
});
|
||||||
export const TYPOGRAPHY = Object.freeze({level:28,moves:32,dialogTitle:24,body:14,label:12,caption:11});
|
export const TYPOGRAPHY = Object.freeze({level:28,moves:32,dialogTitle:24,body:14,label:12,caption:11});
|
||||||
export const MOTION = Object.freeze({pressMs:150,modalMs:420,starStartMs:300,starGapMs:190,collisionMs:800,rotationMs:240,exitMsPerUnit:120,exitRampMs:80,exitMinTravelMs:140,exitMaxTravelMs:2400,exitEffectMs:180,exitLongBaseMs:550,exitLongCellMs:85,exitLongMaxMs:3200,exitLongStartCells:2,exitLongFullCells:5});
|
export const MOTION = Object.freeze({pressMs:150,modalMs:420,starStartMs:300,starGapMs:190,collisionMs:800,rotationMs:240,exitMsPerUnit:85,exitRampMs:80,exitMinTravelMs:140,exitMaxTravelMs:2400,exitEffectMs:180,exitLongBaseMs:550,exitLongCellMs:85,exitLongMaxMs:3200,exitLongStartCells:2,exitLongFullCells:5});
|
||||||
export const RADII = Object.freeze({control:14,dialog:24,small:8});
|
export const RADII = Object.freeze({control:14,dialog:24,small:8});
|
||||||
export function luminance(hex){return hex.slice(1,7).match(/../g).map(v=>parseInt(v,16)/255).map(v=>v<=.04045?v/12.92:((v+.055)/1.055)**2.4).reduce((a,v,i)=>a+v*[.2126,.7152,.0722][i],0);}
|
export function luminance(hex){return hex.slice(1,7).match(/../g).map(v=>parseInt(v,16)/255).map(v=>v<=.04045?v/12.92:((v+.055)/1.055)**2.4).reduce((a,v,i)=>a+v*[.2126,.7152,.0722][i],0);}
|
||||||
export function contrast(a,b){const x=luminance(a),y=luminance(b);return (Math.max(x,y)+.05)/(Math.min(x,y)+.05);}
|
export function contrast(a,b){const x=luminance(a),y=luminance(b);return (Math.max(x,y)+.05)/(Math.min(x,y)+.05);}
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { routePoints } from './geometry.js';
|
|||||||
// wait. Cruising speed is one constant for every piece — until the knee. Past
|
// wait. Cruising speed is one constant for every piece — until the knee. Past
|
||||||
// it the piece accelerates instead of taking longer, because uncapped linear
|
// it the piece accelerates instead of taking longer, because uncapped linear
|
||||||
// timing ran a single departure to seventeen seconds and reads as a stall
|
// timing ran a single departure to seventeen seconds and reads as a stall
|
||||||
// rather than as a journey. Callers may override the ceiling to compare
|
// rather than as a journey. Callers may override the speed or the ceiling to
|
||||||
// settings; labs/exit-timing does exactly that.
|
// compare settings; labs/exit-timing does exactly that.
|
||||||
export function exitTiming(route,{knee=MOTION.exitMaxTravelMs,maxMs=MOTION.exitLongMaxMs}={}) {
|
export function exitTiming(route,{msPerUnit=MOTION.exitMsPerUnit,knee=MOTION.exitMaxTravelMs,maxMs=MOTION.exitLongMaxMs}={}) {
|
||||||
const points=routePoints({...route,end:null},{size:1,ox:0,oy:0});
|
const points=routePoints({...route,end:null},{size:1,ox:0,oy:0});
|
||||||
const distance=points.slice(1).reduce((sum,p,i)=>sum+Math.hypot(p.x-points[i].x,p.y-points[i].y),0);
|
const distance=points.slice(1).reduce((sum,p,i)=>sum+Math.hypot(p.x-points[i].x,p.y-points[i].y),0);
|
||||||
const linear=distance*MOTION.exitMsPerUnit;
|
const linear=distance*msPerUnit;
|
||||||
// Below the knee nothing changes: same speed as before, same feel.
|
// Below the knee nothing changes: same speed as before, same feel.
|
||||||
const base=linear<=knee?linear
|
const base=linear<=knee?linear
|
||||||
:Math.min(maxMs,knee+(linear-knee)*Math.max(0,(maxMs-knee))/Math.max(1,linear-knee)*(1-Math.exp(-(linear-knee)/Math.max(1,maxMs-knee))));
|
:Math.min(maxMs,knee+(linear-knee)*Math.max(0,(maxMs-knee))/Math.max(1,linear-knee)*(1-Math.exp(-(linear-knee)/Math.max(1,maxMs-knee))));
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { position, PORTS, trackPoints } from './path-math.js';
|
import { position, PORTS, trackPoints } from './path-math.js';
|
||||||
export { hexPoints, trackPoints, PORTS } from './path-math.js';
|
export { hexPoints, trackPoints, PORTS } from './path-math.js';
|
||||||
// pad adds that many cell radii of margin on every side (macaron keeps its dissolve inside the canvas with 1.1).
|
// pad adds that many cell radii of margin on every side (macaron keeps its exit dissolve inside the canvas with 0.3).
|
||||||
export function layout(cells,width,height,pad=0) {
|
export function layout(cells,width,height,pad=0) {
|
||||||
const raw=cells.map(c=>position(c.q,c.r));
|
const raw=cells.map(c=>position(c.q,c.r));
|
||||||
const minX=Math.min(...raw.map(p=>p.x))-1,maxX=Math.max(...raw.map(p=>p.x))+1;
|
const minX=Math.min(...raw.map(p=>p.x))-1,maxX=Math.max(...raw.map(p=>p.x))+1;
|
||||||
|
|||||||
+10
-9
@@ -9,7 +9,7 @@ import { center, hexPoints, trackPoints, routePoints, pointAlong } from './geome
|
|||||||
import { TILE_FRAMES, TILE_RADIUS, TILE_ASPECT } from './sprites.js';
|
import { TILE_FRAMES, TILE_RADIUS, TILE_ASPECT } from './sprites.js';
|
||||||
export const COLORS=PIGMENTS.map(p=>p.color);
|
export const COLORS=PIGMENTS.map(p=>p.color);
|
||||||
// Macaron's exit ends in a bubble dissolve that needs more time than the shared 180 ms effect window.
|
// Macaron's exit ends in a bubble dissolve that needs more time than the shared 180 ms effect window.
|
||||||
const MACARON_DISSOLVE_MS=560;
|
const MACARON_DISSOLVE_MS=640;
|
||||||
export function exitDuration(route,artStyle){const timing=exitTiming(route);return artStyle==='macaron'?timing.travelMs+MACARON_DISSOLVE_MS:timing.duration;}
|
export function exitDuration(route,artStyle){const timing=exitTiming(route);return artStyle==='macaron'?timing.travelMs+MACARON_DISSOLVE_MS:timing.duration;}
|
||||||
// Four-point sparkle star used by the macaron win celebration.
|
// Four-point sparkle star used by the macaron win celebration.
|
||||||
const sparkle=(x,y,r)=>Array.from({length:8},(_,j)=>{const b=j*Math.PI/4-Math.PI/2,rr=j%2?r*.38:r;return {x:x+Math.cos(b)*rr,y:y+Math.sin(b)*rr};});
|
const sparkle=(x,y,r)=>Array.from({length:8},(_,j)=>{const b=j*Math.PI/4-Math.PI/2,rr=j%2?r*.38:r;return {x:x+Math.cos(b)*rr,y:y+Math.sin(b)*rr};});
|
||||||
@@ -247,13 +247,14 @@ export function drawExit(p,route,view,progress,tile,reduced=false){
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Macaron exit: the token stops at its port and dissolves like a sugar cube. The solid face, rim and arrow fade in the
|
// Macaron exit: the token glides out through its port, slowing down as it crosses the tray edge (0.55s beyond the port
|
||||||
// first fifth while a lattice of dots in the token colour takes over; the dots then erode from the rim inward, each
|
// over the first 45%), and dissolves mostly outside the board like a sugar cube: the solid face, rim and arrow fade
|
||||||
// shrinking, thinning towards white and drifting a little as it goes. Circles, polygons and alpha only; the noise is a
|
// while a lattice of dots in the token colour takes over; the dots erode from the rim inward, each shrinking, thinning
|
||||||
// fixed hash of the lattice position, so every frame agrees.
|
// towards white and drifting a little. Circles, polygons and alpha only; the noise is a fixed hash of the lattice position.
|
||||||
function bubbleDissolve(p,edge,angle,size,tile,color,t){
|
function bubbleDissolve(p,edge,angle,size,tile,color,t){
|
||||||
const dx=Math.cos(angle),dy=Math.sin(angle),alphaHex=v=>Math.round(Math.max(0,Math.min(1,v))*255).toString(16).padStart(2,'0'),base=color.slice(0,7);
|
const dx=Math.cos(angle),dy=Math.sin(angle),alphaHex=v=>Math.round(Math.max(0,Math.min(1,v))*255).toString(16).padStart(2,'0'),base=color.slice(0,7);
|
||||||
const x=edge.x+dx*size*.06,y=edge.y+dy*size*.06,r=size*.90,solid=1-smoothMotion(t/.18);
|
const out=1-Math.pow(1-Math.min(1,t/.45),3),x=edge.x+dx*size*.55*out,y=edge.y+dy*size*.55*out,r=size*.90;
|
||||||
|
const solid=1-smoothMotion((t-.15)/.2);
|
||||||
if(solid>0){const face=roundedHex(x,y,r,.16);p.polygon(face,base+alphaHex(solid));p.line([...face,face[0]],'#FFFFFF'+alphaHex(solid),Math.max(1,r*.10));}
|
if(solid>0){const face=roundedHex(x,y,r,.16);p.polygon(face,base+alphaHex(solid));p.line([...face,face[0]],'#FFFFFF'+alphaHex(solid),Math.max(1,r*.10));}
|
||||||
const step=size*.17,inset=r*.82,rows=Math.ceil(inset/(step*.866))+1,cols=Math.ceil(inset/step)+1;
|
const step=size*.17,inset=r*.82,rows=Math.ceil(inset/(step*.866))+1,cols=Math.ceil(inset/step)+1;
|
||||||
for(let j=-rows;j<=rows;j++)for(let i=-cols;i<=cols;i++){
|
for(let j=-rows;j<=rows;j++)for(let i=-cols;i<=cols;i++){
|
||||||
@@ -262,12 +263,12 @@ function bubbleDissolve(p,edge,angle,size,tile,color,t){
|
|||||||
let h=(i*374761393+j*668265263)|0;h=Math.imul(h^(h>>>13),1274126177);h^=h>>>16;
|
let h=(i*374761393+j*668265263)|0;h=Math.imul(h^(h>>>13),1274126177);h^=h>>>16;
|
||||||
const n1=((h>>>0)%1000)/1000,n2=(((h>>>10)>>>0)%1000)/1000;
|
const n1=((h>>>0)%1000)/1000,n2=(((h>>>10)>>>0)%1000)/1000;
|
||||||
const rim=Math.max(ay,ax*.866+ay*.5)/(inset*.866);
|
const rim=Math.max(ay,ax*.866+ay*.5)/(inset*.866);
|
||||||
const u=(t-(.05+.40*(1-rim)+.20*n1))/.33;
|
const u=(t-(.16+.38*(1-rim)+.16*n1))/.28;
|
||||||
if(u>=1)continue;
|
if(u>=1)continue;
|
||||||
if(u<=0){p.circle(x+ox,y+oy,size*.115,base);continue;}
|
if(u<=0){if(solid<1)p.circle(x+ox,y+oy,size*.115,base);continue;}
|
||||||
const k=1-smoothMotion(u),drift=size*.14*u,a=n2*Math.PI*2;
|
const k=1-smoothMotion(u),drift=size*.14*u,a=n2*Math.PI*2;
|
||||||
p.circle(x+ox+Math.cos(a)*drift,y+oy+Math.sin(a)*drift-size*.10*u,size*.115*k,tint(base,.45*u)+alphaHex(.95*(1-u*.6)));
|
p.circle(x+ox+Math.cos(a)*drift,y+oy+Math.sin(a)*drift-size*.10*u,size*.115*k,tint(base,.45*u)+alphaHex(.95*(1-u*.6)));
|
||||||
}
|
}
|
||||||
const arrow=1-smoothMotion(t/.3);
|
const arrow=1-smoothMotion((t-.15)/.25);
|
||||||
if(arrow>0){const rot=pts=>pts.map(([u,v])=>({x:x+r*(u*Math.cos(angle)-v*Math.sin(angle)),y:y+r*(u*Math.sin(angle)+v*Math.cos(angle))})),w=Math.max(1.5,r*.20),white='#FFFFFF'+alphaHex(arrow);p.line(rot([[-.34,0],[.30,0]]),white,w);p.line(rot([[.02,-.30],[.30,0],[.02,.30]]),white,w);}
|
if(arrow>0){const rot=pts=>pts.map(([u,v])=>({x:x+r*(u*Math.cos(angle)-v*Math.sin(angle)),y:y+r*(u*Math.sin(angle)+v*Math.cos(angle))})),w=Math.max(1.5,r*.20),white='#FFFFFF'+alphaHex(arrow);p.line(rot([[-.34,0],[.30,0]]),white,w);p.line(rot([[.02,-.30],[.30,0],[.02,.30]]),white,w);}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { PIGMENTS, LEVEL_THEMES, contrast, themeForScore } from '../src/design-tokens.js';
|
import { MOTION, PIGMENTS, LEVEL_THEMES, contrast, themeForScore } from '../src/design-tokens.js';
|
||||||
import { Game, clone, validateLevel, available } from '../src/core.js';
|
import { Game, clone, validateLevel, available } from '../src/core.js';
|
||||||
import { LEVELS } from '../src/levels.js';
|
import { LEVELS } from '../src/levels.js';
|
||||||
import { drawExit, drawScene, CanvasPainter, exitGeometry } from '../src/renderer.js';
|
import { drawExit, drawScene, CanvasPainter, exitGeometry } from '../src/renderer.js';
|
||||||
@@ -55,15 +55,19 @@ test('motion ramps gently at both ends, never reverses or overshoots',async()=>{
|
|||||||
test('routes below the knee cruise at one speed; longer ones accelerate instead of dragging',async()=>{
|
test('routes below the knee cruise at one speed; longer ones accelerate instead of dragging',async()=>{
|
||||||
const {exitTiming,travelProgress}=await import('../src/exit-animation.js');
|
const {exitTiming,travelProgress}=await import('../src/exit-animation.js');
|
||||||
// Below the knee the old guarantee still holds exactly: same cruising speed
|
// Below the knee the old guarantee still holds exactly: same cruising speed
|
||||||
// for every piece, no cap. distance*120 stays under 2400ms up to distance 20.
|
// for every piece, no cap. The speed itself is a tuning dial, so it is read
|
||||||
|
// from the token rather than written in — pinning the number here would make
|
||||||
|
// every speed change look like a broken guarantee.
|
||||||
|
const perUnit=MOTION.exitMsPerUnit;
|
||||||
|
assert.ok(20*perUnit<MOTION.exitMaxTravelMs,'距离 20 以内必须还在拐点以下,否则下面的匀速断言就测不到匀速段');
|
||||||
for(const distance of [1,5,15]){
|
for(const distance of [1,5,15]){
|
||||||
const timing=exitTiming({travelPoints:[{x:0,y:0},{x:distance,y:0}],steps:[]});
|
const timing=exitTiming({travelPoints:[{x:0,y:0},{x:distance,y:0}],steps:[]});
|
||||||
assert.equal(timing.travelMs,distance*120+Math.min(80,distance*40));
|
assert.equal(timing.travelMs,distance*perUnit+Math.min(MOTION.exitRampMs,distance*perUnit/3));
|
||||||
const t=timing.travelMs*.5,dt=1;
|
const t=timing.travelMs*.5,dt=1;
|
||||||
const speed=(travelProgress((t+dt)/timing.travelMs,timing.travelMs)-travelProgress(t/timing.travelMs,timing.travelMs))*distance;
|
const speed=(travelProgress((t+dt)/timing.travelMs,timing.travelMs)-travelProgress(t/timing.travelMs,timing.travelMs))*distance;
|
||||||
assert.ok(Math.abs(speed-1/120)<1e-9);
|
assert.ok(Math.abs(speed-1/perUnit)<1e-9);
|
||||||
assert.equal(travelProgress(0,timing.travelMs),0);assert.equal(travelProgress(1,timing.travelMs),1);
|
assert.equal(travelProgress(0,timing.travelMs),0);assert.equal(travelProgress(1,timing.travelMs),1);
|
||||||
assert.ok(Math.abs(timing.duration-timing.travelMs-180)<1e-9);
|
assert.ok(Math.abs(timing.duration-timing.travelMs-MOTION.exitEffectMs)<1e-9);
|
||||||
assert.equal(timing.capped,false);
|
assert.equal(timing.capped,false);
|
||||||
}
|
}
|
||||||
// Past the knee the piece speeds up so the journey stays watchable. Uncapped
|
// Past the knee the piece speeds up so the journey stays watchable. Uncapped
|
||||||
|
|||||||
Reference in New Issue
Block a user