feat: add Paper Routes and package five design systems

Add the paper direction with thin tracks, outlined hexagons and line arrows, without exterior color markers or piece numbers. Add a unified catalog for five designs and their 25 themes, with direct review links.

Organize each design into an authored design.md, generated tokens.json and preview. Add Agent routing, shared integration guidance, a machine-readable manifest and an executable rendering example. Preserve legacy preview and document links.

Validation: 16 focused design, theme and package tests passed; npm run design and static build passed. Confirmed all eight authored documents survive generation unchanged. Browser checks covered mobile, tablet and desktop layouts, theme links and migrated previews. Native engine integration and visual verification remain pending.
This commit is contained in:
cabinw
2026-09-13 13:24:34 +08:00
parent e32a1616be
commit 10cf93a324
52 changed files with 3365 additions and 149 deletions
+1
View File
@@ -38,6 +38,7 @@ const requestedTheme=LEVEL_THEMES.find(t=>t.id===new URLSearchParams(location.se
function applyTheme(score){
activeTheme=mode==='edit'||trial?null:directionTheme(requestedDesign,(requestedTheme||themeForScore(score)).id,Number(new URLSearchParams(location.search).get('colors'))||7);
const theme=activeTheme||LEVEL_THEMES[0];
document.body.dataset.theme=theme.id;
document.body.style.setProperty('--color-background',theme.background);
document.body.style.setProperty('--scene-ink',theme.ink);
$('theme-caption').textContent=activeTheme?`${requestedTheme?'配色预览':theme.label} · ${theme.name}`:'自由配色 · 12 色棋子库';
+17
View File
@@ -60,6 +60,23 @@ export const ART_DIRECTIONS=Object.freeze([
[[0,1,2,3,6,7,10],[1,4,8,5,2,6,11],[7,3,6,10,0,4,9],[8,5,4,2,7,10,1],[6,10,2,4,11,0,9]],
['草地绿、奶油白与动物毛色,不使用糖果蓝紫。','秋叶黄配橡果棕、麦芽金和浅奶油,像一次林间秋游。','溪水青配苔绿与暖棕,保留自然界的冷暖关系。','红土与沙色小径,棕熊、奶油兔和绿蛙像野外探险队。','深森林绿配萤火浅黄,浅毛色角色从夜色中浮现。']),
pigments:makePigments(['#D9934F','#A4683B','#E1C58B','#6C9D40','#F1B869','#73503F','#F1DCC1','#448A67','#B78157','#BD784F','#F4E5CF','#87B76B'],['姜黄猫','栗棕熊','麦芽兔','苔绿蛙','奶橘猫','可可熊','奶油兔','溪绿蛙','榛果猫','赤陶熊','雪白兔','嫩叶蛙'])},
{id:'paper',code:'05',name:'轻线纸境',en:'PAPER ROUTES',tag:'实验页衍生',style:'paper',accent:'#294D60',paper:'#F7F9F8',
description:'提取自玩法变种实验页:暖白纸面、轻盈细轨道、白边纯色六角与细线箭头。把颜色留给棋子,让弯路轻轻浮现。',
principles:['浅蓝灰细线与微小格缝,延续实验页的轻盈感','正六角纯色棋子、细白边、圆头线条箭头','无投影与渐变;陶橙和雾蓝是核心配色'],
source:{label:'玩法变种实验页',href:'labs/variants/index.html'},
themes:themes(
['#EAF0F3','#F3EEE5','#E8EFEB','#E7EBEE','#253B4A'],
['雾蓝信纸','暖砂手札','鼠尾草页','石墨练习','夜航图纸'],
[
{board:'#FAFBF7',boardGrid:'#D3DFE3',track:'#B2C4CE',trackHalo:'#FAFBF7'},
{board:'#FFFCF5',boardGrid:'#E3DCD0',track:'#C4B8A4',trackHalo:'#FFFCF5'},
{board:'#FAFCF8',boardGrid:'#D3DFD7',track:'#ADBCB2',trackHalo:'#FAFCF8'},
{board:'#F8FAFB',boardGrid:'#D1DBE1',track:'#A5B6C1',trackHalo:'#F8FAFB'},
{board:'#304A59',boardGrid:'#435E6C',track:'#849FAE',trackHalo:'#304A59'},
],
[[0,1,2,3,4,5,7],[0,1,3,6,8,9,4],[1,2,3,5,9,10,0],[0,1,4,6,7,10,11],[0,2,3,4,5,8,10]],
['保留实验页的雾蓝背景、暖白格面与陶橙/雾蓝棋子,扩展色同样保持哑光。','像暖砂色纸上的铅笔路线,陶橙、赭金与木棕带来温度。','浅鼠尾草纸底,灰绿轨道与蓝、苔绿棋子形成安静的冷暖关系。','灰白图纸与石墨蓝细线,用陶红、雾紫和钢蓝组织复杂路线。','深蓝灰图纸上保留细线与白箭头,暖色棋子像夜航中的小标记。']),
pigments:makePigments(['#D66C4D','#477FC0','#668877','#B58943','#8B7297','#45868C','#B76260','#607B90','#B28168','#46765F','#6B8AAB','#9C7284'],['陶橙','雾蓝','鼠尾草','赭金','雾紫','湖青','陶红','钢蓝','暖木','松绿','烟蓝','藕灰']).map(p=>({...p,arrow:'#FFFFFF'}))},
]);
export function artDirection(id){return ART_DIRECTIONS.find(d=>d.id===id)||ART_DIRECTIONS[0];}
export function directionTheme(direction,themeId,count=7){
+39
View File
@@ -0,0 +1,39 @@
import { ART_DIRECTIONS, directionTheme } from './art-directions.js';
import { drawScene, CanvasPainter } from './renderer.js';
import { layout } from './geometry.js';
import { createStudyLevel } from './study-levels.js';
import { TILE_ATLAS } from './sprites.js';
const level = createStudyLevel(3), atlas = new Image();
const themes = ART_DIRECTIONS[0].themes;
const requestedTheme = new URL(location.href).searchParams.get('theme');
let themeId = themes.some(t => t.id === requestedTheme) ? requestedTheme : themes[0].id;
function paint() {
for (const direction of ART_DIRECTIONS) {
const canvas = document.querySelector(`[data-catalog-design="${direction.id}"]`);
const theme = directionTheme(direction, themeId), painter = new CanvasPainter(canvas.getContext('2d'));
painter.atlas = atlas;
painter.ctx.clearRect(0, 0, canvas.width, canvas.height);
drawScene(painter, level, level.tiles, layout(level.cells, canvas.width, canvas.height), { theme });
canvas.setAttribute('aria-label', `${direction.name} · ${theme.label}主题棋盘`);
const preview = canvas.closest('.card-preview');
preview.style.background = theme.background;
preview.style.color = theme.ink;
preview.querySelector('.preview-theme').textContent = theme.name;
document.querySelector(`[data-review-design="${direction.id}"]`).href = `../design-showcase.html?design=${direction.id}&theme=${themeId}#phone`;
}
document.querySelectorAll('[data-theme]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.theme === themeId)));
document.querySelector('#comparison-status').textContent = `当前对比:${themes.find(t => t.id === themeId).label} · ${ART_DIRECTIONS.length} 套设计`;
}
document.querySelectorAll('[data-theme]').forEach(button => button.addEventListener('click', () => {
themeId = button.dataset.theme;
const url = new URL(location.href);
url.searchParams.set('theme', themeId);
history.replaceState(null, '', url);
paint();
}));
atlas.onload = paint;
atlas.src = '../' + TILE_ATLAS;
paint();
+1 -1
View File
@@ -13,7 +13,7 @@ export function mountDirectionReview(current,onSelect){
function select(d){
current=d;quick.value=d.id;document.body.dataset.design=d.id;
picker.querySelectorAll('[data-direction]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.direction===d.id)));
detail.innerHTML=`<div><span class="eyebrow">${d.en} / ${d.code}</span><h2>${d.name}</h2><p>${d.description}</p><ul>${d.principles.map(p=>`<li>${p}</li>`).join('')}</ul><a class="sheet-link" href="design/directions/${d.id}.html" target="_blank" rel="noopener">查看这套完整设计稿 ↗</a></div><label class="review-note">这套方案的审核意见<textarea id="direction-note" rows="3" placeholder="例如:保留棋子造型,轨道再轻一些……"></textarea><small id="review-saved">意见与多选结果保存在此浏览器</small></label>`;
detail.innerHTML=`<div><span class="eyebrow">${d.en} / ${d.code}</span><h2>${d.name}</h2><p>${d.description}</p><ul>${d.principles.map(p=>`<li>${p}</li>`).join('')}</ul>${d.source?`<p>画法来源:<a href="${d.source.href}" target="_blank" rel="noopener">${d.source.label} ↗</a></p>`:''}<a class="sheet-link" href="design/directions/${d.id}/index.html" target="_blank" rel="noopener">查看这套完整设计稿 ↗</a></div><label class="review-note">这套方案的审核意见<textarea id="direction-note" rows="3" placeholder="例如:保留棋子造型,轨道再轻一些……"></textarea><small id="review-saved">意见与多选结果保存在此浏览器</small></label>`;
const note=detail.querySelector('textarea');note.value=typeof review.notes[d.id]==='string'?review.notes[d.id]:'';note.oninput=()=>{review.notes[d.id]=note.value;save();};
const url=new URL(location.href);url.searchParams.set('design',d.id);history.replaceState(null,'',url);
onSelect(d);
+8 -1
View File
@@ -8,5 +8,12 @@ function paint(){
const c=document.querySelector('#sheet-tokens'),p=new CanvasPainter(c.getContext('2d'));p.atlas=atlas;p.ctx.clearRect(0,0,c.width,c.height);
for(let i=0;i<4;i++)drawTile(p,105+i*165,135+(i%2?-12:12),83,appearanceTile({color:i,dir:1},directionTheme(direction,'lagoon')));
document.querySelectorAll('[data-sheet-theme]').forEach(c=>{const p=new CanvasPainter(c.getContext('2d'));p.atlas=atlas;drawScene(p,level,level.tiles,layout(level.cells,c.width,c.height),{theme:directionTheme(direction,c.dataset.sheetTheme)});});
const reference=document.querySelector('#sheet-reference-board');
if(reference){
const sample=JSON.parse(document.querySelector('#sheet-reference-data').textContent),ctx=reference.getContext('2d'),p=new CanvasPainter(ctx);
const view={size:reference.width/9.3,ox:reference.width/2,oy:reference.height/2};
const theme={...directionTheme(direction,'lagoon'),palette:sample.tiles.map(t=>t.color)};
ctx.clearRect(0,0,reference.width,reference.height);drawScene(p,sample,sample.tiles,view,{theme});
}
}
atlas.onload=paint;atlas.src=TILE_ATLAS.startsWith('assets/')?'../../'+TILE_ATLAS:TILE_ATLAS;paint();
atlas.onload=paint;atlas.src=new URL('../'+TILE_ATLAS,import.meta.url).href;paint();
+11 -11
View File
@@ -10,7 +10,7 @@ import { TILE_FRAMES, TILE_RADIUS, TILE_ASPECT } from './sprites.js';
export const COLORS=PIGMENTS.map(p=>p.color);
// Scene commands remain engine-independent. sprite() is an optional atlas adapter.
export function drawTile(p,x,y,size,tile,angle=-Math.PI/2+tile.dir*Math.PI/3,scale=1) {
const r=size*TILE_RADIUS*scale;
const r=size*(tile.artStyle==='paper'?.88:TILE_RADIUS)*scale;
if(drawStyledToken(p,x,y,r,tile,angle))return;
const textured=p.sprite?.(tile.color,x,y,r)===true;
if(!textured){
@@ -25,6 +25,7 @@ export function drawScene(p,level,tiles,view,state={}) {
const {size}=view;
const visualTile=tile=>appearanceTile({...tile,color:pigmentIndex(state.theme?Math.max(0,level.tiles.findIndex(t=>t.id===tile.id)):tile.color,state.theme)},state.theme);
const materials={...THEME,...state.theme?.materials};
const paper=state.theme?.artStyle==='paper';
const angle=state.boardAngle||0,cos=Math.cos(angle),sin=Math.sin(angle);
const rotate=points=>points.map(pt=>{const x=pt.x-view.ox,y=pt.y-view.oy;return {x:view.ox+x*cos-y*sin,y:view.oy+x*sin+y*cos};});
const base={polygon:(pts,c)=>p.polygon(rotate(pts),c),line:(pts,c,w)=>p.line(rotate(pts),c,w)};
@@ -43,14 +44,13 @@ export function drawScene(p,level,tiles,view,state={}) {
const movingIds=new Set(motions.map(a=>a.route.tile.id));
const cellKeys=new Set(level.cells.map(c=>key(c.q,c.r)));
const sorted=[...level.cells].sort((a,b)=>center(a,view).y-center(b,view).y);
// Complete the contiguous board before drawing any rails. A tiny overlap
// prevents antialiasing seams between touching hexagons.
for(const cell of sorted){const {x,y}=center(cell,view);base.polygon(hexPoints(x,y,size+.2),materials.board);}
// Subtle cell boundaries beneath the rails: retain the hex grid without
// opening background gaps or painting over connected track endpoints.
// Paint cells before rails. Paper keeps the reference's narrow gaps; other
// styles overlap slightly to avoid antialiasing seams.
for(const cell of sorted){const {x,y}=center(cell,view);base.polygon(hexPoints(x,y,paper?size*.97:size+.2),materials.board);}
// Cell boundaries stay beneath the connected track endpoints.
for(const cell of sorted){
const {x,y}=center(cell,view),edge=hexPoints(x,y,size);
base.line([...edge,edge[0]],materials.boardGrid,Math.max(1,size*.024));
const {x,y}=center(cell,view),edge=hexPoints(x,y,paper?size*.97:size);
base.line([...edge,edge[0]],materials.boardGrid,paper?Math.max(.4,size*.025):Math.max(1,size*.024));
}
const rails=[];
for(const cell of sorted) {
@@ -65,15 +65,15 @@ export function drawScene(p,level,tiles,view,state={}) {
const first=points[0],last=points[points.length-1],shrink=1-visualRadius/size;
const dx0=exterior(a)?(first.x-x)*shrink:0,dy0=exterior(a)?(first.y-y)*shrink:0;
const dx1=exterior(b)?(last.x-x)*shrink:0,dy1=exterior(b)?(last.y-y)*shrink:0;
points.forEach((p,i)=>{const t=i/(points.length-1),u=1-t;p.x-=u*u*u*dx0+t*t*t*dx1;p.y-=u*u*u*dy0+t*t*t*dy1;});
if(!paper)points.forEach((p,i)=>{const t=i/(points.length-1),u=1-t;p.x-=u*u*u*dx0+t*t*t*dx1;p.y-=u*u*u*dy0+t*t*t*dy1;});
rails.push({points,primary:!state.trackEmphasis||state.trackEmphasis(cell,a,b)});
}
}
// Pattern studies can emphasize real routes while retaining the other paired
// connections as secondary lines. Ordinary levels keep the same drawing passes.
for(const {points,primary} of rails)if(!primary)base.line(points,state.secondaryTrackColor||materials.boardGrid,Math.max(1,size*.025));
for(const {points,primary} of rails)if(primary)base.line(points,materials.trackHalo,Math.max(4,size*.16));
for(const {points,primary} of rails)if(primary)base.line(points,materials.track,Math.max(2.5,size*.10));
if(!paper)for(const {points,primary} of rails)if(primary)base.line(points,materials.trackHalo,Math.max(4,size*.16));
for(const {points,primary} of rails)if(primary)base.line(points,materials.track,paper?Math.max(.7,size*.055):Math.max(2.5,size*.10));
if(state.path) p.line(routePoints(state.path,view),state.path.status==='exit'?THEME.primary:THEME.warning,Math.max(4,size*.12));
if(state.selected) {
const cell=level.cells.find(c=>c.q===state.selected.q&&c.r===state.selected.r);
+3 -1
View File
@@ -20,7 +20,7 @@ const regularLevel=createStudyLevel(3);
let studyLevel=createStudyLevel(5);
let colorCount=7;
let direction=artDirection(new URLSearchParams(location.search).get("design"));
let theme=themeWithColors(LEVEL_THEMES[0],colorCount),game=new Game(studyLevel),animation=null,rotation=null,frame=0,view=null,hint=null,afterRotation=null;
let theme=themeWithColors(LEVEL_THEMES.find(t=>t.id===new URLSearchParams(location.search).get('theme'))||LEVEL_THEMES[0],colorCount),game=new Game(studyLevel),animation=null,rotation=null,frame=0,view=null,hint=null,afterRotation=null;
const atlas=new Image();atlas.src=TILE_ATLAS;atlas.onload=()=>{painter.atlas=atlas;draw();paintSamples();};
function decorateIcons(){
document.querySelectorAll('[data-icon]').forEach(el=>{
@@ -35,8 +35,10 @@ $('pigment-grid').innerHTML=PIGMENTS.map((p,i)=>`<div class="pigment-item"><canv
$('quick-theme').innerHTML=LEVEL_THEMES.map(t=>`<option value="${t.id}">${t.label}</option>`).join('');
$('quick-theme').onchange=()=>setTheme(direction.themes.find(t=>t.id===$('quick-theme').value));
function setTheme(next){
const url=new URL(location.href);url.searchParams.set('theme',next.id);history.replaceState(null,'',url);
$('quick-theme').value=next.id;
theme=directionTheme(direction,next.id,colorCount);$('phone').style.setProperty('--scene-bg',theme.background);$('density-grid').style.setProperty('--bg',theme.background);$('density-grid').style.setProperty('--ink',theme.ink);$('phone').style.setProperty('--scene-ink',theme.ink);
document.body.dataset.theme=theme.id;
$('item-study-content').style.setProperty('--scene-bg',theme.background);$('item-study-content').style.setProperty('--scene-ink',theme.ink);
$('scene-label').textContent=`${theme.label} / ${theme.range}`;$('scene-name').textContent=theme.name;
$('scene-description').textContent=`${theme.description} 当前展示 ${colorCount} 色扩展搭配。`;$('phone-label').textContent=`${theme.label} · ${theme.name}`;
+9
View File
@@ -1,8 +1,17 @@
import { roundedHex } from './tile-shape.js';
import { hexPoints } from './path-math.js';
export function tint(hex,amount){return '#'+hex.slice(1).match(/../g).map(v=>Math.round(amount>=0?parseInt(v,16)+(255-parseInt(v,16))*amount:parseInt(v,16)*(1+amount)).toString(16).padStart(2,'0')).join('');}
export function drawStyledToken(p,x,y,r,tile,angle){
const fill=tile.fill,ink='#263A3A';
if(!fill||!tile.artStyle||tile.artStyle==='classic')return false;
if(tile.artStyle==='paper'){
const face=hexPoints(x,y,r),unit=r/.88;
p.polygon(face,fill);p.line([...face,face[0]],'#FFFFFFCC',unit*.035);
const rotate=points=>points.map(([u,v])=>({x:x+unit*(u*Math.cos(angle)-v*Math.sin(angle)),y:y+unit*(u*Math.sin(angle)+v*Math.cos(angle))}));
p.line(rotate([[-.3,0],[.3,0]]),tile.arrow,unit*.09);
p.line(rotate([[.08,-.17],[.3,0],[.08,.17]]),tile.arrow,unit*.09);
return true;
}
const face=roundedHex(x,y,r*.94),closed=[...face,face[0]];
let arrowY=y,arrowScale=.58;
if(tile.artStyle==='vivid'){