纠正仓库内容为衍生项目,标明 campaign-200-r2 最新版

This commit is contained in:
toshiocn
2026-10-19 16:56:31 +08:00
parent a492c5b80e
commit fdca4f67ea
1396 changed files with 392768 additions and 19530 deletions
+23
View File
@@ -0,0 +1,23 @@
from pathlib import Path
import json,csv
root=Path(r'G:\项目\NumberSwipe-Variants');demo=root/'campaign-50'
# Add a convenient entry without rebuilding or touching the existing mode files.
entry='<a href="campaign-50/index.html" class="card"><span class="pill">可玩DEMO · 50关</span><h2>50关统一主线</h2><p>五章渐进 · 奇偶与机制组合 · 全部关卡开放</p></a>'
p=root/'index.html';s=p.read_text(encoding='utf-8-sig')
if 'href="campaign-50/index.html"' not in s:s=s.replace('<div class="cards">','<div class="cards">'+entry,1)
p.write_text(s,encoding='utf-8')
p=root/'tools/build.mjs';s=p.read_text(encoding='utf-8-sig')
if entry not in s:s=s.replace('<div class="cards">${definitions.map','<div class="cards">'+entry+'${definitions.map',1)
p.write_text(s,encoding='utf-8')
link='<p class="callout"><strong>50关可玩版已制作:</strong><a href="../../campaign-50/index.html">打开50关DEMO →</a> 本页保留原始设计目标,制作验收见试玩目录报告。</p>'
p=root/'design/demo-50/index.html';s=p.read_text(encoding='utf-8-sig')
if '../../campaign-50/index.html' not in s:s=s.replace('<div class="banner">',link+'<div class="banner">',1)
p.write_text(s,encoding='utf-8')
p=root/'design/demo-50/build-h5.py';s=p.read_text(encoding='utf-8-sig')
if link not in s:s=s.replace('<div class="banner">',link+'<div class="banner">',1)
p.write_text(s,encoding='utf-8')
notes=json.loads((demo/'design.json').read_text(encoding='utf-8'))
with (demo/'reports/difficulty-observations.csv').open('w',encoding='utf-8-sig',newline='') as f:
writer=csv.writer(f);writer.writerow(['order','level_id','title','target_difficulty','node_count','immediate_choice_points','mechanism_count','human_difficulty','feedback'])
for n in notes:writer.writerow([n['level']['order'],n['level']['id'],n['level']['title'],n['level']['targetDifficulty'],len(n['level']['cells']),n['metrics']['noImmediateConflictChoices'],len(n['witnesses']),'',''])
print('Added playable entries and feedback CSV without modifying old mode files.')
+57
View File
@@ -0,0 +1,57 @@
from pathlib import Path
import hashlib,json,re
root=Path(r'G:\项目\NumberSwipe-Variants\campaign-50');source=Path(r'G:\项目\NumberSwipe')
(root/'reports/ui-before.json').write_text(json.dumps({n:hashlib.sha256((root/n).read_bytes()).hexdigest() for n in ['levels.json','rules.mjs','order-a.json','order-b.json']},indent=2),encoding='utf-8')
css=(source/'modes/01-number-stroke/web/style.css').read_text(encoding='utf-8-sig')
css+='''
/* DEMO additions: preserve the original token and component system. */
#constraints{display:flex;gap:6px;flex-wrap:wrap;justify-content:center;margin:4px 0 0;min-height:0;flex-shrink:0}#constraints .chip{height:28px;min-height:28px;padding:0 10px;font-size:12px}.level-sub{max-width:240px;overflow:hidden;text-overflow:ellipsis}.caption{flex-shrink:0;height:36px;padding:0 4px}.topbar,.level-progress,.bottombar,.gesture-help{flex-shrink:0}#board-wrap{margin-top:8px}.next-overlay{position:absolute;bottom:calc(var(--safe-bottom) + 114px);left:50%;transform:translateX(-50%);padding:0 22px;min-width:160px;height:44px;margin:0;z-index:3}.next-overlay[hidden]{display:none}.next-overlay:active{transform:translateX(-50%) scale(.98)}#status{z-index:4}.rules-button{position:absolute;right:20px;top:calc(var(--safe-top) + 68px);background:none;font-size:12px;color:var(--ink-2);padding:3px}.sheet-backdrop{position:absolute;inset:0;background:rgba(35,36,58,.36);z-index:6;display:flex;align-items:flex-end}.sheet-backdrop[hidden]{display:none}.sheet{width:100%;max-height:75%;overflow:auto;background:var(--bg);border-radius:24px 24px 0 0;padding:24px 20px calc(20px + var(--safe-bottom))}.sheet h2{font-size:22px;font-weight:800;margin:0 0 12px}.sheet p{font-size:15px;line-height:1.7;color:var(--ink-2)}.sheet textarea{width:100%;min-height:100px;padding:12px;border:1px solid var(--line);border-radius:14px;font:inherit;user-select:text}.sheet .sheet-actions{display:flex;gap:8px;margin-top:16px}.sheet .action{height:48px}.sheet .primary{height:48px;padding:0 16px;margin:0}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)}.select-footer .chip{min-height:44px;height:44px}.level-tile{min-height:44px}.select-footer a{display:flex;align-items:center;text-decoration:none;color:var(--ink-2)}@media(max-height:660px){.select-header{padding-top:8px}.caption{font-size:13px;height:30px}#constraints .chip{height:24px;min-height:24px}.bottombar{padding-top:8px}.action{height:48px}.gesture-help{font-size:12px}.screen{padding-top:calc(var(--safe-top) + 8px)}}
'''
(root/'style.css').write_text(css,encoding='utf-8')
s=(root/'ui.mjs').read_text(encoding='utf-8-sig')
s=s.replace("const pack=PACK;", "const pack=PACK;\nconst BOARD_FONT='ui-rounded, \"SF Pro Rounded\", \"PingFang SC\", \"Hiragino Sans GB\", \"Noto Sans SC\", system-ui, -apple-system, \"Segoe UI\", sans-serif';\nconst UI_THEME={cell:'#E6E7EC',ink:'#23243A',primary:'#6558F5',bright:'#7D72FF',danger:'#E5484D',hint:'#F5A524',ring:'#DFE1EC'};let toastTimer=null;")
s=s.replace("function setStatus(s){$('status').textContent=s;}", "function setStatus(s){$('status').textContent=s;$('status').className='toast show';if(toastTimer)clearTimeout(toastTimer);toastTimer=setTimeout(()=>{$('status').className='toast';},1800);}function showPlay(){$('screen-select').className='screen hidden';$('screen-play').className='screen enter';draw();}function showSelect(){saveRecord();$('screen-select').className='screen enter';$('screen-play').className='screen hidden';renderSelectors();$('status').className='toast';}")
s=s.replace('function start(i){','function start(i,open=true){')
s=s.replace("$('title').textContent=pack.title", "$('title').textContent='数字一笔'")
s=s.replace('renderConstraints();renderSelectors();draw();}', 'renderConstraints();renderSelectors();if(open)showPlay();draw();}',1)
a=s.index('function renderSelectors(){');b=s.index('function rulesFor(',a)
s=s[:a]+'''function renderSelectors(){
const list=$('pack-list');list.replaceChildren();for(const chapter of pack.chapters){const section=document.createElement('section');section.className='pack';const head=document.createElement('div');head.className='pack-title';const title=document.createElement('span');title.textContent=chapter.title;const count=document.createElement('span');count.className='pack-progress';const chapterLevels=pack.levels.filter(l=>l.chapter===chapter.id);count.textContent=chapterLevels.filter(l=>completed.includes(l.id)).length+' / 10';head.append(title,count);const grid=document.createElement('div');grid.className='level-grid';for(const l of chapterLevels){const i=pack.levels.indexOf(l),button=document.createElement('button');button.textContent=String(l.order);button.className='level-tile'+(completed.includes(l.id)?' cleared':'')+(i===index?' current':'');button.onclick=()=>start(i);grid.append(button);}section.append(head,grid);list.append(section);}
$('position').textContent=level.chapter+'-'+((level.order-1)%10+1);$('level-sub').textContent=level.chapterTitle+' · 第'+level.order+'关';$('progress').textContent=completed.length+' / 50';$('overall-fill').style.width=(completed.length*2)+'%';$('btn-continue').textContent='继续 · '+level.chapter+'-'+((level.order-1)%10+1);$('level-progress').replaceChildren();for(const l of pack.levels.filter(l=>l.chapter===level.chapter)){const seg=document.createElement('i');seg.className=(completed.includes(l.id)?'cleared ':'')+(l.id===level.id?'current':'');$('level-progress').append(seg);}
}
''' +s[b:]
a=s.index('function renderConstraints(){');b=s.index('function localGeometry(){',a)
s=s[:a]+'''function renderConstraints(){const list=$('constraints');list.replaceChildren();const add=(text,ids)=>{const b=document.createElement('button');b.className='chip';b.textContent=text;b.onclick=()=>{focus=ids;draw();};list.append(b);};if(level.geometry==='blocks')add('大格一步',level.cells.filter(c=>c.w*c.h>1).map(c=>c.id));if(level.features.includes('direction'))add('通道 · 按图进出',level.cells.filter(c=>c.direction).map(c=>c.id));if(level.features.includes('alternation'))add('蓝 → 橙 → 蓝',[]);if(level.features.includes('parity'))add('奇/偶 · 看步号',level.cells.filter(c=>c.parity).map(c=>c.id));if(level.walls?.length)add('永久墙',level.walls.flat());for(const d of level.doors||[])add(doorOpen(d,path.length)?'门开 · 下一格关':'门关 · 下一格开',d.edge);}
''' +s[b:]
# Keep raw polygons for hit and adjacency; inset rounded silhouettes are only visual.
a=s.index('function draw(){');b=s.index('function drawChannel(',a)
s=s[:a]+'''function draw(){if(!level)return;const width=canvas.clientWidth,height=canvas.clientHeight;if(!width||!height)return;const dpr=window.devicePixelRatio||1;canvas.width=Math.round(width*dpr);canvas.height=Math.round(height*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,width,height);
const {raw,center}=localGeometry(),all=raw.flatMap(r=>r.p),xmin=Math.min(...all.map(p=>p[0])),xmax=Math.max(...all.map(p=>p[0])),ymin=Math.min(...all.map(p=>p[1])),ymax=Math.max(...all.map(p=>p[1]));unit=Math.min(level.cells.length<=6?96:110,(width-20)/(xmax-xmin),(height-20)/(ymax-ymin));const ox=(width-unit*(xmax-xmin))/2-unit*xmin,oy=(height-unit*(ymax-ymin))/2-unit*ymin-height*.03;const tr=([x,y])=>[ox+x*unit,oy+y*unit];polygons=raw.map(r=>({...r,p:r.p.map(tr)}));centers=new Map([...center].map(([id,p])=>[id,tr(p)]));
// Place irregular-block labels inside the nearest occupied unit, as in the original view.
if(level.geometry!=='hex')for(const c of level.cells)if(c.shape){const middle=center.get(c.id);const candidates=units(c).map(([x,y])=>[x+.5,y+.5]);candidates.sort((a,b)=>Math.hypot(a[0]-middle[0],a[1]-middle[1])-Math.hypot(b[0]-middle[0],b[1]-middle[1]));centers.set(c.id,tr(candidates[0]));}
const bad=violations(level,path),badIds=new Set(bad.flatMap(b=>b.cells));
for(let i=1;i<path.length;i++)drawBridge(path[i-1],path[i]);
for(const c of level.cells){const visited=path.includes(c.id),start=c.clue===1,ring=c.clue!==undefined||c.parity||c.direction;const alternate=c.category!==undefined;let fill=alternate?(c.category==='circle'?'#64acf3':'#ffae55'):badIds.has(c.id)?UI_THEME.danger:cleared?UI_THEME.bright:visited||start?UI_THEME.primary:UI_THEME.cell;const foreground=!alternate&&(visited||start||badIds.has(c.id))?'#FFFFFF':UI_THEME.ink;if(focus.includes(c.id))paintCell(c,unit*.008,UI_THEME.hint);if(ring)paintCell(c,unit*.045,visited||start?UI_THEME.ring:UI_THEME.ink);paintCell(c,unit*.05+(ring?Math.max(2,unit*.05):0),fill);if(alternate&&visited){const [x,y]=centers.get(c.id);ctx.beginPath();ctx.moveTo(x-unit*.2,y+unit*.29);ctx.lineTo(x+unit*.2,y+unit*.29);ctx.strokeStyle=UI_THEME.primary;ctx.lineWidth=3;ctx.stroke();}
const [x,y]=centers.get(c.id),step=path.indexOf(c.id)+1;let value=step||c.clue;const ratio=value===1?.6:value>=10?.46:.52,font=Math.max(14,Math.min(52,unit*ratio));ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillStyle=foreground;
if(c.direction)drawChannel(c.direction,x,y-(value?unit*.23:0),Math.min(unit*.25,value?unit*.14:22),foreground);
if(value){ctx.font='800 '+font+'px '+BOARD_FONT;ctx.fillText(String(value),x,y+(c.direction?unit*.09:c.parity?-unit*.06:0)+font*.04);if(c.parity){ctx.font='700 '+Math.max(10,unit*.19)+'px '+BOARD_FONT;ctx.fillText(c.parity==='odd'?'奇':'偶',x,y+unit*.27);}if(c.category){ctx.font='700 '+Math.max(10,unit*.18)+'px '+BOARD_FONT;ctx.fillText(c.category==='circle'?'○':'△',x,y-unit*.3);}if(c.clue!==undefined&&step&&step!==c.clue){ctx.font='800 '+Math.max(10,unit*.19)+'px '+BOARD_FONT;ctx.fillText('需'+c.clue,x,y-unit*.3);}}
else if(c.parity){ctx.font='700 '+Math.max(16,unit*.44)+'px '+BOARD_FONT;ctx.fillText(c.parity==='odd'?'奇':'偶',x,y+unit*.015);}else if(c.category){ctx.font='700 '+Math.max(14,unit*.36)+'px '+BOARD_FONT;ctx.fillText(c.category==='circle'?'○':'△',x,y);}
}
for(const edge of level.walls||[])drawBarrier(edge,'wall');for(const d of level.doors||[])drawBarrier(d.edge,doorOpen(d,path.length)?'open':'closed');
$('remaining-n').textContent=cleared?'完成':String(level.cells.length-path.length);$('remaining').className='remaining'+(cleared?' done':'');if(level.doors?.length)renderConstraints();if(bad.length)setStatus(bad[0].message+',可以点已走格退回。');$('undo').disabled=!path.length;$('next').hidden=!cleared||index===pack.levels.length-1;
}
function roundedRect(x,y,w,h,r){r=Math.min(r,w/2,h/2);ctx.beginPath();ctx.moveTo(x+r,y);ctx.lineTo(x+w-r,y);ctx.quadraticCurveTo(x+w,y,x+w,y+r);ctx.lineTo(x+w,y+h-r);ctx.quadraticCurveTo(x+w,y+h,x+w-r,y+h);ctx.lineTo(x+r,y+h);ctx.quadraticCurveTo(x,y+h,x,y+h-r);ctx.lineTo(x,y+r);ctx.quadraticCurveTo(x,y,x+r,y);ctx.closePath();}
function paintCell(c,inset,colour){ctx.fillStyle=colour;if(level.geometry==='hex'){const p=polygons.find(p=>p.id===c.id).p,[cx,cy]=centers.get(c.id),scale=1-inset/(unit*.86);const pts=p.map(([x,y])=>[cx+(x-cx)*scale,cy+(y-cy)*scale]);ctx.beginPath();for(let i=0;i<6;i++){const a=pts[(i+5)%6],b=pts[i],d=pts[(i+1)%6],before=[b[0]+(a[0]-b[0])*.12,b[1]+(a[1]-b[1])*.12],after=[b[0]+(d[0]-b[0])*.12,b[1]+(d[1]-b[1])*.12];if(i===0)ctx.moveTo(...before);else ctx.lineTo(...before);ctx.quadraticCurveTo(...b,...after);}ctx.closePath();ctx.fill();return;}
const polygonsHere=polygons.filter(p=>p.id===c.id),u=units(c),origin=[polygonsHere[0].p[0][0]-u[0][0]*unit,polygonsHere[0].p[0][1]-u[0][1]*unit],radius=Math.max(2,unit*.18-inset*.2);
if(!c.shape){roundedRect(origin[0]+c.x*unit+inset,origin[1]+c.y*unit+inset,c.w*unit-inset*2,c.h*unit-inset*2,radius);ctx.fill();return;}
for(const [x,y]of u){roundedRect(origin[0]+x*unit+inset,origin[1]+y*unit+inset,unit-inset*2,unit-inset*2,radius);ctx.fill();}
for(const [x,y]of u)for(const [dx,dy]of [[1,0],[0,1]])if(u.some(p=>p[0]===x+dx&&p[1]===y+dy)){const xx=origin[0]+x*unit,yy=origin[1]+y*unit;const a=dx?[xx+unit-inset-radius,yy+inset,inset*2+radius*2,unit-inset*2]:[xx+inset,yy+unit-inset-radius,unit-inset*2,inset*2+radius*2];roundedRect(...a,0);ctx.fill();}}
function drawBridge(a,b){const segments=sharedSegments(a,b);if(!segments.length)return;const [p,q]=segments[0],cx=(p[0]+q[0])/2,cy=(p[1]+q[1])/2,len=Math.hypot(q[0]-p[0],q[1]-p[1]),nx=-(q[1]-p[1])/len,ny=(q[0]-p[0])/len;ctx.beginPath();ctx.moveTo(cx-nx*unit*.09,cy-ny*unit*.09);ctx.lineTo(cx+nx*unit*.09,cy+ny*unit*.09);ctx.lineWidth=unit*.46;ctx.lineCap='butt';ctx.strokeStyle=cleared?UI_THEME.bright:UI_THEME.primary;ctx.stroke();}
function sharedSegments(a,b){const edges=[];for(const p of polygons.filter(p=>p.id===a))for(const q of polygons.filter(p=>p.id===b))for(let i=0;i<p.p.length;i++)for(let j=0;j<q.p.length;j++){const x=p.p[i],y=p.p[(i+1)%p.p.length],v=q.p[j],w=q.p[(j+1)%q.p.length];if(Math.hypot(x[0]-w[0],x[1]-w[1])<.01&&Math.hypot(y[0]-v[0],y[1]-v[1])<.01)edges.push([x,y]);}return edges;}
''' +s[b:]
s=s.replace('function drawChannel(kind,x,y,r){', 'function drawChannel(kind,x,y,r,ink=UI_THEME.ink){').replace("ctx.strokeStyle='#c1daf7'", "ctx.strokeStyle=ink==='#FFFFFF'?'#8E85FD':'#DFE1EC'").replace("ctx.strokeStyle='#15497b'", "ctx.strokeStyle=ink")
s=s.replace("window.addEventListener('resize',draw);", "$('btn-back').onclick=showSelect;$('btn-continue').onclick=showPlay;$('btn-rules').onclick=()=>{$('rules-sheet').hidden=false;};$('close-rules').onclick=()=>{$('rules-sheet').hidden=true;};$('btn-feedback').onclick=()=>{$('feedback-sheet').hidden=false;};$('close-feedback').onclick=()=>{saveRecord();$('feedback-sheet').hidden=true;};\nwindow.addEventListener('resize',draw);")
s=s.replace('start(initial>=0?initial:0);','start(initial>=0?initial:0,false);')
s=s.replace("records:records.length,path:","records:records.length,screen:$('screen-play').className.includes('hidden')?'select':'play',path:")
(root/'ui.mjs').write_text(s,encoding='utf-8')
print('Original UI CSS and rounded board rendering applied; gameplay rules untouched.')
+25
View File
@@ -0,0 +1,25 @@
from pathlib import Path
root=Path(r'G:\项目\NumberSwipe-Variants\campaign-50')
p=root/'tools/build.mjs';s=p.read_text(encoding='utf-8-sig');a=s.index('const html=`');b=s.index('fs.writeFileSync(path.join(root,\'index.html\')',a)
s=s[:a]+'''const html=`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#ffffff"><title>数字一笔 · 50关DEMO</title><style>${css}</style></head><body><div id="app">
<section id="screen-select" class="screen"><header class="select-header"><h1 id="title">数字一笔</h1><p class="sub">50关DEMO · 一笔走遍每一格</p><div class="overall"><div class="overall-bar"><i id="overall-fill"></i></div><span id="progress" class="overall-text">0 / 50</span></div></header><div id="pack-list" class="pack-list"></div><button id="btn-continue" class="primary">继续</button><footer class="select-footer"><a href="../index.html" class="chip">原型入口</a><button id="btn-feedback" class="chip">试玩反馈</button></footer></section>
<section id="screen-play" class="screen hidden"><header class="topbar"><button id="btn-back" class="icon" aria-label="返回选关"><svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></button><div class="level-info"><div id="position" class="level-title"></div><div id="level-sub" class="level-sub"></div></div><div id="remaining" class="remaining" aria-live="polite"><svg class="remaining-icon tile" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="4.5" fill="none" stroke="currentColor" stroke-width="2.4"/></svg><svg class="remaining-icon check" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"/></svg><span id="remaining-n">0</span></div></header><div id="level-progress" class="level-progress" aria-hidden="true"></div><div id="caption" class="caption"></div><div id="constraints" aria-label="当前规则"></div><div id="board-wrap" class="board-wrap"><canvas id="board" aria-label="拖动或点按共边格画路"></canvas></div><h2 id="goal" class="sr-only"></h2><p class="gesture-help">按住滑动连线 · 点已走格回退</p><footer class="bottombar"><button id="restart" class="action"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M4 12a8 8 0 1 0 2.5-5.8M4 4v5h5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg><span>重开</span></button><button id="undo" class="action"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M9 5l-5 5 5 5M4 10h10a6 6 0 0 1 0 12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg><span>撤销</span></button><button id="hint" class="action"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M5 19v-7a4 4 0 0 1 4-4h10m-4-4 4 4-4 4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg><span>指路</span></button></footer><button id="next" class="primary next-overlay" hidden>下一关 →</button><button id="btn-rules" class="rules-button">规则</button></section>
<div id="status" class="toast" role="status" aria-live="polite"></div>
<div id="rules-sheet" class="sheet-backdrop" hidden><section class="sheet" role="dialog" aria-label="本关规则"><h2>本关规则</h2><p id="rule-text"></p><div class="sheet-actions"><button id="close-rules" class="action">返回游戏</button></div></section></div>
<div id="feedback-sheet" class="sheet-backdrop" hidden><section class="sheet" role="dialog" aria-label="试玩反馈"><h2>试玩反馈</h2><p>记下有趣、枯燥、太难或规则不清楚的关卡,导出记录后用于调整。</p><textarea id="reasoning" aria-label="本关反馈" placeholder="例如:第18关有趣;第24关太难……"></textarea><div class="sheet-actions"><button id="close-feedback" class="action">关闭</button><button id="export" class="primary">导出全部记录</button></div></section></div>
</div><script>const PACK=${JSON.stringify(pack).replaceAll('<','\\\\u003c')};\\n${rules}\\n${ui}</script></body></html>`;
''' +s[b:]
p.write_text(s,encoding='utf-8')
p=root/'ui.mjs';s=p.read_text(encoding='utf-8').replace('(height-20)/(ymax-ymin)','(height-20-height*.06)/(ymax-ymin)');p.write_text(s,encoding='utf-8')
p=root/'tests/ui.test.mjs';s=p.read_text(encoding='utf-8-sig')
s=s.replace("this.value='';", "this.value='';this.style={};this.className='';")
s=s.replace("lineTo(x,y){this.p.push([x,y]);},closePath()", "lineTo(x,y){this.p.push([x,y]);},quadraticCurveTo(cx,cy,x,y){this.p.push([x,y]);},closePath()")
s=s.replace("setTimeout:f=>f()", "setTimeout:f=>f(),clearTimeout(){}")
s=s.replace("get('difficulties').children[ch].click();get('levels').children[(n-1)%10].click();", "get('pack-list').children[ch].children[1].children[(n-1)%10].click();")
s=s.replace("ui.get('difficulties').children.length,5", "ui.get('pack-list').children.length,5")
s=s.replace("ui.get('levels').children.length,10", "ui.get('pack-list').children[Math.floor((n-1)/10)].children[1].children.length,10")
s=s.replace("ui.get('remaining').textContent,'完成 ✓'", "ui.get('remaining-n').textContent,'完成'")
s=s.replace("/1\\/50/", "/1\\s*\\/\\s*50/")
s+='''\ntest('original project select/play screens, progress segments and rule sheet',()=>{const ui=boot();assert.equal(ui.snapshot().screen,'select');ui.get('btn-continue').click();assert.equal(ui.snapshot().screen,'play');assert.equal(ui.get('level-progress').children.length,10);ui.get('btn-rules').click();assert.equal(ui.get('rules-sheet').hidden,false);ui.get('close-rules').click();assert.equal(ui.get('rules-sheet').hidden,true);ui.get('btn-back').click();assert.equal(ui.snapshot().screen,'select');ui.select(22);assert.equal(ui.snapshot().screen,'play');assert.equal(ui.get('position').textContent,'3-2');});
'''
p.write_text(s,encoding='utf-8')
+16
View File
@@ -0,0 +1,16 @@
import fs from 'node:fs';import path from 'node:path';import {fileURLToPath} from 'node:url';import {constraints,directionNames,violations} from '../rules.mjs';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'),pack=JSON.parse(fs.readFileSync(path.join(root,'levels.json'),'utf8'));const rules=fs.readFileSync(path.join(root,'rules.mjs'),'utf8').replaceAll('export ',''),ui=fs.readFileSync(path.join(root,'ui.mjs'),'utf8').replace(/^import[^\n]+\n/gm,''),css=fs.readFileSync(path.join(root,'style.css'),'utf8');
const audio=fs.readFileSync(path.join(root,'audio.mjs'),'utf8').replaceAll('export ','');
const html=`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#ffffff"><title>数字一笔 · 53关DEMO</title><style>${css}</style></head><body><div id="app">
<section id="screen-select" class="screen"><header class="select-header"><h1 id="title">数字一笔</h1><p class="sub">53关DEMO · 一笔走遍每一格</p><div class="overall"><div class="overall-bar"><i id="overall-fill"></i></div><span id="progress" class="overall-text">0 / 53</span></div></header><div id="pack-list" class="pack-list"></div><button id="btn-continue" class="primary">继续</button><footer class="select-footer"><a href="../index.html" class="chip">原型入口</a><button id="btn-feedback" class="chip">试玩反馈</button></footer></section>
<section id="screen-play" class="screen hidden"><header class="topbar"><button id="btn-back" class="icon" aria-label="返回选关"><svg viewBox="0 0 24 24" width="26" height="26" aria-hidden="true"><path d="M15 5l-7 7 7 7" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></button><div class="level-info"><div id="position" class="level-title"></div><div id="level-sub" class="level-sub"></div></div><button id="btn-sound" class="sound-button" aria-label="切换音效" aria-pressed="true">音效开</button><button id="btn-rules" class="rules-button" aria-label="查看本关规则">规则</button><div id="remaining" class="remaining" aria-live="polite"><svg class="remaining-icon tile" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><rect x="4" y="4" width="16" height="16" rx="4.5" fill="none" stroke="currentColor" stroke-width="2.4"/></svg><svg class="remaining-icon check" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round"/></svg><span id="remaining-n">0</span></div></header><div id="level-progress" class="level-progress" aria-hidden="true"></div><div id="caption" class="caption"></div><div id="constraints" aria-label="当前规则"></div><div id="board-wrap" class="board-wrap"><canvas id="board" aria-label="拖动或点按共边格画路"></canvas></div><div class="feedback-row" aria-label="操作反馈与下一关"><div id="status" class="toast" role="status" aria-live="polite"></div><button id="next" class="primary next-overlay" hidden>下一关 →</button></div><h2 id="goal" class="sr-only"></h2><p class="gesture-help">按住滑动连线 · 沿来时路逐格回退</p><footer class="bottombar"><button id="restart" class="action"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M4 12a8 8 0 1 0 2.5-5.8M4 4v5h5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg><span>重开</span></button><button id="undo" class="action"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M9 5l-5 5 5 5M4 10h10a6 6 0 0 1 0 12" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg><span>撤销</span></button><button id="hint" class="action"><svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M5 19v-7a4 4 0 0 1 4-4h10m-4-4 4 4-4 4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg><span>指路</span></button></footer></section>
<div id="rules-sheet" class="sheet-backdrop" hidden><section class="sheet" role="dialog" aria-label="本关规则"><h2>本关规则</h2><p id="rule-text"></p><div class="sheet-actions"><button id="close-rules" class="action">返回游戏</button></div></section></div>
<div id="feedback-sheet" class="sheet-backdrop" hidden><section class="sheet" role="dialog" aria-label="试玩反馈"><h2>试玩反馈</h2><p>记下有趣、枯燥、太难或规则不清楚的关卡,导出记录后用于调整。</p><textarea id="reasoning" aria-label="本关反馈" placeholder="例如:第18关有趣;第24关太难……"></textarea><div class="sheet-actions"><button id="close-feedback" class="action">关闭</button><button id="export" class="primary">导出全部记录</button></div></section></div>
</div><script>const PACK=${JSON.stringify(pack).replaceAll('<','\\u003c')};\n${rules}\n${audio}\n${ui}</script></body></html>`;
fs.writeFileSync(path.join(root,'index.html'),html);
// Offline release has no runtime file dependencies; the return link needs the original folder.
fs.writeFileSync(path.join(root,'NumberSwipe-53关DEMO.html'),html);fs.writeFileSync(path.join(root,'NumberSwipe-50关DEMO.html'),html);
let notes=JSON.parse(fs.readFileSync(path.join(root,'design.json'),'utf8'));for(const item of notes){const l=item.level,author=item.author;item.breakdown=constraints(l).map(r=>r.kind==='parity'?`${r.cell} 为第${author.indexOf(r.cell)+1}步,要求${r.value==='odd'?'奇':'偶'}`:r.kind==='direction'?`${r.cell} 必须${directionNames[r.value]}`:r.kind==='wall'?`墙 ${r.edge.join('—')} 封闭该邻接`:r.kind==='door'?`门 ${r.edge.join('—')} 通过前按已走格数切换,通过后保持开启;正确解从第${author.findIndex((id,i)=>i&&r.edge.includes(id)&&r.edge.includes(author[i-1]))}步末端通过`:'每步蓝橙交替');const first=item.witnesses[0];if(first){const fork=first.path.findIndex((id,i)=>id!==author[i]);item.keyFork={step:fork+1,correct:author[fork],tempting:first.path[fork]};item.reason=violations(l,first.path).map(v=>v.message).join(';');}else{item.keyFork={step:0,correct:author[0],tempting:'数字不符的候选路线'};item.reason='基础数字约束';}}
fs.writeFileSync(path.join(root,'design.json'),JSON.stringify(notes,null,2));
const md=['# 53关DEMO制作记录','', '53关均已制作具体坐标。目标难度来自策划,实际难度等待试玩;推理任务不因自动唯一性而被视为人解质量已通过。',''];for(const item of notes){const l=item.level;md.push(`## ${l.order} · ${l.title}`, '',`章节:${l.chapterTitle};节点${l.cells.length};角色${l.role};策划目标${l.targetDifficulty}。`, '',`作者解:${item.author.join(' → ')}。`, '',`原规划任务:${item.planningIntent}。`, '',`结构记录:无即时冲突选择点${item.metrics.noImmediateConflictChoices};最多连续同向${item.metrics.longestSameDirectionTransfers}次转移。此指标不是人类难度。`, '',...item.breakdown.map(s=>'- '+s), '',...item.witnesses.map(w=>`- 解除 ${w.constraint} 后的完整反例:${w.path.join(' → ')}`),'');}fs.writeFileSync(path.join(root,'DESIGN.md'),md.join('\n'));
console.log('Built index.html + standalone DEMO, and author/ablation design record.');
+15
View File
@@ -0,0 +1,15 @@
import fs from 'node:fs';import path from 'node:path';import {fileURLToPath} from 'node:url';import {outlineUnits,units,constraints,directionNames} from '../rules.mjs';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');const definitions=[{id:'campaign-50',title:'53关DEMO',rule:'每格一次;数字为访问步号,各关显示当前启用规则。'}];
const esc=s=>String(s).replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;');
function diagram(l,author=null){let shapes=[],centers=new Map();for(const c of l.cells){if(l.geometry==='hex'){const x=1.5*c.r,y=Math.sqrt(3)*(c.q+c.r/2);centers.set(c.id,[x,y]);let p=[];for(let a=0;a<6;a++)p.push([x+Math.cos(a*Math.PI/3),y+Math.sin(a*Math.PI/3)]);shapes.push({id:c.id,p});}else{const u=units(c);centers.set(c.id,[u.reduce((s,p)=>s+p[0]+.5,0)/u.length,u.reduce((s,p)=>s+p[1]+.5,0)/u.length]);shapes.push({id:c.id,p:outlineUnits(c)});}}
const all=shapes.flatMap(s=>s.p),xmin=Math.min(...all.map(p=>p[0])),xmax=Math.max(...all.map(p=>p[0])),ymin=Math.min(...all.map(p=>p[1])),ymax=Math.max(...all.map(p=>p[1])),s=Math.min(340/(xmax-xmin),410/(ymax-ymin)),tr=([x,y])=>[(x-xmin)*s+20,(y-ymin)*s+20];const width=(xmax-xmin)*s+40,height=(ymax-ymin)*s+40;
let svg=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" role="img" aria-label="${esc(l.title)}">`;
for(const item of shapes){const c=l.cells.find(c=>c.id===item.id);svg+=`<polygon points="${item.p.map(p=>tr(p).join(',')).join(' ')}" fill="${c.category==='circle'?'#64acf3':c.category==='triangle'?'#ffae55':'#f1eef9'}" stroke="#aaa1c7" stroke-width="2"/>`;}
if(author)svg+=`<polyline points="${author.map(id=>tr(centers.get(id)).join(',')).join(' ')}" fill="none" stroke="#7963c9" stroke-width="5" opacity=".4"/>`;
for(const item of [...(l.walls||[]).map(edge=>({edge,kind:'wall'})),...(l.doors||[]).map(d=>({edge:d.edge,kind:'door'}))]){const [a,b]=item.edge.map(id=>tr(centers.get(id))),x=(a[0]+b[0])/2,y=(a[1]+b[1])/2,angle=Math.atan2(b[1]-a[1],b[0]-a[0])*180/Math.PI;svg+=`<line x1="${x}" y1="${y-s*.38}" x2="${x}" y2="${y+s*.38}" stroke="${item.kind==='wall'?'#272638':'#aa4d25'}" stroke-width="8" transform="rotate(${angle} ${x} ${y})"/>`;if(item.kind==='wall')svg+=`<line x1="${x}" y1="${y-s*.38}" x2="${x}" y2="${y+s*.38}" stroke="#ffc542" stroke-width="4" stroke-dasharray="5 4" transform="rotate(${angle} ${x} ${y})"/>`;else svg+=`<text x="${x+8}" y="${y-8}" font-size="12">步进门</text>`;}for(let i=0;i<(l.gates||[]).length;i++){const r=l.gates[i],[a,b]=r.edge.map(id=>tr(centers.get(id))),x=(a[0]+b[0])/2,y=(a[1]+b[1])/2,angle=Math.atan2(b[1]-a[1],b[0]-a[0])*180/Math.PI;svg+=`<line x1="${x}" y1="${y-s*.33}" x2="${x}" y2="${y+s*.33}" stroke="#cf7824" stroke-width="7" transform="rotate(${angle} ${x} ${y})"/><text x="${x+8}" y="${y-8}" font-size="12">门${i+1}</text>`;}
for(const c of l.cells){const [x,y]=tr(centers.get(c.id)),w=(l.windows||[]).find(r=>r.cell===c.id),sw=(l.gates||[]).findIndex(r=>r.switchCell===c.id);let label=author?String(author.indexOf(c.id)+1):c.clue===undefined?'':String(c.clue);let tags=[w?`${w.min}–${w.max}`:'',c.parity?(c.parity==='odd'?'奇':'偶'):c.direction?({lr:'─',du:'│',ru:'└',dr:'┌',dl:'┐',lu:'┘'})[c.direction]:'',c.category==='circle'?'○':c.category==='triangle'?'△':'',sw>=0?`开${sw+1}`:''].filter(Boolean).join(' ');svg+=`<text x="${x}" y="${y-8}" text-anchor="middle" font-family="system-ui,Microsoft YaHei" font-size="${Math.max(11,Math.min(21,s*.29))}" font-weight="700">${esc(label||tags)}</text><text x="${x}" y="${y+10}" text-anchor="middle" font-family="system-ui" font-size="10" fill="#665c78">${c.id}${label&&tags?' · '+esc(tags):''}</text>`;}
return svg+'</svg>';}
for(const def of definitions){const folder=root,pack=JSON.parse(fs.readFileSync(path.join(folder,'levels.json'),'utf8')),notes=JSON.parse(fs.readFileSync(path.join(folder,'design.json'),'utf8'));let articles='';const ordered=pack.playOrder.map(id=>pack.levels.find(l=>l.id===id));for(let i=0;i<ordered.length;i++){const l=ordered[i],n=notes.find(n=>n.level.id===l.id);fs.writeFileSync(path.join(folder,String(l.order).padStart(2,'0')+'-board.svg'),diagram(l));fs.writeFileSync(path.join(folder,String(l.order).padStart(2,'0')+'-solution.svg'),diagram(l,n.author));
articles+=`<article><h2>${i+1} · ${l.chapterTitle} · ${esc(l.title)}</h2><div class="boards"><figure>${diagram(l)}<figcaption>原始盘面 · 格id用于对照JSON</figcaption></figure><details><summary>展开作者解与路线</summary><figure>${diagram(l,n.author)}</figure><p>${n.author.join(' → ')}</p></details></div><h3>推理依据</h3><ul>${n.breakdown.map(t=>`<li>${esc(t)}</li>`).join('')}</ul><p>关键分叉:第 ${n.keyFork.step} 步应走 ${n.keyFork.correct},诱惑路线走 ${n.keyFork.tempting}。排除依据:${esc(n.reason)}。</p><details><summary>逐项机制必要性反例</summary>${n.witnesses.map(w=>`<p>解除 ${w.constraint} 后:${w.path.join(' → ')}</p>`).join('')}</details></article>`;}
fs.writeFileSync(path.join(folder,'DESIGN.html'),`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${def.title} · 设计评审</title><style>body{max-width:920px;margin:24px auto;padding:0 18px;background:#f7f5fb;color:#303047;font:15px/1.7 system-ui,'Microsoft YaHei'}article{background:white;border:1px solid #ddd5ed;border-radius:16px;padding:20px;margin:20px 0;break-inside:avoid}.boards{display:flex;gap:24px;flex-wrap:wrap}figure{margin:0;width:380px;max-width:100%}svg{max-height:450px;width:100%}details{cursor:pointer}a{color:#6552aa}@media print{body{background:white}article{border:none;page-break-after:always}} </style><a href="index.html">进入试玩</a><h1>${def.title} · 53关设计评审</h1><p>${esc(def.rule)}</p><p>原始盘面和作者解分开展示。标题是设计任务,真人推理路径与难度仍需试玩验证。数字、窗口、方向、类别和开关显示在格内;步差与先后关系见逐关推理依据。</p>${articles}</html>`);}
console.log('Generated 53 boards, 53 solution SVGs and design review page.');
+22
View File
@@ -0,0 +1,22 @@
from pathlib import Path
root=Path(r'G:\项目\NumberSwipe-Variants\campaign-50')
p=root/'ui.mjs';s=p.read_text(encoding='utf-8')
s=s.replace("let toastTimer=null;", "let toastTimer=null;let channelViews=[];")
s=s.replace("const bad=violations(level,path),badIds=new Set(bad.flatMap(b=>b.cells));", "const bad=violations(level,path),badIds=new Set(bad.flatMap(b=>b.cells));channelViews=[];")
s=s.replace("if(c.direction)drawChannel(c.direction,x,y-(value?unit*.23:0),Math.min(unit*.25,value?unit*.14:22),foreground);", "if(c.direction){const radius=Math.min(unit*.26,28),state=directionState(c,badIds);drawDirectionPath(c,x,y);drawChannel(c.direction,x,y,radius,state);channelViews.push({cell:c.id,kind:c.direction,center:[x,y],radius,state});}")
s=s.replace("if(value){ctx.font='800 '+font+'px '+BOARD_FONT;ctx.fillText", "if(value&&c.direction){const size=Math.max(10,Math.min(18,unit*.22));ctx.fillStyle=visited||start?'#FFFFFF':UI_THEME.cell;roundedRect(x+unit*.105,y-unit*.38,unit*.28,unit*.28,unit*.07);ctx.fill();ctx.fillStyle=UI_THEME.ink;ctx.font='800 '+size+'px '+BOARD_FONT;ctx.fillText(String(value),x+unit*.245,y-unit*.24);}\n else if(value){ctx.font='800 '+font+'px '+BOARD_FONT;ctx.fillText")
a=s.index('function drawChannel(');b=s.index('function drawBarrier(',a)
s=s[:a]+'''function sideOf(c,id){const a=level.cells.find(a=>a.id===id);return a.x<c.x?'l':a.x>c.x?'r':a.y<c.y?'u':'d';}
function directionState(c,badIds=new Set()){const k=path.indexOf(c.id);if(k<0)return 'idle';if(k===0||badIds.has(c.id))return 'error';const entry=sideOf(c,path[k-1]);if(!c.direction.includes(entry))return 'error';if(k<path.length-1){const exit=sideOf(c,path[k+1]);if([entry,exit].sort().join('')!==c.direction)return 'error';}return 'correct';}
function drawDirectionPath(c,x,y){const k=path.indexOf(c.id);if(k<0)return;const vectors={l:[-1,0],r:[1,0],u:[0,-1],d:[0,1]};ctx.beginPath();if(k>0){const [dx,dy]=vectors[sideOf(c,path[k-1])];ctx.moveTo(x+dx*unit*.44,y+dy*unit*.44);ctx.lineTo(x,y);}else ctx.moveTo(x,y);if(k<path.length-1){const [dx,dy]=vectors[sideOf(c,path[k+1])];ctx.lineTo(x+dx*unit*.44,y+dy*unit*.44);}ctx.lineCap='round';ctx.lineJoin='round';ctx.lineWidth=unit*.14;ctx.strokeStyle='#BEB8FF';ctx.stroke();}
function drawChannel(kind,x,y,r,state='idle'){const vector={l:[-1,0],r:[1,0],u:[0,-1],d:[0,1]};const [a,b]=kind.split('').map(k=>vector[k]);ctx.beginPath();ctx.moveTo(x+a[0]*r,y+a[1]*r);ctx.lineTo(x,y);ctx.lineTo(x+b[0]*r,y+b[1]*r);ctx.lineCap='round';ctx.lineJoin='round';ctx.lineWidth=10;ctx.strokeStyle=state==='correct'?'#C8C2FF':state==='error'?'#FFD5D7':'#DFE1EC';ctx.stroke();ctx.lineWidth=5;ctx.strokeStyle=state==='correct'?'#FFFFFF':state==='error'?'#E5484D':UI_THEME.ink;ctx.stroke();}
''' +s[b:]
s=s.replace("records:records.length,screen:", "records:records.length,directionMarkers:channelViews,screen:")
p.write_text(s,encoding='utf-8')
p=root/'tests/ui.test.mjs';s=p.read_text(encoding='utf-8-sig').replace("import {solve}","import {solve,graph}")
s=s.replace('append(e){this.children.push(e);}', 'append(...e){this.children.push(...e);}')
s=s.replace("const window={devicePixelRatio:1", "get('screen-play').className='screen hidden';get('screen-select').className='screen';const window={devicePixelRatio:1")
s+='''
test('direction glyph stays centered and same size on landing, exit, undo and errors',()=>{const ui=boot();ui.select(11);const l=pack.levels[10],p=solve(l)[0],c=l.cells.find(c=>c.direction),at=p.indexOf(c.id);const initial=ui.snapshot().directionMarkers.find(m=>m.cell===c.id);assert.equal(initial.state,'idle');for(const id of p.slice(0,at+1))ui.press(id);let active=ui.snapshot().directionMarkers.find(m=>m.cell===c.id);assert.deepEqual(active.center,initial.center);assert.equal(active.radius,initial.radius);assert.equal(active.state,'correct');assert.ok(ui.draws.some(d=>d.kind==='stroke'&&d.colour==='#BEB8FF'));ui.press(p[at+1]);active=ui.snapshot().directionMarkers.find(m=>m.cell===c.id);assert.deepEqual(active.center,initial.center);assert.equal(active.radius,initial.radius);ui.get('undo').click();assert.deepEqual(ui.snapshot().directionMarkers.find(m=>m.cell===c.id).center,initial.center);ui.get('restart').click();const g=graph(l);let wrong=null;function search(prefix){if(wrong)return;const last=prefix.at(-1);if(last===c.id){if(prefix.length>1){const from=l.cells.find(x=>x.id===prefix.at(-2));const side=from.x<c.x?'l':from.x>c.x?'r':from.y<c.y?'u':'d';if(!c.direction.includes(side))wrong=prefix;}return;}for(const n of g.adj[g.ids.get(last)]){const id=l.cells[n].id;if(!prefix.includes(id))search([...prefix,id]);}}search([p[0]]);assert.ok(wrong);for(const id of wrong)ui.press(id);const bad=ui.snapshot().directionMarkers.find(m=>m.cell===c.id);assert.equal(bad.state,'error');assert.deepEqual(bad.center,initial.center);assert.equal(bad.radius,initial.radius);});
'''
p.write_text(s,encoding='utf-8')
+38
View File
@@ -0,0 +1,38 @@
import {attachTutorials} from './tutorials.mjs';
import fs from 'node:fs';import path from 'node:path';import {fileURLToPath} from 'node:url';import {solve,violations,constraints,directionAt,assertStructure,graph,canAppend} from '../rules.mjs';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..'),plan=JSON.parse(fs.readFileSync(path.join(root,'../design/demo-50/demo-design.json'),'utf8'));let seed=1;const rand=()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/4294967296;},pick=a=>a[Math.floor(rand()*a.length)];function shuffle(a){const b=[...a];for(let i=b.length-1;i>0;i--){const j=Math.floor(rand()*(i+1));[b[i],b[j]]=[b[j],b[i]];}return b;}
function patch(n,variant){const width=n<=6?3:n<=12?(variant%3===1?4:3):4,height=Math.ceil(n/width);let units=[];for(let y=0;y<height;y++)for(let x=0;x<width;x++)units.push([x,y]);const remove=units.length-n;
let priority=variant%4===0?[...units].reverse():shuffle(units.filter(([x,y])=>x===0||x===width-1||y===0||y===height-1));if(variant%4===3&&n>12)priority=shuffle(units);for(const p of priority.slice(0,remove))units=units.filter(u=>u!==p);
if(variant%2)units=units.map(([x,y])=>[width-1-x,y]);if(variant%5===2)units=units.map(([x,y])=>[y,x]);return units.map(([x,y],i)=>({id:'c'+i,x,y,w:1,h:1}));}
function makeCells(spec,variant){const n=spec.nodeCountTarget,num=spec.displayOrder;if(spec.geometry==='hex'){const width=n<=12?2:3;let coords=[];for(let q=0;coords.length<n;q++)for(let r=0;r<width&&coords.length<n;r++)coords.push({id:'c'+coords.length,q,r});if(variant%3===1)coords=coords.map(c=>({...c,q:c.q+Math.floor(c.r/2)}));if(variant%3===2)coords=coords.map(c=>({...c,q:-c.q,r:-c.r}));return coords;}
if(spec.geometry==='square')return patch(n,variant);
const specialL=[5,23,43].includes(num)||num>=41;const two=[18,28,41,42,43,46].includes(num);const templates=specialL?[[[0,0],[1,0],[0,1]],...(two?[[[0,0],[1,0]]]:[])]:[[[0,0],[1,0],[0,1],[1,1]],...(two?[[[0,0],[1,0]]]:[])];
const extra=templates.reduce((s,t)=>s+t.length-1,0);let cells=patch(n+extra,variant),taken=new Set();for(const shape of templates){let choices=[];for(const a of cells){if(taken.has(a.id)||a.shape||a.w!==1||a.h!==1)continue;for(const rotation of [0,1]){const rotated=shape.map(([x,y])=>rotation?[y,x]:[x,y]);const matches=rotated.map(([x,y])=>cells.find(c=>!taken.has(c.id)&&c.x===a.x+x&&c.y===a.y+y&&c.w===1&&c.h===1&&!c.shape));if(matches.every(Boolean))choices.push({a,rotated,matches});}}
if(!choices.length)return null;const {a,rotated,matches}=pick(choices);const xs=rotated.map(p=>p[0]),ys=rotated.map(p=>p[1]);a.w=Math.max(...xs)+1;a.h=Math.max(...ys)+1;if(rotated.length!==a.w*a.h)a.shape=rotated;for(const c of matches)if(c!==a)taken.add(c.id);cells=cells.filter(c=>!taken.has(c.id));}
return cells.map((c,i)=>({...c,id:'c'+i}));}
const baseCache=new Map();function base(spec,variant){const cells=makeCells(spec,variant);if(!cells)return null;const key=JSON.stringify(cells);if(baseCache.has(key))return baseCache.get(key);const l={mode:'base',features:[],geometry:spec.geometry,cells};try{assertStructure(l);const paths=solve(l,{limit:15001,nodeLimit:4000000});if(!paths.length){baseCache.set(key,null);return null;}const item={cells,paths,truncated:paths.length>=15001};baseCache.set(key,item);return item;}catch{baseCache.set(key,null);return null;}}
const wallCounts={8:2,18:2,24:2,32:2,34:2,38:2,42:2,43:2,44:3,47:2,48:2,50:2};
function setup(spec,cells,author){const num=spec.displayOrder,n=cells.length,l={id:spec.levelId,contentRevision:1,order:num,mode:'campaign-50',geometry:spec.geometry,features:[...spec.features],title:spec.title,chapter:spec.chapter,chapterTitle:plan.chapters[spec.chapter-1].title,role:spec.role,targetDifficulty:spec.targetDifficulty,caption:caption(spec),cells:structuredClone(cells)};if(!spec.unknownStart)l.cells.find(c=>c.id===author[0]).clue=1;if(num===1)l.cells.find(c=>c.id===author.at(-1)).clue=n;
const g=graph(l),positions=new Map(author.map((id,i)=>[id,i]));
if(l.features.includes('alternation'))for(let i=0;i<n;i++)l.cells.find(c=>c.id===author[i]).category=i%2?'triangle':'circle';
if(l.features.includes('direction')){const pool={lr:[],du:[],ru:[],dr:[],dl:[],lu:[]};for(let i=1;i<n-1;i++)pool[directionAt(l,author,i)].push(author[i]);const pattern=num===11?['lr']:num===12?['du']:num===13?['corner']:num===14?['corner','corner']:num===47?['lr','du','corner']:['lr','corner'];const selected=[];for(const want of pattern){let kinds=want==='corner'?['ru','dr','dl','lu'].filter(k=>pool[k].length):[want];if(!kinds.length)return null;const kind=pick(kinds);if(!pool[kind]?.length)return null;const id=pick(pool[kind]);pool[kind].splice(pool[kind].indexOf(id),1);l.cells.find(c=>c.id===id).direction=kind;selected.push(kind);}if(num===14&&selected[0]===selected[1])return null;}
if(l.features.includes('rhythm-door')){const options=[];for(let i=2;i<n-1;i+=2)if(num<28||i>=Math.floor(n/3))options.push(i);if(!options.length)return null;const i=pick(options);l.doors=[{edge:[author[i],author[i+1]],initialOpen:false}];}
if(l.features.includes('parity')){const count=[24,38,43,46,48].includes(num)?2:1;const pool=shuffle(author.slice(2,-1).filter(id=>!(l.doors||[]).some(d=>d.edge.includes(id))));if(pool.length<count)return null;let selected=pool.slice(0,count);if(num===21){const even=pool.filter(id=>(positions.get(id)+1)%2===0);if(!even.length)return null;selected=[pick(even)];}if(num===22){const odd=pool.filter(id=>(positions.get(id)+1)%2===1);if(!odd.length)return null;selected=[pick(odd)];}for(const id of selected)l.cells.find(c=>c.id===id).parity=(positions.get(id)+1)%2?'odd':'even';if(count===2&&num===24&&new Set(selected.map(id=>l.cells.find(c=>c.id===id).parity)).size<2)return null;}
if(l.features.includes('walls')){const options=[];for(let a=0;a<n;a++)for(const b of g.adj[a])if(a<b){const x=g.cells[a].id,y=g.cells[b].id;if(Math.abs(positions.get(x)-positions.get(y))===1)continue;if(l.features.includes('alternation')&&l.cells[a].category===l.cells[b].category)continue;options.push([x,y]);}const count=wallCounts[num]||1;if(options.length<count)return null;l.walls=shuffle(options).slice(0,count);}
if(l.geometry==='blocks'){const large=l.cells.filter(c=>c.w*c.h>1);if(large.some(c=>positions.get(c.id)===0||positions.get(c.id)===n-1))return null;}
return l;}
function caption(s){const n=s.displayOrder;if(n===1)return '从1开始,走遍全部格,最后到5。';if(n===2)return '走错沿来时路逐格退回,松手也能继续画。';if(n===3)return '整块大格只算一步。';if(n===5)return '异形也是整块一步,只看真实共边。';if(n===6)return '黑黄墙封住这条边,整局不会打开。';if(n===11)return '横通道只从左右进出,也可反向走。';if(n===12)return '竖通道只从上下进出。';if(n===13)return '拐角连接哪两边,就从哪两边进出。';if(n===16)return '蓝→橙→蓝,每一步换颜色。';if(n===21)return '“偶”要求本格的访问步号是偶数。';if(n===22)return '“奇”要求本格的访问步号是奇数。';if(n===26)return '空盘门关,落下起点后门开;通过前每格切换一次,通过后保持开启。';if(n===31)return '六边形有六个共边方向,墙仍不能穿过。';if(n===37)return '这关没有1,先观察起点。';return '先观察,再画;松手可继续,走错可回退。';}
function chooseClues(l,author,basePaths){let paths=basePaths.filter(p=>l.cells.every(c=>c.clue===undefined||p[c.clue-1]===c.id)),atoms=constraints(l),keys=atoms.map(r=>r.key);const masks=paths.map(p=>new Set(violations(l,p,{ignoreClues:true}).map(x=>x.key)));let survivors=paths.map((_,i)=>i).filter(i=>!masks[i].size),witnesses=keys.map(key=>paths.map((_,i)=>i).filter(i=>masks[i].size===1&&masks[i].has(key)));if(witnesses.some(w=>!w.length)||!survivors.length)return null;
const candidates=shuffle(author.map((id,i)=>({id,step:i+1})).filter(c=>c.step!==1&&c.step!==author.length&&!l.cells.find(x=>x.id===c.id).parity&&!l.cells.find(x=>x.id===c.id).direction));let added=[];
function addOne(contenders){let best=null;for(const c of candidates.filter(c=>!added.includes(c))){const matches=i=>paths[i][c.step-1]===c.id,kept=survivors.filter(matches),ws=witnesses.map(w=>w.filter(matches));if(ws.some(w=>!w.length))continue;const rejected=contenders.filter(p=>p[c.step-1]!==c.id).length;if(!rejected)continue;if(!best||rejected>best.rejected||rejected===best.rejected&&kept.length<best.kept.length)best={c,kept,ws,rejected};}if(!best)return false;added.push(best.c);l.cells.find(c=>c.id===best.c.id).clue=best.c.step;survivors=best.kept;witnesses=best.ws;return true;}
while(survivors.length>1){if(!addOne(survivors.map(i=>paths[i]))||added.length>4)return null;}
// Geometry enumeration may be sampled; every accepted puzzle is fully solved afterwards.
for(let pass=0;pass<6;pass++){let full;try{full=solve(l,{limit:2,nodeLimit:1500000});}catch{return null;}if(full.length===1&&full[0].join()===author.join())break;if(!full.length||!addOne(full.filter(p=>p.join()!==author.join()))||added.length>4)return null;if(pass===5)return null;}
for(const c of shuffle(l.cells.filter(c=>c.clue!==undefined&&c.clue!==1&&!(l.order===1&&c.clue===l.cells.length)))){const old=c.clue;delete c.clue;let count;try{count=solve(l,{limit:2,nodeLimit:1500000}).length;}catch{count=0;}if(count!==1)c.clue=old;}
const saved=keys.map((key,i)=>({constraint:key,path:paths[witnesses[i][0]]}));if(saved.some(w=>!w.path||violations(l,w.path,{omit:w.constraint}).length||!violations(l,w.path).length))return null;return saved;}
function metrics(l,p){const g=graph(l);let choices=0,maxRun=0,run=0,last=null;for(let i=0;i<p.length-1;i++){const prefix=p.slice(0,i+1);const options=g.adj[g.ids.get(p[i])].map(j=>g.cells[j].id).filter(id=>canAppend(l,prefix,id)&&!violations(l,[...prefix,id]).length);if(options.length>1)choices++;const a=g.cells[g.ids.get(p[i])],b=g.cells[g.ids.get(p[i+1])];const key=l.geometry==='hex'?`${Math.sign(b.q-a.q)},${Math.sign(b.r-a.r)}`:`${Math.sign(b.x-a.x)},${Math.sign(b.y-a.y)}`;run=key===last?run+1:1;last=key;maxRun=Math.max(maxRun,run);}return {noImmediateConflictChoices:choices,longestSameDirectionTransfers:maxRun,scope:'结构代理,不是人类难度或完整规则推理的有效分叉'};}
const checkpoint=path.join(root,'design.json');let existing=fs.existsSync(checkpoint)?JSON.parse(fs.readFileSync(checkpoint,'utf8')):[];const force=process.argv.includes('--fresh');if(force)existing=[];const notes=[];
for(const spec of plan.levels){if(existing.some(x=>x.level.id===spec.levelId)){notes.push(existing.find(x=>x.level.id===spec.levelId));console.log('reuse',spec.displayOrder);continue;}seed=20261014+spec.displayOrder*997;let accepted=null;const triedShapes=new Set();
for(let attempt=0;attempt<4500;attempt++){const variant=Math.floor(attempt/70),item=base(spec,variant);if(!item)continue;const author=pick(item.paths),l=setup(spec,item.cells,author);if(!l)continue;const witnesses=chooseClues(l,author,item.paths);if(!witnesses)continue;try{assertStructure(l);}catch{continue;}const m=metrics(l,author);const minimum=spec.displayOrder<=3?0:spec.role==='释放'||spec.role==='收束'?1:spec.targetDifficulty>=60?3:spec.targetDifficulty>=40?2:1;if(m.noImmediateConflictChoices<minimum)continue;if(l.cells.length>10&&m.longestSameDirectionTransfers/l.cells.length>.5)continue;accepted={level:l,author,witnesses,metrics:m,candidateAttempts:attempt+1,basePathSampleSize:item.paths.length,baseEnumerationTruncated:item.truncated,designKey:spec.designKey,planningIntent:spec.teachingGoal,difficultyStatus:'目标曲线,待玩家校准'};break;}
if(!accepted)throw Error('无法制作第'+spec.displayOrder+'关 '+spec.title);notes.push(accepted);fs.writeFileSync(checkpoint,JSON.stringify(notes,null,2));console.log('made',spec.displayOrder,spec.title,'N='+accepted.level.cells.length,'keys='+constraints(accepted.level).length,'clues='+accepted.level.cells.filter(c=>c.clue!==undefined).length,'choices='+accepted.metrics.noImmediateConflictChoices,'attempts='+accepted.candidateAttempts);}
const levels=notes.map(x=>x.level);const pack={mode:'campaign-50',version:'demo-2026-10-14-v1',title:'数字一笔 · 50关DEMO',chapters:plan.chapters,rule:'每格一次,数字是访问步号。每关只启用显示的规则;大格整块一步,墙不能穿过,双色每步换色,奇偶看步号,通道按图示两边进出,门通过前每落一格切换,通过后保持开启。',levels};attachTutorials(pack,notes,root);fs.writeFileSync(checkpoint,JSON.stringify(notes,null,2));fs.writeFileSync(path.join(root,'levels.json'),JSON.stringify(pack,null,2));fs.writeFileSync(path.join(root,'order-a.json'),JSON.stringify({id:'A',levelIds:plan.orders.A.levelIds},null,2));fs.writeFileSync(path.join(root,'order-b.json'),JSON.stringify({id:'B',levelIds:plan.orders.B.levelIds},null,2));console.log('Generated complete campaign: '+levels.length);
+16
View File
@@ -0,0 +1,16 @@
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
const host='192.168.3.74',port=17850;
const server=http.createServer((req,res)=>{
const route=new URL(req.url,'http://localhost').pathname;
if(!['/','/index.html'].includes(route)){res.writeHead(404,{'Content-Type':'text/plain; charset=utf-8'});res.end('Not found');return;}
if(!['GET','HEAD'].includes(req.method)){res.writeHead(405);res.end();return;}
const html=fs.readFileSync(path.join(root,'index.html'));
res.writeHead(200,{'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-store','Content-Length':html.length});
res.end(req.method==='HEAD'?undefined:html);
});
server.listen(port,host,()=>console.log(`NumberSwipe DEMO: http://${host}:${port}/`));
server.on('error',error=>{console.error(error.message);process.exitCode=1;});
+16
View File
@@ -0,0 +1,16 @@
from pathlib import Path
root=Path(r'G:\项目\NumberSwipe-Variants');dest=root/'campaign-50'
s=(root/'tools/design-pages.mjs').read_text(encoding='utf-8-sig')
s=s.replace("import {definitions} from './definitions.mjs';", "")
s=s.replace("from './engine-template.mjs'", "from '../rules.mjs'")
s=s.replace("const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');", "const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');const definitions=[{id:'campaign-50',title:'50关DEMO',rule:'每格一次;数字为访问步号,各关显示当前启用规则。'}];")
s=s.replace("const folder=path.join(root,'modes',def.id)", "const folder=root")
s=s.replace('for(let i=0;i<8;i++)','for(let i=0;i<pack.levels.length;i++)')
s=s.replace("l.id+'-board.svg'", "String(l.order).padStart(2,'0')+'-board.svg'").replace("l.id+'-solution.svg'", "String(l.order).padStart(2,'0')+'-solution.svg'")
s=s.replace("c.direction?({lr:", "c.parity?(c.parity==='odd'?'奇':'偶'):c.direction?({lr:")
s=s.replace('${l.id} · ${l.difficulty}', '${l.order} · ${l.chapterTitle}')
s=s.replace('${l.title}', '${l.title}')
s=s.replace('href="play.html"','href="index.html"')
s=s.replace('8关设计评审','50关设计评审')
s=s.replace('Generated 40 original boards, 40 solutions and 5 design review pages.','Generated 50 boards, 50 solution SVGs and design review page.')
(dest/'tools/design-pages.mjs').write_text(s,encoding='utf-8')
+37
View File
@@ -0,0 +1,37 @@
from pathlib import Path
root=Path(r'G:\项目\NumberSwipe-Variants');dest=root/'campaign-50'
s=(root/'tools/ui-template.mjs').read_text(encoding='utf-8-sig')
s=s.replace("level.mode.includes('alternation')", "level.features.includes('alternation')")
s=s.replace("const memoryKey='variant:'+pack.mode;", "const memoryKey='variant:campaign-50:v1';")
s=s.replace("const labels=new Map();const metrics={};let started=0,record=null;", "const labels=new Map();let started=0,firstDrawAt=null,record=null;let records=[];try{records=JSON.parse(localStorage.getItem(memoryKey+':records')||'[]');}catch{}\nfunction saveRecord(){if(!record)return;record.reasoning=$('reasoning').value;record.elapsedSeconds=Math.round((performance.now()-started)/1000);record.completed=cleared;const copy={...record,endedAt:new Date().toISOString()};const at=records.findIndex(r=>r.attemptId===copy.attemptId);if(at>=0)records[at]=copy;else records.push(copy);try{localStorage.setItem(memoryKey+':records',JSON.stringify(records.slice(-500)));}catch{}}")
s=s.replace("return {mode:pack.mode,level:level.id,difficulty:level.difficulty,title:level.title,startedAt:new Date().toISOString(),", "return {attemptId:Date.now().toString(36)+'-'+Math.random().toString(36).slice(2,8),mode:pack.mode,level:level.id,contentRevision:level.contentRevision,order:level.order,chapter:level.chapter,title:level.title,startedAt:new Date().toISOString(),thinkingBeforeFirstDrawSeconds:null,")
s=s.replace("function start(i){index=i;", "function start(i){if(record)saveRecord();index=i;")
s=s.replace("started=performance.now();record=makeRecord();", "started=performance.now();firstDrawAt=null;record=makeRecord();")
s=s.replace("$('rule-text').textContent=pack.rule;", "$('rule-text').textContent=rulesFor(level);try{localStorage.setItem(memoryKey+':current',level.id);}catch{}")
a=s.index('function renderSelectors(){');b=s.index('function renderConstraints(){',a)
s=s[:a]+'''function renderSelectors(){
$('difficulties').replaceChildren();for(const chapter of pack.chapters){const b=document.createElement('button');b.textContent='第'+chapter.id+'章';b.className=level.chapter===chapter.id?'active':'';b.onclick=()=>start(pack.levels.findIndex(l=>l.chapter===chapter.id));$('difficulties').append(b);}
$('levels').replaceChildren();for(let i=0;i<pack.levels.length;i++){const l=pack.levels[i];if(l.chapter!==level.chapter)continue;const b=document.createElement('button');b.textContent=String(l.order)+(completed.includes(l.id)?' ✓':'');b.className=i===index?'active':'';b.onclick=()=>start(i);$('levels').append(b);}
$('position').textContent='第 '+level.order+'/50 关 · '+level.chapterTitle;
$('progress').textContent='已完成 '+completed.length+'/50';
}
function rulesFor(l){const a=['每格恰好经过一次,共边才能走;固定数字是访问步号。'];if(l.geometry==='blocks')a.push('大格和异形整块只算一步。');if(l.geometry==='hex')a.push('六边形有六个共边方向。');if(l.features.includes('direction'))a.push('通道连接哪两边,就从哪两边进出,可反向;通道不能是端点。');if(l.features.includes('alternation'))a.push('蓝→橙→蓝,每一步换颜色;○蓝,△橙。');if(l.features.includes('parity'))a.push('奇/偶标记要求该格访问步号的奇偶,不按面积计数。');if(l.features.includes('walls'))a.push('黑黄墙永久封闭这条共边。');if(l.features.includes('rhythm-door'))a.push('门空盘关,起点也切换,每落一格切换一次;移动前看当前门位,撤销恢复。');return a.join(' ');}
''' +s[b:]
s=s.replace(" for(const c of level.cells)if(c.direction)add", " for(const c of level.cells)if(c.parity)add(c.parity==='odd'?'奇 · 第1、3、5…步':'偶 · 第2、4、6…步',[c.id]);\n for(const c of level.cells)if(c.direction)add")
s=s.replace("if(c.direction)symbol='';", "if(c.direction)symbol='';if(c.parity)symbol=c.parity==='odd'?'奇':'偶';")
s=s.replace("index===7", "index===pack.levels.length-1")
s=s.replace(" if(!path.length)started=performance.now();path.push(id);", " if(firstDrawAt===null){firstDrawAt=performance.now();record.thinkingBeforeFirstDrawSeconds=Math.round((firstDrawAt-started)/1000);}path.push(id);")
s=s.replace("renderSelectors();}draw();}", "saveRecord();renderSelectors();}draw();}")
s=s.replace("started=performance.now();setStatus('已重开。');", "setStatus('已重开。');")
s=s.replace("selected=pack.levels[index+1].difficulty;start(index+1);", "if(index+1<pack.levels.length)start(index+1);")
a=s.index("$('export').onclick=");b=s.index("window.addEventListener('resize'",a)
s=s[:a]+'''$('export').onclick=()=>{saveRecord();const payload={version:pack.version,exportedAt:new Date().toISOString(),clearedIds:completed,records};const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='NumberSwipe-DEMO50-试玩记录.json';a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);setStatus('已导出全部试玩记录。');};
window.addEventListener('pagehide',saveRecord);
''' +s[b:]
s=s.replace("window.addEventListener('resize',draw);start(0);", "window.addEventListener('resize',draw);let initialId=null;try{initialId=localStorage.getItem(memoryKey+':current');}catch{}let initial=pack.levels.findIndex(l=>l.id===initialId);start(initial>=0?initial:0);")
s=s.replace("level:level.id,path:", "level:level.id,order:level.order,chapter:level.chapter,records:records.length,path:")
a=s.index('function drawBarrier(');b=s.index('function inside(',a);barrier=s[a:b];barrier=barrier.replace('let common=null','let commons=[]').replace('common=[p,q]','commons.push([p,q])').replace('if(!common)return;const [a,b]=common;', 'for(const common of commons){const [a,b]=common;');end=barrier.rfind('}');barrier=barrier[:end]+'}'+barrier[end:];s=s[:a]+barrier+s[b:]
(dest/'ui.mjs').write_text(s,encoding='utf-8')
source=(root/'tools/build.mjs').read_text(encoding='utf-8-sig');css=source.split('const css=`',1)[1].split('`;',1)[0];css+=' #difficulties{grid-template-columns:repeat(5,1fr)}#levels{display:grid;grid-template-columns:repeat(10,1fr);gap:5px}#levels button{padding:8px 2px;min-width:0}#progress{font-size:13px;color:#77638d}#next{display:block;width:100%;margin-top:10px}#next[hidden]{display:none}@media(max-width:480px){#levels{grid-template-columns:repeat(5,1fr)}#difficulties button{padding:8px 2px;font-size:13px}}'
(dest/'style.css').write_text(css,encoding='utf-8')
print('Campaign UI with per-level rules, chapter selection, parity and local records created.')
+15
View File
@@ -0,0 +1,15 @@
from pathlib import Path
import hashlib,json
root=Path(r'G:\项目\NumberSwipe-Variants');dest=root/'campaign-50'
files=list((root/'modes').rglob('*'));baseline=[{'path':str(p),'sha256':hashlib.sha256(p.read_bytes()).hexdigest()} for p in files if p.is_file()]
(dest/'reports/prototype-before.json').write_text(json.dumps(baseline,ensure_ascii=False,indent=2),encoding='utf-8')
s=(root/'tools/engine-template.mjs').read_text(encoding='utf-8-sig')
s=s.replace("level.mode.includes('alternation')", "(level.features||[]).includes('alternation')")
s=s.replace(" for(const c of level.cells)if(c.direction)result.push", " for(const c of level.cells)if(c.parity)result.push({key:'parity:'+c.id,kind:'parity',cell:c.id,value:c.parity});\n for(const c of level.cells)if(c.direction)result.push")
s=s.replace(" else if(r.kind==='direction'){", " else if(r.kind==='parity'){where=[r.cell];const p=positions.get(r.cell);fail=p!==undefined&&(p%2===1)!==(r.value==='odd');}\n else if(r.kind==='direction'){")
s=s.replace("r.kind==='direction'?'进出方向不符'", "r.kind==='parity'?'该格步号奇偶不符':r.kind==='direction'?'进出方向不符'")
s=s.replace(" const g=graph(level),n=g.cells.length;if(n<3", " const g=graph(level),n=g.cells.length;if(level.features?.includes('alternation')&&level.features?.includes('parity'))throw Error('不叠加双色与奇偶');if(level.features?.includes('direction')&&level.geometry!=='square')throw Error('通道仅适用单位方格');if(n<3")
s=s.replace(" if(r.kind==='direction'&&!Object.keys", " if(r.kind==='parity'&&(!['odd','even'].includes(r.value)||g.cells[g.ids.get(r.cell)].clue!==undefined))throw Error('奇偶字段或同格数字冲突');\n if(r.kind==='direction'&&!Object.keys")
(dest/'rules.mjs').write_text(s,encoding='utf-8')
(dest/'package.json').write_text(json.dumps({'name':'numberswipe-demo50','private':True,'type':'module','engines':{'node':'>=22'},'scripts':{'generate':'node tools/generate.mjs','build':'node tools/build.mjs','validate':'node tools/validate.mjs','test':'node --test tests/*.test.mjs','check':'node --test tests/*.test.mjs && node tools/validate.mjs'}},indent=2),encoding='utf-8')
print('Independent campaign rules created; '+str(len(baseline))+' prototype files hashed.')
+2
View File
@@ -0,0 +1,2 @@
import fs from 'node:fs';import {solve,constraints,violations} from '../rules.mjs';
export function attachTutorials(pack,notes,root){const tutorials=JSON.parse(fs.readFileSync(new URL('../tutorials.json',import.meta.url),'utf8'));const originals=pack.levels.filter(l=>!l.tutorial);pack.levels=[...originals,...tutorials];pack.playOrder=[];for(const l of originals){if(l.order===11)pack.playOrder.push('tutorial-straight','tutorial-corner');if(l.order===16)pack.playOrder.push('tutorial-colour');pack.playOrder.push(l.id);}pack.title='数字一笔 · 53关DEMO';pack.version='demo-2026-10-14-v2';for(const l of tutorials){if(notes.some(n=>n.level.id===l.id))continue;const paths=solve(l);if(paths.length!==1)throw Error('引导不唯一');const author=paths[0],witnesses=constraints(l).map(c=>{const path=solve(l,{omit:c.key,limit:Infinity}).find(p=>p.join()!==author.join()&&violations(l,p).some(v=>v.key===c.key));if(!path)throw Error('引导规则没有实际影响');return {constraint:c.key,path};});notes.push({level:l,author,witnesses,metrics:{noImmediateConflictChoices:1,longestSameDirectionTransfers:2,scope:'引导关,不作真人难度评分'},planningIntent:l.caption,difficultyStatus:'非常简单的规则教学,待试玩确认'});}return pack;}
+15
View File
@@ -0,0 +1,15 @@
import fs from 'node:fs';import path from 'node:path';import crypto from 'node:crypto';import {fileURLToPath} from 'node:url';import {solve,constraints,violations,assertStructure,canAppend,graph} from '../rules.mjs';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');const pack=JSON.parse(fs.readFileSync(path.join(root,'levels.json'),'utf8')),notes=JSON.parse(fs.readFileSync(path.join(root,'design.json'),'utf8'));const plan=JSON.parse(fs.readFileSync(path.join(root,'../design/demo-50/demo-design.json'),'utf8'));let checks=0;const results=[];function check(ok,msg){checks++;if(!ok)throw Error(msg);}
check(pack.levels.length===53,'必须53关');check(new Set(pack.levels.map(l=>l.id)).size===53,'ID不唯一');
check(pack.playOrder.length===53&&new Set(pack.playOrder).size===53&&pack.playOrder.every(id=>pack.levels.some(l=>l.id===id)),'试玩顺序必须覆盖53个唯一ID');check(pack.playOrder[10]==='tutorial-straight'&&pack.playOrder[11]==='tutorial-corner'&&pack.playOrder[17]==='tutorial-colour','首次机制前的引导顺序');
function geometricallyValid(l,p){const g=graph(l);return p.length===l.cells.length&&new Set(p).size===l.cells.length&&p.every((id,i)=>g.ids.has(id)&&(!i||g.adj[g.ids.get(p[i-1])].includes(g.ids.get(id))));}
for(let i=0;i<pack.levels.length;i++){const l=pack.levels[i],spec=i<50?plan.levels[i]:{levelId:pack.levels[i].id,nodeCountTarget:pack.levels[i].cells.length,features:pack.levels[i].features,unknownStart:false},note=notes.find(n=>n.level.id===l.id);assertStructure(l);check(l.id===spec.levelId&&l.order===i+1,'关序或稳定ID不一致');check(l.cells.length===spec.nodeCountTarget,'节点数量不一致');check(JSON.stringify(l.features)===JSON.stringify(spec.features),'机制组合不一致');check(Boolean(l.cells.some(c=>c.clue===1))!==spec.unknownStart,'起点策略不一致');
const paths=solve(l,{limit:2,nodeLimit:5000000});check(paths.length===1,'非唯一解 '+l.order);check(paths[0].join()===note.author.join(),'作者解不一致');check(geometricallyValid(l,paths[0]),'作者几何不合法');for(let k=0;k<paths[0].length;k++)check(canAppend(l,paths[0].slice(0,k),paths[0][k]),'操作与求解不一致 '+l.order);
const ablations=[];for(const r of constraints(l)){const alts=solve(l,{limit:2,omit:r.key,nodeLimit:5000000});check(alts.length>=2,'冗余机制 '+l.order+' '+r.key);const w=note.witnesses.find(w=>w.constraint===r.key);check(Boolean(w)&&geometricallyValid(l,w.path)&&!violations(l,w.path,{omit:r.key}).length&&violations(l,w.path).some(v=>v.key===r.key),'反例无效 '+l.order+' '+r.key);ablations.push({key:r.key,solutionsAfterRemoval:'至少2',witness:w.path});}
const numeric=[];for(const c of l.cells.filter(c=>c.clue!==undefined&&c.clue!==1&&!(l.order===1&&c.clue===l.cells.length))){const copy=structuredClone(l);delete copy.cells.find(x=>x.id===c.id).clue;const count=solve(copy,{limit:2,nodeLimit:5000000}).length;check(count>=2,'冗余非教学数字 '+l.order);numeric.push({cell:c.id,step:c.clue,necessary:true});}
results.push({order:l.order,id:l.id,title:l.title,chapter:l.chapter,role:l.role,nodeCount:l.cells.length,features:l.features,unique:true,allMechanismsNecessary:true,ablations,numericClues:numeric,structure:note.metrics,targetDifficulty:l.targetDifficulty,humanDifficultyCalibrated:false});}
for(const filename of ['index.html','NumberSwipe-50关DEMO.html']){const h=fs.readFileSync(path.join(root,filename),'utf8');check(!/<script[^>]+src=|fetch\(|type="module"/.test(h),'不是离线单文件');check(!h.includes('"author":')&&!h.includes('"witnesses":'),'答案字段进入试玩包');}
const prototypes=JSON.parse(fs.readFileSync(path.join(root,'reports/prototype-before.json'),'utf8'));const changed=prototypes.filter(p=>!fs.existsSync(p.path)||crypto.createHash('sha256').update(fs.readFileSync(p.path)).digest('hex')!==p.sha256).map(p=>p.path);check(!changed.length,'旧40关文件发生修改');
const original=JSON.parse(fs.readFileSync(path.join(root,'../reports/original-before.json'),'utf8').replace(/^\uFEFF/,''));const oldChanged=original.filter(p=>!fs.existsSync(p.path)||crypto.createHash('sha256').update(fs.readFileSync(p.path)).digest('hex').toUpperCase()!==p.hash).map(p=>p.path);check(!oldChanged.length,'原项目历史文件发生变化');
const report={checkedAt:new Date().toISOString(),passed:pack.levels.length,total:pack.levels.length,checks,uniqueAll:true,mechanismsNecessaryAll:true,prototypesChecked:prototypes.length,prototypeFilesChanged:changed,originalHistoricalFilesChecked:original.length,originalHistoricalFilesChanged:oldChanged,scope:'关卡数学与操作规则验收;不代表真人难度、关卡意图或真机体验校准',results};fs.writeFileSync(path.join(root,'reports/validation.json'),JSON.stringify(report,null,2));
fs.writeFileSync(path.join(root,'reports/VALIDATION.md'),'# 53关DEMO自动验收\n\n'+`53/53关恰好一条完整有序解;每条新约束逐项必要;检查${checks}项。既有40关${prototypes.length}个文件校验值不变;原项目历史${original.length}文件不变。\n\n`+'|关|标题|格数|必要机制数|目标分|结构选择点|\n|---:|---|---:|---:|---:|---:|\n'+results.map(r=>`|${r.order}|${r.title}|${r.nodeCount}|${r.ablations.length}|${r.targetDifficulty}|${r.structure.noImmediateConflictChoices}|`).join('\n')+'\n\n目标分非实测,结构选择点不是人类难度。盘面自动通过不代表规划推理意图已由玩家验证。\n');console.log(`PASS 53/53; ${checks} checks; old40 ${prototypes.length} files unchanged.`);