← 都来码 / Dolaima← 例程索引c/3d/snake3d.c原文

c/3d/snake3d.c · 27092 字节 · 下载原文

/* snake3d.c —— **3D 贪吃蛇**:用「写一个真游戏」来验 3D 接口好不好用。
 * snake3d.c — **3D Snake**: a real game written to test whether the 3D API is pleasant to use.
 *
 * ## 这个例子存在的理由(与 `solar3d.c` 分工不同)
 * ## Why this example exists (a different job from `solar3d.c`)
 *
 * `solar3d` 压的是**声明式运动**(`ui_3d_obj_set_spin` / `ui_3d_obj_set_orbit`,主循环一个几何调用都不发),
 * `solar3d` stresses **declarative motion** (`ui_3d_obj_set_spin` / `ui_3d_obj_set_orbit`; the main loop issues no geometry calls at all),
 * `g3_all.c` 压的是**号段逐号验收**(每个号一条断言)。两者都没压到**游戏真正的主循环**:
 * `g3_all.c` stresses **per-syscall acceptance** (one assertion per syscall number). Neither exercises a **real game main loop**:
 *
 *   · **近 200 个物体**同时存在(`ui_3d_obj_create` 建 196 个 + 4 面墙;`VML3D_MAX_OBJECTS` 自 2026-10-08 起是 1024,
 *     从前 256 时这叫"逼近上限",现在离得开)
 *   - **~200 objects alive at once** (`ui_3d_obj_create` builds 196 plus 4 walls; `VML3D_MAX_OBJECTS` has been 1024
 *     since 2026-10-08, so "close to the cap" only held back when it was 256)
 *   · **每帧改一批物体的位置/可见性/颜色**(`ui_3d_obj_set_pos` × 蛇长 + 食物)
 *   - **Mutate a batch of objects every frame**: position / visibility / color (`ui_3d_obj_set_pos` x snake length + food)
 *   · **用 `ui_3d_obj_set_visible` 当"对象池"**(一次建满、之后只切可见性,不再建/删)
 *   - **Use `ui_3d_obj_set_visible` as an object pool** (build all once, then only toggle visibility; never create/destroy)
 *   · 两种网格 + `ui_3d_obj_set_rot`(四面墙复用同一张网格,两侧转 90°)
 *   - Two meshes + `ui_3d_obj_set_rot` (the four walls share one mesh; two of them rotated 90 degrees)
 *   · 3D 物体与 2D HUD **同屏叠放**(分数、游戏结束)
 *   - 3D objects and a 2D HUD **overlaid on the same screen** (score, game over)
 *
 * 这些正是"接口好不好用"的判据:建 196 个物体要几行?改位置要不要重建?可见性切换贵不贵?
 * These are exactly the questions that decide whether the API is pleasant: how many lines to build 196 objects? Does moving one require a rebuild? How expensive is a visibility toggle?
 *
 * ## 判据(能自己响)
 * ## Pass criteria (self-checking)
 *
 * 正常玩:方向键 / WASD / 滑动转向,撞墙或撞自己结束,空格重来。
 * Normal play: arrow keys / WASD / swipe to turn; hitting a wall or yourself ends the game; Space restarts.
 * 机械化验证:把 `RUN_FRAMES` 改成大于 0,跑完打印
 * Automated check: set `RUN_FRAMES` greater than 0; when it finishes it prints
 * `SNAKE-DONE frames=… len=… score=…`(行首锚定即可断)。
 * `SNAKE-DONE frames=... len=... score=...` (anchor at line start to assert on it).
 *
 * ## 坐标系与手感
 * ## Coordinate system and feel
 *
 * 棋盘在 **XZ 平面**(y = 0),方块中心 y = 0.5。相机斜俯视、注视棋盘中心。
 * The board lies on the **XZ plane** (y = 0); block centers sit at y = 0.5. The camera looks down at an angle, aimed at the board center.
 * 斜俯视下 `+X` 朝屏幕右、`+Z` 朝屏幕下 ⇒ `cx+1` 往右、`cy+1` 往下。
 * In that view `+X` points screen-right and `+Z` points screen-down => `cx+1` moves right and `cy+1` moves down.
 */
