初始提交:三玩法 Web 原型与设计文档
- 玩法一 数字一笔:规则库、求解器、26 关、竖屏触摸 Web 原型、测试 - 玩法二 连连气球:连消畅玩版核心、Web 原型、Cocos 球槽组件与导出脚本 - 玩法三 六边一笔:规则库、求解器、10 关 + 转墙实验包、Web 原型、测试 - 产品文档、UI 设计规范、各玩法 GDD / HANDOFF - 文档移除全部外部参考作引用;删除玩法二 v0 旧稿
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FlowGame, FEVER_TARGET } from '../src/flow';
|
||||
import { FlowAnimation } from '../src/flow-animation';
|
||||
import { balloonRadius, cascade } from '../src/cascade';
|
||||
import { canFlowAppend, legalFlow } from '../src/flow-rules';
|
||||
import { Gesture } from '../src/input';
|
||||
import { supply } from '../src/supply';
|
||||
import { Random } from '../src/random';
|
||||
import { FLOW_SAVE_KEY, freshRecord, loadRecord, recordRound, saveRecord } from '../src/record';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { settle } from '../src/physics';
|
||||
import type { Ball } from '../src/types';
|
||||
const ball = (n: number, id: number, x: number, y = 160): Ball => ({ n, id, x, y, r: balloonRadius(n), vx: 0, vy: 0 });
|
||||
// 最右端合成的 4,依次接上左侧的 2/3、3/4 和 4/5。
|
||||
const relay = () => [ball(1, 1, 100, 280), ball(2, 2, 150, 235), ball(3, 3, 190, 190),
|
||||
ball(2, 4, 85, 125), ball(3, 5, 135, 155), ball(3, 6, 90, 210), ball(4, 7, 140, 205),
|
||||
ball(4, 8, 92, 180), ball(5, 9, 140, 130)];
|
||||
|
||||
test('畅连:正反顺号和同号都可连,不能混号、重复、跳号或只有两颗', () => {
|
||||
const ascending = [ball(1, 1, 50), ball(2, 2, 100), ball(3, 3, 150)];
|
||||
const same = [ball(2, 1, 50), ball(2, 2, 100), ball(2, 3, 150)];
|
||||
assert(legalFlow(ascending)); assert(legalFlow([...ascending].reverse())); assert(legalFlow(same));
|
||||
assert(!legalFlow(same.slice(0, 2))); assert(!legalFlow([same[0], same[1], ascending[2]]));
|
||||
assert(!legalFlow([ascending[0], ascending[1], same[2]]));
|
||||
assert(!canFlowAppend(ascending, ascending[0])); assert(!canFlowAppend([ascending[0]], ascending[2]));
|
||||
assert(!canFlowAppend([ascending[0], ascending[1]], ball(1, 9, 140)));
|
||||
assert(!legalFlow([ball(2, 1, 40), ball(2, 2, 200), ball(2, 3, 300)]));
|
||||
});
|
||||
test('同号手势支持快滑、回退、取消;非法松手不会提交', () => {
|
||||
const balls = [ball(2, 1, 45), ball(2, 2, 95), ball(2, 3, 145)];
|
||||
const gesture = new Gesture(() => balls, canFlowAppend);
|
||||
gesture.down(balls[0]); gesture.move(balls[2]); assert.deepEqual(gesture.chain, [1, 2, 3]);
|
||||
gesture.move(balls[1]); assert.deepEqual(gesture.chain, [1, 2]);
|
||||
gesture.cancel(); assert.deepEqual(gesture.up(), []);
|
||||
});
|
||||
test('自动接力在链尾升级,按 1/2/3 倍计分,普通手势最多三次', () => {
|
||||
const source = relay(), saved = structuredClone(source), r = cascade(source, [1, 2, 3])!;
|
||||
assert.equal(r.stages.length, 3); assert.deepEqual(r.stages.map(s => s.created.n), [4, 5, 6]);
|
||||
assert.deepEqual(r.stages.map(s => s.points), [1200, 3000, 5400]); assert.equal(r.score, 9600);
|
||||
assert(r.stages.every(s => s.created.id === 3 && s.created.x === 190 && s.created.y === 190));
|
||||
for (const s of r.stages) assert(legalFlow(s.path));
|
||||
assert.deepEqual(source, saved);
|
||||
});
|
||||
test('彩虹允许第四连,每次基础合成双倍;10 号放飞只加一次奖励', () => {
|
||||
const r = cascade(relay(), [1, 2, 3], true)!;
|
||||
assert.equal(r.stages.length, 4); assert.deepEqual(r.stages.map(s => s.points), [2400, 6000, 10800, 16800]);
|
||||
const ten = cascade([ball(7, 1, 70), ball(8, 2, 160), ball(9, 3, 250)], [1, 2, 3], true)!;
|
||||
assert.equal(ten.score, 16000); assert.equal(ten.flight?.n, 10); assert.equal(ten.balls.length, 0);
|
||||
assert.equal(ten.stages.length, 1);
|
||||
});
|
||||
test('非法路径与预览不消耗随机数、预告、能量或撤销记录', () => {
|
||||
const game = new FlowGame(), before = game.snapshot();
|
||||
assert(game.preview(game.hint())); assert.equal(game.commit([1, 2]), null);
|
||||
assert.equal(game.commit([9991, 9992, 9993]), null);
|
||||
assert.deepEqual(game.snapshot(), before); assert.equal(game.canUndo, false);
|
||||
});
|
||||
test('补球后的自动接力进入同一条时间线,撤销重走逐项一致', () => {
|
||||
const game = new FlowGame(1), before = game.snapshot(), ids = [1, 2, 3];
|
||||
const first = game.commit(ids)!; assert.equal(first.stages.length, 3);
|
||||
const kinds = first.beats.map(b => b.kind).join(','); assert(kinds.includes('fall,merge'));
|
||||
assert.equal(game.stats.turns, 1); assert.equal(game.stats.reactions, 3);
|
||||
const after = game.snapshot(); assert(game.undo()); assert.deepEqual(game.snapshot(), before);
|
||||
const second = game.commit(ids); assert.deepEqual(second, first); assert.deepEqual(game.snapshot(), after);
|
||||
});
|
||||
test('下一袋预告按序兑现,新落点保留现有气球 ID 和数字', () => {
|
||||
const game = new FlowGame(1234), announced = game.queue.slice(0, 3), result = game.commit(game.hint())!;
|
||||
const drops = result.beats.filter(b => b.kind === 'fall');
|
||||
assert(drops.length); assert.deepEqual(drops[0].supply.spawned.slice(0, 3).map(b => b.n), announced);
|
||||
for (let i = 1; i < result.beats.length; i++) {
|
||||
const beat = result.beats[i]; if (beat.kind !== 'fall' || beat.supply.breeze) continue;
|
||||
const previous = result.beats[i - 1]; if (previous.kind !== 'merge') continue;
|
||||
const consumed = new Set(previous.stage.path.map(b => b.id));
|
||||
for (const b of previous.stage.before.filter(b => !consumed.has(b.id)))
|
||||
assert(beat.supply.balls.some(a => a.id === b.id && a.n === b.n));
|
||||
}
|
||||
});
|
||||
test('彩虹从下一次手势起持续五次,免费工具不消耗,结束后重新积攒', () => {
|
||||
const game = new FlowGame(45), prepared = game.snapshot(); prepared.feverCharge = FEVER_TARGET - 1; game.restore(prepared);
|
||||
const trigger = game.commit(game.hint())!; assert(trigger.feverStarted); assert(!trigger.doubled); assert.equal(game.feverLeft, 5);
|
||||
for (let remaining = 4; remaining >= 0; remaining--) {
|
||||
const before = game.snapshot(); game.shuffle(); assert.equal(game.feverLeft, before.feverLeft); assert(game.undo());
|
||||
const r = game.commit(game.hint())!; assert(r.doubled); assert.equal(game.feverLeft, remaining); assert.equal(game.feverCharge, 0);
|
||||
}
|
||||
assert(!game.commit(game.hint())!.doubled); assert(game.feverCharge > 0);
|
||||
});
|
||||
test('10 号放飞腾出空间、累计记录,撤销恢复放飞前全部状态', () => {
|
||||
const game = new FlowGame(), s = game.snapshot();
|
||||
s.balls = [ball(7, 1, 70), ball(8, 2, 160), ball(9, 3, 250)]; s.nextId = 4; game.restore(s);
|
||||
const r = game.commit([1, 2, 3])!; assert.equal(game.stats.flown, 1); assert.equal(game.stats.maxNumber, 10);
|
||||
assert.equal(game.stats.score, 13000); assert(!game.balls.some(b => b.n >= 10)); assert(game.hint().length >= 3);
|
||||
assert(r.beats.some(b => b.kind === 'fall' && b.flight?.n === 10));
|
||||
assert(game.undo()); assert.deepEqual(game.snapshot(), s);
|
||||
});
|
||||
test('吹口气免费、可连续使用并撤销;正常重排保留全部数字和 ID', () => {
|
||||
const game = new FlowGame(65), before = game.snapshot();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const r = game.shuffle(); assert.equal(r.kind, 'breeze'); assert(game.hint().length >= 3);
|
||||
assert.deepEqual(game.balls.map(b => [b.id, b.n]).sort(), before.balls.map(b => [b.id, b.n]).sort());
|
||||
assert.deepEqual(game.stats, before.stats); assert.deepEqual(game.queue, before.queue);
|
||||
}
|
||||
for (let i = 0; i < 3; i++) assert(game.undo()); assert.deepEqual(game.snapshot(), before);
|
||||
});
|
||||
test('极端断号通过有界微风恢复连线,并保留大气球', () => {
|
||||
const big = [ball(5, 1, 70), ball(7, 2, 180), ball(9, 3, 285)];
|
||||
const r = supply(big, [], 4, new Random(88));
|
||||
assert(r.breeze); assert(r.choices > 0); assert(r.balls.some(b => b.id === 3 && b.n === 9));
|
||||
assert(r.balls.length <= 24); assert.equal(new Set(r.balls.map(b => b.id)).size, r.balls.length);
|
||||
});
|
||||
test('合成和多次掉落的动画跳帧与逐帧相同,且不改变最终规则状态', () => {
|
||||
const game = new FlowGame(), result = game.commit(game.hint())!, saved = game.snapshot();
|
||||
const step = new FlowAnimation(result, 0), jump = new FlowAnimation(result, 0);
|
||||
for (let t = 0; t < step.duration; t += 97) {
|
||||
const a = step.sample(t); assert.deepEqual(a, jump.sample(t));
|
||||
const direct = new FlowAnimation(result, 0); assert.deepEqual(a, direct.sample(t));
|
||||
}
|
||||
assert.equal(step.sample(step.duration), null); assert.deepEqual(game.snapshot(), saved);
|
||||
});
|
||||
test('v2 纪录独立、单调累计;损坏、非法及不可用存储可恢复', () => {
|
||||
const map = new Map([['mode:02-connect-1234:v1', '{"version":1,"bestScore":99}']]);
|
||||
const storage = { read: (k: string) => map.get(k) || null, write: (k: string, v: string) => {map.set(k, v);} };
|
||||
assert.deepEqual(loadRecord(storage), freshRecord());
|
||||
const record = freshRecord(), game = new FlowGame(); game.commit(game.hint()); recordRound(record, game.stats);
|
||||
assert(saveRecord(storage, record)); assert.deepEqual(loadRecord(storage), record); assert.equal(map.get('mode:02-connect-1234:v1'), '{"version":1,"bestScore":99}');
|
||||
game.undo(); recordRound(record, game.stats); assert(record.bestScore > 0);
|
||||
map.set(FLOW_SAVE_KEY, '{broken'); assert.deepEqual(loadRecord(storage), freshRecord());
|
||||
map.set(FLOW_SAVE_KEY, JSON.stringify({version:2,bestScore:-1,bestNumber:1.5,bestCombo:Infinity,sound:false}));
|
||||
assert.deepEqual(loadRecord(storage), {...freshRecord(),sound:false});
|
||||
const blocked = {read(): string|null {throw Error('blocked');}, write(){throw Error('quota');}};
|
||||
assert.deepEqual(loadRecord(blocked), freshRecord()); assert(!saveRecord(blocked, record));
|
||||
});
|
||||
|
||||
|
||||
test('后期多球接触点充分松弛,修复种子 6559 的拥挤重叠', () => {
|
||||
const fixture = JSON.parse(readFileSync(new URL('../docs/qa-v2/overlap-regression.json', import.meta.url), 'utf8'));
|
||||
const balls: Ball[] = fixture.balls; settle(balls, 0, 96);
|
||||
for (const a of balls) for (const b of balls) if (a.id < b.id)
|
||||
assert(a.r + b.r - Math.hypot(a.x - b.x, a.y - b.y) < 1);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user