更新微信小游戏版:动画教学、分段高亮指路及发布包
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
// 微信原生短音频播放:资源随包提供,不依赖浏览器振荡器。
|
||||
const instances=new Set();
|
||||
export const audioDiagnostics={backend:'InnerAudioContext',lastError:null,plays:0};
|
||||
export function stopAudio(){for(const instance of instances)instance.stop();}
|
||||
export class CasualAudio{
|
||||
constructor(host,storage,key){this.storage=storage;this.key=key;this.channels=[];this.cursor=0;this.timers=new Set();this.lastEvent=null;this.lastAt={};this.warned=false;try{this.enabled=storage.getItem(key)!=='off';}catch{this.enabled=true;}instances.add(this);}
|
||||
unlock(){if(!this.enabled||!wx.createInnerAudioContext)return false;
|
||||
try{wx.setInnerAudioOption?.({obeyMuteSwitch:false,mixWithOther:true});if(!this.channels.length)for(let i=0;i<4;i++){const a=wx.createInnerAudioContext();a.obeyMuteSwitch=false;a.volume=.85;a.onError?.(error=>this.error(error));this.channels.push(a);}return true;}catch(error){this.error(error);return false;}}
|
||||
error(error){audioDiagnostics.lastError={errCode:error?.errCode||0,errMsg:error?.errMsg||String(error)};if(!this.warned){this.warned=true;wx.showToast?.({title:'音效加载失败,请重新进入游戏',icon:'none'});}}
|
||||
stop(){for(const timer of this.timers)clearTimeout(timer);this.timers.clear();for(const a of this.channels){try{a.stop();}catch{}}}
|
||||
toggle(){this.enabled=!this.enabled;try{this.storage.setItem(this.key,this.enabled?'on':'off');}catch{}if(this.enabled)this.play('correct');else this.stop();return this.enabled;}
|
||||
play(kind,delay=0,step=1){if(!this.unlock())return;const now=Date.now();if(kind==='error'&&now-(this.lastAt.error||0)<180)return;this.lastAt[kind]=now;
|
||||
if(kind==='restart')this.stop();const file=kind==='move'?'move-'+Math.max(1,Math.min(36,Math.floor(step))):kind;
|
||||
const run=()=>{if(!this.enabled)return;const a=this.channels[this.cursor++%this.channels.length];try{a.stop();a.src='audio/'+file+'.wav';a.play();this.lastEvent=kind;audioDiagnostics.plays++;}catch(error){this.error(error);}};
|
||||
if(delay>0){const timer=setTimeout(()=>{this.timers.delete(timer);run();},delay*1000);this.timers.add(timer);}else run();}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export const tutorials={
|
||||
basic:'从数字 1 开始,按住滑动,把所有格子连起来。只能走到边挨着边的格子,不能斜着走。每格只能经过一次。',
|
||||
walls:'黑黄条纹是一堵墙,不能直接穿过去。请从别的格子绕到墙的另一边。',
|
||||
straight:'画着横线的格子只能横着穿过:从左边进、右边出,也可以反过来。不能在里面转弯,也不能作为起点或终点。',
|
||||
vertical:'画着竖线的格子只能竖着穿过:从上边进、下边出,也可以反过来。不能在里面转弯,也不能作为起点或终点。',
|
||||
corner:'这个格子里画着一条转弯的线。沿这条线,从一端进入、另一端离开;正着走、反着走都可以。不能作为起点或终点。',
|
||||
colour:'每走一步都要换颜色:蓝色 → 橙色 → 蓝色。同一种颜色的两个格子,即使挨着,也不能连续走。',
|
||||
parity:'从起点开始数,第一个格子算第 1 步。“奇”格必须在第 1、3、5…步经过;“偶”格必须在第 2、4、6…步经过。',
|
||||
'rhythm-door':'门一开始是关着的。每走进一个格子,门就开、关切换一次;起点也算一步。只有显示“开”时才能穿过。穿过后,门会保持打开;如果退回到穿门之前,门又会继续切换。',
|
||||
hex:'六边形的每条边旁边都可能有一个格子。只要两个格子有一整条边挨在一起,就能直接走过去。每经过一个格子算一步。',
|
||||
'unknown-start':'这一关没有数字 1,可以自己选起点。先看看其他数字,试着找出从哪里开始,才能按正确的步数到达它们。'
|
||||
};
|
||||
export function rulesFor(l){const lines=['按住滑动,把所有格子连成一条线。每个格子只能经过一次。',l.geometry==='hex'?'六边形之间,有一整条边挨在一起才能走过去。':'只能走到边挨着边的格子,不能斜着走。','格子里的数字表示第几步到这里。例如数字 5,必须在第 5 步走到它。'];
|
||||
if(l.geometry==='blocks')lines.push('一个大格或不规则形状的格子,整个算一步。');
|
||||
for(const [feature,key] of [['direction','corner'],['alternation','colour'],['parity','parity'],['walls','walls'],['rhythm-door','rhythm-door']])if(l.features.includes(feature))lines.push(feature==='direction'?'有线条的格子,要沿图中的线从一端进入、另一端离开;不能作为起点或终点。':tutorials[key]);
|
||||
if(!l.cells.some(c=>c.clue===1))lines.push(tutorials['unknown-start']);
|
||||
lines.push('走错了,可以沿刚才的路线往回退;也可以点“撤销”退一步,点“重开”重新开始。');return lines.join('\n');}
|
||||
const replacements={
|
||||
'固定数字步号不符':'到达数字格的步数不对',
|
||||
'该格步号奇偶不符':'走到“奇”格要数到 1、3、5…;走到“偶”格要数到 2、4、6…',
|
||||
'进出方向不符':'请沿格子里画的线,从一端进入、另一端离开',
|
||||
'这一步门是关闭的':'门现在关着,请先走到别的格子,让门切换到“开”',
|
||||
'蓝橙颜色必须交替':'每一步都要换颜色:蓝色和橙色交替走',
|
||||
'规则不符,请沿来时路退回上一格再试。':'这一步不符合规则,请沿刚才的路线退回上一格再试。',
|
||||
'下一步在黄色边框格;提示不会自动画路。':'下一步走到黄色边框的格子,请自己连过去。',
|
||||
'请沿来时路逐格退回黄色边框格,再继续。':'请沿刚才的路线,一格一格退回黄色边框处,再试另一条路。',
|
||||
'起点需要调整,请重开并从黄色边框格开始。':'起点选错了。请点“重开”,从黄色边框的格子开始。',
|
||||
'门现在关闭;绕行落格后会切换。':'门现在关着。先走到别的格子,门就会开、关切换一次。',
|
||||
'黑黄墙不能穿过,请从另一侧绕行。':tutorials.walls,
|
||||
'同色格不能连续经过,请换另一种颜色。':'下一步要换颜色:蓝格之后走橙格,橙格之后走蓝格。'
|
||||
};
|
||||
export function playerText(value){let s=String(value);for(const [from,to]of Object.entries(replacements))s=s.split(from).join(to);return s.replaceAll(',沿来时路逐格退回。','。请沿刚才的路线退回。').replaceAll('访问步号','第几步经过').replaceAll('步号','步数').replaceAll('认清异形共边','找出不规则格子旁边能走的格子').replaceAll('六向共边','六边形怎么走').replaceAll('共边','边挨着边');}
|
||||
@@ -0,0 +1,58 @@
|
||||
export function hintSegment(level, solution, path) {
|
||||
const cells=new Map(level.cells.map(c=>[c.id,c]));
|
||||
const anchors=solution.flatMap((id,i)=>{const c=cells.get(id);return i===0||i===solution.length-1||c.clue!==undefined||c.direction||c.parity?[i]:[];});
|
||||
let prefix=0;while(prefix<path.length&&path[prefix]===solution[prefix])prefix++;
|
||||
for(let i=1;i<anchors.length;i++)if(prefix<=anchors[i])return {start:anchors[i-1],end:anchors[i],route:solution.slice(anchors[i-1],anchors[i]+1)};
|
||||
return null;
|
||||
}
|
||||
export function createHintController(){
|
||||
let active=null,owner=null;
|
||||
return {
|
||||
clear(){active=owner=null;},
|
||||
request(level,solution,path){owner=level;active=hintSegment(level,solution,path);return active;},
|
||||
view(level,solution,path){
|
||||
if(owner!==level)active=null;
|
||||
if(!active)return null;
|
||||
let prefix=0;while(prefix<path.length&&path[prefix]===solution[prefix])prefix++;
|
||||
if(prefix>active.end){active=null;return null;}
|
||||
const current=hintSegment(level,solution,path);
|
||||
if(current&¤t.start<active.start)active=current;
|
||||
return {...active,route:[...active.route]};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Draw only on the dedicated transparent hint canvas, never on the player board.
|
||||
export function drawHint(ctx,hint,centers,unit,elapsed,playerPath=[]){
|
||||
if(!hint||hint.route.length<2)return;
|
||||
const points=hint.route.map(id=>centers[id]);if(points.some(p=>!p))return;
|
||||
const visited=new Set(playerPath);
|
||||
const covered=hint.route.slice(1).map((b,i)=>{const a=hint.route[i];return playerPath.some((id,j)=>j>0&&((playerPath[j-1]===a&&id===b)||(playerPath[j-1]===b&&id===a)));});
|
||||
const width=Math.max(4,Math.min(9,unit*.13));
|
||||
const progress=elapsed%Math.max(1200,(points.length-1)*240)/Math.max(1200,(points.length-1)*240)*(points.length-1);
|
||||
ctx.save();ctx.lineCap='round';ctx.lineJoin='round';
|
||||
const trace=(i,color,w)=>{if(covered[i])return;ctx.beginPath();ctx.moveTo(...points[i]);ctx.lineTo(...points[i+1]);ctx.strokeStyle=color;ctx.lineWidth=w;ctx.stroke();};
|
||||
for(let i=0;i<points.length-1;i++)trace(i,'rgba(35,36,58,.62)',width+4);
|
||||
for(let i=0;i<points.length-1;i++)trace(i,'rgba(255,255,255,.78)',width);
|
||||
for(let j=10;j>=0;j--){
|
||||
const t=progress-j*.045;if(t<0)continue;
|
||||
const k=Math.min(Math.floor(t),points.length-2),f=t-k;if(covered[k])continue;
|
||||
const a=points[k],b=points[k+1],x=a[0]+(b[0]-a[0])*f,y=a[1]+(b[1]-a[1])*f;
|
||||
ctx.beginPath();ctx.arc(x,y,width*(.45+(10-j)*.035),0,Math.PI*2);
|
||||
ctx.fillStyle=`rgba(255,255,255,${.12+(10-j)*.075})`;ctx.fill();
|
||||
if(j===0){ctx.strokeStyle='rgba(35,36,58,.9)';ctx.lineWidth=2;ctx.stroke();}
|
||||
}
|
||||
for(const i of [0,points.length-1]){
|
||||
if(visited.has(hint.route[i]))continue;
|
||||
ctx.beginPath();ctx.arc(...points[i],Math.max(8,unit*.23),0,Math.PI*2);
|
||||
ctx.strokeStyle='rgba(35,36,58,.72)';ctx.lineWidth=7;ctx.stroke();
|
||||
ctx.strokeStyle='rgba(255,255,255,.92)';ctx.lineWidth=3;ctx.stroke();
|
||||
}
|
||||
// Clear the hint inside every actually visited cell, including wrong routes.
|
||||
// The base board's filled cells, numbers and channel effects stay fully visible.
|
||||
ctx.globalCompositeOperation='destination-out';ctx.fillStyle='#000000';
|
||||
for(const p of hint.visitedPolygons||[]){
|
||||
ctx.beginPath();ctx.moveTo(...p[0]);for(const v of p.slice(1))ctx.lineTo(...v);ctx.closePath();ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import {solve,doorOpen,doorLatched,graph,violations} from './upstream/rules.mjs';
|
||||
import {tutorials} from './copy.mjs';
|
||||
export function lessonKeys(level){if(level.tutorial)return [level.tutorial];const keys=['basic'];if(level.features.includes('walls'))keys.push('walls');for(const [key,kind]of [['straight','lr'],['vertical','du']])if(level.cells.some(c=>c.direction===kind))keys.push(key);if(level.cells.some(c=>c.direction&&!['lr','du'].includes(c.direction)))keys.push('corner');if(level.features.includes('alternation'))keys.push('colour');if(level.features.includes('parity'))keys.push('parity');if(level.doors?.length)keys.push('rhythm-door');if(level.geometry==='hex')keys.push('hex');if(!level.cells.some(c=>c.clue===1))keys.push('unknown-start');return keys;}
|
||||
export function makeLesson(pack,key){const level=pack.levels.find(l=>l.tutorial===key);if(!level)throw Error('缺少教学关 '+key);const solution=solve(level)[0];const routes=Array.from({length:solution.length+1},(_,n)=>solution.slice(0,n));if(key==='rhythm-door'){for(let n=solution.length-1;n>=0;n--)routes.push(solution.slice(0,n));}const examples=[];const g=graph(level);
|
||||
if(key==='walls')examples.push({after:1,route:solution.slice(0,1),blocked:level.walls[0],message:'试着穿墙:这条边被挡住,需要绕行'});
|
||||
if(key==='colour') {const first=level.cells.find(c=>c.id===solution[0]);const other=g.adj[g.ids.get(first.id)].map(i=>g.cells[i]).find(c=>c.category===first.category);if(other)examples.push({after:1,route:solution.slice(0,1),blocked:[first.id,other.id],message:'同色不能连续走;改走旁边的橙色格'});}
|
||||
if(['straight','vertical','corner','parity'].includes(key)){for(let n=1;n<solution.length;n++){const prefix=solution.slice(0,n);const bad=g.adj[g.ids.get(prefix.at(-1))].map(i=>g.cells[i].id).find(id=>!prefix.includes(id)&&violations(level,[...prefix,id]).some(v=>v.key.startsWith((key==='parity'?'parity':'direction')+':')));if(bad){examples.push({after:n,route:[...prefix,bad],message:key==='parity'?'错误示例:奇偶与当前步数不符,格子变红':'错误示例:没有沿图中的两端进出,图标变红'});break;}}}
|
||||
if(key==='parity'){const start=solution[0];for(const j of g.adj[g.ids.get(start)])for(const k of g.adj[j]){const route=[start,g.cells[j].id,g.cells[k].id];if(new Set(route).size===3&&violations(level,route).some(v=>v.key.startsWith('parity:'))&&!examples.length)examples.push({after:1,route,message:'错误示例:第 3 步走到“偶”格,格子变红'});}}
|
||||
const beats=[];routes.forEach((route,i)=>{beats.push({route});if(i<solution.length)for(const e of examples.filter(e=>e.after===i)){beats.push(e);for(let back=e.route.length-1;back>route.length;back--)beats.push({route:e.route.slice(0,back),message:'沿来时路逐格退回,再按规则走'});beats.push({route,message:e.blocked?'改走符合规则的相邻格子':'沿来时路退回,重新按规则走'});}});
|
||||
const continuous=[];for(const beat of beats){const previous=continuous.at(-1);if(previous&&JSON.stringify(previous.route)===JSON.stringify(beat.route)&&!beat.blocked&&!previous.blocked)continue;if(previous&&beat.route.length>previous.route.length+1){for(let n=previous.route.length+1;n<beat.route.length;n++)continuous.push({route:beat.route.slice(0,n)});}continuous.push(beat);}return {key,level,solution,routes,beats:continuous,copy:tutorials[key],duration:1000};}
|
||||
export function lessonFrame(lesson,elapsed){const duration=lesson.duration,segments=lesson.beats.length-1,position=Math.max(0,Math.min(1,elapsed/duration))*segments,index=Math.min(segments,Math.floor(position)+1),beat=lesson.beats[Math.min(index,lesson.beats.length-1)],route=beat.route,previous=lesson.beats[Math.max(0,Math.min(index-1,lesson.beats.length-1))].route;const n=route.length,key=lesson.key,c=lesson.level.cells.find(c=>c.id===route.at(-1));let message=n?'第 '+n+' 步':'先观察格子,再开始连线';if(key==='basic')message=n?'第 '+n+' 步 · 每个格子只经过一次':'从数字 1 开始';if(key==='colour'&&n)message+=' · '+(c.category==='circle'?'蓝色;下一步换橙色':'橙色;下一步换蓝色');if(key==='parity'&&n)message+=' · '+(n%2?'奇数步:1、3、5…':'偶数步:2、4、6…');if(['straight','vertical','corner'].includes(key)){const at=route.findIndex(id=>lesson.level.cells.find(c=>c.id===id)?.direction);message=at<0?'观察线条连接的两端':at===n-1?'从线的一端进入,下一步沿另一端离开':'沿指定的线穿过,图标亮起';}if(key==='rhythm-door'){const d=lesson.level.doors[0];message+=' · '+(doorLatched(d,route)?'已经穿过:门保持打开':doorOpen(d,route)?'门开:现在可以穿过':'门关:此时不能穿过');if(previous.length>n)message='回退到第 '+n+' 步 · '+(doorLatched(d,route)?'尚未退过门,保持打开':'已撤销穿门,恢复开关切换');}if(key==='walls')message=n?'沿其他格子绕过去,黑黄墙始终不能穿越':'找到黑黄墙:这条边不能走';if(key==='hex')message=n?'第 '+n+' 步 · 沿相邻的整条边移动':'观察六边形相邻的边';if(key==='unknown-start')message=n?'第 '+n+' 步 · 核对是否按时到达数字格':'没有数字 1:观察其他数字,选择起点';message=beat.message||message;return {route,previous,message,blocked:beat.blocked,progress:elapsed>=duration?1:position-Math.floor(position),finished:elapsed>=duration,index};}
|
||||
@@ -0,0 +1,147 @@
|
||||
import {stopAudio,audioDiagnostics} from './audio.mjs';
|
||||
import { mainCanvas, boardCanvas, document, window, PACK } from './platform.mjs';
|
||||
import {renderLesson,hintView} from './upstream/ui.mjs';
|
||||
import {drawHint} from './hints.mjs';
|
||||
import {lessonKeys,makeLesson,lessonFrame} from './lessons.mjs';
|
||||
import {tutorials} from './copy.mjs';
|
||||
const lessonCanvas=wx.createCanvas();let lesson=null,lessonStarted=0,lessonState=null,lessonCache=new Map();
|
||||
const $ = id => document.getElementById(id), ctx = mainCanvas.getContext('2d');
|
||||
let width = 375, height = 812, top = 60, bottom = 24, page = 0, buttons = [], pointer = null, visible = true, raf = 0;
|
||||
let rulePage=0,ruleKey='';
|
||||
const hintCanvas=wx.createCanvas(),hintContext=hintCanvas.getContext('2d');
|
||||
let hintAnimation={key:'',started:0};
|
||||
const ordered = PACK.playOrder.map(id => PACK.levels.find(l => l.id === id));
|
||||
function resize() { const info = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync(); width = info.windowWidth; height = info.windowHeight; top = Math.max(info.safeArea?.top || 0, wx.getMenuButtonBoundingClientRect?.().bottom || 0) + 12; bottom = Math.max(16, height - (info.safeArea?.bottom || height)); window.devicePixelRatio = Math.min(info.pixelRatio || 1, 3); mainCanvas.width = width * window.devicePixelRatio; mainCanvas.height = height * window.devicePixelRatio; $('board').clientWidth = width; $('board').clientHeight = Math.max(140, height - top - bottom - 218); window.handlers.resize?.(); }
|
||||
function text(value, x, y, size = 16, color = '#23243A') { ctx.fillStyle = color; ctx.font = size + 'px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(String(value), x, y); }
|
||||
function splitLines(value,maxWidth,size=16){ctx.font=size+'px sans-serif';const lines=[];for(const paragraph of String(value).split('\n')){let line='';for(const c of paragraph){if(line&&ctx.measureText(line+c).width>maxWidth){lines.push(line);line='';}line+=c;}lines.push(line);}return lines;}
|
||||
function wrapped(value,x,y,maxWidth,size=16){const lines=splitLines(value,maxWidth,size);lines.forEach((line,i)=>text(line,x,y+i*(size+9),size));return lines.length;}
|
||||
function button(label, x, y, w, h, run, { enabled = true, primary = false } = {}) { ctx.fillStyle = enabled ? (primary ? '#6558F5' : '#ECECF3') : '#F4F4F6'; ctx.fillRect(x, y, w, h); text(label, x + w / 2, y + h / 2, 15, enabled ? (primary ? '#FFFFFF' : '#23243A') : '#AAAAAA'); buttons.push({ label, x, y, w, h, run, enabled }); }
|
||||
function click(id) { if(id==='btn-rules')ruleKey=''; $('board').handlers.pointerup?.(); pointer = null; $(id).click(); }
|
||||
function levelButtons() { return $('pack-list').children.flatMap(section => section.children[1].children); }
|
||||
function draw() {
|
||||
if (!visible)
|
||||
return;
|
||||
const d = window.devicePixelRatio;
|
||||
ctx.setTransform(d, 0, 0, d, 0, 0);
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
buttons = [];
|
||||
const select = $('screen-play').className.includes('hidden');
|
||||
if (select) {
|
||||
text('数字一笔', width / 2, top + 24, 30);
|
||||
text('200关 · 评分修订版', width / 2, top + 61, 16);
|
||||
text($('progress').textContent, width / 2, top + 90, 14);
|
||||
const rows = Math.max(1, Math.floor((height - top - bottom - 242) / 57)), count = rows * 4, pages = Math.ceil(200 / count);
|
||||
page = Math.max(0, Math.min(pages - 1, page));
|
||||
const levels = levelButtons();
|
||||
for (let j = 0; j < count && page * count + j < levels.length; j++) {
|
||||
const index = page * count + j, node = levels[index];
|
||||
button(node.textContent + (node.className.includes('cleared') ? ' ✓' : ''), 18 + j % 4 * (width - 30) / 4, top + 120 + Math.floor(j / 4) * 57, (width - 48) / 4 - 4, 45, () => node.click(), { primary: node.className.includes('current') });
|
||||
}
|
||||
button('上一页', 18, height - bottom - 106, 90, 38, () => page = Math.max(0, page - 1));
|
||||
text((page + 1) + ' / ' + pages, width / 2, height - bottom - 87);
|
||||
button('下一页', width - 108, height - bottom - 106, 90, 38, () => page = Math.min(pages - 1, page + 1));
|
||||
button($('btn-continue').textContent, 18, height - bottom - 56, width - 126, 42, () => click('btn-continue'), { primary: true });
|
||||
button('反馈', width - 98, height - bottom - 56, 80, 42, feedback);
|
||||
}
|
||||
else {
|
||||
button('选关', 16, top, 62, 36, () => click('btn-back'));
|
||||
text('第 ' + window.variantSnapshot().displayOrder + ' 关', width / 2, top + 18, 20);
|
||||
button($('btn-sound').textContent, width - 94, top, 78, 36, () => click('btn-sound'));
|
||||
text($('level-sub').textContent, width / 2, top + 59, 14);
|
||||
ctx.drawImage(boardCanvas, 0, top + 82, width, $('board').clientHeight);
|
||||
const hint=hintView(),boardState=window.variantSnapshot();
|
||||
if(hint){
|
||||
const key=boardState.level+':'+hint.start+':'+hint.end;
|
||||
if(hintAnimation.key!==key)hintAnimation={key,started:Date.now()};
|
||||
const h=$('board').clientHeight;
|
||||
if(hintCanvas.width!==Math.round(width*d))hintCanvas.width=Math.round(width*d);
|
||||
if(hintCanvas.height!==Math.round(h*d))hintCanvas.height=Math.round(h*d);
|
||||
hintContext.setTransform(d,0,0,d,0,0);hintContext.clearRect(0,0,width,h);
|
||||
drawHint(hintContext,hint,boardState.centers,boardState.unit,Date.now()-hintAnimation.started,boardState.path);
|
||||
ctx.drawImage(hintCanvas,0,top+82,width,h);
|
||||
}else hintAnimation={key:'',started:0};
|
||||
const foot = height - bottom - 102;
|
||||
const cleared=window.variantSnapshot().cleared;
|
||||
const message=cleared?(window.variantSnapshot().displayOrder===ordered.length?'200 关全部完成!':'完成!准备好就进入下一关。'):($('status').className.includes('show')?$('status').textContent:'按住滑动连线 · 沿来时路逐格回退');
|
||||
const messageLines=splitLines(message,width-36,13);
|
||||
messageLines.slice(0,2).forEach((line,i)=>text(i===1&&messageLines.length>2?line.slice(0,-1)+'…':line,width/2,foot-12+i*22,13,cleared?'#6558F5':'#777789'));
|
||||
if(cleared){
|
||||
button(!$('next').hidden?'下一关 →':'返回选关',16,foot+22,width-32,44,()=>click(!$('next').hidden?'next':'btn-back'),{primary:true});
|
||||
}else{
|
||||
const bw=(width-48)/3;
|
||||
button('重开',16,foot+22,bw,44,()=>click('restart'));
|
||||
button('撤销',24+bw,foot+22,bw,44,()=>click('undo'),{enabled:!$('undo').disabled});
|
||||
button('指路',32+2*bw,foot+22,bw,44,()=>click('hint'));
|
||||
}
|
||||
button('规则', 16, top + 43, 56, 30, () => click('btn-rules'));
|
||||
text('剩余 ' + $('remaining-n').textContent, width - 52, top + 58, 14);
|
||||
}
|
||||
if (!$('rules-sheet').hidden) {
|
||||
ctx.fillStyle = 'rgba(25,24,44,.55)';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
ctx.fillStyle = '#FFFFFF';
|
||||
const panelTop=top+12,panelHeight=Math.max(240,height-panelTop-bottom-20);
|
||||
ctx.fillRect(16,panelTop,width-32,panelHeight);
|
||||
buttons=[];
|
||||
text('怎么玩',width/2,panelTop+28,23);
|
||||
const key=window.variantSnapshot().level+':'+$('rule-text').textContent;
|
||||
if(ruleKey!==key){ruleKey=key;rulePage=0;lesson=null;}
|
||||
const current=ordered.find(l=>l.id===window.variantSnapshot().level),keys=lessonKeys(current);
|
||||
const pages=[];const visualHeight=Math.max(90,Math.min(220,panelHeight*.34));
|
||||
const perPage=Math.max(1,Math.floor((panelHeight-visualHeight-230)/23));
|
||||
for(const k of keys){const extra=k==='basic'&&!current.tutorial?'\n格子里的数字表示第几步到这里,例如数字 5,要在第 5 步到达。'+(current.geometry==='blocks'?'大格或不规则格子,整个只算一步。':'')+'走错后沿原路退一步,或点撤销;重开会清空路线。':'';const lines=splitLines(tutorials[k]+extra,width-72,15);for(let n=0;n<lines.length;n+=perPage)pages.push({key:k,lines:lines.slice(n,n+perPage)});}
|
||||
rulePage=Math.max(0,Math.min(pages.length-1,rulePage));const part=pages[rulePage];
|
||||
const lessonKey=key+':'+rulePage;
|
||||
if(!lesson||lesson.id!==lessonKey){if(!lessonCache.has(part.key))lessonCache.set(part.key,makeLesson(PACK,part.key));lesson={...lessonCache.get(part.key),id:lessonKey};lessonStarted=Date.now();}
|
||||
lessonState=lessonFrame(lesson,Date.now()-lessonStarted);
|
||||
const area={x:32,y:panelTop+49,w:width-64,h:visualHeight};
|
||||
const view=renderLesson(lessonCanvas,lesson.level,lessonState.progress<1?lessonState.previous:lessonState.route,area.w,area.h);
|
||||
ctx.drawImage(lessonCanvas,area.x,area.y,area.w,area.h);
|
||||
if(lessonState.blocked){const [a,b]=lessonState.blocked.map(id=>view.centers[id]);ctx.beginPath();ctx.moveTo(area.x+a[0],area.y+a[1]);ctx.lineTo(area.x+b[0],area.y+b[1]);ctx.setLineDash([5,5]);ctx.strokeStyle='#E5484D';ctx.lineWidth=4;ctx.stroke();ctx.setLineDash([]);text('×',area.x+(a[0]+b[0])/2,area.y+(a[1]+b[1])/2,26,'#E5484D');}
|
||||
const last=lessonState.route.at(-1),prev=lessonState.previous.at(-1),end=view.centers[last],start=view.centers[prev]||end;
|
||||
if(end){const t=lessonState.progress,x=area.x+start[0]+(end[0]-start[0])*t,y=area.y+start[1]+(end[1]-start[1])*t;if(start!==end){ctx.beginPath();ctx.moveTo(area.x+start[0],area.y+start[1]);ctx.lineTo(x,y);ctx.strokeStyle='#6558F5';ctx.lineWidth=6;ctx.lineCap='round';ctx.stroke();}ctx.beginPath();ctx.arc(x,y,10,0,Math.PI*2);ctx.strokeStyle='#F5A524';ctx.lineWidth=3;ctx.stroke();}
|
||||
const msg=splitLines(lessonState.message,width-76,14);msg.forEach((line,i)=>text(line,width/2,area.y+area.h+16+i*20,14,'#6558F5'));
|
||||
part.lines.forEach((line,i)=>text(line,width/2,area.y+area.h+65+i*23,15));
|
||||
button('重播',width/2-32,panelTop+panelHeight-144,64,30,()=>{lessonStarted=Date.now();});
|
||||
if(pages.length>1){button('上一段',38,panelTop+panelHeight-102,80,30,()=>{rulePage--;lesson=null;},{enabled:rulePage>0});text((rulePage+1)+' / '+pages.length,width/2,panelTop+panelHeight-87,14);button('下一段',width-118,panelTop+panelHeight-102,80,30,()=>{rulePage++;lesson=null;},{enabled:rulePage<pages.length-1});}
|
||||
button('返回游戏',40,panelTop+panelHeight-60,width-80,42,()=>click('close-rules'),{primary:true});
|
||||
}
|
||||
raf = requestAnimationFrame(draw);
|
||||
}
|
||||
function feedback() { wx.showModal({ title: '试玩反馈', editable: true, placeholderText: '记录有趣、太难或规则不清楚的关卡', content: $('reasoning').value || '', success: r => { if (r.confirm) {
|
||||
$('reasoning').value = r.content || '';
|
||||
click('close-feedback');
|
||||
wx.showModal({ title: '反馈已保存', content: '是否导出全部试玩记录?', success: r => { if (r.confirm)
|
||||
click('export'); } });
|
||||
} } }); }
|
||||
function event(p) { return { clientX: p.clientX, clientY: p.clientY - top - 82, pointerId: p.identifier, pointerType: 'touch', preventDefault() { } }; }
|
||||
wx.onTouchStart(e => { if (pointer !== null)
|
||||
return; const p = e.changedTouches[0]; if (!p)
|
||||
return; const b = buttons.find(b => p.clientX >= b.x && p.clientX <= b.x + b.w && p.clientY >= b.y && p.clientY <= b.y + b.h); if (b) {
|
||||
if (b.enabled)
|
||||
b.run();
|
||||
return;
|
||||
} if (!$('rules-sheet').hidden || $('screen-play').className.includes('hidden'))
|
||||
return; if (p.clientY < top + 82 || p.clientY > top + 82 + $('board').clientHeight)
|
||||
return; pointer = p.identifier; $('board').handlers.pointerdown?.(event(p)); });
|
||||
wx.onTouchMove(e => { const p = e.touches.find(p => p.identifier === pointer); if (p)
|
||||
$('board').handlers.pointermove?.(event(p)); });
|
||||
const release = e => { if (e.changedTouches.some(p => p.identifier === pointer)) {
|
||||
$('board').handlers.pointerup?.();
|
||||
pointer = null;
|
||||
} };
|
||||
wx.onTouchEnd(release);
|
||||
wx.onTouchCancel(release);
|
||||
wx.onHide(() => { visible = false; lessonStarted=Date.now(); stopAudio(); cancelAnimationFrame(raf); pointer = null; $('board').handlers.pointercancel?.(); window.handlers.pagehide?.(); });
|
||||
wx.onShow(() => { if (!visible) {
|
||||
visible = true;lessonStarted=Date.now();
|
||||
window.handlers.resize?.();
|
||||
raf = requestAnimationFrame(draw);
|
||||
} });
|
||||
wx.onAudioInterruptionBegin?.(stopAudio);
|
||||
wx.onWindowResize?.(resize);
|
||||
// 只读状态,供微信开发者工具和发布包自动验收定位问题。
|
||||
GameGlobal.numberSwipeR2 = { snapshot: () => ({...window.variantSnapshot(),hint:hintView(),audio:{...audioDiagnostics},rules: $('rule-text').textContent,lesson:!$('rules-sheet').hidden&&lessonState?{key:lesson?.key,path:[...lessonState.route],message:lessonState.message}:null}), buttons: () => buttons.map(({ label, x, y, w, h, enabled }) => ({ label, x, y, w, h, enabled })) };
|
||||
resize();
|
||||
raf = requestAnimationFrame(draw);
|
||||
@@ -0,0 +1,69 @@
|
||||
import PACK from './upstream/levels.json';
|
||||
import {tutorials,playerText} from './copy.mjs';
|
||||
for(const level of PACK.levels){if(level.tutorial)level.caption=tutorials[level.tutorial]||level.caption;level.title=playerText(level.title);}
|
||||
export { PACK };
|
||||
export const performance = wx.getPerformance ? wx.getPerformance() : { now: () => Date.now() };
|
||||
if (!Array.prototype.at)
|
||||
Object.defineProperty(Array.prototype, 'at', { value: function (index) { index = Math.trunc(index) || 0; return this[index < 0 ? this.length + index : index]; }, configurable: true, writable: true });
|
||||
export const mainCanvas = wx.createCanvas();
|
||||
const boardCanvas = wx.createCanvas();
|
||||
const elements = new Map();
|
||||
class Element {
|
||||
constructor(id = '') { this.id = id; this.children = []; this.handlers = {}; this.value = ''; this.style = {}; this.className = ''; this.textContent = ''; this.hidden = false; this.disabled = false; }
|
||||
set textContent(value){this._text=playerText(value);}
|
||||
get textContent(){return this._text||'';}
|
||||
replaceChildren(...children) { this.children = children; }
|
||||
append(...children) { this.children.push(...children); }
|
||||
addEventListener(type, fn) { this.handlers[type] = fn; }
|
||||
setAttribute() { }
|
||||
setPointerCapture() { }
|
||||
getBoundingClientRect() { return { left: 0, top: 0, width: this.clientWidth, height: this.clientHeight }; }
|
||||
click() { if (!this.disabled)
|
||||
this.onclick?.(); }
|
||||
}
|
||||
export const document = {
|
||||
getElementById(id) { if (!elements.has(id))
|
||||
elements.set(id, new Element(id)); return elements.get(id); },
|
||||
createElement() { return new Element(); }
|
||||
};
|
||||
const board = document.getElementById('board');
|
||||
board.getContext = () => boardCanvas.getContext('2d');
|
||||
for (const key of ['width', 'height'])
|
||||
Object.defineProperty(board, key, { get: () => boardCanvas[key], set: value => { boardCanvas[key] = value; } });
|
||||
board.clientWidth = 375;
|
||||
board.clientHeight = 440;
|
||||
document.getElementById('screen-play').className = 'screen hidden';
|
||||
document.getElementById('rules-sheet').hidden = true;
|
||||
document.getElementById('feedback-sheet').hidden = true;
|
||||
export const localStorage = {
|
||||
getItem(key) { try {
|
||||
return wx.getStorageSync(key) || null;
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
} },
|
||||
setItem(key, value) { try {
|
||||
wx.setStorageSync(key, String(value));
|
||||
}
|
||||
catch { } },
|
||||
removeItem(key) { wx.removeStorageSync(key); }
|
||||
};
|
||||
export const window = {
|
||||
devicePixelRatio: 1, handlers: {},
|
||||
addEventListener(type, fn) { this.handlers[type] = fn; },
|
||||
AudioContext: wx.createWebAudioContext ? function () { return wx.createWebAudioContext(); } : undefined
|
||||
};
|
||||
export { boardCanvas };
|
||||
export function exportRecords(payload) {
|
||||
const filename = wx.env.USER_DATA_PATH + '/NumberSwipe-campaign-200-r2-records.json';
|
||||
try {
|
||||
wx.getFileSystemManager().writeFileSync(filename, JSON.stringify(payload, null, 2), 'utf8');
|
||||
if (wx.shareFileMessage)
|
||||
wx.shareFileMessage({ filePath: filename, fileName: 'NumberSwipe-r2-试玩记录.json' });
|
||||
else
|
||||
wx.showModal({ title: '记录已保存', content: filename, showCancel: false });
|
||||
}
|
||||
catch {
|
||||
wx.showToast({ title: '记录导出失败', icon: 'none' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Small offline Web Audio cues: no network or audio-file dependency.
|
||||
export class CasualAudio {
|
||||
constructor(host,storage,key){this.host=host;this.storage=storage;this.key=key;this.context=null;this.lastEvent=null;this.lastAt={};try{this.enabled=storage.getItem(key)!=='off';}catch{this.enabled=true;}}
|
||||
unlock(){if(!this.enabled)return false;try{const Audio=this.host.AudioContext||this.host.webkitAudioContext;if(!Audio)return false;if(!this.context)this.context=new Audio();if(this.context.state==='suspended')this.context.resume()?.catch(()=>{});return true;}catch{return false;}}
|
||||
toggle(){this.enabled=!this.enabled;try{this.storage.setItem(this.key,this.enabled?'on':'off');}catch{}if(this.enabled)this.unlock();return this.enabled;}
|
||||
play(kind,delay=0,step=1){if(!this.unlock())return;const ctx=this.context,now=ctx.currentTime;const gap=kind==='error'?.18:0;if(this.lastAt[kind]!==undefined&&now-this.lastAt[kind]<gap)return;this.lastAt[kind]=now;this.lastEvent=kind;
|
||||
const cues={move:[[523*Math.pow(2,(Math.max(1,step)-1)/12),.07,0]],correct:[[659,.09,0],[880,.14,.075]],error:[[330,.10,0],[247,.15,.09]],undo:[[587,.07,0],[440,.10,.055]],restart:[[523,.075,0],[392,.09,.065],[523,.11,.14]],complete:[[523,.12,0],[659,.12,.10],[784,.14,.20],[1046,.25,.32]]};
|
||||
for(const [frequency,duration,offset]of cues[kind]||[]){try{const start=now+delay+offset,osc=ctx.createOscillator(),gain=ctx.createGain();osc.type='sine';osc.frequency.setValueAtTime(frequency,start);gain.gain.setValueAtTime(.0001,start);gain.gain.linearRampToValueAtTime(kind==='error'?.055:.075,start+.008);gain.gain.exponentialRampToValueAtTime(.0001,start+duration);osc.connect(gain);gain.connect(ctx.destination);osc.onended=()=>{osc.disconnect();gain.disconnect();};osc.start(start);osc.stop(start+duration+.015);}catch{}}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,128 @@
|
||||
// Pure rules / geometry / solver. Each mode receives its own standalone copy.
|
||||
export function units(c){return c.shape?c.shape.map(([x,y])=>[c.x+x,c.y+y]):Array.from({length:(c.w||1)*(c.h||1)},(_,i)=>[c.x+i%(c.w||1),c.y+Math.floor(i/(c.w||1))]);}
|
||||
export function outlineUnits(c){
|
||||
const occupied=new Set(units(c).map(p=>p.join(','))),edges=[];
|
||||
for(const [x,y]of units(c))for(const [dx,dy,a,b]of [[0,-1,[x,y],[x+1,y]],[1,0,[x+1,y],[x+1,y+1]],[0,1,[x+1,y+1],[x,y+1]],[-1,0,[x,y+1],[x,y]]])if(!occupied.has((x+dx)+','+(y+dy)))edges.push([a,b]);
|
||||
const next=new Map(edges.map(([a,b])=>[a.join(','),b])),start=edges[0][0],points=[start];let at=next.get(start.join(','));
|
||||
while(at.join(',')!==start.join(',')){points.push(at);at=next.get(at.join(','));if(points.length>edges.length)throw Error('Invalid block contour');}
|
||||
return points.filter((b,i)=>{const a=points[(i+points.length-1)%points.length],d=points[(i+1)%points.length];return (b[0]-a[0])*(d[1]-b[1])!==(b[1]-a[1])*(d[0]-b[0]);});
|
||||
}
|
||||
export function graph(level){
|
||||
const cells=level.cells, ids=new Map(cells.map((c,i)=>[c.id,i]));
|
||||
if(ids.size!==cells.length)throw Error('重复格 id');
|
||||
const adj=cells.map(()=>[]), occupancy=new Map();
|
||||
if(level.geometry!=='hex') for(let i=0;i<cells.length;i++)for(const [x,y]of units(cells[i])){const k=x+','+y;if(occupancy.has(k))throw Error('拼块重叠');occupancy.set(k,i);}
|
||||
for(let i=0;i<cells.length;i++)for(let j=i+1;j<cells.length;j++){
|
||||
let connected=false;
|
||||
if(level.geometry==='hex'){let dq=cells[j].q-cells[i].q,dr=cells[j].r-cells[i].r;connected=[[1,0],[-1,0],[0,1],[0,-1],[1,-1],[-1,1]].some(([a,b])=>a===dq&&b===dr);}
|
||||
else connected=units(cells[i]).some(([x,y])=>[[1,0],[-1,0],[0,1],[0,-1]].some(([a,b])=>occupancy.get((x+a)+','+(y+b))===j));
|
||||
if(connected){adj[i].push(j);adj[j].push(i);}
|
||||
}
|
||||
const clues=new Map();for(const c of cells)if(c.clue!==undefined){if(!Number.isInteger(c.clue)||c.clue<1||c.clue>cells.length||clues.has(c.clue))throw Error('数字范围或重复');clues.set(c.clue,ids.get(c.id));}
|
||||
return {cells,ids,adj,clues};
|
||||
}
|
||||
export function constraints(level){
|
||||
const result=[];
|
||||
for(let i=0;i<(level.relations||[]).length;i++)result.push({key:'relation:'+i,kind:'relation',...level.relations[i]});
|
||||
for(let i=0;i<(level.orderChains||[]).length;i++)for(let j=0;j<level.orderChains[i].length-1;j++)result.push({key:'order:'+i+':'+j,kind:'order',from:level.orderChains[i][j],to:level.orderChains[i][j+1]});
|
||||
for(let i=0;i<(level.windows||[]).length;i++)result.push({key:'window:'+i,kind:'window',...level.windows[i]});
|
||||
for(const c of level.cells)if(c.parity)result.push({key:'parity:'+c.id,kind:'parity',cell:c.id,value:c.parity});
|
||||
for(const c of level.cells)if(c.direction)result.push({key:'direction:'+c.id,kind:'direction',cell:c.id,value:c.direction});
|
||||
if((level.features||[]).includes('alternation'))result.push({key:'alternation',kind:'alternation'});
|
||||
for(let i=0;i<(level.walls||[]).length;i++)result.push({key:'wall:'+i,kind:'wall',edge:level.walls[i]});
|
||||
for(let i=0;i<(level.doors||[]).length;i++)result.push({key:'door:'+i,kind:'door',...level.doors[i]});
|
||||
for(let i=0;i<(level.gates||[]).length;i++)result.push({key:'gate:'+i,kind:'gate',...level.gates[i]});
|
||||
return result;
|
||||
}
|
||||
export function sideBetween(level,cellId,neighborId){
|
||||
const c=level.cells.find(c=>c.id===cellId),n=level.cells.find(c=>c.id===neighborId);const occupied=new Set(units(n).map(p=>p.join(',')));const sides=new Set();for(const [x,y]of units(c))for(const [dx,dy,s]of [[-1,0,'l'],[1,0,'r'],[0,-1,'u'],[0,1,'d']])if(occupied.has((x+dx)+','+(y+dy)))sides.add(s);return sides.size===1?[...sides][0]:null;
|
||||
}
|
||||
export function directionAt(level,path,k){if(k===0||k===path.length-1)return null;const a=sideBetween(level,path[k],path[k-1]),b=sideBetween(level,path[k],path[k+1]);return a&&b?[a,b].sort().join(''):null;}
|
||||
export function doorLatched(door,path){
|
||||
for(let i=1;i<path.length;i++)if(door.edge.includes(path[i-1])&&door.edge.includes(path[i])&&(Boolean(door.initialOpen)!==Boolean(i%2)))return true;
|
||||
return false;
|
||||
}
|
||||
export function doorOpen(door,path){return Array.isArray(path)?doorLatched(door,path)||(Boolean(door.initialOpen)!==Boolean(path.length%2)):Boolean(door.initialOpen)!==Boolean(path%2);}
|
||||
export const directionNames={lr:'左右直行',du:'上下直行',ru:'上与右拐角',dr:'右与下拐角',dl:'下与左拐角',lu:'左与上拐角'};
|
||||
export function edgeBlocked(level,path,a,b,{omit=null}={}){
|
||||
for(let i=0;i<(level.walls||[]).length;i++)if(omit!=='wall:'+i&&level.walls[i].includes(a)&&level.walls[i].includes(b))return true;
|
||||
for(let i=0;i<(level.doors||[]).length;i++)if(omit!=='door:'+i&&level.doors[i].edge.includes(a)&&level.doors[i].edge.includes(b)&&!doorOpen(level.doors[i],path))return true;
|
||||
return false;
|
||||
}
|
||||
export function violations(level,path,{omit=null,complete=path.length===level.cells.length,ignoreClues=false}={}){
|
||||
const positions=new Map(path.map((id,i)=>[id,i+1])),bad=[];
|
||||
if(!ignoreClues)for(const c of level.cells)if(c.clue!==undefined){const at=positions.get(c.id);if(at!==undefined&&at!==c.clue||path.length>=c.clue&&path[c.clue-1]!==c.id)bad.push({key:'clue:'+c.id,cells:[c.id,path[c.clue-1]].filter(Boolean),message:'固定数字步号不符'});}
|
||||
for(const r of constraints(level)){
|
||||
if(r.key===omit)continue;
|
||||
let fail=false,where=[];
|
||||
if(r.kind==='relation'){
|
||||
const a=positions.get(r.from),b=positions.get(r.to);where=[r.from,r.to];
|
||||
fail=b!==undefined&&(a===undefined||b-a!==r.delta)||a!==undefined&&b===undefined&&path.length>=a+r.delta;
|
||||
}else if(r.kind==='order'){where=[r.from,r.to];fail=positions.has(r.to)&&(!positions.has(r.from)||positions.get(r.from)>positions.get(r.to));}
|
||||
else if(r.kind==='window'){where=[r.cell];const p=positions.get(r.cell);fail=p!==undefined?(p<r.min||p>r.max):path.length>=r.max;}
|
||||
else if(r.kind==='parity'){where=[r.cell];const p=positions.get(r.cell);fail=p!==undefined&&(p%2===1)!==(r.value==='odd');}
|
||||
else if(r.kind==='direction'){where=[r.cell];const p=positions.get(r.cell);fail=p===1||p!==undefined&&p<path.length&&directionAt(level,path,p-1)!==r.value||complete&&p===path.length;}
|
||||
else if(r.kind==='alternation'){for(let i=1;i<path.length;i++){const a=level.cells.find(c=>c.id===path[i-1]),b=level.cells.find(c=>c.id===path[i]);if(a.category===b.category){fail=true;where.push(a.id,b.id);}}}
|
||||
else if(r.kind==='wall'||r.kind==='door')for(let i=1;i<path.length;i++){if(r.edge.includes(path[i-1])&&r.edge.includes(path[i])&&(r.kind==='wall'||!doorOpen(r,path.slice(0,i)))){fail=true;where=r.edge;}}
|
||||
else if(r.kind==='gate')for(let i=1;i<path.length;i++){if(r.edge.includes(path[i-1])&&r.edge.includes(path[i])){const s=positions.get(r.switchCell);if(s===undefined||s>i){fail=true;where=r.edge;}}}
|
||||
if(fail)bad.push({key:r.key,cells:where,message:r.kind==='relation'?'相对步差不符':r.kind==='order'?'标记次序不符':r.kind==='window'?'到达窗口不符':r.kind==='parity'?'该格步号奇偶不符':r.kind==='direction'?'进出方向不符':r.kind==='wall'?'永久墙不能穿过':r.kind==='door'?'这一步门是关闭的':r.kind==='gate'?'开关尚未打开此门':'蓝橙颜色必须交替'});
|
||||
}
|
||||
return bad;
|
||||
}
|
||||
export function canAppend(level,path,id){
|
||||
const g=graph(level);if(path.includes(id)||!g.ids.has(id))return false;
|
||||
if(!path.length)return !g.clues.has(1)||g.cells[g.clues.get(1)].id===id;
|
||||
const last=path.at(-1);if(edgeBlocked(level,path,last,id))return false;if(!g.adj[g.ids.get(last)].includes(g.ids.get(id)))return false;
|
||||
if((level.features||[]).includes('alternation')&&g.cells[g.ids.get(last)].category===g.cells[g.ids.get(id)].category)return false;
|
||||
for(const gate of level.gates||[])if(gate.edge.includes(last)&&gate.edge.includes(id)&&!path.includes(gate.switchCell))return false;
|
||||
return true;
|
||||
}
|
||||
export function solve(level,{limit=2,omit=null,ignoreClues=false,prune=true,nodeLimit=Infinity}={}){
|
||||
const g=graph(level),n=g.cells.length,results=[],path=[],seen=new Set();let nodes=0;
|
||||
const active=constraints(level).filter(r=>r.key!==omit),alternating=active.some(r=>r.kind==='alternation');
|
||||
const adj=g.adj.map((a,i)=>a.filter(j=>(!alternating||g.cells[i].category!==g.cells[j].category)&&!active.some(r=>r.kind==='wall'&&r.edge.includes(g.cells[i].id)&&r.edge.includes(g.cells[j].id))));
|
||||
const forced=ignoreClues?new Map():g.clues;
|
||||
function connected(cur){const reached=new Set([cur]),queue=[cur];for(let k=0;k<queue.length;k++)for(const j of adj[queue[k]])if(!seen.has(j)&&!reached.has(j)){reached.add(j);queue.push(j);}return reached.size===n-path.length+1;}
|
||||
function visit(i){if(++nodes>nodeLimit)throw Error('求解节点预算耗尽,不能视为唯一');seen.add(i);path.push(g.cells[i].id);
|
||||
const k=path.length;
|
||||
if(!violations(level,path,{omit,ignoreClues}).length){
|
||||
if(k===n)results.push([...path]);
|
||||
else if(!prune||connected(i)){
|
||||
const f=forced.get(k+1);let next=adj[i].filter(j=>!seen.has(j)&&(f===undefined||j===f));
|
||||
next.sort((a,b)=>adj[a].filter(x=>!seen.has(x)).length-adj[b].filter(x=>!seen.has(x)).length);
|
||||
for(const j of next){if(!ignoreClues&&g.cells[j].clue!==undefined&&g.cells[j].clue!==k+1)continue;
|
||||
let blocked=edgeBlocked(level,path,g.cells[i].id,g.cells[j].id,{omit});for(const r of active)if(r.kind==='gate'&&r.edge.includes(g.cells[i].id)&&r.edge.includes(g.cells[j].id)&&!path.includes(r.switchCell))blocked=true;
|
||||
if(!blocked)visit(j);if(results.length>=limit)break;
|
||||
}
|
||||
}
|
||||
}
|
||||
path.pop();seen.delete(i);
|
||||
}
|
||||
let starts=forced.has(1)?[forced.get(1)]:g.cells.map((_,i)=>i);
|
||||
for(const i of starts){if(!ignoreClues&&g.cells[i].clue!==undefined&&g.cells[i].clue!==1)continue;visit(i);if(results.length>=limit)break;}
|
||||
return results;
|
||||
}
|
||||
export function hint(solution,path){let k=0;while(k<path.length&&path[k]===solution[k])k++;if(k<path.length)return {kind:k?'backtrack':'restart',cell:k?path[k-1]:solution[0]};return k===solution.length?{kind:'none'}:{kind:k?'next':'start',cell:solution[k]};}
|
||||
export function assertStructure(level){
|
||||
const g=graph(level),n=g.cells.length;if(level.features?.includes('direction')&&(level.geometry==='hex'||level.cells.some(c=>c.direction&&units(c).length!==1)))throw Error('方向标记放在方形单位格,可与矩形/异形拼块混用');if(n<3||n>40)throw Error('格数须为3–40');
|
||||
if(level.geometry==='hex'){const keys=new Set();for(const c of g.cells){if(!Number.isInteger(c.q)||!Number.isInteger(c.r))throw Error('六边坐标');const key=c.q+','+c.r;if(keys.has(key))throw Error('六边重叠');keys.add(key);}}
|
||||
else for(const c of g.cells){if(!Number.isInteger(c.x)||!Number.isInteger(c.y))throw Error('坐标');const u=units(c);if(!u.length||new Set(u.map(p=>p.join(','))).size!==u.length)throw Error('异形重复');const seen=new Set([u[0].join(',')]);for(let changed=true;changed;){changed=false;for(const [x,y]of u)if(!seen.has(x+','+y)&&[[1,0],[-1,0],[0,1],[0,-1]].some(([a,b])=>seen.has((x+a)+','+(y+b)))){seen.add(x+','+y);changed=true;}}if(seen.size!==u.length)throw Error('异形不连通');}
|
||||
const reach=new Set([0]),queue=[0];for(let k=0;k<queue.length;k++)for(const j of g.adj[queue[k]])if(!reach.has(j)){reach.add(j);queue.push(j);}if(reach.size!==n)throw Error('盘面不连通');
|
||||
if((level.doors||[]).length>1)throw Error('步进门只允许一道门');
|
||||
const physicalEdges=[...(level.walls||[]),...(level.doors||[]).map(d=>d.edge)].map(e=>[...e].sort().join('|'));if(new Set(physicalEdges).size!==physicalEdges.length)throw Error('墙门共边重复');
|
||||
const pairs=[];
|
||||
for(const r of constraints(level)){
|
||||
for(const id of [r.cell,r.from,r.to,r.switchCell,...(r.edge||[])].filter(Boolean))if(!g.ids.has(id))throw Error('约束引用不存在');
|
||||
if(r.kind==='relation'&&(!Number.isInteger(r.delta)||r.delta<1||r.delta>=n||r.from===r.to))throw Error('步差');
|
||||
if(r.kind==='order')pairs.push([r.from,r.to]);
|
||||
if(r.kind==='window'&&(!Number.isInteger(r.min)||!Number.isInteger(r.max)||r.min<1||r.max>n||r.max-r.min<2||g.cells[g.ids.get(r.cell)].clue!==undefined))throw Error('窗口需允许至少两个同奇偶步号且不与数字重叠');
|
||||
if(r.kind==='parity'&&(!['odd','even'].includes(r.value)||g.cells[g.ids.get(r.cell)].clue!==undefined))throw Error('奇偶字段或同格数字冲突');
|
||||
if(r.kind==='direction'&&!Object.keys(directionNames).includes(r.value))throw Error('方向');
|
||||
if((r.kind==='wall'||r.kind==='door')&&(r.edge.length!==2||!g.adj[g.ids.get(r.edge[0])].includes(g.ids.get(r.edge[1]))))throw Error('墙或门不在共边');
|
||||
if(r.kind==='gate'&&(r.edge.length!==2||!g.adj[g.ids.get(r.edge[0])].includes(g.ids.get(r.edge[1]))))throw Error('门不在共边');
|
||||
}
|
||||
if((level.gates||[]).length>3||new Set((level.gates||[]).map(r=>r.switchCell)).size!==(level.gates||[]).length)throw Error('最多三门且开关一对一');
|
||||
if((level.features||[]).includes('alternation')&&g.cells.some(c=>!['circle','triangle'].includes(c.category)))throw Error('缺少类别');
|
||||
function cycle(v,stack=new Set(),done=new Set()){if(stack.has(v))throw Error('先后链循环');if(done.has(v))return;stack.add(v);for(const [a,b]of pairs)if(a===v)cycle(b,stack,done);stack.delete(v);done.add(v);}for(const [a]of pairs)cycle(a);
|
||||
return g;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import {graph,units,sideBetween,outlineUnits,solve,violations,canAppend,hint,doorOpen,doorLatched,directionNames} from './rules.mjs';
|
||||
import {CasualAudio} from './audio.mjs';
|
||||
// The build embeds this module together with the local rule file and JSON.
|
||||
const pack=PACK;const playLevels=(pack.playOrder||pack.levels.map(l=>l.id)).map(id=>pack.levels.find(l=>l.id===id));const total=playLevels.length;
|
||||
const BOARD_FONT='ui-rounded, "SF Pro Rounded", "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", system-ui, -apple-system, "Segoe UI", sans-serif';
|
||||
const UI_THEME={cell:'#E6E7EC',ink:'#23243A',primary:'#6558F5',bright:'#7D72FF',danger:'#E5484D',hint:'#F5A524',ring:'#DFE1EC'};let toastTimer=null;let channelViews=[];let cellViews=[];let inputKind="mouse";
|
||||
const $=id=>document.getElementById(id),canvas=$('board'),ctx=canvas.getContext('2d');
|
||||
let level,solution,path=[],index=0,selected='入门',polygons=[],centers=new Map(),unit=1,focus=[],drag=false,lastPoint=null,cleared=false;
|
||||
const memoryKey='variant:campaign-200-r2:v1';const sound=new CasualAudio(window,localStorage,memoryKey+':sound');let completed=[];try{completed=JSON.parse(localStorage.getItem(memoryKey)||'[]');}catch{}
|
||||
const labels=new Map();let started=0,firstDrawAt=null,record=null;let records=[];try{records=JSON.parse(localStorage.getItem(memoryKey+':records')||'[]');}catch{}
|
||||
function 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{}}
|
||||
function setStatus(s){$('status').textContent=s;$('status').className='toast show';if(toastTimer)clearTimeout(toastTimer);toastTimer=setTimeout(()=>{$('status').className='toast';},1800);}function showPlay(){if(level.tutorial&&!completed.includes(level.id)){$('rule-text').textContent=level.caption;$('rules-sheet').hidden=false;}$('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';}
|
||||
function modeMarkers(){labels.clear();let ids=[...(level.relations||[]).flatMap(r=>[r.from,r.to]),...(level.orderChains||[]).flat()];for(const id of ids)if(!labels.has(id))labels.set(id,String.fromCharCode(65+labels.size));}
|
||||
function makeRecord(){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,elapsedSeconds:0,firstError:null,backtracks:0,restarts:0,hints:0,completed:false,reasoning:''};}
|
||||
function start(i,open=true){if(record)saveRecord();index=i;level=playLevels[i];solution=solve(level)[0];if(!solution)throw Error('没有合法路线');path=[];focus=[];cleared=false;started=performance.now();firstDrawAt=null;record=makeRecord();$('reasoning').value='';modeMarkers();$('goal').textContent=level.title;$('caption').textContent=level.caption||'先观察,再画;错了可以退回。';$('title').textContent='数字一笔';$('rule-text').textContent=rulesFor(level);try{localStorage.setItem(memoryKey+':current',level.id);}catch{}setStatus('');renderConstraints();renderSelectors();if(open)showPlay();draw();}
|
||||
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=playLevels.filter(l=>l.chapter===chapter.id);count.textContent=chapterLevels.filter(l=>completed.includes(l.id)).length+' / '+chapterLevels.length;head.append(title,count);const grid=document.createElement('div');grid.className='level-grid';for(const l of chapterLevels){const i=playLevels.indexOf(l),button=document.createElement('button');button.levelId=l.id;button.textContent=String(playLevels.indexOf(l)+1);button.title=l.title;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+'-'+(playLevels.filter(l=>l.chapter===level.chapter).indexOf(level)+1);$('level-sub').textContent=level.chapterTitle+' · 第'+(index+1)+'关'+(level.tutorial?' · 引导':'');$('progress').textContent=completed.filter(id=>playLevels.some(l=>l.id===id)).length+' / '+total;$('overall-fill').style.width=(completed.filter(id=>playLevels.some(l=>l.id===id)).length/total*100)+'%';$('btn-continue').textContent='继续 · '+level.chapter+'-'+(playLevels.filter(l=>l.chapter===level.chapter).indexOf(level)+1);$('level-progress').replaceChildren();for(const l of playLevels.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);}
|
||||
}
|
||||
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(' ');}
|
||||
function renderConstraints(){$('constraints').replaceChildren();}
|
||||
function localGeometry(){
|
||||
const raw=[],center=new Map();for(const c of level.cells){if(level.geometry==='hex'){
|
||||
// Rotate the axial strip into portrait orientation; adjacency remains axial.
|
||||
const x=1.5*c.r,y=Math.sqrt(3)*(c.q+c.r/2);center.set(c.id,[x,y]);let p=[];for(let a=0;a<6;a++){const angle=a*Math.PI/3;p.push([x+Math.cos(angle),y+Math.sin(angle)]);}raw.push({id:c.id,p});
|
||||
}else{const u=units(c);center.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]);for(const [x,y]of u)raw.push({id:c.id,p:[[x,y],[x+1,y],[x+1,y+1],[x,y+1]]});}}
|
||||
return {raw,center};
|
||||
}
|
||||
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-height*.06)/(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);if(level.tutorial&&!bad.length&&!cleared)focus=path.length?[solution[path.length]]:level.cells.filter(c=>c.direction).map(c=>c.id);const badIds=new Set(bad.flatMap(b=>b.cells));channelViews=[];cellViews=[];
|
||||
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=badIds.has(c.id)?UI_THEME.danger:alternate?(c.category==='circle'?'#64acf3':'#ffae55'):cleared?UI_THEME.bright:visited||start?UI_THEME.primary:UI_THEME.cell;const foreground=badIds.has(c.id)||!alternate&&(visited||start)?'#FFFFFF':UI_THEME.ink;const cellUnit=level.geometry==='hex'?Math.sqrt(3)*unit:unit;cellViews.push({cell:c.id,fill,error:badIds.has(c.id),category:c.category||null,cellUnit});if(focus.includes(c.id))paintCell(c,cellUnit*.008,UI_THEME.hint);if(ring)paintCell(c,cellUnit*.045,visited||start?UI_THEME.ring:UI_THEME.ink);paintCell(c,cellUnit*.05+(ring?Math.max(2,cellUnit*.05):0),fill);
|
||||
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,cellUnit*ratio));ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillStyle=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});}
|
||||
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);}
|
||||
else 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.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);}
|
||||
}
|
||||
for(const edge of level.walls||[])drawBarrier(edge,'wall');for(const d of level.doors||[])drawBarrier(d.edge,doorLatched(d,path)?'latched':doorOpen(d,path)?'open':'closed');
|
||||
updateTutorial(bad);$('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===playLevels.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];const radius=Math.max(2,Math.sqrt(3)*unit*.18-inset*.2),tangent=Math.min(radius/Math.sqrt(3),Math.hypot(a[0]-b[0],a[1]-b[1])*.3);const toward=p=>{const length=Math.hypot(p[0]-b[0],p[1]-b[1]);return [b[0]+(p[0]-b[0])*tangent/length,b[1]+(p[1]-b[1])*tangent/length];};const before=toward(a),after=toward(d);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;}
|
||||
const boundary=outlineUnits(c).map(([x,y])=>[origin[0]+x*unit,origin[1]+y*unit]);
|
||||
const insetPoints=boundary.map((b,i)=>{const a=boundary[(i+boundary.length-1)%boundary.length],d=boundary[(i+1)%boundary.length];const n=([x,y],[xx,yy])=>{const len=Math.hypot(xx-x,yy-y);return [-(yy-y)/len,(xx-x)/len];};const n1=n(a,b),n2=n(b,d);return [b[0]+(n1[0]+n2[0])*inset,b[1]+(n1[1]+n2[1])*inset];});
|
||||
ctx.beginPath();for(let i=0;i<insetPoints.length;i++){const a=insetPoints[(i+insetPoints.length-1)%insetPoints.length],b=insetPoints[i],d=insetPoints[(i+1)%insetPoints.length];const cross=(b[0]-a[0])*(d[1]-b[1])-(b[1]-a[1])*(d[0]-b[0]);const r=cross>0?Math.min(radius,Math.hypot(b[0]-a[0],b[1]-a[1])/2,Math.hypot(d[0]-b[0],d[1]-b[1])/2):0;const toward=(p)=>{const len=Math.hypot(p[0]-b[0],p[1]-b[1]);return [b[0]+(p[0]-b[0])*r/len,b[1]+(p[1]-b[1])*r/len];};const before=toward(a),after=toward(d);if(i===0)ctx.moveTo(...before);else ctx.lineTo(...before);ctx.quadraticCurveTo(...b,...after);}ctx.closePath();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=(level.geometry==='hex'?Math.sqrt(3)*unit: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;}
|
||||
function sideOf(c,id){return sideBetween(level,c.id,id);}
|
||||
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])]||[0,0]);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])]||[0,0]);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();}
|
||||
function drawBarrier(edge,kind){const isOpen=kind==='open'||kind==='latched';const left=polygons.filter(p=>p.id===edge[0]),right=polygons.filter(p=>p.id===edge[1]);let commons=[];for(const a of left)for(const b of right)for(let i=0;i<a.p.length;i++)for(let j=0;j<b.p.length;j++){const p=a.p[i],q=a.p[(i+1)%a.p.length],r=b.p[j],t=b.p[(j+1)%b.p.length];if(Math.hypot(p[0]-t[0],p[1]-t[1])<.01&&Math.hypot(q[0]-r[0],q[1]-r[1])<.01)commons.push([p,q]);}for(const common of commons){const [a,b]=common;const point=f=>[a[0]+(b[0]-a[0])*f,a[1]+(b[1]-a[1])*f];ctx.lineCap='butt';ctx.lineWidth=9;ctx.strokeStyle=isOpen?'#23895f':kind==='wall'?'#272638':'#aa4d25';ctx.beginPath();if(isOpen){ctx.moveTo(...point(.05));ctx.lineTo(...point(.26));ctx.moveTo(...point(.74));ctx.lineTo(...point(.95));}else{ctx.moveTo(...point(.05));ctx.lineTo(...point(.95));}ctx.stroke();if(kind==='wall'){ctx.lineWidth=4;ctx.strokeStyle='#ffc542';ctx.setLineDash([5,4]);ctx.beginPath();ctx.moveTo(...point(.05));ctx.lineTo(...point(.95));ctx.stroke();ctx.setLineDash([]);}if(kind!=='wall'){const [x,y]=point(.5);ctx.font='700 '+Math.max(10,Math.min(12,unit*.3))+'px '+BOARD_FONT;ctx.textAlign='center';ctx.fillStyle=isOpen?'#12623f':'#993b1e';ctx.fillText(kind==='latched'?'已过':isOpen?'开':'关',x+10,y-8);}}}
|
||||
function inside(x,y,p){let hit=false;for(let i=0,j=p.length-1;i<p.length;j=i++){const [a,b]=p[i],[c,d]=p[j];if((b>y)!==(d>y)&&x<(c-a)*(y-b)/(d-b)+a)hit=!hit;}return hit;}
|
||||
function hit(x,y){return polygons.find(s=>{if(level.geometry!=='hex')return inside(x,y,s.p);const [cx,cy]=centers.get(s.id),scale=inputKind==='touch'? .92:.86;return inside(x,y,s.p.map(([px,py])=>[cx+(px-cx)*scale,cy+(py-cy)*scale]));})?.id;}
|
||||
function markError(id,message){sound.play('error');if(!record.firstError)record.firstError={step:path.length+1,cell:id,message};setStatus(message);}
|
||||
function advance(id){if(!id||cleared)return;const at=path.indexOf(id);if(at!==-1){if(path.length>=2&&at===path.length-2){record.backtracks++;path.pop();sound.play('undo');focus=[];setStatus('已回退,继续观察剩余格。');draw();}return;}
|
||||
if(!canAppend(level,path,id)){const g=graph(level),adjacent=path.length&&g.adj[g.ids.get(path.at(-1))].includes(g.ids.get(id));if(adjacent||!path.length){markError(id,path.length?(level.walls?.some(e=>e.includes(path.at(-1))&&e.includes(id))?'这是永久墙,不能穿过。':level.doors?.some(d=>d.edge.includes(path.at(-1))&&d.edge.includes(id))?'门现在关闭;绕行落格后会切换。':'同色格不能连续经过,请换另一种颜色。'):'请从数字 1 开始。');}return;}
|
||||
if(firstDrawAt===null){firstDrawAt=performance.now();record.thinkingBeforeFirstDrawSeconds=Math.round((firstDrawAt-started)/1000);}path.push(id);focus=[];const bad=violations(level,path);if(bad.length)markError(id,bad[0].message);else{const cell=level.cells.find(c=>c.id===id);sound.play(cell.clue!==undefined&&cell.clue===path.length?'correct':'move',0,path.length);setStatus('');}if(path.length===level.cells.length&&!bad.length){cleared=true;record.completed=true;record.elapsedSeconds=Math.round((performance.now()-started)/1000);if(!completed.includes(level.id)){completed.push(level.id);try{localStorage.setItem(memoryKey,JSON.stringify(completed));}catch{}}setStatus(index===playLevels.length-1?total+'关完成!':'完成!');sound.play('complete',.23);saveRecord();renderSelectors();}draw();}
|
||||
function point(e){const r=canvas.getBoundingClientRect();return [e.clientX-r.left,e.clientY-r.top];}
|
||||
canvas.addEventListener('pointerdown',e=>{e.preventDefault();sound.unlock();inputKind=e.pointerType||'mouse';drag=true;canvas.setPointerCapture(e.pointerId);lastPoint=point(e);advance(hit(...lastPoint));});
|
||||
canvas.addEventListener('pointermove',e=>{if(!drag)return;e.preventDefault();const p=point(e),a=lastPoint||p,d=Math.hypot(p[0]-a[0],p[1]-a[1]),steps=Math.ceil(d/Math.max(3,unit*.1));for(let i=1;i<=steps;i++)advance(hit(a[0]+(p[0]-a[0])*i/steps,a[1]+(p[1]-a[1])*i/steps));lastPoint=p;});
|
||||
function end(){drag=false;lastPoint=null;}canvas.addEventListener('pointerup',end);canvas.addEventListener('pointercancel',end);
|
||||
$('undo').onclick=()=>{sound.unlock();if(path.length){path.pop();sound.play('undo');record.backtracks++;record.completed=false;cleared=false;focus=[];setStatus('已撤销一步。');draw();}};
|
||||
$('restart').onclick=()=>{sound.unlock();sound.play('restart');path=[];cleared=false;focus=[];record.restarts++;record.completed=false;setStatus('已重开。');draw();};
|
||||
$('hint').onclick=()=>{record.hints++;const h=hint(solution,path);focus=h.kind==='none'?[]:[h.cell];setStatus(h.kind==='backtrack'?'请沿来时路逐格退回黄色边框格,再继续。':h.kind==='restart'?'起点需要调整,请重开并从黄色边框格开始。':h.kind==='none'?'已经完成。':'下一步在黄色边框格;提示不会自动画路。');draw();};
|
||||
$('next').onclick=()=>{if(index+1<playLevels.length)start(index+1);};
|
||||
$('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-DEMO200-试玩记录.json';a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);setStatus('已导出全部试玩记录。');};
|
||||
window.addEventListener('pagehide',saveRecord);
|
||||
$('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;};
|
||||
window.addEventListener('resize',draw);window.visualViewport?.addEventListener('resize',draw);window.addEventListener('orientationchange',draw);let initialId=null;try{initialId=localStorage.getItem(memoryKey+':current');}catch{}let initial=playLevels.findIndex(l=>l.id===initialId);start(initial>=0?initial:0,false);
|
||||
function renderSoundButton(){$('btn-sound').textContent=sound.enabled?'音效开':'音效关';$('btn-sound').title=sound.enabled?'关闭音效':'开启音效';$('btn-sound').setAttribute?.('aria-pressed',String(sound.enabled));}
|
||||
$('btn-sound').onclick=()=>{sound.toggle();renderSoundButton();};renderSoundButton();
|
||||
function updateTutorial(bad){if(!level.tutorial)return;let text=level.caption;if(bad.length)text='规则不符,请沿来时路退回上一格再试。';else if(cleared)text='学会了!接下来试试正式关卡。';else if(level.tutorial==='colour')text=path.length===1?'下一步去橙色块;同色蓝块不能连续走。':path.length===2?'再换回蓝色,完成蓝 → 橙 → 蓝。':level.caption;else if(['straight','vertical','corner'].includes(level.tutorial)){const c=level.cells.find(c=>c.direction);const at=path.indexOf(c.id);if(at>=0&&at===path.length-1)text=level.tutorial==='straight'?'沿横线从另一侧离开,不能拐弯。':level.tutorial==='vertical'?'沿竖线从另一侧离开,不能向左右走。':'沿图中拐角连接的另一侧离开。';}$('caption').textContent=text;}
|
||||
// Read-only snapshot used for interaction verification; never supplies an answer.
|
||||
window.variantSnapshot=()=>({mode:pack.mode,level:level.id,order:level.order,displayOrder:index+1,tutorial:level.tutorial||null,soundEnabled:sound.enabled,lastSound:sound.lastEvent,chapter:level.chapter,records:records.length,cellViews,geometry:level.geometry,directionMarkers:channelViews,screen:$('screen-play').className.includes('hidden')?'select':'play',path:[...path],violations:violations(level,path),cleared,centers:Object.fromEntries(centers),unit,openedGates:(level.gates||[]).map(g=>path.includes(g.switchCell)),doors:(level.doors||[]).map(d=>doorOpen(d,path)),latchedDoors:(level.doors||[]).map(d=>doorLatched(d,path))});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user