初始提交:三玩法 Web 原型与设计文档

- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试
- 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本
- 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试
- 产品文档、UI 设计规范、各玩法 GDD / HANDOFF
- 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
This commit is contained in:
cabinw
2026-09-16 14:30:59 +08:00
commit 8026e4628d
169 changed files with 22162 additions and 0 deletions
+599
View File
@@ -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,只打印不写文件)');
}