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>
This commit is contained in:
cabinw
2026-09-14 14:00:33 +08:00
co-authored by Claude Opus 5
parent f3e91e3597
commit 643cba0ed9
8 changed files with 27443 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
# D5 · 三转链式锁构造原型
目的:验证在现行规则(消除单调、旋转免费可逆)下,能否稳定构造出**最少需要三次旋转**、且**开局大部分棋子被锁住**的关卡。这是 v0.6 难度曲线里 D5 档的内容来源;v0.5 千关里该档为 0,`createStageSeed` 只能产出两转锁。
打开:`npm run dev` 后访问 `/labs/d5/index.html`。只读 `levels/prototypes/d5.json`,不写入主线存档。
## 构造方法
`src/deep-lock.js`
1. **`createDeepLock(base)`** — 四子链式锁 A/B/C/D。朝向 0 全部被锁;+1 只有 B 能走;+2(B 走后)只有 C 能走,A 与 D 互锁;转回 +1 放 A,再放 D。网格路线只用来提议,每一阶段都用主游戏的扫掠碰撞 `trace()` 逐条认证,最后由 `solveRotations()` 证明不存在一转或两转的清盘方案。
2. **`growDeepLevel(base, lock, { target, layerShares })`** — 反向插入更多棋子,按朝向 0 / 1 / 2 分层配额;优先选「在更早的朝向都被挡」的棋子,使它们真正等旋转;每插一枚重新求解,最少旋转数跌破 3 即拒绝。
3. **`deepLevelStats(level)`** — 锁住占比(首转时剩余)、每次旋转释放数、首转是否只有一侧能开路、波次。
## 一次扫描的产出(6 形态 × r3/r4 × 8 种子)
```
找到锁 55/96 · 长成 R≥3 关 55 · 旋转次数 {3:47, 4:1, 5:7}
锁住占比中位 78%(v0.5 现状 22%)· 每转释放 ≥2 枚 38/55 · 首转单向 9/55
随机玩家 动作÷棋子 中位 7.5(v0.5 全库 1.67,v0.4 手作 3.42)
switchback r3、crossing r3 产出 0:小盘上这两种形态找不到满足四子约束的候选
```
重新生成:`node scripts/prototype-d5.mjs --seeds=8 --keep=18`(`--radii=3,4,5` 可含 91 格;默认不含)。
## 「无解」问题与可玩性门槛
第一批里的第 02 关(orbits r3 s3)被试玩者判为无解。穷举全部 324 个可达状态:0 个死局,参考解在真实界面里也能清盘。问题出在体感:**开局 100% 锁死 + 首转只有右边有效 + 最后一步要转回来**。先往左转的人连看 3 个空朝向,右右之后继续右转的人连看 4 个——两条路都读作"无解"。
对策分两层,都已落地:
1. **结构门槛**(`scripts/prototype-d5.mjs`):`deadTurnAudit()` 穷举玩家可达的全部状态(任意点击顺序、任意旋转序列),证明没有死局,并统计「双向死转态」(卡住时左右各转一次都打不开的状态数)与「最长盲转」。入选关必须:首转两侧都能开路、开局锁住 ≤90%、无不可解可达态;按双向死转态升序排。现在前 4 关这两项都是 0,随机玩家动作÷棋子回到 3.6–6.5。
2. **反馈**(`labs/d5/app.js`):卡住后闲置 3 秒,正确的旋转按钮开始脉冲并提示方向——即 §07 第 1 条的做法,方向永远不即时给出。任何输入都会清掉提示。
## 尚未解决
- 随机玩家的动作数仍高于手作版(v0.4 为 3.42);D5 放在第 501 关后是否合适,要真人试玩。
- 现在的参考解是求解器给出的最短解,不是作者解;正式接入生产时应保留作者见证(同 `createStageSeed`)。
- 「首转释放 ≥2 枚」只在扫描时排序,未在生长时强制。
- 「双向死转态 = 0」目前只是排序偏好,不是硬门槛;D5 后段若需要单向关,应配合闲置提示一起上线,不能单独放。
+159
View File
@@ -0,0 +1,159 @@
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);
+46
View File
@@ -0,0 +1,46 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>三转锁 · D5 构造原型</title>
<link rel="stylesheet" href="./style.css">
</head>
<body>
<main>
<header>
<a class="back" href="../../">← 返回主线</a>
<span class="badge">D5 · 三转链式锁 · 原型</span>
</header>
<section class="intro">
<p class="eyebrow">THREE TURNS TO CLEAR</p>
<h1>三转锁 <small>深旋转</small></h1>
<p>开局几乎全锁。转一次放一层,转两次放一层,转回来放主角。</p>
</section>
<div class="picker">
<label for="level">关卡</label>
<select id="level" aria-label="选择原型关卡"></select>
<span class="meta" id="meta"></span>
</div>
<div class="status"><span>待离场 <strong id="remaining">0</strong></span><span id="progress">已放行 0 / 0 · 旋转 0</span></div>
<div class="board-wrap"><canvas id="board" aria-label="三转锁原型棋盘,点击箭头棋子离场"></canvas></div>
<p class="message" id="message" role="status">先找找有没有能直接走的棋子——多半没有。转一下。</p>
<nav class="controls" aria-label="棋盘操作">
<button id="left" type="button">↶ <span>左转</span></button>
<button id="undo" type="button">↩ <span>撤销</span></button>
<button id="hint" type="button">✧ <span>提示</span></button>
<button id="reset" type="button">↻ <span>重来</span></button>
<button id="right" type="button">↷ <span>右转</span></button>
</nav>
<section class="evidence">
<h2>结构证据</h2>
<dl id="evidence"></dl>
<p class="witness" id="witness"></p>
</section>
<footer>
<p>数据来自 <code>levels/prototypes/d5.json</code>,由 <code>scripts/prototype-d5.mjs</code> 生成;规则、碰撞与求解与主游戏共用。此页只用于验证构造,不写入主线存档。</p>
</footer>
</main>
<script type="module" src="./app.js"></script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
:root{font-family:system-ui,-apple-system,"PingFang SC",sans-serif;color:#33414d;background:#e9edf0;font-synthesis:none}
*{box-sizing:border-box}
body{margin:0}
main{max-width:900px;margin:auto;padding:16px 28px 24px}
header{display:flex;align-items:center;justify-content:space-between;gap:12px}
.back{color:#526575;text-decoration:none;font-size:13px}
.badge{font-size:11px;letter-spacing:.08em;color:#4d6478;border:1px solid #b9c7d2;border-radius:20px;padding:7px 12px}
.intro{text-align:center;margin:10px 0 12px}
.eyebrow{font-size:10px;letter-spacing:.24em;color:#6b7f90}
h1{font-size:28px;letter-spacing:.15em;margin:7px 0 10px;font-weight:650}
h1 small{font-size:13px;letter-spacing:.1em;font-weight:500;vertical-align:middle;border-left:1px solid #b9c7d2;margin-left:5px;padding-left:12px;color:#6b7f90}
.intro>p:last-child{font-size:13px;margin:0;color:#65717b}
.picker{display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap;margin:6px auto 10px;font-size:12px;color:#526575}
.picker select{font:13px system-ui;padding:7px 10px;border:1px solid #b9c7d2;border-radius:10px;background:#fff;color:#33414d;max-width:100%}
.picker .meta{font-variant-numeric:tabular-nums;color:#6b7f90}
.status{display:flex;align-items:center;justify-content:space-between;gap:12px;max-width:620px;margin:auto;font-size:12px;color:#65717b}
.status strong{font-size:24px;color:#33414d;margin-left:6px}
#progress{font-size:11px;font-variant-numeric:tabular-nums}
.board-wrap{width:100%;height:clamp(400px,calc(100svh - 360px),700px);min-height:380px}
canvas{width:100%;height:100%;display:block;touch-action:manipulation}
.message{margin:4px 0 16px;text-align:center;font-size:13px;min-height:36px;line-height:1.6}
.controls{display:flex;justify-content:center;gap:14px}
button{font:24px system-ui;color:#526575;background:#f9fbfc;border:1px solid #cfd9e1;border-radius:18px;min-width:74px;min-height:66px;padding:6px 12px;cursor:pointer;box-shadow:0 3px 0 #c5d1da}
button span{display:block;font-size:11px}
button:hover{background:#fff}
button:active{transform:translateY(2px);box-shadow:0 1px 0 #c5d1da}
button:disabled{opacity:.4;cursor:default}
button.hinted{border-color:#5b7a94;box-shadow:0 3px 0 #5b7a94,0 0 0 4px rgba(91,122,148,.25);animation:hint-pulse .7s ease-in-out infinite alternate}
@keyframes hint-pulse{from{transform:translateY(0)}to{transform:translateY(-4px)}}
button:focus-visible,a:focus-visible,select:focus-visible{outline:3px solid #5b7a94;outline-offset:4px}
.evidence{max-width:620px;margin:22px auto 0;border-top:1px solid #cfd9e1;padding-top:14px}
.evidence h2{font-size:11px;letter-spacing:.18em;color:#6b7f90;margin:0 0 10px;font-weight:600}
.evidence dl{display:grid;grid-template-columns:1fr 1fr;gap:8px 18px;margin:0;font-size:12.5px}
.evidence dl>div{display:flex;justify-content:space-between;gap:10px;border-bottom:1px dashed #d8e0e6;padding-bottom:6px}
.evidence dt{color:#6b7f90}
.evidence dd{margin:0;font-variant-numeric:tabular-nums;text-align:right;color:#33414d}
.witness{margin:12px 0 0;font-size:12px;color:#526575;font-variant-numeric:tabular-nums;text-align:center}
footer{text-align:center;margin:18px auto 0;max-width:560px;font-size:11px;color:#6b757e;line-height:1.7}
footer code{font-family:ui-monospace,Menlo,monospace;font-size:10.5px;background:#dfe6ec;padding:1px 4px;border-radius:3px}
@media(max-width:540px){main{padding:16px 14px 20px}.board-wrap{height:380px;min-height:0}.controls{gap:9px}button{min-width:57px;min-height:60px;padding:5px 9px}.message{font-size:12px;margin-bottom:12px}.evidence dl{grid-template-columns:1fr}}
@media(prefers-reduced-motion:reduce){button:active{transform:none}}