feat: version campaigns and document original puzzle design

Restore v0.1 and v0.2 archives with separate progress, publish the curved v0.3 campaign, and document the original 24-level design with reproducible evidence. Refine board presentation and movement, and keep video lab tooling within its own directory.
This commit is contained in:
cabinw
2026-09-09 13:17:30 +08:00
parent 70b021d4cc
commit e92cccba82
52 changed files with 76449 additions and 15076 deletions
+32 -1
View File
@@ -175,6 +175,37 @@ export function solveRotations(level, maxStates = 20000) {
return { solvable: false, reachable: false, minimumMoves: null, minimumActions: null, rotations: 0, actions: [], order: [], waves: first.waves, stuck: first.remaining.map(t => t.id), states: visited, exhausted: visited >= maxStates };
}
// Count the paths that successful pieces really travel in the reference plan.
// Decorative bends in unused cells do not count as route-reading practice.
export function solutionPathUsage(level, actions) {
let board = level, remaining = [...level.tiles], straightRun = 0;
const paths = [];
for (const action of actions) {
if (action.type === 'rotate') { board = rotateBoard(board, action.direction); continue; }
const tile = remaining.find(t => t.id === action.id);
if (!tile) continue;
const route = trace(board, tile, remaining);
if (route.status !== 'exit') continue;
const bends = route.steps.filter(s => s.enter !== null && opposite(s.enter) !== s.exit).length;
straightRun = bends ? 0 : straightRun + 1;
paths.push({ id: tile.id, bends, cells: route.steps.length, straightRun });
remaining = remaining.filter(t => t.id !== tile.id);
}
const curvedRemovals = paths.filter(p => p.bends > 0).length;
return {
removals: paths.length,
curvedRemovals,
straightRemovals: paths.length - curvedRemovals,
curvedRatio: paths.length ? curvedRemovals / paths.length : 0,
singleBendRemovals: paths.filter(p => p.bends === 1).length,
multiBendRemovals: paths.filter(p => p.bends > 1).length,
totalBends: paths.reduce((sum, p) => sum + p.bends, 0),
maxBends: Math.max(0, ...paths.map(p => p.bends)),
maxConsecutiveStraight: Math.max(0, ...paths.map(p => p.straightRun)),
paths: paths.map(({ straightRun, ...path }) => path),
};
}
export function analyze(level) {
const solution = solveRotations(level), n = level.tiles.length;
const routes = level.tiles.map(t => trace(level, t, level.tiles, true));
@@ -204,7 +235,7 @@ export function analyze(level) {
// A pacing heuristic for comparing authored levels, not measured playtest time.
estimatedSeconds: Math.round(n * 3 + totalTurns * 0.75 + solution.rotations * 4),
};
return { score, parts, meanTurns, maxTurns, free, freeRatio, sweptOnlyBlockers, dependencyRatio, workload, depth: solution.waves.length, ...solution, label: score < 30 ? '轻松' : score < 50 ? '进阶' : score < 70 ? '烧脑' : '挑战' };
return { score, parts, meanTurns, maxTurns, free, freeRatio, sweptOnlyBlockers, dependencyRatio, workload, pathUsage: solutionPathUsage(level, solution.actions), depth: solution.waves.length, ...solution, label: score < 30 ? '轻松' : score < 50 ? '进阶' : score < 70 ? '烧脑' : '挑战' };
}
export function generateLevel(seed, radius = 3, target = 25, complexity = 0.7) {
const rng = random(seed);