- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试 - 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本 - 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试 - 产品文档、UI 设计规范、各玩法 GDD / HANDOFF - 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
160 lines
7.0 KiB
TypeScript
160 lines
7.0 KiB
TypeScript
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);
|
||
}
|
||
});
|