#include <stdio.h>
#include <vml_3d.h>
#include <vml_ui.h>

/* 机械验证用:> 0 时跑满这么多帧就退出(正常玩保持 0)。*/
/* Machine verification: when > 0, exit after this many frames (keep 0 for normal play). */
#define RUN_FRAMES 0

/* ── 棋盘与规则 ──────────────────────────────────────────────────────────── */
/* ── Board and rules ──────────────────────────────────────────────────────── */
#define GW 14                      /* 宽(X 方向格数) */
                                   /* GW: board width in cells along X */
#define GH 14                      /* 高(Z 方向格数) */
                                   /* GH: board height in cells along Z */
#define CELLS (GW * GH)            /* 196 —— 全部预算成物体(旧上限 256 下是"逼近",新上限 1024 下已经很宽裕) */
                                   /* 196 -- all preallocated as objects ("close to the cap" under the old 256; comfortable under the new 1024) */
#define STEP_MS 240                /* 每 240ms 走一步(= 老写法的 6 帧 @25fps,约 4.2 步/秒) */
                                   /* One step every 240 ms (= the old 6 frames at 25fps, about 4.2 steps/sec) */
#define MAX_SNAKE 180              /* 蛇最长到这里就"通关"(不超物体上限) */
                                   /* Snake "wins" at this length (stays under the object cap) */
#define CELL_PITCH 1.15f           /* 格距:留缝,看得出是一格一格 */
                                   /* Cell pitch: leave a gap so single cells are visible */

/* ── 颜色(0xAARRGGBB)──────────────────────────────────────────────────── */
/* ── Colors (0xAARRGGBB) ────────────────────────────────────────────────── */
#define C_BG    0xFF0B1020
#define C_HEAD  0xFF7CFF4A
#define C_BODY  0xFF2FA84A
#define C_FOOD  0xFFFF4A3D
#define C_WALL  0xFF1B2440

/* ══ 网格装配(与 `solar3d.c` 同一套写法)══════════════════════════════════
/* ══ Mesh assembly (same style as `solar3d.c`) ═════════════════════════════════
 * 顶点 = 8 个 float32(pos3 + nrm3 + uv2,共 32 字节),紧随其后是 int32 索引。
 * Vertex = 8 float32 (pos3 + nrm3 + uv2, 32 bytes total), immediately followed by int32 indices.
 * ⚠ 顶点与索引**在同一块内存里**,索引按 int 指针写 —— 这是位模式搬运,不是数值转换。
 * ⚠ Vertices and indices live **in the same memory block**; indices are written through an int pointer -- a bit-pattern move, not a numeric conversion. */
float MB[1200];
int g_vn;
int g_in;

void mb_vertex(int i, float px, float py, float pz, float nx, float ny, float nz) {
    int b;
    b = i * 8;
    MB[b + 0] = px; MB[b + 1] = py; MB[b + 2] = pz;
    MB[b + 3] = nx; MB[b + 4] = ny; MB[b + 5] = nz;
    MB[b + 6] = 0.0f; MB[b + 7] = 0.0f;
}

void mb_index(int k, int v) {
    int* ip;
    ip = (int*)&MB[0];
    ip[g_vn * 8 + k] = v;
}

int mb_ptr(void) { return (int)&MB[0]; }

/* 轴对齐长方体(以原点为中心,half = 半边长):6 面 × 4 顶点 = 24 顶点、36 索引。
/* Axis-aligned box (centered at the origin, half = half-extent): 6 faces x 4 vertices = 24 vertices, 36 indices.
 * 法线**按面给常量** —— 立方体上"归一化位置当法线"是错的(角上会朝 45°,棱边发糊)。
 * Normals are **constants per face** -- "normalize the position to get a normal" is wrong on a cube (corners would point 45 degrees, edges blur).
 *
 * ⚠ 绕序刻意**不保证**:后端不剔除背面,片元的双面光照走 `abs(dot(n, L))`
 * ⚠ Winding order is deliberately **not guaranteed**: the backend does no back-face culling and the fragment shader uses two-sided lighting `abs(dot(n, L))`
 *   ⇒ 绕序反了也不会变黑(只会两面一样亮)。需要"正面亮、背面暗"时才要管绕序。
 *   => a reversed winding never goes black (both sides just light the same). Only care about winding when you need "front bright, back dark". */
