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;
}