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

c/3d/air3d.c · 25353 字节 · 下载原文

/* air3d.c —— **3D 空战**:第四个"用真游戏验接口"的探针。
 * air3d.c — **3D air combat**: the fourth "write a real game to test the API" probe.
 *
 * ## 它压的是前三个都没有的那一环:**自由三维朝向**
 * ## What it stresses that the previous three did not: **free 3D orientation**
 *
 *   · `snake3d` 只改位置;`pool3d` 只改位置;`race3d` 只绕 **Y** 转(航向)。
 *   · `snake3d` and `pool3d` change position only; `race3d` rotates about **Y** only (heading).
 *     空战要 **yaw + pitch 两个轴**同时生效 ⇒ 这是 `ui_3d_obj_set_rot(o, ax, ay, az)` 的
 *     Air combat needs **both yaw and pitch** active at once => this is where the **multi-axis** use of
 *     **多轴**用法第一次被真游戏压到。
 *     `ui_3d_obj_set_rot(o, ax, ay, az)` gets stressed by a real game for the first time.
 *   · 相机要跟着一个**会俯仰的**载具走 —— 相机的 eye 由载具的**前向量**推出来,
 *   · The camera must follow a vehicle that **pitches** -- the camera eye is derived from the vehicle's **forward vector**,
 *     而不只是"在它后面"。上一轮 `race3d` 只压了水平跟随。
 *     not merely "behind it". The previous `race3d` only stressed horizontal follow.
 *   · 弹幕:一个**对象池**(子弹反复生灭),外加命中判定的三维距离。
 *   · Barrage: an **object pool** (bullets spawning and dying repeatedly), plus 3D distance for hit tests.
 *
 * ## ⚠ 本轮实测钉下的两条接口事实
 * ## ⚠ Two API facts nailed down by measurement this round
 *
 * ① **旋转是 `R = Rz·Ry·Rx`(度)**,模型机头朝 **+Z** ⇒ 飞机用
 * (1) **Rotation is `R = Rz*Ry*Rx` (degrees)**; model nose faces **+Z** => for the plane use
 *    `ui_3d_obj_set_rot(o, -pitch_deg, yaw_deg, 0)`(**X 取负**:正 pitch 表示抬头,
 *    `ui_3d_obj_set_rot(o, -pitch_deg, yaw_deg, 0)` (**negate X**: positive pitch means nose-up,
 *    而绕 X 的正向旋转把 +Z 转向 **-Y**,即低头)。
 *    while a positive rotation about X turns +Z toward **-Y**, i.e. nose-down).
 * ② **前向量 = (cos(pitch)·sin(yaw), sin(pitch), cos(pitch)·cos(yaw))** ——
 * (2) **Forward = (cos(pitch)*sin(yaw), sin(pitch), cos(pitch)*cos(yaw))** --
 *    位移与相机都由它推,**只留这一个真源**("同一处布局两套算法必然漂")。
 *    both motion and camera derive from it; **keep this as the single source of truth** ("two algorithms for one layout inevitably drift").
 *
 * ## 玩法
 * ## Gameplay
 *
 * 飞机自己往前飞:←/→ 转向、↑/↓ 抬头低头,点屏幕 / 空格开火。
 * The plane flies forward on its own: left/right turns, up/down pitches; tap the screen / press space to fire.
 * 打中敌机得分,敌机会在别处重生。Esc 退出。
 * Hitting an enemy scores; enemies respawn elsewhere. Esc quits.
 *
 * ## 判据
 * ## Criteria
 *
 * 机械化验证:`RUN_FRAMES` > 0 时跑满帧数并打印
 * Mechanical check: when `RUN_FRAMES` > 0 it runs the full frame count and prints
 * `AIR-DONE frames=… score=… shots=…`(行首锚定即可断)。
 * `AIR-DONE frames=... score=... shots=...` (anchored at line start, so a grep can settle it).
 */
#include <stdio.h>
#include <math.h>
#include <vml_3d.h>
#include <vml_ui.h>

#define RUN_FRAMES 0

#define N_ENEMY   6
#define N_BULLET  24
#define SPEED     34.0f         /* 飞机前进速度(世界单位/秒) */
                                /* Plane forward speed (world units/sec) */
