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

c/3d/foxcatch.c · 20488 字节 · 下载原文

/* foxcatch.c —— 「**狐狸接果子**」:一个把 .glb 用起来的 3D 游戏。
 * foxcatch.c - "**Fox Catches Fruit**": a 3D game that actually uses a .glb.
 *
 * 用户口径(2026-10-08):「接口实现完成后,先做个 glb 查看器,然后再做个游戏把 glb 用上」。
 * User's call (2026-10-08): "once the interfaces are done, first make a glb viewer, then make a game that uses glb".
 *
 * ══ 玩法 ══════════════════════════════════════════════════════════════════
 * 果子从远处朝你飞过来,拖动(或 ←/→)让狐狸左右移动去接。接到 +1 分,漏一个掉一条命,
 * 三条命掉完结束。**速度会越来越快**,接到 10 个之后每接一个再快一点。
 *
 *   手机:命令行页敲 `vml run examples/c/3d/foxcatch.c`
 *   桌面:`dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll Examples/c/3d/foxcatch.c --screen 393x757`
 *
 * ══ 这个程序演示了什么 ═════════════════════════════════════════════════════
 * · **glb 当主角**:`ui_3d_gltf_load` 装一份 .glb,`ui_3d_gltf_root` 拿到那个**纯变换根物体**,
 *   之后**只动根**(`set_pos`)就把整只狐狸挪走了 —— 不用去动它那 28 个子物体。
 * · **自动取景/自动落地**:模型多大是**数据**(`ui_3d_gltf_bounds` 报的半径),所以缩放与
 *   离地高度都是从它算出来的,**没有一个"狐狸专用"的魔数**(换一个模型照样站得住)。
 * · **步进按墙钟 dt**(`ui_time_get_ms`),**不把"每帧"当时间单位** —— 这是本仓铁律:
 *   帧率不该是玩法的一部分(同一个程序在 30fps 的手机和 60fps 的桌面上必须一样快)。
 * · 平面投影阴影(`#642`)给狐狸和果子"落地"的观感。
 *
 * ⚠ **它不用蒙皮**:本仓的 glTF 装载器**还没解析 `skins`** ⇒ 狐狸是**绑定姿势**(不会动腿)。
 *   蒙皮接口(`#656`–`#660`)已经能用,但要走那条路得程序自己算蒙皮矩阵 ——
 *   这个游戏不碰那一套(它演示的是"glb 能当主角用")。
 * ⚠ **它也不做**碰撞检测库:判定就是"影子中心距离 < 半径",两行算式。 */

#include <vml_3d.h>
#include <vml_ui.h>
#include <math.h>
#include <stdio.h>

/* ══ 规则常量(**全部按世界单位/秒**,与帧率无关)════════════════════════════ */
#define FIELD_X     9.0f        /* 狐狸能走到 ±这个范围(也是果子的落点范围) */
#define CATCH_Z     1.8f        /* 果子与狐狸的 z 差小于它、且 x 差小于 CATCH_X 就算接到 */
#define CATCH_X     2.0f
#define MISS_Z      7.0f        /* 果子跑到这个 z 还没接住 = 漏了 */
#define SPAWN_Z     (0.0f - 46.0f)
#define BASE_SPEED  11.0f       /* 果子初始速度(世界单位/秒) */
#define SPEED_STEP  0.55f       /* 每接一个加多少 */
#define START_LIVES 3
#define MAX_FRUIT   6           /* 同屏最多几个果子(池子大小) */

/* 果子颜色(自发光,暗背景里很显眼) */
static const int FRUIT_C[] = { 0xFFFF5A3C, 0xFFFFC23C, 0xFF7CE04A, 0xFF5AC8FF };

/* ══ 网格:一个单位球(果子用它,缩放到想要的大小)═══════════════════════════
 * 顶点 = 8 个 float32(pos3 + nrm3 + uv2),索引紧随其后 —— 与 `ui_3d_mesh_create` 的约定一致。 */
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; }

