c/app/pacman.c · 42721 字节 · 下载原文
/* pacman.c —— 《吃豆人》C 版,跑在手机端 VML 上
* pacman.c — "Pac-Man" in C, running on the mobile VML.
*
* 编译运行:vml run examples/c/pacman.c
* Build and run: vml run examples/c/pacman.c
*
* 操作:**屏幕手柄的方向键**(← ↓ → ↑)改变方向,走到岔口自动转向。吃到所有豆子过关。
* Controls: the **arrow keys of the on-screen gamepad** (← ↓ → ↑) set the direction; at a fork it turns by itself. Eat every pellet to clear the level.
*
* ## 为什么是吃豆人(而不是继续做射击游戏)
* ## Why Pac-Man (and not another shooter)
*
* 屏幕手柄在宿主侧**只有一个按键槽**(`DrawWindowPage._padDownKey`:按新键会先替旧键
* On the host side the on-screen gamepad has **only one key slot** (`DrawWindowPage._padDownKey`: pressing a new key first sends
* 补一条 KeyUp)⇒ **两个键同时按做不到**。这砍掉了一整类玩法:
* a compensating KeyUp for the old one) ⇒ **holding two keys at once is impossible**. That kills a whole class of gameplay:
* 平台跳跃要"跑动中起跳"(mario.c 栽在这上面)、射击要"边移动边开火"。
* a platformer needs "jump while running" (mario.c fell over on exactly this), a shooter needs "fire while moving".
* 而**吃豆人天生只按一个方向键** —— 手柄的限制在这里正好不是限制。
* Whereas **Pac-Man only ever presses one direction at a time** — here the gamepad's limitation is precisely not a limitation.
*
* ## 为什么这个画面"稳"(对比 starfall.c 的教训)
* ## Why this screen is "stable" (compared with the lessons of starfall.c)
*
* 上一版《星陨》栽在两条上:① **漏了 `ui_2d_clear`** ⇒ 每帧叠上一帧,糊成一团还闪退;
* The previous version, "Starfall", fell over on two things: ① **it forgot `ui_2d_clear`** ⇒ each frame was stacked on the previous one, smearing into a blob and then crashing;
* ② 渐变几何按**像素**传(那个接口要的是千分之一归一化)⇒ 焦点塌成一角,其余全黑。
* ② the gradient geometry was passed in **pixels** (that interface wants thousandths, normalized) ⇒ the focus collapsed into one corner and everything else went black.
* 这一版**刻意不用渐变、不用旋转多边形**:全是平面高对比色块 + 圆 + 圆角矩形,
* This version **deliberately uses no gradients and no rotated polygons**: everything is flat high-contrast colour blocks + circles + rounded rectangles,
* 几何只有"格 × 格边长"一种算法。**能出错的地方少了,能看清的地方就多了。**
* and the geometry has only one algorithm, "tile × tile side length". **Fewer places to go wrong means more places you can see clearly.**
*
* ## 关卡为什么是"梯子形"
* ## Why the levels are "ladder-shaped"
*
* 手绘复杂迷宫极容易画出**封死的口袋**(豆子永远吃不到,游戏无法通关),
* A hand-drawn complex maze very easily ends up with **sealed-off pockets** (pellets you can never reach, so the game can never be finished),
* 而只看矩阵是看不出来的。这里用**构造式**布局,连通性由构造保证:
* and you cannot see that just by looking at the matrix. This uses a **constructive** layout, where connectivity is guaranteed by construction:
* · 第 1/4/7/10/13/16/19 行是**整条横廊**(列 1..17 全通)
* · rows 1/4/7/10/13/16/19 are **full-width corridors** (columns 1..17 all open)
* · 相邻横廊之间在**固定的 6 个列**(1/4/7/11/14/17)有竖井
* · adjacent corridors are joined by vertical shafts at **6 fixed columns** (1/4/7/11/14/17)
* ⇒ 任意两条横廊都连通,任意豆子都吃得到。自测里有一条实际的**泛洪验证**钉着这件事。
* ⇒ any two corridors are connected and every pellet can be reached. A real **flood-fill verification** in the self-test pins this down.
*
* ## 三条 C 前端硬约束(同 tetris.c / mario.c / starfall.c)
* ## Three hard constraints of the C frontend (same as tetris.c / mario.c / starfall.c)
*
* 1. ⚠ `${}` 只认局部变量(**2026-10-04 复验仍成立**:全局量一个装载指令都不生成、参数静默丢)
* 1. ⚠ `${}` only recognises local variables (**still holds, re-checked 2026-10-04**: a global emits no load at all and the argument is silently dropped)
* 2. 全局变量与全局 int 数组读写都正常。
* 2. Reading and writing global variables and global int arrays both work fine.
* 3. ~~⚠ `#define` 不支持反斜杠续行~~ ✅ **已修好(2026-10-04 复验)**:续行宏 = **5**、多行宏 = **42**、字符串续行 = **hello**;
* 3. ~~⚠ `#define` does not support backslash line continuation~~ ✅ **fixed (re-checked 2026-10-04)**: continued macro = **5**, multi-line macro = **42**, continued string = **hello**;
* ⚠ 顶点数组必须是**具名全局数组**(复合字面量**仍不支持** —— 2026-10-04 复验:现在直接编译报错,不再静默)。
* ⚠ a vertex array must be a **named global array** (compound literals are **still unsupported** — re-checked 2026-10-04: now a compile error, no longer silent).
*/
#include <vml_ui.h>
#include <stdlib.h>
/* ── 迷宫尺寸与图块 ─────────────────────────────────────── */
/* ── Maze dimensions and tiles ─────────────────────────────────────── */
#define MW 19 /* 列 */
// columns
#define MH 21 /* 行 */
// rows
#define T_EMPTY 0
#define T_WALL 1
#define T_DOT 2
#define T_POW 3
/* 7 条全通横廊所在的行 */
/* The rows where the 7 full-width corridors are */
/* 1 4 7 10 13 16 19 */
int MAZE[MW * MH] = {
/* 0 */ 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
/* 1 */ 3,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,3,
/* 2 */ 1,2,1,1,2,1,1,2,1,1,1,2,1,1,2,1,1,2,1,
/* 3 */ 1,2,1,1,2,1,1,2,1,1,1,2,1,1,2,1,1,2,1,
/* 4 */ 1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
/* 5 */ 1,2,1,1,2,1,1,1,2,1,2,1,1,1,2,1,1,2,1,
/* 6 */ 1,2,1,1,2,1,1,1,2,1,2,1,1,1,2,1,1,2,1,
/* 7 */ 1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
/* 8 */ 1,2,1,1,2,1,1,1,2,1,2,1,1,1,2,1,1,2,1,
/* 9 */ 1,2,1,1,2,1,1,1,2,1,2,1,1,1,2,1,1,2,1,
/* 10 */ 1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
/* 11 */ 1,2,1,1,2,1,1,1,2,1,2,1,1,1,2,1,1,2,1,
/* 12 */ 1,2,1,1,2,1,1,1,2,1,2,1,1,1,2,1,1,2,1,
/* 13 */ 1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
/* 14 */ 1,2,1,1,2,1,1,1,2,1,2,1,1,1,2,1,1,2,1,
/* 15 */ 1,2,1,1,2,1,1,1,2,1,2,1,1,1,2,1,1,2,1,
/* 16 */ 1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
/* 17 */ 1,2,1,1,2,1,1,1,2,1,2,1,1,1,2,1,1,2,1,
/* 18 */ 1,2,1,1,2,1,1,1,2,1,2,1,1,1,2,1,1,2,1,
/* 19 */ 1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,
/* 20 */ 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
};
/* ── 配色:平面高对比,不用渐变 ─────────────────────────── */
/* ── Colors: flat and high-contrast, no gradients ─────────────────────────── */
#define C_BG 0xFF0A0A14
#define C_WALL 0xFF2B4BC8
#define C_WALL_IN 0xFF14205E
#define C_DOT 0xFFFFE9B0
#define C_POW 0xFFFFC24B
#define C_PAC 0xFFFFD93B
#define C_PAC_M 0xFF0A0A14
#define C_GHOST0 0xFFFF4D4D
#define C_GHOST1 0xFF4DE1FF
#define C_GHOST2 0xFFFFB8DE
#define C_GHOST3 0xFFFFA24D
#define C_FRIGHT 0xFF3A4BD8
#define C_EYE_W 0xFFFFFFFF
#define C_EYE_B 0xFF14204A
#define C_HUD 0xCC05050E
#define C_TEXT 0xFFE8EEFF
#define C_DIM 0xFF8FA3C8
#define C_WARN 0xFFFF6B6B
#define NG 4 /* 鬼数 */
// number of ghosts
#define TICK 40 /* 一拍 40ms */
// one tick is 40ms
/* ── 状态 ───────────────────────────────────────────────── */
/* ── State ───────────────────────────────────────────────── */
int g_lang; /* 界面语言:开局查一次(ui_scr_get_language 是 syscall,别每帧调) */
// UI language: queried once at startup (ui_scr_get_language is a syscall, don't call it every frame)
int W, H, TILE, OX, OY;
int frame, state; /* 0=玩 1=暂停 2=死 3=过关 */
// 0=playing 1=paused 2=died 3=level clear
int acc; /* 运动墙钟欠账(毫秒)—— 按墙钟补步,用户 2026-10-04 口径 */
/* Motion 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) */
int score, best, level, lives;
int dotsLeft;
int pxp, pyp; /* 吃豆人像素坐标(中心)*/
// Pac-Man's pixel coordinates (center)
int pdir, pwant; /* 当前朝向 / 期望朝向(0上 1右 2下 3左)*/
// current direction / wanted direction (0 up, 1 right, 2 down, 3 left)
int mouth; /* 嘴张合动画相位 */
// mouth open/close animation phase
int fright; /* 剩余惊吓拍数 */
// remaining frightened ticks
int pdead; /* 死亡动画计时 */
// death animation timer
int gx[NG], gy[NG], gdir[NG]; /* 鬼:像素坐标 / 朝向 */
// ghosts: pixel coordinates / direction
/* 各鬼的**出场拍数** —— 必须错开。
* Each ghost's **entry tick** — they must be staggered.
* 四只鬼的出生点挨在一起(都在迷宫中部那一行),不错开就是"开局一起扑过来":
* The four ghosts spawn right next to each other (all on the maze's middle row), so without staggering they "all pounce at once" the moment the game starts:
* 实测玩家在第 133 拍(约 5 秒)就掉一条命,连熟悉操作的时间都没有。
* measured, the player loses a life by tick 133 (about 5 seconds), with no time even to get used to the controls.
* 0 = 一开始就在场上。
* 0 = on the board from the very start.
*/
int grel[NG] = { 0, 70, 160, 260 };
/* 游走(scatter)阶段各自的角 —— 原作就是这个机制,追击与游走交替 */
/* Each ghost's own corner during the scatter phase — the original game works this way, chase and scatter alternating */
int gcorner_x[NG] = { 1, 17, 1, 17 };
int gcorner_y[NG] = { 1, 1, 19, 19 };
char nbuf[16];
/* 方向增量:上 右 下 左 */
/* Direction deltas: up, right, down, left */
int DX[4] = { 0, 1, 0, -1 };
int DY[4] = { -1, 0, 1, 0 };
/* ── 小工具 ─────────────────────────────────────────────── */
/* ── Small helpers ─────────────────────────────────────────────── */
char* num_str(int v) {
int i;
int n;
if (v < 0) { nbuf[0] = '-'; nbuf[1] = '0'; nbuf[2] = 0; return nbuf; }
i = 0;
if (v == 0) { nbuf[0] = '0'; nbuf[1] = 0; return nbuf; }
while (v > 0) { nbuf[i] = '0' + (v % 10); i = i + 1; v = v / 10; }
nbuf[i] = 0;
n = 0;
while (n < i / 2) { char t; t = nbuf[n]; nbuf[n] = nbuf[i - 1 - n]; nbuf[i - 1 - n] = t; n = n + 1; }
return nbuf;
}
int tile_at(int c, int r) {
if (c < 0 || c >= MW || r < 0 || r >= MH) return T_WALL;
return MAZE[r * MW + c];
}
int is_wall(int c, int r) { return tile_at(c, r) == T_WALL; }
/* 格 → 像素中心 */
/* Tile → pixel center */
int cx_of(int c) { return OX + c * TILE + TILE / 2; }
int cy_of(int r) { return OY + r * TILE + TILE / 2; }
/* 像素点落在哪一格 */
/* Which tile a pixel falls into */
int col_of(int x) { int v = (x - OX) / TILE; if (v < 0) v = 0; if (v >= MW) v = MW - 1; return v; }
int row_of(int y) { int v = (y - OY) / TILE; if (v < 0) v = 0; if (v >= MH) v = MH - 1; return v; }
int abs_i(int v) { if (v < 0) return -v; return v; }
int rnd(int n) { if (n <= 0) return 0; return ui_math_rand(n); }
/* ── 音效:**用 ui_audio_beep 单音**(v0.96.509 统一换回来) ────────────
* ── Sound effects: **single tones via 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 **distorted on real hardware**, so they were all switched back.
* 破音的是**这里配的音**:死亡/过关都是**几个音先后响**,末音还拖了 14 拍
* What distorted was **the sound configured here**: death / level-clear are **several tones sounding one after another**, and the last one dragged on for 14 ticks
* (一拍 33ms ≈ 460ms)—— 长音加外放就是"滋滋"。
* (one tick 33ms ≈ 460ms) — a long tone through a loudspeaker is exactly that "buzz".
* `ui_audio_beep` 是**单通道**的(后一个音掐掉前一个)⇒ 一个事件永远只有一个音在响,
* `ui_audio_beep` is **single-channel** (the next tone cuts off the previous one) ⇒ one event only ever has one tone sounding,
* **结构上不可能削波、也不会长音叠加**。代价是没有音色。
* so **clipping and long tones stacking up are structurally impossible**. The price is: no timbre.
*
* ⚠ 频率取整块的**首音**(吃豆最高最轻),时长取整块时长、封顶 320ms。
* ⚠ The frequency is the block's **first tone** (eating a pellet is the highest and the lightest), the duration is the block's duration, capped at 320ms.
* **胜负是例外**:赢取整块**最高音**、输取**最低音** —— 「不看屏幕也分得出输赢」
* **Win/lose is the exception**: winning takes the block's **highest tone**, losing the **lowest** — "you can tell win from loss without looking at the screen"
* 就靠这一条(五子棋/象棋两版也是这么配的:赢 1320 / 输 240)。
* rests on exactly this (the gomoku/chess versions are configured the same way: win 1320 / lose 240).
* 低音不低于 C3(131Hz) —— 手机外放在 200Hz 以下衰减很快,玩家听着像没响。
* The low tone never goes below C3(131Hz) — a phone loudspeaker rolls off fast below 200Hz, so the player hears it as if nothing played.
*/
void sfx_dot(void) { ui_audio_beep(2093, 33); } /* 吃豆:高频动作,必须又轻又短 */
// pellet: a high-frequency action, so it must be light and short
void sfx_pow(void) { ui_audio_beep(262, 165); } /* 大力丸:有分量的一声 */
// power pellet: one weighty tone
void sfx_eat(void) { ui_audio_beep(1047, 231); } /* 吃鬼:高而中长 = 爽 */
// eating a ghost: high and medium-long = satisfying
void sfx_die(void) { /* 死亡(输方):**最低音**、最长 */
// death (the losing side): the **lowest tone**, the longest
ui_audio_beep(131, 320);
ui_vibrate_simple(220, 0);
}
void sfx_win(void) { ui_audio_beep(1047, 320); } /* 过关(赢方):**最高音**、最长 */
// level clear (the winning side): the **highest tone**, the longest
/* ── 开局 ───────────────────────────────────────────────── */
/* ── Starting a round ───────────────────────────────────────────────── */
void load_level(void) {
int i;
dotsLeft = 0;
i = 0;
while (i < MW * MH) {
if (MAZE[i] == T_DOT || MAZE[i] == T_POW) dotsLeft = dotsLeft + 1;
i = i + 1;
}
}
void respawn(void) {
int i;
pxp = cx_of(9);
pyp = cy_of(19);
pdir = 3; /* 朝左,和原作一样 */
// facing left, same as the original
pwant = 3;
mouth = 0;
pdead = 0;
fright = 0;
i = 0;
while (i < NG) {
gx[i] = cx_of(8 + i);
gy[i] = cy_of(10);
gdir[i] = 0;
i = i + 1;
}
}
void new_game(void) {
int i;
score = 0;
level = 1;
lives = 3;
state = 0;
frame = 0;
load_level();
respawn();
}
/* ── 移动 ───────────────────────────────────────────────── */
/* ── Movement ───────────────────────────────────────────────── */
/* 能不能从格 (c,r) 朝 d 走一格 */
/* Whether one can step from tile (c,r) in direction d */
int can_go(int c, int r, int d) {
return is_wall(c + DX[d], r + DY[d]) == 0;
}
/* 是否**正落在格心**上(精确判定,不给容差)。
* Whether we are **exactly on a tile center** (an exact test, with no tolerance).
*
* ⚠⚠ 这条判据前后错了两次,两次都是**位置模型**的问题、不是笔误:
* ⚠⚠ This criterion was wrong twice, and both times it was a **position model** problem, not a typo:
*
* ① 第一版写的是"坐标是不是格边长(TILE)的整数倍" —— 那是**格线**,而吃豆人停的是
* ① The first version said "is the coordinate a multiple of the tile side (TILE)" — but that is a **tile boundary**, whereas Pac-Man stops on
* **格心**(`cx_of` = 原点 + c*TILE + TILE/2)⇒ 转向永远不触发、豆子永远吃不到。
* a **tile center** (`cx_of` = origin + c*TILE + TILE/2) ⇒ turning never triggered and pellets could never be eaten.
* ② 第二版改成"离格心 ≤3px",可移动还是**一次走 spd 像素**:`col_of()` 一越过格线就
* ② The second version changed it to "within ≤3px of the tile center", but movement still **travelled spd pixels at a time**: the moment `col_of()` crossed a boundary it
* 报下一列,"前面是墙"随即在**离格心 9px 的格线上**把人钉死。实测 400 拍
* reported the next column, and "there is a wall ahead" then pinned the player **on a tile boundary 9px from the center**. Measured over 400 ticks,
* `pdir` 恒为 1、`pxp` 恒为 316 —— 而 col17 的格心是 **325**;不吸附、不转向、
* `pdir` stayed at 1 and `pxp` stayed at 316 — while the tile center of col17 is **325**; no snapping, no turning,
* 人再也不动(用户看到的正是"按键有反应、人不动")。
* and the player never moved again (exactly the "the keys respond but the player does not move" that the user saw).
*
* 现在两处一起改:**逐像素推进 + 精确格心判定**(见 `move_pac`)。
* Now both are changed together: **pixel-by-pixel advance + exact tile-center test** (see `move_pac`).
* 「落在格心上」于是成了构造保证 —— 从格心出发、每次只走 1px,必然**精确经过**格心,
* "Being on a tile center" thus becomes a guarantee by construction — starting from a center and moving 1px at a time necessarily **passes exactly through** the centers,
* 与速度无关。旧写法在 `spd` 不整除 `TILE` 的关卡(4/5/7…)还会累积漂移。
* independently of speed. The old approach also accumulated drift on levels where `spd` does not divide `TILE` evenly (4/5/7…).
*/
int at_center(int v, int origin) {
int r = (v - origin) % TILE;
if (r < 0) r = r + TILE;
return r == TILE / 2;
}
/* 在格 (c,r) 吃豆。凑到格心才判 —— 与转向同一时刻,语义干净。*/
/* Eat the pellet at tile (c,r). Only judged once we reach the tile center — the same moment as turning, so the semantics stay clean. */
void eat_at(int c, int r) {
if (tile_at(c, r) == T_DOT) {
MAZE[r * MW + c] = T_EMPTY;
dotsLeft = dotsLeft - 1;
score = score + 10;
sfx_dot();
} else if (tile_at(c, r) == T_POW) {
MAZE[r * MW + c] = T_EMPTY;
dotsLeft = dotsLeft - 1;
score = score + 50;
fright = 140;
sfx_pow();
}
}
void move_pac(void) {
int spd;
int i;
int c;
int r;
spd = 3 + level / 3;
if (spd > 9) spd = 9;
/* 一拍走 spd 像素,但**一像素一判**:只有"此刻正落在格心"才允许转向、吃豆、停下。
* spd pixels are travelled per tick, but **each pixel is judged on its own**: turning, eating and stopping are only allowed "when we are right now on a tile center".
* 到格心顺手吸附一次(把任何偏差归零),保证位置永远精确落在格心网格上。
* Reaching a center also snaps once (zeroing any deviation), so the position always lands exactly on the tile-center grid.
*/
i = 0;
while (i < spd) {
c = col_of(pxp);
r = row_of(pyp);
if (at_center(pxp, OX) && at_center(pyp, OY)) {
pxp = cx_of(c);
pyp = cy_of(r);
eat_at(c, r);
if (pwant != pdir && can_go(c, r, pwant)) pdir = pwant;
if (!can_go(c, r, pdir)) return; /* 前面是墙:停在格心 */
// a wall ahead: stop on the tile center
}
pxp = pxp + DX[pdir];
pyp = pyp + DY[pdir];
i = i + 1;
}
}
/* 鬼:朝吃豆人方向选一条能走的路(不原地掉头,除非无路可走)*/
/* Ghosts: pick a walkable route towards Pac-Man (no turning back on the spot, unless there is no way through) */
void move_ghost(int i) {
int c;
int r;
int spd;
int k;
int d;
int best;
int bestv;
int v;
int tx;
int ty;
spd = 2 + level / 2;
if (spd > 4) spd = 4;
if (frame < grel[i]) return; /* 还没到出场时间 */
// not due to enter the board yet
/* 与吃豆人**同一套走法**:逐像素推进,只在格心做抉择。
* **The same walk as Pac-Man's**: advance pixel by pixel and only make choices at tile centers.
* (旧版"不在格心就一次挪 2px"同样会卡在格线上,而且 spd=4 不整除 18 时还会漂。)
* (The old "shift 2px at a time when not on a center" also got stuck on tile boundaries, and it drifted when spd=4 did not divide 18 evenly.)
*/
k = 0;
while (k < spd) {
if (at_center(gx[i], OX) && at_center(gy[i], OY)) {
c = col_of(gx[i]);
r = row_of(gy[i]);
gx[i] = cx_of(c);
gy[i] = cy_of(r);
/* 候选方向:能走的四个方向,排除掉头 */
/* Candidate directions: the four walkable directions, excluding turning back */
/* 目标格:追击段盯着吃豆人,游走段各自回自己的角(原作就有这个交替)。
* Target tile: during the chase phase they fix on Pac-Man, during the scatter phase each returns to its own corner (the original alternates in exactly this way).
* 没有游走段的话四只鬼会**无休止死追** —— 自测里那个贪心机器人
* Without a scatter phase the four ghosts **chase relentlessly without end** — the greedy bot in the self-test
* 4000 拍死了 42 次(约合 4 秒一条命),人玩只会更难受。
* died 42 times in 4000 ticks (about one life every 4 seconds), and a human player would only have a worse time of it.
*/
tx = col_of(pxp);
ty = row_of(pyp);
if ((frame / 300) % 2 == 1) { tx = gcorner_x[i]; ty = gcorner_y[i]; }
best = -1;
bestv = 1000000;
d = 0;
while (d < 4) {
if (can_go(c, r, d)) {
if (d != ((gdir[i] + 2) & 3) || best < 0) {
/* 惊吓时逃(远离),平时靠近目标 */
/* When frightened, flee (move away); otherwise, approach the target */
v = abs_i(c + DX[d] - tx) + abs_i(r + DY[d] - ty);
if (fright > 0) v = -v;
v = v + rnd(2); /* 一点随机,免得四只鬼叠在一起 */
// a touch of randomness so the four ghosts don't stack up on top of each other
if (v < bestv) { bestv = v; best = d; }
}
}
d = d + 1;
}
if (best < 0) best = (gdir[i] + 2) & 3; /* 死路:掉头 */
// dead end: turn back
gdir[i] = best;
}
gx[i] = gx[i] + DX[gdir[i]];
gy[i] = gy[i] + DY[gdir[i]];
k = k + 1;
}
}
void lose_life(void) {
lives = lives - 1;
state = 2;
sfx_die();
if (lives <= 0) {
/* 保持 state=2,由 START 重开 */
/* Keep state=2; START restarts the game */
}
}
/* ── 每拍 ───────────────────────────────────────────────── */
/* ── Per tick ───────────────────────────────────────────────── */
void tick(void) {
int i;
int hit;
if (state != 0) return;
frame = frame + 1;
mouth = (mouth + 1) % 8;
if (fright > 0) fright = fright - 1;
move_pac();
i = 0;
while (i < NG) {
move_ghost(i);
i = i + 1;
}
/* 与鬼碰撞 */
/* Collision with the ghosts */
i = 0;
while (i < NG) {
if (abs_i(gx[i] - pxp) < TILE * 3 / 4 && abs_i(gy[i] - pyp) < TILE * 3 / 4) {
if (fright > 0) {
/* 吃掉鬼:送回中间 */
/* Eating a ghost: send it back to the middle */
gx[i] = cx_of(9);
gy[i] = cy_of(10);
gdir[i] = 0;
score = score + 200;
sfx_eat();
} else {
lose_life();
return;
}
}
i = i + 1;
}
/* 过关 */
/* Level clear */
if (dotsLeft <= 0) {
level = level + 1;
state = 3;
sfx_win();
return;
}
}
/* ── 绘制 ───────────────────────────────────────────────── */
/* ── Drawing ───────────────────────────────────────────────── */
void draw_maze(void) {
int c;
int r;
int x;
int y;
int t;
int pw;
r = 0;
while (r < MH) {
c = 0;
while (c < MW) {
t = tile_at(c, r);
x = OX + c * TILE;
y = OY + r * TILE;
if (t == T_WALL) {
ui_2d_rect(x, y, TILE, TILE, C_WALL, 1, 0, 2);
/* 内层深色:让墙看着有厚度,而不是一片蓝 */
/* Inner darker layer: gives the wall some thickness instead of a flat slab of blue */
ui_2d_rect(x + 2, y + 2, TILE - 4, TILE - 4, C_WALL_IN, 1, 0, 1);
} else if (t == T_DOT) {
pw = TILE / 6;
if (pw < 2) pw = 2;
ui_2d_circle(x + TILE / 2, y + TILE / 2, pw, C_DOT, 1, 0);
} else if (t == T_POW) {
pw = TILE / 3 + (mouth < 4 ? 1 : 0); /* 轻微脉动 */
// a slight pulse
ui_2d_circle(x + TILE / 2, y + TILE / 2, pw, C_POW, 1, 0);
ui_2d_circle(x + TILE / 2, y + TILE / 2, pw / 2, 0xFFFFFFFF, 1, 0);
}
c = c + 1;
}
r = r + 1;
}
}
/* 嘴楔子:具名全局数组装顶点(复合字面量仍不支持,2026-10-04 复验会直接编译报错)*/
/* Mouth wedge: a named global array holds the vertices (compound literals are not supported) */
int wedge[8];
void ui_polygon_wedge(int cx, int cy, int r, int dir) {
if (dir == 0) { /* 朝右的楔 */
// a wedge pointing right
wedge[0] = cx; wedge[1] = cy;
wedge[2] = cx + r; wedge[3] = cy - r;
wedge[4] = cx + r; wedge[5] = cy + r;
} else if (dir == 1) { /* 朝左 */
// pointing left
wedge[0] = cx; wedge[1] = cy;
wedge[2] = cx - r; wedge[3] = cy - r;
wedge[4] = cx - r; wedge[5] = cy + r;
} else if (dir == 2) { /* 朝上 */
// pointing up
wedge[0] = cx; wedge[1] = cy;
wedge[2] = cx - r; wedge[3] = cy - r;
wedge[4] = cx + r; wedge[5] = cy - r;
} else { /* 朝下 */
// pointing down
wedge[0] = cx; wedge[1] = cy;
wedge[2] = cx - r; wedge[3] = cy + r;
wedge[4] = cx + r; wedge[5] = cy + r;
}
ui_2d_polygon(wedge, 3, C_BG, 0, 0, "");
}
void draw_pac(void) {
int r;
int a;
int open;
open = (mouth < 4) ? 1 : 0;
r = TILE * 2 / 5;
if (r < 4) r = 4;
ui_2d_circle(pxp, pyp, r, C_PAC, 1, 0);
/* 嘴:用背景色的三角"咬"掉一口。方向按 pdir 分四种写死 ——
* Mouth: a triangle in the background colour "bites" a piece out. The direction is hard-coded as four cases on pdir —
* 省掉三角函数,也省掉"旋转多边形"那类几何风险。
* which saves both trigonometry and that class of geometric risk, "rotating polygons".
*/
if (open != 0) {
if (pdir == 1) { /* 右 */
// right
ui_polygon_wedge(pxp, pyp, r + 2, 0);
} else if (pdir == 3) { /* 左 */
// left
ui_polygon_wedge(pxp, pyp, r + 2, 1);
} else if (pdir == 0) { /* 上 */
// upwards
ui_polygon_wedge(pxp, pyp, r + 2, 2);
} else { /* 下 */
// down
ui_polygon_wedge(pxp, pyp, r + 2, 3);
}
}
}
void draw_ghosts(void) {
int i;
int r;
int ex;
int ey;
int body;
i = 0;
while (i < NG) {
r = TILE * 2 / 5;
if (r < 4) r = 4;
body = C_FRIGHT;
if (fright <= 0) {
if (i == 0) body = C_GHOST0;
else if (i == 1) body = C_GHOST1;
else if (i == 2) body = C_GHOST2;
else body = C_GHOST3;
}
/* 身体:上半圆 + 下方方块(不用路径,纯圆+矩形拼)*/
/* Body: a top semicircle + a block below (no paths, just a circle and a rectangle put together) */
ui_2d_circle(gx[i], gy[i] - r / 3, r, body, 1, 0);
ui_2d_rect(gx[i] - r, gy[i] - r / 3, r * 2, r + r / 3, body, 1, 0, 0);
/* 裙摆:三个小方块 */
/* Skirt: three small blocks */
ui_2d_rect(gx[i] - r, gy[i] + r - 3, r * 2 / 3, 4, C_BG, 1, 0, 0);
ui_2d_rect(gx[i] + r / 3, gy[i] + r - 3, r * 2 / 3, 4, C_BG, 1, 0, 0);
/* 眼 */
/* Eyes */
ex = r / 2;
ey = r / 3;
ui_2d_circle(gx[i] - ex, gy[i] - ey, r / 3, C_EYE_W, 1, 0);
ui_2d_circle(gx[i] + ex, gy[i] - ey, r / 3, C_EYE_W, 1, 0);
ui_2d_circle(gx[i] - ex + DX[gdir[i]] * 2, gy[i] - ey + DY[gdir[i]] * 2, r / 6, C_EYE_B, 1, 0);
ui_2d_circle(gx[i] + ex + DX[gdir[i]] * 2, gy[i] - ey + DY[gdir[i]] * 2, r / 6, C_EYE_B, 1, 0);
i = i + 1;
}
}
void draw_hud(void) {
int i;
ui_2d_rect(0, 0, W, 30, C_HUD, 1, 0, 0);
ui_text_set_font(13, VML_FONT_BOLD, C_TEXT, VML_ANCHOR_LEFT);
ui_2d_text_cur(8, 7, num_str(score));
ui_text_set_font(10, 0, C_DIM, VML_ANCHOR_LEFT);
ui_2d_text_cur(8, 20, g_lang == 0 ? "分数" : "Score");
ui_text_set_font(11, 0, C_DIM, VML_ANCHOR_CENTER);
/* "Level"(≈28px)比"第"(11px)宽,但两边(关卡数字 / "剩·Left")都还留着空隙 */
/* "Level" (≈28px) is much wider than the one-character label (11px), but both sides (the level number / the "Left" label) still keep a gap */
ui_2d_text_cur(W / 2 - 26, 12, g_lang == 0 ? "第" : "Level");
ui_text_set_font(13, VML_FONT_BOLD, C_TEXT, VML_ANCHOR_CENTER);
ui_2d_text_cur(W / 2, 12, num_str(level));
ui_text_set_font(11, 0, C_DIM, VML_ANCHOR_CENTER);
ui_2d_text_cur(W / 2 + 34, 12, g_lang == 0 ? "剩" : "Left");
ui_text_set_font(13, VML_FONT_BOLD, C_POW, VML_ANCHOR_CENTER);
ui_2d_text_cur(W / 2 + 62, 12, num_str(dotsLeft));
/* 命:黄圆点 */
/* Lives: yellow dots */
i = 0;
while (i < lives && i < 5) {
ui_2d_circle(W - 18 - i * 18, 15, 6, C_PAC, 1, 0);
i = i + 1;
}
if (fright > 0) {
ui_text_set_font(12, VML_FONT_BOLD, C_GHOST1, VML_ANCHOR_CENTER);
ui_2d_text_cur(W / 2, 42, g_lang == 0 ? "鬼可吃!" : "Eat the ghosts!");
}
if (state == 1) {
ui_2d_rect(0, H / 2 - 36, W, 72, 0xCC000000, 1, 0, 0);
ui_text_set_font(22, VML_FONT_BOLD, C_TEXT, VML_ANCHOR_CENTER);
ui_2d_text_cur(W / 2, H / 2 - 24, g_lang == 0 ? "暂停" : "Paused");
ui_text_set_font(12, 0, C_DIM, VML_ANCHOR_CENTER);
ui_2d_text_cur(W / 2, H / 2 + 6, g_lang == 0 ? "SELECT 继续" : "SELECT to resume");
}
if (state == 2) {
ui_2d_rect(0, H / 2 - 56, W, 112, 0xCC000000, 1, 0, 0);
ui_text_set_font(22, VML_FONT_BOLD, C_WARN, VML_ANCHOR_CENTER);
ui_2d_text_cur(W / 2, H / 2 - 38, g_lang == 0 ? "被抓住了" : "Caught!");
ui_text_set_font(13, 0, C_TEXT, VML_ANCHOR_CENTER);
ui_2d_text_cur(W / 2, H / 2 - 4, g_lang == 0 ? "分数" : "Score");
ui_text_set_font(18, VML_FONT_BOLD, C_PAC, VML_ANCHOR_CENTER);
ui_2d_text_cur(W / 2, H / 2 + 20, num_str(score));
ui_text_set_font(12, 0, C_DIM, VML_ANCHOR_CENTER);
ui_2d_text_cur(W / 2, H / 2 + 46, g_lang == 0 ? "START 重新开始" : "START to restart");
}
if (state == 3) {
ui_2d_rect(0, H / 2 - 44, W, 88, 0xE6000000, 1, 0, 0);
ui_text_set_font(22, VML_FONT_BOLD, C_POW, VML_ANCHOR_CENTER);
ui_2d_text_cur(W / 2, H / 2 - 26, g_lang == 0 ? "过关!" : "Level clear!");
ui_text_set_font(13, 0, C_TEXT, VML_ANCHOR_CENTER);
ui_2d_text_cur(W / 2, H / 2 + 4, g_lang == 0 ? "START 进入下一关" : "START for next level");
}
}
void draw_all(void) {
/* ⚠ **必须先 clear**:所有绘制都是往图元表追加,只有它会清空。
* ⚠ **It must be cleared first**: every drawing call appends to the figure list, and only this one empties it.
* 漏了就是"每帧叠上一帧"—— 画面糊掉,且图元表无限增长直到闪退
* Miss it out and "each frame is stacked on the previous one" — the image smears, and the figure list grows without bound until the app crashes
* (starfall.c 第一版就栽在这上面,见那边文件头的记录)。
* (the first version of starfall.c fell over on exactly this, see the note at the top of that file).
*/
ui_2d_clear(C_BG);
draw_maze();
draw_pac();
draw_ghosts();
draw_hud();
ui_2d_present();
}
/* ── 输入 ───────────────────────────────────────────────── */
/* ── Input ───────────────────────────────────────────────── */
void on_key(int k) {
if (k == VML_KEY_SELECT || k == VML_KEY_PAUSE) {
if (state == 0) state = 1;
else if (state == 1) state = 0;
draw_all();
return;
}
if (k == VML_KEY_ENTER) {
if (state == 3) {
/* 下一关:重铺豆子,鬼回中,吃豆人回原位 */
/* Next level: lay the pellets out again, ghosts back to the middle, Pac-Man back to his start */
state = 0;
frame = 0; /* 重置出场计时,否则新关卡一开始四只鬼就全在场 */
// reset the entry timer, otherwise all four ghosts are on the board the moment a new level starts
respawn();
load_level();
draw_all();
return;
}
/* 死亡或暂停中按 START:整局重开 */
/* Pressing START while dead or paused: restart the whole game */
new_game();
draw_all();
return;
}
if (state != 0) return;
if (k == VML_KEY_UP) pwant = 0;
else if (k == VML_KEY_RIGHT) pwant = 1;
else if (k == VML_KEY_DOWN) pwant = 2;
else if (k == VML_KEY_LEFT) pwant = 3;
}
/* ── 主循环 ─────────────────────────────────────────────── */
/* ── Main loop ─────────────────────────────────────────────── */
int main(void) {
int msg[4];
int t;
int sw;
int sh;
int tw;
int th;
int mx;
int my;
g_lang = ui_scr_get_language();
ui_win_open(g_lang == 0 ? "吃豆人" : "Pac-Man", 0, 0);
sw = ui_scr_get_w();
sh = ui_scr_get_h();
if (sw <= 0) sw = 360;
if (sh <= 0) sh = 620;
ui_scr_set_keep_on(1);
W = sw;
H = sh;
/* 格边长:横竖都装得下 19×21,再居中 */
/* Tile side length: fit 19×21 both ways, then center */
tw = (W - 8) / MW;
th = (H - 40) / MH;
TILE = tw;
if (th < TILE) TILE = th;
if (TILE < 8) TILE = 8;
OX = (W - MW * TILE) / 2;
OY = 34 + ((H - 34) - MH * TILE) / 2;
if (OX < 0) OX = 0;
if (OY < 34) OY = 34;
new_game();
draw_all();
ui_timer_set(TICK, 0);
/* ⚠ 中文这条整句写在同一行里:跨行拼接时后续几段会落在没有 `g_lang == 0` 的行上
⚠ The Chinese sentence is kept whole on a single line: split across lines, the later fragments would land on lines without `g_lang == 0`
(语言审计脚本按行认),而相邻字面量拼接本来就只是 C 的语法糖。
(the language audit script decides by line), and concatenating adjacent literals is just C syntactic sugar anyway.
*/
if (g_lang == 0) ui_dlg_msg("吃豆人", "用屏幕下方的方向键控制:按哪个方向就走哪个方向,到岔口自动转。吃光所有豆子过关;吃到大豆子(金圈)后可以反过来吃鬼。START 重开、SELECT 暂停。", VML_DLG_INFO);
else ui_dlg_msg("Pac-Man", "Use the on-screen arrows: press a direction and it turns at the next opening. Clear all dots to advance; after a power pellet the ghosts are edible. START restart, SELECT pause.", VML_DLG_INFO);
/* 墙钟起点:进主循环时取(不在模块级/全局初始化里调 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 steps this frame takes: **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 > TICK * 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/dead/level-cleared */
if (acc >= TICK) {
while (acc >= TICK) { acc = acc - TICK; tick(); }
draw_all();
}
}
t = ui_msg_wait(msg, 0);
if (t == 0) continue;
if (t == VML_MSG_WINDOWCLOSE) break;
if (t == VML_MSG_TIMER) {
/* 定时器**只负责唤醒**(推进在上面按墙钟欠账走) */
/* the timer **only wakes us up** (advancement happens above, driven by the wall-clock deficit) */
continue;
}
/* 触摸也当方向键用:点屏幕上半/下半/左半/右半 —— 手柄之外的备用操作 */
/* Touch also acts as the direction keys: tap the top/bottom/left/right half of the screen — a fallback beyond the gamepad */
if (t == VML_MSG_TOUCHDOWN || t == VML_MSG_MOUSEDOWN) {
if (state == 0) {
mx = msg[1] - pxp;
my = msg[2] - pyp;
if (abs_i(mx) > abs_i(my)) { if (mx > 0) pwant = 1; else pwant = 3; }
else { if (my > 0) pwant = 2; else pwant = 0; }
}
continue;
}
if (t == VML_MSG_KEYDOWN) {
if (msg[1] == VML_KEY_ESCAPE) break;
on_key(msg[1]);
continue;
}
}
ui_scr_set_keep_on(0);
ui_sfx_panic();
ui_win_close();
return 0;
}