初始提交:三玩法 Web 原型与设计文档
- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试 - 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本 - 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试 - 产品文档、UI 设计规范、各玩法 GDD / HANDOFF - 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
// 关卡生成器。GDD §8.2 的第 1–2 步:随机铺盘(1×1 + 矩形/异形大格 + 空洞,保证连通且有哈密顿路径),
|
||||
// 随机取一条作者解,再挑线索使其唯一。
|
||||
//
|
||||
// npm run generate 生成一包候选并打印盘面,不写文件
|
||||
// npm run generate -- --seed 7 --out levels/pack-2-advanced.json
|
||||
// npm run generate -- --levels 12 --pool 80 --attempts 600
|
||||
//
|
||||
// 流程:铺盘(矩形 45% / 俄罗斯方块异形 55%:L/I/S/T)→ 美感过滤(多单位格都要被路径真正穿过、
|
||||
// 不能蛇形到底、无线索路径要够多)→ 挑线索:带线索 1 的关固定「1 + 引导步(4~7)」给开局指引,
|
||||
// 再补 1~2 个拉开差距的线索凑唯一(难度的主旋钮是相邻线索的步号差);约 18% 的候选不带线索 1。
|
||||
// → 难度分(以最大步号差为主)排序,从偏难的一段里等距挑 --levels 关。
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { Board } from '../src/board';
|
||||
import { solve } from '../src/solver';
|
||||
import type { CellDef, LevelDef, PackDef } from '../src/types';
|
||||
import { pathToIds, renderAscii } from './ascii';
|
||||
|
||||
// ---------- 参数 ----------
|
||||
|
||||
interface Opts {
|
||||
seed: number;
|
||||
out: string | null;
|
||||
levels: number;
|
||||
pool: number;
|
||||
attempts: number;
|
||||
minFree: number;
|
||||
pack: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
function parseOpts(): Opts {
|
||||
const a = process.argv.slice(2);
|
||||
const num = (flag: string, dflt: number): number => {
|
||||
const i = a.indexOf(flag);
|
||||
return i === -1 ? dflt : Number(a[i + 1]);
|
||||
};
|
||||
const str = (flag: string): string | null => {
|
||||
const i = a.indexOf(flag);
|
||||
return i === -1 ? null : a[i + 1];
|
||||
};
|
||||
if (a.includes('--help')) {
|
||||
console.log('用法: npm run generate -- [--seed N] [--levels N] [--pool N] [--attempts N] [--min-free N] [--pack N] [--title 名] [--out file.json]');
|
||||
process.exit(0);
|
||||
}
|
||||
return {
|
||||
seed: num('--seed', Date.now() % 100000),
|
||||
out: str('--out'),
|
||||
levels: num('--levels', 10),
|
||||
pool: num('--pool', 60),
|
||||
attempts: num('--attempts', 400),
|
||||
minFree: num('--min-free', 120),
|
||||
pack: num('--pack', 2),
|
||||
title: str('--title') ?? '进阶',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 随机数 ----------
|
||||
|
||||
function mulberry32(seed: number): () => number {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = s;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 铺盘 ----------
|
||||
|
||||
const GRIDS: Array<[number, number, number]> = [
|
||||
// cols, rows, 权重
|
||||
[5, 5, 0.45],
|
||||
[4, 5, 0.2],
|
||||
[5, 4, 0.15],
|
||||
[4, 4, 0.2],
|
||||
];
|
||||
|
||||
function pickGrid(rand: () => number): [number, number] {
|
||||
let r = rand();
|
||||
for (const [c, rr, w] of GRIDS) {
|
||||
if ((r -= w) < 0) return [c, rr];
|
||||
}
|
||||
return [5, 5];
|
||||
}
|
||||
|
||||
type Offset = readonly [number, number];
|
||||
|
||||
/**
|
||||
* 大格模板:大连块为主(2×2 + 3×3 占 40%),俄罗斯方块异形(L/I/S/T)44%,条 16%。
|
||||
* 摆放时随机旋转 × 随机镜像,所以 L 含 J、S 含 Z、条含横竖。
|
||||
*/
|
||||
const TEMPLATES: Array<{ name: string; base: Offset[]; weight: number }> = [
|
||||
{ name: 'O4', base: [[0, 0], [1, 0], [0, 1], [1, 1]], weight: 28 },
|
||||
{ name: 'O9', base: [[0, 0], [1, 0], [2, 0], [0, 1], [1, 1], [2, 1], [0, 2], [1, 2], [2, 2]], weight: 18 },
|
||||
{ name: 'I2', base: [[0, 0], [1, 0]], weight: 16 },
|
||||
{ name: 'L4', base: [[0, 0], [0, 1], [0, 2], [1, 2]], weight: 16 },
|
||||
{ name: 'I3', base: [[0, 0], [1, 0], [2, 0]], weight: 6 },
|
||||
{ name: 'I4', base: [[0, 0], [1, 0], [2, 0], [3, 0]], weight: 6 },
|
||||
{ name: 'S4', base: [[1, 0], [2, 0], [0, 1], [1, 1]], weight: 9 },
|
||||
{ name: 'T4', base: [[0, 0], [1, 0], [2, 0], [1, 1]], weight: 7 },
|
||||
];
|
||||
|
||||
function pickTemplate(rand: () => number): (typeof TEMPLATES)[number] {
|
||||
let r = rand() * TEMPLATES.reduce((s, t) => s + t.weight, 0);
|
||||
for (const t of TEMPLATES) {
|
||||
if ((r -= t.weight) < 0) return t;
|
||||
}
|
||||
return TEMPLATES[0];
|
||||
}
|
||||
|
||||
/** 旋转 rot×90° + 可选镜像,然后平移到原点 */
|
||||
function transformShape(base: Offset[], rot: number, mirror: boolean, rand: () => number): Offset[] {
|
||||
void rand;
|
||||
let pts = base.map(([x, y]) => [x, y] as Offset);
|
||||
if (mirror) pts = pts.map(([x, y]) => [-x, y] as Offset);
|
||||
for (let i = 0; i < rot; i++) pts = pts.map(([x, y]) => [y, -x] as Offset);
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
for (const [x, y] of pts) {
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
}
|
||||
return pts.map(([x, y]) => [x - minX, y - minY] as Offset);
|
||||
}
|
||||
|
||||
/** 随机铺一盘(只含几何,id 后补)。格数不在 [nMin, nMax] 返回 null。 */
|
||||
function randomBoardCells(rand: () => number, nMin: number, nMax: number): { cells: CellDef[]; holes: number } | null {
|
||||
const [cols, rows] = pickGrid(rand);
|
||||
const taken = new Uint8Array(cols * rows);
|
||||
|
||||
const cells: CellDef[] = [];
|
||||
const area = cols * rows;
|
||||
// 大连块多放一些:大盘 2~3 个多单位格,小盘 1~2 个
|
||||
const bigTarget = area >= 20 ? 2 + Math.floor(rand() * 2) : 1 + Math.floor(rand() * 2);
|
||||
// 先抽模板再按面积降序放:3×3 需要整片空地,后放容易摆不下
|
||||
const bigPicks = Array.from({ length: bigTarget }, () => {
|
||||
const t = pickTemplate(rand);
|
||||
return transformShape(t.base, Math.floor(rand() * 4), rand() < 0.5, rand);
|
||||
}).sort((a, b) => b.length - a.length);
|
||||
for (const pts of bigPicks) {
|
||||
const bw = Math.max(...pts.map(([x]) => x)) + 1;
|
||||
const bh = Math.max(...pts.map(([, y]) => y)) + 1;
|
||||
for (let tries = 0; tries < 40; tries++) {
|
||||
const x = Math.floor(rand() * (cols - bw + 1));
|
||||
const y = Math.floor(rand() * (rows - bh + 1));
|
||||
if (pts.every(([dx, dy]) => !taken[(y + dy) * cols + (x + dx)])) {
|
||||
for (const [dx, dy] of pts) taken[(y + dy) * cols + (x + dx)] = 1;
|
||||
// 铺满 bbox 的就是矩形格(不写 shape,保持 JSON 简洁)
|
||||
const full = pts.length === bw * bh;
|
||||
cells.push(full ? { id: '?', x, y, w: bw, h: bh } : { id: '?', x, y, w: bw, h: bh, shape: pts });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
if (!taken[y * cols + x]) cells.push({ id: '?', x, y, w: 1, h: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
// 挖洞:只挖 1×1,挖完必须还连通(后面统一验)
|
||||
let holes = 0;
|
||||
const holeTarget = Math.floor(rand() * 3);
|
||||
const smalls = cells.filter((c) => !c.shape && c.w === 1 && c.h === 1);
|
||||
for (let h = 0; h < holeTarget && smalls.length; h++) {
|
||||
const i = Math.floor(rand() * smalls.length);
|
||||
const [victim] = smalls.splice(i, 1);
|
||||
cells.splice(cells.indexOf(victim), 1);
|
||||
holes++;
|
||||
}
|
||||
|
||||
const n = cells.length;
|
||||
if (n < nMin || n > nMax) return null;
|
||||
return { cells, holes };
|
||||
}
|
||||
|
||||
/** 按阅读顺序(y 后 x)起名:多单位格大写、1×1 小写,与 pack-1 一致。 */
|
||||
function assignIds(cells: CellDef[]): CellDef[] {
|
||||
const sorted = cells.slice().sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
let big = 0;
|
||||
let small = 0;
|
||||
const multi = (c: CellDef): boolean => (c.shape ? c.shape.length > 1 : c.w * c.h > 1);
|
||||
return sorted.map((c) => ({
|
||||
...c,
|
||||
id: multi(c) ? String.fromCharCode(65 + big++) : String.fromCharCode(97 + small++),
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------- 随机作者解 ----------
|
||||
|
||||
/** 随机化 Warnsdorff DFS 找一条哈密顿路径。多次换起点重试。 */
|
||||
function randomHamilton(board: Board, rand: () => number): number[] | null {
|
||||
const n = board.n;
|
||||
const visited = new Uint8Array(n);
|
||||
const path: number[] = [];
|
||||
const free = (v: number): number => {
|
||||
let d = 0;
|
||||
for (const w of board.adj[v]) if (!visited[w]) d++;
|
||||
return d;
|
||||
};
|
||||
const dfs = (cur: number): boolean => {
|
||||
if (path.length === n) return true;
|
||||
const cands = board.adj[cur]
|
||||
.filter((v) => !visited[v])
|
||||
.map((v) => ({ v, k: free(v) + rand() * 1.2 }))
|
||||
.sort((a, b) => a.k - b.k);
|
||||
for (const { v } of cands) {
|
||||
visited[v] = 1;
|
||||
path.push(v);
|
||||
if (dfs(v)) return true;
|
||||
path.pop();
|
||||
visited[v] = 0;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
for (let t = 0; t < 24; t++) {
|
||||
visited.fill(0);
|
||||
path.length = 0;
|
||||
const start = Math.floor(rand() * n);
|
||||
visited[start] = 1;
|
||||
path.push(start);
|
||||
if (dfs(start)) return path.slice();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- 美感过滤 ----------
|
||||
|
||||
interface Beauty {
|
||||
ok: boolean;
|
||||
reason: string;
|
||||
big: number;
|
||||
poly: number;
|
||||
maxRunRatio: number;
|
||||
}
|
||||
|
||||
function beauty(board: Board, path: number[]): Beauty {
|
||||
const n = board.n;
|
||||
let big = 0;
|
||||
let poly = 0;
|
||||
for (const c of board.cells) {
|
||||
if (c.units.length > 1) {
|
||||
big++;
|
||||
if (!c.isRect) poly++;
|
||||
// 多单位格必须在路径中段(进出各一次),不许当端点——每个拼块都要被真正穿过
|
||||
const p = path.indexOf(c.index);
|
||||
if (p === 0 || p === n - 1) return { ok: false, reason: `${c.id} 当了端点`, big, poly, maxRunRatio: 0 };
|
||||
}
|
||||
}
|
||||
if (big === 0) return { ok: false, reason: '没有大格', big, poly, maxRunRatio: 0 };
|
||||
|
||||
// 最长直线段占比:蛇形盘一眼望穿,太顺不好看
|
||||
let maxRun = 1;
|
||||
let run = 1;
|
||||
const heading = (a: number, b: number): string => {
|
||||
const A = board.cells[a];
|
||||
const B = board.cells[b];
|
||||
const dx = B.x + B.w / 2 - (A.x + A.w / 2);
|
||||
const dy = B.y + B.h / 2 - (A.y + A.h / 2);
|
||||
return Math.abs(dx) > Math.abs(dy) ? 'h' : 'v';
|
||||
};
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
if (i > 1 && heading(path[i - 1], path[i]) === heading(path[i - 2], path[i - 1])) run++;
|
||||
else run = 1;
|
||||
maxRun = Math.max(maxRun, run);
|
||||
}
|
||||
const maxRunRatio = maxRun / (n - 1);
|
||||
if (maxRunRatio > 0.5) return { ok: false, reason: `最长直线 ${maxRun}/${n - 1} 太蛇形`, big, poly, maxRunRatio };
|
||||
return { ok: true, reason: '', big, poly, maxRunRatio };
|
||||
}
|
||||
|
||||
// ---------- 挑线索 ----------
|
||||
|
||||
/** CellDef 需要 shape 字段时从运行时 Cell 的 units 还原(uniqueWithClues 重建 Board 用) */
|
||||
function toCellDef(c: Board['cells'][number], clue?: number): CellDef {
|
||||
return {
|
||||
id: c.id,
|
||||
x: c.x,
|
||||
y: c.y,
|
||||
w: c.w,
|
||||
h: c.h,
|
||||
...(c.isRect ? {} : { shape: c.units.map(([ux, uy]) => [ux - c.x, uy - c.y] as [number, number]) }),
|
||||
...(clue !== undefined ? { clue } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueWithClues(board: Board, clues: Map<number, number>): boolean {
|
||||
const defs = board.cells.map((c) => toCellDef(c, clues.get(c.index)));
|
||||
return solve(Board.fromCells(defs), { limit: 2 }).length === 1;
|
||||
}
|
||||
|
||||
function clueScore(board: Board, author: number[], steps: number[]): number {
|
||||
const n = author.length;
|
||||
const sorted = steps.slice().sort((a, b) => a - b);
|
||||
let score = 0;
|
||||
for (const s of sorted) if (s === n) score += 3; // 少用终点
|
||||
const ideal = n / (sorted.length + 1);
|
||||
for (let i = 1; i < sorted.length; i++) score += Math.abs(sorted[i] - sorted[i - 1] - ideal) * 0.2;
|
||||
// 线索格在盘面上两两相邻 → 读起来挤,扣分
|
||||
const cells = sorted.map((s) => author[s - 1]);
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
for (let j = i + 1; j < cells.length; j++) if (board.areAdjacent(cells[i], cells[j])) score += 1.5;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
function combinations(pool: number[], k: number, cap: number, rand: () => number): number[][] {
|
||||
const total = pool.length;
|
||||
// 组合数不大就全枚举,太大就随机采样
|
||||
let count = 1;
|
||||
for (let i = 0; i < k; i++) count = (count * (total - i)) / (i + 1);
|
||||
if (count <= cap) {
|
||||
const out: number[][] = [];
|
||||
const combo: number[] = [];
|
||||
const rec = (from: number): void => {
|
||||
if (combo.length === k) {
|
||||
out.push(combo.slice());
|
||||
return;
|
||||
}
|
||||
for (let i = from; i < pool.length; i++) {
|
||||
combo.push(pool[i]);
|
||||
rec(i + 1);
|
||||
combo.pop();
|
||||
}
|
||||
};
|
||||
rec(0);
|
||||
return out;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const out: number[][] = [];
|
||||
while (out.length < cap) {
|
||||
const combo = pool.slice().sort(() => rand() - 0.5).slice(0, k).sort((a, b) => a - b);
|
||||
const key = combo.join(',');
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
out.push(combo);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 挑线索。
|
||||
* 带线索 1:固定「1 + 引导步 guide∈[4,7]」——开局 5~6 步内有近距指引,玩家知道从哪起手;
|
||||
* 再从 guide+2..n 补 1~2 个线索凑唯一(难度由后面的差距承担)。
|
||||
* 无线索 1:k 从 2 升到 4 纯搜索。
|
||||
*/
|
||||
function pickClues(
|
||||
board: Board,
|
||||
author: number[],
|
||||
withStart: boolean,
|
||||
rand: () => number,
|
||||
): { steps: number[]; score: number; guide: number | null } | null {
|
||||
const n = author.length;
|
||||
const fixed = withStart ? [1, 4 + Math.floor(rand() * 4)] : [];
|
||||
const pool: number[] = [];
|
||||
for (let s = withStart ? fixed[1] + 2 : 2; s <= n; s++) pool.push(s);
|
||||
const maxK = 4;
|
||||
for (let k = Math.max(2, fixed.length + 1); k <= maxK; k++) {
|
||||
const need = k - fixed.length;
|
||||
if (need > pool.length) continue;
|
||||
let best: { steps: number[]; score: number } | null = null;
|
||||
for (const combo of combinations(pool, need, 2000, rand)) {
|
||||
const steps = [...fixed, ...combo];
|
||||
const clues = new Map(steps.map((s) => [author[s - 1], s]));
|
||||
if (uniqueWithClues(board, clues)) {
|
||||
const sc = clueScore(board, author, steps);
|
||||
if (!best || sc < best.score) best = { steps, score: sc };
|
||||
}
|
||||
}
|
||||
if (best) return { ...best, guide: withStart ? fixed[1] : null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- 难度分 ----------
|
||||
|
||||
/** 相邻线索步号差(含最后一条到终点 N 的尾段) */
|
||||
export function clueGaps(steps: number[], n: number): number[] {
|
||||
const sorted = steps.slice().sort((a, b) => a - b);
|
||||
const gaps: number[] = [];
|
||||
for (let i = 1; i < sorted.length; i++) gaps.push(sorted[i] - sorted[i - 1]);
|
||||
gaps.push(n - sorted[sorted.length - 1]);
|
||||
return gaps;
|
||||
}
|
||||
|
||||
function difficulty(c: Candidate): number {
|
||||
const maxGap = Math.max(...clueGaps(c.clueSteps, c.n));
|
||||
return (
|
||||
maxGap * 7 +
|
||||
c.n * 1.2 +
|
||||
Math.log10(c.clueFree + 1) * 5 -
|
||||
c.clueSteps.length * 3 +
|
||||
c.big * 1.5 +
|
||||
(c.noStart ? 12 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
interface Candidate {
|
||||
cells: CellDef[];
|
||||
n: number;
|
||||
big: number;
|
||||
poly: number;
|
||||
holes: number;
|
||||
clueFree: number;
|
||||
clueSteps: number[];
|
||||
guide: number | null;
|
||||
noStart: boolean;
|
||||
maxRunRatio: number;
|
||||
solution: number[];
|
||||
score: number;
|
||||
}
|
||||
|
||||
// ---------- 主流程 ----------
|
||||
|
||||
const opts = parseOpts();
|
||||
const rand = mulberry32(opts.seed);
|
||||
const pool: Candidate[] = [];
|
||||
const rejections = new Map<string, number>();
|
||||
const reject = (why: string): void => {
|
||||
rejections.set(why, (rejections.get(why) ?? 0) + 1);
|
||||
};
|
||||
|
||||
console.log(`seed=${opts.seed} 目标: ${opts.levels} 关,候选池 ${opts.pool},最多尝试 ${opts.attempts} 次`);
|
||||
|
||||
for (let attempt = 0; attempt < opts.attempts && pool.length < opts.pool; attempt++) {
|
||||
if (attempt % 25 === 24) console.log(` … 已尝试 ${attempt + 1} 次,候选 ${pool.length}`);
|
||||
const raw = randomBoardCells(rand, 13, 21);
|
||||
if (!raw) {
|
||||
reject('格数不在 13..21');
|
||||
continue;
|
||||
}
|
||||
const cells = assignIds(raw.cells);
|
||||
let board: Board;
|
||||
try {
|
||||
board = Board.fromCells(cells);
|
||||
} catch {
|
||||
reject('盘面非法');
|
||||
continue;
|
||||
}
|
||||
if (!board.isConnected()) {
|
||||
reject('不连通');
|
||||
continue;
|
||||
}
|
||||
const author = randomHamilton(board, rand);
|
||||
if (!author) {
|
||||
reject('没有哈密顿路径');
|
||||
continue;
|
||||
}
|
||||
const b = beauty(board, author);
|
||||
if (!b.ok) {
|
||||
reject(b.reason);
|
||||
continue;
|
||||
}
|
||||
const clueFree = solve(board, { ignoreClues: true, limit: 600 }).length;
|
||||
if (clueFree < opts.minFree) {
|
||||
reject(`无线索路径太少 (${clueFree})`);
|
||||
continue;
|
||||
}
|
||||
const noStart = rand() < 0.18;
|
||||
const picked = pickClues(board, author, !noStart, rand);
|
||||
if (!picked) {
|
||||
reject('4 个线索仍不唯一');
|
||||
continue;
|
||||
}
|
||||
pool.push({
|
||||
cells,
|
||||
n: board.n,
|
||||
big: b.big,
|
||||
poly: b.poly,
|
||||
holes: raw.holes,
|
||||
clueFree,
|
||||
clueSteps: picked.steps.slice().sort((x, y) => x - y),
|
||||
guide: picked.guide,
|
||||
noStart,
|
||||
maxRunRatio: b.maxRunRatio,
|
||||
solution: author,
|
||||
score: 0,
|
||||
});
|
||||
}
|
||||
for (const c of pool) c.score = difficulty(c);
|
||||
|
||||
console.log(`\n候选 ${pool.length} 个。淘汰原因:`);
|
||||
for (const [why, cnt] of [...rejections.entries()].sort((a, b) => b[1] - a[1])) console.log(` ${why} × ${cnt}`);
|
||||
|
||||
if (pool.length < opts.levels) {
|
||||
console.error(`\n候选不够(${pool.length} < ${opts.levels}),放宽过滤或加 --attempts`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ---------- 选拔:偏难 65% 里等距取,保证花样 ----------
|
||||
|
||||
pool.sort((a, b) => a.score - b.score);
|
||||
const hard = pool.slice(Math.floor(pool.length * 0.35));
|
||||
const picks: Candidate[] = [];
|
||||
const used = new Set<Candidate>();
|
||||
for (let i = 0; i < opts.levels; i++) {
|
||||
const idx = Math.round((i * (hard.length - 1)) / (opts.levels - 1));
|
||||
picks.push(hard[idx]);
|
||||
used.add(hard[idx]);
|
||||
}
|
||||
// 无线索 1:至少 1 关、至多 2 关(稀缺才新鲜),且必须在第 3 关之后(GDD:13 关之后才出现)
|
||||
const MAX_NOSTART = 2;
|
||||
if (!picks.some((c) => c.noStart)) {
|
||||
const alt = [...pool].reverse().find((c) => c.noStart && !used.has(c));
|
||||
if (alt) {
|
||||
picks[Math.max(3, opts.levels - 3)] = alt;
|
||||
used.add(alt);
|
||||
} else {
|
||||
console.log('\n⚠ 池里没有无线索 1 的候选,本包全带线索 1');
|
||||
}
|
||||
}
|
||||
for (let i = picks.length - 1; i >= 0 && picks.filter((c) => c.noStart).length > MAX_NOSTART; i--) {
|
||||
if (!picks[i].noStart) continue;
|
||||
const alt = [...pool].reverse().find((c) => !c.noStart && !used.has(c));
|
||||
if (!alt) break;
|
||||
used.delete(picks[i]);
|
||||
picks[i] = alt;
|
||||
used.add(alt);
|
||||
}
|
||||
picks.sort((a, b) => a.score - b.score);
|
||||
// 无线索 1 不许落在前 3(GDD:13 关之后);用搬移而不是交换,保持其余关的难度顺序
|
||||
for (let i = 0; i < Math.min(3, picks.length); i++) {
|
||||
if (picks[i].noStart) {
|
||||
const [c] = picks.splice(i, 1);
|
||||
picks.splice(Math.min(3, picks.length), 0, c);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 输出 ----------
|
||||
|
||||
const levels: LevelDef[] = picks.map((c, i) => {
|
||||
const clueOf = new Map<number, number>();
|
||||
for (const s of c.clueSteps) clueOf.set(c.solution[s - 1], s);
|
||||
const cells: CellDef[] = c.cells.map((cell, idx) => ({
|
||||
...cell,
|
||||
...(clueOf.has(idx) ? { clue: clueOf.get(idx)! } : {}),
|
||||
}));
|
||||
// cells 顺序变了,solution 下标基于 Board.fromCells(cells) 的顺序 —— assignIds 已排序,这里保持一致
|
||||
const board = Board.fromCells(cells);
|
||||
const gaps = clueGaps(c.clueSteps, c.n);
|
||||
return {
|
||||
id: `${opts.pack}-${i + 1}`,
|
||||
teach:
|
||||
`自动生成 seed=${opts.seed}。N=${c.n} 多单位格=${c.big}(异形${c.poly}) 空洞=${c.holes} ` +
|
||||
`无线索路径=${c.clueFree >= 600 ? '600+' : c.clueFree} 线索=${c.clueSteps.join('/')}${c.noStart ? '(无线索1)' : ''} ` +
|
||||
`步号差=${gaps.join('/')} 难度分=${c.score.toFixed(1)}。作者解: ${pathToIds(board, c.solution)}`,
|
||||
cells,
|
||||
};
|
||||
});
|
||||
|
||||
console.log(`\n== 选出 ${levels.length} 关(按难度分升序)==`);
|
||||
levels.forEach((lv, i) => {
|
||||
const c = picks[i];
|
||||
const board = Board.fromCells(lv.cells);
|
||||
const check = solve(board, { limit: 2 });
|
||||
console.log(
|
||||
`\n${lv.id} N=${c.n} 多单位格=${c.big}(异形${c.poly}) 空洞=${c.holes} 无线索=${c.clueFree >= 600 ? '600+' : c.clueFree} ` +
|
||||
`线索=${c.clueSteps.join('/')}${c.noStart ? '(无1)' : ''} 步号差=${clueGaps(c.clueSteps, c.n).join('/')} 分=${c.score.toFixed(1)} 解数=${check.length}`,
|
||||
);
|
||||
console.log(renderAscii(board, c.solution));
|
||||
});
|
||||
|
||||
if (opts.out) {
|
||||
const cellJson = (cell: CellDef): string => {
|
||||
const parts = [`"id": ${JSON.stringify(cell.id)}`, `"x": ${cell.x}`, `"y": ${cell.y}`, `"w": ${cell.w}`, `"h": ${cell.h}`];
|
||||
if (cell.shape) parts.push(`"shape": ${JSON.stringify(cell.shape)}`);
|
||||
if (cell.clue !== undefined) parts.push(`"clue": ${cell.clue}`);
|
||||
return `{ ${parts.join(', ')} }`;
|
||||
};
|
||||
const levelJson = (lv: LevelDef): string =>
|
||||
[
|
||||
` {`,
|
||||
` "id": ${JSON.stringify(lv.id)},`,
|
||||
` "teach": ${JSON.stringify(lv.teach)},`,
|
||||
` "cells": [`,
|
||||
lv.cells.map((cell) => ` ${cellJson(cell)}`).join(',\n'),
|
||||
` ]`,
|
||||
` }`,
|
||||
].join('\n');
|
||||
const pack: PackDef = {
|
||||
mode: '01-number-stroke',
|
||||
pack: opts.pack,
|
||||
title: opts.title,
|
||||
notes:
|
||||
`${opts.levels} 关,tools/generate.ts 自动生成(seed=${opts.seed})+ 人工过目。15+ 格、俄罗斯方块异形格、` +
|
||||
`开局 5~7 步内有引导线索、之后靠步号差上难度,部分关不带线索 1。`,
|
||||
levels,
|
||||
};
|
||||
const json =
|
||||
`{\n "mode": ${JSON.stringify(pack.mode)},\n "pack": ${pack.pack},\n "title": ${JSON.stringify(pack.title)},\n` +
|
||||
` "notes": ${JSON.stringify(pack.notes)},\n "levels": [\n${levels.map(levelJson).join(',\n')}\n ]\n}\n`;
|
||||
writeFileSync(opts.out, json);
|
||||
console.log(`\n已写入 ${opts.out}`);
|
||||
} else {
|
||||
console.log('\n(未指定 --out,只打印不写文件)');
|
||||
}
|
||||
Reference in New Issue
Block a user