/* 单位球:法线就是归一化后的位置。(rings=10, segs=14 ⇒ 176 顶点 / 840 索引,够圆了) */
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; float sp; float cp;
        phi = 3.14159265f * (float)i / (float)rings;
        sp = (float)sin((double)phi);
        cp = (float)cos((double)phi);
        for (j = 0; j <= segs; j = j + 1) {
            float th; float nx; float ny; float nz;
            th = 6.28318531f * (float)j / (float)segs;
            nx = sp * (float)cos((double)th);
            ny = cp;
            nz = sp * (float)sin((double)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; int b;
            a = i * (segs + 1) + j;
            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;
}

/* 一块地:y = 0 平面上的方片(法线朝 +Y),木纹色 */
void gen_ground(float h) {
    mb_vertex(0, 0 - h, 0.0f, 0 - h, 0.0f, 1.0f, 0.0f);
    mb_vertex(1, h, 0.0f, 0 - h, 0.0f, 1.0f, 0.0f);
    mb_vertex(2, h, 0.0f, h, 0.0f, 1.0f, 0.0f);
    mb_vertex(3, 0 - h, 0.0f, h, 0.0f, 1.0f, 0.0f);
    g_vn = 4;
    mb_index(0, 0); mb_index(1, 1); mb_index(2, 2);
    mb_index(3, 0); mb_index(4, 2); mb_index(5, 3);
    g_in = 6;
}

/* ══ 候选模型 ══════════════════════════════════════════════════════════════
 * ⚠⚠ **相对路径按「程序的所在目录」解析**(2026-10-08 实测 —— **不是按 cwd**):本程序在
 *   `<工作区>/examples/c/3d/`,所以 `models/Fox.glb` 才命中;写成
 *   `examples/c/3d/models/Fox.glb` 会被拼成 `…/c/3d/examples/c/3d/models/Fox.glb`。
 *   手机与桌面是**同一套解析**(同一份共享层代码)⇒ 一条就够,后面是兜底。 */
static const char* MODELS[] = {
    /* ⚠ 2026-10-09:`Fox.glb` **已从仓库与包里删掉**(它自带 `asset.copyright` = CC-BY 4.0,要署名,
     *   而仓里当时记成 CC0 ⇒ 无署名随包分发)。下面两段先找 Fox(用户自己丢一份进来仍能玩),
     *   再兜底到**随包的 CC0 模型** —— 换个模型照样能玩,只是主角不再是狐狸。
     *   随包了哪些、各自什么许可,见 `models/CREDITS.md`。 */
    "models/Fox.glb",
    "models/fox.glb",
    "Fox.glb",
    "examples/c/3d/models/Fox.glb",
    "third_party/vml/Examples/c/3d/models/Fox.glb",
    /* 兜底:随包的 CC0 模型(`vml_lib.zip` 里那几份)。
     * ⚠ 2026-10-10 两轮换过:大文件(Avocado 8 MB 等)删了换小的;随后 `Duck`(许可实为
     *   **SCEA** 而非 CC0)也出局 ⇒ 这里用 `AnimatedColorsCube` / `InterpolationTest`。
     *   逐份许可与理由见 `models/CREDITS.md`。 */
    "models/AnimatedColorsCube.glb",
    "examples/c/3d/models/AnimatedColorsCube.glb",
    "models/InterpolationTest.glb",
    "examples/c/3d/models/InterpolationTest.glb",
};
#define NMODEL ((int)(sizeof(MODELS) / sizeof(MODELS[0])))

int main(void) {
    int sw; int sh; int vp; int i;
    int scene; int root; int world; int ground; int m_sph; int m_gnd;
    float sc; float rootY;
    int   fxIdx[MAX_FRUIT];      /* 果子物体句柄(池子,建一次反复用) */
    float fxX[MAX_FRUIT];
    float fxZ[MAX_FRUIT];
    int   fxOn[MAX_FRUIT];

    float foxX; float speed; int score; int lives; int state; int best;
    int clockMs; int lastMs;
    int touch[4]; int dragging; int lastTx; int tapped;
    char buf[160];

    ui_win_open_ex("狐狸接果子", 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 = 360;
    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, 0xFF0E1420);
    ui_3d_viewport_set_light(vp, 0.35f, 0.90f, 0.45f, 0xFFFFFFFF, 0.34f);
    ui_3d_viewport_set_light_at(vp, 1, -0.5f, 0.35f, -0.6f, 0x50A0C8FF, 0.0f);
    /* 平面投影阴影(`#642`):狐狸与果子在 y = 0 上各留一块影子 ⇒ "站得住"的观感。
     * ⚠ 光**不能太水平**(|y| 太小影子会拉得极长、穿过整个场地)—— 上面那盏主光 y = 0.90。 */
    ui_3d_viewport_set_shadow(vp, 1);

    /* ── 家具:地 + 球的网格(建一次)── */
    /* ⚠ 地面要**盖过果子出生点**(`SPAWN_Z` = −46):只铺到 −26 的话,果子在落地之前
     *   会先"悬在虚空里"飘一段(画面上是暗背景里凭空冒出一个球),看着像模型穿帮。 */
    gen_ground(48.0f);
    m_gnd = ui_3d_mesh_create((int)&MB[0], g_vn, g_in);
    gen_sphere(10, 14);
    m_sph = ui_3d_mesh_create((int)&MB[0], g_vn, g_in);
    if (m_gnd <= 0 || m_sph <= 0) { printf("NO_MESH=1\n"); return 0; }
    ground = ui_3d_obj_create(vp, m_gnd);
    ui_3d_obj_set_color(ground, 0xFF3A4A32);

    /* ── 狐狸:装 glb,拿**纯变换根**当主角 ──────────────────────────────────
     * ⚠ 根是 `ui_3d_gltf_object_at(scene, 0)`(一个没有网格的变换物体),**只动它**就整只狐狸跟着走。
     *   这比"遍历 28 个子物体逐个 set_pos"省 28 倍 syscall —— 也正是"整机摆位"那个入口的用法。 */
    scene = 0;
    for (i = 0; i < NMODEL; i = i + 1) {
        scene = ui_3d_gltf_load(vp, (char*)MODELS[i]);
        if (scene > 0) break;
    }
    if (scene <= 0) {
        /* ⚠ **没有模型要如实说**(这是个演示"glb 当主角"的程序,没模型就没意义),
         *   但**不能白屏着崩**:下面照常画地、提示用户把模型放上去。 */
        printf("NO_MODEL=1\n");
        ui_2d_clear(0);
        ui_2d_text(14, 14, "没找到可用的 .glb —— 把模型放到本程序旁边的 models/ 里(见 models/CREDITS.md)", -65536, 13,
                   VML_ANCHOR_LEFT);
        ui_2d_present();
        ui_msg_fetch_wait(4000);
        return 0;
    }
    root = ui_3d_gltf_root(scene);

    /* ── 缩放与离地高度**都从包围球算**(换模型照样站得住)──────────────────
     * 目标:整只狐狸约 **WANT_R** 那么大,底刚好落在 y = 0 上。
     *   缩放 s = WANT_R / r
     *   包围球中心在 (cx,cy,cz),底面在 cy − r ⇒ 想让底面落到 0,根要抬 **s·(r − cy)** */
    {
        float bnd[4]; float r; float cy; float WANT_R;
        bnd[0] = 0.0f; bnd[1] = 0.0f; bnd[2] = 0.0f; bnd[3] = 0.0f;
        r = 1.0f; cy = 0.0f;
        if (ui_3d_gltf_bounds(scene, (int)&bnd[0]) == 0 && bnd[3] > 0.0f) {
            r = bnd[3]; cy = bnd[1];
        }
        WANT_R = 2.6f;
        sc = WANT_R / r;
        rootY = sc * (r - cy);
    }
    ui_3d_obj_set_scale(root, sc);
    ui_3d_obj_set_rot(root, 0.0f, 0.0f, 0.0f);
    /* 面向镜头(狐狸模型的朝向是 +z,镜头在 +z 侧 ⇒ 转 180° 让它面对玩家) */
    ui_3d_obj_set_rot(root, 0.0f, 180.0f, 0.0f);

    /* 相机:从斜上方俯视一小片场地;注视点抬到狐狸胸口,画面才舒服。
     * ⚠⚠ **不要在这里调 `ui_3d_viewport_fit`**:它是"相机距离"的**唯一真源**(按包围球与
     *   宽高比反推距离)⇒ 调了它,上面那个 `set_camera` 的眼睛位置**当场被覆盖**
     *   (实测:画面变成一个巨大的地面 + 一个指甲盖大的狐狸,看着像"模型太小")。
     *   `set_orbit` 同理(它给的是角度,距离仍由 `fit` 定)——**固定机位就用 `set_camera` 一个**。 */
    /* ⚠ **注视点推到狐狸前面**(z = −7):这样狐狸(z = 0,离相机更近)落在**画面下半**,
     *   果子从远处(z = −46)飞过来落在**上半** —— 就是"跑酷"那种一眼看懂谁往谁走的构图。
     *   注视点放在狐狸身上(z = 0)会让狐狸正好压在屏幕正中、也就是结束面板的位置。 */
    ui_3d_viewport_set_camera(vp, 0.0f, 9.0f, 16.0f, 0.0f, 1.6f, 0.0f - 7.0f);
    ui_3d_viewport_set_fov(vp, 52.0f, 1.0f, 400.0f);

    /* ── 果子池:**建一次,反复用**(每接一个就 create/free 会在 60fps 下把 GC/后端表搅烂)── */
    for (i = 0; i < MAX_FRUIT; i = i + 1) {
        fxIdx[i] = ui_3d_obj_create(vp, m_sph);
        ui_3d_obj_set_scale(fxIdx[i], 0.85f);
        ui_3d_obj_set_color(fxIdx[i], FRUIT_C[i % 4]);
        ui_3d_obj_set_emissive(fxIdx[i], 1);
        ui_3d_obj_set_visible(fxIdx[i], 0);
        fxOn[i] = 0; fxX[i] = 0.0f; fxZ[i] = 0.0f;
    }

    foxX = 0.0f; speed = BASE_SPEED; score = 0; lives = START_LIVES; state = 0; best = 0;
    dragging = 0; lastTx = 0; tapped = 0;
    lastMs = ui_time_get_ms();

    while (ui_win_is_closed() == 0) {
        int now; float dt; int k;

        /* ⚠⚠ **步进按墙钟 dt**(本仓铁律:帧率不该是玩法的一部分)。
         *   写死"每帧走多少"的程序,在 30fps 的手机上会比 60fps 的桌面**慢一半**,
         *   而那种差异看着像"手机上就是卡",不像"我把帧当时间单位了"。
         *   dt 钳到 100ms:暂停/切后台回来时那一大跳不该让果子瞬移穿过狐狸。 */
        now = ui_time_get_ms();
        dt = (float)(now - lastMs) / 1000.0f;
        lastMs = now;
        if (dt < 0.0f) dt = 0.0f;
        if (dt > 0.10f) dt = 0.10f;

        /* ── 输入:单指拖(横向跟手)+ 点屏当"再来一局" ────────────────────── */
        if (ui_touch_read(0, touch)) {
            if (touch[2]) {
                if (dragging) {
                    /* 世界单位/像素 —— 用场地宽度的比例算,这样任何屏幕上"从中间拖到边"都是同一段距离 */
                    foxX = foxX + (float)(touch[0] - lastTx) * (FIELD_X * 2.0f) / 320.0f;
                } else tapped = 1;
                dragging = 1; lastTx = touch[0];
            } else dragging = 0;
        } else dragging = 0;
        /* 键盘兜底(手机手柄的 ←/→,或桌面) */
        if (ui_key_is_down(VML_KEY_LEFT)) foxX = foxX - 14.0f * dt;
        if (ui_key_is_down(VML_KEY_RIGHT)) foxX = foxX + 14.0f * dt;
        if (foxX > FIELD_X) foxX = FIELD_X;
        if (foxX < 0.0f - FIELD_X) foxX = 0.0f - FIELD_X;

        if (state == 1) {
            /* 结束画面:A 键或点屏重开 */
            if (ui_key_is_down(VML_KEY_PAD_A) || tapped) {
                state = 0; score = 0; lives = START_LIVES; speed = BASE_SPEED;
                for (i = 0; i < MAX_FRUIT; i = i + 1) {
                    fxOn[i] = 0;
                    ui_3d_obj_set_visible(fxIdx[i], 0);
                }
            }
            tapped = 0;
        } else {
            tapped = 0;

            /* ── 推进果子(一切位移都是 * dt)────────────────────────────── */
            for (i = 0; i < MAX_FRUIT; i = i + 1) {
                if (fxOn[i] == 0) continue;
                fxZ[i] = fxZ[i] + speed * dt;

                /* 接到:**同一个判定式**给"接住"和"看起来接住了"用(本仓 E071 那条:
                 * 判定、吸附、停下必须是同一个位置)*/
                if (fxZ[i] > 0.0f - CATCH_Z && fxZ[i] < CATCH_Z) {
                    float dx;
                    dx = fxX[i] - foxX;
                    if (dx < 0.0f) dx = 0.0f - dx;
                    if (dx < CATCH_X) {
                        fxOn[i] = 0;
                        ui_3d_obj_set_visible(fxIdx[i], 0);
                        score = score + 1;
                        speed = speed + SPEED_STEP * (score > 10 ? 1.6f : 1.0f);
                        if (score > best) best = score;
                        continue;
                    }
                }
                /* 漏了 */
                if (fxZ[i] > MISS_Z) {
                    fxOn[i] = 0;
                    ui_3d_obj_set_visible(fxIdx[i], 0);
                    lives = lives - 1;
                    if (lives <= 0) { lives = 0; state = 1; }
                    continue;
                }
                ui_3d_obj_set_pos(fxIdx[i], fxX[i], 1.0f, fxZ[i]);
                ui_3d_obj_set_spin(fxIdx[i], 0.0f, 140.0f, 0.0f);   /* 声明式自转:果子转起来 */
            }

            /* ── 投放:空了就补一个(**同一批里只补一个**,免得一次涌出好几个)── */
            {
                int free1; int nOn;
                free1 = -1; nOn = 0;
                for (i = 0; i < MAX_FRUIT; i = i + 1) {
                    if (fxOn[i]) nOn = nOn + 1;
                    else if (free1 < 0) free1 = i;
                }
                /* 同屏上限随难度涨:3 → 6 */
                {
                    int cap;
                    cap = 3 + score / 6;
                    if (cap > MAX_FRUIT) cap = MAX_FRUIT;
                    if (free1 >= 0 && nOn < cap) {
                        float t;
                        fxOn[free1] = 1;
                        /* ⚠ 落点用**整数位错开**(0/1/2 轮流):纯随机在手机上没有 srand 的席位,
                         *   而定死的"每次都中间"两下就腻。这里用一个随分数走的错位,够用。 */
                        t = (float)((score + free1 * 3) % 7) - 3.0f;
                        fxX[free1] = t * (FIELD_X / 3.0f);
                        fxZ[free1] = SPAWN_Z;
                        ui_3d_obj_set_pos(fxIdx[free1], fxX[free1], 1.0f, fxZ[free1]);
                        ui_3d_obj_set_visible(fxIdx[free1], 1);
                    }
                }
            }
        }

        /* ── 狐狸跟着玩家的 x 走(**只动根**)── */
        ui_3d_obj_set_pos(root, foxX, rootY, 0.0f);
        /* 走路时轻微前倾:让人看出来它"在动"(模型是绑定姿势、腿不会动,靠这个补一点生气) */
        ui_3d_obj_set_rot(root, 0.0f, 180.0f + (foxX - 0.0f) * 3.0f, 0.0f);

        /* ── HUD ───────────────────────────────────────────────────────── */
        ui_2d_clear(0);
        sprintf(buf, "分数 %d    命 %d    速度 %.0f", score, lives, (double)speed);
        ui_2d_text(14, 12, buf, -1, 16, VML_ANCHOR_LEFT);
        ui_2d_text(14, 34, "拖动 / ←→ 移动狐狸    接到 +1,漏了掉一条命", -8355712, 11,
                   VML_ANCHOR_LEFT);
        if (state == 1) {
            sprintf(buf, "结束    本局 %d 分    最高 %d", score, best);
            /* ⚠ 面板放**下部**:狐狸在下半屏,放正中的话正好把它盖住("看不见主角的结束画面")。 */
            ui_2d_rect(0, sh * 3 / 4 - 50, sw, 104, 0xC0000000, 1, 0, 0);
            ui_2d_text(sw / 2, sh * 3 / 4 - 26, buf, -1, 18, VML_ANCHOR_CENTER);
            ui_2d_text(sw / 2, sh * 3 / 4 + 8, "按 A 或点屏再来一局", -8355712, 13, VML_ANCHOR_CENTER);
        }
        ui_2d_present();

        /* ⚠ 这一句只是"等输入、最多等这么久";**上屏节奏由宿主管**,别靠改这个数调帧率。 */
        k = ui_msg_fetch_wait(16);
        (void)k;
    }

    ui_3d_gltf_free(scene);
    ui_3d_viewport_free(vp);
    return 0;
}