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