void gen_box(float hx, float hy, float hz) {
    int f; int v; int k;
    float nx[6]; float ny[6]; float nz[6];
    nx[0] = 1.0f;  ny[0] = 0.0f;  nz[0] = 0.0f;
    nx[1] = -1.0f; ny[1] = 0.0f;  nz[1] = 0.0f;
    nx[2] = 0.0f;  ny[2] = 1.0f;  nz[2] = 0.0f;
    nx[3] = 0.0f;  ny[3] = -1.0f; nz[3] = 0.0f;
    nx[4] = 0.0f;  ny[4] = 0.0f;  nz[4] = 1.0f;
    nx[5] = 0.0f;  ny[5] = 0.0f;  nz[5] = -1.0f;

    v = 0;
    for (f = 0; f < 6; f = f + 1) {
        int u; int w; int q;
        if (f == 0 || f == 1) { u = 1; w = 2; }        /* ±X 面:另两轴是 Y/Z */
                                                       /* ±X face: the other two axes are Y/Z */
        else if (f == 2 || f == 3) { u = 0; w = 2; }   /* ±Y 面:X/Z */
                                                       /* ±Y face: X/Z */
        else { u = 0; w = 1; }                          /* ±Z 面:X/Y */
                                                        /* ±Z face: X/Y */
        for (q = 0; q < 4; q = q + 1) {
            float a; float b; float px; float py; float pz;
            a = ((q == 0 || q == 3) ? 1.0f : -1.0f) * ((u == 0) ? hx : ((u == 1) ? hy : hz));
            b = ((q == 0 || q == 1) ? 1.0f : -1.0f) * ((w == 0) ? hx : ((w == 1) ? hy : hz));
            if (f == 0)      { px = hx;  py = a;  pz = b; }
            else if (f == 1) { px = -hx; py = a;  pz = b; }
            else if (f == 2) { px = a;  py = hy;  pz = b; }
            else if (f == 3) { px = a;  py = -hy; pz = b; }
            else if (f == 4) { px = a;  py = b;   pz = hz; }
            else             { px = a;  py = b;   pz = -hz; }
            mb_vertex(v, px, py, pz, nx[f], ny[f], nz[f]);
            v = v + 1;
        }
    }
    g_vn = v;
    k = 0;
    for (f = 0; f < 6; f = f + 1) {
        int a;
        a = f * 4;
        mb_index(k, a);     k = k + 1;
        mb_index(k, a + 1); k = k + 1;
        mb_index(k, a + 2); k = k + 1;
        mb_index(k, a);     k = k + 1;
        mb_index(k, a + 2); k = k + 1;
        mb_index(k, a + 3); k = k + 1;
    }
    g_in = k;
}

/* ══ 游戏状态 ════════════════════════════════════════════════════════════ */
/* ══ Game state ═════════════════════════════════════════════════════════ */
int obj_of_cell[CELLS];        /* 每格一个物体(**对象池**:只切可见性,不建不删) */
                               /* One object per cell (**object pool**: only toggle visibility, never create/destroy) */
int vp;
int snake[CELLS][2];           /* 蛇身格子坐标,[0] 是头 */
                               /* Snake body cell coordinates; [0] is the head */
int snake_len;
int nextx; int nexty;          /* 下一帧生效的方向(见主循环里的说明) */
                               /* Direction to take effect next frame (see the note in the main loop) */
int dirx;  int diry;           /* 已经生效的方向 */
                               /* Direction already in effect */
int foodx; int foody;
int score;
int gameover;
int frame;
int lastMs; int stepAccMs;   /* 墙钟步进(见主循环里那段的说明) */
                             /* Wall-clock stepping (see that section in the main loop) */
int rnd;

int rnd_next(void) {
    rnd = rnd * 1103515245 + 12345;
    rnd = (rnd >> 16) & 32767;
    return rnd;
}

int cell_index(int cx, int cy) { return cy * GW + cx; }

float cell_x(int cx) { return ((float)cx - (GW - 1) * 0.5f) * CELL_PITCH; }
float cell_z(int cy) { return ((float)cy - (GH - 1) * 0.5f) * CELL_PITCH; }

