← 都来码 / Dolaima← 例程索引cpp/app/minesweeper.cpp原文

cpp/app/minesweeper.cpp · 63591 字节 · 下载原文

// 扫雷 —— 用 C++ 的类写

// Minesweeper — written with C++ classes

//

// 棋盘**按画布自适应**:列数固定 9,格子边长同时受"按宽度算"和"按高度算"两个上界约束

// The board **adapts to the canvas**: the column count is fixed at 9 and the cell edge is bounded both by the width and by the height

// (取小者),行数再由"剩下多高放得下几行"决定 —— 无论画布是窄是扁都铺得满、也不会溢出。

// (the smaller wins), while the row count follows from how many fit in the remaining height — so it fills the space and never overflows, however narrow or flat the canvas is.

// 状态条高度、图标半径、字号同样全部由画布高推出来,不再有任何"照着某个固定尺寸写死"的数。

// The status-bar height, the icon radii and the font sizes are all derived from the canvas height too; nothing is written against a fixed size any more.

// 雷数同样按面积折算(9 列 12 行 ⇒ 12 颗),面积变了雷数跟着变。

// The mine count scales with the area as well (9 columns by 12 rows gives 12 mines), so a different area shifts the mine count with it.

//

// ## 画布的"坐标空间"才是排版的唯一真源

// ## The canvas's coordinate space is the one and only source of truth for the layout

//

// ⚠ 程序画的每一笔都落进**窗口的坐标空间**,出了界就被宿主裁掉 —— 而 `ui_scr_get_w/h()` 报的是

// ⚠ Every mark the program draws lands inside **the window's coordinate space**; anything outside is clipped by the host — while `ui_scr_get_w/h()` reports

//   "宿主此刻量到的可用绘图区"。两者**只在声明了 `VML_WIN_ROTATABLE` 时才恒等**

//   "the usable drawing area the host has measured right now". The two are equal **only when `VML_WIN_ROTATABLE` was declared**

//   (视口一变,宿主就把新空间整个给过来,同时报同一个数)。

//   (on any viewport change the host hands the whole new space over and reports that same number).

//

//   ⚠ 这里原来声明的是 `VML_WIN_PORTRAIT` —— 那一档的坐标空间**开窗时就冻住**,宿主只把整幅画面

//   ⚠ This used to declare `VML_WIN_PORTRAIT` — that mode **freezes its coordinate space at open time**, and the host merely

//     缩放着显示;可 `ui_scr_get_w/h()` 仍会跟着视口变。于是收到 `WINDOWRESIZE` 后按**新尺寸**重排、

//     scales the picture to fit; yet `ui_scr_get_w/h()` still follows the viewport. So after a `WINDOWRESIZE` the program re-laid out for the **new size**

//     画出来却落进**旧空间**里 ⇒ 实测症状就是"棋盘最下面一行只画了半格、右上角计时器只露出一个数字、

//     while drawing into the **old space** ⇒ measured on a phone as "the bottom row of the board shows only half a cell, the timer in the top-right corner shows just one digit,

//     状态条右侧内容越界"。**排版用的数、开窗用的数、能画的数三者必须同源**。

//     and the right end of the status bar runs off the canvas". The numbers used to lay out, to open the window and to draw **must come from one source**.

//

//   竖屏这件事改由 `ui_scr_lock_orientation(VML_LOCK_PORTRAIT)` 声明 —— 那是"**系统**让不让转",与

//   Portrait is declared by `ui_scr_lock_orientation(VML_LOCK_PORTRAIT)` instead — that is about whether the **system** may rotate, which is

//   "视口变了窗口跟不跟"(= ROTATABLE)是两件事,混成一件就会掉进上面那个坑。

//   a different matter from "does the window follow a viewport change" (= ROTATABLE); conflating the two is exactly what falls into the trap above.

//

// ## 操作

// ## Controls

//

//   单击 = 翻开;长按(约 0.5 秒)= 插旗 / 取消插旗;点笑脸 = 重开一局。

//   Tap = reveal; long press (about half a second) = flag / unflag; tap the face = start a new round.

//   接物理键盘时也能玩:方向键移动光标、回车翻开、空格插旗、ESC 退出。

//   A physical keyboard works too: arrow keys move the cursor, Enter reveals, Space flags, ESC quits.

//

// ## 手感上的两条关键约定

// ## Two key conventions that make it feel right

//

//   ① **首点安全区**:雷是在**第一次翻开之后**才布下的,而且第一下点到的格子**连同它周围八格**

//   ① **First-click safe zone**: the mines are only laid **after the first reveal**, and neither the tapped cell

//      都不会有雷 —— 所以第一下必定是一片空白,翻开就是一次泛洪。

//      **nor its eight neighbours** can hold a mine — so the first tap is always a blank patch that floods open.

//   ② **长按在"手指还按着"的时候就判定**(看 `ui_time_get_ms()` 的流逝时间),不是等抬手才算。

//   ② **The long press is decided while the finger is still down** (from the elapsed time reported by `ui_time_get_ms()`), not on release.

//      等抬手才判的话,"长按翻旗之后接着拖动"这条路就断了。

//      Deciding it on release would break the "hold to flag, then keep dragging" path.

//

// ## 跑法

// ## How to run

//

//   手机:`vml run examples/cpp/minesweeper.cpp`

//   Phone: `vml run examples/cpp/minesweeper.cpp`

//   桌面:`vmlcli Examples/cpp/minesweeper.cpp --screen 411x726 --frame /tmp/ms.png`

//   Desktop: `vmlcli Examples/cpp/minesweeper.cpp --screen 411x726 --frame /tmp/ms.png`


#include <vml_ui.h>

// ════════════════════════════════════════════════════════════════════

// 尺寸、节奏与配色常量

// Sizing, timing and colour constants

// ════════════════════════════════════════════════════════════════════


#define COLS      9        // 棋盘列数(固定)

// Board width in columns (fixed)

#define MAXROWS   20       // 行数上限

// Upper bound on rows

// ⚠ 格子总数上限**必须写成字面量**:本前端不会折叠数组维度里的表达式 ——

// ⚠ The cell-capacity constant **must be written as a literal**: this front end does not fold expressions inside array dimensions —

//   `(COLS * MAXROWS)` 会被当成 1,于是 `int gMine[MAXCELLS]` 是一个**单元素数组**,

//   `(COLS * MAXROWS)` is taken as 1, so `int gMine[MAXCELLS]` becomes a **one-element array**

//   而棋盘循环会一路踩到后面所有全局量上(症状是"字段读出来全是 0、窗口开成 320×240")。

//   and the board loops then trample every global declared after it (the symptom is "every field reads back as 0 and the window opens at 320 by 240").

//   它等于 `COLS * MAXROWS`,`Layout()` 里另有一道按同一个数的钳位守着,改上面两个数不必记得改这里。

//   It equals `COLS * MAXROWS`, and `Layout()` carries a clamp against the same number, so changing those two does not require remembering this one.

#define MAXCELLS  180
#define MINROWS   8        // 行数下限 —— 格子边长也按它算,保证扁画布上棋盘不会比画布还高

// Lower bound on rows — the cell edge is derived from it too, which is what keeps the board from being taller than a flat canvas

#define MINCELL   8        // 格子边长下限(比这还小就不是能玩的棋盘了)

// Lower bound on the cell edge (below this it is no longer a playable board)

// ── 下面这几个都是**比例**,不是像素:画布多大都按它折算,改画布大小不用改这里 ──

// ── The following are **ratios**, not pixels: everything is derived from them for any canvas size ──

#define BAR_PCT   8        // 状态条高度 = 画布高 × 8%

// Status-bar height = canvas height × 8%

#define BAR_MIN   32       // 状态条高度下限(再矮笑脸/数字就挤在一起了)

// Lower bound on the status-bar height (any shorter and the face and the digits collide)

#define BAR_MAX   64       // 状态条高度上限(再高就白占棋盘的地方)

