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;
}