初始提交:三玩法 Web 原型与设计文档
- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试 - 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本 - 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试 - 产品文档、UI 设计规范、各玩法 GDD / HANDOFF - 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user