// 关卡生成器。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): 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(); 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(); 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(); 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(); 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,只打印不写文件)'); }