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

c/3d/pool3d.c · 26482 字节 · 下载原文

/* pool3d.c —— **3D 桌球**:第二个"用真游戏验接口"的探针。
 * pool3d.c — **3D pool**: the second "write a real game to test the API" probe.
 *
 * ## 它压的是 `snake3d.c` 压不到的那一半
 * ## It stresses the half that `snake3d.c` cannot
 *
 * `snake3d` 是**格子对齐**的(位置只会落在 196 个格点上),而桌球要的是
 * `snake3d` is **grid-aligned** (positions only ever land on 196 lattice points), while pool needs
 * **连续坐标 + 精确到小数的位置/半径**:
 * **continuous coordinates with fractional positions and radii**:
 *
 *   · 球的位置是**浮点**、每帧都在变(不是格子)—— 检验 `ui_3d_obj_set_pos` 的精度与调用频率
 *   - Ball positions are **floats** changing every frame (not a grid) -- exercises `ui_3d_obj_set_pos` precision and call frequency
 *   · 球半径靠 `ui_3d_obj_set_scale`(单位球缩放),**大小必须一致**(不同缩放会让碰撞判定失真)
 *   - Ball radius comes from `ui_3d_obj_set_scale` (scaling a unit sphere); **the size must be consistent** (a different scale would distort collision tests)
 *   · 球与球、球与库边的**碰撞**(O(n²) 的成对循环,每帧跑)
 *   - Ball-to-ball and ball-to-cushion **collisions** (an O(n^2) pairwise loop, run every frame)
 *   · 相机**斜俯视** + 拖动旋转 + 开合缩放(`ui_3d_viewport_set_orbit` / `ui_3d_viewport_set_zoom`)
 *   - Camera **looking down at an angle** + drag to rotate + pinch to zoom (`ui_3d_viewport_set_orbit` / `ui_3d_viewport_set_zoom`)
 *   · 3D→2D 投影做 HUD(`ui_3d_obj_get_screen` 标出瞄准线)
 *   - 3D-to-2D projection to build the HUD (`ui_3d_obj_get_screen` marks the aiming line)
 *
 * ## 玩法
 * ## How to play
 *
 * 按住往**想打的方向**滑一下(滑多长 = 多大力),白球被打出去;球进袋就消失。
 * Hold and swipe in **the direction you want to hit** (swipe length = force); the cue ball shoots off; a ball drops out of play once potted.
 * 全部打完或按空格重开。Esc 退出。
 * Clear them all, or press Space to restart. Esc quits.
 *
 * ## 判据
 * ## Pass criteria
 *
 * 机械化验证:把 `RUN_FRAMES` 改成大于 0,跑完打印
 * Automated check: set `RUN_FRAMES` greater than 0; when it finishes it prints
 * `POOL-DONE frames=… alive=… potted=…`(行首锚定即可断)。
 * `POOL-DONE frames=... alive=... potted=...` (anchor at line start to assert on it).
 */
#include <stdio.h>
#include <vml_3d.h>
#include <vml_ui.h>
/* ⚠⚠ **必须包含 math.h**(2026-10-01 实测踩到,卡了一整轮):
/* ⚠⚠ **math.h must be included** (hit on 2026-10-01, cost a whole round of debugging):
 *   少了它,`sin`/`cos`/`sqrt` 就成了**隐式声明** —— 前端照样编过、照样链接,
 *   without it `sin`/`cos`/`sqrt` become **implicit declarations** -- the frontend still compiles and links them,
 *   但落到一个**把实参原样返回**的实现上:`sqrt(4.0f)` 得 `4.0`、`sin(0.5f)` 得 `0.5`。
 *   but they land on an implementation that **returns the argument unchanged**: `sqrt(4.0f)` gives `4.0`, `sin(0.5f)` gives `0.5`.
 *   症状是"球体的顶点全是 0 / 全相等" ⇒ **球画不出来,而编译链接运行全绿、不报任何错**。
 *   The symptom is "every sphere vertex is 0 / all equal" => **nothing draws the sphere, while compile, link and run are all green and report no error at all**.
 *   `Lib/c/math.h` 才是那条把名字接对的路(它声明的是 `double sin(double)`)。
 *   `Lib/c/math.h` is the path that wires the names up correctly (it declares `double sin(double)`).
 *   ⚠ 同一族的既有风险见 `docs/开发教训.md` E132(跨库同名裸标签"后链的赢")——
 *   ⚠ For the same family of risk see the dev-lessons doc, E132 (a bare label with the same name across libraries: "the last one linked wins") --
 *     `sin`/`cos`/`sqrt` 在 `fixed` / `mathd` / `mathf` **三个库里都有定义**。
 *     `sin`/`cos`/`sqrt` are **defined in all three** of the `fixed` / `mathd` / `mathf` libraries. */