// Upper bound on the status-bar height (any taller and it just eats the board's space)

#define PAD_PCT   2        // 棋盘四周留白 = 画布窄边 × 2%

// Margin around the board = the canvas's narrow edge × 2%

#define PAD_MIN   3        // 留白下限

// Lower bound on the margin

#define HOLD_MS   500      // 长按判定阈值(毫秒)

// Long-press threshold in milliseconds

#define TICK_MS   50       // 主定时器周期(20Hz:够做长按判定,也不费电)

// Main timer period (20Hz: fine enough for the long press and cheap on the battery)

#define MINE_NUM  12       // 雷数 = 格子数 * MINE_NUM / MINE_DEN(9 列 12 行 ⇒ 12 颗)

// Mines = cells * MINE_NUM / MINE_DEN (9 columns by 12 rows gives 12)

#define MINE_DEN  108

// 局面状态

// Game states

#define ST_PLAY  0         // 进行中

// In progress

#define ST_BOOM  1         // 刚踩到雷(亮雷 + 震动,约 1 秒后转到结束态)

// Just stepped on a mine (mines shown and a buzz, switching to the over state after about a second)

#define ST_WIN   2         // 赢

// Won

#define ST_OVER  3         // 结束(点屏幕重开)

// Over (tap the screen to restart)


// 配色。棋盘走**经典浅灰**:1-8 的经典配色(蓝/绿/红/深蓝/棕/青/黑/灰)只有压

// Colours. The board uses the **classic light grey**: the classic digit colours (blue/green/red/navy/brown/teal/black/grey) are

// 在浅底上才读得清;状态条保持深色,两边分工明确。

// only legible on a light background; the status bar stays dark, so the two halves have clear roles.

#define C_BG        0xFF1A1E28
#define C_BAR       0xFF232838
#define C_BAR_LINE  0xFF3A4258
#define C_TEXT      0xFFE8ECF8
#define C_TEXT_DIM  0xFF98A0B8
#define C_ICON      0xFF9AA4BC
#define C_GRID      0xFF6E7686   // 棋盘底板(格子缝里透出来的那一色)

// The board backdrop (the colour showing through the seams between cells)

#define C_HID_FACE  0xFFB4BCCE   // 未翻开:亮面

// Unrevealed: the light face

#define C_HID_DARK  0xFF78808F   // 未翻开:右下露出的暗边

// Unrevealed: the dark edge showing along the bottom right

#define C_OPEN_BG   0xFFC4CAD8   // 已翻开:浅灰底

// Revealed: the light grey fill

#define C_MINE      0xFF1E2430
#define C_FLAG      0xFFD02828
#define C_POLE      0xFF303038
#define C_BOOM_BG   0xFFD03030
#define C_FACE_BG   0xFFFFD24A
#define C_FACE_EDGE 0xFFB08A10
#define C_FACE_INK  0xFF2A2410
#define C_CURSOR    0xFFFFD24A
#define C_OVL       0xE0181C26
#define C_WIN_INK   0xFF66E08A
#define C_LOSE_INK  0xFFFF7A6A
// 数字 1-8 的经典配色

// The classic colours for the digits 1 to 8

#define C_N1 0xFF1E50D8
#define C_N2 0xFF1E8C34
#define C_N3 0xFFC81E1E
#define C_N4 0xFF101C78
#define C_N5 0xFF7A3A10
#define C_N6 0xFF1E8C8C
#define C_N7 0xFF101018
#define C_N8 0xFF6E7480

// ════════════════════════════════════════════════════════════════════

// 棋盘数据

// Board data

//

// ⚠ 全部放**文件级数组**,不放进类里:本前端"类内部用隐式 `this` 给成员数组做下标读"

// ⚠ Everything lives in **file-level arrays** rather than in the class: in this front end, "reading a member array

//   还会算错地址(见 `gorilla.cpp` 文件头 ①)。文件级数组在成员方法里下标访问是好的。

//   by index inside the class through an implicit `this`" still computes the wrong address (see point ① in `gorilla.cpp`).

//   File-level arrays indexed from inside a member method are fine.

// ════════════════════════════════════════════════════════════════════


static int gMine[MAXCELLS];      // 1 = 这一格有雷

// 1 = this cell holds a mine

static int gReveal[MAXCELLS];    // 1 = 已翻开

// 1 = already revealed

static int gFlag[MAXCELLS];      // 1 = 插了旗

// 1 = flagged

static int gAdj[MAXCELLS];       // 相邻雷数 0..8

// Number of adjacent mines, 0..8

static int gStack[MAXCELLS];     // 泛洪用的**显式**栈(不许递归)

// The **explicit** stack used by the flood fill (recursion is not allowed)

// ⚠ ~~顶点数组也放**文件级**:同名的函数局部数组当指针传给 `ui_2d_polygon/ui_2d_polyline` 时,~~

// ⚠ ~~The vertex array lives at **file level** too: a function-local array of this name passed as a pointer to `ui_2d_polygon` or `ui_2d_polyline`~~

//   ~~本前端会把地址算成 0(实测 `polyline ptsAddr=0` ⇒ 那一笔**静默不画**),~~

//   ~~has its address computed as 0 by this front end (measured as `polyline ptsAddr=0` ⇒ that call **draws nothing, silently**),~~

//   ~~而文件级数组取地址是好的。形状都在同一帧里顺序使用,共用一个不冲突。~~

//   ~~while taking the address of a file-level array works. The shapes are used strictly one after another within a frame, so sharing one is safe.~~

//   ✅ **已修好(2026-10-04 复验)** —— 函数局部数组当指针传给 `ui_2d_polygon` 实测 `ptsAddr=1028`(文件级数组 `ptsAddr=1057`),两笔都正常画出。

//   ✅ **Fixed (re-measured 2026-10-04)** — a function-local array passed as a pointer to `ui_2d_polygon` measured `ptsAddr=1028` (a file-level array gives `ptsAddr=1057`); both draw normally.

//   形状仍共用文件级数组 —— 那是**已经跑通的形状**,能跑就别动;新写的代码不必再绕。

//   The shapes still share the file-level array — that shape **already works**, so leave it if it runs; new code need not avoid it.

static int gPts[10];

// 界面语言:开局查一次(`ui_scr_get_language` 是 syscall,别每帧调)

// UI language: looked up once at start (`ui_scr_get_language` is a syscall, do not call it every frame)

static int gLang;

// ════════════════════════════════════════════════════════════════════

// 通用小工具

// Small general helpers

// ════════════════════════════════════════════════════════════════════


// 整数 → 字符串(不引 std::string;`sprintf` 在各前端形态不一)

// Integer to string (no std::string; `sprintf` differs across front ends)

static char g_num[12];

char* numstr(int v)
{
    int n = 0;
    int m = 0;
    char tmp[12];
    if (v < 0) { g_num[0] = '-'; v = -v; n = 1; }
    if (v == 0) { g_num[n] = '0'; g_num[n + 1] = 0; return g_num; }
    while (v > 0) { tmp[m] = (char)('0' + v % 10); v = v / 10; m = m + 1; }
    while (m > 0) { m = m - 1; g_num[n] = tmp[m]; n = n + 1; }
    g_num[n] = 0;
    return g_num;
}

// 数字 → 经典配色。用 if 链而不是数组初值:本前端对 `{...}` 初始化列表支持未验。

// Digit to its classic colour. An if chain rather than an array initialiser: this front end's support for `{...}` lists is unverified.

int NumColor(int n)
{
    if (n == 1) { return C_N1; }
    if (n == 2) { return C_N2; }
    if (n == 3) { return C_N3; }
    if (n == 4) { return C_N4; }
    if (n == 5) { return C_N5; }
    if (n == 6) { return C_N6; }
    if (n == 7) { return C_N7; }
    return C_N8;
}

// ════════════════════════════════════════════════════════════════════

