FiveInRow 五子棋 — 实现原理详解

从规则、数据结构、核心算法、AI 到界面交互,逐层拆解 FiveInRow 五子棋的实现思路:落子校验、四向胜负、Renju 禁手、棋形评分 AI 与 Compose 点击落子流程。

本文从「规则 → 数据结构 → 核心算法 → AI → 界面交互」逐层拆解 FiveInRow 的实现思路。即使你不熟悉 Android 或 C,也能看懂五子棋程序是如何「下棋、判胜负、禁手、让 AI 落子」的。


一、程序要解决什么问题?

五子棋的规则人人会,但写成程序,至少要稳定回答下面几类问题:

问题 典型场景
这步能下吗? 点已有子的交叉点、越界、Renju 禁手
谁赢了? 刚下的那步是否形成五连;Renju 下长连不算赢
怎么悔棋? 撤销最近一步或人机各一步
电脑下哪? 评估每个空位的攻防价值,选最优
界面怎么响应? 手指点屏幕 → 换算成 (row, col) → 调引擎

FiveInRow 把这些逻辑集中在 C 引擎engine/src/main/cpp/),Kotlin 只负责界面、存档和流程编排。好处是:规则只写一遍,换 UI 或换平台不必重写棋规。

整体数据流:

flowchart LR
    A[手指点击棋盘] --> B[BoardCanvas 换算行列]
    B --> C[GameViewModel]
    C --> D[NativeBoardEngine]
    D --> E[JNI]
    E --> F[C 引擎 fir_place_stone 等]
    F --> G[返回结果 / 棋盘快照]
    G --> C
    C --> H[Compose 重绘]

二、棋盘在内存里长什么样?

引擎核心结构定义在 engine_internal.h

// Function/Android/engine/src/main/cpp/src/engine_internal.h
typedef struct {
    int size;
    int rule_mode;
    int *board;
    FiveInRowMove history[FIVEINROW_MAX_MOVES];
    int history_count;
    int current_player;
} FiveInRowEngine;

可以这样理解:

例如 15×15 棋盘上,坐标 (row=7, col=8) 在数组中的下标是:

index = row * size + col = 7 * 15 + 8 = 113

重置棋盘时,用 memset 全部置空,并把 current_player 设回黑棋:

// Function/Android/engine/src/main/cpp/src/board.c
void fir_board_reset(FiveInRowEngine *e, int size) {
    if (e == NULL || size <= 0 || size > FIVEINROW_MAX_BOARD_SIZE) {
        return;
    }
    e->size = size;
    memset(e->board, 0, (size_t)(size * size) * sizeof(int));
    e->history_count = 0;
    e->current_player = FIVEINROW_STONE_BLACK;
}

三、落子:一步棋的完整校验

对外 API 是 fiveinrow_place_stone,内部由 fir_place_stone 实现。落子前按顺序检查:

越界? → 已有子? → 步数已满? → Renju 禁手? → 写入棋盘 → 记入 history → 换手

核心代码:

// Function/Android/engine/src/main/cpp/src/rules.c
int fir_place_stone(FiveInRowEngine *e, int row, int col) {
    FiveInRowMove move;
    if (e == NULL) {
        return FIVEINROW_PLACE_OUT_OF_BOUNDS;
    }
    if (!fir_in_bounds(e, row, col)) {
        return FIVEINROW_PLACE_OUT_OF_BOUNDS;
    }
    if (e->board[row * e->size + col] != FIVEINROW_STONE_EMPTY) {
        return FIVEINROW_PLACE_OCCUPIED;
    }
    if (e->history_count >= FIVEINROW_MAX_MOVES) {
        return FIVEINROW_PLACE_GAME_OVER;
    }
    if (fir_renju_is_forbidden(e, row, col)) {
        return FIVEINROW_PLACE_FORBIDDEN;
    }

    e->board[row * e->size + col] = e->current_player;
    move.row = row;
    move.col = col;
    move.player = e->current_player;
    e->history[e->history_count++] = move;
    e->current_player = fir_opponent(e->current_player);
    return FIVEINROW_PLACE_OK;
}

返回值约定(见 types.h):

含义 UI 层表现
0 成功 刷新棋盘,检查胜负
1 已有棋子 Toast「此处已有棋子」
2 越界 不应出现(UI 已 clamp)
3 对局结束 不应再落子
4 Renju 禁手 Toast「禁手,不能在此落子」

设计要点:合法性在引擎里一次性判完,ViewModel 只根据返回码决定提示语,不在 Kotlin 里重复写规则。


四、胜负判定:四方向数连子

五子棋的赢法只有一种:在横、竖、两条斜线任一方向上连续五颗同色子
实现上不需要遍历全盘——只需在刚落子的那一点,向四个方向数连子长度

4.1 四个方向

水平 →     (0, 1)
垂直 ↓     (1, 0)
主斜 ↘     (1, 1)
副斜 ↙     (1, -1)

fir_count_dir(row, col) 沿 (dr, dc) 方向数同色子个数(不含自身)。

4.2 判定逻辑

对刚下的点 (row, col),每个方向:

