初始提交:三玩法 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
+117
View File
@@ -0,0 +1,117 @@
// v1 关卡版回归:保留历史实验,当前 Web / Cocos 主入口使用 FlowGame。
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { Game } from '../src/game';
import { Gesture } from '../src/input';
import { LEVELS } from '../src/levels';
import { WIDTH, HEIGHT, radius } from '../src/config';
import { adjacent, canAppend, findChains, legal, resultNumber } from '../src/rules';
import { fresh, loadProgress, saveProgress, starsFor, unlocked } from '../src/progress';
import { settle } from '../src/physics';
import { hint } from '../src/solver';
import type { Ball } from '../src/types';
const ball = (n: number, id = n, x = n * 45, y = 120): Ball => ({ id, n, x, y, r: radius(n), vx: 0, vy: 0 });
test('正反连续链得到同一个最大数 +1;不能同号、跳号、换向或重复', () => {
const chain = [ball(1), ball(2), ball(3), ball(4)];
assert(legal(chain)); assert(legal([...chain].reverse()));
assert.equal(resultNumber(chain), 5); assert.equal(resultNumber([...chain].reverse()), 5);
assert(!legal(chain.slice(0, 2))); assert(!legal([chain[0], chain[1], chain[3]]));
assert(!canAppend(chain.slice(0, 3), ball(2, 9, 150)));
assert(!legal([ball(2, 1, 100), ball(2, 2, 140), ball(2, 3, 180)]));
assert(!canAppend([chain[0], chain[1]], chain[0]));
});
test('邻接只看圆心距离,边界是半径和的 1.35 倍', () => {
const a = ball(1, 1, 0), b = ball(2, 2, (22 + 28) * 1.35);
assert(adjacent(a, b)); b.x += .001; assert(!adjacent(a, b));
});
test('合成位置取链尾,最后一步目标完成优先于步数失败', () => {
const g = new Game({ ...LEVELS[0], moves: 1 });
const ids = hint(g), tail = g.balls.find(b => b.id === ids[ids.length - 1])!;
const x = tail.x, y = tail.y, r = tail.r;
const result = g.commit(ids)!;
assert.equal(result.created.id, tail.id); assert.equal(result.created.x, x); assert.equal(result.created.y, y);
assert(result.created.r > r); assert.equal(g.stats.score, 1200); assert.equal(g.movesLeft, 0); assert.equal(g.status, 'won');
});
test('非法和过时 ID 不改变盘面、步数和随机数', () => {
const g = new Game(LEVELS[2]), before = g.snapshot();
assert.equal(g.commit([1, 2]), null); assert.equal(g.commit([991, 992, 993]), null);
assert.deepEqual(g.snapshot(), before); assert.equal(g.canUndo, false);
});
test('同一种子、撤销重走后,补球、坐标、分数都完全一致', () => {
const g = new Game(LEVELS[4]), ids = hint(g), initial = g.snapshot();
assert.deepEqual(initial, new Game(LEVELS[4]).snapshot());
assert(g.commit(ids)); const after = g.snapshot();
assert(g.undo()); assert.deepEqual(g.snapshot(), initial);
assert(g.commit(ids)); assert.deepEqual(g.snapshot(), after);
});
test('快滑采样中间球;回到上一颗撤回,取消不提交', () => {
const balls = [ball(1, 1, 40), ball(2, 2, 90), ball(3, 3, 145)];
const input = new Gesture(() => balls);
input.down(balls[0]); input.move(balls[2]); assert.deepEqual(input.chain, [1, 2, 3]);
input.move(balls[1]); assert.deepEqual(input.chain, [1, 2]);
input.move(balls[2]); assert.deepEqual(input.up(), [1, 2, 3]);
assert.equal(input.active, false); assert.deepEqual(input.up(), []);
input.down(balls[0]); input.move(balls[2]); input.cancel(); assert.deepEqual(input.up(), []);
});
test('打乱保留数字、不扣步且只用一次;撤销恢复打乱之前', () => {
const g = new Game(LEVELS[4]), before = g.snapshot(), numbers = g.balls.map(b => b.n).sort();
assert(g.shuffle()); assert.equal(g.shuffles, 0); assert.equal(g.movesLeft, before.movesLeft);
assert.deepEqual(g.balls.map(b => b.n).sort(), numbers); assert(!g.shuffle());
assert(g.undo()); assert.deepEqual(g.snapshot(), before);
});
test('无解数字不会空扣打乱次数', () => {
const g = new Game(LEVELS[0]); g.balls.forEach(b => { b.n = 1; b.r = 22; }); g.status = 'stuck';
const before = g.snapshot(); assert(!g.shuffle()); assert.deepEqual(g.snapshot(), before);
});
test('目标没完成而步数耗尽,进入失败且允许撤销', () => {
const g = new Game({ ...LEVELS[0], moves: 1, goals: [{ kind: 'number', value: 8 }] });
assert(g.commit(findChains(g.balls)[0])); assert.equal(g.status, 'lost');
assert(g.undo()); assert.equal(g.status, 'playing'); assert.equal(g.movesLeft, 1);
});
test('自由模式不检查关卡目标、不扣步数', () => {
const g = new Game(LEVELS[0], true); assert(g.commit(findChains(g.balls)[0]));
assert.notEqual(g.status, 'won'); assert.equal(g.movesLeft, -1);
});
test('物理将重叠同心球分开并限制左右与底部边界', () => {
const balls = Array.from({ length: 18 }, (_, i) => ball(i % 4 + 1, i + 1, 180, 100)); settle(balls, 400);
for (const a of balls) {
assert(Number.isFinite(a.x) && Number.isFinite(a.y));
assert(a.x >= a.r && a.x <= WIDTH - a.r); assert(a.y <= HEIGHT - a.r);
for (const b of balls) if (a.id !== b.id) assert(Math.hypot(a.x - b.x, a.y - b.y) >= a.r + b.r - 1.5);
}
});
test('损坏/不可用存档不阻塞游戏,非法成绩被过滤', () => {
assert.deepEqual(loadProgress({ read: () => '{broken', write() {} }), fresh());
assert.deepEqual(loadProgress({ read() { throw Error('blocked'); }, write() {} }), fresh());
assert.equal(saveProgress({ read: () => null, write() { throw Error('quota'); } }, fresh()), false);
const p = loadProgress({ read: () => JSON.stringify({ version: 1, levels: { 1: { stars: 3, score: 1200 }, 2: { stars: 99, score: -1 } }, sound: false }), write() {} });
assert.equal(unlocked(p), 2); assert.equal(p.sound, false); assert.equal(p.levels[2], undefined);
assert.equal(starsFor(3, 3, false), 3); assert.equal(starsFor(3, 3, true), 2); assert.equal(starsFor(6, 3, false), 1);
});
for (const level of LEVELS) test(`第 ${level.id} 关:标准解通过真实手势控制器逐步回放`, () => {
const g = new Game(level), input = new Gesture(() => g.balls);
assert(level.solution?.length); assert.equal(g.status, 'playing');
for (const path of level.solution!) {
assert.deepEqual(hint(g), path);
const targets = path.map(id => g.balls.find(b => b.id === id)!);
input.down(targets[0]); for (const target of targets.slice(1)) input.move(target);
assert.deepEqual(input.chain, path, `第 ${g.stats.merges + 1} 步不能直接划到`);
assert(g.commit(input.up()));
for (const b of g.balls) {
assert(b.y >= b.r - 2, `球 ${b.id} 超出顶部`);
assert(b.x >= b.r && b.x <= WIDTH - b.r && b.y <= HEIGHT - b.r);
}
}
assert.equal(g.status, 'won'); assert(g.stats.merges <= level.moves);
});
test('动画不改变规则状态,跳帧与逐帧采样得到相同物理画面', async () => {
const { MergeAnimation } = await import('../src/animation');
const g = new Game(LEVELS[7]), result = g.commit(hint(g))!, saved = g.snapshot();
const a = new MergeAnimation(result, 0), b = new MergeAnimation(result, 0);
for (let t = 0; t < 700; t += 16) a.sample(t);
assert.deepEqual(a.sample(700), b.sample(700));
assert.deepEqual(g.snapshot(), saved);
assert.equal(a.sample(760), null);
});