void place_cell(int cx, int cy, int obj, int argb) {
    ui_3d_obj_set_pos(obj, cell_x(cx), 0.5f, cell_z(cy));
    ui_3d_obj_set_color(obj, argb);
    ui_3d_obj_set_visible(obj, 1);
}

int cell_has_snake(int cx, int cy) {
    int i;
    for (i = 0; i < snake_len; i = i + 1) {
        if (snake[i][0] == cx && snake[i][1] == cy) return 1;
    }
    return 0;
}

void spawn_food(void) {
    int i; int cx; int cy;
    for (i = 0; i < 500; i = i + 1) {
        cx = rnd_next() % GW;
        cy = rnd_next() % GH;
        if (cell_has_snake(cx, cy) == 0) { foodx = cx; foody = cy; return; }
    }
}

void reset_game(void) {
    int i;
    for (i = 0; i < CELLS; i = i + 1) ui_3d_obj_set_visible(obj_of_cell[i], 0);
    snake_len = 3;
    snake[0][0] = GW / 2;     snake[0][1] = GH / 2;
    snake[1][0] = GW / 2 - 1; snake[1][1] = GH / 2;
    snake[2][0] = GW / 2 - 2; snake[2][1] = GH / 2;
    nextx = 1; nexty = 0;
    dirx = 1;  diry = 0;
    score = 0;
    gameover = 0;
    spawn_food();
}

/* 把蛇身与食物摆出来(**每帧重摆**:位置、颜色、可见性都要跟上) */
/* Place the snake body and the food (**re-placed every frame**: position, color and visibility must all keep up) */
void draw_board(void) {
    int i;
    for (i = 0; i < snake_len; i = i + 1) {
        int o;
        o = obj_of_cell[cell_index(snake[i][0], snake[i][1])];
        if (i == 0) place_cell(snake[i][0], snake[i][1], o, C_HEAD);
        else place_cell(snake[i][0], snake[i][1], o, C_BODY);
    }
    place_cell(foodx, foody, obj_of_cell[cell_index(foodx, foody)], C_FOOD);
}

void step_game(void) {
    int nx; int ny; int i; int grew;
    if (gameover) return;
    nx = snake[0][0] + nextx;
    ny = snake[0][1] + nexty;
    dirx = nextx; diry = nexty;
    if (nx < 0 || ny < 0 || nx >= GW || ny >= GH) { gameover = 1; return; }
    /* 撞自己:**尾巴那一格会让开**,所以只查到 snake_len-1(差一位就是"贴着尾巴走也被判死") */
    /* Self-collision: **the tail cell vacates**, so only scan up to snake_len-1 (off by one = "walking right behind your tail also kills you") */
    for (i = 0; i < snake_len - 1; i = i + 1) {
        if (snake[i][0] == nx && snake[i][1] == ny) { gameover = 1; return; }
    }
    grew = (nx == foodx && ny == foody);
    if (grew == 0) {
        ui_3d_obj_set_visible(obj_of_cell[cell_index(snake[snake_len - 1][0], snake[snake_len - 1][1])], 0);
    } else {
        if (snake_len < MAX_SNAKE && snake_len < CELLS) snake_len = snake_len + 1;
        score = score + 1;
    }
    for (i = snake_len - 1; i > 0; i = i - 1) {
        snake[i][0] = snake[i - 1][0];
        snake[i][1] = snake[i - 1][1];
    }
    snake[0][0] = nx;
    snake[0][1] = ny;
    if (grew) spawn_food();
}

/* 转向:**只改"下一帧的方向"**。一帧内连按两次(先上后左)若直接改生效方向,
/* Turning: **only changes "the direction for the next frame"**. If two presses in one frame (up then left) changed the live direction directly,
 * 会让蛇当场掉头撞自己,而玩家只按了两下键 —— 这是键盘版贪吃蛇的经典坑。
 * the snake would reverse on the spot into itself after only two key presses -- the classic pitfall of keyboard Snake. */
void turn(int nx, int ny) { nextx = nx; nexty = ny; }

