import { FLOWER_LEVEL, flowerTrackEmphasis } from '../../src/flower-study.js'; 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 total = FLOWER_LEVEL.tiles.length; const taps = new TapQueue(); const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; let game = new Game(FLOWER_LEVEL), view, width, height, animation = null, turning = null, hint = null, path = null, frame = 0; function stats() { const busy = !!(animation || turning), preview = $('pattern-only').checked; $('remaining').textContent = game.tiles.length; $('progress').textContent = game.won ? '一朵花,全部盛放' : `已放行 ${total - game.tiles.length} / ${total}${taps.ids.length ? ` · 待出发 ${taps.ids.length}` : ''}`; for (const id of ['left', 'right', 'hint']) $(id).disabled = busy || game.won || preview; $('undo').disabled = busy || !game.history.length || preview; $('pattern-only').disabled = busy; if (game.won && !busy) $('message').textContent = '十枚棋子都已离场,完整的重瓣花开了。'; } function draw(now = performance.now()) { cancelAnimationFrame(frame); if (!view) return; context.clearRect(0, 0, width, height); const state = { hint, path, reduced, queued: taps.ids, trackEmphasis: $('all-tracks').checked ? null : flowerTrackEmphasis(game.orientation), secondaryTrackColor: '#c6c9c7' }; 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, $('pattern-only').checked ? [] : game.tiles, view, state); if (!animation && !turning && !$('pattern-only').checked) { const next = taps.take(game.tiles); if (next) { tap(next); return; } } stats(); if (animation || turning) frame = requestAnimationFrame(draw); } function resize() { 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; } function tap(tile) { if (game.won || $('pattern-only').checked) return; if (animation || turning) { if (taps.add(tile.id, animation?.route.tile.id)) draw(); return; } hint = null; path = null; const result = game.tap(tile.id); if (result.status === 'exit') { animation = { route: result, start: performance.now(), duration: reduced ? 100 : exitTiming(result).duration }; $('message').textContent = result.steps.length > 2 ? '沿着内外花瓣和环带,绕到另一侧的出口。' : '先让开这个出口,花瓣里的长路就有了空间。'; playSound('exit'); } else { taps.clear(); if (result.travelPoints) animation = { route: result, start: performance.now(), duration: reduced ? 150 : MOTION.collisionMs }; $('message').textContent = '出口还有一枚棋子。先给它让路,或转一下底盘再试。'; playSound('hit'); } draw(); } function rotate(direction) { if (animation || turning || game.won || $('pattern-only').checked) 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)); turning = { direction, start: performance.now(), duration: reduced ? 100 : MOTION.rotationMs }; $('message').textContent = opened.length ? '花心换了一种连接,新的出口打开了。' : '花瓣仍然整齐,花心的连接已经换了方向。'; 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 = () => { clearEffects(); $('pattern-only').checked = false; game = new Game(FLOWER_LEVEL); $('message').textContent = '先放走朝外的两枚,给花瓣里的长路让位置。'; draw(); }; $('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(); }; $('all-tracks').onchange = () => draw(); $('pattern-only').onchange = () => { if (animation || turning) $('pattern-only').checked = false; hint = null; path = null; draw(); }; 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);