import { Game, available, trace, solveRotations } from '../../src/core.js'; import { TapQueue } from '../../src/tap-queue.js'; import { layout, hitCell } from '../../src/geometry.js'; import { CanvasPainter, drawScene } from '../../src/renderer.js'; import { TILE_ATLAS } from '../../src/sprites.js'; import { exitTiming } from '../../src/exit-animation.js'; import { MOTION } from '../../src/design-tokens.js'; import { unlockAudio, playSound } from '../../src/audio.js'; const $ = id => document.getElementById(id); const canvas = $('board'), context = canvas.getContext('2d'), painter = new CanvasPainter(context); const taps = new TapQueue(); const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; let levels = [], current = null, game = null, view, width, height, animation = null, turning = null, hint = null, path = null, frame = 0; const pct = value => `${Math.round(value * 100)}%`; function describeWitness(actions, tiles) { // Compress the solver reference into "+1 · 3 枚 · +1 · 2 枚 · −1 · 4 枚". const parts = []; let taps = 0; for (const action of actions) { if (action.type === 'rotate') { if (taps) parts.push(`${taps} 枚`); taps = 0; parts.push(action.direction === 1 ? '右转' : '左转'); } else taps++; } if (taps) parts.push(`${taps} 枚`); return `参考解:${parts.join(' · ')}(共 ${tiles.length} 枚)`; } function showEvidence() { const p = current.prototype, s = p.stats; const rows = [ ['底盘 / 棋子', `${current.cells.length} 格 · ${s.pieces} 枚 · ${p.family} r${p.radius} · seed ${p.seed}`], ['最少旋转', `${s.rotations} 次`], ['开局被锁住', `${s.lockedAtStart} / ${s.pieces} 枚(${pct(s.lockedShare)})`], ['每次旋转释放', s.releasePerTurn.join(' / ') + ' 枚'], ['首转方向', s.firstTurnDirections === 1 ? '只有一侧能开路' : '两侧都能开路'], ['主角路线', `${p.lock.heroBends} 弯`], ['随机玩家 动作 ÷ 棋子', p.monkeyActionsPerPiece.toFixed(2) + '(v0.5 全库 1.67)'], ]; $('evidence').innerHTML = rows.map(([k, v]) => `
${k}
${v}
`).join(''); $('witness').textContent = describeWitness(current.experience.actions, current.tiles); } // Stuck-state idle hint: when nothing can leave, wait, then point at the turn // that opens something. This is the §07 rule — the direction is never shown // instantly, so "which way" stays the player's move first. let idleTimer = null; function clearIdleHint() { clearTimeout(idleTimer); idleTimer = null; for (const id of ['left', 'right']) $(id).classList.remove('hinted'); } function scheduleIdleHint() { clearIdleHint(); if (!game || game.won || animation || turning || available(game.level, game.tiles).length) return; idleTimer = setTimeout(() => { const action = solveRotations({ ...game.level, tiles: game.tiles, moveLimit: game.moves }).actions[0]; if (!action || action.type !== 'rotate') return; $(action.direction === 1 ? 'right' : 'left').classList.add('hinted'); $('message').textContent = `这个朝向没有出口。试试向${action.direction === 1 ? '右' : '左'}转。`; }, 3000); } function stats() { const busy = !!(animation || turning); if (!busy) scheduleIdleHint(); $('remaining').textContent = game.tiles.length; $('progress').textContent = game.won ? '清盘 · 三转到位' : `已放行 ${current.tiles.length - game.tiles.length} / ${current.tiles.length} · 旋转 ${game.rotations}${taps.ids.length ? ` · 待出发 ${taps.ids.length}` : ''}`; for (const id of ['left', 'right', 'hint']) $(id).disabled = busy || game.won; $('undo').disabled = busy || !game.history.length; if (game.won && !busy) $('message').textContent = `清盘。用了 ${game.rotations} 次旋转,参考解 ${current.prototype.stats.rotations} 次。`; } function draw(now = performance.now()) { cancelAnimationFrame(frame); if (!view || !game) return; context.clearRect(0, 0, width, height); const state = { hint, path, reduced, queued: taps.ids }; if (animation) { const progress = Math.min(1, (now - animation.start) / animation.duration); state.animation = { route: animation.route, progress }; if (progress >= 1) animation = null; } if (turning) { const progress = Math.min(1, (now - turning.start) / turning.duration); state.boardAngle = -turning.direction * Math.PI / 3 * Math.pow(1 - progress, 3); if (progress >= 1) turning = null; } drawScene(painter, game.level, game.tiles, view, state); if (!animation && !turning) { const next = taps.take(game.tiles); if (next) { tap(next); return; } } stats(); if (animation || turning) frame = requestAnimationFrame(draw); } function resize() { if (!game) return; const rect = canvas.getBoundingClientRect(), dpr = Math.min(devicePixelRatio || 1, 3); width = rect.width; height = rect.height; canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); context.setTransform(dpr, 0, 0, dpr, 0, 0); view = layout(game.level.cells, width, height); draw(); } function clearEffects() { taps.clear(); cancelAnimationFrame(frame); animation = null; turning = null; hint = null; path = null; clearIdleHint(); } canvas.addEventListener('pointerdown', clearIdleHint, { passive: true }); for (const id of ['left', 'right', 'undo', 'reset', 'hint']) $(id).addEventListener('pointerdown', clearIdleHint, { passive: true }); function load(index) { current = levels[index]; clearEffects(); game = new Game(current); const free = available(game.level, game.tiles).length; $('message').textContent = free ? `开局有 ${free} 枚能走,其余都在等旋转。` : '开局没有任何棋子能走。转一下,看哪一层松开了。'; $('meta').textContent = `${current.cells.length} 格 · ${current.tiles.length} 枚 · 最少 ${current.prototype.stats.rotations} 转`; showEvidence(); resize(); } function tap(tile) { if (game.won) return; if (animation || turning) { if (taps.add(tile.id, animation?.route.tile.id)) draw(); return; } hint = null; path = null; const before = new Set(available(game.level, game.tiles).map(t => t.id)); const result = game.tap(tile.id); if (result.status === 'exit') { animation = { route: result, start: performance.now(), duration: reduced ? 100 : exitTiming(result).duration }; const opened = available(game.level, game.tiles).filter(t => !before.has(t.id)).length; $('message').textContent = opened ? `移开这一枚,${opened} 枚棋子的路通了。` : result.steps.length > 3 ? '沿着轨道绕出去了。' : '出去了。'; playSound('exit'); } else { taps.clear(); if (result.travelPoints) animation = { route: result, start: performance.now(), duration: reduced ? 150 : MOTION.collisionMs }; $('message').textContent = result.status === 'loop' ? '这条路线绕回原处。转一下底盘换条路。' : '前方有棋子挡住。它在等另一个朝向。'; playSound('hit'); } draw(); } function rotate(direction) { if (animation || turning || game.won) return; taps.clear(); const before = new Set(available(game.level, game.tiles).map(t => t.id)); game.rotate(direction); hint = null; path = null; const opened = available(game.level, game.tiles).filter(t => !before.has(t.id)).length; turning = { direction, start: performance.now(), duration: reduced ? 100 : MOTION.rotationMs }; $('message').textContent = opened ? `这一转松开了 ${opened} 枚。` : '这个朝向什么都没松开。再转,或转回去。'; playSound('rotate'); draw(); } canvas.addEventListener('pointerup', event => { const rect = canvas.getBoundingClientRect(), cell = hitCell(game.level.cells, view, event.clientX - rect.left, event.clientY - rect.top); const tile = cell && game.tiles.find(t => t.q === cell.q && t.r === cell.r); if (tile) tap(tile); }); $('left').onclick = () => rotate(-1); $('right').onclick = () => rotate(1); $('undo').onclick = () => { if (!animation && !turning && game.undo()) { clearEffects(); $('message').textContent = '回到上一步。'; draw(); } }; $('reset').onclick = () => load(Number($('level').value)); $('hint').onclick = () => { const action = solveRotations({ ...game.level, tiles: game.tiles, moveLimit: game.moves }).actions[0]; if (!action) return; if (action.type === 'rotate') { $('message').textContent = `向${action.direction === 1 ? '右' : '左'}转一次。`; } else { hint = action.id; path = trace(game.level, game.tiles.find(t => t.id === hint), game.tiles); $('message').textContent = '这一枚现在可以走。'; } draw(); }; $('level').onchange = () => load(Number($('level').value)); document.addEventListener('pointerdown', unlockAudio, { passive: true }); const atlas = new Image(); atlas.onload = () => { painter.atlas = atlas; draw(); }; atlas.src = '../../' + TILE_ATLAS; new ResizeObserver(resize).observe(canvas); const response = await fetch('../../levels/prototypes/d5.json'); levels = (await response.json()).levels; $('level').innerHTML = levels.map((level, i) => ``).join(''); load(0);