feat: expand campaign and refine puzzle visuals and controls
This commit is contained in:
+36
-21
@@ -44,12 +44,12 @@ export function validateLevel(input) {
|
||||
if (input.rotationEnabled !== undefined && typeof input.rotationEnabled !== 'boolean') errors.push('rotationEnabled 必须是布尔值');
|
||||
if (input.version !== 1) errors.push('version 必须为 1');
|
||||
if (typeof input.name !== 'string' || !input.name.trim() || input.name.length > 60) errors.push('名称须为 1–60 个字符');
|
||||
if (!Array.isArray(input.cells) || input.cells.length < 1 || input.cells.length > 61) return [...errors, '底板须为 1–61 格'];
|
||||
if (!Array.isArray(input.cells) || input.cells.length < 1 || input.cells.length > 127) return [...errors, '底板须为 1–127 格'];
|
||||
if (!Array.isArray(input.tiles) || input.tiles.length > input.cells.length) return [...errors, '棋子数量不能超过底板'];
|
||||
const coords = new Set();
|
||||
for (const c of input.cells) {
|
||||
if (!c || !Number.isInteger(c.q) || !Number.isInteger(c.r) || Math.max(Math.abs(c.q), Math.abs(c.r), Math.abs(c.q + c.r)) > 4) {
|
||||
errors.push('坐标必须为半径 4 内的轴向整数坐标'); continue;
|
||||
if (!c || !Number.isInteger(c.q) || !Number.isInteger(c.r) || Math.max(Math.abs(c.q), Math.abs(c.r), Math.abs(c.q + c.r)) > 6) {
|
||||
errors.push('坐标必须为半径 6 内的轴向整数坐标'); continue;
|
||||
}
|
||||
const k = key(c.q, c.r);
|
||||
if (coords.has(k)) errors.push(`底板重复:${k}`);
|
||||
@@ -65,9 +65,9 @@ export function validateLevel(input) {
|
||||
if (typeof t.id !== 'string' || !t.id || ids.has(t.id)) errors.push('棋子 id 须为唯一非空字符串');
|
||||
ids.add(t.id);
|
||||
if (!Number.isInteger(t.dir) || t.dir < 0 || t.dir > 5) errors.push('方向必须为 0–5');
|
||||
if (!Number.isInteger(t.color) || t.color < 0 || t.color > 3) errors.push('颜色必须为 0–3');
|
||||
if (!Number.isInteger(t.color) || t.color < 0 || t.color > 11) errors.push('颜色必须为 0–11');
|
||||
}
|
||||
if (!Number.isInteger(input.moveLimit) || input.moveLimit < 1 || input.moveLimit > 999) errors.push('步数须为 1–999');
|
||||
if (!Number.isInteger(input.moveLimit) || input.moveLimit < 1 || input.moveLimit > 999) errors.push('成功离场额度须为 1–999');
|
||||
return [...new Set(errors)];
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ export function canRotate(level) {
|
||||
|
||||
// Rotation search: removing an available piece is always safe (occupancy only decreases).
|
||||
// Exhaust available pieces at each orientation, then breadth-first search CW/CCW turns.
|
||||
// Each edge costs one turn; total solution cost is tile count + shortest turn count.
|
||||
// Each search edge costs one turn, but only successful removals spend the move budget.
|
||||
export function solveRotations(level, maxStates = 20000) {
|
||||
const boards = Array.from({ length: 6 }, (_, i) => rotateBoard(level, i));
|
||||
const rotating = canRotate(level) && level.rotationEnabled !== false;
|
||||
@@ -161,8 +161,8 @@ export function solveRotations(level, maxStates = 20000) {
|
||||
if (seen.has(signature)) continue;
|
||||
seen.add(signature); visited++;
|
||||
if (!node.remaining.length && level.tiles.length) {
|
||||
const minimumMoves = level.tiles.length + node.rotations;
|
||||
return { solvable: minimumMoves <= level.moveLimit, reachable: true, minimumMoves, rotations: node.rotations, actions: node.actions, order: node.actions.filter(a => a.type === 'tap').map(a => a.id), waves: node.waves, stuck: [], states: visited, exhausted: false };
|
||||
const minimumMoves = level.tiles.length, minimumActions = minimumMoves + node.rotations;
|
||||
return { solvable: minimumMoves <= level.moveLimit, reachable: true, minimumMoves, minimumActions, rotations: node.rotations, actions: node.actions, order: node.actions.filter(a => a.type === 'tap').map(a => a.id), waves: node.waves, stuck: [], states: visited, exhausted: false };
|
||||
}
|
||||
if (!rotating) continue;
|
||||
for (const direction of [1, -1]) {
|
||||
@@ -172,24 +172,39 @@ export function solveRotations(level, maxStates = 20000) {
|
||||
if (!seen.has(signature)) queue.push({ ...child, rotations: node.rotations + 1 });
|
||||
}
|
||||
}
|
||||
return { solvable: false, reachable: false, minimumMoves: null, rotations: 0, actions: [], order: [], waves: first.waves, stuck: first.remaining.map(t => t.id), states: visited, exhausted: visited >= maxStates };
|
||||
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 };
|
||||
}
|
||||
|
||||
export function analyze(level) {
|
||||
const solution = solveRotations(level), n = level.tiles.length;
|
||||
const routes = level.tiles.map(t => trace(level, t, level.tiles, true));
|
||||
const meanTurns = n ? routes.reduce((sum, p) => sum + p.steps.filter(s => s.enter !== null && opposite(s.enter) !== s.exit).length, 0) / n : 0;
|
||||
const free = available(level).length;
|
||||
const routeTurns = routes.map(p => p.steps.filter(s => s.enter !== null && opposite(s.enter) !== s.exit).length);
|
||||
const totalTurns = routeTurns.reduce((sum, turns) => sum + turns, 0);
|
||||
const meanTurns = n ? totalTurns / n : 0, maxTurns = Math.max(0, ...routeTurns);
|
||||
const occupiedRoutes = level.tiles.map(t => trace(level, t));
|
||||
const free = occupiedRoutes.filter(p => p.status === 'exit').length, freeRatio = n ? free / n : 0;
|
||||
const sweptOnlyBlockers = occupiedRoutes.filter((p, i) => p.collision && !routes[i].blockers.includes(p.blocker)).length;
|
||||
const dependencyRatio = n > 1 ? Math.max(0, solution.waves.length - 1) / (n - 1) : 0;
|
||||
// Cognitive difficulty measures route reading and dependencies, never board area,
|
||||
// piece count or a penalty for experimenting. Workload is reported separately.
|
||||
const parts = {
|
||||
scale: Math.min(1, n / 37) * 15,
|
||||
turns: Math.min(1, meanTurns / 5) * 20,
|
||||
depth: Math.min(1, Math.max(0, solution.waves.length - 1) / 9) * 20,
|
||||
blocked: (n ? 1 - free / n : 0) * 15,
|
||||
rotation: Math.min(1, solution.rotations / 4) * 20,
|
||||
precision: (n ? Math.min(1, Math.max(0, 1 - (level.moveLimit - (solution.minimumMoves ?? n)) / Math.max(1, n * 0.4))) : 0) * 10,
|
||||
bends: Math.min(1, meanTurns / 4) * 22,
|
||||
longestRoute: Math.min(1, maxTurns / 8) * 12,
|
||||
dependencies: Math.min(1, dependencyRatio) * 18,
|
||||
blocked: (n ? 1 - freeRatio : 0) * 28,
|
||||
sweptBlockers: (n ? sweptOnlyBlockers / n : 0) * 12,
|
||||
rotation: Math.min(1, solution.rotations / 3) * 8,
|
||||
};
|
||||
const score = Math.round(Object.values(parts).reduce((a, b) => a + b, 0));
|
||||
return { score, parts, meanTurns, free, depth: solution.waves.length, ...solution, label: score < 30 ? '轻松' : score < 50 ? '进阶' : score < 70 ? '烧脑' : '挑战' };
|
||||
const workload = {
|
||||
removals: n,
|
||||
rotations: solution.rotations,
|
||||
minimumActions: solution.minimumActions,
|
||||
routeCells: routes.reduce((sum, p) => sum + p.steps.length, 0),
|
||||
// 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 ? '烧脑' : '挑战' };
|
||||
}
|
||||
export function generateLevel(seed, radius = 3, target = 25, complexity = 0.7) {
|
||||
const rng = random(seed);
|
||||
@@ -238,7 +253,7 @@ export class Game {
|
||||
this.snapshot();
|
||||
this.level = rotateBoard(this.level, direction);
|
||||
this.orientation = (this.orientation + direction + 6) % 6;
|
||||
this.rotations++; this.moves--;
|
||||
this.rotations++;
|
||||
return { status: 'rotated', direction, orientation: this.orientation };
|
||||
}
|
||||
tap(id) {
|
||||
@@ -246,8 +261,8 @@ export class Game {
|
||||
const tile = this.tiles.find(t => t.id === id);
|
||||
if (!tile) return { status: 'missing' };
|
||||
const route = trace(this.level, tile, this.tiles);
|
||||
this.snapshot(); this.moves--;
|
||||
if (route.status === 'exit') this.tiles = this.tiles.filter(t => t.id !== id);
|
||||
this.snapshot();
|
||||
if (route.status === 'exit') { this.tiles = this.tiles.filter(t => t.id !== id); this.moves--; }
|
||||
else this.mistakes++;
|
||||
return { ...route, tile: clone(tile) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user