#include <math.h>

#define RUN_FRAMES 0

/* ── 台面尺寸(世界单位)──────────────────────────────────────────────── */
/* ── Table dimensions (world units) ──────────────────────────────────────── */
#define TABLE_W 20.0f          /* X 方向长 */
                               /* TABLE_W: length along X */
#define TABLE_D 10.0f          /* Z 方向宽 */
                               /* TABLE_D: width along Z */
#define WALL_H  0.7f           /* 库边高 */
                               /* Cushion height */
#define BALL_R  0.45f          /* 球半径 */
                               /* Ball radius */
#define POCKET_R 0.85f         /* 袋口半径(判定用) */
                               /* Pocket radius (used for the check) */
#define NBALL 10               /* 球数(1 白球 + 9 彩球)—— 够压接口,又看得清 */
                               /* Ball count (1 cue + 9 colored) -- enough to stress the API while still readable */

#define C_BG     0xFF101418
#define C_CLOTH  0xFF1E6B3C
#define C_CUSH   0xFF6B3A1E
#define C_CUE    0xFFFFFFFF
#define C_BALL1  0xFFFFD54A
#define C_BALL2  0xFF4AA8FF
#define C_BALL3  0xFFFF6B6B
#define C_BALL4  0xFFB07CFF
#define C_BALL5  0xFF6BFFB0

/* ══ 网格装配(与另外两个 3D 例子同一套写法)══ */
/* ══ Mesh assembly (same style as the other two 3D examples) ══ */
float MB[4400];
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]; }

/* 单位球(半径 1):法线就是归一化位置。球类统一用这一张网格 + `ui_3d_obj_set_scale(BALL_R)`。 */
/* Unit sphere (radius 1): the normal is just the normalized position. All balls share this mesh plus `ui_3d_obj_set_scale(BALL_R)`. */
void gen_sphere(int rings, int segs) {
    int i; int j; int k; int v;
    v = 0;
    for (i = 0; i <= rings; i = i + 1) {
        float phi = 3.14159265f * i / rings;
        float sp = sin(phi);
        float cp = cos(phi);
        for (j = 0; j <= segs; j = j + 1) {
            float th = 6.28318531f * j / segs;
            float nx = sp * cos(th);
            float ny = cp;
            float nz = sp * sin(th);
            mb_vertex(v, nx, ny, nz, nx, ny, nz);
            v = v + 1;
        }
    }
    g_vn = v;
    k = 0;
    for (i = 0; i < rings; i = i + 1) {
        for (j = 0; j < segs; j = j + 1) {
            int a = i * (segs + 1) + j;
            int b = a + segs + 1;
            mb_index(k, a);     k = k + 1;
            mb_index(k, b);     k = k + 1;
            mb_index(k, a + 1); k = k + 1;
            mb_index(k, a + 1); k = k + 1;
            mb_index(k, b);     k = k + 1;
            mb_index(k, b + 1); k = k + 1;
        }
    }
    g_in = k;
}

