← 都来码 / Dolaima← 例程索引c/app/gomoku.c原文

c/app/gomoku.c · 29302 字节 · 下载原文

/* gomoku.c —— 五子棋(人机对战),跑在手机端 VML 上
 * gomoku.c -- Gomoku (human vs computer), running on mobile VML
 *
 * 用到的都是现成的宿主接口:`vml_ui.h` 那套(窗体 / 绘图 / 触摸消息队列),
 * Everything it uses is an existing host API: the `vml_ui.h` set (window, drawing, touch message queue),
 * 外加 v0.96.173 的**手感接口**(`ui_audio_beep` 落子与胜负、`ui_vibrate_simple`、`ui_dlg_msg` 报胜负)。
 * plus the v0.96.173 **feel APIs** (`ui_audio_beep` for moves and results, `ui_vibrate_simple`, `ui_dlg_msg` for the result dialog).
 * 棋盘与棋子全部用绘图指令画出来,不依赖任何图片资源;棋盘尺寸按实际可用绘图区
 * The board and stones are all drawn with drawing commands and depend on no image assets; the board size follows the actual usable drawing area
 * (syscall #566/#567)自适应,所以同一份代码在手机、平板、模拟器上都能铺满。
 * (syscall #566/#567) so the same code fills the screen on a phone, a tablet or an emulator.
 *
 * 编译运行(手机 App 的 vml 工具):
 * Build and run (with the vml tool in the phone app):
 *     vml run gomoku.c
 * 桌面(无 UI 号段,只能验证编译与算法):
 * On the desktop (no UI syscall range, so only compilation and the algorithm can be checked):
 *     vmlhost run Examples/c/gomoku.c
 *
 * ⚠ 这里**不能**用全局变量去接 ${} 参数:`GenerateAsmStatement` 的 `${名}` 只查局部
 * ⚠ You **cannot** use a global variable to receive a ${} argument here: the `${name}` of `GenerateAsmStatement` only searches the local
 *   变量表(`variables`),全局变量在 `dataSection` 里、查不到,会退化成 `R12+0`。
 *   variable table (`variables`); globals live in `dataSection` and are not found, so it degrades to `R12+0`.
 *   (**2026-10-04 复验仍成立**:`--vml` 打出来的汇编里 `${局部量}` 有装载指令、`${全局量}` 一条都没有。)
 *   (**still holds, re-checked 2026-10-04**: in the `--vml` disassembly, `${local}` gets a load instruction and `${global}` gets none.)
 *   所以:棋盘是 main 的局部数组,靠参数传给各个函数;调 syscall 一律走
 *   Therefore: the board is a local array in main and is passed to each function as a parameter; syscalls always go through
 *   `vml_ui.h` 的包装函数(它们的形参就是普通 C 参数,安全)。
 *   the `vml_ui.h` wrappers (their formal parameters are plain C parameters, which is safe).
 */

#include <vml_ui.h>

#define N 15          /* 15 路棋盘 */
                      /* 15x15 board */
#define EMPTY 0
#define BLACK 1       /* 人 */
                      /* Human */
#define WHITE 2       /* 电脑 */
                      /* Computer */

#define COL_BG      0xFF1B1B22
#define COL_LINE    0xFF5A5A66
#define COL_BOARD   0xFFE8C48A
#define COL_BLACK   0xFF14141A
#define COL_WHITE   0xFFF2F2F6
#define COL_TEXT    0xFFEDEDF2
#define COL_ACCENT  0xFF4ADE80

/* ─────────── 棋盘几何(都是 main 算好、按参数传下去,避免全局变量)─────────── */
/* ----------- Board geometry (all computed in main and passed down as parameters, avoiding globals) ----------- */

/* 把落点像素坐标换算成格线序号;越界返回 -1。 */
/* Convert a tapped pixel coordinate into a grid line index; returns -1 when out of range. */
int hit_col(int x, int pad, int cell) {
    int c;
    c = (x - pad + cell / 2) / cell;
    if (c < 0) return -1;
    if (c >= N) return -1;
    return c;
}

/* ─────────── 胜负判定 ─────────── */
/* ----------- Win detection ----------- */