// 图形零件 —— 盘面与状态条共用同一份,所以每种形状只写一遍

// Drawing parts — the board and the status bar share these, so every shape is written once

// ════════════════════════════════════════════════════════════════════


// 未翻开的格子:**两笔**就画出立体感 —— 暗色整格 + 亮面只铺左上一角,

// An unrevealed tile: **two calls** give it volume — a dark full cell plus a light face covering only the top-left,

// 右边和下边因此各露出两像素暗边(经典"凸起方块"的光影),而每格只花两个图元。

// so two dark pixels show along the right and bottom edges (the classic raised-tile shading) at two figures per cell.

//

// ⚠⚠ **别改成"录成图块再贴"**:图块贴一次会把**块内的每一笔再加上 5 条变换行**都算进

// ⚠⚠ **Do not change this into "record a block and stamp it"**: a stamp counts **every line inside the block plus five transform lines**

//   场景图元数(实测:135 格 × 9 行 = **1097 行**,而同一个画面逐笔直接画只有 2 行/格)。

//   towards the scene's figure count (measured: 135 cells × 9 lines = **1097 lines**, while drawing them directly costs 2 lines per cell).

//   图块省的是 VM→宿主 的调用次数,**不是**每帧图元数 —— 而手机上被盯着看的那个量正是后者。

//   A block saves VM-to-host calls, **not** per-frame figures — and the figure count is exactly what gets watched on a phone.

void HiddenTile(int x, int y, int c)
{
    ui_2d_rect(x, y, c, c, C_HID_DARK, 1, 0, 0);
    ui_2d_rect(x, y, c - 2, c - 2, C_HID_FACE, 1, 0, 0);
}

// 已翻开的格子:一块浅灰底

// A revealed cell: one light grey fill

void OpenTile(int x, int y, int c)
{
    ui_2d_rect(x, y, c - 1, c - 1, C_OPEN_BG, 1, 0, 0);
}

// 数字格:浅灰底 + 一个经典配色的数字(竖直方向按格心居中)

// A numbered cell: the light grey fill plus a digit in its classic colour, centred vertically on the cell

void NumberTile(int x, int y, int c, int n)
{
    OpenTile(x, y, c);
    ui_2d_text_v(x + c / 2, y + c / 2, numstr(n), NumColor(n), c * 62 / 100, VML_ANCHOR_CENTER, VML_VANCHOR_MIDDLE, VML_FONT_BOLD);
}

// 地雷形状(圆心 + 四条交叉的刺)

// The mine shape (a filled circle plus four crossing spikes)

void MineShape(int cx, int cy, int r, int col)
{
    int e;
    e = r + r / 2;
    ui_2d_circle(cx, cy, r, col, 1, 0);
    ui_2d_line(cx - e, cy, cx + e, cy, col, 2);
    ui_2d_line(cx, cy - e, cx, cy + e, col, 2);
    ui_2d_line(cx - r, cy - r, cx + r, cy + r, col, 2);
    ui_2d_line(cx - r, cy + r, cx + r, cy - r, col, 2);
}

// 旗子:杆 + 三角旗面(画在 (x,y) 那一格的原地)

// The flag: a pole plus a triangular pennant (drawn in place, in the cell at (x, y))

void FlagShape(int x, int y, int c, int poleCol, int flagCol)
{
    gPts[0] = x + c * 4 / 10; gPts[1] = y + c * 2 / 10;
    gPts[2] = x + c * 8 / 10; gPts[3] = y + c * 45 / 100;
    gPts[4] = x + c * 4 / 10; gPts[5] = y + c * 7 / 10;
    ui_2d_line(x + c * 4 / 10, y + c * 2 / 10, x + c * 4 / 10, y + c * 8 / 10, poleCol, 2);
    ui_2d_line(x + c * 2 / 10, y + c * 8 / 10, x + c * 7 / 10, y + c * 8 / 10, poleCol, 2);
    ui_2d_polygon(gPts, 3, flagCol, 0, 0, 0);
}

// 笑脸的嘴:五点的折线,弯成一张弓(屏幕坐标 y 向下 ⇒ 中间更低才是"笑")

// The face's mouth: a five-point polyline bent into a bow (screen y grows downward, so a smile has its middle lower)

void MouthShape(int cx, int cy, int r, int smile, int col)
{
    int s;
    if (smile != 0) { s = 1; } else { s = -1; }
    gPts[0] = cx - r * 5 / 10; gPts[1] = cy + s * r * 1 / 10;
    gPts[2] = cx - r * 3 / 10; gPts[3] = cy + s * r * 5 / 10;
    gPts[4] = cx;              gPts[5] = cy + s * r * 7 / 10;
    gPts[6] = cx + r * 3 / 10; gPts[7] = cy + s * r * 5 / 10;
    gPts[8] = cx + r * 5 / 10; gPts[9] = cy + s * r * 1 / 10;
    ui_2d_polyline(gPts, 5, col, 3, 0);
}

// ════════════════════════════════════════════════════════════════════

// Game —— 总控

// Game — the master controller

// ════════════════════════════════════════════════════════════════════


class Game
{
public:
    int sw;            // 画布宽(= 窗口坐标空间的宽,开窗/排版/绘制共用这一个数)

    // Canvas width (= the width of the window's coordinate space, shared by opening, layout and drawing)

    int sh;            // 画布高

    // Canvas height

    int statusH;       // 状态条高度(由画布高推)

    // Status-bar height (derived from the canvas height)

    int pad;           // 棋盘四周留白(由画布窄边推)

    // Margin around the board (derived from the canvas's narrow edge)

    int cols;
    int rows;
    int cell;          // 格子边长(像素,取整后的值)

    // Cell edge length in pixels (already rounded down)

    int boardX;
    int boardY;
    int boardW;
    int boardH;
    int mines;         // 总雷数

    // Total number of mines

    int flags;         // 已插旗数

    // Number of flags placed

    int revealed;      // 已翻开的格子数

    // Number of revealed cells

    int state;
    int started;       // 首点翻过了没有(雷就是在这个时候才布的)

    // Whether the first reveal has happened (that is when the mines are laid)

    int startMs;
    int shownSec;      // 显示出来的秒数 —— 只在它变了的时候才重绘

    // The seconds figure on screen — a redraw happens only when it changes

    int boomMs;
    int boomIdx;       // 踩中的那一颗(红底)

    // The mine that was stepped on (drawn on a red background)

    int cursor;        // 键盘光标的格子下标

    // Board index of the keyboard cursor

    int useCursor;
    int pressCell;     // 按下的那一格(-1 = 没按在棋盘上)

    // The cell under the finger (-1 = not pressing the board)

    int pressMs;
    int longFired;     // 这一按的长按已经兑现过了

    // Whether this press has already paid out its long press

    int pressOnFace;
    int quit;

    // ── 排版:把**画布坐标空间**切成"状态条 + 棋盘"两块 ────────────────

    // ── Layout: split the **canvas coordinate space** into a status bar and a board ──

    //

    // ⚠ 传进来的 (w, h) 就是画布的坐标空间,**开窗、排版、绘制只能用同一个数**(见文件头)。

    // ⚠ The (w, h) passed in are the canvas coordinate space; the window is opened, the layout is computed and the drawing is done **from that same pair** (see the file header).

    //   传 0/0 = "还没量到,问宿主"(开窗之前那一次调用)。

    //   0/0 means "not measured yet, ask the host" (the one call made before the window opens).