int main(void) {
    int sw; int sh; int w; int h;
    int mesh_box; int mesh_wall;
    int i;
    int touch[3];
    int dragging; int lastTx; int lastTy;

    /* ⚠ 问尺寸要用 **_ex**:下面开的窗是「不要手柄」(gamepad=0),而 `ui_scr_get_w()` 按
    /* ⚠ Use **_ex** when querying the size: the window opened below is "no gamepad" (gamepad=0), but `ui_scr_get_w()` estimates with the
     * **默认** chrome(要手柄)估算 ⇒ 问到的比实际画布矮一截,视口就会贴左上角
     * **default** chrome (gamepad present) => it reports less than the real canvas and the viewport sticks to the top-left
     * (`solar3d.c` 里记过这个坑:用户实测报的「太阳不在窗口中心」)。
     * (`solar3d.c` records this pitfall: a user measured and reported "the sun is not in the center of the window"). */
    sw = ui_scr_get_w_ex(ui_scr_get_orientation(), VML_CHROME_NONE);
    sh = ui_scr_get_h_ex(ui_scr_get_orientation(), VML_CHROME_NONE);
    if (sw <= 0) sw = 640;
    if (sh <= 0) sh = 640;
    /* 棋盘是方的 ⇒ 开方窗,画面里不留大黑边(窗口宽高就是画布的坐标空间) */
    /* The board is square => open a square window so no big black bars appear (the window size is the canvas coordinate space) */
    if (sw > sh) { w = sh; h = sh; } else { w = sw; h = sw; }
    ui_win_open_ex("3D 贪吃蛇 Snake 3D", w, h, VML_WIN_ROTATABLE, VML_WIN_NO_GAMEPAD);

    vp = ui_3d_viewport_create(0, 0, w, h, 0);
    if (vp <= 0) {
        /* 拿不到 3D 设备(后端缺失 / 驱动不给上下文)—— 如实报,不装作画出来了 */
        /* Could not get a 3D device (backend missing / driver gave no context) -- say so honestly, never pretend it drew something */
        printf("NO_3D_DEVICE=1\n");
        return 0;
    }
    ui_3d_viewport_set_bg(vp, C_BG);
    /* 相机:斜俯视,**注视棋盘中心(原点)**。方向由 eye 定,距离由 `vp_fit` 按半径推。 */
    /* Camera: looking down at an angle, **aimed at the board center (origin)**. Direction is set by eye, distance inferred by `vp_fit` from the radius. */
    ui_3d_viewport_set_camera(vp, 0.0f, 1.35f, 1.0f, 0.0f, 0.0f, 0.0f);
    ui_3d_viewport_set_fov(vp, 55.0f, 0.5f, 4000.0f);
    ui_3d_viewport_fit(vp, 13.5f);            /* 棋盘半对角 ≈ 11.4,留点余量 */
                                              /* Board half-diagonal is about 11.4; leave some margin */
    ui_3d_viewport_set_light(vp, 0.45f, 1.0f, 0.35f, 0xFFFFFFFF, 0.42f);

    /* ── 两张网格:蛇身/食物的方块,与四面墙用的长条 ─────────────────────── */
    /* ── Two meshes: the box for the snake/food, and the long bar used by the four walls ─── */
    gen_box(0.44f, 0.44f, 0.44f);
    mesh_box = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    gen_box((GW * CELL_PITCH) * 0.5f + 0.5f, 0.18f, 0.25f);
    mesh_wall = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    printf("SNAKE-MESH box=%d wall=%d\n", mesh_box, mesh_wall);
    if (mesh_box <= 0 || mesh_wall <= 0) { printf("NO_MESH=1\n"); return 0; }

    /* ── 对象池:**一次建满 196 个**,之后全程只切可见性/改位置 ─────────────
    /* ── Object pool: **build all 196 up front**; from then on only toggle visibility / change positions ────────────
     * 这正是本轮要验的核心用法 —— 建 196 个物体就是下面这 4 行。
     * This is the core usage being tested here -- building 196 objects is just the 4 lines below. */
    for (i = 0; i < CELLS; i = i + 1) {
        int o;
        o = ui_3d_obj_create(vp, mesh_box);
        if (o <= 0) { printf("SNAKE-OBJ-FAIL=%d\n", i); return 0; }
        obj_of_cell[i] = o;
        ui_3d_obj_set_visible(o, 0);
    }
    printf("SNAKE-OBJS=%d\n", CELLS);

    /* 四面墙:**同一张网格 + `ui_3d_obj_set_rot` 转 90°**(顺带压一下旋转这条路) */
    /* Four walls: **one shared mesh + `ui_3d_obj_set_rot` rotated 90 degrees** (also exercises the rotation path) */
    {
        float half; float edge;
        int k;
        half = (GW * CELL_PITCH) * 0.5f + 0.5f;
        edge = (GW - 1) * 0.5f * CELL_PITCH + 0.9f;
        for (k = 0; k < 4; k = k + 1) {
            int o;
            o = ui_3d_obj_create(vp, mesh_wall);
            if (o <= 0) { printf("SNAKE-WALL-FAIL=%d\n", k); return 0; }
            if (k == 0)      { ui_3d_obj_set_pos(o, 0.0f, 0.1f, -edge); }
            else if (k == 1) { ui_3d_obj_set_pos(o, 0.0f, 0.1f, edge);  }
            else if (k == 2) { ui_3d_obj_set_pos(o, -edge, 0.1f, 0.0f); ui_3d_obj_set_rot(o, 0.0f, 90.0f, 0.0f); }
            else             { ui_3d_obj_set_pos(o, edge, 0.1f, 0.0f);  ui_3d_obj_set_rot(o, 0.0f, 90.0f, 0.0f); }
            ui_3d_obj_set_color(o, C_WALL);
            ui_3d_obj_set_visible(o, 1);
        }
        (void)half;
    }

    rnd = 20261001;
    reset_game();
    draw_board();

    dragging = 0; lastTx = 0; lastTy = 0;
    frame = 0;
    lastMs = ui_time_get_ms(); stepAccMs = 0;

    while (1) {
        /* ── 输入:方向键 / WASD ──────────────────────────────────────────
        /* ── Input: arrow keys / WASD ──────────────────────────────────────────
         * "不能掉头"的判定用**已生效**的 `dir`,不是 `next` —— 否则连按上下会自杀。
         * The "cannot reverse" test uses the **live** `dir`, not `next` -- otherwise mashing up then down is suicide. */
        if (ui_key_is_down(VML_KEY_LEFT))  { if (dirx != 1)  turn(-1, 0); }
        if (ui_key_is_down(VML_KEY_RIGHT)) { if (dirx != -1) turn(1, 0); }
        if (ui_key_is_down(VML_KEY_UP))    { if (diry != 1)  turn(0, -1); }
        if (ui_key_is_down(VML_KEY_DOWN))  { if (diry != -1) turn(0, 1); }
        if (ui_key_is_down(65)) { if (dirx != 1)  turn(-1, 0); }   /* 'A' */
        if (ui_key_is_down(68)) { if (dirx != -1) turn(1, 0); }    /* 'D' */
        if (ui_key_is_down(87)) { if (diry != 1)  turn(0, -1); }   /* 'W' */
        if (ui_key_is_down(83)) { if (diry != -1) turn(0, 1); }    /* 'S' */
        if (ui_key_is_down(VML_KEY_ESCAPE)) break;
        if (gameover && ui_key_is_down(VML_KEY_SPACE)) { reset_game(); draw_board(); }

        /* ── 触摸:滑动转向(用**增量**,手指从边上重新落下时才不会跳一大截)── */
        /* ── Touch: swipe to turn (use **deltas**, so a finger re-landing near the edge does not jump a big distance) ── */
        if (ui_touch_read(0, touch)) {
            if (touch[2]) {
                if (dragging) {
                    int dx; int dy; int ax; int ay;
                    dx = touch[0] - lastTx;
                    dy = touch[1] - lastTy;
                    ax = (dx < 0) ? -dx : dx;
                    ay = (dy < 0) ? -dy : dy;
                    if ((ax > 16 || ay > 16)) {
                        if (ax >= ay) { if (dx > 0) turn(1, 0); else turn(-1, 0); }
                        else          { if (dy > 0) turn(0, 1); else turn(0, -1); }
                        /* ⚠ 判完**立刻重置基准**:不清的话一次长滑会在每帧触发一次转向,
                        /* ⚠ **Reset the baseline right after a turn**: without it one long swipe triggers a turn every frame,
                         *   手指还没抬就已经转了好几次(本仓 E106 同族)。
                         *   and the snake turns several times before the finger is even lifted (same family as E106 in this repo). */
                        lastTx = touch[0]; lastTy = touch[1];
                    }
                } else {
                    lastTx = touch[0]; lastTy = touch[1];
                }
                dragging = 1;
            } else {
                dragging = 0;
            }
        } else {
            dragging = 0;
        }

        /* ── 步进:**按墙钟**每 STEP_MS 毫秒走一步(用户 2026-10-04 定的口径:
        /* ── Stepping: **by wall clock**, one step every STEP_MS ms (the convention the user set on 2026-10-04:
         * **帧率不该是玩法的一部分**)。老写法数帧(每 STEP_FRAMES 帧一步,
         * **frame rate should not be part of the gameplay**). The old version counted frames (one step per STEP_FRAMES frames,
         * 注释里自认"25fps ⇒ 约 4.2 步/秒"),宿主改成跟着显示走之后会快 2.4 倍。
         * its comment admitting "25fps => about 4.2 steps/sec"), which runs 2.4x faster once the host follows the display.
         * ⚠ 用"欠账累加"而不是"取模":掉帧时能把欠的步数补回来,不会悄悄变慢。
         * ⚠ Uses an accumulating deficit rather than a modulo, so dropped frames are caught up instead of the game quietly slowing down. */
        {
            int now2 = ui_time_get_ms();
            stepAccMs = stepAccMs + (now2 - lastMs);
            lastMs = now2;
        }
        if (stepAccMs >= STEP_MS) {
            stepAccMs = stepAccMs - STEP_MS;
            if (stepAccMs > STEP_MS * 4) stepAccMs = 0;   /* 卡太久就别一次补一大串 */
                                                          /* stalled too long: do not catch up a whole burst at once */
            step_game();
            draw_board();
        }

        /* ── HUD:**2D 叠在 3D 之上**(`ui_2d_clear(0)` = 全透明打底)────────── */
        /* ── HUD: **2D drawn on top of 3D** (`ui_2d_clear(0)` = fully transparent base) ── */
        ui_2d_clear(0);
        ui_2d_text(12, 10, "3D 贪吃蛇 Snake 3D", -1, 15, VML_ANCHOR_LEFT);
        {
            char buf[64];
            sprintf(buf, "len %d   score %d", snake_len, score);
            ui_2d_text(12, 30, buf, -8355712, 12, VML_ANCHOR_LEFT);
        }
        if (gameover) {
            ui_2d_text(12, 52, "撞了!按空格重来 GAME OVER - SPACE", -65536, 14, VML_ANCHOR_LEFT);
        }

        ui_2d_present();
        /* ⚠ **不是帧节流**(2026-10-04 起**上屏节奏由宿主管**:`ui_2d_present()` 之后宿主按显示预算走,并把这个超时夹到「距下一帧还有多久」)。这里只是「等输入最多等多久」。
        /* ⚠ **This is not frame throttling** (since 2026-10-04 **the host owns on-screen pacing**: after `ui_2d_present()` the host paces by the display budget and clamps this timeout to "time left until the next frame"). Here it only means "how long to wait for input at most".
         *   (当年那句"没有它主循环会在零点几秒内烧完几百帧、屏幕一闪而过",说的是**宿主还没有
         *   (the old line "without it the main loop burns hundreds of frames in a fraction of a second and the screen flashes by" described an era when **the host had no
         *   节流**的年代;现在 `PaceFrame` 已经兜住了。)
         *   throttling**; nowadays `PaceFrame` covers it.) */
        ui_msg_fetch_wait(40);

        frame = frame + 1;
        if (RUN_FRAMES > 0) { if (frame >= RUN_FRAMES) break; }
    }

    printf("SNAKE-DONE frames=%d len=%d score=%d\n", frame, snake_len, score);
    ui_3d_mesh_free(mesh_box);
    ui_3d_mesh_free(mesh_wall);
    ui_3d_viewport_free(vp);
    return 0;
}