《移动游戏产品的架构·客户端与移动运行时》2.2 纯逻辑内核:把玩法从引擎里拆出来

把三消规则从引擎节点、动画回调和全局随机数里拆出来,内核只接收棋盘、输入意图与注入的 Clock/Random,返回可重放的状态迁移与结算。本节给出棋盘生成、级联消除、重力补位与会话过期规则,并说明方舟卡牌战斗如何复用同一套边界,让同一局在 Node 里复现。

当结算只能靠手点

案例设定:潮汐消消的一次活动给三消棋盘加了「连锁加分」。上线后部分玩家反馈分数和结算页不一致。团队在真机上反复手点,偶尔能复现,但换一台设备、换一次掉落顺序就消失。最后发现,分数在消除动画的回调里累加,动画被跳过或降帧时回调次数不同;掉落又用了引擎的全局随机数,同一局无法重放。

这类问题不是「动画写得不好」,而是玩法规则和引擎生命周期绑在了一起。只要规则需要帧回调、节点树或全局随机数才能运行,它就无法被稳定测试;测试只能模拟点击,无法证明「同样的输入一定得到同样的结果」。

纯逻辑内核要做的,是把「规则」从「表演」里拆出来。它不是把代码搬到另一个目录,而是改变依赖方向:内核不 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 测试只证明规则可重放,不证明触摸延迟、动画帧率或服务端结算;这些分别属于设备矩阵和服务端章节。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查