c/app/tetris.c · 52906 字节 · 下载原文
/* tetris.c —— 俄罗斯方块(C 版),跑在手机端 VML 上
* tetris.c — Tetris (C version), running on VML on the mobile side
*
* 用到的都是现成的宿主接口(`vml_ui.h` 那套:窗体 / 绘图 / 消息队列 / 定时器),
* It uses only existing host interfaces (the `vml_ui.h` set: window / drawing / message queue / timer),
* 外加 v0.96.172 新加的**手感接口**(`ui_audio_beep` / `ui_vibrate_simple` / `ui_scr_set_keep_on` / `ui_store_*`)。
* plus the **feel interfaces** added in v0.96.172 (`ui_audio_beep` / `ui_vibrate_simple` / `ui_scr_set_keep_on` / `ui_store_*`).
* 布局全部按实际可用绘图区(syscall #566/#567)算,所以手机、平板、模拟器上都能铺满。
* The whole layout is computed from the actual available drawing area (syscall #566/#567), so it fills the screen on phones, tablets and emulators alike.
*
* 编译运行(手机 App 的 vml 工具):
* Build and run (with the phone App's vml tool):
* vml run tetris.c
*
* 操作全部走**绘图窗口底部那排屏幕手柄**(方向键 + START/SELECT + X/Y/A/B),
* All controls go through **the row of on-screen gamepad buttons at the bottom of the drawing window** (arrows + START/SELECT + X/Y/A/B);
* 手柄按键就是 Win32 虚拟键值,接物理键盘也是同一套:
* the gamepad buttons are Win32 virtual key codes, so a physical keyboard uses the same set:
* ← → 左右移动(按住连发)
* ← → move left/right (hold to auto-repeat)
* ↓ 加速下落(按住连发)
* ↓ soft drop faster (hold to auto-repeat)
* ↑ / A / X 旋转
* ↑ / A / X rotate
* 空格 / B / Y 直落到底
* Space / B / Y hard drop to the bottom
* START 重开一局
* START restart a game
* SELECT 暂停 / 继续
* SELECT pause / resume
* 返回箭头 退出
* Back arrow quit
*
* ## 为什么不自己画手柄、也不判触摸(v0.96.173 去掉的)
* ## Why it neither draws its own gamepad nor handles touch (removed in v0.96.173)
*
* 上一版在窗口里自绘了一套十字键 + 旋转/直落/暂停/重开,靠触摸命中去判按键。
* The previous version drew its own D-pad + rotate/hard-drop/pause/restart inside the window and decided key presses by touch hit-testing.
* 那是**多此一举**:绘图窗口底部本来就有一排屏幕手柄(`DrawWindowPage`),
* That was **pointless**: the drawing window already has a row of on-screen gamepad buttons at the bottom (`DrawWindowPage`),
* 程序只要收 `VML_MSG_KEYDOWN` 就行。自绘那套的代价是实打实的 ——
* so the program only has to receive `VML_MSG_KEYDOWN`. The cost of the self-drawn version was real —
* 1. 要占掉约 140px 的窗口高度(棋盘就矮一截,手机上少两行);
* 1. it took up about 140px of window height (the board got shorter, two rows fewer on a phone);
* 2. 几何要在"画"与"命中判定"两处各算一遍(本文件上一版专门写了段注释讲这件事),
* 2. the geometry had to be computed twice, once for "drawing" and once for "hit-testing" (the previous version of this file had a whole comment about that),
* 改个间距就会出现"看着在键上、点下去没反应";
* so changing a spacing produced "it looks like it's on the key, but tapping does nothing";
* 3. 每个游戏各画一套,风格互不相同,用户还得重新学一遍;
* 3. every game drew its own set with a different look, and the user had to learn it all over again;
* 4. 触摸与按键两条输入路径并存,程序里两套状态(`held` 与键盘)容易不一致。
* 4. having both touch and key input paths meant two sets of state in the program (`held` and the keyboard) that easily disagreed.
* 现在**只认按键消息**,触摸消息一概不处理 —— 窗口就是一块显示区。
* Now it **honors key messages only** and ignores touch messages entirely — the window is just a display area.
*
* ## 三条 C 前端的硬约束(踩过才写的)
* ## Three hard constraints of the C frontend (written only after tripping over them)
*
* 1. ⚠ **`${}` 占位符只认局部变量**(`GenerateAsmStatement` 只查 `variables` 表)。
* 1. ⚠ **A `${}` placeholder only resolves local variables** (`GenerateAsmStatement` consults only the `variables` table).
* (**2026-10-04 复验仍成立**:`--vml` 打出来的汇编里,`${局部量}` 有装载指令、`${全局量}` 一条都没有。)
* (**still holds, re-checked 2026-10-04**: in the `--vml` disassembly, `${local}` gets a load instruction and `${global}` gets none.)
* 所以调 syscall 一律走 `vml_ui.h` 的包装函数 —— 它们的形参就是普通 C 形参,
* So always call syscalls through the wrapper functions in `vml_ui.h` — their parameters are ordinary C parameters and
* 安全;而**绝不能**自己写 `asm("SYSCALL #525, ${gx}, ...")` 去传全局变量,那会退化成 R12+0。
* are safe; **never** write `asm("SYSCALL #525, ${gx}, ...")` yourself to pass a global, since that degrades to R12+0.
* 2. **文件级全局变量是好的**(实测:`int b[BW * BH]` 这种全局数组读写都正确),
* 2. **File-level globals are fine** (measured: a global array like `int b[BW * BH]` reads and writes correctly),
* 所以游戏状态就放全局,不必像五子棋那样把棋盘当参数到处传 —— 那个写法是被
* so the game state just lives in globals; there is no need to pass the board around as a parameter the way gomoku does — that style came from
* "全局变量不能进 ${}"这条误推出来的,实际上只影响 asm,不影响普通读写。
* a wrong inference from "globals cannot go into ${}", which in fact only affects asm, not ordinary reads and writes.
* 但为了统一,本文件仍全部通过包装函数调 syscall。
* For consistency, though, this file still calls every syscall through the wrappers.
* 3. ~~⚠ **`#define` 不支持折行续行**(反斜杠续行会让词法器在下一行报"未知字符")~~,
* 3. ~~⚠ **`#define` does not support line continuation** (a backslash continuation makes the lexer report "unknown character" on the next line)~~,
* 所以宏本身一行写完,多行说明另起一段块注释。
* so a macro must be written on one line, and multi-line explanations get their own block comment.
* ✅ **已修好(2026-10-04 复验)**:续行宏 `ADD(2,3)` = **5**、多行宏 = **42**、字符串续行 = **hello**。
* ✅ **fixed (re-checked 2026-10-04)**: continued macro `ADD(2,3)` = **5**, multi-line macro = **42**, continued string literal = **hello**.
*
* ## 存档为什么用「全局缓冲 + atoi」
* ## Why the high score uses a "global buffer + atoi"
*
* `ui_store_get(key, buf, cap)` 把字符串写进调用方给的缓冲区。这里把它读回来用的是
* `ui_store_get(key, buf, cap)` writes a string into the buffer the caller provides. Reading it back here uses
* `atoi`(最高分本来就是个数),缓冲区放在文件级全局 —— 这两条**不是硬约束,只是习惯**。
* `atoi` (the high score is a number anyway), and the buffer sits in a file-level global — neither is **a hard constraint, just a habit**.
*
* v0.96.173 时这里曾写着"缓冲区必须放全局、不能用下标读",那是**误判**:真因是 C 前端
* In v0.96.173 this spot used to say "the buffer must be global and cannot be read by index"; that was **a misdiagnosis**: the real cause was that the C frontend
* 对**全局** `char` 数组的元素访问按 32 位读写(局部数组反而正常),症状是「长度对、内容不对」。
* accesses elements of a **global** `char` array with 32-bit reads and writes (local arrays were fine), giving "right length, wrong contents".
* 那条已在 v0.96.174 修掉(`patches/0004-c-global-array-elem-type.patch`),所以
* That was fixed in v0.96.174 (`patches/0004-c-global-array-elem-type.patch`), so
* `buf[0] == '1'` 现在也能直接读。留个记号在这里:**当时是用 `puts` 当判据才误判的**
* `buf[0] == '1'` now reads directly too. A note is left here on purpose: **the misdiagnosis came from using `puts` as the criterion**
* (本环境的 `puts` 在字面量多的程序里会串行),换成一个不经 stdio 的判据,六个格子一次就量清了。
* (in this environment `puts` serializes in programs with many literals); switching to a criterion that does not go through stdio cleared up all six cells in one pass.
*/
#include <vml_ui.h>
#include <stdlib.h>
#define BW 10 /* 棋盘宽(格) */
/* Board width (in cells). */
#define BH 20 /* 棋盘高(格) */
/* Board height (in cells). */
#define COL_BG 0xFF0E0E14
#define COL_BOARD 0xFF16161F
#define COL_FRAME 0xFF2A2A38
#define COL_GRID 0xFF21212C
#define COL_TEXT 0xFFEDEDF2
#define COL_DIM 0xFF8A8A99
#define COL_ACCENT 0xFF4ADE80
#define COL_WARN 0xFFF87171
#define COL_PANEL 0xFF1B1B26
#define COL_SHADE 0xCC000000
#define COL_GOLD 0xFFFACC15
/* 存档键(`ui_store_*` 会再加一层 `vml.` 前缀,与 App 自己的配置隔开) */
/* Save key (`ui_store_*` adds another `vml.` prefix, keeping it apart from the App's own config). */
#define KEY_HI "tetris.hi"
/* ── 游戏状态 ───────────────────────────────────────────── */
/* ── Game state ─────────────────────────────────────── */
int b[BW * BH]; /* 棋盘:0 = 空,否则 = 方块号 + 1。
* Board: 0 = empty, otherwise = piece number + 1.
* 维度写成**宏 × 宏**是故意的 —— 它原先会因为 C 前端不折常量表达式
* Writing the dimension as **macro × macro** is deliberate — it used to silently allocate
* 而静默只分配 1 个元素(编得过、跑起来数据全乱),v0.96.170 修掉了,
* only 1 element because the C frontend did not fold constant expressions (it built, but the data was all wrong at run time); v0.96.170 fixed it,
* 这行就是那个修复的现场回归(**2026-10-04 复验仍正常**:全局 `int b[BW*BH]` 读写正确)。
* and this line is that fix's on-the-spot regression (**re-checked 2026-10-04, still fine**: the global `int b[BW*BH]` reads and writes correctly). */
int pid; /* 当前方块 0..6 */
/* Current piece 0..6 */
int rot; /* 旋转 0..3 */
/* Rotation 0..3 */
int px; /* 4×4 包围盒左上角(棋盘格坐标) */
/* Top-left of the 4×4 bounding box (board-cell coordinates) */
int py;
int npid; /* 下一个方块 */
/* Next piece */
int score;
int best; /* 最高分(从存档里读出来,破纪录时写回去) */
/* High score (read from the save data, written back when a record is broken) */
int nlines;
int level;
int state; /* 0 = 运行 1 = 暂停 2 = 结束 */
/* 0 = running, 1 = paused, 2 = over */
int tid; /* 重力定时器 id(0 = 没有) */
/* Gravity timer id (0 = none) */
int rid; /* 长按连发定时器 id(0 = 没有) */
/* Hold-to-repeat timer id (0 = none) */
int held; /* 正在按住的方向键(1 左 2 右 3 下,0 = 没按) */
/* Direction key currently held (1 left, 2 right, 3 down; 0 = none) */
int rptLeft; /* 这次连发还剩几拍(见 hold_repeat 的"自限"说明) */
/* How many ticks are left in this repeat burst (see the "self-limiting" note in hold_repeat) */
int rptStuck; /* 连续几拍"按了但没动" */
/* How many ticks in a row were "pressed but nothing moved" */
int dropMs;
int acc; /* 重力墙钟欠账(毫秒)—— 按墙钟补步,用户 2026-10-04 口径 */
/* gravity wall-clock debt in ms -- steps are caught up from the wall clock (user's 2026-10-04 rule) */
int lastMs; /* 上次补步取的墙钟时刻(进主循环时初始化) */
/* wall-clock time of the last catch-up step (initialized when entering the main loop) */
/* ── 布局(main 里算一次,绘图全读它) ──────────────────── */
/* ── Layout (computed once in main) ───────── */
int g_lang; /* 界面语言:开局查一次(ui_scr_get_language 是 syscall,别每帧调) */
/* UI language: queried once at game start (ui_scr_get_language is a syscall, don't call it every frame) */
int sw;
int sh;
int cell; /* 格子边长 */
/* Cell side length */
int bx; /* 棋盘左上角 */
/* Board top-left corner */
int by;
int panelX; /* 右侧信息面板 */
/* Right-hand info panel */
int panelW;
/* 数字的中间缓冲(`draw_int` / `score_to_str` 用;全局 int 数组,实测可用) */
/* Scratch buffer for digits (used by `draw_int` / `score_to_str`; a global int array, measured to work) */
int digs[12];
int sdigs[12];
/* 存档缓冲:**必须是全局**(局部数组的地址传给函数是错的,见文件头) */
/* Save buffer: **must be global** (passing the address of a local array to a function is wrong; see the file header) */
char hibuf[16]; /* ui_store_get 读进来 */
/* read in by ui_store_get */
char hiout[16]; /* 自己逐位拼出去给 ui_store_set */
/* assembled digit by digit for ui_store_set */
/* ── 方块颜色 ───────────────────────────────────────────── */
/* ── Piece colors ───────────────────────────────────── */
int piece_color(int p) {
if (p == 0) return 0xFF22D3EE; /* I 青 */
/* I cyan */
if (p == 1) return 0xFFFACC15; /* O 黄 */
/* O yellow */
if (p == 2) return 0xFF4ADE80; /* S 绿 */
/* S green */
if (p == 3) return 0xFFF87171; /* Z 红 */
/* Z red */
if (p == 4) return 0xFFC084FC; /* T 紫 */
/* T purple */
if (p == 5) return 0xFFFB923C; /* L 橙 */
/* L orange */
return 0xFF60A5FA; /* J 蓝 */
/* J blue */
}
/* 同色高光(块面顶部那一条)。用查表而不是移位算 —— 免得依赖位运算的可用性。 */
/* A lighter shade of the same color (the strip at the top of a block face). A lookup table is used instead of shifting — so as not to rely on bit operations being available. */
int piece_light(int p) {
if (p == 0) return 0xFF7DE8F7;
if (p == 1) return 0xFFFDE68A;
if (p == 2) return 0xFF86EFAC;
if (p == 3) return 0xFFFCA5A5;
if (p == 4) return 0xFFD8B4FE;
if (p == 5) return 0xFFFDBA74;
return 0xFF93C5FD;
}
char* digit_str(int d) {
if (d == 0) return "0";
if (d == 1) return "1";
if (d == 2) return "2";
if (d == 3) return "3";
if (d == 4) return "4";
if (d == 5) return "5";
if (d == 6) return "6";
if (d == 7) return "7";
if (d == 8) return "8";
return "9";
}
/* ── 手感:音效与震动 ───────────────────────────────────── */
/* ── Feel: sound and vibration ──────────────────── */
/* ── 音效:**用 ui_audio_beep 单音**(v0.96.509 统一换回来) ────────────
* ── Sound: **use the single-tone ui_audio_beep** (switched back uniformly in v0.96.509) ────────────
*
* ⚠⚠ 这些音一度走共享库的音序器(`ui_sfx_add`),**真机上破音**,换回来了。
* ⚠⚠ These sounds once went through the shared library's sequencer (`ui_sfx_add`) and **broke up on real hardware**, so they were switched back.
* 破音的是**这里配的音**(机制本身没问题)—— 那一版同时踩了两条:
* What broke up was **the sounds configured here** (the mechanism itself is fine) — that version tripped over two things at once:
* · **多个音叠着响**:消行是 1–4 个声部的和弦,多声部混音一叠加,音量顶到削波;
* · **several notes sounding together**: a line clear was a 1–4 voice chord, and summing that many voices pushed the volume into clipping;
* · **长音拖尾**:结束音末了一个 12 拍(一拍 33ms ≈ 400ms)的低音。
* · **a long tail**: the game-over sound ended on a 12-tick (one tick = 33ms ≈ 400ms) low note.
* `ui_audio_beep` 是**单通道**的(后一个音掐掉前一个)⇒ 一个事件永远只有一个音在响,
* `ui_audio_beep` is **single-channel** (a later note cuts off the earlier one) ⇒ one event never has more than one note sounding,
* **结构上不可能削波**。代价是没有和弦、没有音色。
* so clipping is **structurally impossible**. The price is no chords and no timbre.
*
* ⚠ 频率取整块的**首音**(哪个事件什么音是设计,不是机制),时长取整块时长、
* ⚠ The frequency is the **first note** of the block (which event gets which note is design, not mechanism), the duration is the block's duration
* 封顶 320ms。**结局类取两端**:升级/胜利取整块**最高音**、结束/失败取**最低音**
* capped at 320ms. **Outcome sounds take the two extremes**: level up / win take the block's **highest note**, game over / loss take the **lowest**
* —— 五子棋/象棋两版也是这么配的(赢 1320 / 输 240)。
* — the gomoku and chess versions are configured the same way (win 1320 / lose 240).
* 低音不低于 C3(131Hz) —— `vml_ui.h` 写着「手机外放在 200Hz 以下衰减很快…
* Low notes do not go below C3 (131Hz) — `vml_ui.h` says "a phone's speaker rolls off fast below 200Hz …
* C2 出来是"噗"一声闷响,玩家听着像没响」。
* a C2 comes out as a muffled puff and players think nothing played." */
/* 消行:**行数越多、音越高越长** —— 单音表达"这波赚了多少"只剩这一个杠杆。
* Line clear: **the more rows, the higher and longer the tone** — with a single tone this is the only lever for "how much did that batch earn".
* 音高对应原来和弦的第几个音(do / mi / sol / do 高八度)。
* The pitch matches the old chord's note index (do / mi / sol / do an octave up). */
void sfx_clear(int n) {
if (n >= 4) ui_audio_beep(1047, 320);
else if (n == 3) ui_audio_beep(784, 264);
else if (n == 2) ui_audio_beep(659, 198);
else ui_audio_beep(523, 132);
ui_vibrate_simple(28, 0);
}
/* 旋转:轻、短、不抢戏 —— 这是个高频动作,响一点就烦。 */
/* Rotate: light, short, unobtrusive — this is a high-frequency action, and anything louder gets annoying. */
void sfx_rotate(void) { ui_audio_beep(1047, 33); }
/* 自然落地:一声闷响(不震)。 */
/* Natural landing: one dull thud (no vibration). */
void sfx_land(void) { ui_audio_beep(165, 66); }
/* 硬降:**更低更长** —— 与自然落地必须分得出(玩家是主动砸的还是没赶上)。
* Hard drop: **lower and longer** — it must be told apart from a natural landing (did the player slam it, or just not get there in time?).
* ⚠ 原来两条同为 note 48,靠波形与时长分;波形没了 ⇒ 改用音高(硬降更沉)。
* ⚠ Both used to be note 48 and were distinguished by waveform and duration; with the waveform gone ⇒ pitch does it (hard drop is heavier). */
void sfx_harddrop(void) { ui_audio_beep(131, 99); }
/* 暂停 / 继续:一低一高,一听就知道是"停"还是"走"。 */
/* Pause / resume: one low and one high, so one listen tells "stop" from "go". */
void sfx_pause(void) { ui_audio_beep(523, 66); }
void sfx_resume(void) { ui_audio_beep(784, 66); }
/* 重开:一声干脆的起手音。 */
/* Restart: one crisp opening note. */
void sfx_restart(void) { ui_audio_beep(659, 99); }
/* 升级:**盖过消行音**(更高更长)—— 升级更值得听见。
* Level up: **drowns out the line-clear sound** (higher and longer) — a level up is more worth hearing.
* ⚠ 取整块的**最高音**(原来琶音的顶 96),否则与"四行消"撞成同一个数。
* ⚠ Takes the block's **highest note** (the top 96 of the old arpeggio), otherwise it would collide with "four-row clear" on the same number. */
void sfx_levelup(void) { ui_audio_beep(2093, 320); }
/* 结束:**低而长** —— 与"升级"正好是两端。 */
/* Game over: **low and long** — exactly the opposite end from "level up". */
void sfx_over(void) {
ui_audio_beep(131, 320);
ui_vibrate_simple(220, 0);
}
/* ── 方块几何 ───────────────────────────────────────────── */
/* ── Piece geometry ─────────────────────────────────── */
/* 某个方块某次旋转的**包围盒左上角**,打包成 minx*16 + miny。
* The **top-left corner of the bounding box** for a given piece and rotation, packed as minx*16 + miny.
*
* 为什么需要它:`ui_piece_get_cell` 的旋转是绕 4×4 包围盒中心转的,I 这种长条转完
* Why it is needed: `ui_piece_get_cell` rotates around the center of the 4×4 bounding box, so for a long bar like I the whole shape
* 整个形状会"跳一格"。做法是"旋转后把包围盒左上角对回原处",看起来才稳。
* "jumps one cell" once it is rotated. The trick is to put the bounding box's top-left corner back where it was, which looks stable. */
int piece_min(int p, int r) {
int i;
int v;
int x;
int y;
int mx;
int my;
mx = 3;
my = 3;
i = 0;
while (i < 4) {
v = ui_piece_get_cell(p, r, i);
if (v >= 0) {
x = v / 16;
y = v % 16;
if (x < mx) mx = x;
if (y < my) my = y;
}
i = i + 1;
}
return mx * 16 + my;
}
/* 方块放在 (x0,y0) 时会不会撞墙 / 撞地 / 撞已有块。y<0 表示还在顶部之外(允许)。 */
/* Whether the piece placed at (x0,y0) would hit a wall / the floor / an existing block. y<0 means it is still above the top (allowed). */
int collide(int p, int r, int x0, int y0) {
int i;
int v;
int x;
int y;
i = 0;
while (i < 4) {
v = ui_piece_get_cell(p, r, i);
if (v >= 0) {
x = x0 + v / 16;
y = y0 + v % 16;
if (x < 0 || x >= BW) return 1;
if (y >= BH) return 1;
if (y >= 0 && b[y * BW + x] != 0) return 1;
}
i = i + 1;
}
return 0;
}
void set_speed(void) {
if (tid > 0) ui_timer_kill(tid);
dropMs = 620 - (level - 1) * 55;
if (dropMs < 110) dropMs = 110;
tid = ui_timer_set(dropMs, 0);
}
/* ── 存档:最高分 ───────────────────────────────────────── */
/* ── Save data: high score ────────────────────────── */
/* 把非负整数写成十进制字符串(**2026-10-04 复验仍成立**:C 前端的 `"a"+"b"` 字符串拼接实测得空串,逐位自己拼)。
* Writes a non-negative integer as a decimal string (string concatenation in the C frontend is unreliable, so it is assembled digit by digit).
* 顺序**必须从前到后**、最后补一个 0 —— 全局 char 数组的赋值是 32 位写,
* The order **must be front to back**, with a trailing 0 — assigning to a global char array is a 32-bit write,
* 正序写下来每个下标的低字节都是对的,末尾那个 0 顺手把后面几个字节一起清零。
* so writing in ascending order leaves the right low byte at every index, and that final 0 conveniently clears the bytes after it as well. */
void score_to_str(int v, char* dst) {
int n;
int t;
int i;
if (v < 0) v = 0;
n = 0;
t = v;
while (t > 0) {
sdigs[n] = t % 10;
t = t / 10;
n = n + 1;
}
if (n == 0) {
sdigs[0] = 0;
n = 1;
}
i = 0;
while (i < n) {
dst[i] = 48 + sdigs[n - 1 - i];
i = i + 1;
}
dst[n] = 0;
}
void load_best(void) {
best = 0;
if (ui_store_get(KEY_HI, hibuf, 16) >= 0) best = atoi(hibuf);
if (best < 0) best = 0;
}
void save_best(void) {
score_to_str(best, hiout);
ui_store_set(KEY_HI, hiout);
}
void submit_score(void) {
if (score > best) {
best = score;
save_best();
}
}
/* ── 游戏动作 ───────────────────────────────────────────── */
/* ── Game actions ───────────────────────────────────── */
void spawn(void) {
int m;
pid = npid;
rot = 0;
npid = ui_math_rand(7);
px = 3;
m = piece_min(pid, 0);
py = 0 - m % 16; /* 让形状最高的一行贴在第 0 行 */
/* Puts the shape's topmost row on row 0 */
if (collide(pid, rot, px, py) != 0) { /* 出生位就满了 = 结束 */
/* Spawning already collides = game over */
state = 2;
submit_score();
sfx_over();
}
}
/* 把当前方块写进棋盘、消行、算分。 */
/* Writes the current piece into the board, clears lines and scores. */
void lock_piece(void) {
int i;
int v;
int x;
int y;
int row;
int col;
int full;
int n;
int k;
int lv;
i = 0;
while (i < 4) {
v = ui_piece_get_cell(pid, rot, i);
if (v >= 0) {
x = px + v / 16;
y = py + v % 16;
if (y >= 0 && y < BH && x >= 0 && x < BW) b[y * BW + x] = pid + 1;
}
i = i + 1;
}
n = 0;
row = BH - 1;
while (row >= 0) {
full = 1;
col = 0;
while (col < BW) {
if (b[row * BW + col] == 0) full = 0;
col = col + 1;
}
if (full == 1) {
n = n + 1;
/* 本行以上整体下移一格;**row 不前进** —— 掉下来的那行还得再判一次 */
/* Shifts everything above this row down by one; **row does not advance** — the row that fell still has to be checked again */
k = row;
while (k > 0) {
col = 0;
while (col < BW) {
b[k * BW + col] = b[(k - 1) * BW + col];
col = col + 1;
}
k = k - 1;
}
col = 0;
while (col < BW) {
b[col] = 0;
col = col + 1;
}
} else {
row = row - 1;
}
}
if (n > 0) {
if (n == 1) score = score + 100 * level;
if (n == 2) score = score + 300 * level;
if (n == 3) score = score + 500 * level;
if (n >= 4) score = score + 800 * level;
nlines = nlines + n;
lv = nlines / 10 + 1;
if (lv > 12) lv = 12;
if (lv > level) {
level = lv;
sfx_levelup(); /* 升级盖过消行音:升级更值得听见 */
/* A level up drowns out the line-clear sound: a level up is more worth hearing */
ui_vibrate_simple(60, 0);
} else {
sfx_clear(n);
}
set_speed();
} else {
sfx_land(); /* 自然落地:一声闷响,不震 */
/* Natural landing: one dull thud, no vibration */
}
}
/* 下落一格;落不下去就锁定并出新方块。 */
/* Drops one cell; if it cannot drop, the piece is locked and a new one spawns. */
void step_down(void) {
if (collide(pid, rot, px, py + 1) == 0) {
py = py + 1;
} else {
lock_piece();
spawn();
}
}
/* 旋转(带踢墙:原位不行就往左右各试 1、2 格)。 */
/* Rotate (with wall kicks: if the original position fails, try 1 and then 2 cells to each side). */
void rotate_piece(void) {
int nr;
int om;
int nm;
int nx;
int ny;
nr = (rot + 1) % 4;
om = piece_min(pid, rot);
nm = piece_min(pid, nr);
nx = px + om / 16 - nm / 16;
ny = py + om % 16 - nm % 16;
sfx_rotate();
if (collide(pid, nr, nx, ny) == 0) {
rot = nr;
px = nx;
py = ny;
return;
}
if (collide(pid, nr, nx - 1, ny) == 0) {
rot = nr;
px = nx - 1;
py = ny;
return;
}
if (collide(pid, nr, nx + 1, ny) == 0) {
rot = nr;
px = nx + 1;
py = ny;
return;
}
if (collide(pid, nr, nx - 2, ny) == 0) {
rot = nr;
px = nx - 2;
py = ny;
return;
}
if (collide(pid, nr, nx + 2, ny) == 0) {
rot = nr;
px = nx + 2;
py = ny;
}
}
void hard_drop(void) {
int n;
n = 0;
while (collide(pid, rot, px, py + 1) == 0) {
py = py + 1;
n = n + 1;
}
score = score + n * 2;
sfx_harddrop(); /* 低频闷响 = "砸下去了" */
/* A low-frequency thud = "slammed it down" */
ui_vibrate_simple(22, 0);
lock_piece();
spawn();
}
void restart(void) {
int i;
i = 0;
while (i < BW * BH) {
b[i] = 0;
i = i + 1;
}
score = 0;
nlines = 0;
level = 1;
state = 0;
held = 0;
rptLeft = 0;
rptStuck = 0;
if (rid > 0) {
ui_timer_kill(rid);
rid = 0;
}
npid = ui_math_rand(7);
spawn();
set_speed();
sfx_restart();
}
/* ── 绘图 ───────────────────────────────────────────────── */
/* ── Drawing ──────────────────────────────────────────── */
/* 一个方块格:圆角实心 + 顶部一条高光。 */
/* One block cell: a filled rounded rectangle plus a highlight strip along the top. */
void draw_block(int x, int y, int p, int size) {
int pad;
int r;
int hi;
if (size < 6) {
ui_2d_rect(x, y, size, size, piece_color(p), 1, 0, 0);
return;
}
pad = size / 9;
if (pad < 1) pad = 1;
r = size / 5;
if (r < 2) r = 2;
hi = size / 6;
if (hi < 2) hi = 2;
ui_2d_rect(x + pad, y + pad, size - pad * 2, size - pad * 2, piece_color(p), 1, 0, r);
ui_2d_rect(x + pad + r, y + pad + hi, size - (pad + r) * 2, hi, piece_light(p), 1, 0, hi / 2);
}
/* 数字:**逐位画**,不拼字符串(**2026-10-04 复验仍成立**:`"AB" + "CD"` 实测得空串;而十个数字字面量就够了)。 */
/* Numbers: **drawn digit by digit**, with no string concatenation (that path is unreliable in this frontend, and ten digit literals are enough). */
void draw_int(int x, int y, int v, int size, int color) {
int n;
int i;
int t;
int half;
if (v < 0) v = 0;
n = 0;
t = v;
while (t > 0 || n == 0) {
digs[n] = t % 10;
t = t / 10;
n = n + 1;
}
half = size / 2;
if (half < 5) half = 5;
ui_text_set_font(size, VML_FONT_BOLD, color, VML_ANCHOR_LEFT);
i = n;
while (i > 0) {
i = i - 1;
ui_2d_text_cur(x + (n - 1 - i) * half, y, digit_str(digs[i]));
}
}
void draw_panel(void) {
int i;
int v;
int x;
int y;
int mini;
int pw;
int ty;
ui_2d_rect(panelX, by, panelW, BH * cell, COL_PANEL, 1, 0, 8);
x = panelX + panelW / 2;
ui_text_set_font(12, 0, COL_DIM, VML_ANCHOR_CENTER);
ui_2d_text_cur(x, by + 10, g_lang == 0 ? "下一个" : "Next");
mini = cell / 2;
if (mini < 7) mini = 7;
pw = mini * 4;
i = 0;
while (i < 4) {
v = ui_piece_get_cell(npid, 0, i);
if (v >= 0) {
y = v % 16;
draw_block(panelX + (panelW - pw) / 2 + (v / 16) * mini, by + 32 + y * mini, npid, mini);
}
i = i + 1;
}
ty = by + 32 + mini * 4 + 18;
ui_text_set_font(12, 0, COL_DIM, VML_ANCHOR_LEFT);
ui_2d_text_cur(panelX + 10, ty, g_lang == 0 ? "分数" : "Score");
draw_int(panelX + 10, ty + 16, score, 17, COL_TEXT);
ty = ty + 52;
ui_text_set_font(12, 0, COL_DIM, VML_ANCHOR_LEFT);
ui_2d_text_cur(panelX + 10, ty, g_lang == 0 ? "最高" : "Best");
draw_int(panelX + 10, ty + 16, best, 17, COL_GOLD);
ty = ty + 52;
ui_text_set_font(12, 0, COL_DIM, VML_ANCHOR_LEFT);
ui_2d_text_cur(panelX + 10, ty, g_lang == 0 ? "消行" : "Lines");
draw_int(panelX + 10, ty + 16, nlines, 17, COL_TEXT);
ty = ty + 52;
ui_text_set_font(12, 0, COL_DIM, VML_ANCHOR_LEFT);
ui_2d_text_cur(panelX + 10, ty, g_lang == 0 ? "等级" : "Level");
draw_int(panelX + 10, ty + 16, level, 17, COL_ACCENT);
}
/* 暂停 / 结束的遮罩 + 两行提示。提示文案要写**手柄上的键名** ——
* The pause / game-over overlay plus two lines of text. The wording must name **the keys on the gamepad** —
* 屏幕上已经没有自绘按键了,写"点「重开」"用户找不到那个东西。
* there are no self-drawn buttons on screen any more, so telling the user to "tap Restart" leaves them hunting for something that isn't there. */
void draw_overlay(void) {
int w;
int h;
int x;
int y;
char* s;
char* s2;
if (state == 0) return;
w = BW * cell - 16;
h = 84;
x = bx + 8;
y = by + (BH * cell - h) / 2;
ui_2d_rect(bx, by, BW * cell, BH * cell, COL_SHADE, 1, 0, 0);
ui_2d_rect(x, y, w, h, COL_PANEL, 1, 0, 10);
if (state == 2) {
s = g_lang == 0 ? "游戏结束" : "Game Over";
s2 = g_lang == 0 ? "按 START 再来一局" : "Press START to restart";
} else {
s = g_lang == 0 ? "已暂停" : "Paused";
s2 = g_lang == 0 ? "按 SELECT 继续" : "Press SELECT to resume";
}
ui_text_set_font(20, VML_FONT_BOLD, COL_WARN, VML_ANCHOR_CENTER);
ui_2d_text_cur(x + w / 2, y + 16, s);
ui_text_set_font(12, 0, COL_DIM, VML_ANCHOR_CENTER);
ui_2d_text_cur(x + w / 2, y + 52, s2);
}
void draw_all(void) {
int i;
int v;
int x;
int y;
ui_2d_clear(COL_BG);
/* 棋盘:外框 + 底 + 网格 */
/* Board: outer frame + background + grid */
ui_2d_rect(bx - 3, by - 3, BW * cell + 6, BH * cell + 6, COL_FRAME, 1, 0, 8);
ui_2d_rect(bx, by, BW * cell, BH * cell, COL_BOARD, 1, 0, 4);
i = 1;
while (i < BW) {
ui_2d_line(bx + i * cell, by + 1, bx + i * cell, by + BH * cell - 1, COL_GRID, 1);
i = i + 1;
}
i = 1;
while (i < BH) {
ui_2d_line(bx + 1, by + i * cell, bx + BW * cell - 1, by + i * cell, COL_GRID, 1);
i = i + 1;
}
/* 已落定的方块 */
/* Settled blocks */
i = 0;
while (i < BW * BH) {
v = b[i];
if (v != 0) {
x = i % BW;
y = i / BW;
draw_block(bx + x * cell, by + y * cell, v - 1, cell);
}
i = i + 1;
}
/* 当前方块(结束后不画) */
/* Current piece (not drawn once the game is over) */
if (state != 2) {
i = 0;
while (i < 4) {
v = ui_piece_get_cell(pid, rot, i);
if (v >= 0) {
x = px + v / 16;
y = py + v % 16;
if (y >= 0) draw_block(bx + x * cell, by + y * cell, pid, cell);
}
i = i + 1;
}
}
draw_panel();
draw_overlay();
ui_2d_present();
}
/* ── 动作派发 ───────────────────────────────────────────── */
/* ── Action dispatch ────────────────────────────────── */
/* 按一下某个键。返回 **画面是否需要重画** —— 每次重画都要把整幅场景光栅化一遍
* Presses one key. Returns **whether the frame needs repainting** — every repaint rasterizes the whole scene
* (手机上是几百毫秒),所以"撞墙没动""结束后乱按"这些情况必须直接跳过重画。
* (hundreds of milliseconds on a phone), so "hit a wall and did not move" and "random presses after game over" must skip the repaint outright. */
int press(int btn) {
if (btn == 7) {
restart();
return 1;
}
if (state == 2) return 0; /* 结束了:除「重开」外一律无反应 */
/* Game over: nothing responds except "restart" */
if (btn == 6) {
if (state == 0) {
state = 1;
if (tid > 0) ui_timer_kill(tid);
tid = 0;
sfx_pause();
} else {
state = 0;
set_speed();
sfx_resume();
}
return 1;
}
if (state != 0) return 0; /* 暂停中 */
/* paused */
if (btn == 1) {
if (collide(pid, rot, px - 1, py) != 0) return 0;
px = px - 1;
return 1;
}
if (btn == 2) {
if (collide(pid, rot, px + 1, py) != 0) return 0;
px = px + 1;
return 1;
}
if (btn == 3) {
step_down();
score = score + 1;
return 1;
}
if (btn == 4) {
rotate_piece();
return 1;
}
if (btn == 5) {
hard_drop();
return 1;
}
return 0;
}
/* 长按连发:左/右/下按住不放就每 130ms 再来一次。
* Hold-to-repeat: holding left/right/down repeats the action every 130ms.
*
* 靠 `VML_MSG_KEYUP` 收尾 —— 手柄按下发 KeyDown、抬手发 KeyUp(`DrawWindowPage`
* It is ended by `VML_MSG_KEYUP` — pressing the gamepad sends KeyDown and releasing sends KeyUp (`DrawWindowPage`
* 用 Button 的 Pressed/Released,不再是一按就 Down+Up 一起发)。但**不能把"一定会收到
* uses the Button's Pressed/Released, so a single press no longer fires Down+Up together). But **you must not assume that
* KeyUp"当成前提**:手指划出按键范围、系统吃掉 CANCEL、页面被切走,都可能让 KeyUp 永远不来,
* KeyUp will always arrive**: a finger sliding off the button, the system swallowing CANCEL, the page being switched away — any of these can keep KeyUp from ever coming,
* 而连发一旦跑起来就会一直跑(`ui_timer_set` 是重复定时器)。
* and once repeat starts running it keeps running (`ui_timer_set` is a repeating timer).
* 所以连发**自带三道刹车**,任何一道都不会让程序卡在"一直往左移"上:
* So auto-repeat **carries three brakes of its own**, and none of them lets the program get stuck "drifting left forever":
* 1. **换键即接管** —— 另一个方向键的 KeyDown 直接改写 `held`;
* 1. **a new key takes over** — a KeyDown from another arrow key rewrites `held` outright;
* 2. **按了没动两次就停** —— 已经贴墙了还按,说明再按也没意义;
* 2. **stop after two presses that moved nothing** — pressing while already against the wall means further presses are pointless;
* 3. **总拍数上限 40**(约 5 秒)—— 横穿整个棋盘只要 10 拍、竖到底最多 20 拍,
* 3. **a cap of 40 ticks in total** (about 5 seconds) — crossing the whole board takes only 10 ticks and dropping to the bottom 20,
* 40 拍远超任何真实操作,纯粹是丢 KeyUp 时的兜底。
* so 40 ticks far exceeds any real play and is purely a fallback for a lost KeyUp.
* 三道里前两道是"手感",第三道是"安全网";写游戏时**重复定时器都要有这么一道**。
* Of the three, the first two are "feel" and the third is a "safety net"; **every repeating timer in a game needs one of these**. */
void hold_repeat(int btn) {
if (held == btn) {
if (rid > 0) return; /* 同一键重复按下(系统重复键):不重建定时器 */
/* the same key pressed again (system key repeat): do not rebuild the timer */
rid = ui_timer_set(130, 1);
return;
}
held = btn;
rptLeft = 40;
rptStuck = 0;
if (rid > 0) ui_timer_kill(rid);
rid = ui_timer_set(130, 1);
}
void release(void) {
held = 0;
rptLeft = 0;
rptStuck = 0;
if (rid > 0) {
ui_timer_kill(rid);
rid = 0;
}
}
/* 连发的一拍。返回画面是否要重画。 */
/* One tick of auto-repeat. Returns whether the screen needs repainting. */
int repeat_tick(void) {
if (held == 0 || state != 0) return 0;
rptLeft = rptLeft - 1;
if (press(held) != 0) {
rptStuck = 0;
if (rptLeft <= 0) release();
return 1;
}
rptStuck = rptStuck + 1;
if (rptStuck >= 2 || rptLeft <= 0) release();
return 0;
}
/* 键码 → 按钮码;0 = 这个键不做事。
* Key code → button code; 0 = this key does nothing.
* 手柄那几个键在 `VmlKeys` 里刻意映射成了自然键盘等价键(A/B/X/Y 就是字母键、
* The gamepad keys are deliberately mapped in `VmlKeys` to natural keyboard equivalents (A/B/X/Y are the letter keys,
* START=回车、SELECT=Shift),所以同一份程序接物理键盘也能玩。
* START=Enter, SELECT=Shift), so the same program is playable with a physical keyboard. */
int key_to_btn(int k) {
if (k == VML_KEY_LEFT) return 1;
if (k == VML_KEY_RIGHT) return 2;
if (k == VML_KEY_DOWN) return 3;
if (k == VML_KEY_UP) return 4;
if (k == VML_KEY_PAD_A) return 4;
if (k == VML_KEY_PAD_X) return 4;
if (k == VML_KEY_SPACE) return 5;
if (k == VML_KEY_PAD_B) return 5;
if (k == VML_KEY_PAD_Y) return 5;
if (k == VML_KEY_SELECT) return 6;
if (k == VML_KEY_PAUSE) return 6;
if (k == VML_KEY_ENTER) return 7;
return 0;
}
/* ── 主循环 ─────────────────────────────────────────────── */
/* ── Main loop ───────────────────────────────────────── */
int main(void) {
int msg[4];
int t;
int k;
int btn;
int reserveP;
int availH;
int availW;
int cw;
int chh;
ui_piece_init(); /* 形状表一次初始化(几何都在共享库里,各语言共用一份) */
/* Initialize the shape table once (the geometry lives in the shared library and is common to every language) */
/* **开窗传 `0, 0`(= 尺寸交给宿主),开窗之后再问可用绘图区** —— 窗口宽高就是画布的坐标空间,
* **Open with `0, 0` (let the host size it), then ask for the available drawing area afterwards** — the window's width and height are the canvas's coordinate space,
* 而**开窗之前**问到的只是估算(宿主那时还不知道页面留了多少),手机上会与真实画布不一样。
* and what you get **before** the open is only an estimate (the host does not yet know what the page reserves), which differs from the real canvas on a phone.
* 排版用的数若不是真实画布(按屏幕 744 排版 / 画布只有 360 宽)就会把内容画到画布外,实测就是"右边被切掉"。
* Laying out for a number that is not the real canvas (laying out for a 744-wide screen while the canvas is 360 wide) draws content outside the canvas; measured, that is exactly "the right side gets cut off". */
g_lang = ui_scr_get_language();
ui_win_open(g_lang == 0 ? "俄罗斯方块" : "Tetris", 0, 0);
sw = ui_scr_get_w();
sh = ui_scr_get_h();
if (sw <= 0) sw = 360;
if (sh <= 0) sh = 620;
/* 玩游戏时别熄屏 —— 一手不动盯着棋盘想下一步,屏幕自己黑了最扫兴。
* Don't let the screen sleep while playing — holding still with one hand while you think about the next move, only to have the screen go black on its own, is the most annoying thing.
* 退出时会关掉(见文件末尾),所以不会一直亮着。
* It is turned off on exit (see the end of the file), so it does not stay lit forever. */
ui_scr_set_keep_on(1);
/* ── 布局(只算这一处)──
* ── Layout (computed in this one place only) ──
*
* 手柄交给系统之后,整个窗口高度都归棋盘用(旧版要留 140px 画手柄)。
* With the gamepad handed over to the system, the whole window height goes to the board (the old version had to reserve 140px to draw the gamepad).
* `reserveP` 是右侧信息面板的宽度,窄屏收一点。
* `reserveP` is the width of the right-hand info panel, trimmed a little on narrow screens. */
reserveP = 88;
if (sw < 360) reserveP = 76;
availW = sw - reserveP - 24;
availH = sh - 16;
cw = availW / BW;
chh = availH / BH;
cell = cw;
if (chh < cell) cell = chh;
if (cell < 8) cell = 8;
bx = 8 + (availW - BW * cell) / 2;
if (bx < 6) bx = 6;
by = 8 + (availH - BH * cell) / 2;
if (by < 6) by = 6;
panelX = sw - reserveP - 8;
panelW = reserveP;
if (panelX < bx + BW * cell + 6) panelX = bx + BW * cell + 6;
/* ── 开局 ── */
/* ── Game start ── */
tid = 0;
rid = 0;
held = 0;
score = 0;
nlines = 0;
level = 1;
state = 0;
load_best();
npid = ui_math_rand(7);
spawn();
set_speed();
/* ⚠ 中文这条整句写在同一行里:跨行拼接时第二段会落在没有 `g_lang == 0` 的行上
⚠ The Chinese message is kept whole on one line: if it were split across lines, the second part would land on a line without `g_lang == 0`
(语言审计脚本按行认),而相邻字面量拼接本来就只是 C 的语法糖。
(the language audit script works line by line), and adjacent-literal concatenation is only C syntactic sugar anyway. */
if (g_lang == 0) ui_dlg_msg("俄罗斯方块", "用屏幕下方的游戏按键操作:方向键移动与旋转、A 旋转、B 直落,START 重开、SELECT 暂停。返回箭头退出。", VML_DLG_INFO);
else ui_dlg_msg("Tetris", "Use the on-screen gamepad: arrows to move/rotate, A rotate, B hard drop, START restart, SELECT pause. Back arrow to quit.", VML_DLG_INFO);
draw_all();
/* 墙钟起点:进主循环时取(不在模块级/全局初始化里调 syscall) */
/* wall-clock origin: sampled when entering the main loop (no syscall at module/global-init level) */
acc = 0;
lastMs = ui_time_get_ms();
while (ui_win_is_closed() == 0) {
/* 这一帧该落几格:**按墙钟欠账**(各平台定时器分辨率不同 ⇒ 不能拿"收到几拍"当时间)。
* how many drops this frame: **driven by the wall-clock deficit** (timer resolution differs per platform => never treat "ticks received" as time).
* 用户 2026-10-04 口径:所有游戏时钟取系统毫秒数。
* user's 2026-10-04 rule: all game clocks read system milliseconds. */
{
int now2 = ui_time_get_ms();
acc = acc + (now2 - lastMs);
lastMs = now2;
if (acc > dropMs * 8) acc = 0; /* 卡太久别一次补一大串(防瞬移) */
/* do not catch up in one huge burst after a long stall (prevents teleporting) */
if (state != 0) acc = 0; /* 暂停/结束不累积 */
/* no accumulation while paused/finished */
if (acc >= dropMs) {
while (acc >= dropMs) { acc = acc - dropMs; step_down(); }
draw_all();
}
}
t = ui_msg_wait(msg, 0);
if (t == 0) continue;
if (t == VML_MSG_WINDOWCLOSE) break;
if (t == VML_MSG_TIMER) {
if (msg[2] == 1) { /* tag 1 = 长按连发 */
/* tag 1 = hold-to-repeat */
if (repeat_tick() != 0) draw_all();
} else if (state != 0 && tid > 0) {
/* tag 0 = 重力定时器:**只负责唤醒**,真正的落格在上面按墙钟欠账走。
* tag 0 = gravity timer: **it only wakes the loop**; the real drop is caught up above from the wall clock.
* 结束/暂停时把重力停掉 —— 否则定时器会一直往队列里投消息,
* Stop gravity on game over / pause — otherwise the timer keeps posting messages into the queue,
* 主循环空转不说,暂停期间还白耗电。重开时 set_speed 会重建。
* which not only spins the main loop for nothing but also burns power while paused. set_speed rebuilds it on restart. */
ui_timer_kill(tid);
tid = 0;
}
continue;
}
if (t == VML_MSG_KEYUP) {
k = msg[1];
/* 只有"抬起的是当前按住的键"才结束连发;否则连发的节奏会被
* Only a KeyUp for the key currently held ends auto-repeat; otherwise the repeat rhythm would be
* 另一个方向键的抬起打断(屏幕上同时按两个键是很常见的)。
* interrupted by the release of the other arrow key (pressing two keys at once on screen is very common). */
if (held != 0 && key_to_btn(k) == held) release();
continue;
}
if (t == VML_MSG_KEYDOWN) {
k = msg[1];
if (k == VML_KEY_ESCAPE) break;
btn = key_to_btn(k);
if (btn == 0) continue;
if (press(btn) != 0) draw_all();
/* 连发只挂"能连续做的动作",重开/暂停那种一次性键不挂 */
/* Auto-repeat is attached only to "actions that can be done continuously", not to one-shot keys like restart/pause */
if (btn == 1 || btn == 2 || btn == 3) hold_repeat(btn);
}
}
release();
if (tid > 0) ui_timer_kill(tid);
ui_scr_set_keep_on(0);
ui_win_close();
return 0;
}