65 lines
8.2 KiB
JavaScript
65 lines
8.2 KiB
JavaScript
import { itemRow, itemStudy } from '../src/item-study.js';
|
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
import { THEME, CONTRAST_PAIRS, contrast, TYPOGRAPHY, MOTION, RADII } from '../src/design-tokens.js';
|
|
import { ICONS, ICON_SIZE, ICON_ATLAS } from '../src/ui-skin.js';
|
|
import { TILE_ATLAS, TILE_FRAMES, TILE_ASPECT } from '../src/sprites.js';
|
|
import { tileSample, roundedHex } from '../src/tile-shape.js';
|
|
import { drawScene, drawTile } from '../src/renderer.js';
|
|
import { layout } from '../src/geometry.js';
|
|
import { LEVELS } from '../src/levels.js';
|
|
|
|
const read=name=>readFileSync(new URL('../'+name,import.meta.url),'utf8');
|
|
const dataImage=name=>'data:image/png;base64,'+readFileSync(new URL('../'+name,import.meta.url)).toString('base64');
|
|
const esc=value=>String(value).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
const markdown=read('Design.md');
|
|
// This intentionally small renderer supports the headings, lists, links and tables
|
|
// used by Design.md. Escape text before formatting; do not execute embedded HTML.
|
|
function inline(value){return esc(value).replace(/`([^`]+)`/g,'<code>$1</code>').replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>').replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');}
|
|
function renderMarkdown(source){
|
|
const lines=source.split('\n'),out=[];let i=0,heading=0;
|
|
while(i<lines.length){const line=lines[i];
|
|
if(!line.trim()){i++;continue;}
|
|
const h=line.match(/^(#{1,6})\s+(.+)$/);
|
|
if(h){const level=Math.min(6,h[1].length+1);out.push(`<h${level} id="spec-${heading++}">${inline(h[2])}</h${level}>`);i++;continue;}
|
|
if(line.startsWith('|')){const rows=[];while(i<lines.length&&lines[i].startsWith('|'))rows.push(lines[i++].split('|').slice(1,-1).map(s=>s.trim()));const header=rows.shift();if(rows[0]?.every(s=>/^:?-+:?$/.test(s)))rows.shift();out.push(`<div class="table-scroll"><table><thead><tr>${header.map(s=>`<th>${inline(s)}</th>`).join('')}</tr></thead><tbody>${rows.map(row=>`<tr>${row.map(s=>`<td>${inline(s)}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`);continue;}
|
|
if(/^(- |\d+\. )/.test(line)){const ordered=/^\d/.test(line),items=[];while(i<lines.length&&(ordered?/^\d+\. /:/^- /).test(lines[i]))items.push(lines[i++].replace(/^(- |\d+\. )/,''));out.push(`<${ordered?'ol':'ul'}>${items.map(s=>`<li>${inline(s)}</li>`).join('')}</${ordered?'ol':'ul'}>`);continue;}
|
|
const p=[line];i++;while(i<lines.length&&lines[i].trim()&&!/^(#|\||- |\d+\. )/.test(lines[i]))p.push(lines[i++]);out.push(`<p>${inline(p.join(' '))}</p>`);
|
|
}
|
|
return out.join('\n');
|
|
}
|
|
const iconNames={gear:'设置',grid:'选关',back:'返回',close:'关闭',left:'左转',right:'右转',undo:'撤销',restart:'重来',bulb:'提示',star:'星星',edit:'编辑',play:'试玩',arrow:'方向',erase:'擦除',track:'轨道',gem:'六角'};
|
|
const icon=name=>`<span class="icon icon-${name}" aria-hidden="true"></span>`;
|
|
const iconCSS=Object.entries(ICONS).map(([name,f])=>`.icon-${name}{background-size:${ICON_SIZE.w/f.w*100}% ${ICON_SIZE.h/f.h*100}%;background-position:${f.x/(ICON_SIZE.w-f.w)*100}% ${f.y/(ICON_SIZE.h-f.h)*100}%}`).join('\n');
|
|
const meanings={background:'游戏背景',board:'棋盘底格',boardGrid:'六边形分格',track:'轨道中心线',trackHalo:'轨道浅色外缘',surface:'弹窗与编辑面板',textPrimary:'主要文字',textSecondary:'说明与辅助文字',primary:'主操作与当前选中',onPrimary:'主操作上的文字',secondary:'次级操作表面',utility:'工具图标底板',onUtility:'工具图标与文字',selection:'弱选中表面',onSelection:'弱选中上的文字',disabled:'原生禁用参考表面',onDisabled:'原生禁用参考文字',input:'输入框表面',reward:'奖励与星星强调',warning:'警告文字',warningSurface:'警告提示表面',scrim:'模态遮罩 · 约 48%',focus:'键盘焦点',tileBlue:'蓝棋子 · 代表采样值',tilePink:'粉棋子 · 代表采样值',tileYellow:'黄棋子 · 代表采样值',tilePurple:'紫棋子 · 代表采样值',arrowLight:'蓝 / 粉 / 紫棋子箭头',arrowDark:'黄色棋子箭头'};
|
|
const swatches=Object.entries(THEME).map(([name,hex])=>`<button class="swatch" data-color="${hex}" data-search="${name.toLowerCase()} ${hex.toLowerCase()} ${meanings[name]}" aria-label="复制 ${name} ${hex}"><span class="swatch-color" style="--swatch:${hex}"></span><span class="swatch-label"><strong>${meanings[name]}</strong><code>${name}</code><span class="hex">${hex}<span aria-hidden="true">复制</span></span></span></button>`).join('');
|
|
const reports=CONTRAST_PAIRS.map(([label,fg,bg,min])=>({label,fg,bg,min,ratio:contrast(THEME[fg],THEME[bg])}));
|
|
const pairRows=reports.map(r=>`<tr><td><span class="pair-example" style="color:${THEME[r.fg]};background:${THEME[r.bg]}">${r.min===3?'➜':'Aa'}</span>${r.label}</td><td><code>${r.fg}<br>${r.bg}</code></td><td><b>${r.ratio.toFixed(2)}:1</b></td><td>≥ ${r.min}:1</td><td>${r.ratio>=r.min?'通过':'需调整'}</td></tr>`).join('');
|
|
// Record the game's actual renderer commands as SVG for a static composition.
|
|
// Original PNG pixels are embedded unchanged; shared sampling and clip geometry
|
|
// match the game. This is a documentation adapter, not an alternative game UI.
|
|
let serial=0;
|
|
function sceneSVG(render,label){const nodes=[],defs=[];const points=pts=>pts.map(p=>`${p.x.toFixed(3)},${p.y.toFixed(3)}`).join(' ');
|
|
const painter={
|
|
polygon(pts,fill){nodes.push(`<polygon points="${points(pts)}" fill="${fill}"/>`);},
|
|
line(pts,color,width){nodes.push(`<polyline points="${points(pts)}" fill="none" stroke="${color}" stroke-width="${width}" stroke-linejoin="round" stroke-linecap="round"/>`);},
|
|
circle(x,y,r,fill){nodes.push(`<circle cx="${x}" cy="${y}" r="${r}" fill="${fill}"/>`);},
|
|
sprite(index,x,y,r){const f=tileSample(TILE_FRAMES[index]),w=r*2,h=w*TILE_ASPECT,id=`tile-clip-${serial++}`;defs.push(`<clipPath id="${id}"><polygon points="${points(roundedHex(x,y,r))}"/></clipPath>`);nodes.push(`<g clip-path="url(#${id})"><svg x="${x-w/2}" y="${y-h/2}" width="${w}" height="${h}" viewBox="${f.x} ${f.y} ${f.w} ${f.h}" preserveAspectRatio="none"><use href="#tile-atlas-image"/></svg></g>`);return true;},
|
|
};
|
|
render(painter);return `<svg class="game-art" viewBox="0 0 360 360" role="img" aria-label="${esc(label)}"><defs>${defs.join('')}</defs>${nodes.join('')}</svg>`;
|
|
}
|
|
const level=LEVELS[1];
|
|
const board=sceneSVG(p=>drawScene(p,level,level.tiles,layout(level.cells,360,360)),'第二关棋盘静态展示,使用游戏实际轨道、棋子图片与颜色');
|
|
const tiles=sceneSVG(p=>[0,1,2,3].forEach((color,i)=>drawTile(p,95+(i%2)*170,95+Math.floor(i/2)*170,75,{color,dir:0})),'蓝、粉、黄、紫四种实际棋子,黄色使用深色箭头');
|
|
const replacements={
|
|
TOKENS:Object.entries(THEME).map(([k,v])=>`--${k}:${v};`).join('')+`--pressMs:${MOTION.pressMs}ms;--modalMs:${MOTION.modalMs}ms;--starOne:${MOTION.starStartMs}ms;--starTwo:${MOTION.starStartMs+MOTION.starGapMs}ms;--starThree:${MOTION.starStartMs+MOTION.starGapMs*2}ms;--controlRadius:${RADII.control}px;--dialogRadius:${RADII.dialog}px;`,ICON_ATLAS:dataImage(ICON_ATLAS),ICON_CSS:iconCSS,
|
|
TILE_IMAGE:`<svg width="0" height="0" aria-hidden="true" class="svg-defs"><defs><image id="tile-atlas-image" width="1293" height="1216" href="${dataImage(TILE_ATLAS)}"/></defs></svg>`,
|
|
GAME_TOOLS_CSS:read('design/game-tools.css'),GAME_ITEMS:itemStudy(icon),GAME_ITEMS_ROW:itemRow(icon),
|
|
BOARD:board,TILES:tiles,MOVES:level.moveLimit,SWATCHES:swatches,PAIR_ROWS:pairRows,
|
|
ICONS:Object.keys(ICONS).map(name=>`<div class="icon-item"><span class="icon-ground">${icon(name)}</span><b>${iconNames[name]}</b><code>${name}</code></div>`).join(''),
|
|
SPEC:renderMarkdown(markdown),PAYLOAD:JSON.stringify({markdown,theme:THEME,typography:TYPOGRAPHY,motion:MOTION,radii:RADII}).replace(/</g,'\\u003c'),
|
|
};
|
|
const template=read('design/guide-template.html');
|
|
const output=template.replace(/\{\{([A-Z_]+)\}\}/g,(_,key)=>{if(!(key in replacements))throw new Error(`Unknown guide placeholder ${key}`);return replacements[key];});
|
|
writeFileSync(new URL('../Design.html',import.meta.url),output);
|
|
console.log(`Design.html built: ${Object.keys(THEME).length} colors, ${Object.keys(ICONS).length} original icons, ${reports.length} contrast pairs, complete specification. Standalone / offline.`);
|