112 lines
6.2 KiB
JavaScript
112 lines
6.2 KiB
JavaScript
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);
|