import releaseInfo from '../release-info.json' with {type:'json'}; import {chapterLayout,levelCard} from './level-select.mjs'; import {CLEAR_DURATION,clearProgress,drawClearCelebration} from './bright-classroom/celebration.mjs'; import {stopAudio,audioDiagnostics} from './audio.mjs'; import { mainCanvas, boardCanvas, document, window, PACK, setViewInvalidator } from './platform.mjs'; import {renderLesson,hintView,resumeGameplay,refreshBrightBoard,brightOverlay,brightAnimating} from './upstream/ui.mjs'; import {drawHint} from './hints.mjs'; import {lessonKeys,makeLesson,lessonFrame,loopLessonTime} from './lessons.mjs'; import {tutorials} from './copy.mjs'; import {C,textFont,label,panel,fontDiagnostics} from './bright-classroom/theme.mjs'; import {background,buttonArt,stage,statusPill} from './bright-classroom/ui.mjs'; import {preferences} from './bright-classroom/board-renderer.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='',lessonBoardCache=null,lessonBoardRenders=0; const hintCanvas=wx.createCanvas(),hintContext=hintCanvas.getContext('2d'); let hintAnimation={key:'',started:0}; let settingsOpen=false,pressedLabel='',pressedUntil=0; let renderedFrames=0,clearCelebration=null,selectWasOpen=false; function requestPaint(){if(visible&&!raf)raf=requestAnimationFrame(draw);} const hintBaseCanvas=wx.createCanvas();let hintBaseKey='',hintBaseHits=0; setViewInvalidator(()=>{hintBaseKey='';requestPaint();}); try{preferences.reducedMotion=wx.getStorageSync('number-swipe:reduced-motion')==='1';}catch{} const ordered = PACK.playOrder.map(id => PACK.levels.find(l => l.id === id)); function resize() { hintBaseKey=''; 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?.(); requestPaint(); } function text(value, x, y, size = 16, color = C.ink) { label(ctx,value,x,y,size,color); } function splitLines(value,maxWidth,size=16){textFont(ctx,size);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 } = {}) { buttonArt(ctx,label,x,y,w,h,{enabled,primary,pressed:!preferences.reducedMotion&&pressedLabel===label&&Date.now() section.children[1].children); } function paintHintLayer(hint,boardState,d){ 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); ctx.save();ctx.translate(0,top+82);brightOverlay(ctx);ctx.restore(); }else hintAnimation={key:'',started:0}; } function draw() { raf=0; if (!visible) return; renderedFrames++; const d = window.devicePixelRatio; ctx.setTransform(d, 0, 0, d, 0, 0); const state=window.variantSnapshot(); if(!state.cleared||state.screen!=='play')clearCelebration=null; else if(!clearCelebration||clearCelebration.key!==state.level)clearCelebration={key:state.level,started:Date.now(),requested:audioDiagnostics.completeRequestedAt}; if(clearCelebration&&state.soundEnabled&&audioDiagnostics.completeRequestedAt===clearCelebration.requested){ const actual=audioDiagnostics.completeStartedAt; clearCelebration.started=actual??(Date.now()-clearCelebration.requested<500?null:clearCelebration.requested); } const animationHint=hintView(); const cacheEligible=Boolean(animationHint)&&state.screen==='play'&&!state.cleared&&!settingsOpen&&$('rules-sheet').hidden&&!brightAnimating()&&Date.now()>=pressedUntil; const baseKey=JSON.stringify([state.level,state.path,width,height,d,preferences.reducedMotion]); if(cacheEligible&&hintBaseKey===baseKey){ hintBaseHits++;ctx.drawImage(hintBaseCanvas,0,0,width,height);paintHintLayer(animationHint,state,d); if(!preferences.reducedMotion)requestPaint();return; } const currentLevel=ordered.find(l=>l.id===state.level); const dense=(currentLevel.features||[]).length>=3||state.cellViews?.length>=30; background(ctx,width,height,!$('screen-play').className.includes('hidden')&&dense); buttons = []; const select = $('screen-play').className.includes('hidden'); if (select) { text('数字一笔', width / 2, top + 18, 30,C.primary); text('总进度 '+$('progress').textContent, width / 2, top + 42, 12,C.inkSecondary); button('设置',width-72,top+4,56,34,()=>settingsOpen=true); const levels=levelButtons(),pages=PACK.chapters.length; if(!selectWasOpen)page=Math.floor((state.displayOrder-1)/10); selectWasOpen=true;page=Math.max(0,Math.min(pages-1,page)); const chapter=PACK.chapters[page],chapterLevels=ordered.filter(l=>l.chapter===chapter.id); const nodes=chapterLevels.map(l=>levels[ordered.indexOf(l)]); const layout=chapterLayout(width,height,top,bottom,nodes.length); text('第 '+(page+1)+' 章 · '+chapter.title.replace(/^第.*?章[·\s]*/,''),width/2,top+66,18,C.primary); text(nodes.filter(n=>n.className.includes('cleared')).length+' / '+nodes.length+' 已通过',width/2,top+89,13,C.inkSecondary); nodes.forEach((node,j)=>{ const row=Math.floor(j/layout.columns),column=j%layout.columns,last=nodes.length-row*layout.columns; const offset=lastnode.click(),enabled:true}); }); button('上一页', 18, height - bottom - 106, 90, 38, () => page = Math.max(0, page - 1),{enabled:page>0}); text((page + 1) + ' / ' + pages, width / 2, height - bottom - 87); button('下一页', width - 108, height - bottom - 106, 90, 38, () => page = Math.min(pages - 1, page + 1),{enabled:page click('btn-continue'), { primary: true }); button('反馈', width - 98, height - bottom - 56, 80, 42, feedback); } else { selectWasOpen=false; button('选关', 16, top, 62, 36, () => click('btn-back')); panel(ctx,width/2-60,top+2,120,32,16,C.coral,3); text('第 ' + window.variantSnapshot().displayOrder + ' 关', width / 2, top + 18, 18,C.onPrimary); button($('btn-sound').textContent, width - 94, top, 78, 36, () => click('btn-sound')); statusPill(ctx,84,top+43,width-100,31,state.path.length,currentLevel.cells.length-state.path.length); stage(ctx,width,top+82,$('board').clientHeight,dense); refreshBrightBoard(Date.now()); ctx.drawImage(boardCanvas, 0, top + 82, width, $('board').clientHeight); 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?$('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?C.success:C.inkSecondary)); const actionHeight=height<700?54:70; if(cleared){ button(!$('next').hidden?'下一关 →':'返回选关',16,foot+22,width-32,actionHeight,()=>click(!$('next').hidden?'next':'btn-back'),{primary:true}); }else{ const bw=(width-48)/3; button('重开',16,foot+22,bw,actionHeight,()=>click('restart')); button('撤销',24+bw,foot+22,bw,actionHeight,()=>click('undo'),{enabled:!$('undo').disabled}); button('指路',32+2*bw,foot+22,bw,actionHeight,()=>click('hint'),{primary:true}); } button('规则', 16, top + 43, 56, 30, () => click('btn-rules')); } if(clearCelebration&&clearCelebration.started!==null&&!preferences.reducedMotion&&!settingsOpen&&$('rules-sheet').hidden)drawClearCelebration(ctx,width,top+82,$('board').clientHeight,Date.now()-clearCelebration.started); if (!$('rules-sheet').hidden) { ctx.fillStyle = 'rgba(67,56,47,.32)'; ctx.fillRect(0, 0, width, height); const panelTop=top+12,panelHeight=Math.max(240,height-panelTop-bottom-20); panel(ctx,16,panelTop,width-32,panelHeight,24,C.surface,5); 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;nview.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=C.danger;ctx.lineWidth=4;ctx.stroke();ctx.setLineDash([]);text('×',area.x+(a[0]+b[0])/2,area.y+(a[1]+b[1])/2,26,C.danger);} 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=C.primary;ctx.lineWidth=6;ctx.lineCap='round';ctx.stroke();}ctx.beginPath();ctx.arc(x,y,10,0,Math.PI*2);ctx.strokeStyle=C.hint;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,C.primary)); 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:rulePageclick('close-rules'),{primary:true}); } if(settingsOpen){ ctx.fillStyle='rgba(67,56,47,.32)';ctx.fillRect(0,0,width,height);buttons=[]; const y=Math.max(top,height/2-150);panel(ctx,24,y,width-48,280,24,C.surface,5); text('设置',width/2,y+32,24); button($('btn-sound').textContent,44,y+65,width-88,48,()=>click('btn-sound')); button('减少动态:'+(preferences.reducedMotion?'开':'关'),44,y+125,width-88,48,()=>{preferences.reducedMotion=!preferences.reducedMotion;wx.setStorageSync('number-swipe:reduced-motion',preferences.reducedMotion?'1':'0');}); button('完成',44,y+205,width-88,48,()=>settingsOpen=false,{primary:true}); } if(cacheEligible){ hintBaseCanvas.width=mainCanvas.width;hintBaseCanvas.height=mainCanvas.height; hintBaseCanvas.getContext('2d').drawImage(mainCanvas,0,0);hintBaseKey=baseKey; }else hintBaseKey=''; if(!settingsOpen&&$('rules-sheet').hidden&&state.screen==='play')paintHintLayer(animationHint,state,d); const liveBoard=!settingsOpen&&$('rules-sheet').hidden&&state.screen==='play'; const liveLesson=!settingsOpen&&!$('rules-sheet').hidden&&!preferences.reducedMotion&&Boolean(lesson); const liveClear=liveBoard&&clearCelebration&&!preferences.reducedMotion&&(clearCelebration.started===null||Date.now()-clearCelebration.started { 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){hintBaseKey='';pressedLabel=b.label;pressedUntil=Date.now()+120; b.run(); requestPaint(); } return; } if (settingsOpen || !$('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)); requestPaint(); }); wx.onTouchMove(e => { const p = e.touches.find(p => p.identifier === pointer); if (p){ $('board').handlers.pointermove?.(event(p));requestPaint();} }); 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);raf=0; pointer = null; $('board').handlers.pointercancel?.(); window.handlers.pagehide?.(); }); wx.onShow(() => { if (!visible) { visible = true;lessonStarted=Date.now();settingsOpen=false;resumeGameplay(); resize(); requestPaint(); } }); wx.onAudioInterruptionBegin?.(stopAudio); wx.onWindowResize?.(resize); // 只读状态,供微信开发者工具和发布包自动验收定位问题。 GameGlobal.numberSwipeR2 = { snapshot: () => ({...window.variantSnapshot(),theme:'bright-classroom',release:releaseInfo,fonts:{...fontDiagnostics},renderedFrames,hintBaseHits,lessonBoardRenders,selection:{chapter:page+1,chapters:PACK.chapters.length},reducedMotion:preferences.reducedMotion,boardRect:{x:0,y:top+82,w:width,h:$('board').clientHeight},hint:hintView(),audio:{...audioDiagnostics},rules: $('rule-text').textContent,lesson:!$('rules-sheet').hidden&&lessonState?{key:lesson?.key,finished:lessonState.finished,duration:lesson?.duration,path:[...lessonState.route],message:lessonState.message}:null}), buttons: () => buttons.map(({ label, x, y, w, h, enabled }) => ({ label, x, y, w, h, enabled })) }; resize(); requestPaint();