Files
NumberSwipe_New/modes/01-number-stroke/tools/design.ts
T
cabinw 8026e4628d 初始提交:三玩法 Web 原型与设计文档
- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试
- 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本
- 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试
- 产品文档、UI 设计规范、各玩法 GDD / HANDOFF
- 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
2026-09-16 14:30:59 +08:00

145 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 关卡设计辅助。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} 个方案`);
}
}