/* 从 (x,y) 出发沿 (dx,dy) 数同色连子(含自己),≥5 即胜。 */
/* Starting at (x,y), count same-colored stones along (dx,dy) including itself; 5 or more means a win. */
int count_dir(int* b, int x, int y, int dx, int dy, int who) {
    int cnt;
    int cx;
    int cy;
    cnt = 1;
    cx = x + dx;
    cy = y + dy;
    while (cx >= 0 && cx < N && cy >= 0 && cy < N && b[cy * N + cx] == who) {
        cnt = cnt + 1;
        cx = cx + dx;
        cy = cy + dy;
    }
    cx = x - dx;
    cy = y - dy;
    while (cx >= 0 && cx < N && cy >= 0 && cy < N && b[cy * N + cx] == who) {
        cnt = cnt + 1;
        cx = cx - dx;
        cy = cy - dy;
    }
    return cnt;
}

int has_won(int* b, int x, int y, int who) {
    if (count_dir(b, x, y, 1, 0, who) >= 5) return 1;    /* 横 */
                                                         /* Horizontal */
    if (count_dir(b, x, y, 0, 1, who) >= 5) return 1;    /* 竖 */
                                                         /* Vertical */
    if (count_dir(b, x, y, 1, 1, who) >= 5) return 1;    /* 撇 */
                                                         /* Down-right diagonal */
    if (count_dir(b, x, y, 1, -1, who) >= 5) return 1;   /* 捺 */
                                                         /* Up-right diagonal */
    return 0;
}

/* ─────────── 电脑棋力:单方向估值 ─────────── */
/* ----------- Computer strength: single-direction evaluation ----------- */

/* 假设 (x,y) 已被 who 占,沿 (dx,dy) 这条线的价值。
 * Assume (x,y) is already taken by who; this is the value of the line along (dx,dy).
 * 两头都开放(活)与只有一头开放(眠)给的分差一个量级 —— 这是最简单的棋力来源。
 * Both ends open versus only one end open differ by an order of magnitude -- this is the simplest source of playing strength.
 */
int score_dir(int* b, int x, int y, int dx, int dy, int who) {
    int cnt;
    int openA;
    int openB;
    int cx;
    int cy;

    cnt = 1;
    openA = 0;
    openB = 0;

    cx = x + dx;
    cy = y + dy;
    while (cx >= 0 && cx < N && cy >= 0 && cy < N && b[cy * N + cx] == who) {
        cnt = cnt + 1;
        cx = cx + dx;
        cy = cy + dy;
    }
    if (cx >= 0 && cx < N && cy >= 0 && cy < N && b[cy * N + cx] == EMPTY) openA = 1;

    cx = x - dx;
    cy = y - dy;
    while (cx >= 0 && cx < N && cy >= 0 && cy < N && b[cy * N + cx] == who) {
        cnt = cnt + 1;
        cx = cx - dx;
        cy = cy - dy;
    }
    if (cx >= 0 && cx < N && cy >= 0 && cy < N && b[cy * N + cx] == EMPTY) openB = 1;

    if (cnt >= 5) return 1000000;
    if (cnt == 4) {
        if (openA == 1 && openB == 1) return 100000;
        if (openA == 1 || openB == 1) return 10000;
        return 0;
    }
    if (cnt == 3) {
        if (openA == 1 && openB == 1) return 8000;
        if (openA == 1 || openB == 1) return 800;
        return 0;
    }
    if (cnt == 2) {
        if (openA == 1 && openB == 1) return 500;
        if (openA == 1 || openB == 1) return 50;
        return 0;
    }
    if (openA == 1 && openB == 1) return 10;
    return 3;
}

int score_cell(int* b, int x, int y, int who) {
    int s;
    s = 0;
    s = s + score_dir(b, x, y, 1, 0, who);
    s = s + score_dir(b, x, y, 0, 1, who);
    s = s + score_dir(b, x, y, 1, 1, who);
    s = s + score_dir(b, x, y, 1, -1, who);
    return s;
}

