@@ -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 `
${r.label}
路径距离 ${r.distance.toFixed(1)} · 来自 ${r.level}
-
现状 · 不封顶
${(now.duration / 1000).toFixed(2)}s
-
候选设置
${(next.duration / 1000).toFixed(2)}s
+
线上已发布
${(now.duration / 1000).toFixed(2)}s
+
候选设定
${(next.duration / 1000).toFixed(2)}s
`;
}).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();
diff --git a/scripts/prototype-d5.mjs b/scripts/prototype-d5.mjs
index d7f31c5..17baa53 100644
--- a/scripts/prototype-d5.mjs
+++ b/scripts/prototype-d5.mjs
@@ -6,7 +6,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { Game, random } from '../src/core.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 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.
const MIN_RELEASE = Number(args.minRelease ?? 2);
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_CRUISES = Number(args.minCruises ?? 3);
// 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 g7 = g6.filter(r => r.show.openingChoices >= MIN_OPENING_CHOICES);
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(` 反差 中位 ${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';
diff --git a/src/art-directions.js b/src/art-directions.js
index c852b30..4bcc20a 100644
--- a/src/art-directions.js
+++ b/src/art-directions.js
@@ -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]],
['晨雾蓝灰世界色配奶白卡片,靛蓝、松石与琥珀领色。','杏仁暖砂世界色,朱砂与靛蓝领色,像午后的纸面。','苔原灰绿世界色,紫藤与玫红提亮。','暮砂玫瑰灰世界色,玫红与靛蓝对照。','夜航深蓝灰世界色,奶白卡片像被灯照亮的图。']),
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:'奶油色世界上托一张白色软托盘,六角格像带白缝的软糖砖,藕紫轨道压在糖砖上。棋子是糖果色圆角六角,白色贴纸边,粗圆头白箭头。反馈用果糖色说话:路线点亮、糖砖出场、离场撒星。',
principles:['白托盘加白缝,格与格靠留白分开,不画格线','十二色统一白色粗箭头,与白缝同一套白','状态用颜色和弹跳表达:路线点亮、碰撞橘红、出场从左上角一波弹开'],
route:{blocked:'#F0663A',hint:'#F7C948'},
diff --git a/src/deep-lock.js b/src/deep-lock.js
index 1cc78d2..6b1cd64 100644
--- a/src/deep-lock.js
+++ b/src/deep-lock.js
@@ -1,5 +1,6 @@
import { Game, available, key, opposite, random, rotateBoard, solveRotations, trace, validateLevel } from './core.js';
import { exitTiming } from './exit-animation.js';
+import { MOTION } from './design-tokens.js';
const positionKey = tile => `${tile.q},${tile.r}`;
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,
// whether the level opens with one, and — the failure the design brief names
// 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) {
const solution = solveRotations(level);
if (!solution.solvable || !solution.actions) return null;
@@ -295,7 +308,7 @@ export function showStats(level) {
medianMs: Math.round(sorted[Math.floor(sorted.length / 2)]),
longestMs: Math.round(sorted[sorted.length - 1]),
// 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,
firstShowAfter: firstShow ? firstShow.afterActions : null,
maxDryActions: maxDry,
diff --git a/src/design-tokens.js b/src/design-tokens.js
index cea1958..315a193 100644
--- a/src/design-tokens.js
+++ b/src/design-tokens.js
@@ -38,7 +38,7 @@ export const SURFACES = Object.freeze({
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 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 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);}
diff --git a/src/exit-animation.js b/src/exit-animation.js
index 3b4175d..7bbd20a 100644
--- a/src/exit-animation.js
+++ b/src/exit-animation.js
@@ -5,12 +5,12 @@ import { routePoints } from './geometry.js';
// wait. Cruising speed is one constant for every piece — until the knee. Past
// it the piece accelerates instead of taking longer, because uncapped linear
// 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
-// settings; labs/exit-timing does exactly that.
-export function exitTiming(route,{knee=MOTION.exitMaxTravelMs,maxMs=MOTION.exitLongMaxMs}={}) {
+// rather than as a journey. Callers may override the speed or the ceiling to
+// compare settings; labs/exit-timing does exactly that.
+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 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.
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))));
diff --git a/src/geometry.js b/src/geometry.js
index 597afb0..a0b2ac9 100644
--- a/src/geometry.js
+++ b/src/geometry.js
@@ -1,6 +1,6 @@
import { position, PORTS, trackPoints } 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) {
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;
diff --git a/src/renderer.js b/src/renderer.js
index 007707c..955b218 100644
--- a/src/renderer.js
+++ b/src/renderer.js
@@ -9,7 +9,7 @@ import { center, hexPoints, trackPoints, routePoints, pointAlong } from './geome
import { TILE_FRAMES, TILE_RADIUS, TILE_ASPECT } from './sprites.js';
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.
-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;}
// 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};});
@@ -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
-// first fifth while a lattice of dots in the token colour takes over; the dots then erode from the rim inward, each
-// shrinking, thinning towards white and drifting a little as it goes. Circles, polygons and alpha only; the noise is a
-// fixed hash of the lattice position, so every frame agrees.
+// Macaron exit: the token glides out through its port, slowing down as it crosses the tray edge (0.55s beyond the port
+// over the first 45%), and dissolves mostly outside the board like a sugar cube: the solid face, rim and arrow fade
+// while a lattice of dots in the token colour takes over; the dots erode from the rim inward, each shrinking, thinning
+// 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){
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));}
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++){
@@ -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;
const n1=((h>>>0)%1000)/1000,n2=(((h>>>10)>>>0)%1000)/1000;
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<=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;
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);}
}
diff --git a/tests/themes.test.js b/tests/themes.test.js
index 73dc8fd..7f206a8 100644
--- a/tests/themes.test.js
+++ b/tests/themes.test.js
@@ -1,6 +1,6 @@
import test from 'node:test';
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 { LEVELS } from '../src/levels.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()=>{
const {exitTiming,travelProgress}=await import('../src/exit-animation.js');
// 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