#define BULLET_SP 90.0f
#define FIRE_CD   8             /* 开火冷却(帧) */
                                /* Fire cooldown (frames) */
#define HIT_R     3.2f          /* 命中判定半径 */
                                /* Hit-test radius */

#define C_BG      0xFF0C1A2E
#define C_GROUND  0xFF24522F
#define C_PLAYER  0xFF58B6FF
#define C_ENEMY   0xFFE05A5A
#define C_BULLET  0xFFFFE066
#define C_MOUNT   0xFF3A5A46

/* ══ 网格装配(与前三个 3D 例子同一套写法)══ */
/* Mesh assembly (same style as the previous three 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]; }

/* 长方体(以原点为中心)—— 机身/弹丸/山都用它。 */
/* Box (centered at the origin) -- used for the fuselage / bullets / mountains. */
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 hu; float hw; float cx; float cy; float cz;
            hu = ((q == 0 || q == 3) ? 1.0f : -1.0f) *
                 ((axisU == 0) ? hx : ((axisU == 1) ? hy : hz));
            hw = ((q == 0 || q == 1) ? 1.0f : -1.0f) *
                 ((axisW == 0) ? hx : ((axisW == 1) ? hy : hz));
            if (f == 0)      { cx = hx;  cy = hu; cz = hw; }
            else if (f == 1) { cx = -hx; cy = hu; cz = hw; }
            else if (f == 2) { cx = hu; cy = hy;  cz = hw; }
            else if (f == 3) { cx = hu; cy = -hy; cz = hw; }
            else if (f == 4) { cx = hu; cy = hw;  cz = hz; }
            else             { cx = hu; cy = hw;  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;
}