/* 挑一个最好的空点。返回 y*N+x;没有空点返回 -1。
 * Pick the best empty point. Returns y*N+x; returns -1 when there is no empty point.
 *
 * 打分 = 进攻分 + 防守分:
 * The score is the attack score plus the defense score:
 *   · 自己能成五 → 进攻分 1000000,再加 5000000 保证"能赢就先赢",
 *   · If we can make five -> attack score 1000000, plus 5000000 to guarantee "win first when we can",
 *     不会被"挡住对方成五"同分抢走(两个都是 1000000,谁先扫到谁赢,那是掷骰子);
 *     so it is not stolen by an equal score for blocking the opponent's five (both are 1000000, so whoever scans first wins, which is a dice roll);
 *   · 对方能成五 → 防守分 1000000 → 必然去堵。
 *   · If the opponent can make five -> defense score 1000000 -> we will certainly block it.
 * 平手时选**更靠近中心**的点(中心先手价值高)。
 * On a tie, pick the point **closer to the center** (the center is worth more to the first player).
 */
int ai_pick(int* b, int firstEmpty) {
    int x;
    int y;
    int best;
    int bx;
    int by;
    int off;
    int def;
    int sc;
    int bd;
    int d;
    int cd;

    best = -1;
    bx = -1;
    by = -1;
    bd = 9999;

    for (y = 0; y < N; y = y + 1) {
        for (x = 0; x < N; x = x + 1) {
            if (b[y * N + x] != EMPTY) continue;
            off = score_cell(b, x, y, WHITE);
            def = score_cell(b, x, y, BLACK);
            sc = off + def;
            if (off >= 1000000) sc = sc + 5000000;

            d = x - 7;
            if (d < 0) d = 0 - d;
            cd = y - 7;
            if (cd < 0) cd = 0 - cd;
            d = d + cd;

            if (sc > best || (sc == best && d < bd)) {
                best = sc;
                bx = x;
                by = y;
                bd = d;
            }
        }
    }
    if (bx < 0) return -1;
    return by * N + bx;
}

/* ─────────── 绘制 ─────────── */
/* ----------- Drawing ----------- */

void draw_board(int* b, int pad, int padY, int cell, int lastIdx, int over, int lang) {
    int i;
    int x0;
    int y0;
    int x1;
    int y1;
    int cx;
    int cy;
    int r;
    int v;
    int half;
    char* s;

    half = (N - 1) * cell;
    ui_2d_clear(COL_BG);

    /* 棋盘底 */
    /* Board background */
    ui_2d_rect(pad - cell / 2, padY - cell / 2, half + cell, half + cell, COL_BOARD, 1, 0, 6);

    /* 格线 */
    /* Grid lines */
    for (i = 0; i < N; i = i + 1) {
        ui_2d_line(pad, padY + i * cell, pad + half, padY + i * cell, COL_LINE, 1);
        ui_2d_line(pad + i * cell, padY, pad + i * cell, padY + half, COL_LINE, 1);
    }

    /* 星位(15 路:4 个 (3,3) 型 + 天元) */
    /* Star points (15x15: four (3,3) type points plus the center) */
    ui_2d_circle(pad + 3 * cell, padY + 3 * cell, 3, COL_LINE, 1, 0);
    ui_2d_circle(pad + 11 * cell, padY + 3 * cell, 3, COL_LINE, 1, 0);
    ui_2d_circle(pad + 3 * cell, padY + 11 * cell, 3, COL_LINE, 1, 0);
    ui_2d_circle(pad + 11 * cell, padY + 11 * cell, 3, COL_LINE, 1, 0);
    ui_2d_circle(pad + 7 * cell, padY + 7 * cell, 3, COL_LINE, 1, 0);

    /* 棋子。半径取格宽的五分之二 —— 留出缝,挨着的子不会糊成一片 */
    /* Stones. The radius is two fifths of the cell width -- that leaves a gap so adjacent stones do not blur together */
    r = cell * 2 / 5;
    if (r < 3) r = 3;

    for (i = 0; i < N * N; i = i + 1) {
        v = b[i];
        if (v == EMPTY) continue;
        cx = pad + (i % N) * cell;
        cy = padY + (i / N) * cell;
        if (v == BLACK) {
            ui_2d_circle(cx, cy, r, COL_BLACK, 1, 0);
        } else {
            ui_2d_circle(cx, cy, r, COL_WHITE, 1, 0);
        }
        /* 最后一手加个记号,方便看清电脑刚下在哪 */
        /* Mark the last move so it is easy to see where the computer just played */
        if (i == lastIdx) ui_2d_circle(cx, cy, r / 3, COL_ACCENT, 1, 0);
    }

    /* 状态行:用**状态式**文字接口 —— 属性设一次,之后只管给坐标和字符串。
     * Status line: use the **stateful** text API -- set the attributes once, then only pass coordinates and strings.
     * 结束时不再写"点任意处再来一局" —— 那件事现在由 `finish()` 的弹框交代,
     * When the game is over we no longer write "tap anywhere to play again" -- the `finish()` dialog now handles that,
     * 一行小字既没人看、又与弹框重复。这行只管"当前该谁下"。
     * such a small line is neither read nor needed next to the dialog. This line only says whose turn it is.
     */
    if (over == 1) s = lang == 0 ? "你赢了!" : "You win!";
    else if (over == 2) s = lang == 0 ? "电脑赢了" : "Computer wins";
    else if (over == 3) s = lang == 0 ? "平局" : "Draw";
    else s = lang == 0 ? "你执黑,点棋盘落子" : "You are black — tap to place";

    ui_text_set_font(15, VML_FONT_BOLD, COL_TEXT, VML_ANCHOR_LEFT);
    ui_2d_text_cur(pad, padY + half + cell, s);

    /* 结束时有结果,用强调色再画一遍(同一条字符串、同一套接口,只换了颜色) */
    /* When the game is over, draw the result again in the accent color (same string, same API, only the color changed) */
    if (over != 0) {
        ui_text_set_font(15, VML_FONT_BOLD | VML_FONT_ITALIC, COL_ACCENT, VML_ANCHOR_RIGHT);
        ui_2d_text_cur(pad + half, padY + half + cell, s);
    }

    ui_2d_present();
}