/* 长方体(以原点为中心)—— 台面与库边共用,靠不同的 half 参数区分。 */
/* Box (centered at the origin) -- shared by the table and the cushions, told apart by the different half-extents. */
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 axisU; int axisW; int q;
        if (f == 0 || f == 1) { axisU = 1; axisW = 2; }
        else if (f == 2 || f == 3) { axisU = 0; axisW = 2; }
        else { axisU = 0; axisW = 1; }
        for (q = 0; q < 4; q = q + 1) {
            float halfU; float halfW; float cx; float cy; float cz;
            halfU = ((q == 0 || q == 3) ? 1.0f : -1.0f) *
                    ((axisU == 0) ? hx : ((axisU == 1) ? hy : hz));
            halfW = ((q == 0 || q == 1) ? 1.0f : -1.0f) *
                    ((axisW == 0) ? hx : ((axisW == 1) ? hy : hz));
            if (f == 0)      { cx = hx;  cy = halfU; cz = halfW; }
            else if (f == 1) { cx = -hx; cy = halfU; cz = halfW; }
            else if (f == 2) { cx = halfU; cy = hy;  cz = halfW; }
            else if (f == 3) { cx = halfU; cy = -hy; cz = halfW; }
            else if (f == 4) { cx = halfU; cy = halfW; cz = hz; }
            else             { cx = halfU; cy = halfW; cz = -hz; }
            mb_vertex(v, cx, cy, cz, nx[f], ny[f], nz[f]);
            v = v + 1;
        }
    }
    g_vn = v;
    k = 0;
    for (f = 0; f < 6; f = f + 1) {
        int base;
        base = f * 4;
        mb_index(k, base);     k = k + 1;
        mb_index(k, base + 1); k = k + 1;
        mb_index(k, base + 2); k = k + 1;
        mb_index(k, base);     k = k + 1;
        mb_index(k, base + 2); k = k + 1;
        mb_index(k, base + 3); k = k + 1;
    }
    g_in = k;
}

/* ══ 球的状态 ═══════════════════════════════════════════════════════════ */
/* ══ Ball state ═════════════════════════════════════════════════════════ */
int vp;
int ball_obj[NBALL];      /* 物体句柄 */
                          /* Object handles */
float bx[NBALL];          /* 位置 x */
                          /* Position x */
float bz[NBALL];          /* 位置 z */
                          /* Position z */
float vx[NBALL];          /* 速度 x */
                          /* Velocity x */
float vz[NBALL];          /* 速度 z */
                          /* Velocity z */
int alive[NBALL];
int alive_count;
int potted;
int frame;
int lastMs; int physAcc;   /* 墙钟步进(见主循环里物理那段) */
                           /* Wall-clock stepping (see the physics section in the main loop) */
int rnd;

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

void sync_ball(int i) {
    ui_3d_obj_set_pos(ball_obj[i], bx[i], BALL_R, bz[i]);
    ui_3d_obj_set_visible(ball_obj[i], alive[i]);
}

/* 开球摆位:白球在左边,彩球挤在右边一小片(够产生碰撞,又不至于一杆全散) */
/* Rack layout: cue ball on the left, colored balls packed into a small patch on the right (enough to collide, yet not all scattered by one shot) */
void reset_game(void) {
    int i;
    for (i = 0; i < NBALL; i = i + 1) {
        alive[i] = 1;
        vx[i] = 0.0f;
        vz[i] = 0.0f;
        if (i == 0) {
            bx[0] = -TABLE_W * 0.32f;
            bz[0] = 0.0f;
        } else {
            int col = (i - 1) / 3;
            int row = (i - 1) % 3;
            bx[i] = TABLE_W * 0.20f + (float)col * (BALL_R * 2.2f);
            bz[i] = ((float)row - 1.0f) * (BALL_R * 2.2f);
        }
        ui_3d_obj_set_color(ball_obj[i], (i == 0) ? C_CUE :
                     ((i == 1) ? C_BALL1 : ((i == 2) ? C_BALL2 :
                     ((i == 3) ? C_BALL3 : ((i == 4) ? C_BALL4 : C_BALL5)))));
        sync_ball(i);
    }
    alive_count = NBALL;
    potted = 0;
}

