Files
cabinwandClaude Opus 5 643cba0ed9 feat(levels): D5 three-rotation prototype
The shipped 1000 levels have a hole: 568 of them need no rotation at all, and
none needs more than two — the mechanic the game is built on is mostly
decoration. This fills the empty band with levels that need at least three.

createDeepLock builds a four-piece chain where each link only releases at a
later orientation, and growDeepLevel packs the rest of the board around it
without opening a shortcut. Candidate pools are built per orientation and left
untruncated; ranking by bend count first starved the packer and capped levels
at four to six pieces instead of twelve.

deadTurnAudit enumerates every reachable (orientation, remaining-set) state to
prove no dead ends exist, and counts how long a player can be left with
nothing to do — solvable and feeling solvable are different things, which is
what "level 2 is impossible" turned out to be about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 14:00:33 +08:00

160 lines
9.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]) => `<div><dt>${k}</dt><dd>${v}</dd></div>`).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) => `<option value="${i}">${String(i + 1).padStart(2, '0')} · ${level.prototype.family} r${level.prototype.radius} · ${level.tiles.length} 枚 · 锁住 ${pct(level.prototype.stats.lockedShare)} · 释放 ${level.prototype.stats.releasePerTurn.join('/')}</option>`).join('');
load(0);