/* ─────────── 一局结束 ─────────── */
/* ----------- End of a round ----------- */

/* 出声 + 弹框问要不要再来一局。返回 1 = 再来,0 = 退出。
 * Play a sound and ask with a dialog whether to play again. Returns 1 = play again, 0 = quit.
 *
 * **为什么必须弹框**:原来只在棋盘下面写了一行 15px 的小字(「你赢了!点任意处再来一局」),
 * **Why a dialog is required**: previously only a 15px line was written under the board ("You win! Tap anywhere to play again"),
 * 而玩家盯着的是棋盘 —— 那一行在屏幕下方、又不闪不动,实测用户的原话是
 * while the player is staring at the board -- that line sits at the bottom of the screen and neither blinks nor moves; the user's actual words were
 * 「赢了输了都没看到输赢的提示框,只是棋盘清空了,重新开始了」。
 * "for a win or a loss I never saw a result dialog, the board just cleared and the game started over".
 * 胜负是这一局唯一必须让玩家知道的事,**用一行小字交代等于没交代**:
 * The result is the one thing the player must be told this round; **conveying it in one small line is the same as not conveying it at all**:
 * 要么弹框(挡住视线、必须点一下才消失),要么根本别做这个游戏。
 * either use a dialog (it blocks the view and must be dismissed with a tap) or do not make this game at all.
 *
/* ── 音效:**用 ui_audio_beep 单音**(v0.96.507 换回来) ────────────────
 * -- Sound effects: **single tones via ui_audio_beep** (switched back in v0.96.507) --
 *
 * ⚠⚠ **这里一度改用共享库的音序器(`ui_sfx_add`)做过一版,手机上破音,换回来了。**
 * ⚠⚠ **One version of this used the shared-library sequencer (`ui_sfx_add`), which crackled on a phone, so it was switched back.**
 *   音序器本身没问题(tetris / pacman 那些用得好好的),破音的是**这里配的音**:
 *   The sequencer itself is fine (the tetris and pacman ones use it happily); what crackled was **the tuning used here**:
 *   胜负那一组是**多个音叠着响**(大三和弦 3 个声部 + 收尾音,输的那组也是 3 个),
 *   the win/lose group has **several tones sounding together** (a major triad of 3 voices plus a closing note, and the losing group is also 3),
 *   多声部混音一叠加,音量就顶到削波;再加上赢的收尾音留了 14 拍那么长,
 *   mixing several voices pushes the volume into clipping; and the closing note of the win sound was held for 14 ticks,
 *   两个人在同一小段时间里一起响,外放就"滋滋"了。
 *   so two of them sounding within a short window made the speaker buzz.
 *
 *   `ui_audio_beep` 是**单通道**的(后一个音掐掉前一个)⇒ 一个事件永远只有一个音在响,
 *   `ui_audio_beep` is **single channel** (a new tone cuts off the previous one) => an event can never have more than one tone sounding,
 *   结构上不可能削波。代价是没有和弦、没有音色,但对落子/胜负这类**一次性提示音**
 *   so clipping is structurally impossible. The cost is no chords and no timbre, but for **one-shot cues** like a move or a result
 *   够用 —— 而且这正是它"以前一直没出问题"的原因。
 *   that is enough -- and that is exactly why it never caused trouble before.
 *
 * ⚠ 频率仍然照着「不看屏幕也分得出」配:
 * ⚠ The frequencies still follow "tell them apart without looking at the screen":
 *   人 880Hz(清亮) / 电脑 620Hz(低一截) / 赢 1320Hz 长音 / 输 260Hz 长音 / 平局 500Hz。
 *   human 880Hz (bright) / computer 620Hz (a step lower) / win 1320Hz long / lose 260Hz long / draw 500Hz.
 *   这几个数是 v0.96.173 起就在用的,**别随手调**(调了要真机听)。
 *   These values have been in use since v0.96.173; **do not tweak them casually** (if you change them, listen on a real device).
 */

