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>
81 lines
5.1 KiB
JavaScript
81 lines
5.1 KiB
JavaScript
// Source of truth for the palette. Run npm run design after changing values.
|
||
// See /Design.md for semantic pairings, ratios and component rules.
|
||
export const THEME = Object.freeze({
|
||
background: '#A8BECF',
|
||
board: '#F2EEE5',
|
||
boardGrid: '#89867D',
|
||
track: '#435F78',
|
||
trackHalo: '#F2EEE5',
|
||
surface: '#F7F6F2',
|
||
textPrimary: '#273B50',
|
||
textSecondary: '#314A5F',
|
||
primary: '#6652B8',
|
||
onPrimary: '#FFFFFF',
|
||
secondary: '#E4EAF0',
|
||
utility: '#52697E',
|
||
onUtility: '#FFFFFF',
|
||
selection: '#DFD9F3',
|
||
onSelection: '#3F327A',
|
||
disabled: '#CBD4DD',
|
||
onDisabled: '#526579',
|
||
input: '#FFFFFF',
|
||
reward: '#B9760C',
|
||
warning: '#A44825',
|
||
warningSurface: '#F9E3D2',
|
||
scrim: '#273B507A',
|
||
focus: '#3F327A',
|
||
// Approximate center pixels of the accepted, unchanged generated token art.
|
||
tileBlue: '#2392FD',
|
||
tilePink: '#FC3E80',
|
||
tileYellow: '#FDBC28',
|
||
tilePurple: '#874FFD',
|
||
arrowLight: '#FFFFFF',
|
||
arrowDark: '#273B50',
|
||
});
|
||
export const SURFACES = Object.freeze({
|
||
surface: 'surface', primary: 'primary', secondary: 'secondary', utility: 'utility',
|
||
warning: 'warningSurface', reward: 'reward', input: 'input', selection: 'selection',
|
||
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: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);}
|
||
export const CONTRAST_PAIRS = [
|
||
['正文 / 背景','textPrimary','background',4.5], ['辅助文字 / 背景','textSecondary','background',4.5],
|
||
['正文 / 弹窗','textPrimary','surface',4.5], ['辅助文字 / 弹窗','textSecondary','surface',4.5],
|
||
['主按钮文字','onPrimary','primary',4.5], ['工具图标','onUtility','utility',3],
|
||
['次按钮文字','textPrimary','secondary',4.5], ['选中标签','onSelection','selection',4.5],
|
||
['阻挡提示文字','warning','warningSurface',4.5], ['分格 / 棋盘','boardGrid','board',3],
|
||
['轨道 / 棋盘','track','board',3], ['轨道 / 浅色外缘','track','trackHalo',3],
|
||
['方向箭头 / 蓝棋子','arrowLight','tileBlue',3], ['方向箭头 / 粉棋子','arrowLight','tilePink',3],
|
||
['方向箭头 / 黄棋子','arrowDark','tileYellow',3], ['方向箭头 / 紫棋子','arrowLight','tilePurple',3],
|
||
];
|
||
|
||
// V2: global pigment library. A level uses only its curated subset.
|
||
export const PIGMENTS = Object.freeze([
|
||
{name:'晴空蓝', color:THEME.tileBlue}, {name:'莓果粉', color:THEME.tilePink},
|
||
{name:'日光黄', color:THEME.tileYellow}, {name:'葡萄紫', color:THEME.tilePurple},
|
||
{name:'湖水青', color:'#008B83'}, {name:'珊瑚橙', color:'#EF704F'},
|
||
{name:'嫩芽绿', color:'#91C95B'}, {name:'鸢尾蓝', color:'#5267CF'},
|
||
{name:'玫瑰红', color:'#C83F66'}, {name:'蜜桃杏', color:'#F5AA83'},
|
||
{name:'冰川青', color:'#58C9D5'}, {name:'兰花紫', color:'#AE64C7'},
|
||
].map(p=>Object.freeze({...p, arrow:contrast(THEME.arrowLight,p.color)>=3?THEME.arrowLight:THEME.arrowDark})));
|
||
export const LEVEL_THEMES = Object.freeze([
|
||
{id:'lagoon',name:'薄荷浅湾',label:'轻松',range:'0–29',background:'#C5E4DF',ink:'#273B50',palette:[0,4,2],description:'冷润的浅湾,配蓝、青与一点日光黄。'},
|
||
{id:'apricot',name:'杏色午后',label:'进阶',range:'30–49',background:'#F1D7B8',ink:'#273B50',palette:[5,7,4,9],description:'温暖杏底,以珊瑚、鸢尾蓝和湖水青平衡。'},
|
||
{id:'lavender',name:'鸢尾花园',label:'烧脑',range:'50–69',background:'#D8D0EB',ink:'#273B50',palette:[3,6,8,10],description:'柔紫空间,用嫩芽绿与冰川青拉开层次。'},
|
||
{id:'rose',name:'玫瑰暮色',label:'挑战',range:'70–100',background:'#E7C3CE',ink:'#273B50',palette:[8,7,2,11],description:'低饱和玫瑰底,深色棋子与日光黄形成节奏。'},
|
||
{id:'midnight',name:'午夜极光',label:'每周挑战',range:'特别主题',background:'#293956',ink:'#F7F6F2',palette:[10,11,2,1],description:'深靛蓝舞台,冰川青、兰花紫和明亮暖色。'},
|
||
].map(t=>Object.freeze({...t,palette:Object.freeze(t.palette)})));
|
||
export function themeForScore(score){return LEVEL_THEMES[score<30?0:score<50?1:score<70?2:3];}
|
||
export function pigmentIndex(color,theme){return theme?theme.palette[color%theme.palette.length]:color;}
|
||
|
||
// Keep each world's anchor colors, then add distinct hues before close relatives.
|
||
export function themeWithColors(theme,count=7){
|
||
const limit=[4,7,9,12].includes(count)?count:7;
|
||
const order=[...new Set([...theme.palette,0,1,2,3,4,5,6,7,8,9,10,11])];
|
||
return {...theme,palette:order.slice(0,limit)};
|
||
}
|