初始提交:三玩法 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
@@ -0,0 +1,159 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { Board } from '../src/board';
import { Game } from '../src/game';
import { cellRect, fitBoard, type Rect } from '../src/layout';
import type { Painter } from '../src/painter';
import { THEME } from '../src/theme';
import type { CellDef } from '../src/types';
import { BoardView } from '../src/view';
// a(1) b c(3)
// d e f
// 正解可为 a b c f e d;错误分支 a d e 在第 3 步使用了 c 预定的数字。
function setup(withNumber = true, parity?: 'odd' | 'even') {
const cells: CellDef[] = ['a', 'b', 'c', 'd', 'e', 'f'].map((id, i) => ({
id, x: i % 3, y: Math.floor(i / 3), w: 1, h: 1,
clue: i === 0 ? 1 : withNumber && i === 2 ? 3 : undefined,
parity: i === 4 ? parity : undefined,
}));
return new Game(Board.fromCells(cells));
}
function startWrongBranch(game: Game) {
game.apply({ type: 'start', cell: 0 });
game.apply({ type: 'append', cell: 3 });
return game.apply({ type: 'append', cell: 4 });
}
test('固定数字出现于普通格时立即标错;未走的固定数字格保持未走状态', () => {
const game = setup();
const events = startWrongBranch(game);
assert.ok(events.some(e => e.type === 'step' && e.conflict && !e.clueHit));
assert.equal(game.isConflict(4), true);
assert.equal(game.hasConflict(), true);
assert.equal(game.isConflict(3), false, '第 2 步没有固定位置,不按隐藏答案标错');
assert.equal(game.stepOf(2), 0);
assert.equal(game.isConflict(2), false);
assert.equal(game.isCleared, false);
});
test('数字位置错误优先于奇偶命中,不播放踩对线索反馈', () => {
const game = setup(true, 'odd');
const events = startWrongBranch(game);
assert.equal(game.clueAt(4).parity, 'odd');
assert.equal(game.stepOf(4), 3);
assert.ok(events.some(e => e.type === 'step' && e.conflict && !e.clueHit));
assert.equal(game.isConflict(4), true);
});
test('撤销或多步回退后清除占号冲突,可改走正确数字格通关', () => {
for (const action of ['undo', 'rewind'] as const) {
const game = setup();
startWrongBranch(game);
game.apply(action === 'undo' ? { type: 'undo' } : { type: 'rewind', cell: 0 });
assert.equal(game.hasConflict(), false);
game.apply({ type: 'rewind', cell: 0 });
for (const cell of [1, 2, 5, 4, 3]) {
const events = game.apply({ type: 'append', cell });
assert.ok(events.every(e => e.type !== 'step' || !e.conflict));
}
assert.equal(game.isCleared, true);
}
});
test('补数字会立即检查已有路线,并在重开后继续约束该步的位置', () => {
const game = setup(false);
startWrongBranch(game);
assert.equal(game.hasConflict(), false);
game.apply({ type: 'reveal', cell: 2, step: 3 });
assert.equal(game.stepOf(2), 0);
assert.equal(game.isConflict(4), true, '补出的 3 尚未被踩到,别处的第 3 步已应判错');
game.apply({ type: 'reset' });
assert.equal(game.hasConflict(), false);
assert.ok(startWrongBranch(game).some(e => e.type === 'step' && e.conflict));
game.apply({ type: 'reset' });
for (const cell of [0, 1, 2, 5, 4, 3]) game.apply({ type: game.path.length ? 'append' : 'start', cell });
assert.equal(game.isCleared, true);
assert.equal(new Game(game.board).clueAt(2).clue, null);
});
test('非固定数字的奇偶要求不占用所有同类步号', () => {
const game = setup(false, 'odd');
game.apply({ type: 'start', cell: 0 });
game.apply({ type: 'append', cell: 1 });
game.apply({ type: 'append', cell: 2 });
assert.equal(game.hasConflict(), false, '另一个普通格上出现奇数 3 是允许的');
});
test('冲突只标红格,进出连线保持紫色,不显示 ×;本格线索不符仍显示原要求', () => {
for (const parity of [undefined, 'odd', 'even'] as const) {
const game = setup(true, parity);
const view = new BoardView(game, { reducedMotion: true });
view.layout = fitBoard(game.board, 350, 400);
for (const cell of [0, 3, 4, 5]) game.apply({ type: game.path.length ? 'append' : 'start', cell }).forEach(e => view.onEvent(e));
const texts: string[] = [];
const fills: string[] = [];
const bridges: string[] = [];
let badges = 0;
const painter: Painter = {
fillRoundRect: (_r, radius, color) => { fills.push(color); if (radius === 0) bridges.push(color); },
strokeRoundRect: () => {}, fillCircle: () => { badges++; }, text: text => { texts.push(text); },
};
view.draw(painter);
assert.ok(!texts.includes('×'));
if (parity === 'even') assert.ok(texts.includes('偶'));
assert.equal(badges, parity === 'even' ? 1 : 0);
assert.ok(!texts.includes(''), '新增冲突不能留下空角标');
assert.ok(fills.includes(THEME.conflict));
assert.deepEqual(bridges, [THEME.visited, THEME.visited, THEME.visited], '错误格进出两侧连线均保持紫色');
}
});
function frame(view: BoardView): Rect[] {
const cells: Rect[] = [];
view.draw({
fillRoundRect: rect => { cells.push(rect); },
strokeRoundRect: () => {}, fillCircle: () => {}, text: () => {},
});
return cells.slice(0, view.game.n); // 底板首先按格序绘制
}
function settle(view: BoardView): void {
for (let i = 0; i < 120; i++) view.update(1 / 60);
}
test('落错时只晃动出错格,动画结束复位;撤销和重开立即停止晃动', () => {
for (const cancel of ['undo', 'reset', 'finish'] as const) {
const game = setup();
const view = new BoardView(game);
view.layout = fitBoard(game.board, 350, 400);
for (const cell of [0, 3]) game.apply({ type: game.path.length ? 'append' : 'start', cell }).forEach(e => view.onEvent(e));
settle(view);
const before = frame(view);
game.apply({ type: 'append', cell: 4 }).forEach(e => view.onEvent(e));
view.update(1 / 60);
const shaken = frame(view);
assert.ok(Math.abs(shaken[4].x - before[4].x) > 0.1, '错误格应横向晃动');
assert.equal(shaken[4].w, before[4].w, '错误反馈只晃动,不叠加落子缩放');
for (const cell of [0, 1, 2, 3, 5]) assert.deepEqual(shaken[cell], before[cell], `其他格 ${cell} 不晃动`);
if (cancel === 'finish') settle(view);
else game.apply({ type: cancel }).forEach(e => view.onEvent(e));
assert.deepEqual(frame(view)[4], before[4], `${cancel} 后立即恢复原位置`);
}
});
test('补数字发现旧路径的错误也会晃动;减弱动态时保留红色但不晃动', () => {
for (const reducedMotion of [false, true]) {
const game = setup(false);
const view = new BoardView(game, { reducedMotion });
view.layout = fitBoard(game.board, 350, 400);
for (const cell of [0, 3, 4]) game.apply({ type: game.path.length ? 'append' : 'start', cell }).forEach(e => view.onEvent(e));
settle(view);
game.apply({ type: 'reveal', cell: 2, step: 3 }).forEach(e => view.onEvent(e));
view.update(1 / 60);
const original = cellRect(game.board, 4, view.layout);
assert.equal(game.isConflict(4), true);
if (reducedMotion) assert.deepEqual(frame(view)[4], original);
else assert.ok(Math.abs(frame(view)[4].x - original.x) > 0.1);
}
});