/* 人落子:清亮、短。 */
/* Human move: bright and short. */
void sfx_put_human(void) { ui_audio_beep(880, 25); }

/* 电脑落子:低一截 —— 与人的那条**一耳朵分得出**。 */
/* Computer move: a step lower -- **tellable apart at a glance** from the human one. */
void sfx_put_ai(void) { ui_audio_beep(620, 25); }

/* 重开:一声干脆的起手音。 */
/* New game: one crisp opening tone. */
void sfx_newgame(void) { ui_audio_beep(760, 60); }

/* 赢:高、长,明亮。 */
/* Win: high, long and bright. */
void sfx_win(void) {
    ui_audio_beep(1320, 320);
    ui_vibrate_simple(60, 0);
}

/* 输:低、长 —— 与"赢"是**两个方向**,不会听错。 */
/* Lose: low and long -- **the opposite direction** from the win sound, so it cannot be misheard. */
void sfx_lose(void) {
    ui_audio_beep(260, 420);
    ui_vibrate_simple(220, 0);
}

/* 平局:中性(既不欢快也不沮丧)。 */
/* Draw: neutral (neither cheerful nor sad). */
void sfx_draw(void) {
    ui_audio_beep(500, 300);
    ui_vibrate_simple(40, 0);
}
int finish(int over, int lang) {
    int r;
    if (over == 1) {
        sfx_win();
        r = ui_dlg_msg(lang == 0 ? "五子棋" : "Gomoku",
                       lang == 0 ? "你赢了!再来一局?" : "You win! Play again?", VML_DLG_QUESTION);
    } else if (over == 2) {
        sfx_lose();
        r = ui_dlg_msg(lang == 0 ? "五子棋" : "Gomoku",
                       lang == 0 ? "电脑赢了。再来一局?" : "Computer wins. Play again?", VML_DLG_QUESTION);
    } else {
        sfx_draw();
        r = ui_dlg_msg(lang == 0 ? "五子棋" : "Gomoku",
                       lang == 0 ? "平局。再来一局?" : "Draw. Play again?", VML_DLG_QUESTION);
    }
    /* 弹框失败(返回 -1)也当"再来" —— 总不能因为宿主弹不出框就把整局卡死在这儿 */
    /* A failed dialog (returns -1) also counts as "play again" -- we should not freeze the whole round just because the host could not show a dialog */
    if (r == 1) return 0;
    return 1;
}

/* ─────────── 主循环 ─────────── */
/* ----------- Main loop ----------- */

