@@ -1,7 +1,9 @@
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 , refreshBrightBoard , brightOverlay , brightAnimating } from './upstream/ui.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' ;
@@ -11,22 +13,38 @@ 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 = '' ;
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 ;
let renderedFrames = 0 , clearCelebration = null , selectWasOpen = false ;
function requestPaint ( ) { if ( visible && ! raf ) raf = requestAnimationFrame ( draw ) ; }
setViewInvalidator ( requestPaint ) ;
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 ( ) { 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 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 ( ) < pressedUntil } ) ; 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 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 )
@@ -41,31 +59,47 @@ function draw() {
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 + 24 , 30 , C . primary ) ;
text ( '每块一次 · 数字对步数' , width / 2 , top + 61 , 16 ) ;
text ( '已完成 ' + $ ( 'progress' ) . textContent , width / 2 , top + 90 , 14 , C . inkSecondary ) ;
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 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 ( ) ;
panel ( ctx , 12 , top + 110 , width - 24 , rows * 57 + 4 , 24 , C . stage , 3 ) ;
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 ) ) ;
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 = last < layout . columns ? ( layout . columns - last ) * ( layout . w + layout . gap ) / 2 : 0 ;
const x = layout . x + offset + column * ( layout . w + layout . gap ) , y = layout . y + row * ( layout . h + layout . gap ) ;
const label = node . textContent + ( node . className . includes ( 'cleared' ) ? ' ✓' : '' ) ;
levelCard ( ctx , node . textContent , x , y , layout . w , layout . h , { completed : node . className . includes ( 'cleared' ) , current : node . className . includes ( 'current' ) } ) ;
buttons . push ( { label , x , y , w : layout . w , h : layout . h , run : ( ) => node . 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 ) ) ;
button ( '下一页' , width - 108 , height - bottom - 106 , 90 , 38 , ( ) => page = Math . min ( pages - 1 , page + 1 ) , { enabled : page < pages - 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 {
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 ) ;
@@ -74,18 +108,6 @@ function draw() {
stage ( ctx , width , top + 82 , $ ( 'board' ) . clientHeight , dense ) ;
refreshBrightBoard ( Date . now ( ) ) ;
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 ) ;
ctx . save ( ) ; ctx . translate ( 0 , top + 82 ) ; brightOverlay ( ctx ) ; ctx . restore ( ) ;
} 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 ? $ ( 'status' ) . textContent : '按住滑动连线 · 沿来时路逐格回退' ) ;
@@ -121,7 +143,10 @@ function draw() {
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 , preferences . reducedMotion ? lesson . duration : loopLessonTime ( 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 ) ;
const lessonRoute = lessonState . progress < 1 ? lessonState . previous : lessonState . route ;
const lessonBoardKey = JSON . stringify ( [ lesson . id , lessonRoute , area . w , area . h , d ] ) ;
if ( lessonBoardCache ? . key !== lessonBoardKey ) { lessonBoardRenders ++ ; lessonBoardCache = { key : lessonBoardKey , view : renderLesson ( lessonCanvas , lesson . level , lessonRoute , area . w , area . h ) } ; }
const view = lessonBoardCache . view ;
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 = 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 ;
@@ -140,6 +165,11 @@ function draw() {
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 < CLEAR _DURATION ) ;
@@ -155,7 +185,7 @@ function event(p) { return { clientX: p.clientX, clientY: p.clientY - top - 82,
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 ) { pressedLabel = b . label ; pressedUntil = Date . now ( ) + 120 ;
if ( b . enabled ) { hintBaseKey = '' ; pressedLabel = b . label ; pressedUntil = Date . now ( ) + 120 ;
b . run ( ) ;
requestPaint ( ) ;
}
@@ -173,13 +203,14 @@ 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 ( ) ;
visible = true ; lessonStarted = Date . now ( ) ; settingsOpen = false ; resumeGameplay ( ) ;
resize ( ) ;
requestPaint ( ) ;
} } ) ;
wx . onAudioInterruptionBegin ? . ( stopAudio ) ;
wx . onWindowResize ? . ( resize ) ;
// 只读状态,供微信开发者工具和发布包自动验收定位问题。
GameGlobal . numberSwipeR2 = { snapshot : ( ) => ( { ... window . variantSnapshot ( ) , theme : 'bright-classroom' , fonts : { ... fontDiagnostics } , renderedFrames , 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 } ) ) } ;
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 ( ) ;