feat(levels): give every level an opening worth watching

The chain locks all four of its pieces at the opening by construction, and the
pieces grown around it are mostly locked too, so a level built from it hands a
player two rim pieces and a third of a second of animation before any thinking
starts. Measured across the 235-level pool: median two tappable pieces at the
opening, and the best departure available plays for 0.57s.

Growth now reserves a couple of slots for showcase pieces — tappable at the
opening, with a route long enough to read as a journey and short enough to stay
under the animation ceiling. Median opening choices go 2 to 3 and the best
opening show 0.72s to 1.12s, without shortcutting the lock: each candidate is
still put through the solver.

Rotation quota is gone from the board. Turning is free and it is what the game
is about, so depth is grown rather than capped; what is gated is whether each
turn pays out, whether pieces swing between hopeless and trivial, and whether
there is a show up front.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
cabinw
2026-09-14 16:45:24 +08:00
co-authored by Claude Opus 5
parent a250baba53
commit 0aa080c7c3
5 changed files with 296 additions and 98 deletions
+113 -10
View File
@@ -1,4 +1,5 @@
import { available, key, opposite, random, rotateBoard, solveRotations, trace, validateLevel } from './core.js';
import { Game, available, key, opposite, random, rotateBoard, solveRotations, trace, validateLevel } from './core.js';
import { exitTiming } from './exit-animation.js';
const positionKey = tile => `${tile.q},${tile.r}`;
const countBends = route => route.steps.filter(step => step.enter !== null && opposite(step.enter) !== step.exit).length;
@@ -109,11 +110,17 @@ export function createDeepLock(base, {
// Reverse insertion keeps the chain intact while raising how much of the
// board depends on turning. Each inserted piece must leave at its scheduled
// orientation, and the solver must still need exactly three turns afterwards.
// Rotation is free and it is what the game is about, so depth is the thing to
// grow, not cap. Scheduling pieces at orientations 3/4/5 as well as 0/1/2 is
// what lets a level need five or six turns instead of always exactly three —
// and each of those extra turns pays out, because the piece scheduled there is
// waiting to leave.
export function growDeepLevel(base, lock, {
seed = 0, target = 12, layerShares = { 0: .35, 1: .35, 2: .3 }, maxHeroBends = 70, minRotations = 3,
seed = 0, depth = 3, perTurn = 2, maxHeroBends = 70, minRotations = 3, target = null,
showcase = 2, showcaseMinDistance = 6, showcaseMaxDistance = 20,
} = {}) {
const rng = random(seed + 7919);
const boards = [0, 1, 2].map(turn => rotateBoard(base, turn));
const boards = [0, 1, 2, 3, 4, 5].map(turn => rotateBoard(base, turn));
// A layer piece only needs a departure at its own orientation. Requiring
// exits at every orientation would leave almost nothing to insert.
const layerPool = boards.map(board => {
@@ -125,13 +132,14 @@ export function growDeepLevel(base, lock, {
return pool;
});
const tiles = [...lock.tiles], placements = [...lock.placements];
const extras = Math.max(0, target - tiles.length);
// Depth comes from a quota per orientation, not from random shares. Shares
// left some orientations empty, and an orientation with nothing waiting is a
// turn that pays nothing — the one thing that reliably reads as boring.
const schedule = [];
for (const [turn, share] of Object.entries(layerShares)) for (let i = 0; i < Math.round(extras * share); i++) schedule.push(Number(turn));
while (schedule.length < extras) schedule.push(0);
schedule.length = extras;
let rejected = { orientationless: 0, shortcut: 0 };
for (const orientation of shuffled(schedule, rng)) {
for (let turn = 0; turn < depth; turn++) for (let i = 0; i < perTurn; i++) schedule.push(turn);
if (target != null) schedule.length = Math.max(0, Math.min(schedule.length, target - tiles.length));
let rejected = { orientationless: 0, shortcut: 0, unfilled: 0 };
for (const orientation of [...schedule].sort((a, b) => b - a)) {
const occupied = new Set(tiles.map(positionKey));
const choices = shuffled(layerPool[orientation].filter(c => !occupied.has(c.position)), rng);
let chosen = null, fallback = null;
@@ -147,9 +155,33 @@ export function growDeepLevel(base, lock, {
fallback = tile;
}
chosen = chosen || fallback;
if (!chosen) break;
// One orientation running out of candidates must not abandon the rest.
if (!chosen) { rejected.unfilled++; continue; }
tiles.push(chosen); placements.push(orientation);
}
// The chain locks everything at the opening by construction, so a level built
// only from it hands a casual player two rim pieces and a third of a second
// of animation before the thinking starts. Reserve a couple of slots for
// pieces that are tappable immediately and whose route is worth watching:
// long enough to read as a journey, short enough not to hit the ceiling.
for (let i = 0; i < showcase; i++) {
const occupied = new Set(tiles.map(positionKey));
let best = null;
for (const c of shuffled(layerPool[0].filter(x => !occupied.has(x.position)), rng)) {
const tile = { ...c.tile, id: `t${tiles.length}`, color: tiles.length % 7 }, proposed = [...tiles, tile];
const route = trace(boards[0], tile, proposed);
if (route.status !== 'exit') continue;
const { distance } = exitTiming(route);
if (distance < showcaseMinDistance || distance > showcaseMaxDistance) continue;
// It must not shortcut the lock it is decorating.
const solution = solveRotations({ ...base, tiles: proposed, moveLimit: proposed.length });
if (!solution.solvable || solution.exhausted || solution.rotations < minRotations) continue;
if (!best || distance > best.distance) best = { tile, distance };
}
if (!best) break;
tiles.push(best.tile); placements.push(0);
}
const level = {
...base, tiles, moveLimit: tiles.length,
name: `${(base.name || 'route').replace(' · 路线草图', '')} · 深旋转`,
@@ -165,6 +197,77 @@ export function growDeepLevel(base, lock, {
// Structural evidence for the difficulty bands: how much of the board waits
// for a turn, how many pieces each turn releases, and whether the direction
// of the first turn is a real choice.
// How far a piece swings between "looks hopeless" and "walks straight out".
// Cost is what the eye judges from: cells travelled plus twice the bends. A
// piece that reads as a 128-cost tangle at one orientation and a 5-cost stroll
// at another is where the satisfaction comes from; families whose pieces all
// sit around the same cost no matter how the board turns feel mechanical.
export function contrastStats(level) {
const boards = [0, 1, 2, 3, 4, 5].map(k => rotateBoard(level, k));
const cost = route => route.status === 'exit' ? route.steps.length + 2 * countBends(route) : null;
const HOPELESS = 60;
const swings = [];
for (const tile of level.tiles) {
const costs = boards.map(board => cost(trace(board, tile, [], true)));
const reachable = costs.filter(c => c != null);
if (!reachable.length) continue;
const best = Math.min(...reachable);
const opening = trace(level, tile, level.tiles);
const seen = opening.status === 'exit' ? cost(opening) : (costs[0] ?? HOPELESS);
swings.push(seen - best);
}
if (!swings.length) return { contrast: 0, maxSwing: 0 };
return {
contrast: swings.reduce((a, b) => a + b, 0) / swings.length,
maxSwing: Math.max(...swings),
};
}
// What the player actually watches. Rotation costs no steps precisely so that
// people try things; the payout is the animation of a piece threading a winding
// route and leaving. So the show is measurable: how long each departure plays,
// whether the level opens with one, and — the failure the design brief names
// outright — how many actions can pass with nothing happening at all.
export function showStats(level) {
const solution = solveRotations(level);
if (!solution.solvable || !solution.actions) return null;
const game = new Game(level);
const shows = [];
let dry = 0, maxDry = 0, firstShow = null, sinceStart = 0;
for (const action of solution.actions) {
if (action.type === 'rotate') { game.rotate(action.direction); dry++; sinceStart++; maxDry = Math.max(maxDry, dry); continue; }
const result = game.tap(action.id);
sinceStart++;
if (result.status === 'exit') {
const ms = exitTiming(result).duration;
shows.push(ms);
if (firstShow === null) firstShow = { ms, afterActions: sinceStart };
dry = 0;
} else { dry++; maxDry = Math.max(maxDry, dry); }
}
if (!shows.length) return null;
const sorted = [...shows].sort((a, b) => a - b);
// What the player can see straight away if they pick well, rather than what
// the optimal script happens to tap first. An opening whose only departure is
// a piece already touching the rim plays for 0.3s and shows nothing.
const opening = new Game(level);
const openingShows = available(level, opening.tiles)
.map(t => exitTiming(trace(level, t, opening.tiles)).duration);
return {
exits: shows.length,
openingChoices: openingShows.length,
openingBestMs: openingShows.length ? Math.round(Math.max(...openingShows)) : 0,
totalMs: Math.round(shows.reduce((a, b) => a + b, 0)),
medianMs: Math.round(sorted[Math.floor(sorted.length / 2)]),
longestMs: Math.round(sorted[sorted.length - 1]),
// A cruise: long enough that watching it is the point, not a blink.
cruises: shows.filter(ms => ms >= 900).length,
firstShowMs: firstShow ? Math.round(firstShow.ms) : null,
firstShowAfter: firstShow ? firstShow.afterActions : null,
maxDryActions: maxDry,
};
}
export function deepLevelStats(level) {
const n = level.tiles.length, solution = solveRotations(level);
let board = level, remaining = [...level.tiles];