Files
NumberSwipe_New/modes/01-number-stroke/tools/generate.ts
T
cabinw 8026e4628d 初始提交:三玩法 Web 原型与设计文档
- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试
- 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本
- 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试
- 产品文档、UI 设计规范、各玩法 GDD / HANDOFF
- 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
2026-09-16 14:30:59 +08:00

600 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 关卡生成器。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,只打印不写文件)');
}