/* 库边反弹:把球钳回台内并反向速度(带 0.9 的损耗,不然会永远弹) */
/* Cushion bounce: clamp the ball back inside the table and reverse its velocity (with 0.9 damping, or it would bounce forever) */
void hit_cushion(int i) {
    float lx = TABLE_W * 0.5f - BALL_R;
    float lz = TABLE_D * 0.5f - BALL_R;
    if (bx[i] < -lx) { bx[i] = -lx; vx[i] = -vx[i] * 0.9f; }
    if (bx[i] > lx)  { bx[i] = lx;  vx[i] = -vx[i] * 0.9f; }
    if (bz[i] < -lz) { bz[i] = -lz; vz[i] = -vz[i] * 0.9f; }
    if (bz[i] > lz)  { bz[i] = lz;  vz[i] = -vz[i] * 0.9f; }
}

/* 落袋:四个角袋 */
/* Pocketing: the four corner pockets */
void check_pocket(int i) {
    float px[4]; float pz[4];
    int k;
    px[0] = -TABLE_W * 0.5f; pz[0] = -TABLE_D * 0.5f;
    px[1] = TABLE_W * 0.5f;  pz[1] = -TABLE_D * 0.5f;
    px[2] = -TABLE_W * 0.5f; pz[2] = TABLE_D * 0.5f;
    px[3] = TABLE_W * 0.5f;  pz[3] = TABLE_D * 0.5f;
    for (k = 0; k < 4; k = k + 1) {
        float dx = bx[i] - px[k];
        float dz = bz[i] - pz[k];
        if (dx * dx + dz * dz < POCKET_R * POCKET_R) {
            /* ⚠ 白球落袋**也要消失**(否则它会卡在袋里反复触发);
            /* ⚠ The cue ball **also disappears** when potted (otherwise it gets stuck in the pocket and re-triggers it);
             *   真实规则是"白球回位",这里从简,并在 HUD 里说清。
             *   the real rule is "cue ball returns to the baulk"; here we simplify and spell it out in the HUD. */
            alive[i] = 0;
            alive_count = alive_count - 1;
            vx[i] = 0.0f;
            vz[i] = 0.0f;
            if (i != 0) potted = potted + 1;
            sync_ball(i);
            return;
        }
    }
}

