初始提交:三玩法 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
+144
View File
@@ -0,0 +1,144 @@
// 关卡设计辅助。GDD §8.2 的第 3–5 步:挑线索、验唯一、删到最少。
//
// npm run design -- 1-7 看这关:全部无线索路径、当前线索下的解
// npm run design -- 1-7 --minimize 当前线索里哪些可以删掉仍唯一
// npm run design -- 1-7 --suggest 2 以当前解为“作者解”,找 2 个线索(含 1)就唯一的方案
// npm run design -- file.json 也可以直接指定一个临时关卡文件(单关 LevelDef 或整包)
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Board } from '../src/board';
import { clueLabel, hasClue } from '../src/clue';
import { parsePack } from '../src/levels';
import { solve } from '../src/solver';
import type { CellDef, LevelDef } from '../src/types';
import { pathToIds, renderAscii } from './ascii';
const here = dirname(fileURLToPath(import.meta.url));
const args = process.argv.slice(2);
const target = args[0];
if (!target) {
console.log('用法: npm run design -- <关卡id|json文件> [--minimize] [--suggest k] [--path a,b,c]');
process.exit(1);
}
function findLevel(idOrFile: string): LevelDef {
if (idOrFile.endsWith('.json') && existsSync(idOrFile)) {
const raw = JSON.parse(readFileSync(idOrFile, 'utf8'));
if (Array.isArray(raw.levels)) return parsePack(raw).levels[0];
return raw as LevelDef;
}
const dir = join(here, '..', 'levels');
for (const f of readdirSync(dir).filter((x) => x.endsWith('.json'))) {
const pack = parsePack(JSON.parse(readFileSync(join(dir, f), 'utf8')));
const lv = pack.levels.find((l) => l.id === idOrFile);
if (lv) return lv;
}
throw new Error(`找不到关卡 ${idOrFile}`);
}
const level = findLevel(target);
const board = Board.fromLevel(level);
const clueCells = board.cells.filter((c) => c.clue !== null);
console.log(`\n${level.id} N=${board.n} 线索: ${board.cells.filter(hasClue).map((c) => `${c.id}=${clueLabel(c)}`).join(' ') || '无'}`);
console.log(renderAscii(board));
const allPaths = solve(board, { ignoreClues: true, limit: 2000 });
console.log(`\n无线索哈密顿路径: ${allPaths.length >= 2000 ? '2000+' : allPaths.length}`);
const withClues = solve(board, { limit: 50 });
console.log(`当前线索下的解: ${withClues.length}${withClues.length >= 50 ? '+' : ''}`);
for (const p of withClues.slice(0, 8)) console.log(' ' + pathToIds(board, p));
if (withClues.length === 1) console.log(renderAscii(board, withClues[0]));
// 作者解:--path 指定,否则用当前唯一解
let author: number[] | null = null;
const pathArg = args.indexOf('--path');
if (pathArg !== -1) {
author = args[pathArg + 1].split(',').map((id) => board.cellById(id.trim()).index);
} else if (withClues.length === 1) {
author = withClues[0];
}
function countWithClues(clues: Map<number, number>, limit = 2): number {
const defs: CellDef[] = level.cells.map(({ clue, ...c }, index) => ({
...c,
...(clues.has(index) ? { clue: clues.get(index)! } : {}),
}));
return solve(Board.fromCells(defs), { limit }).length;
}
if (args.includes('--minimize') && withClues.length === 1) {
console.log('\n[minimize] 尝试删线索:');
const current = new Map(clueCells.map((c) => [c.index, c.clue!]));
const removable: string[] = [];
for (const c of clueCells) {
const trial = new Map(current);
trial.delete(c.index);
const n = countWithClues(trial);
console.log(` 去掉 ${c.id}=${c.clue} → ${n === 1 ? '仍唯一 ✓' : n === 0 ? '无解' : '多解'}`);
if (n === 1) removable.push(`${c.id}=${c.clue}`);
}
for (const c of board.cells.filter((cell) => cell.parity !== null)) {
const defs = level.cells.map((d) => {
const { parity, ...rest } = d;
return d.id === c.id ? rest : d;
});
const n = solve(Board.fromCells(defs), { limit: 2 }).length;
console.log(` 去掉 ${c.id}=${clueLabel(c)} → ${n === 1 ? '仍唯一 ✓' : '多解'}`);
if (n === 1) removable.push(`${c.id}=${clueLabel(c)}`);
}
console.log(removable.length ? ` 单独可删: ${removable.join(', ')}` : ' 每个线索都必要');
}
const suggestArg = args.indexOf('--suggest');
if (suggestArg !== -1) {
if (!author) {
console.log('\n[suggest] 需要一条作者解:先让当前线索唯一,或用 --path 指定');
} else {
const k = Number(args[suggestArg + 1] ?? 2);
const n = author.length;
const keepStart = clueCells.some((c) => c.clue === 1);
console.log(`\n[suggest] 作者解: ${pathToIds(board, author)}`);
console.log(` 找 ${k} 个线索${keepStart ? '(含起点 1)' : ''} 使其唯一,按“少用端点、分布均匀”排序:`);
const positions: number[] = [];
for (let s = 1; s <= n; s++) if (board.cells[author[s - 1]].parity === null) positions.push(s);
const results: { clues: number[]; score: number }[] = [];
const combo: number[] = [];
const startFixed = keepStart ? [1] : [];
const pool = positions.filter((s) => !startFixed.includes(s));
const need = k - startFixed.length;
function rec(from: number): void {
if (combo.length === need) {
const steps = [...startFixed, ...combo];
const clues = new Map(steps.map((s) => [author![s - 1], s]));
if (countWithClues(clues) === 1) {
// 端点扣分;线索之间间距越均匀越好
const sorted = steps.slice().sort((a, b) => a - b);
let score = 0;
for (const s of sorted) if (s === 1 || s === n) score += 3;
for (let i = 1; i < sorted.length; i++) score += Math.abs(sorted[i] - sorted[i - 1] - n / (sorted.length + 1)) * 0.2;
results.push({ clues: steps, score });
}
return;
}
for (let i = from; i < pool.length; i++) {
combo.push(pool[i]);
rec(i + 1);
combo.pop();
}
}
rec(0);
results.sort((a, b) => a.score - b.score);
if (!results.length) console.log(' 没有方案,试试 k+1');
for (const r of results.slice(0, 12)) {
const desc = r.clues
.slice()
.sort((a, b) => a - b)
.map((s) => `${board.cells[author![s - 1]].id}=${s}`)
.join(' ');
console.log(` ${desc}`);
}
if (results.length > 12) console.log(` … 共 ${results.length} 个方案`);
}
}