Files
NumberSwipe_New/modes/03-hex-walls/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

114 lines
5.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.
// 关卡设计辅助:
// 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(' 没有');
}