初始提交:三玩法 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
+114
View File
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { HexBoard } from '../src/board';
import { Game } from '../src/game';
import type { HexCellDef } from '../src/types';
const c = (id: string, q: number, r: number, clue?: number): HexCellDef => ({ id, q, r, clue });
// 1-3:a(1) b / c d(4),墙 a|b
const level13 = () => HexBoard.fromCells([c('a', 0, 0, 1), c('b', 1, 0), c('c', 0, 1), c('d', 1, 1, 4)], [['a', 'b']]);
test('有线索 1 时只能从它起手', () => {
const g = new Game(level13());
assert.deepEqual(g.apply({ type: 'start', cell: 1 }), [{ type: 'rejectStart', cell: 1, clueCell: 0 }]);
assert.equal(g.status, 'empty');
assert.equal(g.apply({ type: 'start', cell: 0 })[0].type, 'step');
assert.equal(g.status, 'drawing');
});
test('无线索 1 时任意格可起手', () => {
const g = new Game(HexBoard.fromCells([c('a', 0, 0), c('b', 1, 0, 2)]));
assert.equal(g.apply({ type: 'start', cell: 0 })[0].type, 'step');
});
test('穿墙 → wallHit 事件,不追加', () => {
const b = level13();
const g = new Game(b);
g.apply({ type: 'start', cell: 0 });
const ev = g.apply({ type: 'append', cell: 1 });
assert.deepEqual(ev, [{ type: 'wallHit', from: 0, to: 1, wall: 0, spinner: -1 }]);
assert.equal(g.path.length, 1);
assert.deepEqual(g.wallTo(1), { wall: 0, spinner: -1 });
assert.equal(g.canAppend(1), false);
assert.equal(g.canAppend(2), true);
});
test('追加只能到相邻无墙未走格;冲突不打断;撤销恢复', () => {
const b = level13();
const g = new Game(b);
const id = (s: string) => b.cellById(s).index;
g.apply({ type: 'start', cell: id('a') });
assert.deepEqual(g.apply({ type: 'append', cell: id('d') }), []); // (1,1) 与 (0,0) 不相邻
g.apply({ type: 'append', cell: id('c') });
const ev = g.apply({ type: 'append', cell: id('d') }); // d=3,冲突
assert.equal(ev[0].type, 'step');
assert.equal((ev[0] as { conflict: boolean }).conflict, true);
assert.equal(g.isConflict(id('d')), true);
const last = g.apply({ type: 'append', cell: id('b') });
assert.equal(g.path.length, 4);
assert.equal(g.status, 'drawing', '填满但有冲突不算过关');
assert.ok(last.some((e) => e.type === 'deadEnd'));
g.apply({ type: 'undo' });
const un = g.apply({ type: 'undo' });
assert.deepEqual(un, [{ type: 'unstep', cell: id('d'), step: 3 }]);
assert.equal(g.isConflict(id('d')), false);
});
test('全走完且线索全对 → cleared,之后锁住', () => {
const b = level13();
const g = new Game(b);
const id = (s: string) => b.cellById(s).index;
g.apply({ type: 'start', cell: id('a') });
g.apply({ type: 'append', cell: id('c') });
g.apply({ type: 'append', cell: id('b') });
const ev = g.apply({ type: 'append', cell: id('d') });
assert.ok(ev.some((e) => e.type === 'cleared'));
assert.ok(ev.some((e) => e.type === 'step' && e.clueHit));
assert.equal(g.status, 'cleared');
assert.deepEqual(g.apply({ type: 'undo' }), []);
assert.deepEqual(g.apply({ type: 'append', cell: 0 }), []);
});
test('release / resume / reset 流转', () => {
const g = new Game(level13());
g.apply({ type: 'start', cell: 0 });
g.apply({ type: 'release' });
assert.equal(g.status, 'idle');
g.apply({ type: 'resume' });
assert.equal(g.status, 'drawing');
assert.deepEqual(g.apply({ type: 'reset' }), [{ type: 'reset' }]);
assert.equal(g.status, 'empty');
assert.deepEqual(g.apply({ type: 'reset' }), [], '空盘重开不产生事件');
g.apply({ type: 'start', cell: 0 });
g.apply({ type: 'undo' });
assert.equal(g.status, 'empty');
});
test('转墙:每走一格转 120°,按已走格数判定', () => {
// 花形 7 格,轴心在中心格 o 的第 4 个角(顶部),周围三格 o / nw / ne
const cells = [c('o', 0, 0, 1), c('e', 1, 0), c('ne', 1, -1), c('nw', 0, -1), c('w', -1, 0), c('sw', -1, 1), c('se', 0, 1)];
const b = HexBoard.fromCells(cells, [], [{ cell: 'o', corner: 4, phase: 0 }]);
const sp = b.spinners[0];
const id = (s: string) => b.cellById(s).index;
// 角 4 夹在方向 240°(nw) 与 300°(ne) 之间:边 0 = nw-ne,边 1 = o-ne,边 2 = o-nw
assert.deepEqual(sp.edges[0], [Math.min(id('nw'), id('ne')), Math.max(id('nw'), id('ne'))]);
assert.deepEqual(sp.edges[1], [id('o'), id('ne')]);
assert.deepEqual(sp.edges[2], [id('o'), id('nw')]);
// t=0 挡 nw-ne;t=1 挡 o-ne;t=2 挡 o-nw
assert.equal(b.spinnerBlocking(id('nw'), id('ne'), 0), 0);
assert.equal(b.spinnerBlocking(id('o'), id('ne'), 1), 0);
assert.equal(b.spinnerBlocking(id('o'), id('nw'), 2), 0);
assert.equal(b.spinnerBlocking(id('o'), id('ne'), 0), -1);
const g = new Game(b);
g.apply({ type: 'start', cell: id('o') }); // 已走 1 格 → 墙挡 o-ne
const hit = g.apply({ type: 'append', cell: id('ne') });
assert.deepEqual(hit, [{ type: 'wallHit', from: id('o'), to: id('ne'), wall: -1, spinner: 0 }]);
assert.equal(g.apply({ type: 'append', cell: id('nw') })[0].type, 'step'); // t=1 时 o-nw 开着
// 现在已走 2 格 → 墙挡 o-nw(已走,无所谓);nw-ne 开着
assert.equal(g.apply({ type: 'append', cell: id('ne') })[0].type, 'step');
g.apply({ type: 'undo' });
g.apply({ type: 'undo' });
assert.equal(g.path.length, 1);
assert.deepEqual(g.apply({ type: 'append', cell: id('ne') })[0].type, 'wallHit', '撤销后墙转回去');
});