初始提交:三玩法 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
+53
View File
@@ -0,0 +1,53 @@
// 终端字符盘面:点顶六边形按行错位显示。每格 4 字符宽,相邻行错开 2 字符。
import type { HexBoard } from '../src/board';
export function renderAscii(board: HexBoard, path?: number[]): string {
const stepOf = new Map<number, number>();
if (path) path.forEach((c, i) => stepOf.set(c, i + 1));
let minR = Infinity;
let maxR = -Infinity;
let minX = Infinity;
for (const c of board.cells) {
minR = Math.min(minR, c.r);
maxR = Math.max(maxR, c.r);
minX = Math.min(minX, 2 * c.q + c.r);
}
const lines: string[] = [];
for (let r = minR; r <= maxR; r++) {
const row = board.cells.filter((c) => c.r === r).sort((a, b) => a.q - b.q);
let line = '';
for (const c of row) {
const col = (2 * c.q + c.r - minX) * 2;
while (line.length < col) line += ' ';
let tok: string;
if (path) {
const s = stepOf.get(c.index);
tok = s === undefined ? ' . ' : String(s).padStart(2) + ' ';
if (c.clue !== null && s !== undefined) tok = String(s).padStart(2) + (s === c.clue ? '*' : '!');
} else {
tok = c.clue !== null ? `[${c.clue}]`.padStart(3) : ` ${c.id.slice(0, 2).padEnd(2)}`;
}
line += tok.padEnd(4);
}
lines.push(line.replace(/\s+$/, ''));
}
if (board.walls.length) {
lines.push('墙: ' + board.walls.map((w) => `${board.cells[w.a].id}|${board.cells[w.b].id}`).join(' '));
}
if (board.spinners.length) {
lines.push(
'转墙: ' +
board.spinners
.map((sp) => {
const e = sp.edges.map((x) => (x ? `${board.cells[x[0]].id}|${board.cells[x[1]].id}` : '外')).join(' → ');
return `${board.cells[sp.cell].id}/${sp.corner} 相位${sp.phase} [${e}]`;
})
.join(' '),
);
}
return lines.join('\n');
}
export function pathToIds(board: HexBoard, path: number[]): string {
return path.map((i) => board.cells[i].id).join(',');
}
+113
View File
@@ -0,0 +1,113 @@
// 关卡设计辅助:
// npm run design -- 1-7 打印盘面、无线索路径数、当前解
// npm run design -- 1-7 --minimize 当前线索里哪些可删(删掉仍唯一)
// npm run design -- 1-7 --walls 哪些墙可删(删掉仍唯一)
// npm run design -- 1-7 --suggest 3 --path a,b,c,... 给作者解找最少线索(≤3 个)
// npm run design -- /path/draft.json --level 1-7 ... 也可以指定临时关卡文件
import { readdirSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { HexBoard } from '../src/board';
import { loadPack, parsePack, type LoadedLevel } from '../src/levels';
import { redundantWalls, solve, spinnerReport, wallsMatter } from '../src/solver';
import type { LevelDef } from '../src/types';
import { pathToIds, renderAscii } from './ascii';
const here = dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const flag = (name: string): string | undefined => {
const i = argv.indexOf(name);
return i >= 0 ? argv[i + 1] : undefined;
};
function findLevel(): LoadedLevel {
const target = argv[0];
if (!target) throw new Error('用法: npm run design -- <关卡id | 文件> [--level id] [--minimize|--walls|--suggest k --path ...]');
const files = target.endsWith('.json')
? [target]
: readdirSync(join(here, '..', 'levels'))
.filter((f) => f.endsWith('.json'))
.map((f) => join(here, '..', 'levels', f));
const wantId = target.endsWith('.json') ? flag('--level') : target;
for (const f of files) {
const pack = parsePack(JSON.parse(readFileSync(f, 'utf8')));
const levels = loadPack(pack);
const lv = wantId ? levels.find((l) => l.def.id === wantId) : levels[0];
if (lv) return lv;
}
throw new Error(`找不到关卡 ${wantId ?? target}`);
}
function withClues(def: LevelDef, clueOf: Map<string, number>): HexBoard {
return HexBoard.fromCells(
def.cells.map((c) => ({ id: c.id, q: c.q, r: c.r, clue: clueOf.get(c.id) })),
def.walls ?? [],
def.spinners ?? [],
);
}
const lv = findLevel();
const b = lv.board;
console.log(`\n${lv.def.id} N=${b.n} 墙=${b.walls.length}`);
console.log(renderAscii(b).replace(/^/gm, ' '));
const free = solve(b, { ignoreClues: true, limit: 2000 });
console.log(`无线索哈密顿路径: ${free.length >= 2000 ? '2000+' : free.length}`);
const cur = solve(b, { limit: 3 });
console.log(`当前线索下的解: ${cur.length}${cur.length === 1 ? '(唯一)' : ''}`);
for (const p of cur) console.log(' ' + pathToIds(b, p));
if (cur.length === 1) {
console.log(renderAscii(b, cur[0]).replace(/^/gm, ' '));
console.log(`去墙后解: ${solve(b.withoutWalls(), { limit: 100 }).length} 墙参与唯一性: ${wallsMatter(b) ? '是' : '否'}`);
for (const r of spinnerReport(b)) {
console.log(`转墙 ${r.index}: ${r.removable ? '冗余' : '必要'};${r.freezable.length ? `可换成静态墙(边 ${r.freezable.join('/')})` : '不能用静态墙替代'}`);
}
}
if (argv.includes('--minimize') && cur.length === 1) {
const clues = b.cells.filter((c) => c.clue !== null);
for (const c of clues) {
const m = new Map(clues.filter((x) => x !== c).map((x) => [x.id, x.clue!]));
const n = solve(withClues(lv.def, m), { limit: 2 }).length;
console.log(` 删掉 ${c.id}=${c.clue}: ${n === 1 ? '仍唯一(可删)' : n === 0 ? '无解' : '多解(必须留)'}`);
}
}
if (argv.includes('--walls') && cur.length === 1) {
const r = redundantWalls(b);
console.log(r.length ? `可删的墙: ${r.map((i) => `${b.cells[b.walls[i].a].id}|${b.cells[b.walls[i].b].id}`).join(', ')}` : '每堵墙都必要');
}
const suggestK = flag('--suggest');
if (suggestK) {
const pathIds = (flag('--path') ?? '').split(',').filter(Boolean);
const path = pathIds.length ? pathIds.map((id) => b.cellById(id).index) : cur[0];
if (!path || path.length !== b.n) throw new Error('--path 必须是覆盖全部格的作者解');
const k = Number(suggestK);
const stepOf = new Map<number, number>();
path.forEach((c, i) => stepOf.set(c, i + 1));
const cells = b.cells.map((c) => c.index);
const results: Array<{ ids: string[]; score: number }> = [];
const rec = (start: number, chosen: number[]): void => {
if (chosen.length > 0) {
const m = new Map(chosen.map((c) => [b.cells[c].id, stepOf.get(c)!]));
const n = solve(withClues(lv.def, m), { limit: 2 }).length;
if (n === 1) {
const steps = chosen.map((c) => stepOf.get(c)!).sort((x, y) => x - y);
// 偏好:线索少、少用端点、分布均匀
let score = chosen.length * 10;
for (const s of steps) if (s === 1 || s === b.n) score += 1;
for (let i = 1; i < steps.length; i++) score += Math.abs(steps[i] - steps[i - 1] - b.n / (steps.length + 1)) * 0.1;
results.push({ ids: chosen.map((c) => `${b.cells[c].id}=${stepOf.get(c)}`), score });
return;
}
}
if (chosen.length === k) return;
for (let i = start; i < cells.length; i++) rec(i + 1, [...chosen, cells[i]]);
};
rec(0, []);
results.sort((x, y) => x.score - y.score);
console.log(`\n作者解: ${pathToIds(b, path)}`);
console.log(`≤${k} 个线索可唯一的组合(前 12):`);
for (const r of results.slice(0, 12)) console.log(' ' + r.ids.join(' '));
if (!results.length) console.log(' 没有');
}
+189
View File
@@ -0,0 +1,189 @@
// 生成器(GDD §8.2):铺一块六边形聚落 → 随机作者解 → 在非解边上插墙 → 挑线索直到唯一 → 去墙不唯一检查。
// 只用来出候选,人过一眼再进包。
// npm run generate -- --shape hex2 --holes 2 --walls 3 --clues 3 --seed 7 --count 5
// npm run generate -- --from draft.json --level 1-9 --walls 2 --clues 2
// --spinners 1 额外放转墙(放在作者解不会被挡的顶点上;候选必须“去掉转墙不唯一、换静态墙也不唯一”)
// shape: hex1(7) hex2(19) tri4(10) tri5(15) para3x4(12) para4x4(16) rect4x3(12) rect5x3(15) rect4x4(16)
import { readFileSync } from 'node:fs';
import { HexBoard } from '../src/board';
import { loadPack, parsePack } from '../src/levels';
import { redundantWalls, solve, spinnerReport, wallsMatter } from '../src/solver';
import type { HexCellDef, SpinnerDef, WallDef } from '../src/types';
import { pathToIds, renderAscii } from './ascii';
const argv = process.argv.slice(2);
const flag = (name: string, def: string): string => {
const i = argv.indexOf(name);
return i >= 0 ? argv[i + 1] : def;
};
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shape(name: string): Array<[number, number]> {
const out: Array<[number, number]> = [];
const m = /^(hex|tri|para|rect)(\d)(?:x(\d))?$/.exec(name);
if (!m) throw new Error(`未知形状 ${name}`);
const a = Number(m[2]);
const b = Number(m[3] ?? a);
if (m[1] === 'hex') {
for (let q = -a; q <= a; q++) for (let r = Math.max(-a, -q - a); r <= Math.min(a, -q + a); r++) out.push([q, r]);
} else if (m[1] === 'tri') {
for (let r = 0; r < a; r++) for (let q = 0; q < a - r; q++) out.push([q, r]);
} else if (m[1] === 'para') {
for (let r = 0; r < b; r++) for (let q = 0; q < a; q++) out.push([q, r]);
} else {
// 矩形(偶行错位):每行 a 个,共 b 行
for (let r = 0; r < b; r++) for (let x = 0; x < a; x++) out.push([x - Math.floor(r / 2), r]);
}
return out;
}
const rnd = mulberry32(Number(flag('--seed', String(Date.now() % 100000))));
const wantWalls = Number(flag('--walls', '2'));
const wantClues = Number(flag('--clues', '3'));
const holes = Number(flag('--holes', '0'));
const wantSpinners = Number(flag('--spinners', '0'));
const count = Number(flag('--count', '3'));
const maxTries = Number(flag('--tries', '400'));
const from = flag('--from', '');
let baseCells: HexCellDef[];
if (from) {
const pack = parsePack(JSON.parse(readFileSync(from, 'utf8')));
const lv = loadPack(pack).find((l) => l.def.id === flag('--level', pack.levels[0].id))!;
baseCells = lv.def.cells.map((c) => ({ id: c.id, q: c.q, r: c.r }));
} else {
const ids = 'abcdefghijklmnopqrstuvwxyz';
baseCells = shape(flag('--shape', 'hex1')).map(([q, r], i) => ({ id: ids[i] ?? `c${i}`, q, r }));
}
interface Candidate {
cells: HexCellDef[];
walls: WallDef[];
spinners: SpinnerDef[];
path: number[];
freePaths: number;
noWallPaths: number;
board: HexBoard;
}
const found: Candidate[] = [];
let tries = 0;
while (found.length < count && tries < maxTries) {
tries++;
// 1. 挖洞(保证连通)
let cells = baseCells.map((c) => ({ ...c }));
for (let h = 0; h < holes; h++) {
for (let attempt = 0; attempt < 20; attempt++) {
const i = Math.floor(rnd() * cells.length);
const trial = cells.filter((_, j) => j !== i);
if (HexBoard.fromCells(trial).isConnected()) {
cells = trial;
break;
}
}
}
const plain = HexBoard.fromCells(cells);
// 2. 随机作者解
const p = solve(plain, { ignoreClues: true, limit: 1, random: rnd });
if (!p.length) continue;
const path = p[0];
const onPath = new Set<string>();
for (let i = 0; i + 1 < path.length; i++) onPath.add(`${Math.min(path[i], path[i + 1])}-${Math.max(path[i], path[i + 1])}`);
// 3. 在非解边上插墙
const edges: Array<[number, number]> = [];
for (let i = 0; i < plain.n; i++) for (const j of plain.geoAdj[i]) if (i < j && !onPath.has(`${i}-${j}`)) edges.push([i, j]);
for (let i = edges.length - 1; i > 0; i--) {
const j = Math.floor(rnd() * (i + 1));
[edges[i], edges[j]] = [edges[j], edges[i]];
}
const walls: WallDef[] = edges.slice(0, wantWalls).map(([a, b]) => [plain.cells[a].id, plain.cells[b].id]);
// 3b. 转墙:随机顶点 + 相位,要求作者解每一步都不被挡
const spinners: SpinnerDef[] = [];
if (wantSpinners > 0) {
const stepOfCell = new Map<number, number>();
path.forEach((c, i) => stepOfCell.set(c, i + 1));
for (let attempt = 0; attempt < 60 && spinners.length < wantSpinners; attempt++) {
const cell = plain.cells[Math.floor(rnd() * plain.n)];
const def: SpinnerDef = { cell: cell.id, corner: Math.floor(rnd() * 6), phase: Math.floor(rnd() * 3) };
let b: HexBoard;
try {
b = HexBoard.fromCells(cells, walls, [...spinners, def]);
} catch {
continue;
}
// 作者解是否仍合法
let legal = true;
for (let i = 0; i + 1 < path.length && legal; i++) legal = b.canStep(path[i], path[i + 1], i + 1);
if (!legal) continue;
// 至少要挡到作者解经过的某个格周围的边才有意义:三条边至少两条在盘上
spinners.push(def);
}
if (spinners.length < wantSpinners) continue;
}
// 4. 挑线索:从含 1 开始,逐个加最“区分”的线索直到唯一
const stepOf = new Map<number, number>();
path.forEach((c, i) => stepOf.set(c, i + 1));
const chosen = new Set<number>([path[0]]);
const build = (): HexBoard =>
HexBoard.fromCells(
cells.map((c, i) => ({ ...c, clue: chosen.has(i) ? stepOf.get(i) : undefined })),
walls,
spinners,
);
let board = build();
let ok = solve(board, { limit: 2 }).length === 1;
while (!ok && chosen.size < wantClues) {
let best = -1;
let bestCount = Infinity;
const order = cells.map((_, i) => i).sort(() => rnd() - 0.5);
for (const i of order) {
if (chosen.has(i)) continue;
chosen.add(i);
const n = solve(build(), { limit: 50 }).length;
chosen.delete(i);
if (n < bestCount) {
bestCount = n;
best = i;
}
if (n === 1) break;
}
if (best < 0) break;
chosen.add(best);
board = build();
ok = bestCount === 1;
}
if (!ok) continue;
// 5. 去墙不唯一 + 每堵墙都必要
if ((walls.length || spinners.length) && !wallsMatter(board)) continue;
if (redundantWalls(board).length) continue;
if (spinnerReport(board).some((r) => r.removable || r.freezable.length)) continue;
found.push({
cells: board.cells.map((c) => ({ id: c.id, q: c.q, r: c.r, ...(c.clue !== null ? { clue: c.clue } : {}) })),
walls,
spinners,
path,
freePaths: solve(plain, { ignoreClues: true, limit: 500 }).length,
noWallPaths: solve(board.withoutWalls(), { limit: 500 }).length,
board,
});
}
console.log(`尝试 ${tries} 次,得到 ${found.length} 个候选\n`);
for (const c of found) {
console.log(`--- N=${c.cells.length} 墙=${c.walls.length}${c.spinners.length ? '+转' + c.spinners.length : ''} 线索=${c.cells.filter((x) => x.clue).length} 无线索路径=${c.freePaths} 去墙解=${c.noWallPaths}`);
console.log(renderAscii(c.board).replace(/^/gm, ' '));
console.log(renderAscii(c.board, c.path).replace(/^/gm, ' '));
console.log(' 解: ' + pathToIds(c.board, c.path));
console.log(' JSON: ' + JSON.stringify({ cells: c.cells, walls: c.walls, spinners: c.spinners }));
console.log();
}
+73
View File
@@ -0,0 +1,73 @@
// 发布前校验:levels/*.json 每一关必须连通、恰好 1 条满足线索的哈密顿路径;
// 第 3 关起至少 1 堵墙,且去掉全部墙后解不再唯一(GDD §4.6)。
// 用法:npm run validate (全部包)
// npm run validate -- 1-7 (只看某关,并打印解)
import { readdirSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadPack, parsePack } from '../src/levels';
import { redundantWalls, solve, spinnerReport, wallsMatter } from '../src/solver';
import { pathToIds, renderAscii } from './ascii';
const here = dirname(fileURLToPath(import.meta.url));
const levelsDir = join(here, '..', 'levels');
const only = process.argv[2];
let failed = 0;
let total = 0;
for (const file of readdirSync(levelsDir).filter((f) => f.endsWith('.json')).sort()) {
const pack = parsePack(JSON.parse(readFileSync(join(levelsDir, file), 'utf8')));
console.log(`\n== ${file} · ${pack.title} · ${pack.levels.length} 关`);
let levels;
try {
levels = loadPack(pack);
} catch (e) {
console.log(` ✗ 解析失败: ${(e as Error).message}`);
failed++;
continue;
}
for (const lv of levels) {
if (only && lv.def.id !== only) continue;
total++;
const b = lv.board;
const clues = b.cells.filter((c) => c.clue !== null).map((c) => c.clue!).sort((a, z) => a - z);
const t0 = performance.now();
const paths = solve(b, { limit: 3 });
const ms = (performance.now() - t0).toFixed(1);
const free = solve(b, { ignoreClues: true, limit: 500 }).length;
const noWall = solve(b.withoutWalls(), { limit: 500 }).length;
const problems: string[] = [];
if (!b.isConnected()) problems.push('去墙后不连通');
if (paths.length === 0) problems.push('无解');
if (paths.length > 1) problems.push(`多解 (≥${paths.length})`);
const wallCount = b.walls.length + b.spinners.length;
if (lv.index >= 3 || pack.pack >= 2) {
if (wallCount === 0) problems.push('第 3 关起每关至少 1 堵墙');
else if (paths.length === 1 && !wallsMatter(b)) problems.push('去墙后仍唯一,墙没参与推理');
}
const redundant = paths.length === 1 ? redundantWalls(b) : [];
if (paths.length === 1) {
for (const r of spinnerReport(b)) {
if (r.removable) problems.push(`转墙 ${r.index} 冗余`);
if (r.freezable.length) problems.push(`转墙 ${r.index} 可换成静态墙(边 ${r.freezable.join('/')})`);
}
}
const ok = problems.length === 0;
if (!ok) failed++;
const mark = ok ? '✓' : '✗';
console.log(
` ${mark} ${lv.def.id.padEnd(5)} N=${String(b.n).padStart(2)} 墙=${b.walls.length}${b.spinners.length ? '+转' + b.spinners.length : ''} 线索=${clues.join(',').padEnd(10)} 无线索路径=${String(free >= 500 ? '500+' : free).padStart(4)} 去墙解=${String(noWall >= 500 ? '500+' : noWall).padStart(4)} ${ms}ms` +
(redundant.length ? ` 可删墙=${redundant.map((i) => `${b.cells[b.walls[i].a].id}|${b.cells[b.walls[i].b].id}`).join(',')}` : '') +
(ok ? '' : ` ← ${problems.join(';')}`),
);
if (only || !ok) {
console.log(renderAscii(b).replace(/^/gm, ' '));
for (const p of paths) console.log(' 解: ' + pathToIds(b, p));
if (only && paths.length === 1) console.log(renderAscii(b, paths[0]).replace(/^/gm, ' '));
}
}
}
console.log(`\n${total - failed}/${total} 关通过`);
process.exit(failed ? 1 : 0);