/* 大平板(地面)—— 给一个地平线,飞行姿态才有参照物。 */
/* Large slab (the ground) -- provides a horizon so the flight attitude has a reference. */
void gen_plane(float half, int segs) {
    int i; int j; int k; int v;
    v = 0;
    for (i = 0; i <= segs; i = i + 1) {
        for (j = 0; j <= segs; j = j + 1) {
            float x = ((float)i / segs - 0.5f) * 2.0f * half;
            float z = ((float)j / segs - 0.5f) * 2.0f * half;
            mb_vertex(v, x, 0.0f, z, 0.0f, 1.0f, 0.0f);
            v = v + 1;
        }
    }
    g_vn = v;
    k = 0;
    for (i = 0; i < segs; 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;
}

/* ══ 状态 ═══════════════════════════════════════════════════════════════ */
/* == State == */
int vp;
int o_player;
int o_enemy[N_ENEMY];
int o_bullet[N_BULLET];

float px; float py; float pz;            /* 玩家位置 */
                                         /* Player position */
float pyaw; float ppitch;                /* 朝向(度) */
                                         /* Heading (degrees) */
int score; int shots; int fire_cd; int frame;

float ex[N_ENEMY]; float ey[N_ENEMY]; float ez[N_ENEMY];
float ea[N_ENEMY];                        /* 敌机的绕行相位 */
                                          /* Enemy orbit phase */
int e_alive[N_ENEMY];

float bxp[N_BULLET]; float byp[N_BULLET]; float bzp[N_BULLET];
float bvx[N_BULLET]; float bvy[N_BULLET]; float bvz[N_BULLET];
int b_alive[N_BULLET];

/* 前向量:**唯一真源**(位移与相机都由它推) */
/* Forward vector: **the single source of truth** (both motion and camera derive from it) */
void forward_vec(float yaw_deg, float pitch_deg, float* out) {
    float y = yaw_deg * 0.017453292f;
    float p = pitch_deg * 0.017453292f;
    out[0] = cos(p) * sin(y);
    out[1] = sin(p);
    out[2] = cos(p) * cos(y);
}

void reset_game(void) {
    int i;
    px = 0.0f; py = 26.0f; pz = 0.0f;
    pyaw = 0.0f; ppitch = 0.0f;
    score = 0; shots = 0; fire_cd = 0;
    for (i = 0; i < N_ENEMY; i = i + 1) {
        ea[i] = 6.28318531f * i / N_ENEMY;
        ey[i] = 16.0f + (float)(i % 3) * 8.0f;
        e_alive[i] = 1;
    }
    for (i = 0; i < N_BULLET; i = i + 1) {
        b_alive[i] = 0;
        ui_3d_obj_set_visible(o_bullet[i], 0);
    }
}

int main(void) {
    int sw; int sh;
    int mesh_plane; int mesh_player; int mesh_bullet; int mesh_mount;
    int i;
    int touch[3];
    int dragging;
    float fwd[3];
    float camY;
    float dt;
    int lastMs; int nowMs;   /* 墙钟步长用(见主循环顶部那段的说明) */
                             /* For the wall-clock step (see the note at the top of the main loop) */

    ui_win_open_ex("3D 空战 Air Combat 3D", 0, 0, VML_WIN_ROTATABLE, VML_WIN_NO_GAMEPAD);
    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;

    vp = ui_3d_viewport_create(0, 0, sw, sh, 0);
    if (vp <= 0) { printf("NO_3D_DEVICE=1\n"); return 0; }
    ui_3d_viewport_set_bg(vp, C_BG);
    ui_3d_viewport_set_fov(vp, 65.0f, 0.4f, 4000.0f);
    /* ⚠ **不调 `ui_3d_viewport_fit`**:它每帧按半径重算距离,会把逐帧给的相机坐标覆盖掉。 */
    /* ⚠ **Do not call `ui_3d_viewport_fit`**: it recomputes the distance from radius every frame and would overwrite the per-frame camera position. */
    ui_3d_viewport_set_light(vp, 0.30f, 1.0f, 0.45f, 0xFFFFFFFF, 0.55f);

    /* ── 网格 ─────────────────────────────────────────────────────────── */
    /* ── Mesh ─────────────────────────────────────────────────────────── */
    gen_plane(600.0f, 8);
    mesh_plane = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    gen_box(1.1f, 0.35f, 3.0f);            /* 机身:**长轴 Z**(机头朝 +Z) */
                                           /* Fuselage: **long axis Z** (nose faces +Z) */
    mesh_player = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    gen_box(0.3f, 0.3f, 0.9f);
    mesh_bullet = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    gen_box(1.0f, 6.0f, 1.0f);             /* 山:细高的柱,给地面纵深 */
                                           /* Mountain: a tall thin pillar, gives the ground depth */
    mesh_mount = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
    printf("AIR-MESH plane=%d player=%d bullet=%d mount=%d\n",
           mesh_plane, mesh_player, mesh_bullet, mesh_mount);
    if (mesh_plane <= 0 || mesh_player <= 0 || mesh_bullet <= 0 || mesh_mount <= 0)
    { printf("NO_MESH=1\n"); return 0; }

    /* 地面 + 几座山(静态,给地平线和速度参照) */
    /* Ground + a few mountains (static, provides a horizon and a speed reference) */
    {
        int o;
        o = ui_3d_obj_create(vp, mesh_plane);
        ui_3d_obj_set_pos(o, 0.0f, 0.0f, 0.0f);
        ui_3d_obj_set_color(o, C_GROUND);
        for (i = 0; i < 14; i = i + 1) {
            float th = 6.28318531f * i / 14.0f;
            int m;
            float hh;
            m = ui_3d_obj_create(vp, mesh_mount);
            hh = 6.0f + (float)((i * 7) % 5) * 3.0f;
            ui_3d_obj_set_scale(m, hh / 6.0f);
            ui_3d_obj_set_pos(m, cos(th) * 120.0f, hh, sin(th) * 120.0f);
            ui_3d_obj_set_color(m, C_MOUNT);
        }
    }

    o_player = ui_3d_obj_create(vp, mesh_player);
    ui_3d_obj_set_color(o_player, C_PLAYER);

    for (i = 0; i < N_ENEMY; i = i + 1) {
        o_enemy[i] = ui_3d_obj_create(vp, mesh_player);
        ui_3d_obj_set_scale(o_enemy[i], 1.0f);
        ui_3d_obj_set_color(o_enemy[i], C_ENEMY);
    }
    for (i = 0; i < N_BULLET; i = i + 1) {
        o_bullet[i] = ui_3d_obj_create(vp, mesh_bullet);
        ui_3d_obj_set_color(o_bullet[i], C_BULLET);
        ui_3d_obj_set_visible(o_bullet[i], 0);
    }
    printf("AIR-OBJS=%d\n", 15 + 1 + N_ENEMY + N_BULLET);

    reset_game();

    dragging = 0;
    camY = 0.0f;
    frame = 0;
    /* 步长**按墙钟算**(用户 2026-10-04 定的口径:**帧率不该是玩法的一部分**)。
     * Step **by wall clock** (the user's 2026-10-04 ruling: **frame rate must not be part of the gameplay**).
     * 老写法把它写死成 0.04(= 1/25 秒,配下方的 ui_msg_fetch_wait(25)),
     * The old style hard-coded it to 0.04 (= 1/25 sec, paired with ui_msg_fetch_wait(25) below),
     * 而宿主现在一帧跟着显示走 ⇒ 同一个游戏会快 2.4 倍。
     * while the host now paces a frame to the display => the same game runs 2.4x too fast. */
    lastMs = ui_time_get_ms();
    dt = 0.04f;

    while (1) {
        float yawRate; float pitchRate;
        int firing;

        /* 这一帧的步长:距上一帧的**真实毫秒数**。夹上限是为了卡顿/切页面回来时
         * This frame's step: the **real milliseconds** since the last frame. The upper clamp keeps a stutter or a return to the page
         * 不要一次跳出去;下限是为了同一毫秒内连跑两帧时不出现 dt=0(静止)。
         * from jumping in one go; the lower clamp keeps two frames in the same millisecond from producing dt=0 (frozen). */
        nowMs = ui_time_get_ms();
        dt = (nowMs - lastMs) * 0.001f;
        if (dt > 0.1f) dt = 0.1f;
        if (dt <= 0.0f) dt = 0.0001f;
        lastMs = nowMs;

        /* ── 输入 ──────────────────────────────────────────────────────── */
        /* ── Input ─────────────────────────────────────────────────────────────────── */
        yawRate = 0.0f; pitchRate = 0.0f;
        if (ui_key_is_down(VML_KEY_LEFT))  yawRate = -1.0f;
        if (ui_key_is_down(VML_KEY_RIGHT)) yawRate = 1.0f;
        if (ui_key_is_down(VML_KEY_UP))    pitchRate = 1.0f;
        if (ui_key_is_down(VML_KEY_DOWN))  pitchRate = -1.0f;
        if (ui_key_is_down(65)) yawRate = -1.0f;      /* 'A' */
        if (ui_key_is_down(68)) yawRate = 1.0f;       /* 'D' */
        if (ui_key_is_down(87)) pitchRate = 1.0f;     /* 'W' */
        if (ui_key_is_down(83)) pitchRate = -1.0f;    /* 'S' */
        if (ui_key_is_down(VML_KEY_ESCAPE)) break;

        firing = 0;
        if (ui_key_is_down(VML_KEY_SPACE)) firing = 1;
        if (ui_touch_read(0, touch)) {
            /* 触摸:屏幕左右半边转向、上下半边俯仰;**按下即开火**(冷却由 fire_cd 管) */
            /* Touch: left/right half turns, top/bottom half pitches; **press fires** (cooldown handled by fire_cd) */
            if (touch[2]) {
                if (touch[0] < sw / 2) yawRate = -1.0f; else yawRate = 1.0f;
                if (touch[1] < sh / 2) pitchRate = 1.0f; else pitchRate = -1.0f;
                firing = 1;
                dragging = 1;
            } else {
                dragging = 0;
            }
        }

        /* ── 玩家:转向 + 前进(前向量只算一次,位移与相机共用)─────── */
        /* ── Player: turn + advance (forward vector computed once, shared by motion and camera) ─────── */
        pyaw = pyaw + yawRate * 62.0f * dt;
        if (pyaw > 180.0f) pyaw = pyaw - 360.0f;
        if (pyaw < -180.0f) pyaw = pyaw + 360.0f;
        ppitch = ppitch + pitchRate * 48.0f * dt;
        if (ppitch > 62.0f) ppitch = 62.0f;
        if (ppitch < -62.0f) ppitch = -62.0f;

        forward_vec(pyaw, ppitch, fwd);
        px = px + fwd[0] * SPEED * dt;
        py = py + fwd[1] * SPEED * dt;
        pz = pz + fwd[2] * SPEED * dt;
        /* 别钻到地下、也别飞出天外(低空掠过地面最有速度感,但要兜住) */
        /* Don't burrow underground or fly off the top (skimming the ground feels fastest, but keep it bounded) */
        if (py < 4.0f) { py = 4.0f; if (ppitch < 0.0f) ppitch = 0.0f; }
        if (py > 120.0f) { py = 120.0f; if (ppitch > 0.0f) ppitch = 0.0f; }

        /* ⚠ X 取负:正 ppitch = 抬头,而绕 X 的正向旋转把 +Z 转向 -Y(低头) */
        /* ⚠ Negate X: positive ppitch = nose-up, while a positive rotation about X turns +Z toward -Y (nose-down) */
        ui_3d_obj_set_pos(o_player, px, py, pz);
        ui_3d_obj_set_rot(o_player, -ppitch, pyaw, 0.0f);

        /* ── 开火:对象池里找一个空闲的 ────────────────────────────────── */
        /* ── Fire: find a free slot in the object pool ────────────────────────────────── */
        if (fire_cd > 0) fire_cd = fire_cd - 1;
        if (firing && fire_cd == 0) {
            int b;
            for (b = 0; b < N_BULLET; b = b + 1) {
                if (b_alive[b] == 0) break;
            }
            if (b < N_BULLET) {
                b_alive[b] = 1;
                bxp[b] = px + fwd[0] * 3.5f;
                byp[b] = py + fwd[1] * 3.5f;
                bzp[b] = pz + fwd[2] * 3.5f;
                bvx[b] = fwd[0] * BULLET_SP;
                bvy[b] = fwd[1] * BULLET_SP;
                bvz[b] = fwd[2] * BULLET_SP;
                ui_3d_obj_set_pos(o_bullet[b], bxp[b], byp[b], bzp[b]);
                ui_3d_obj_set_visible(o_bullet[b], 1);
                shots = shots + 1;
                fire_cd = FIRE_CD;
            }
        }

        /* ── 子弹:前进 + 到期回收(**对象池**:不建不删,只切可见性)──── */
        /* ── Bullets: advance + recycle on expiry (**object pool**: never create/delete, just toggle visibility) ──── */
        for (i = 0; i < N_BULLET; i = i + 1) {
            if (b_alive[i] == 0) continue;
            bxp[i] = bxp[i] + bvx[i] * dt;
            byp[i] = byp[i] + bvy[i] * dt;
            bzp[i] = bzp[i] + bvz[i] * dt;
            if (byp[i] < 0.5f || byp[i] > 400.0f) { b_alive[i] = 0; ui_3d_obj_set_visible(o_bullet[i], 0); continue; }
            /* 飞太远也收回(不然池子会被"打向天空"的弹占满) */
            /* Also recycle when they fly too far (else the pool fills up with shots fired "into the sky") */
            {
                float ddx = bxp[i] - px; float ddy = byp[i] - py; float ddz = bzp[i] - pz;
                if (ddx * ddx + ddy * ddy + ddz * ddz > 90000.0f)
                { b_alive[i] = 0; ui_3d_obj_set_visible(o_bullet[i], 0); continue; }
            }
            ui_3d_obj_set_pos(o_bullet[i], bxp[i], byp[i], bzp[i]);
        }

        /* ── 敌机:绕圈巡逻(声明式位置由这里算,不用 `ui_3d_obj_set_orbit`——
        /* ── Enemies: circle patrol (declarative position computed here, not via `ui_3d_obj_set_orbit` --
         *   因为还要按朝向转机身,而 `obj_orbit` 只管位置)───────────────
         *   because we also rotate the body to face the heading, while `obj_orbit` only handles position) ── */
        for (i = 0; i < N_ENEMY; i = i + 1) {
            float r;
            ea[i] = ea[i] + 0.35f * dt;
            r = 46.0f + (float)(i % 3) * 15.0f;
            ex[i] = cos(ea[i]) * r;
            ez[i] = sin(ea[i]) * r;
            e_alive[i] = 1;
            ui_3d_obj_set_pos(o_enemy[i], ex[i], ey[i], ez[i]);
            /* 机头朝切线:atan2 求航向(度)。`obj_orbit` 做不到"朝向也转" */
            /* Nose along the tangent: atan2 gives the heading (degrees). `obj_orbit` cannot "turn the facing too" */
            ui_3d_obj_set_rot(o_enemy[i], 0.0f, -(ea[i] * 57.29577951f) + 90.0f, 0.0f);
            ui_3d_obj_set_visible(o_enemy[i], 1);
        }

        /* ── 命中:子弹 vs 敌机(三维距离),打中就收弹 + 得分 ─────────── */
        /* ── Hit: bullet vs enemy (3D distance); a hit recycles the bullet + scores ─────────── */
        for (i = 0; i < N_BULLET; i = i + 1) {
            int k;
            if (b_alive[i] == 0) continue;
            for (k = 0; k < N_ENEMY; k = k + 1) {
                float ddx = bxp[i] - ex[k];
                float ddy = byp[i] - ey[k];
                float ddz = bzp[i] - ez[k];
                if (ddx * ddx + ddy * ddy + ddz * ddz < HIT_R * HIT_R) {
                    b_alive[i] = 0;
                    ui_3d_obj_set_visible(o_bullet[i], 0);
                    score = score + 1;
                    /* 敌机换个高度/半径重生(不复用同一处,免得一炮扫一排) */
                    /* Enemy respawns at a different height/radius (not the same spot, so one shot doesn't wipe a row) */
                    ey[k] = 14.0f + (float)((score + k) % 4) * 9.0f;
                    ea[k] = ea[k] + 2.1f;
                    break;
                }
            }
        }

        /* ── 跟随相机:eye 由**前向量**推(不是只在后面平移)───────────── */
        /* ── Follow camera: eye derived from the **forward vector** (not just translated behind) ───────────── */
        {
            float ex2 = px - fwd[0] * 15.0f;
            float ey2 = py - fwd[1] * 15.0f + 4.6f;
            float ez2 = pz - fwd[2] * 15.0f;
            float tx2 = px + fwd[0] * 26.0f;
            float ty2 = py + fwd[1] * 26.0f;
            float tz2 = pz + fwd[2] * 26.0f;
            if (ey2 < 2.0f) ey2 = 2.0f;
            ui_3d_viewport_set_camera(vp, ex2, ey2, ez2, tx2, ty2, tz2);
        }

        /* ── HUD ──────────────────────────────────────────────────────── */
        ui_2d_clear(0);
        ui_2d_text(12, 10, "3D 空战 Air Combat 3D", -1, 15, VML_ANCHOR_LEFT);
        {
            char buf[80];
            sprintf(buf, "得分 %d   高度 %.0f   航向 %.0f   弹药 %d", score, py, pyaw, shots);
            ui_2d_text(12, 30, buf, -8355712, 12, VML_ANCHOR_LEFT);
        }
        ui_2d_text(12, 48, "←/→ 转向   ↑/↓ 俯仰   空格/点屏 开火", -8355712, 11, VML_ANCHOR_LEFT);
        /* 准星:屏幕正中(相机永远看向机头前方远点 ⇒ 准星就在中心) */
        /* Crosshair: dead center of the screen (the camera always looks at a far point ahead of the nose => it lands in the middle) */
        {
            int cx; int cy;
            cx = sw / 2; cy = sh / 2;
            ui_2d_text(cx - 6, cy - 10, "+", -65536, 18, VML_ANCHOR_LEFT);
        }

        ui_2d_present();
        ui_msg_fetch_wait(25);        /* 只是「等输入最多等一会儿」(2026-10-04 起**上屏节奏由宿主管**:`ui_2d_present()` 之后宿主按显示预算走,并把这个超时夹到「距下一帧还有多久」) */
                                      /* Just "wait for input, at most a while" (since 2026-10-04 **the host owns on-screen pacing**: after `ui_2d_present()` the host follows 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("AIR-DONE frames=%d score=%d shots=%d\n", frame, score, shots);
    ui_3d_mesh_free(mesh_plane);
    ui_3d_mesh_free(mesh_player);
    ui_3d_mesh_free(mesh_bullet);
    ui_3d_mesh_free(mesh_mount);
    ui_3d_viewport_free(vp);
    return 0;
}