    void Layout(int w, int h)
    {
        int narrow;
        int availH;
        int byW;
        int byH;

        if (w <= 0 || h <= 0) { w = ui_scr_get_w(); h = ui_scr_get_h(); }
        if (w <= 0) { w = 411; }
        if (h <= 0) { h = 726; }
        sw = w;
        sh = h;

        // 状态条高度**由画布高推**(大屏上宽一点才不显扁),再夹进"读得清"的范围

        // The status-bar height is **derived from the canvas height** (it needs to be roomier on a large screen) and then clamped into a legible range

        statusH = sh * BAR_PCT / 100;
        if (statusH < BAR_MIN) { statusH = BAR_MIN; }
        if (statusH > BAR_MAX) { statusH = BAR_MAX; }

        // 留白按**窄边**推:按宽度推的话,扁画布上上下就贴边了

        // The margin is derived from the **narrow edge**: from the width alone a flat canvas would have its top and bottom flush against the edges

        narrow = sw < sh ? sw : sh;
        pad = narrow * PAD_PCT / 100;
        if (pad < PAD_MIN) { pad = PAD_MIN; }

        cols = COLS;

        // ⚠ 格子边长取**两个方向上界的较小者**:只按宽度算,扁画布上棋盘会比画布还高

        // ⚠ The cell edge takes **the smaller of the two directional bounds**: from the width alone the board ends up taller than a flat canvas,

        //   (底部那一行只能画出半格);只按高度算,窄画布上又会横着溢出(右侧那一列被切掉)。

        //   (the bottom row getting only half drawn); from the height alone it overflows sideways on a narrow canvas (the right-hand column being cut off).

        //   `byH` 用 MINROWS 做分母 ⇒ 反推出的行数天然 ≥ MINROWS,下面不必再往下夹。

        //   `byH` divides by MINROWS, so the row count that falls out is always ≥ MINROWS and needs no further clamping below.

        byW = (sw - pad * 2) / cols;
        byH = (sh - statusH - pad * 2) / MINROWS;
        cell = byW < byH ? byW : byH;
        if (cell < MINCELL) { cell = MINCELL; }

        availH = sh - statusH - pad * 2;
        rows = availH / cell;
        if (rows > MAXROWS) { rows = MAXROWS; }
        // 兜底:格子总数不许超过数组容量(`MAXCELLS` 是字面量,这一句让两者永远对得上)

        // Belt and braces: the cell count must not exceed the array capacity (`MAXCELLS` is a literal, and this line keeps the two in step)

        while (cols * rows > MAXCELLS && rows > MINROWS) { rows = rows - 1; }

        boardW = cols * cell;
        boardH = rows * cell;
        // 上面两步的取整方向保证 boardW ≤ sw - 2·pad、boardH ≤ availH ⇒ 居中之后必定落在画布里

        // The rounding direction of the two steps above guarantees boardW ≤ sw - 2·pad and boardH ≤ availH ⇒ once centred it is inside the canvas by construction

        boardX = (sw - boardW) / 2;
        boardY = statusH + pad + (availH - boardH) / 2;
        if (boardX < 0) { boardX = 0; }
        if (boardY < statusH) { boardY = statusH; }

        // 雷数按面积折算(9 列 12 行 ⇒ MINE_NUM 颗),并且**留出首点安全区的余量**(3×3 格)

        // The mine count scales with the area (9 columns by 12 rows gives MINE_NUM) and **leaves room for the first-click safe zone** (a 3×3 block)

        mines = cols * rows * MINE_NUM / MINE_DEN;
        if (mines > cols * rows - 12) { mines = cols * rows - 12; }
        if (mines < 1) { mines = 1; }

        // ⚠ 这里**不清棋盘数组** —— `Layout()` 也会走到"重排了但棋盘形状没变"那条路上

        // ⚠ This does **not** clear the board arrays — `Layout()` is also reached on the "re-laid out but the board shape is unchanged" path,

        //   (换算一下尺寸、换个坐标系),清掉就等于把玩家的一局抹了,而 `mines`/`revealed`

        //   (a size tweak, a new coordinate space), and clearing there would wipe the player's round while `mines`/`revealed`

        //   还是旧值 ⇒ 局面立刻对不上账。清盘是 `NewGame()` **一个人**的活。

        //   kept their old values ⇒ the round would no longer add up. Clearing the board is `NewGame()`'s job **and nobody else's**.

    }

    // 宿主把**新的坐标空间**给过来了 ⇒ 重排;**只有棋盘形状真的变了才重开一局**。

    // The host has handed over a **new coordinate space** ⇒ re-layout; **only a real change of board shape starts a new round**.

    // ⚠ 尺寸抖一下就把玩家的一局清掉是烦人的;反过来,形状变了却沿用旧棋盘,那些格子就落在新空间外面。

    // ⚠ Clearing the player's round over a size jitter is annoying; conversely, keeping the old board after the shape changed would put those cells outside the new space.

    void Relayout(int w, int h)
    {
        int oldRows;
        int oldCell;

        oldRows = rows;
        oldCell = cell;
        Layout(w, h);
        if (rows != oldRows || cell != oldCell) { NewGame(); }
    }

    // 笑脸半径 —— **画和命中判定共用这一处**:两处各算一遍,迟早会漂成"看得见却点不中"

    // The face radius — **shared by drawing and hit testing**: two copies of this arithmetic would eventually drift into "visible but not clickable"

    int FaceR()
    {
        int r;
        r = statusH * 36 / 100;
        if (r < 6) { r = 6; }
        return r;
    }

    // 屏幕坐标 → 格子下标(不在棋盘上返回 -1)

    // Screen point to board index (returns -1 when the point is off the board)

    int IndexAt(int px, int py)
    {
        int cx;
        int cy;
        if (px < boardX || py < boardY) { return -1; }
        cx = (px - boardX) / cell;
        cy = (py - boardY) / cell;
        if (cx < 0 || cx >= cols) { return -1; }
        if (cy < 0 || cy >= rows) { return -1; }
        return cy * cols + cx;
    }

    // 笑脸按钮的命中判定(半径与画的那一个同源)

    // Hit test for the face button (the radius comes from the same place as the drawing)

    int FaceHit(int px, int py)
    {
        int dx;
        int dy;
        int r;
        r = FaceR();
        dx = px - sw / 2;
        dy = py - statusH / 2;
        if (dx * dx + dy * dy <= r * r) { return 1; }
        return 0;
    }

    // ── 布雷:**在第一次翻开之后**才调用,`safe` 就是首点那一格 ─────────

    // ── Lay the mines: called **after the first reveal**, with `safe` being the first-clicked cell ──

    void PlaceMines(int safe)
    {
        int i;
        int n;
        int r;
        int c;
        int sr;
        int sc;
        int guard;
        int total;

        i = 0;
        while (i < MAXCELLS) { gMine[i] = 0; i = i + 1; }

        total = rows * cols;
        sr = safe / cols;
        sc = safe % cols;

        n = 0;
        guard = 0;
        while (n < mines && guard < 20000)
        {
            guard = guard + 1;
            r = ui_math_rand(rows);
            c = ui_math_rand(cols);
            i = r * cols + c;
            if (gMine[i] != 0) { continue; }
            // 首点安全区:点到的那格**与它周围八格**都不布雷(经典手感:第一下必是一片空白)

            // First-click safe zone: neither the tapped cell **nor its eight neighbours** gets a mine (the classic feel: the first tap always opens a blank patch)

            if (r >= sr - 1 && r <= sr + 1 && c >= sc - 1 && c <= sc + 1) { continue; }
            gMine[i] = 1;
            n = n + 1;
        }

        // 兜底:随机撞不满(安全区偏大)就退化成"只保证点到的那格不是雷",绝不死循环

        // Fallback: if random placement cannot fill up (an oversized safe zone), degrade to "only the tapped cell is safe" instead of looping forever

        i = 0;
        while (n < mines && i < total)
        {
            if (gMine[i] == 0 && i != safe)
            {
                gMine[i] = 1;
                n = n + 1;
            }
            i = i + 1;
        }

        RecomputeAdj();
    }

    // 重算每格的相邻雷数(八邻域)

    // Recompute the adjacent mine count of every cell (the eight neighbours)

