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