当结算只能靠手点
案例设定:潮汐消消的一次活动给三消棋盘加了「连锁加分」。上线后部分玩家反馈分数和结算页不一致。团队在真机上反复手点,偶尔能复现,但换一台设备、换一次掉落顺序就消失。最后发现,分数在消除动画的回调里累加,动画被跳过或降帧时回调次数不同;掉落又用了引擎的全局随机数,同一局无法重放。
这类问题不是「动画写得不好」,而是玩法规则和引擎生命周期绑在了一起。只要规则需要帧回调、节点树或全局随机数才能运行,它就无法被稳定测试;测试只能模拟点击,无法证明「同样的输入一定得到同样的结果」。
纯逻辑内核要做的,是把「规则」从「表演」里拆出来。它不是把代码搬到另一个目录,而是改变依赖方向:内核不 import 引擎,引擎反过来调用内核。下面的边界表先说明哪些东西必须注入,哪些东西必须返回。
内核边界:输入、输出、注入
| 边界要素 | 引擎内写法 | 纯内核写法 | 不拆的后果 |
|---|---|---|---|
| 时间 | 帧回调、Date.now、场景计时器 | 注入 Clock,会话用 durationMs 计算剩余时间 | 掉帧或暂停会改变结算 |
| 随机 | 引擎全局随机、Math.random | 注入 Random.nextInt(max) | 同一局无法重放,问题无法复现 |
| 输入 | 触摸事件、节点坐标 | 领域坐标 Coord 与交换意图 | 规则测试必须启动渲染 |
| 输出 | 直接改节点、播动画 | 返回 TurnResult 与 MatchGroup[] | 表现层和规则层互相污染 |
| 错误 | throw、静默返回、日志 | Result<T, Match3Error> 带原因 | 非法输入变成线上崩溃或静默吞掉 |
| 会话 | 场景生命周期 | 构造时注入时钟,显式判断过期 | 前后台切换后计时不可解释 |
这张表里最容易被忽略的是「错误」。很多玩法内核用 throw 处理非法交换,调用方要么全局捕获,要么干脆不捕获;前者把一次玩家误触变成崩溃,后者把错误吞掉。Result 把「交换越界」「不相邻」「不产生消除」「会话已结束」变成可枚举的原因,表现层可以分别处理:越界忽略、不相邻回弹、无消除回弹、会话结束进结算页。
另一个容易被忽略的是「会话」。三消单局 90 秒是案例设定,但计时不能靠场景的 update 累加,否则暂停、切后台、掉帧都会让剩余时间漂移。内核只接收 Clock,remainingMs() 由「配置时长减去经过时间」计算;测试里用 ManualClock.advance() 就能精确推进到过期,不需要等待真实时间。
这里给出的是规则模型,不是真机结论。Node 进程能证明棋盘生成、消除、补位和计时逻辑可重放,不能证明真机上的帧率、触摸延迟或动画表现;那些属于第 6 章和第 9 章的建模项。
确定性从哪来
确定性不是「不用随机数」,而是「随机数从哪里来、什么时候被消费」可被记录。内核做三件事。
第一,棋盘生成时避免初始三连。每一格从允许的宝石里选,排除左侧两格相同和上方两格相同的候选;这样生成的棋盘不需要「先消一次再开始」,也不会因为重试次数不同而消耗额外随机数。
第二,消除后的补位按固定顺序消费随机数。先处理每一列的重力,再从下往上填空;空位的随机值按列顺序依次取。顺序固定,同一个种子就能得到同一块棋盘。
第三,级联有上限。理论上级联可能很长,但内核设置 MAX_CASCADES,避免异常随机序列导致无限循环;达到上限时返回当前棋盘,下一回合继续处理。上限是防御性边界,不是玩法规则。
这三件事都不依赖引擎。随机接口 Random 与 SeededRandom 放在 src/core/random.ts,与 Clock 同级;下面的 match3.ts 只 import Clock、Random 与 Result,把棋盘、交换、匹配、级联和会话放在一个文件里。
// file: src/domain/match3.ts
// tier: L1
// verify: node --test test/domain/match3.test.ts
import type { Clock } from "../core/clock.ts";
import type { Random } from "../core/random.ts";
import { fail, ok, type Result } from "../core/result.ts";
export type Gem = number;
export type Cell = Gem | null;
export type Board = readonly (readonly Cell[])[];
export interface Coord { readonly row: number; readonly col: number; }
export interface Match3Config {
readonly rows: number;
readonly cols: number;
readonly kinds: number;
readonly minRun: number;
readonly baseScore: number;
readonly comboStep: number;
readonly durationMs: number;
}
export const DEFAULT_MATCH3_CONFIG: Match3Config = {
rows: 8, cols: 8, kinds: 6, minRun: 3, baseScore: 60, comboStep: 25, durationMs: 90_000,
};
export type Match3Error =
"out-of-bounds" | "not-adjacent" | "same-cell" | "no-match" | "session-over";
export interface MatchGroup {
readonly orientation: "row" | "col"; readonly gem: Gem; readonly cells: readonly Coord[];
}
export interface TurnResult {
readonly board: Board; readonly score: number; readonly cleared: number;
readonly cascades: number; readonly groups: readonly MatchGroup[];
}
function cellKey(cell: Coord): string {
return `${cell.row}:${cell.col}`;
}
function inBounds(board: Board, cell: Coord): boolean {
if (cell.row < 0 || cell.row >= board.length) {
return false;
}
const line = board[cell.row];
return line !== undefined && cell.col >= 0 && cell.col < line.length;
}
function sameCell(a: Coord, b: Coord): boolean {
return a.row === b.row && a.col === b.col;
}
function adjacent(a: Coord, b: Coord): boolean {
return Math.abs(a.row - b.row) + Math.abs(a.col - b.col) === 1;
}
function pickGem(kinds: number, banned: ReadonlySet<Gem>, random: Random): Gem {
const allowed: Gem[] = [];
for (let gem = 0; gem < kinds; gem += 1) {
if (!banned.has(gem)) {
allowed.push(gem);
}
}
return allowed[random.nextInt(allowed.length)];
}
export function createBoard(config: Match3Config, random: Random): Board {
if (config.rows < config.minRun || config.cols < config.minRun) {
throw new RangeError("board is smaller than the minimum run");
}
const rows: Gem[][] = [];
for (let row = 0; row < config.rows; row += 1) {
const line: Gem[] = [];
for (let col = 0; col < config.cols; col += 1) {
const banned = new Set<Gem>();
if (col >= 2 && line[col - 1] === line[col - 2]) {
banned.add(line[col - 1] as Gem);
}
if (row >= 2 && rows[row - 1][col] === rows[row - 2][col]) {
banned.add(rows[row - 1][col]);
}
line.push(pickGem(config.kinds, banned, random));
}
rows.push(line);
}
return rows;
}
export function swapCells(board: Board, a: Coord, b: Coord): Result<Board, Match3Error> {
if (!inBounds(board, a) || !inBounds(board, b)) {
return fail("out-of-bounds", `swap ${cellKey(a)} -> ${cellKey(b)} leaves the board`);
}
if (sameCell(a, b)) {
return fail("same-cell", `swap ${cellKey(a)} -> ${cellKey(b)} does not move`);
}
if (!adjacent(a, b)) {
return fail("not-adjacent", `swap ${cellKey(a)} -> ${cellKey(b)} is not adjacent`);
}
const next: Cell[][] = board.map((line) => line.slice());
const held = next[a.row][a.col];
next[a.row][a.col] = next[b.row][b.col];
next[b.row][b.col] = held;
return ok(next);
}
function pushRun(groups: MatchGroup[], orientation: "row" | "col", gem: Gem,
start: number, end: number, fixed: number, minRun: number): void {
if (end - start < minRun) {
return;
}
const cells: Coord[] = [];
for (let index = start; index < end; index += 1) {
cells.push(orientation === "row" ? { row: fixed, col: index } : { row: index, col: fixed });
}
groups.push({ orientation, gem, cells });
}
export function findMatches(board: Board, minRun = 3): readonly MatchGroup[] {
const groups: MatchGroup[] = [];
for (let row = 0; row < board.length; row += 1) {
const line = board[row];
let start = 0;
for (let col = 1; col <= line.length; col += 1) {
const previous = line[col - 1];
const current = col < line.length ? line[col] : undefined;
if (current !== previous) {
if (previous !== null && previous !== undefined) {
pushRun(groups, "row", previous, start, col, row, minRun);
}
start = col;
}
}
}
const cols = board[0]?.length ?? 0;
for (let col = 0; col < cols; col += 1) {
let start = 0;
for (let row = 1; row <= board.length; row += 1) {
const previous = board[row - 1][col];
const current = row < board.length ? board[row][col] : undefined;
if (current !== previous) {
if (previous !== null && previous !== undefined) {
pushRun(groups, "col", previous, start, row, col, minRun);
}
start = row;
}
}
}
return groups;
}
function collapseAndRefill(grid: Cell[][], config: Match3Config, random: Random): Board {
for (let col = 0; col < config.cols; col += 1) {
const kept: Gem[] = [];
for (let row = config.rows - 1; row >= 0; row -= 1) {
const cell = grid[row][col];
if (cell !== null) {
kept.push(cell);
}
}
let index = 0;
for (let row = config.rows - 1; row >= 0; row -= 1) {
grid[row][col] = index < kept.length ? kept[index] : random.nextInt(config.kinds);
index += 1;
}
}
return grid;
}
const MAX_CASCADES = 32;
export function playTurn(board: Board, a: Coord, b: Coord, config: Match3Config, random: Random): Result<TurnResult, Match3Error> {
const swapped = swapCells(board, a, b);
if (!swapped.ok) {
return swapped;
}
if (findMatches(swapped.value, config.minRun).length === 0) {
return fail("no-match", `swap ${cellKey(a)} -> ${cellKey(b)} creates no run`);
}
let current: Board = swapped.value;
let cascades = 0;
let cleared = 0;
let score = 0;
const groups: MatchGroup[] = [];
while (cascades < MAX_CASCADES) {
const matches = findMatches(current, config.minRun);
if (matches.length === 0) {
break;
}
cascades += 1;
groups.push(...matches);
const marked = new Set<string>();
for (const group of matches) {
for (const cell of group.cells) {
marked.add(cellKey(cell));
}
}
cleared += marked.size;
score += marked.size * config.baseScore + (cascades - 1) * config.comboStep * matches.length;
const grid: Cell[][] = current.map((line) => line.slice());
for (const key of marked) {
const [row, col] = key.split(":").map(Number);
grid[row][col] = null;
}
current = collapseAndRefill(grid, config, random);
}
return ok({ board: current, score, cleared, cascades, groups });
}
export class Match3Session {
#clock: Clock;
#random: Random;
#config: Match3Config;
#board: Board;
#startedAtMs: number;
#score = 0;
#turns = 0;
#lastTurnAtMs: number | null = null;
constructor(
clock: Clock,
random: Random,
config: Match3Config = DEFAULT_MATCH3_CONFIG,
initialBoard?: Board,
) {
this.#clock = clock;
this.#random = random;
this.#config = config;
this.#board = initialBoard ?? createBoard(config, random);
this.#startedAtMs = clock.nowMs();
}
get board(): Board { return this.#board; }
get score(): number { return this.#score; }
get turns(): number { return this.#turns; }
get lastTurnAtMs(): number | null { return this.#lastTurnAtMs; }
elapsedMs(): number { return this.#clock.nowMs() - this.#startedAtMs; }
remainingMs(): number { return Math.max(0, this.#config.durationMs - this.elapsedMs()); }
isExpired(): boolean { return this.remainingMs() === 0; }
swap(a: Coord, b: Coord): Result<TurnResult, Match3Error> {
if (this.isExpired()) {
return fail("session-over", `session ended after ${this.#config.durationMs}ms`);
}
const result = playTurn(this.#board, a, b, this.#config, this.#random);
if (!result.ok) {
return result;
}
this.#board = result.value.board;
this.#score += result.value.score;
this.#turns += 1;
this.#lastTurnAtMs = this.#clock.nowMs();
return result;
}
}
代码里有几个值得单独说明的决策。
Match3Session 的构造函数接受可选的 initialBoard。正常开局由 createBoard 生成;从存档恢复或测试固定局面时,直接注入棋盘,不消耗随机数。这让「同一局重放」和「从存档继续」共用同一条路径,而不是为测试单独开一个后门。
playTurn 先交换、再检查是否产生消除。不产生消除时返回 no-match,调用方据此回弹;它不会自动交换回去,因为「回弹」是表现层的动画决策,不是规则的一部分。规则只回答「这次交换是否合法」,不回答「回弹用多少毫秒」。
TurnResult 同时返回最终棋盘、本次得分、清除数量、级联次数和所有匹配组。表现层可以用匹配组播放消除特效,用级联次数决定音调,用最终棋盘刷新视图;它不需要再跑一遍规则来推导这些信息。
测试如何证明可重放
下面的测试都在 Node 里运行,不需要引擎、网络或设备。它们覆盖七类断言。
| 断言 | 证明什么 |
|---|---|
| 同种子生成同一棋盘,且初始无三连 | 生成确定、开局合法 |
| 越界、同一格、不相邻被拒绝 | 输入边界完整 |
| 横向与纵向三连都能识别 | 匹配规则正确 |
不产生消除的交换返回 no-match | 合法性判断在规则层 |
| 一次有效交换的清除数、分数、补位确定 | 结算与重力可复现 |
ManualClock 推进到 90 秒后会话过期 | 时间注入生效 |
| 同种子、同输入重放同一回合 | 整条链路确定 |
运行命令是 node --test test/domain/match3.test.ts,7 条断言全部通过。测试里用了两种随机源:SeededRandom 用于生成棋盘和重放,SequenceRandom 用于精确控制补位序列。后者只在测试里出现,内核只依赖 Random 接口,不知道具体实现。
这里要强调「可重放」的边界:它证明的是同一份代码、同一个种子、同一串输入得到同一结果,不证明不同设备上的动画一致,也不证明服务端结算一致。跨端一致性需要协议和权威结算,属于服务端架构的讨论范围。
方舟的卡牌战斗如何复用同一套边界
方舟的卡牌战斗内核同样不 import 引擎。src/domain/card-battle.ts 的构造函数接收 Clock、Random 和战斗单位列表,attack() 返回 Result<BattleTurn, BattleError>;回合顺序由速度降序、同速按 id 决定,伤害由攻击力加上注入随机数产生,时间戳来自注入时钟。
这套边界和消消完全同构:时间注入、随机注入、错误用 Result、输出是可序列化的回合记录。差别只在规则复杂度,不在依赖方向。
强联网不改变这条边界。服务端权威、客户端预测与回滚属于另一本书的讨论,可以参考客户端预测与服务端权威 ;本地内核只负责「给定输入和随机数,规则如何推进」,这正是它能在 Node 里被测试的原因。
test/domain/card-battle.test.ts 覆盖了回合顺序、伤害与时间戳、非法目标、战斗结束四类断言,4 条全部通过。它和消消的 7 条测试共用 ManualClock、Random 与 Result,没有新增基础设施。
与相邻章节的关系
2.1 定义了 gameplay 层的职责与依赖方向,本节把职责落成一个具体内核。2.3 会把「内核不能 import 引擎」从约定变成模块图断言,检查真实 import 是否越界。
存档结构可以参考存档结构与迁移 ,因为内核返回的棋盘和回合结果是可序列化的;表现层与内核的接线方式可以参考模块化 。
小结
- 纯逻辑内核不 import 引擎,引擎反过来调用内核;依赖方向是拆分的第一步。
- 时间和随机数必须注入,会话时长由
Clock计算,不能靠帧回调累加。 - 错误用
Result返回可枚举原因,让表现层分别处理越界、不相邻、无消除和会话结束。 - 棋盘生成避免初始三连,补位按固定顺序消费随机数,级联有上限,三者共同保证可重放。
- 消消和方舟共用同一套边界:Clock、Random、Result、可序列化输出;差别在规则复杂度,不在依赖方向。
阅读导航:上一节:2.1 表现、玩法、系统、平台四层与依赖方向 · 下一节:2.3 边界测试:依赖规则写成可执行断言 。
常见错误与边界
- 在核心里调用
Date.now()或Math.random()。一次调用就让测试失去可重放性;时间用Clock,随机用Random,测试才能固定输入。 - 把动画回调当成结算入口。动画可能被跳过、降帧或中断,分数不能依赖回调次数;结算必须在
playTurn里完成。 - 用
throw处理非法交换。玩家误触不是异常;用Result返回no-match、not-adjacent,表现层才能回弹而不是崩溃。 - 让内核直接弹窗或播音效。规则只输出事件和结果,表现层决定怎么演;否则内核无法在无界面环境里运行。
- 忽略级联上限。异常随机序列可能让级联很长;设置上限是防御,不是玩法规则,达到上限后应在下一回合继续处理。
- 边界:本节不证明真机表现。Node 测试只证明规则可重放,不证明触摸延迟、动画帧率或服务端结算;这些分别属于设备矩阵和服务端章节。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。