    void RecomputeAdj()
    {
        int i;
        int r;
        int c;
        int dr;
        int dc;
        int nr;
        int nc;
        int cnt;
        int total;

        total = rows * cols;
        i = 0;
        while (i < total)
        {
            r = i / cols;
            c = i % cols;
            cnt = 0;
            dr = -1;
            while (dr <= 1)
            {
                dc = -1;
                while (dc <= 1)
                {
                    if (dr != 0 || dc != 0)
                    {
                        nr = r + dr;
                        nc = c + dc;
                        if (nr >= 0 && nr < rows && nc >= 0 && nc < cols)
                        {
                            if (gMine[nr * cols + nc] != 0) { cnt = cnt + 1; }
                        }
                    }
                    dc = dc + 1;
                }
                dr = dr + 1;
            }
            gAdj[i] = cnt;
            i = i + 1;
        }
    }

    // 新一局:清盘面、复位所有状态。**不布雷**(雷等首点之后才布)。

    // A new round: clear the board and reset all state. **No mines are laid** (they wait until after the first reveal).

    void NewGame()
    {
        int span;
        int i;

        span = rows * cols;
        i = 0;
        while (i < MAXCELLS)
        {
            gMine[i] = 0; gReveal[i] = 0; gFlag[i] = 0; gAdj[i] = 0;
            i = i + 1;
        }

        state = ST_PLAY;
        started = 0;
        startMs = ui_time_get_ms();
        shownSec = 0;
        boomMs = 0;
        boomIdx = -1;
        flags = 0;
        revealed = 0;
        useCursor = 0;
        cursor = (rows / 2) * cols + cols / 2;
        pressCell = -1;
        pressMs = 0;
        longFired = 1;
        pressOnFace = 0;
    }

    // 泛洪:从 idx 起把连成一片的空白翻开。**显式栈,不递归。**

    // Flood fill: open the connected blank area starting at idx. **An explicit stack, never recursion.**

    int FloodFrom(int idx)
    {
        int sp;
        int c;
        int n;
        int r;
        int cc;
        int dr;
        int dc;
        int nr;
        int nc;
        int got;

        got = 0;
        sp = 0;
        if (gReveal[idx] != 0 || gFlag[idx] != 0) { return 0; }

        // ⚠ 在**入栈时**就打上"已翻开":这样每格最多进栈一次,栈深天然不超过格子总数

        // ⚠ Mark the cell as revealed **at push time**: that way every cell enters the stack at most once and the depth can never exceed the cell count

        gReveal[idx] = 1;
        got = 1;
        gStack[sp] = idx;
        sp = sp + 1;

        while (sp > 0)
        {
            sp = sp - 1;
            c = gStack[sp];
            // 只有"周围一颗雷都没有"的格子才继续往外扩 —— 数字格到此为止

            // Only a cell with **no adjacent mine at all** keeps expanding — a numbered cell stops here

            if (gAdj[c] != 0) { continue; }

            r = c / cols;
            cc = c % cols;
            dr = -1;
            while (dr <= 1)
            {
                dc = -1;
                while (dc <= 1)
                {
                    if (dr != 0 || dc != 0)
                    {
                        nr = r + dr;
                        nc = cc + dc;
                        if (nr >= 0 && nr < rows && nc >= 0 && nc < cols)
                        {
                            n = nr * cols + nc;
                            if (gReveal[n] == 0 && gFlag[n] == 0 && gMine[n] == 0)
                            {
                                gReveal[n] = 1;
                                got = got + 1;
                                gStack[sp] = n;
                                sp = sp + 1;
                            }
                        }
                    }
                    dc = dc + 1;
                }
                dr = dr + 1;
            }
        }
        return got;
    }

    // 翻开一格

    // Reveal one cell

    void RevealAt(int idx)
    {
        int got;

        if (state != ST_PLAY) { return; }
        if (idx < 0 || idx >= rows * cols) { return; }
        if (gReveal[idx] != 0 || gFlag[idx] != 0) { return; }

        if (started == 0)
        {
            // 第一下:**先布雷、再翻开**,这样"首点安全区"才成立

            // The first tap: **lay the mines, then reveal**, which is what makes the "first-click safe zone" hold

            PlaceMines(idx);
            started = 1;
            startMs = ui_time_get_ms();
            shownSec = 0;
        }

        if (gMine[idx] != 0)
        {
            boomIdx = idx;
            state = ST_BOOM;
            boomMs = ui_time_get_ms();
            pressCell = -1;
            ui_vibrate_simple(200, 0);
            ui_audio_beep(131, 300);
            return;
        }

        got = FloodFrom(idx);
        revealed = revealed + got;
        // 一次翻出好几个格才发声 —— `ui_audio_beep` 是单通道的,逐格配音只会响成一片噪音

        // Only a multi-cell open makes a sound — `ui_audio_beep` is single-channel and per-cell tones would just pile up into noise

        if (got >= 6) { ui_audio_beep(660, 60); }
        CheckWin();
    }

    // 插旗 / 取消插旗

    // Flag or unflag a cell

    void ToggleFlag(int idx)
    {
        if (state != ST_PLAY) { return; }
        if (idx < 0 || idx >= rows * cols) { return; }
        if (gReveal[idx] != 0) { return; }
        if (gFlag[idx] != 0) { gFlag[idx] = 0; flags = flags - 1; }
        else { gFlag[idx] = 1; flags = flags + 1; }
        ui_audio_beep(880, 45);
    }

    // 赢了吗:所有非雷格都翻开了

    // Is it won yet: every non-mine cell is revealed

    int CheckWin()
    {
        int i;
        int span;

        if (state != ST_PLAY) { return 0; }
        if (started == 0) { return 0; }
        span = rows * cols;
        if (revealed < span - mines) { return 0; }

        state = ST_WIN;
        pressCell = -1;
        // 赢了就把剩下的雷全插上旗 —— 盘面立刻读作"全清"

        // On a win, flag every remaining mine so the board instantly reads as fully cleared

        i = 0;
        while (i < span)
        {
            if (gMine[i] != 0 && gFlag[i] == 0) { gFlag[i] = 1; flags = flags + 1; }
            i = i + 1;
        }
        ui_audio_beep(1047, 220);
        ui_vibrate_simple(120, 0);
        return 1;
    }

    // ── 每轮推进:只做"按真实时间该发生的事",返回 1 表示画面变了 ────────

    // ── Advance one round: only what real elapsed time calls for; returns 1 when the picture changed ──

    int Update()
    {
        int changed;
        int s;

        changed = 0;

        // ⚠ 长按**必须趁手指还按着**判(等抬手才判,"长按后拖动"就没法做)

        // ⚠ The long press **must be judged while the finger is still down** (judging on release would rule out "hold then drag")

        // ⚠ 计时源是 `ui_time_get_ms()`(真实流逝毫秒),不是"收了几条定时器消息" —— 主循环忙的时候

        // ⚠ The time source is `ui_time_get_ms()` (real elapsed milliseconds), not "how many timer messages arrived" — when the main loop is busy

        //   消息会积压,数消息会让长按变得忽长忽短。

        //   messages back up, and counting them would make the long press randomly longer or shorter.

        if (pressCell >= 0 && longFired == 0)
        {
            if (ui_time_get_ms() - pressMs >= HOLD_MS)
            {
                longFired = 1;
                ToggleFlag(pressCell);
                changed = 1;
            }
        }

        // 秒表:只在"显示出来的那一秒"真的变了时才要求重绘

        // The stopwatch: it asks for a redraw only when the second **shown on screen** really changes

        if (state == ST_PLAY && started != 0)
        {
            s = (ui_time_get_ms() - startMs) / 1000;
            if (s != shownSec) { shownSec = s; changed = 1; }
        }

        // 踩雷之后先亮足 1 秒,再转结束态(让玩家看清是哪一颗)

        // After hitting a mine, show it for a full second before switching to the over state (so the player can see which one it was)

        if (state == ST_BOOM)
        {
            if (ui_time_get_ms() - boomMs >= 1000)
            {
                state = ST_OVER;
                changed = 1;
            }
        }

        return changed;
    }