int main(void) {
    int b[225];
    int msg[4];
    int sw;
    int sh;
    int cell;
    int pad;
    int padY;
    int availW;
    int availH;
    int i;
    int t;
    int x;
    int y;
    int col;
    int row;
    int idx;
    int over;      /* 0=进行中 1=人胜 2=电脑胜 3=平局 */
                   /* 0=running 1=human won 2=computer won 3=draw */
    int moves;
    int lastIdx;
    int aiIdx;
    int t0;
    int t1;
    int r;
    char* title;
    int lang;      /* 界面语言:开局查一次(ui_scr_get_language 是 syscall,别每帧调) */
                   /* UI language: queried once at start (ui_scr_get_language is a syscall, do not call it every frame) */

    /* 开局:棋盘清空 */
    /* Start: clear the board */
    for (i = 0; i < N * N; i = i + 1) b[i] = EMPTY;

    lang = ui_scr_get_language();
    title = lang == 0 ? "五子棋" : "Gomoku";

    /* **开窗时传 `0, 0`(= 尺寸交给宿主用默认值),开窗之后**再问可用绘图区。
     * **Open the window with `0, 0` (let the host size it), then ask for the usable drawing area afterwards.**
     *
     * `ui_win_open_ex` 的第 2/3 个参数传 `0, 0` = 「尺寸交给宿主」,宿主按本窗声明的 chrome
     * (这里 = 不要手柄)算**真实可用区**。**开窗之前**问 `ui_scr_get_w/h()` 只能拿到**估算**
     * (那时宿主还不知道页面给画布留了多少),手机上会与真实画布差一截;**开窗之后**问的才是真值。
     * Passing `0, 0` as the 2nd/3rd parameters means "let the host pick the size": it computes the **real
     * usable area** for the chrome this window declares (here: no gamepad). `ui_scr_get_w/h()` asked
     * **before** the open only returns an **estimate** (the host does not yet know what the page reserves),
     * which differs from the real canvas on a phone; only the answer **after** the open is the true one.
     * 而窗口的宽高就是**画布的坐标空间**:按 395 的宽度排版、却开一个 360 宽的窗口,
     * The window width and height are the **coordinate space of the canvas**: lay out for a width of 395 but open a 360-wide window
     * 棋盘右半边会被画到画布外面(实测就是"屏幕右边超出"——左边留 17、右边直接切掉)。
     * and the right half of the board is drawn outside the canvas (measured as "the right side is cut off" -- 17 left on the left, the right simply clipped).
     * 所以排版用的那个数必须是**开窗后问到的真实画布**。
     * So the number used for layout must be the **real canvas asked for after the window opens**.
     */
    /* **只支持竖屏 + 不要手柄区**:棋盘是竖着看的,转屏只会让格子重排一次、
     * **Portrait only, plus no gamepad area**: the board is viewed upright, so rotating the screen would only make the cells re-layout once
     * 玩家还得多转回来;而五子棋全程用触摸落子,屏幕手柄一个都用不到 ——
     * and the player would have to rotate back; and gomoku places stones entirely by touch, so not one on-screen pad button is used --
     * 留着一整块手柄区等于白白吃掉一百多像素的棋盘高度。
     * keeping a whole gamepad area would waste more than a hundred pixels of board height for nothing.
     */
    ui_win_open_ex(title, 0, 0, VML_WIN_PORTRAIT, VML_WIN_NO_GAMEPAD);
    sw = ui_scr_get_w();
    sh = ui_scr_get_h();
    if (sw <= 0) sw = 360;
    if (sh <= 0) sh = 620;

    /* 布局:**宽和高分开算约束**,再在整块画布里居中。
     * Layout: **compute the width and height constraints separately**, then center inside the whole canvas.
     *
     * 这里原来写的是 `avail = sh - 34; if (avail > sw) avail = sw;` —— 把两个方向压成
     * This used to read `avail = sh - 34; if (avail > sw) avail = sw;` -- squeezing both directions into
     * 一个数取小者:手机上 sh≈744、sw≈395 ⇒ avail 被压成 395,于是格子按宽度算完之后
     * one number by taking the smaller: on a phone sh is about 744 and sw about 395 => avail was squeezed to 395, so after sizing the cells by width
     * **棋盘被居中在"顶部那 395px"里**,屏幕下面空掉一大半(棋盘挤在上半屏、
     * **the board was centered inside "the top 395px"** and more than half the screen below was left empty (the board squeezed into the upper half
     * 状态文字浮在屏幕中间)。宽高各自约束才不会互相吃掉。
     * and the status text floating in the middle of the screen). Separate width and height constraints stop them from eating each other.
     */
    availW = sw;
    availH = sh - 34;                     /* 底下留一行状态文字 */
                                          /* Leave one line for the status text at the bottom */
    if (availH < 40) availH = 40;

    cell = availW / (N + 1);
    if (availH / (N + 1) < cell) cell = availH / (N + 1);
    if (cell < 4) cell = 4;

    pad = (sw - (N - 1) * cell) / 2;
    padY = (availH - (N - 1) * cell) / 2 + cell / 2;
    if (padY < cell / 2) padY = cell / 2;

    t0 = ui_dlg_msg(lang == 0 ? "五子棋" : "Gomoku",
                    lang == 0 ? "你执黑先行。点棋盘落子,返回箭头退出。"
                              : "You are black. Tap to place, Back to quit.", VML_DLG_INFO);

    over = 0;
    moves = 0;
    lastIdx = -1;
    draw_board(b, pad, padY, cell, lastIdx, over, lang);

    /* 主循环:一个统一的消息队列,取到触摸就换算格子 */
    /* Main loop: one unified message queue; when a touch arrives, convert it into a cell */
    while (ui_win_is_closed() == 0) {
        t = ui_msg_wait(msg, 0);
        if (t == 0) continue;                     /* 超时(这里不会发生,0=无限等) */
                                                  /* Timeout (cannot happen here, 0 means wait forever) */
        if (t == VML_MSG_WINDOWCLOSE) break;
        if (t != VML_MSG_TOUCHDOWN && t != VML_MSG_MOUSEDOWN) continue;

        x = msg[1];
        y = msg[2];

        col = hit_col(x, pad, cell);
        row = hit_col(y, padY, cell);
        if (col < 0 || row < 0) continue;

        idx = row * N + col;
        if (b[idx] != EMPTY) continue;             /* 这儿有子了 */
                                                   /* There is already a stone here */

        /* 人落子 */
        /* The human plays */
        b[idx] = BLACK;
        moves = moves + 1;
        lastIdx = idx;
        sfx_put_human();          /* 人:清亮一点 */
                                  /* Human: a bit brighter */

        if (has_won(b, col, row, BLACK) == 1) {
            over = 1;
        } else if (moves >= N * N) {
            over = 3;
        } else {
            /* 先画一手人的,让手感立刻有反馈,再算电脑的 */
            /* Draw the human's move first so there is instant feedback, then work out the computer's */
            draw_board(b, pad, padY, cell, lastIdx, over, lang);

            /* 电脑落子 */
            /* The computer plays */
            aiIdx = ai_pick(b, 0);
            if (aiIdx < 0) {
                over = 3;
            } else {
                b[aiIdx] = WHITE;
                moves = moves + 1;
                lastIdx = aiIdx;
                col = aiIdx % N;
                row = aiIdx / N;
                sfx_put_ai();              /* 电脑:低一点,一耳朵分得出是谁下的 */
                                           /* Computer: a bit lower, so you can tell who played */
                if (has_won(b, col, row, WHITE) == 1) over = 2;
                else if (moves >= N * N) over = 3;
            }
        }

        draw_board(b, pad, padY, cell, lastIdx, over, lang);

        /* 一局结束:**收在这一处**(四种结束方式都汇到这里)——
         * End of a round: **collected in this one place** (all four ways of ending converge here) --
         * 出声、弹框问要不要再来;不想再来就退出窗口,而不是默默重开。
         * play a sound, ask with a dialog whether to play again; if not, close the window rather than silently restarting.
         */
        if (over != 0) {
            if (finish(over, lang) == 0) break;
            /* 清掉上一局没读完的输入。一次点击会产生**多条**消息(按下/抬起/移动),
             * Clear the unread input left from the previous round. One tap produces **several** messages (down/up/move),
             * 而主循环通常只读它要的那条,剩下的就留在队列里 —— 不清的话新一局
             * and the main loop usually reads only the one it needs, leaving the rest in the queue -- without clearing them, the new round
             * 一上来就会把上一局最后那点读出来,黑子立刻落到那个位置上。
             * would immediately read the tail of the previous round and a black stone would land on that spot at once.
             */
            ui_msg_clear();
            for (i = 0; i < N * N; i = i + 1) b[i] = EMPTY;
            over = 0;
            moves = 0;
            lastIdx = -1;
            sfx_newgame();
            draw_board(b, pad, padY, cell, lastIdx, over, lang);
        }
    }

    /* 收尾:等一小会儿再关,免得窗口一闪而过(宿主定时刷新,这里只是留个缓冲) */
    /* Wrap up: wait a moment before closing so the window does not flash by (the host refreshes on a timer; this is just a buffer) */
    ui_timer_set(120, 0);
    t1 = ui_msg_wait(msg, 400);
    ui_win_close();
    return 0;
}