total = 1 + 正向连子数 + 反向连子数
若 total >= 5 → 可能获胜

Renju 特殊规则:黑棋长连(超过 5 连)不算赢,代码里会 continue 跳过该方向:

// Function/Android/engine/src/main/cpp/src/rules.c
    for (d = 0; d < 4; d++) {
        int dr = dirs[d][0];
        int dc = dirs[d][1];
        int total = 1 + fir_count_dir(e, row, col, dr, dc, p)
                    + fir_count_dir(e, row, col, -dr, -dc, p);
        if (total >= 5) {
            if (e->rule_mode == FIVEINROW_RULE_RENJU && p == FIVEINROW_STONE_BLACK && total > 5) {
                continue;
            }

若判定获胜,还会回填 五连坐标 out_cells[5][2],供 UI 高亮获胜棋子。

4.3 和棋

棋盘没有空位且无人五连 → fir_is_board_full 返回真,ViewModel 进入和棋状态。


五、Renju 禁手:黑棋的三三、四四、长连

标准五子棋黑白规则对称;Renju(连珠) 为平衡先手,规定 仅黑棋 在形成五连前不得下禁手。FiveInRow 实现了三类:

禁手 含义
长连 同一方向连续超过 5 颗黑子
三三 一步棋同时形成两个「活三」
四四 一步棋同时形成两个「四」

5.1 判定思路:先模拟,再分析

禁手不能只看当前盘面——要看 「如果黑棋落在这里,会形成什么」
因此 fir_renju_simulate_black 会:

  1. 临时在 (row, col) 放一颗黑子
  2. 检查是否长连、活三、四的数量
  3. 再把该点恢复为空
// Function/Android/engine/src/main/cpp/src/renju.c
static int fir_renju_simulate_black(FiveInRowEngine *e, int row, int col) {
    int forbidden;
    e->board[row * e->size + col] = FIVEINROW_STONE_BLACK;
    forbidden = fir_renju_check_placed(e, row, col);
    e->board[row * e->size + col] = FIVEINROW_STONE_EMPTY;
    return forbidden;
}

只有 当前行棋方是黑棋规则为 Renju 时才检查:

// Function/Android/engine/src/main/cpp/src/renju.c
int fir_renju_is_forbidden(FiveInRowEngine *e, int row, int col) {
    if (e == NULL || e->rule_mode != FIVEINROW_RULE_RENJU) {
        return 0;
    }
    if (e->current_player != FIVEINROW_STONE_BLACK) {
        return 0;
    }
    ...
    return fir_renju_simulate_black(e, row, col);
}

5.2 局部 9 点窗口

对每个方向,以落子点为中心取 9 个连续交叉点 组成线段 line[9],在窗口内识别棋形:

  . . . . ● . . . .
  0 1 2 3 4 5 6 7 8   ← index 4 是落子点

open_threes >= 2fours >= 2,则判为禁手。

5.3 AI 与提示也要遵守禁手

人机模式下,若 AI 执黑且为 Renju,fir_pick_ai_move 会跳过禁手点;提示功能 fir_pick_hint 同样过滤,避免推荐非法着法。


六、AI 怎么选点?——棋形评分法

FiveInRow 的 AI 不是神经网络,而是经典的 启发式搜索:对每个空位模拟落子,按棋形打分,取得分最高者。

6.1 单方向棋形分

fir_score_line 在某方向统计连子长度和 开口数(一端或两端是否为空):

棋形 大致分值
五连 100000
活四(4 连且至少 1 口) 10000
活三(3 连且 2 口) 1000
活二(2 连且 2 口) 100
其他 连子数
// Function/Android/engine/src/main/cpp/src/ai.c
    if (count >= 5) {
        return 100000;
    }
    if (count == 4 && open >= 1) {
        return 10000;
    }
    if (count == 3 && open >= 2) {
        return 1000;
    }
    if (count == 2 && open >= 2) {
        return 100;
    }
    return count;

四个方向分数相加,得到该点的 进攻分

6.2 攻防兼顾

对空位 (r, c)

attack = 假设 AI 下在这里的得分
defend = 假设对手下在这里的得分(挡棋)
score  = attack × 1.1 + defend - 距棋盘中心曼哈顿距离 × 0.5

6.3 三档难度

难度 行为
入门 在最优解上加随机扰动;约 35% 概率随机选合法空位
普通 纯按 score 选最大
困难 额外加权 attack + defend,更重视双杀与必防点
// Function/Android/engine/src/main/cpp/src/ai.c
            if (level == FIVEINROW_AI_EASY) {
                score += fir_rand01() * 80.0;
            } else if (level == FIVEINROW_AI_HARD) {
                score += attack * 0.5 + defend * 0.5;
            }

提示fir_pick_hint)与 AI 共用 fir_evaluate_move,但只算当前玩家进攻分,不混入防守与随机。


七、悔棋:历史栈回溯

每步落子写入 history[]。悔棋时 fir_undo_steps 从栈顶弹出,清空对应格子,并把 current_player 恢复为那步的落子方:

// Function/Android/engine/src/main/cpp/src/history.c
int fir_undo_steps(FiveInRowEngine *e, int steps) {
    int i;
    if (e == NULL || steps <= 0 || e->history_count < steps) {
        return 0;
    }
    for (i = 0; i < steps; i++) {
        FiveInRowMove last;
        ...
        e->board[last.row * e->size + last.col] = FIVEINROW_STONE_EMPTY;
        e->history_count--;
        e->current_player = last.player;
    }
    return 1;
}

ViewModel 层约定:


八、界面层:点击如何变成落子?

8.1 屏幕坐标 → 棋盘坐标

BoardCanvas 用 Compose Canvas 画线画子。棋盘为正方形,n×n 交叉点均匀分布:

step = 画布宽度 / (n - 1)
col = round( touchX / step )
row = round( touchY / step )
// Function/Android/app/src/main/kotlin/com/sdtech/fiveinrow/ui/game/BoardCanvas.kt
                detectTapGestures { offset ->
                    val n = gridSize.coerceAtLeast(2)
                    val stepPx = this.size.width / (n - 1)
                    val col = ((offset.x + stepPx / 2) / stepPx).toInt().coerceIn(0, gridSize - 1)
                    val row = ((offset.y + stepPx / 2) / stepPx).toInt().coerceIn(0, gridSize - 1)
                    onCellTap(row, col)

stepPx / 2 是为了让点击落在交叉点附近时归到正确的格点。

8.2 ViewModel 编排一局棋

用户点击后,GameViewModel.onCellTap

  1. 检查是否终局、是否 AI 思考中
  2. 人机模式下检查是否轮到玩家
  3. 调用 engine.placeStone(row, col)
  4. 成功则 afterMove:判胜负 → 刷新 UI → 存档 → 调度 AI
// Function/Android/app/src/main/kotlin/com/sdtech/fiveinrow/ui/game/GameViewModel.kt
    private fun afterMove(row: Int, col: Int) {
        val settings = internal.value.settings
        val win = engine.checkWinAt(row, col)
        if (win != null) {
            finishGame(win.player, "五连达成", true, win.cells.toSet())
            ...
            return
        }
        if (engine.isBoardFull()) {
            finishGame(null, "棋盘已满", true)
            ...
            return
        }
        refresh(modeFlow.value, settings, row to col)
        persistIfPlaying()
        maybeScheduleAiMove(settings)
    }

8.3 人机回合衔接

当轮到 AI 时,scheduleAiMove 在协程里:

  1. 显示「AI 思考中…」
  2. 可选延迟 800ms(设置里可关)
  3. engine.pickAiMove 取点 → placeStone → 再走一遍 afterMove

这样 胜负判定、音效、存档 对人机共用同一套路径,不会出现「人下完一套逻辑、AI 下另一套」的分叉。


九、存档与续局

进行中的对局通过 GameRepository 写入 DataStore,保存:

续局时 restoreGamereset,再按 history 逐步 placeStone 回放,引擎状态与离开前一致。


十、模块对照速查

模块 文件 职责
类型与错误码 types.h 棋子、规则、AI 难度、返回值
棋盘 board.c 重置、满盘检测
规则 rules.c 落子、四向胜负
Renju renju.c 三三 / 四四 / 长连禁手
AI ai.c 评分、选点、提示
历史 history.c 悔棋
演示 demo.c 加载示例中盘
JNI engine_jni.cpp C ↔ Kotlin
域模型 NativeBoardEngine.kt 封装引擎为 GameEngine 接口
编排 GameViewModel.kt 状态机、AI 调度、存档
绘制 BoardCanvas.kt 棋盘渲染与点击

十一、小结:实现五子棋的几条经验

  1. 规则下沉引擎
    胜负、禁手、AI 都在 C 层;UI 只展示结果,避免双份逻辑不同步。

  2. 刚落子点判胜负
    不必全盘扫描,四方向数连子即可,复杂度 O(1) 每步。

  3. Renju 用「模拟落子」
    禁手是「下在这里会怎样」,临时改盘再恢复,比维护复杂棋形库更直观。

  4. AI 用棋形分足够休闲
    入门加随机、困难加强攻防权重,三档难度无需 Minimax 也能玩。

  5. history 栈统一悔棋与存档
    一步一记录,悔棋 pop、续局 replay,数据结构简单可靠。

  6. ViewModel 管流程,不管棋规
    点击 → 引擎 → 判终局 → 存档 → AI,一条链清晰可测。


想对照源码阅读,建议顺序:

  1. Function/Android/engine/src/main/cpp/include/fiveinrow/types.h — 常量
  2. Function/Android/engine/src/main/cpp/src/rules.c — 落子与胜负
  3. Function/Android/engine/src/main/cpp/src/renju.c — 禁手
  4. Function/Android/engine/src/main/cpp/src/ai.c — AI
  5. Function/Android/app/src/main/kotlin/com/sdtech/fiveinrow/ui/game/GameViewModel.kt — 产品流程

API 索引见 Function/Android/docs/API.md

若需了解工程目录与分层设计,请参阅 《工程架构介绍》