    // ── 输入:触摸 ─────────────────────────────────────────────────────

    // ── Input: touch ──

    void OnDown(int x, int y)
    {
        int idx;

        if (FaceHit(x, y) != 0)
        {
            // 笑脸:**按下去不算数,抬起来还在它上面才算** —— 防手滑重开

            // The face: **the press alone does not count; the release must still be on it** — guards against stray restarts

            pressOnFace = 1;
            pressCell = -1;
            longFired = 1;
            return;
        }
        pressOnFace = 0;

        if (state == ST_OVER) { NewGame(); return; }
        if (state != ST_PLAY) { return; }

        idx = IndexAt(x, y);
        pressCell = idx;
        pressMs = ui_time_get_ms();
        longFired = 0;
        if (idx >= 0) { useCursor = 0; }
    }

    void OnMove(int x, int y)
    {
        int idx;
        if (pressCell < 0) { return; }
        idx = IndexAt(x, y);
        // 手指划出这一格 ⇒ 这次按压作废(长按也就不会误插到别的格子上)

        // The finger slid off this cell ⇒ the press is void (which also stops the long press from flagging some other cell)

        if (idx != pressCell) { pressCell = -1; }
    }

    void OnUp(int x, int y)
    {
        int idx;
        int cur;

        if (pressOnFace != 0)
        {
            pressOnFace = 0;
            pressCell = -1;
            if (FaceHit(x, y) != 0) { NewGame(); }
            return;
        }

        if (pressCell < 0) { return; }
        idx = pressCell;
        pressCell = -1;

        // 长按已经兑现过了(旗子翻过了)⇒ 抬手只收尾,不再翻开

        // The long press already paid out (the flag was toggled) ⇒ the release just cleans up and never reveals

        if (longFired != 0) { return; }
        if (state != ST_PLAY) { return; }

        // 抬手时手指已经挪到别的格子上了 ⇒ 这一下不算点它

        // The finger had already moved to another cell by the time it lifted ⇒ this tap does not count for the pressed cell

        cur = IndexAt(x, y);
        if (cur != idx) { return; }
        RevealAt(idx);
    }

    // ── 输入:键盘兜底 ─────────────────────────────────────────────────

    // ── Input: keyboard fallback ──

    void OnKey(int k)
    {
        int r;
        int c;
        int span;

        span = rows * cols;

        if (k == VML_KEY_ESCAPE) { quit = 1; return; }

        if (k == VML_KEY_LEFT || k == VML_KEY_RIGHT || k == VML_KEY_UP || k == VML_KEY_DOWN)
        {
            useCursor = 1;
            r = cursor / cols;
            c = cursor % cols;
            if (k == VML_KEY_LEFT && c > 0) { cursor = cursor - 1; }
            if (k == VML_KEY_RIGHT && c < cols - 1) { cursor = cursor + 1; }
            if (k == VML_KEY_UP && r > 0) { cursor = cursor - cols; }
            if (k == VML_KEY_DOWN && r < rows - 1) { cursor = cursor + cols; }
            if (cursor < 0) { cursor = 0; }
            if (cursor >= span) { cursor = span - 1; }
            return;
        }

        if (state != ST_PLAY) { return; }

        // 光标还没开过(纯触摸玩家)时,回车/空格先把它挪到棋盘中间,别拿 0 号格当默认

        // When the cursor has never been used (a touch-only player), Enter or Space first moves it to the middle of the board rather than defaulting to cell 0

        if (useCursor == 0)
        {
            cursor = (rows / 2) * cols + cols / 2;
            useCursor = 1;
            return;
        }

        if (k == VML_KEY_ENTER) { RevealAt(cursor); }
        if (k == VML_KEY_SPACE) { ToggleFlag(cursor); }
    }

    // 已翻开的格子:有相邻雷就画数字,否则画一块空格

    // A revealed cell: draw the digit when it has adjacent mines, otherwise a blank tile

    void DrawOpen(int idx, int x, int y)
    {
        int n;
        n = gAdj[idx];
        if (n >= 1 && n <= 8) { NumberTile(x, y, cell, n); return; }
        OpenTile(x, y, cell);
    }

    void DrawCell(int idx)
    {
        int x;
        int y;

        x = boardX + (idx % cols) * cell;
        y = boardY + (idx / cols) * cell;

        // ⚠ 亮雷这一支**两个态都要走**(踩雷中 + 结束态)。只写 `ST_BOOM` 的话,

        // ⚠ The reveal-all-mines branch **must cover both states** (the boom moment and the over state). With only `ST_BOOM`,

        //   1 秒后转进结束态时全盘会**变回没翻开的样子** —— 玩家刚看清哪儿是雷,雷又藏起来了。

        //   a second later the over state would flip the whole board **back to unrevealed** — the player has just seen where the mines are, and they hide again.

        if (state == ST_BOOM || state == ST_OVER)
        {
            // 踩雷那一刻起:**把所有雷亮出来**(踩中的那颗红底)—— 这是"输"的画面,别省

            // From the moment a mine is hit: **show every mine** (the one stepped on on a red background) — this is the losing picture, do not skimp on it

            if (gMine[idx] != 0)
            {
                // 踩中的那一颗用红底:一眼看得出是哪一格

                // The one stepped on gets a red background so it stands out at a glance

                if (idx == boomIdx) { ui_2d_rect(x, y, cell - 1, cell - 1, C_BOOM_BG, 1, 0, 0); }
                else { OpenTile(x, y, cell); }
                MineShape(x + cell / 2, y + cell / 2, cell * 30 / 100, C_MINE);
                return;
            }
            if (gReveal[idx] != 0) { DrawOpen(idx, x, y); return; }
            if (gFlag[idx] != 0) { HiddenTile(x, y, cell); FlagShape(x, y, cell, C_POLE, C_FLAG); return; }
            HiddenTile(x, y, cell);
            return;
        }

        if (gReveal[idx] != 0) { DrawOpen(idx, x, y); return; }
        if (gFlag[idx] != 0) { HiddenTile(x, y, cell); FlagShape(x, y, cell, C_POLE, C_FLAG); return; }
        HiddenTile(x, y, cell);
    }

    // 状态条:剩余雷数(左)+ 笑脸(中)+ 秒表(右)

    // The status bar: mines remaining (left), the face (centre) and the stopwatch (right)

    //

    // ⚠ 条上**一个写死的像素都没有**:左右边距、图标半径、字号全部由 statusH 推出来,

    // ⚠ **Not one hard-coded pixel on this bar**: the side margins, icon radii and font sizes are all derived from statusH,

    //   所以画布一变它跟着变,右侧那两样永远不会被推出画布。

    //   so they follow any change of canvas and the two right-hand items can never be pushed off it.