/* 一步物理:成对碰撞 + 库边 + 落袋 + 摩擦。dt 固定(每帧一步),保证可复现。 */
/* One physics step: pairwise collisions + cushions + pockets + friction. dt is fixed (one step per frame) for reproducibility. */
void physics(void) {
    int i;
    int j;
    float dt = 0.055f;
    float friction = 0.988f;

    for (i = 0; i < NBALL; i = i + 1) {
        if (alive[i] == 0) continue;
        bx[i] = bx[i] + vx[i] * dt;
        bz[i] = bz[i] + vz[i] * dt;
        vx[i] = vx[i] * friction;
        vz[i] = vz[i] * friction;
        /* 速度小到看不见就停住 —— 不清零的话球会永远以毫米级速度飘,还每帧重算碰撞 */
        /* Stop once the speed is too small to see -- without zeroing it the ball drifts at millimeter-per-second speed forever and recomputes collisions every frame */
        if (vx[i] > -0.02f && vx[i] < 0.02f) vx[i] = 0.0f;
        if (vz[i] > -0.02f && vz[i] < 0.02f) vz[i] = 0.0f;
        hit_cushion(i);
    }

    /* 球球碰撞:等质量、正碰(沿法线交换法向速度分量)—— 台球的标准近似 */
    /* Ball-ball collision: equal mass, head-on (exchange the normal velocity components along the normal) -- the standard billiards approximation */
    for (i = 0; i < NBALL; i = i + 1) {
        if (alive[i] == 0) continue;
        for (j = i + 1; j < NBALL; j = j + 1) {
            float dx; float dz; float d2; float d;
            float nx; float nz; float rel;
            if (alive[j] == 0) continue;
            dx = bx[j] - bx[i];
            dz = bz[j] - bz[i];
            d2 = dx * dx + dz * dz;
            if (d2 > (BALL_R * 2.0f) * (BALL_R * 2.0f)) continue;
            if (d2 < 1e-6f) { dx = 0.001f; dz = 0.0f; d2 = 1e-6f; }
            d = sqrt(d2);
            nx = dx / d;
            nz = dz / d;
            rel = (vx[j] - vx[i]) * nx + (vz[j] - vz[i]) * nz;
            if (rel > 0.0f) continue;                 /* 正在分开,不处理 */
                                                      /* separating already, skip */
            vx[i] = vx[i] + rel * nx * 0.98f;
            vz[i] = vz[i] + rel * nz * 0.98f;
            vx[j] = vx[j] - rel * nx * 0.98f;
            vz[j] = vz[j] - rel * nz * 0.98f;
            /* 位置分离:不推开的话两球会重叠、每帧都判成碰撞("粘在一起抖") */
            /* Position separation: without pushing them apart the two balls overlap and register a collision every frame ("stuck together, jittering") */
            {
                float push = (BALL_R * 2.0f - d) * 0.5f;
                bx[i] = bx[i] - nx * push;
                bz[i] = bz[i] - nz * push;
                bx[j] = bx[j] + nx * push;
                bz[j] = bz[j] + nz * push;
            }
        }
    }

    for (i = 0; i < NBALL; i = i + 1) {
        if (alive[i] == 0) continue;
        check_pocket(i);
        if (alive[i]) sync_ball(i);
    }
}

