feat: add hex trail puzzle, level editor, and visual design guide
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
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>`,
|
||||
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.`);
|
||||
@@ -0,0 +1,19 @@
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import { THEME, SURFACES, TYPOGRAPHY, MOTION, RADII, CONTRAST_PAIRS, contrast } from '../src/design-tokens.js';
|
||||
// Render a newly authored flat color texture from tokens, with no editing of image artwork.
|
||||
const names=Object.keys(SURFACES),width=names.length*32,height=32,raw=Buffer.alloc((width*4+1)*height);
|
||||
for(let y=0;y<height;y++)for(let x=0;x<width;x++){
|
||||
const c=THEME[SURFACES[names[Math.floor(x/32)]]],v=c.slice(1).match(/../g).map(v=>parseInt(v,16)),o=y*(width*4+1)+1+x*4;
|
||||
raw[o]=v[0];raw[o+1]=v[1];raw[o+2]=v[2];raw[o+3]=255;
|
||||
}
|
||||
function crc32(b){let c=0xffffffff;for(const v of b){c^=v;for(let i=0;i<8;i++)c=(c>>>1)^((c&1)?0xedb88320:0);}return(c^0xffffffff)>>>0;}
|
||||
function chunk(type,b){const t=Buffer.from(type),n=Buffer.alloc(4),crc=Buffer.alloc(4);n.writeUInt32BE(b.length);crc.writeUInt32BE(crc32(Buffer.concat([t,b])));return Buffer.concat([n,t,b,crc]);}
|
||||
const ihdr=Buffer.alloc(13);ihdr.writeUInt32BE(width);ihdr.writeUInt32BE(height,4);ihdr[8]=8;ihdr[9]=6;
|
||||
writeFileSync(new URL('../assets/ui-surfaces.png',import.meta.url),Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]),chunk('IHDR',ihdr),chunk('IDAT',deflateSync(raw)),chunk('IEND',Buffer.alloc(0))]));
|
||||
const kebab=s=>s.replace(/[A-Z]/g,c=>'-'+c.toLowerCase());
|
||||
writeFileSync(new URL('../design/tokens.css',import.meta.url),'/* Generated by npm run design. Edit src/design-tokens.js. */\n:root{\n'+Object.entries(THEME).map(([k,v])=>` --color-${kebab(k)}:${v};`).join('\n')+'\n}\n');
|
||||
writeFileSync(new URL('../design/tokens.json',import.meta.url),JSON.stringify({theme:THEME,surfaces:SURFACES,typography:TYPOGRAPHY,motion:MOTION,radii:RADII},null,2)+'\n');
|
||||
const report=CONTRAST_PAIRS.map(([label,fg,bg,min])=>({label,foreground:THEME[fg],background:THEME[bg],ratio:Number(contrast(THEME[fg],THEME[bg]).toFixed(3)),minimum:min,pass:contrast(THEME[fg],THEME[bg])>=min}));
|
||||
writeFileSync(new URL('../design/contrast-report.json',import.meta.url),JSON.stringify(report,null,2)+'\n');
|
||||
if(report.some(r=>!r.pass)){console.error('Contrast checks failed:',report.filter(r=>!r.pass));process.exitCode=1;}else console.log(`Design tokens built. ${report.length} contrast pairs pass.`);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { cp, mkdir, rm } from 'node:fs/promises';
|
||||
const out=new URL('../dist/',import.meta.url);
|
||||
await rm(out,{recursive:true,force:true});await mkdir(out,{recursive:true});
|
||||
for(const name of ['index.html','style.css','src','levels','assets','design','Design.md','Design.html'])await cp(new URL('../'+name,import.meta.url),new URL(name,out),{recursive:true});
|
||||
console.log('Static build ready in dist/');
|
||||
@@ -0,0 +1,21 @@
|
||||
import { generateLevel, analyze, rotateBoard, solveRotations } from '../src/core.js';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
const names=['转一转,路就通了','换个方向','弯道别碰头','给我让条路','粉色回旋','蓝色小径','向左看齐','第一圈冒险','旋转甜甜圈','差一点擦肩','紫色回廊','再转一次','绕开拥挤','转角遇见光','别挤在一起','糖果交响曲','盘旋之间','左右逢源','小心翼翼','寻找缝隙','回转大挑战','紧密相连','终极转盘','六角大满贯'];
|
||||
const candidates=[];
|
||||
for(let i=0;i<100;i++){
|
||||
const radius=i<25?2:3,target=i<25?5+i%10:16+i%18;
|
||||
const level=rotateBoard(generateLevel(2+i*73,radius,target,.15+(i%10)/12),1+i%3);
|
||||
const solution=solveRotations({...level,moveLimit:999});
|
||||
if(!solution.reachable||!solution.rotations)continue;
|
||||
level.rotationEnabled=true;
|
||||
level.moveLimit=solution.minimumMoves+Math.max(2,Math.round(target*.2));
|
||||
level.requiresRotation=true;
|
||||
const analysis=analyze(level);
|
||||
if(analysis.solvable)candidates.push({level,analysis});
|
||||
}
|
||||
candidates.sort((a,b)=>a.analysis.score-b.analysis.score);
|
||||
if(candidates.length<24)throw new Error('Not enough verified rotation puzzles');
|
||||
const levels=names.map((name,i)=>({...candidates[Math.round(i*(candidates.length-1)/23)].level,id:`turn-level-${i+1}`,name}));
|
||||
await writeFile(new URL('../levels/campaign.json',import.meta.url),JSON.stringify(levels,null,2)+'\n');
|
||||
await writeFile(new URL('../src/levels.js',import.meta.url),'// Generated by npm run levels. Rotation + swept collision campaign.\nexport const LEVELS = '+JSON.stringify(levels)+';\n');
|
||||
console.log(levels.map((l,i)=>{const a=analyze(l);return `${i+1}. ${l.name} ${l.tiles.length} tiles ${a.score}/100 min ${a.rotations} turns (${a.minimumMoves} moves)`;}).join('\n'));
|
||||
@@ -0,0 +1,35 @@
|
||||
# Newly authored geometric icon artwork, rendered to a portable PNG atlas.
|
||||
from PIL import Image,ImageDraw
|
||||
from math import sin,cos,pi
|
||||
S=4; im=Image.new('RGBA',(1024*S,1024*S));d=ImageDraw.Draw(im)
|
||||
for i in range(16):
|
||||
ox=(i%4)*256;oy=(i//4)*256
|
||||
def p(x,y):return ((ox+32+x*8)*S,(oy+32+y*8)*S)
|
||||
def poly(pts,fill='white'):d.polygon([p(*v) for v in pts],fill=fill)
|
||||
def line(pts,w=2.3):
|
||||
d.line([p(*v) for v in pts],fill='white',width=int(w*8*S),joint='curve')
|
||||
for x,y in [pts[0],pts[-1]]:ell(x-w/2,y-w/2,x+w/2,y+w/2)
|
||||
def ell(a,b,c,e,fill='white'):d.ellipse([p(a,b),p(c,e)],fill=fill)
|
||||
def rect(a,b,c,e,r=1.4):d.rounded_rectangle([p(a,b),p(c,e)],radius=r*8*S,fill='white')
|
||||
def turn(clock=True):
|
||||
pts=[(12+7*cos((-60+j*290/60)*pi/180),12+7*sin((-60+j*290/60)*pi/180)) for j in range(61)]
|
||||
if not clock:pts=[(24-x,y)for x,y in pts]
|
||||
line(pts);x,y=pts[-1];vx=x-pts[-2][0];vy=y-pts[-2][1];n=(vx*vx+vy*vy)**.5;vx/=n;vy/=n
|
||||
poly([(x+vx*2,y+vy*2),(x-vx*3-vy*3,y-vy*3+vx*3),(x-vx*3+vy*3,y-vy*3-vx*3)])
|
||||
if i==0:
|
||||
poly([(12+(9 if j%4 in [0,1] else 7)*cos(j*pi/16),12+(9 if j%4 in [0,1] else 7)*sin(j*pi/16))for j in range(32)]);ell(8.5,8.5,15.5,15.5,(0,0,0,0))
|
||||
elif i==1:
|
||||
for x,y in [(3,3),(14,3),(3,14),(14,14)]:rect(x,y,x+7,y+7)
|
||||
elif i==2:line([(15,4),(7,12),(15,20)],3)
|
||||
elif i==3:line([(5,5),(19,19)],3);line([(19,5),(5,19)],3)
|
||||
elif i in [4,5,7]:turn(i!=4)
|
||||
elif i==6:line([(5,8),(14,8),(18,10),(19,14),(17,18),(13,19)]);poly([(2,8),(9,2),(9,14)])
|
||||
elif i==8:ell(5,2,19,16);rect(9,14,15,19);line([(10,22),(14,22)],2)
|
||||
elif i==9:poly([(12+(10 if j%2==0 else 4.8)*cos(-pi/2+j*pi/5),12+(10 if j%2==0 else 4.8)*sin(-pi/2+j*pi/5))for j in range(10)])
|
||||
elif i==10:poly([(4,16),(16,4),(20,8),(8,20),(3,21)])
|
||||
elif i==11:poly([(6,3),(21,12),(6,21)])
|
||||
elif i==12:poly([(12,2),(22,12),(16,12),(16,22),(8,22),(8,12),(2,12)])
|
||||
elif i==13:poly([(3,15),(14,3),(22,11),(12,21),(9,21)])
|
||||
elif i==14:line([(5,20),(8,19),(10,16),(12,8),(14,5),(19,4)],3)
|
||||
else:poly([(12+10*cos(j*pi/3),12+10*sin(j*pi/3))for j in range(6)])
|
||||
im.resize((1024,1024),Image.Resampling.LANCZOS).save('assets/ui-icons-flat.png')
|
||||
@@ -0,0 +1,15 @@
|
||||
import http from 'node:http';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { resolve, extname, sep } from 'node:path';
|
||||
const root=resolve(process.argv.includes('--dist')?'dist':'.');
|
||||
const port=Number(process.env.PORT||4173);
|
||||
const types={'.html':'text/html; charset=utf-8','.js':'text/javascript; charset=utf-8','.css':'text/css; charset=utf-8','.json':'application/json; charset=utf-8','.svg':'image/svg+xml','.png':'image/png'};
|
||||
http.createServer(async(req,res)=>{
|
||||
try {
|
||||
const url=new URL(req.url,'http://localhost');
|
||||
const path=resolve(root,'.'+decodeURIComponent(url.pathname==='/'?'/index.html':url.pathname));
|
||||
if(path!==root&&!path.startsWith(root+sep)){res.writeHead(403);res.end('Forbidden');return;}
|
||||
if(!(await stat(path)).isFile()){res.writeHead(404);res.end('Not found');return;}
|
||||
const body=await readFile(path);res.writeHead(200,{'Content-Type':types[extname(path)]||'application/octet-stream','Cache-Control':'no-cache'});res.end(body);
|
||||
}catch{res.writeHead(404);res.end('Not found');}
|
||||
}).listen(port,'0.0.0.0',()=>console.log(`HEX TRAILS http://localhost:${port}`));
|
||||
Reference in New Issue
Block a user