    void DrawBar()
    {
        int n;
        int cx;
        int cy;
        int r;
        int bx;            // 条左右各自的边距

        // The margin at each end of the bar

        int ic;            // 图标半径

        // Icon radius

        int fs;            // 数字字号

        // Digit font size

        int clockX;        // 表盘中心

        // Centre of the dial

        int digits;
        int t;

        ui_2d_rect(0, 0, sw, statusH, C_BAR, 1, 0, 0);
        ui_2d_line(0, statusH - 1, sw, statusH - 1, C_BAR_LINE, 2);

        cx = sw / 2;
        cy = statusH / 2;
        r = FaceR();
        bx = statusH * 26 / 100;
        ic = statusH * 18 / 100;
        if (ic < 3) { ic = 3; }
        fs = statusH * 40 / 100;
        if (fs < 9) { fs = 9; }

        // 剩余雷数 = 总雷数 − 已插旗数。**可以是负数**(旗插多了),所以用带符号的 numstr

        // Mines remaining = total mines minus flags placed. It **can be negative** (too many flags), which is why the signed numstr is used

        n = mines - flags;
        MineShape(bx + ic, cy, ic, C_ICON);
        ui_2d_text_v(bx + ic * 3, cy, numstr(n), C_TEXT, fs, VML_ANCHOR_LEFT, VML_VANCHOR_MIDDLE, VML_FONT_BOLD);

        // 秒表:小圆表盘 + 右对齐的数字。**先按位数留出数字的地儿,再把表盘排在它左边** ——

        // The stopwatch: a small round dial plus a right-aligned number. **Reserve room for the digits by their count first, then put the dial to their left** —

        // 固定偏移的话,秒数一跳到三位,表盘就和数字叠上了(原来那里写的是 `sw - 78`)。

        // with a fixed offset the dial overlaps the number the moment the seconds reach three digits (that used to be a hard-coded `sw - 78`).

        digits = 1;
        t = shownSec;
        while (t >= 10) { t = t / 10; digits = digits + 1; }
        ui_2d_text_v(sw - bx, cy, numstr(shownSec), C_TEXT, fs, VML_ANCHOR_RIGHT, VML_VANCHOR_MIDDLE, VML_FONT_BOLD);
        clockX = sw - bx - digits * fs * 6 / 10 - ic * 2;
        if (clockX < cx + r + ic * 2) { clockX = cx + r + ic * 2; }
        ui_2d_circle(clockX, cy, ic, C_ICON, 0, 2);
        ui_2d_line(clockX, cy, clockX, cy - ic * 6 / 10, C_ICON, 2);
        ui_2d_line(clockX, cy, clockX + ic / 2, cy + ic * 3 / 10, C_ICON, 2);

        DrawFace(cx, cy, r);
    }

    // 笑脸:四张表情 —— 平常 / 按着(惊讶)/ 死 / 赢

    // The face: four expressions — normal, pressed (surprised), dead and won

    void DrawFace(int cx, int cy, int r)
    {
        int ex;
        int ey;

        ex = r * 42 / 100;
        ey = r * 32 / 100;

        ui_2d_circle(cx, cy, r, C_FACE_BG, 1, 0);
        ui_2d_circle(cx, cy, r, C_FACE_EDGE, 0, 2);

        if (state == ST_BOOM || state == ST_OVER)
        {
            // 死掉的脸:两只叉眼 + 一张撇嘴

            // The dead face: two cross eyes and a frown

            ui_2d_line(cx - ex - 4, cy - ey - 4, cx - ex + 4, cy - ey + 4, C_FACE_INK, 3);
            ui_2d_line(cx - ex + 4, cy - ey - 4, cx - ex - 4, cy - ey + 4, C_FACE_INK, 3);
            ui_2d_line(cx + ex - 4, cy - ey - 4, cx + ex + 4, cy - ey + 4, C_FACE_INK, 3);
            ui_2d_line(cx + ex + 4, cy - ey - 4, cx + ex - 4, cy - ey + 4, C_FACE_INK, 3);
            MouthShape(cx, cy, r, 0, C_FACE_INK);
            return;
        }

        if (state == ST_WIN)
        {
            // 赢:一副墨镜 + 一张笑脸

            // Won: a pair of sunglasses plus a smile

            ui_2d_rect(cx - ex - 6, cy - ey - 3, ex * 2 + 12, 8, C_FACE_INK, 1, 0, 0);
            MouthShape(cx, cy, r, 1, C_FACE_INK);
            return;
        }

        if (pressCell >= 0 || pressOnFace != 0)
        {
            // 按着的时候换一张"惊讶"的脸 —— 玩家一眼就知道这一按收到了

            // While pressed, switch to a surprised face so the player can see at a glance that the press arrived

            ui_2d_circle(cx - ex, cy - ey, 3, C_FACE_INK, 1, 0);
            ui_2d_circle(cx + ex, cy - ey, 3, C_FACE_INK, 1, 0);
            ui_2d_circle(cx, cy + r * 38 / 100, 4, C_FACE_INK, 1, 0);
            return;
        }

        // 平常:两只眼睛 + 一张笑脸

        // Normal: two eyes and a smile

        ui_2d_circle(cx - ex, cy - ey, 3, C_FACE_INK, 1, 0);
        ui_2d_circle(cx + ex, cy - ey, 3, C_FACE_INK, 1, 0);
        MouthShape(cx, cy, r, 1, C_FACE_INK);
    }

    // 结束横幅(高度、字号同样由画布高推 —— 大屏上不至于小得看不见,小屏上不至于糊满整屏)

    // The closing banner (its height and font sizes come from the canvas height too — big enough to read on a large screen, not covering a small one)

    void DrawOverlay()
    {
        int big;
        int small;
        int bh;

        big = sh * 4 / 100;
        if (big < 16) { big = 16; }
        if (big > 48) { big = 48; }
        small = big * 45 / 100;
        if (small < 11) { small = 11; }
        bh = big * 3;

        if (state == ST_WIN)
        {
            ui_2d_rect(0, sh / 2 - bh / 2, sw, bh, C_OVL, 1, 0, 0);
            ui_2d_text_v(sw / 2, sh / 2 - big * 4 / 10, gLang == 0 ? "扫清了!" : "Cleared!", C_WIN_INK, big, VML_ANCHOR_CENTER, VML_VANCHOR_MIDDLE, VML_FONT_BOLD);
            ui_2d_text_v(sw / 2, sh / 2 + big * 6 / 10, gLang == 0 ? "点笑脸再来一局" : "Tap the face to play again", C_TEXT_DIM, small, VML_ANCHOR_CENTER, VML_VANCHOR_MIDDLE, 0);
            return;
        }
        if (state == ST_OVER)
        {
            ui_2d_rect(0, sh / 2 - bh / 2, sw, bh, C_OVL, 1, 0, 0);
            ui_2d_text_v(sw / 2, sh / 2 - big * 4 / 10, gLang == 0 ? "踩到雷了" : "Boom!", C_LOSE_INK, big, VML_ANCHOR_CENTER, VML_VANCHOR_MIDDLE, VML_FONT_BOLD);
            ui_2d_text_v(sw / 2, sh / 2 + big * 6 / 10, gLang == 0 ? "点屏幕再来一局" : "Tap the screen to play again", C_TEXT_DIM, small, VML_ANCHOR_CENTER, VML_VANCHOR_MIDDLE, 0);
        }
    }

    // 一帧:盘面 + 光标 + 状态条 + 横幅,末尾 `ui_2d_present()`

    // One frame: the board, the cursor, the status bar and the banner, ending with `ui_2d_present()`

    void Draw()
    {
        int i;
        int span;

        ui_2d_clear(C_BG);

        // 棋盘底:**一笔**,格子之间那一像素的缝就是它透出来的

        // The board backdrop: **one call**; the one-pixel gaps between cells are where it shows through

        ui_2d_rect(boardX, boardY, boardW, boardH, C_GRID, 1, 0, 0);

        span = rows * cols;
        i = 0;
        while (i < span)
        {
            DrawCell(i);
            i = i + 1;
        }

        if (useCursor != 0 && state == ST_PLAY)
        {
            ui_2d_rect(boardX + (cursor % cols) * cell, boardY + (cursor / cols) * cell, cell, cell, C_CURSOR, 0, 3, 0);
        }

        DrawBar();
        DrawOverlay();
        ui_2d_present();
    }
};

// ════════════════════════════════════════════════════════════════════

// 主循环

// Main loop

// ════════════════════════════════════════════════════════════════════