int main(void) {
    int sw; int sh; int w; int h;
    int mesh_ball; int mesh_table; int mesh_cushion;
    int i;
    int touch[3];
    int dragging; int lastTx; int lastTy; int downX; int downY;
    float camYaw; float camPitch; float camZoom;
    int o_table; int o_cush[4];
    float lw; float ld;

    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 table is horizontal => open a landscape window so no big black bars appear */
    if (sw > sh) { w = sw; h = (sw * 3) / 5; if (h > sh) h = sh; }
    else { h = sh; w = sw; }
    ui_win_open_ex("3D 桌球 Pool 3D", w, h, VML_WIN_ROTATABLE, VML_WIN_NO_GAMEPAD);

    vp = ui_3d_viewport_create(0, 0, w, h, 0);
    if (vp <= 0) { printf("NO_3D_DEVICE=1\n"); return 0; }
    ui_3d_viewport_set_bg(vp, C_BG);
    ui_3d_viewport_set_camera(vp, 0.0f, 1.05f, 0.72f, 0.0f, 0.0f, 0.0f);
    ui_3d_viewport_set_fov(vp, 52.0f, 0.5f, 4000.0f);
    ui_3d_viewport_fit(vp, 13.0f);
    ui_3d_viewport_set_light(vp, 0.40f, 1.0f, 0.30f, 0xFFFFFFFF, 0.46f);

    /* ── 网格:球(单位球)+ 台面 + 库边 ───────────────────────────────── */
    /* ── Meshes: ball (unit sphere) + table + cushions ───────────────────────── */
    gen_sphere(10, 16);
    mesh_ball = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    gen_box(TABLE_W * 0.5f, 0.35f, TABLE_D * 0.5f);
    mesh_table = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    gen_box(TABLE_W * 0.5f + 0.5f, WALL_H * 0.5f, 0.5f);
    mesh_cushion = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    printf("POOL-MESH ball=%d table=%d cush=%d\n", mesh_ball, mesh_table, mesh_cushion);
    if (mesh_ball <= 0 || mesh_table <= 0 || mesh_cushion <= 0) { printf("NO_MESH=1\n"); return 0; }

    /* 台面 */
    /* Table surface */
    o_table = ui_3d_obj_create(vp, mesh_table);
    ui_3d_obj_set_pos(o_table, 0.0f, -0.35f, 0.0f);
    ui_3d_obj_set_color(o_table, C_CLOTH);

    /* 四面库边:长边两条用原网格,短边两条转 90° */
    /* Four cushions: the two long sides use the mesh as-is, the two short sides are rotated 90 degrees */
    lw = TABLE_W * 0.5f + 0.5f;
    ld = TABLE_D * 0.5f + 0.5f;
    for (i = 0; i < 4; i = i + 1) {
        int o;
        o = ui_3d_obj_create(vp, mesh_cushion);
        o_cush[i] = o;
        if (i == 0)      { ui_3d_obj_set_pos(o, 0.0f, WALL_H * 0.5f, -ld); }
        else if (i == 1) { ui_3d_obj_set_pos(o, 0.0f, WALL_H * 0.5f, ld); }
        else if (i == 2) { ui_3d_obj_set_pos(o, -lw, WALL_H * 0.5f, 0.0f); ui_3d_obj_set_rot(o, 0.0f, 90.0f, 0.0f); }
        else             { ui_3d_obj_set_pos(o, lw, WALL_H * 0.5f, 0.0f);  ui_3d_obj_set_rot(o, 0.0f, 90.0f, 0.0f); }
        ui_3d_obj_set_color(o, C_CUSH);
    }

    /* 球:单位球缩放到 BALL_R。**缩放只用这一个数** —— 碰撞判定也用它,两处同源。 */
    /* Balls: scale the unit sphere to BALL_R. **That single number is the only scale** -- the collision test uses it too, so both sites share one source. */
    for (i = 0; i < NBALL; i = i + 1) {
        int o;
        o = ui_3d_obj_create(vp, mesh_ball);
        if (o <= 0) { printf("POOL-OBJ-FAIL=%d\n", i); return 0; }
        ball_obj[i] = o;
        ui_3d_obj_set_scale(o, BALL_R);
    }
    printf("POOL-OBJS=%d\n", NBALL);

    rnd = 20261002;
    reset_game();

    dragging = 0; lastTx = 0; lastTy = 0; downX = 0; downY = 0;
    camYaw = 0.0f; camPitch = 34.0f; camZoom = 1.0f;
    frame = 0;
    lastMs = ui_time_get_ms(); physAcc = 0;   /* 墙钟步进(见下面物理那段) */
                                              /* Wall-clock stepping (see the physics section below) */

    while (1) {
        /* ── 触摸:拖动 = 转相机;**按下-滑动-松开** = 出杆 ─────────────────
        /* ── Touch: drag = orbit the camera; **press-slide-release** = take a shot ──────
         * 同一套触摸要同时表达"转视角"和"打球",所以分两段:
         * One touch dialect has to express both "look around" and "hit the ball", so it is split into two phases:
         *   按住不动 → 这一段只当"瞄准/转视角",松开时若位移够大就出一杆。
         *   holding still -> this phase is only "aiming / orbiting"; on release, a large enough displacement fires a shot.
         * ⚠ 横轴的符号与 `solar3d` 同一条(`ui_3d_viewport_set_orbit` 的 yaw 是球坐标标准式)。
         * ⚠ The horizontal-axis sign follows the same convention as `solar3d` (the yaw of `ui_3d_viewport_set_orbit` is the standard spherical-coordinate form). */
        if (ui_touch_read(0, touch)) {
            if (touch[2]) {
                if (dragging) {
                    int dx; int dy;
                    dx = touch[0] - lastTx;
                    dy = touch[1] - lastTy;
                    camYaw = camYaw - (float)dx * 0.35f;
                    camPitch = camPitch + (float)dy * 0.30f;
                    if (camPitch > 86.0f) camPitch = 86.0f;
                    if (camPitch < 8.0f) camPitch = 8.0f;
                    lastTx = touch[0]; lastTy = touch[1];
                } else {
                    downX = touch[0]; downY = touch[1];
                    lastTx = touch[0]; lastTy = touch[1];
                }
                dragging = 1;
            } else {
                if (dragging) {
                    /* 松开:位移够大就出杆(屏幕方向 → 台面方向:右 = +X,下 = +Z) */
                    /* Release: fire if the displacement is big enough (screen direction -> table direction: right = +X, down = +Z) */
                    float fx = (float)(lastTx - downX);
                    float fy = (float)(lastTy - downY);
                    float len = sqrt(fx * fx + fy * fy);
                    if (len > 24.0f && alive[0]) {
                        float p = len * 0.055f;
                        if (p > 22.0f) p = 22.0f;
                        vx[0] = fx / len * p;
                        vz[0] = fy / len * p;
                    }
                }
                dragging = 0;
            }
        } else {
            dragging = 0;
        }
        if (ui_key_is_down(VML_KEY_SPACE)) reset_game();
        if (ui_key_is_down(VML_KEY_ESCAPE)) break;

        ui_3d_viewport_set_orbit(vp, camYaw, camPitch);
        ui_3d_viewport_set_zoom(vp, camZoom);

        /* 物理**步长不变**(原作者要的可复现性,保留),但**每秒跑几步由墙钟决定** ——
        /* The physics **step size stays the same** (the reproducibility the original author wanted, kept), but **how many steps run per second is decided by the wall clock** --
         * 这才是"帧率不该是玩法的一部分"(用户 2026-10-04)。
         * that is what "frame rate should not be part of the gameplay" means (user, 2026-10-04).
         * 老写法"每帧恰好一步",而主循环是 ui_msg_fetch_wait(40) ⇒ 25 步/秒;
         * The old version did "exactly one step per frame", and the main loop is ui_msg_fetch_wait(40) => 25 steps/sec;
         * 宿主改成跟着显示走之后会变成 60 步/秒 ⇒ 球快 2.4 倍。这里按 40ms 一步补回来。
         * once the host follows the display it would become 60 steps/sec => balls 2.4x faster. Here it is restored to one step per 40 ms. */
        {
            int now2 = ui_time_get_ms();
            physAcc = physAcc + (now2 - lastMs);
            lastMs = now2;
            if (physAcc > 40 * 8) physAcc = 0;   /* 卡太久别一次补一大串 */
                                                 /* stalled too long: do not catch up a whole burst at once */
            while (physAcc >= 40) { physAcc = physAcc - 40; physics(); }
        }

        /* ── HUD ──────────────────────────────────────────────────────── */
        ui_2d_clear(0);
        ui_2d_text(12, 10, "3D 桌球 Pool 3D", -1, 15, VML_ANCHOR_LEFT);
        {
            char buf[64];
            sprintf(buf, "进球 %d   剩 %d", potted, alive_count);
            ui_2d_text(12, 30, buf, -8355712, 12, VML_ANCHOR_LEFT);
        }
        ui_2d_text(12, 48, "按住滑动松手出杆(往想打的方向)", -8355712, 11, VML_ANCHOR_LEFT);

        ui_2d_present();
        ui_msg_fetch_wait(40);       /* 只是「等输入最多等一会儿」(2026-10-04 起**上屏节奏由宿主管**:`ui_2d_present()` 之后宿主按显示预算走,并把这个超时夹到「距下一帧还有多久」) */
                                     /* Only "wait for input at most a little while" (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") */

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

    printf("POOL-DONE frames=%d alive=%d potted=%d\n", frame, alive_count, potted);
    ui_3d_mesh_free(mesh_ball);
    ui_3d_mesh_free(mesh_table);
    ui_3d_mesh_free(mesh_cushion);
    ui_3d_viewport_free(vp);
    return 0;
}