int main()
{
    Game g;
    int tid;
    int t;
    int done;
    int needDraw;

    gLang = ui_scr_get_language();

    // 声明 **ROTATABLE + NO_GAMEPAD**:

    // Declaring **ROTATABLE + NO_GAMEPAD**:

    //   · ROTATABLE ⇒ 视口一变(首次量到真实画布、折叠屏、分屏…)宿主就把**新的坐标空间**整个给过来,

    //   · ROTATABLE ⇒ on any viewport change (the real canvas being measured for the first time, a foldable, split screen…) the host hands the **whole new coordinate space** over,

    //     并同时更新 `SCR_W/H` ⇒ 程序排版用的数永远等于能画的数,下面那句 resize 处理也才有意义;

    //     and updates `SCR_W/H` with it ⇒ the number the layout uses always equals the number that can be drawn into, which is also what makes the resize branch below meaningful;

    //   · NO_GAMEPAD ⇒ 不要手柄区,画布吃满整页。

    //   · NO_GAMEPAD ⇒ no gamepad area, so the canvas uses the whole page.

    // 开窗:尺寸传 0 ⇒ 交给宿主给真实可用区(开窗**之前**问到的只是估算值)

    // Opening: pass size 0 ⇒ the host supplies the real available area (a **pre-open** read is only an estimate)

    ui_win_open_ex(gLang == 0 ? "扫雷" : "Minesweeper", 0, 0, VML_WIN_ROTATABLE, VML_WIN_NO_GAMEPAD);

    // ⚠ `Layout()` 在**开窗之后**调:传 0/0 = "还没量到,问宿主";它给回来的那个数**就是**窗口的坐标空间。

    // ⚠ `Layout()` runs **after the window opens**: 0/0 means "not measured yet, ask the host"; the number it returns **is** the window's coordinate space.

    //   开窗**之前**问到的只是估算值、与真实画布总有出入 —— 那正是"内容偏上"的根因。

    //   A **pre-open** read is only an estimate and always differs from the real canvas — exactly why the content sat high.

    g.Layout(0, 0);

    // 竖屏是**另一件事**:`ui_scr_lock_orientation` 管"**系统**让不让转",与"视口变了窗口跟不跟"

    // Portrait is a **separate matter**: `ui_scr_lock_orientation` decides whether the **system** may rotate, which is not the same as

    // 分成两个接口是刻意的 —— 声明 ROTATABLE 只表示"我会按新空间重排",不表示"我欢迎转屏"。

    // "does the window follow a viewport change". Keeping the two apart is deliberate: declaring ROTATABLE means "I re-layout when the space changes", not "I welcome rotation".

    // 手机上锁竖(棋盘竖着看才顺手);这一端不支持时(如 iOS)返回 0,程序照旧能跑。

    // On a phone this locks to portrait (the board reads better upright); where the host does not support it (iOS) it returns 0 and the program still runs.

    ui_scr_lock_orientation(VML_LOCK_PORTRAIT);

    ui_scr_set_keep_on(1);

    g.NewGame();

    tid = ui_timer_set(TICK_MS, 0);
    done = 0;
    needDraw = 1;

    while (done == 0 && ui_win_is_closed() == 0)
    {
        // 长按判定与秒表都按 `ui_time_get_ms()` 的真实流逝推进,与消息无关 ⇒ 放在每轮开头

        // The long press and the stopwatch both advance on `ui_time_get_ms()`'s real elapsed time rather than on messages ⇒ they run at the top of each round

        if (g.Update() != 0) { needDraw = 1; }
        if (g.quit != 0) { done = 1; }

        if (needDraw != 0)
        {
            g.Draw();
            needDraw = 0;
        }
        if (done != 0) { break; }

        t = ui_msg_fetch_wait(0);

    handle_msg:
        // ⚠ **只有非定时消息才请求重绘**,定时消息不碰 `needDraw`。

        // ⚠ **Only non-timer messages request a redraw**; a timer message never touches `needDraw`.

        //   定时消息的语义是"该看一眼了" —— 秒表与长按都在 `Update()` 里按 `ui_time_get_ms()`

        //   A timer message means "time to look" — the stopwatch and the long press were both derived from `ui_time_get_ms()`

        //   的真实流逝算过了,所以它一帧都不该额外画(20Hz 白画就是纯耗电)。

        //   inside `Update()`, so it should not draw a frame of its own (20Hz of that is pure battery drain).

        //   ⚠ 反过来的写法(默认 `needDraw = 1`、定时那一支再按成 0)有个**看不出来的坑**:

        //   ⚠ The other way round (default `needDraw = 1` and let the timer branch press it to 0) has an **invisible trap**:

        //   抽干队列时若紧跟一条定时消息,它会把**前一条非定时消息刚请求的重绘抹掉** ——

        //   while draining the queue, a timer message right behind it would **erase the redraw the previous non-timer message just asked for** —

        //   实测症状是"赢了/踩了雷却不刷新画面"(胜负音都响了,画面还是旧的)。

        //   the measured symptom is "the round ends but the screen does not refresh" (the win or lose tone plays and the picture stays stale).

        if (t != VML_MSG_TIMER) { needDraw = 1; }

        if (t == VML_MSG_WINDOWCLOSE) { done = 1; }

        // 定时消息在这里**什么都不做**(上面的 `if (t != VML_MSG_TIMER)` 已经放它过去了)——

        // A timer message **does nothing here** (the `if (t != VML_MSG_TIMER)` above already let it through) —

        // 秒表与长按都在 `Update()` 里按真实时间推进,这一支没有别的活。

        // the stopwatch and the long press both advance on real time inside `Update()`, and this branch has no other job.


        if (t == VML_MSG_KEYDOWN) { g.OnKey(ui_msg_get_a()); }

        // 触摸 / 鼠标走同一条:按下、拖动、抬起三个阶段给到各自的处理

        // Touch and mouse share one path: the down, move and up phases each go to their own handler

        if (t == VML_MSG_TOUCHDOWN || t == VML_MSG_MOUSEDOWN) { g.OnDown(ui_msg_get_a(), ui_msg_get_b()); }
        if (t == VML_MSG_TOUCHMOVE || t == VML_MSG_MOUSEMOVE) { g.OnMove(ui_msg_get_a(), ui_msg_get_b()); }
        if (t == VML_MSG_TOUCHUP || t == VML_MSG_MOUSEUP) { g.OnUp(ui_msg_get_a(), ui_msg_get_b()); }

        // 宿主把**新的坐标空间**给过来了(声明了 ROTATABLE 才会来):消息里的 A/B 就是新的宽高。

        // The host has handed over a **new coordinate space** (only if ROTATABLE was declared): A/B in the message are the new width and height.

        // ⚠ **就用消息里这两个数**,别在这儿再问一次 `ui_scr_get_w/h()` —— 宿主"交出空间"和"更新

        // ⚠ **Use those two numbers**, do not ask `ui_scr_get_w/h()` again here — the host "hands the space over" and "updates the

        //   SCR_W/H 记账"是前后两步,夹在中间问到的是**旧值**;旧值更大的时候,就又画到画布外面去了。

        //   SCR_W/H bookkeeping" in two separate steps, and a query in between sees the **old** value; when the old value is the larger one, that draws outside the canvas all over again.

        // (`Relayout` 里只有棋盘形状真变了才重开一局,尺寸抖一下不会把玩家的一局清掉。)

        // (`Relayout` starts a new round only when the board shape really changed, so a size jitter will not clear the player's round.)

        if (t == VML_MSG_WINDOWRESIZE) { g.Relayout(ui_msg_get_a(), ui_msg_get_b()); }

        // 把已经排队的消息一次抽干:`ui_msg_fetch_wait(0)` 一次只取一条,而一次点击常带好几条

        // Drain the queued messages in one go: `ui_msg_fetch_wait(0)` takes only one at a time while a single tap often brings several

        if (done == 0 && ui_msg_get_count() > 0)
        {
            t = ui_msg_fetch();
            goto handle_msg;
        }
    }

    if (tid != 0) { ui_timer_kill(tid); }

    // ⚠ 退出前静音:胜负音还在响的时候玩家就退出了,那一声会一直响到进程结束

    // ⚠ Silence before exiting: if the player quits while a win or lose tone is still ringing it keeps ringing until the process dies

    ui_sfx_panic();
    ui_scr_set_keep_on(0);
    return 0;
}