c/3d/rover3d.c · 84543 字节 · 下载原文
/* rover3d.c —— **3D 漫游寻果**:一局"开着车在起伏地形上收果子"的游戏,同时也是整套 3D 能力的**总验收**。
* rover3d.c - **3D Rover Harvest**: drive around rolling terrain collecting fruit; also the end-to-end
* acceptance for the whole 3D feature set (glTF loading through to 1024 objects).
*
* ## 为什么是"游戏"而不是又一个单项例程
* ## Why a game instead of one more single-feature example
*
* 单项例程可以各自全绿,**合起来却是坏的**。
* Single-feature examples can each be green while the whole thing is still broken:
* `g3_all.c`(逐号验收)、`gltf_demo`(装载)、`gamma_demo`(伽马/镂空/采样器)、
* g3_all (per syscall), gltf_demo (loading), gamma_demo (gamma / cut-out / sampler),
* `pbr_demo`(PBR/光源/环境反射)、`shadowmap_demo`(阴影贴图)各自都在测**自己那一维**。
* pbr_demo (PBR / lights / environment) and shadowmap_demo each measure their own dimension.
* 一局游戏跑起来 = 把**多个维度同时**量一遍:同一帧里要有真模型、镂空栅栏、山坡上的影子、
* A running game measures every dimension at once: one frame must show a real model, a cut-out
* 金属高光、以及 300 多个物体 —— 而且**任何一维坏掉都会表现为"画面不对"**。
* fence, a shadow on a slope, a metal highlight and 300+ objects - a broken dimension shows up as
* 不是"某条断言红了"。
* "the picture is wrong", never as "an assertion went red somewhere".
* A running game measures **every dimension at once**: one frame must show a real model, a cut-out
* fence, a shadow on a slope, a metal highlight and 300+ objects - and a broken dimension shows up as
* "the picture is wrong", never as "an assertion went red somewhere".
*
* ## 素材(各自许可,都是 Khronos glTF-Sample-Assets 的下载件)
* ## Assets (licences listed; all downloaded from Khronos glTF-Sample-Assets)
*
* · `ToyCar.glb` —— **CC0**(babylon.js glTF exporter for 3dsmax 2020)。
* · ToyCar.glb - CC0 (babylon.js glTF exporter for 3dsmax 2020).
* · `ToyCar.glb` - **CC0**; 11 nodes / 3 meshes / 77,429 verts / 8 textures / 3 materials.
* 它是**玩家开的车**,也是 PBR 与材质对比的载体(车漆金属 vs 织物哑光)。
* It is the **car the player drives** and the PBR carrier (metallic paint vs matte fabric).
* · `Avocado.glb` —— **CC0**(Microsoft),1 节点 / 1 网格 / 406 顶点 / 3 张 2048² 贴图。
* · `Avocado.glb` - **CC0** (Microsoft); 1 node / 1 mesh / 406 verts / 3 2048-square textures.
* 它是**要收集的果子**(8 个)。
* It is the fruit to collect (eight of them).
* · `rover_cutout.glb` —— **本仓自产**(`scripts/make-rover-cutout-glb.py`)。
* · rover_cutout.glb - **built in this repo** (scripts/make-rover-cutout-glb.py):
* 一张自绘的镂空图集 + 栅栏 + 树干 + 树冠。**必须自产的原因**见那个脚本的头部。
* a hand-drawn cut-out atlas plus fence, trunks and canopies. See that script's header -
* 程序侧造不出带 alpha 的贴图:`ui_2d_get_image` 一律把 alpha 补成 255。
* a program cannot build an alpha-bearing texture: ui_2d_get_image fills alpha in as 255.
* (`ui_2d_rect` 的 0x00000000 也是"什么都不做",实测擦不出洞。)
* (a 0x00000000 ui_2d_rect is a no-op too - measured, it cannot punch holes either.)
* · `rover_cutout.glb` - **built in this repo** (`scripts/make-rover-cutout-glb.py`): a hand-drawn
* cut-out atlas plus fence, trunks and canopies. See that script's header for why it is home-made:
* 只有 glTF 的 `alphaMode: MASK` 那条路能带 alpha 上 GPU,而下载语料里**没有**树叶/栅栏件。
* only glTF's `alphaMode: MASK` path can get alpha onto the GPU, and the downloaded corpus has no
* foliage or fence cut-out at all.
*
* ⚠ 两件下载件都是 **CC0**(无需署名,这里仍然把出处写出来)。
* ⚠ Both downloads are **CC0** (attribution not required; the credit is here anyway).
* ⚠ 没有用任何 CC-BY-NC / 仅限测试授权的语料。
* ⚠ No CC-BY-NC or "testing only" corpus was used.
*
* ## 玩法
* ## How to play
*
* 方向键 / WASD 开车(↑ 加速、↓ 倒车、←→ 转向),Space 重开,G 切输出变换(0 关 / 3 ACES)。
* Arrow keys / WASD drive; Space restarts; G cycles the output transform (0 off / 3 ACES).
* 右上角小地图是**正交**俯视。把 8 个果子全收完 = 通关,90 秒收不完 = 超时。
* The top-right minimap is an **orthographic** top view; collect all 8 fruit to win, 90 s or you lose.
* ⚠ 默认跑的是**自检模式**(自动驾驶一段、逐条判据、末行给结论)。想自己玩:`-DROVER_PLAY=1`。
* ⚠ By default it runs the **self-check** (autopilot, then every criterion, then a summary line).
* 或者把下面 `ROVER_PLAY` 改成 1 再编译;只想看画面可以再加 `-D ROVER_CHECK=0` 跳过判据。
* To play it yourself pass `-DROVER_PLAY=1`, or change `ROVER_PLAY` below to 1; add `-D ROVER_CHECK=0`
* to skip the criteria and just watch the picture.
*
* ## 判据(每条都能响;末行 `ROVER3D_GAME PASS|FAIL (fails=N)`)
* ## Criteria (every one can go red; the summary line is `ROVER3D_GAME PASS|FAIL (fails=N)`)
*
* ① 装载 / 物体数 / 真模型的几何;② 输出变换(伽马);③ 逐盏灯都得有效果。
* (1) loading / object count / real-model geometry; (2) the output transform; (3) every light matters.
* ④ 阴影贴图落在**起伏地形**上(平面投影够不着)、且**跟着光转**;⑤ 镂空边缘真的被丢弃。
* (4) the shadow map lands on **rolling** terrain and **follows the light**; (5) cut-out really cut.
* ⑥ mipmap 把远处地面的闪烁抹掉;⑦ PBR 与**环境反射**(金属照得出天)。
* (6) mipmap kills the far-ground shimmer; (7) PBR and **environment reflection**.
* ⑧ 小地图的**正交**(远近同大);⑨ 移动 / 收集 / 计数 / 结束条件(收满 = 通关,超时 = 结束)。
* (8) the minimap's **orthographic** view; (9) move / collect / count / end conditions.
*
* ## 跑法
* ## How to run
* 桌面:`dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll third_party/vml/Examples/c/3d/rover3d.c --screen 393x757`
* Desktop: `dotnet scripts/vmlcli/bin/Release/net10.0/vmlcli.dll .../rover3d.c --screen 393x757`
* ⚠ **出图要用 `--frames <目录>`,别用 `--frame`**(`--frame` 取的是运行结束时那一帧的 FBO)。
* ⚠ **Export with `--frames <dir>`, not `--frame`** (the latter grabs the FBO at exit, after the
* 本程序结束时相机已经摆回游戏视点,而判据段又改过一堆状态 ⇒ 拿到的不是想看的那一张。
* criteria have moved the camera and toggled half the features).
* 手机:命令行页敲 `vml run examples/c/3d/rover3d.c`(素材在 `examples/c/3d/*.glb`,随包分发)。
* Phone: type `vml run examples/c/3d/rover3d.c` on the command-line page (the .glb assets ship too).
*/
#include <vml_ui.h>
#include <vml_3d.h>
#include <math.h>
#include <stdio.h>
/* 1 = 纯手动玩(不跑判据);0 = 自检模式(默认)。命令行 `-DROVER_PLAY=1` 可以覆盖。 */
/* 1 = play by hand (no criteria); 0 = self-check (default). Overridable with `-DROVER_PLAY=1`. */
#ifndef ROVER_PLAY
#define ROVER_PLAY 0
#endif
/* 1 = 跑判据(默认);0 = **跳过判据直接玩**(调画面时用,几十秒省成几秒)。 */
/* 1 = run the criteria (default); 0 = **skip straight to the game**, for fast visual iteration. */
#ifndef ROVER_CHECK
#define ROVER_CHECK 1
#endif
/* 自检模式下的**兜底帧上限**(正常应当在收满/超时时就结束;这一段就是 `--frames` 出来的那些)。 */
/* The autopilot's hard frame cap in self-check mode; normally the round ends first. */
#define AUTO_FRAMES 220
/* ── 世界常量(与 `scripts/make-rover-cutout-glb.py` 里那几张表**必须一致**)────────────── */
/* -- World constants that **must match** the tables in `scripts/make-rover-cutout-glb.py` -- */
/* 栅栏半径。 */
/* fence radius. */
#define FENCE_R 46.0f
/* 玩家活动半径(栅栏里面一点)。 */
/* player radius, just inside the fence. */
#define ARENA 43.0f
#define CAR_SCALE 70.0f /* ToyCar 原始包围球 r=0.054 ⇒ ×70 后约 7.6 单位长 */
/* ToyCar's raw bounds radius is 0.054; x70 gives about 7.6 units long */
/* 车心离地(模型原点在包围球中心)。 */
/* car centre above ground; the model origin is the bounding-sphere centre. */
#define CAR_LIFT 1.55f
/* Avocado 原始包围球 r=0.040,乘 46 之后约 3.7 单位。 */
/* Avocado raw r=0.040; x46 gives about 3.7 units. */
#define FRUIT_SCALE 46.0f
/* 果子浮起来自转,好认。 */
/* the fruit floats and spins so it reads. */
#define FRUIT_LIFT 1.5f
/* 果子数。 */
/* number of fruit. */
#define NF 8
/* 石头数 —— 物体数主要靠它们撑过 256。 */
/* rocks; these are what push the object count past 256. */
#define NROCK 200
/* 金属球 + 底座的对数。 */
/* metal sphere plus pedestal pairs. */
#define NMETAL 10
/* 树数(与 `make-rover-cutout-glb.py` 的 N_TREE 一致)。 */
/* trees; must match N_TREE in make-rover-cutout-glb.py. */
#define NTREE 24
/* 树的落点公式起点(两处必须一样)。 */
/* tree placement formula, first term; the two copies must match. */
#define TREE_RR0 16.0f
#define TREE_RR1 21.0f
#define BODY_R 2.2f /* 树干挡车的半径(车当质点看,别把通道堵死)*/
/* trunk push-out radius; the car is treated as a point */
/* 金属球挡车的半径(球半径 + 车宽)。 */
/* sphere radius plus car half-width. */
#define METAL_R 3.6f
/* 用满 5 盏(宿主上限是 8)。 */
/* five of the eight available lights are used. */
#define NLIGHT 5
/* 收果半径(够宽容,但明显小于果子间距)。 */
/* collect radius: forgiving, yet clearly smaller than the spacing between fruit. */
#define COLLECT_R 4.6f
/* 一局多少秒。 */
/* seconds per round. */
#define ROUND_SEC 90.0f
/* 加速度(单位/秒²)。 */
/* acceleration in units per second squared. */
#define ACC 17.0f
/* 最高速。 */
/* top speed. */
#define VMAX 15.0f
/* 转向速率(度/秒)。 */
/* turn rate in degrees per second. */
#define TURN_RATE 135.0f
/* 速度阻尼。 */
/* speed drag. */
#define DRAG 1.35f
/* ── 颜色(0xAARRGGBB)───────────────────────────────────────────────────── */
/* -- Colours (0xAARRGGBB) -- */
#define C_DARK 0xFF0B1220
#define C_WHITE 0xFFFFFFFF
#define C_HUD 0xFFE8F0E8
#define C_HUD_DIM 0xFF9FB09F
#define C_WARN 0xFFFF6A4A
#define C_GOOD 0xFF7CFF9A
#define C_GOLD 0xFFFFD24A
#define C_ROAD 0xFF6E7A5E
/* ══ 网格缓冲(顶点 = pos3 + nrm3 + uv2 = 8 个 float32,紧跟 int32 索引)══════════════ */
/* == Mesh buffer (vertex = pos3 + nrm3 + uv2 = 8 float32, then int32 indices) == */
/* ⚠ 容量必须装得下「顶点数 × 8 + 索引数」:地形 48 段 = 49×49×8 + 48×48×6 = 33032。
* ⚠ The array must hold (verts x 8 + indices): terrain at 48 segments = 33032. Writing less
* 不报错,只是把后面的全局变量整片写烂(本仓栽过:判据以"莫名其妙"的形式变红)。
* corrupts the globals after it **without any error** (this repo has been bitten by that). */
#define MB_CAP 36000
float MB[MB_CAP];
int g_vn;
int g_in;
void mb_vertex_uv(int i, float px, float py, float pz,
float nx, float ny, float nz, float u, float v) {
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] = u; MB[b + 7] = v;
}
/* ⚠ 索引落笔在 `ip[g_vn*8 + k]`,即"紧跟顶点之后" ⇒ **必须等全部顶点写完再写索引**。
* ⚠ Indices land at `ip[g_vn*8 + k]`, right after the vertices, so they must be written **after**
* 边写顶点边写索引会被互相覆盖(症状:屏幕上一条窄平行四边形,而 `obj_get_screen` 照旧报正中)。
* every vertex is in place; interleaving the two overwrites each other. */
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]; }
/* ══ 小工具 ══════════════════════════════════════════════════════════════ */
/* == Small helpers == */
int argb(int r, int g, int b) { return -16777216 + r * 65536 + g * 256 + b; }
int rch(int c) { return (c >> 16) & 255; }
int gch(int c) { return (c >> 8) & 255; }
int bch(int c) { return c & 255; }
int lum(int c) { return rch(c) + gch(c) + bch(c); }
int g_fails;
/* 建出来的物体总数(判据要它)。 */
/* total objects created (the criteria read it). */
int g_objects;
void ck(char* tag, int cond, int got, int want) {
if (cond) printf("PASS %s (%d)\n", tag, got);
else { printf("FAIL %s (got=%d want=%d)\n", tag, got, want); g_fails = g_fails + 1; }
}
int rnd;
int rnd_next(void) {
rnd = rnd * 1103515245 + 12345;
rnd = (rnd >> 16) & 32767;
return rnd;
}
float frnd(void) { return (float)(rnd_next() % 10000) / 10000.0f; }
/* ══ 地形:**起伏**高度场(阴影贴图那条判据全靠它)═══════════════════════════════ */
/* == Terrain: a **rolling** height field (the shadow-map criterion lives on this) == */
/* ⚠ 与 `scripts/make-rover-cutout-glb.py` 的 `terr_h()` **逐项相同** —— 树和栅栏的位置是按它烘进
* ⚠ Term for term identical to `terr_h()` in the generator script: the fence and trees are baked
* 那个 .glb 的,两边漂了就会出现"树悬在半空 / 栅栏埋进土里"。
* against it, and a drift between the two shows up as floating trees or a buried fence. */
float terr_h(float x, float z) {
float h;
h = 3.0f * sin((double)(x * 0.11f)) * cos((double)(z * 0.09f));
h = h + 1.6f * sin((double)((x + z) * 0.055f));
h = h + 0.9f * cos((double)((x - z) * 0.13f));
return h;
}
/* 地形法线:对高度场做中心差分(比解析求导短,而且与网格本身一致)。 */
/* The terrain normal by central differences over the height field. */
void terr_normal(float x, float z, float d, float* out) {
float hl; float hr; float hd; float hu;
float nx; float nz; float ny; float len;
hl = terr_h(x - d, z);
hr = terr_h(x + d, z);
hd = terr_h(x, z - d);
hu = terr_h(x, z + d);
nx = 0.0f - (hr - hl) / (2.0f * d);
nz = 0.0f - (hu - hd) / (2.0f * d);
ny = 1.0f;
len = sqrt((double)(nx * nx + ny * ny + nz * nz));
out[0] = nx / len; out[1] = ny / len; out[2] = nz / len;
}
void gen_terrain(int segs, float half, float rep) {
int i; int j; int v; int k;
float n[3];
float fx; float fz;
v = 0;
for (j = 0; j <= segs; j = j + 1) {
for (i = 0; i <= segs; i = i + 1) {
fx = 0.0f - half + 2.0f * half * i / segs;
fz = 0.0f - half + 2.0f * half * j / segs;
terr_normal(fx, fz, 0.5f, n);
mb_vertex_uv(v, fx, terr_h(fx, fz), fz, n[0], n[1], n[2],
rep * i / segs, rep * j / segs);
v = v + 1;
}
}
g_vn = v;
k = 0;
for (j = 0; j < segs; j = j + 1) {
for (i = 0; i < segs; i = i + 1) {
int a; int b;
a = j * (segs + 1) + i;
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;
}
/* 长方体:**每个面给常量法线**(拿归一化位置当法线在角上会歪 45°,方块会看起来是圆的)。 */
/* A box with a **constant normal per face** (using the normalised position would round the corners). */
void mb_face(int v, float px, float py, float pz, float nx, float ny, float nz) {
mb_vertex_uv(v, px, py, pz, nx, ny, nz, 0.0f, 0.0f);
}
void gen_box(float hx, float hy, float hz) {
int v; int k;
v = 0;
mb_face(v, hx, 0 - hy, 0 - hz, 1, 0, 0); mb_face(v + 1, hx, 0 - hy, hz, 1, 0, 0);
mb_face(v + 2, hx, hy, hz, 1, 0, 0); mb_face(v + 3, hx, hy, 0 - hz, 1, 0, 0); v = v + 4;
mb_face(v, 0 - hx, 0 - hy, hz, -1, 0, 0); mb_face(v + 1, 0 - hx, 0 - hy, 0 - hz, -1, 0, 0);
mb_face(v + 2, 0 - hx, hy, 0 - hz, -1, 0, 0); mb_face(v + 3, 0 - hx, hy, hz, -1, 0, 0); v = v + 4;
mb_face(v, 0 - hx, hy, 0 - hz, 0, 1, 0); mb_face(v + 1, hx, hy, 0 - hz, 0, 1, 0);
mb_face(v + 2, hx, hy, hz, 0, 1, 0); mb_face(v + 3, 0 - hx, hy, hz, 0, 1, 0); v = v + 4;
mb_face(v, 0 - hx, 0 - hy, hz, 0, -1, 0); mb_face(v + 1, hx, 0 - hy, hz, 0, -1, 0);
mb_face(v + 2, hx, 0 - hy, 0 - hz, 0, -1, 0); mb_face(v + 3, 0 - hx, 0 - hy, 0 - hz, 0, -1, 0); v = v + 4;
mb_face(v, 0 - hx, 0 - hy, hz, 0, 0, 1); mb_face(v + 1, hx, 0 - hy, hz, 0, 0, 1);
mb_face(v + 2, hx, hy, hz, 0, 0, 1); mb_face(v + 3, 0 - hx, hy, hz, 0, 0, 1); v = v + 4;
mb_face(v, hx, 0 - hy, 0 - hz, 0, 0, -1); mb_face(v + 1, 0 - hx, 0 - hy, 0 - hz, 0, 0, -1);
mb_face(v + 2, 0 - hx, hy, 0 - hz, 0, 0, -1); mb_face(v + 3, hx, hy, 0 - hz, 0, 0, -1); v = v + 4;
g_vn = v;
k = 0;
for (v = 0; v < 6; v = v + 1) {
int a;
a = v * 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;
}
/* 球:法线取归一化位置(金属球靠它出高光与反射)。低多边形就够,石头也用它。 */
/* A sphere whose normal is the normalised position; low poly is fine, and the rocks reuse it. */
void gen_sphere(int rings, int segs) {
int i; int j; int v; int k;
v = 0;
for (i = 0; i <= rings; i = i + 1) {
float phi; float sp; float cp;
phi = 3.14159265f * i / rings;
sp = sin((double)phi);
cp = cos((double)phi);
for (j = 0; j <= segs; j = j + 1) {
float th; float nx; float ny; float nz;
th = 6.28318531f * j / segs;
nx = sp * cos((double)th);
ny = cp;
nz = sp * sin((double)th);
mb_vertex_uv(v, nx, ny, nz, nx, ny, nz, 2.0f * j / segs, 1.0f - 2.0f * i / rings);
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)—— 小地图的标记用它,俯视正交下正好正对相机。 */
/* A horizontal quad (normal +Y) - the minimap markers use it, facing a top-down camera. */
void gen_flat_quad(float half) {
g_vn = 0; g_in = 0;
mb_vertex_uv(0, 0 - half, 0.0f, 0 - half, 0.0f, 1.0f, 0.0f, 0.0f, 0.0f);
mb_vertex_uv(1, half, 0.0f, 0 - half, 0.0f, 1.0f, 0.0f, 1.0f, 0.0f);
mb_vertex_uv(2, half, 0.0f, half, 0.0f, 1.0f, 0.0f, 1.0f, 1.0f);
mb_vertex_uv(3, 0 - half, 0.0f, half, 0.0f, 1.0f, 0.0f, 0.0f, 1.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;
}
/* ══ 程序贴图(**全都走 2D 层抓图**:`ui_2d_get_image` 给句柄)══════════════════════ */
/* == Procedural textures (all captured from the 2D layer via `ui_2d_get_image`) == */
/* ⚠ 判据要的"高频"是刻意的:地面贴图不高频,远处就不会闪,mipmap 那条判据也就**响不了**。
* ⚠ The high frequency is deliberate: a smooth ground texture never shimmers, and then the mipmap
* 所以草皮是"棋盘 + 斑点"而不是一片渐变的绿。
* criterion can never fire. Hence a checkered, speckled ground rather than a smooth green. */
int make_ground_tex(void) {
int i; int x; int y; int img;
ui_2d_clear(0);
for (y = 0; y < 64; y = y + 1) {
for (x = 0; x < 64; x = x + 1) {
int c;
int cell;
cell = ((x / 4) + (y / 4)) % 2;
if (cell == 0) c = argb(58, 88, 46);
else c = argb(42, 68, 36);
if (((x * 7 + y * 13) % 11) == 0) c = argb(92, 124, 66);
if (((x * 5 + y * 3) % 17) == 0) c = argb(28, 48, 28);
ui_2d_rect(x, y, 1, 1, c, 1, 0, 0);
}
}
ui_2d_present();
img = ui_2d_get_image(0, 0, 64, 64);
ui_2d_clear(0);
ui_2d_present();
i = img;
return i;
}
/* 等距圆柱天空:上半是天顶到地平线的渐变 + 一个太阳,下半是地面霾。
* An equirectangular sky: a zenith-to-horizon gradient plus a sun on top, ground haze below.
* 它同时是**背景**与**环境反射的源**(开了 PBR 才参与光照)。 */
/* It is both the **background** and the **source of the environment reflection**. */
#define SKY_W 256
#define SKY_H 64
int make_sky_tex(void) {
int y; int x; int img;
ui_2d_clear(0);
for (y = 0; y < SKY_H; y = y + 1) {
int c;
if (y < SKY_H / 2) {
float t;
t = (float)y / (float)(SKY_H / 2);
c = argb((int)(60.0f + 120.0f * t), (int)(110.0f + 90.0f * t), (int)(190.0f + 40.0f * t));
} else {
float t;
t = (float)(y - SKY_H / 2) / (float)(SKY_H / 2);
c = argb((int)(120.0f - 70.0f * t), (int)(120.0f - 66.0f * t), (int)(96.0f - 56.0f * t));
}
ui_2d_rect(0, y, SKY_W, 1, c, 1, 0, 0);
}
/* 太阳:一个亮块 + 一圈光晕(放在 u=0.25 处,与主光方向大致对得上)。 */
/* The sun: a bright core plus a halo, at u = 0.25, roughly matching the key light. */
for (x = 52; x < 76; x = x + 1) {
for (y = 8; y < 26; y = y + 1) {
int dx; int dy; int d2;
dx = x - 64; dy = y - 16;
d2 = dx * dx + dy * dy;
if (d2 < 36) ui_2d_rect(x, y, 1, 1, argb(255, 246, 210), 1, 0, 0);
else if (d2 < 160) ui_2d_rect(x, y, 1, 1, argb(228, 224, 186), 1, 0, 0);
}
}
ui_2d_present();
img = ui_2d_get_image(0, 0, SKY_W, SKY_H);
ui_2d_clear(0);
ui_2d_present();
return img;
}
/* ══ 灯光(NLIGHT 盏,逐盏都要有可见作用)═══════════════════════════════════════ */
/* == Lights (NLIGHT of them, every one must visibly matter) == */
float L_DX[NLIGHT]; float L_DY[NLIGHT]; float L_DZ[NLIGHT];
int L_COL[NLIGHT]; float L_AMB[NLIGHT];
void set_light(int vp, int i) {
ui_3d_viewport_set_light_at(vp, i, L_DX[i], L_DY[i], L_DZ[i], L_COL[i], L_AMB[i]);
}
void init_lights(void) {
/* ⚠ 五盏灯是**叠着加**的:每盏都给满强度时,向光面直接烧成一片惨白(实测第一版就是这样,
* ⚠ Five lights **add up**: at full strength every lit surface clips to white (the first version
* 地面成了没有细节的薄荷绿)。所以主光只给 0.6 左右,其余是**补光**级别的小量。
* did exactly that). The key light gets about 0.6 and the rest stay at fill-light levels. */
/* 主光(太阳,唯一投影的那盏)。 */
/* the key light: the sun, and the only one that casts shadows. */
L_DX[0] = 0.34f; L_DY[0] = 0.86f; L_DZ[0] = 0.38f;
L_COL[0] = argb(168, 157, 139); L_AMB[0] = 0.10f;
/* 天光(冷色补光)。 */
/* sky fill, a cool colour. */
L_DX[1] = -0.52f; L_DY[1] = 0.46f; L_DZ[1] = -0.72f;
L_COL[1] = argb(62, 86, 124); L_AMB[1] = 0.06f;
/* 暖色低角度反弹光。 */
/* a warm, low-angle bounce. */
L_DX[2] = 0.68f; L_DY[2] = 0.30f; L_DZ[2] = -0.66f;
L_COL[2] = argb(106, 76, 50); L_AMB[2] = 0.03f;
/* 冷青轮廓光。 */
/* a cool cyan rim light. */
L_DX[3] = -0.40f; L_DY[3] = 0.58f; L_DZ[3] = 0.72f;
L_COL[3] = argb(48, 96, 94); L_AMB[3] = 0.03f;
/* 紫色点缀光。 */
/* a violet accent light. */
L_DX[4] = -0.74f; L_DY[4] = 0.24f; L_DZ[4] = -0.62f;
L_COL[4] = argb(76, 52, 100); L_AMB[4] = 0.02f;
}
/* ══ 游戏状态 ══════════════════════════════════════════════════════════════ */
/* == Game state == */
/* 主视口。 */
/* the main viewport. */
int vp;
/* 小地图视口(正交)。 */
/* the minimap viewport (orthographic). */
int vpmini;
int terrain_mesh; int terrain_obj; int terrain_mini;
int rock_mesh; int sphere_mesh; int box_mesh; int marker_mesh;
int rock_obj[NROCK];
int metal_obj[NMETAL]; int ped_obj[NMETAL];
int car_scene[3]; int car_root[3];
int fruit_scene[NF]; int fruit_root[NF];
int mini_mark[NF]; int mini_player;
int cut_scene;
int mini_w; int mini_h; int scr_w; int scr_h;
int ground_tex; int sky_tex;
/* 判据用的第二张天空盒(纯洋红)。 */
/* the second skybox used by the criteria (solid magenta). */
int g_magenta;
float p_x; float p_z; float p_y; float p_head; float p_speed;
float f_x[NF]; float f_z[NF];
int f_taken[NF];
int score;
/* 0 = 进行中 / 1 = 通关 / 2 = 超时。 */
/* 0 = playing / 1 = won / 2 = out of time. */
int state;
float time_left;
int frame;
int last_ms;
/* 输入 —— 键盘与自动驾驶写的是**同一对变量**。 */
/* input: the keys and the autopilot write the very same pair. */
float in_thr; float in_turn;
/* 金属球的位置 —— 摆位与判据都要它,所以**放在判据段之前**。 */
/* Metal-sphere placement, needed by both the setup and the criteria, so it lives before them. */
float m_x[NMETAL]; float m_y[NMETAL]; float m_z[NMETAL];
/* ══ 建场景 ═══════════════════════════════════════════════════════════════ */
/* == Build the world == */
float b4[4];
int load_first(int vp_, const char** list, int n, const char** used) {
int i; int sc;
sc = -1;
for (i = 0; i < n; i = i + 1) {
sc = ui_3d_gltf_load(vp_, list[i]);
if (sc > 0) { *used = list[i]; break; }
}
return sc;
}
const char* kCarPaths[3] = {
"examples/c/3d/ToyCar.glb", "ToyCar.glb", "models/ToyCar.glb",
};
const char* kFruitPaths[3] = {
"examples/c/3d/Avocado.glb", "Avocado.glb", "models/Avocado.glb",
};
const char* kCutPaths[3] = {
"examples/c/3d/rover_cutout.glb", "rover_cutout.glb", "models/rover_cutout.glb",
};
/* 第 i 棵树的世界坐标 —— 公式与 `scripts/make-rover-cutout-glb.py` 里那份**逐项相同**
* The i-th tree's world position; the formula is term for term the one in the generator script,
* (两处漂了就会出现"车从树干里穿过去"或"果子长在树里够不着")。
* and a drift between them shows up as driving through a trunk or an unreachable fruit. */
void tree_pos(int i, float* ox, float* oz) {
float th; float rr;
th = 6.28318531f * (float)(i + 0.5f) / (float)NTREE;
rr = TREE_RR0 + TREE_RR1 * (float)((i * 7919) % 100) / 100.0f;
*ox = rr * cos((double)th);
*oz = rr * sin((double)th);
}
/* 果子落点:**摊在场地上**,并躲开树干与金属球(不然会出现够不着的果子)。 */
/* Fruit spots, spread out but clear of trunks and metal spheres so every fruit is reachable. */
/* Fruit spots, spread out and non-overlapping, from a fixed LCG so runs are reproducible. */
void place_fruits_spots(void) {
int i;
for (i = 0; i < NF; i = i + 1) {
float th; float rr; int k; int bad;
th = 6.28318531f * (float)i / (float)NF + 0.35f;
rr = 14.0f + 12.0f * ((float)(i % 3) / 2.0f);
f_x[i] = rr * cos((double)th);
f_z[i] = rr * sin((double)th);
for (k = 0; k < 24; k = k + 1) {
int t;
bad = 0;
for (t = 0; t < NTREE; t = t + 1) {
float tx; float tz; float dx; float dz;
tree_pos(t, &tx, &tz);
dx = tx - f_x[i]; dz = tz - f_z[i];
if (dx * dx + dz * dz < 49.0f) bad = 1;
}
if (bad == 0) break;
/* 往外挪一点再试。 */
/* nudge outward and retry. */
rr = rr + 1.4f;
/* 不许被挪到栅栏外面去。 */
/* never past the fence. */
if (rr > 37.0f) break;
f_x[i] = rr * cos((double)th);
f_z[i] = rr * sin((double)th);
}
}
}
void reset_game(void) {
int i;
p_x = 0.0f; p_z = -2.0f; p_head = 0.0f; p_speed = 0.0f;
go_prog = 0; go_escape = 0; go_lastd = 9999.0f;
p_y = terr_h(p_x, p_z);
for (i = 0; i < NF; i = i + 1) {
f_taken[i] = 0;
ui_3d_obj_set_visible(fruit_root[i], 1);
ui_3d_obj_set_visible(mini_mark[i], 1);
}
score = 0;
state = 0;
time_left = ROUND_SEC;
in_thr = 0.0f; in_turn = 0.0f;
}
void place_car(void) {
p_y = terr_h(p_x, p_z);
ui_3d_obj_set_pos(car_root[0], p_x, p_y + CAR_LIFT, p_z);
ui_3d_obj_set_rot(car_root[0], 0.0f, p_head * 57.29578f, 0.0f);
}
/* 一步游戏:**按墙钟的秒数**推进(帧率不该是玩法的一部分)。
* One game step, advanced by **wall-clock seconds** (frame rate is not part of the gameplay). */
void step_game(float dt) {
int i;
if (state != 0) return;
time_left = time_left - dt;
p_speed = p_speed + in_thr * ACC * dt;
if (p_speed > VMAX) p_speed = VMAX;
if (p_speed < -VMAX * 0.55f) p_speed = -VMAX * 0.55f;
p_speed = p_speed - p_speed * DRAG * dt;
if (p_speed > -0.05f && p_speed < 0.05f && in_thr == 0.0f) p_speed = 0.0f;
p_head = p_head + in_turn * TURN_RATE * 0.01745f * dt * (p_speed >= 0.0f ? 1.0f : -1.0f);
p_x = p_x + sin((double)p_head) * p_speed * dt;
p_z = p_z + cos((double)p_head) * p_speed * dt;
{
float d;
d = sqrt((double)(p_x * p_x + p_z * p_z));
if (d > ARENA) { p_x = p_x * ARENA / d; p_z = p_z * ARENA / d; p_speed = p_speed * 0.4f; }
}
/* 树干与金属球**推不开**:本作不做物理,只保证镜头不会钻进模型里。
* Trunks and metal spheres are immovable; there is no physics here, only a push-out so the
* 少了这一段,车会开进金属球内部,第三人称相机会紧贴球面(实测画面被一个白球占满)。
* camera cannot end up inside a sphere (measured: one white ball filled the whole frame). */
{
int i; float dx; float dz; float dd;
for (i = 0; i < NTREE; i = i + 1) {
float tx; float tz;
tree_pos(i, &tx, &tz);
dx = p_x - tx; dz = p_z - tz;
dd = sqrt((double)(dx * dx + dz * dz));
if (dd < BODY_R) {
if (dd < 0.01f) { dx = 1.0f; dz = 0.0f; dd = 1.0f; }
p_x = tx + dx * BODY_R / dd;
p_z = tz + dz * BODY_R / dd;
p_speed = p_speed * 0.25f;
}
}
for (i = 0; i < NMETAL; i = i + 1) {
dx = p_x - m_x[i]; dz = p_z - m_z[i];
dd = sqrt((double)(dx * dx + dz * dz));
if (dd < METAL_R) {
if (dd < 0.01f) { dx = 1.0f; dz = 0.0f; dd = 1.0f; }
p_x = m_x[i] + dx * METAL_R / dd;
p_z = m_z[i] + dz * METAL_R / dd;
p_speed = p_speed * 0.25f;
}
}
}
place_car();
for (i = 0; i < NF; i = i + 1) {
float dx; float dz; float dd;
if (f_taken[i]) continue;
dx = f_x[i] - p_x; dz = f_z[i] - p_z;
dd = sqrt((double)(dx * dx + dz * dz));
if (dd < COLLECT_R) {
f_taken[i] = 1;
score = score + 1;
ui_3d_obj_set_visible(fruit_root[i], 0);
ui_3d_obj_set_visible(mini_mark[i], 0);
}
}
if (score >= NF) state = 1;
else if (time_left <= 0.0f) { time_left = 0.0f; state = 2; }
}
/* 自动驾驶:朝最近的、还没收的果子开 —— **写的正是键盘写的那两个变量**,
* Autopilot: steer at the nearest uncollected fruit; it writes **the very variables the keys write**,
* 所以"自己开"和"自己玩"走的是同一条移动/收集代码。
* so the self-drive and a human drive share one movement/collection code path. */
/* "离目标还没更近"累计了几帧,以及"倒车摆脱"还欠多少帧。 */
/* Frames without getting closer to the target, and how many escape frames are still owed. */
int go_prog;
int go_escape;
float go_lastd;
/* 一个障碍对方向盘的偏置:在正前方(±0.9 弧度内)才管,越近权重越大。
* One obstacle's steering bias: only if it is dead ahead (within +-0.9 rad), weighted by closeness.
* 偏置符号 = "往哪边躲"(与角差同号约定:角差为正 ⇒ 增大朝向角)。 */
/* The sign says which way to dodge, in the same convention as the heading difference. */
float avoid_one(float ox, float oz) {
float dx; float dz; float dd; float rel;
dx = ox - p_x; dz = oz - p_z;
dd = sqrt((double)(dx * dx + dz * dz));
if (dd > 10.0f || dd < 0.01f) return 0.0f;
rel = atan2((double)dx, (double)dz) - (double)p_head;
while (rel > 3.14159265) rel = rel - 6.28318531f;
while (rel < -3.14159265) rel = rel + 6.28318531f;
if (rel < 0.0f) { if (rel < -0.9f) return 0.0f; }
else { if (rel > 0.9f) return 0.0f; }
return (rel > 0.0f) ? (0.0f - (10.0f - dd)) : (10.0f - dd);
}
void autopilot(void) {
int i; int best; float bd; float want; float diff;
/* 撞上树干/金属球时先倒车打方向 —— 纯"朝目标直冲"会把自己顶在障碍上不动。 */
/* On hitting a trunk or sphere, back up and steer: driving straight at the target gets stuck. */
if (go_escape > 0) {
go_escape = go_escape - 1;
in_thr = -1.0f;
in_turn = 1.0f;
return;
}
best = -1; bd = 0.0f;
for (i = 0; i < NF; i = i + 1) {
float dx; float dz; float dd;
if (f_taken[i]) continue;
dx = f_x[i] - p_x; dz = f_z[i] - p_z;
dd = sqrt((double)(dx * dx + dz * dz));
if (best < 0 || dd < bd) { best = i; bd = dd; }
}
if (best < 0) { in_thr = 0.0f; in_turn = 0.0f; return; }
/* ⚠ 不要用"速度小"当卡住判据:起步那几帧速度本来就是 0,会把正常起步也判成卡住。
* ⚠ Do not use "speed is low" as the stuck test: the car starts at zero speed, so every take-off
* 判据是"离目标**没有更近**"连续够多帧(与速度、方向都无关)。 */
/* looks stuck. Use "has not got closer in N frames" instead - speed- and heading-agnostic. */
if (bd < go_lastd - 0.3f) { go_prog = 0; go_lastd = bd; }
else go_prog = go_prog + 1;
if (go_prog > 45) {
go_prog = 0;
go_lastd = bd;
go_escape = 34;
in_thr = -1.0f;
in_turn = 1.0f;
return;
}
want = atan2((double)(f_x[best] - p_x), (double)(f_z[best] - p_z));
diff = want - (double)p_head;
while (diff > 3.14159265) diff = diff - 6.28318531f;
while (diff < -3.14159265) diff = diff + 6.28318531f;
if (diff > 0.12f) in_turn = 1.0f;
else if (diff < -0.12f) in_turn = -1.0f;
else in_turn = 0.0f;
/* ⚠ 油门要**随距离收**:方向盘打到底也有个最小转弯半径(r ≈ v / ω),
* ⚠ Ease the throttle with distance: even at full lock the car has a minimum turn radius
* 全速冲过去会绕着果子转圈、永远进不了收果半径(实测:660 帧只收到 3 个)。
* (r = v / omega), so charging in at top speed orbits the fruit forever (measured: 3 of 8).
* 近了就慢下来,转弯半径跟着变小,才收得进去。 */
/* Slow down up close: the radius shrinks with speed and the car spirals in. */
in_thr = bd / 22.0f;
if (in_thr > 1.0f) in_thr = 1.0f;
if (in_thr < 0.22f) in_thr = 0.22f;
/* 避障:正前方有树干/金属球就把方向盘掰开。少了这一段,车会顶着障碍不动,
* Obstacle avoidance: steer away from a trunk or sphere dead ahead. Without it the car presses
* 而"直冲目标 + 固定倒车摆脱"会在同一个点上来回死循环(实测 660 帧只收到 2 个)。
* into the obstacle, and "drive straight plus a fixed reverse" loops forever (measured: 2 of 8). */
{
int t; float bias; float lx; float lz;
bias = 0.0f;
for (t = 0; t < NTREE; t = t + 1) {
tree_pos(t, &lx, &lz);
bias = bias + avoid_one(lx, lz);
}
for (t = 0; t < NMETAL; t = t + 1) {
bias = bias + avoid_one(m_x[t], m_z[t]);
}
if (bias > 0.01f) in_turn = 1.0f;
else if (bias < -0.01f) in_turn = -1.0f;
}
}
/* ══ 相机 ══════════════════════════════════════════════════════════════════ */
/* == Camera == */
/* 当前主相机 —— 判据改过它,之后要还原。 */
/* the current main camera; the criteria move it and must put it back. */
float cam_x; float cam_y; float cam_z;
void set_view(float ex, float ey, float ez, float tx, float ty, float tz) {
cam_x = ex; cam_y = ey; cam_z = ez;
ui_3d_viewport_set_camera(vp, ex, ey, ez, tx, ty, tz);
}
/* 第三人称:相机在车后上方,始终看着车前方一点。 */
/* Third person: the camera sits behind and above the car, always looking a little ahead of it. */
void game_view(void) {
float bx; float bz; float tx; float tz;
bx = p_x - sin((double)p_head) * 10.5f;
bz = p_z - cos((double)p_head) * 10.5f;
tx = p_x + sin((double)p_head) * 10.0f;
tz = p_z + cos((double)p_head) * 10.0f;
set_view(bx, terr_h(bx, bz) + 5.8f, bz, tx, p_y + CAR_LIFT - 0.6f, tz);
}
void restore_view(void) { set_view(cam_x, cam_y, cam_z, cam_x, cam_y, cam_z); }
/* ══ 采样判据用的工具 ═════════════════════════════════════════════════════ */
/* == Sampling helpers for the criteria == */
#define SGX 33
#define SGY 21
int g_samp[SGX * SGY];
int g_base[SGX * SGY];
/* 把整个视口按 SGX×SGY 打一遍,存**亮度**(判据只需要"变了没有、往哪边变")。 */
/* Sample the whole viewport on an SGX x SGY grid, storing **luminance**. */
void grab(int vp_, int w, int h, int* dst) {
int x; int y;
for (y = 0; y < SGY; y = y + 1) {
for (x = 0; x < SGX; x = x + 1) {
int px; int py;
px = (w - 1) * x / (SGX - 1);
py = (h - 1) * y / (SGY - 1);
dst[y * SGX + x] = lum(ui_3d_viewport_get_pixel(vp_, px, py));
}
}
}
/* 两遍采样里有几格**变暗了**至少 `k`(阴影判据用它)。 */
/* How many samples got **darker** by at least `k` between two passes. */
int count_darker(int* a, int* b, int k) {
int i; int n;
n = 0;
for (i = 0; i < SGX * SGY; i = i + 1) if (a[i] - b[i] >= k) n = n + 1;
return n;
}
/* 两遍采样里最大的单格差("这一盏灯有没有可见作用"用它)。 */
/* The largest single-sample gap between two passes (used for "does this light matter"). */
int max_delta(int* a, int* b) {
int i; int m; int d;
m = 0;
for (i = 0; i < SGX * SGY; i = i + 1) {
d = a[i] - b[i]; if (d < 0) d = 0 - d;
if (d > m) m = d;
}
return m;
}
/* 一个矩形区域里"暗格"的个数(镂空判据用它:缝被丢弃之后暗格应当少掉)。 */
/* How many dark cells a rectangle holds (the cut-out criterion: discarding the holes removes them). */
int count_dark(int vp_, int x0, int y0, int x1, int y1, int thr) {
int x; int y; int n;
n = 0;
for (y = y0; y < y1; y = y + 8) {
for (x = x0; x < x1; x = x + 8) {
if (lum(ui_3d_viewport_get_pixel(vp_, x, y)) < thr) n = n + 1;
}
}
return n;
}
/* 一片小区域里亮度的极差(mipmap 判据用它:缩小时高频会塌下去)。 */
/* Luminance spread over a small patch (the mipmap criterion: minification collapses it). */
int spread_patch(int vp_, int cx, int cy, int hw, int hh) {
int x; int y; int lo; int hi;
lo = 9999; hi = -1;
for (y = cy - hh; y <= cy + hh; y = y + 2) {
for (x = cx - hw; x <= cx + hw; x = x + 2) {
int c;
c = lum(ui_3d_viewport_get_pixel(vp_, x, y));
if (c < lo) lo = c;
if (c > hi) hi = c;
}
}
return hi - lo;
}
/* 一片区域的**粗糙度** = 相邻两列亮度差的平均。mipmap 抹掉的正是这个量;
* A patch's roughness: the mean absolute difference between neighbouring columns. This is exactly
* ⚠ 拿"极差"当判据是不行的:地形的**大尺度明暗**(山坡背光)也让极差很大,mipmap 消不掉它。
* what mipmap removes; a max-minus-min reading stays large because of large-scale hill shading.
*/
int rough_patch(int vp_, int cx, int cy, int hw, int hh) {
int x; int y; int t; int n;
t = 0; n = 0;
for (y = cy - hh; y <= cy + hh; y = y + 2) {
for (x = cx - hw; x <= cx + hw - 4; x = x + 2) {
int a; int b; int d;
a = lum(ui_3d_viewport_get_pixel(vp_, x, y));
b = lum(ui_3d_viewport_get_pixel(vp_, x + 4, y));
d = a - b; if (d < 0) d = 0 - d;
t = t + d; n = n + 1;
}
}
if (n == 0) return 0;
return t / n;
}
/* 一小块区域里"接近某个颜色"的格数(正交判据用它数标记有多大)。 */
/* How many cells in a small region are close to a colour (the ortho criterion measures marker size). */
int count_near(int vp_, int cx, int cy, int hw, int r, int g, int b, int tol) {
int n; int dx; int dy;
n = 0;
for (dy = 0 - hw; dy <= hw; dy = dy + 1) {
for (dx = 0 - hw; dx <= hw; dx = dx + 1) {
int c;
c = ui_3d_viewport_get_pixel(vp_, cx + dx, cy + dy);
if (rch(c) - r < tol && r - rch(c) < tol
&& gch(c) - g < tol && g - gch(c) < tol
&& bch(c) - b < tol && b - bch(c) < tol) n = n + 1;
}
}
return n;
}
/* ══ 判据 ══════════════════════════════════════════════════════════════════ */
/* == Criteria == */
void check_world(void) {
int i; int cars; int fruits; int objs;
cars = 0; fruits = 0;
for (i = 0; i < 3; i = i + 1) if (car_scene[i] > 0) cars = cars + 1;
for (i = 0; i < NF; i = i + 1) if (fruit_scene[i] > 0) fruits = fruits + 1;
printf("WORLD cars=%d/%d fruits=%d/%d cutout=%d\n", cars, 3, fruits, NF, cut_scene);
ck("glTF: all three real/companion models loaded", cars == 3 && fruits == NF && cut_scene > 0,
cars + fruits + (cut_scene > 0 ? 1 : 0), 3 + NF + 1);
/* 真模型的几何:ToyCar 15 个物体、Avocado 3 个 —— 它们**是数据**,不是写死的常量。 */
/* Real-model geometry: ToyCar gives 15 objects and Avocado 3 - data, not a hard-coded constant. */
printf("GEOM car_scene_objs=%d fruit_scene_objs=%d\n",
ui_3d_gltf_object_count(car_scene[0]), ui_3d_gltf_object_count(fruit_scene[0]));
ck("glTF: the car scene expanded into 15 objects (node tree, not one blob)",
ui_3d_gltf_object_count(car_scene[0]) == 15, ui_3d_gltf_object_count(car_scene[0]), 15);
ck("glTF: the fruit scene expanded into 3 objects",
ui_3d_gltf_object_count(fruit_scene[0]) == 3, ui_3d_gltf_object_count(fruit_scene[0]), 3);
b4[0] = 0.0f; b4[1] = 0.0f; b4[2] = 0.0f; b4[3] = 0.0f;
ui_3d_gltf_bounds(car_scene[0], (int)&b4[0]);
{
float rb;
rb = b4[3];
b4[0] = 0.0f; b4[1] = 0.0f; b4[2] = 0.0f; b4[3] = 0.0f;
ui_3d_gltf_bounds(fruit_scene[0], (int)&b4[0]);
printf("BOUNDS car_r=%.4f fruit_r=%.4f\n", rb, b4[3]);
ck("both downloaded models carry real geometry (positive bounds)",
rb > 0.0f && b4[3] > 0.0f, (int)(rb * 10000.0f), 1);
}
/* 玩家开的车**必须在画面里** —— 这是"真模型当主角"这条要求的落点。 */
/* The car the player drives must be **on screen**: that is where "a real model is the hero" lands. */
game_view();
ui_2d_present();
{
int sc; int px; int py; int a; int b; int k; int cn;
sc = ui_3d_obj_get_screen(car_root[0]);
px = sc & 65535; py = (sc >> 16) & 65535;
/* ⚠ **可见性是逐物体的、不沿父子链继承** —— 只把根藏起来,它的孩子照画不误
* ⚠ Visibility is **per object and is NOT inherited** - hiding only the root leaves every
* (第一版就是这么写的,于是"藏了跟没藏一样",判据恒假红)。
* child drawing (the first version did that and the pixel never changed). */
cn = ui_3d_gltf_object_count(car_scene[0]);
for (k = 0; k < cn; k = k + 1) {
ui_3d_obj_set_visible(ui_3d_gltf_object_at(car_scene[0], k), 0);
}
ui_2d_present();
a = ui_3d_viewport_get_pixel(vp, px, py);
for (k = 0; k < cn; k = k + 1) {
ui_3d_obj_set_visible(ui_3d_gltf_object_at(car_scene[0], k), 1);
}
ui_2d_present();
b = ui_3d_viewport_get_pixel(vp, px, py);
printf("CAR screen=%d,%d hidden=%d shown=%d\n", px, py, a, b);
ck("the downloaded car really renders on screen (it is the player)",
a != b && px > 0 && px < scr_w && py > 0 && py < scr_h, px, py);
}
objs = g_objects;
printf("OBJECTS=%d\n", objs);
ck("object count is well past 256 (that is why the cap was raised)",
objs > 256, objs, 256);
}
void check_gamma(void) {
int i; int d_off; int d_srgb;
/* 基线 = **当前就有**的 ACES(`main` 里已经设上了)。 */
/* The baseline is the ACES already in force from main. */
game_view();
ui_2d_present();
grab(vp, scr_w, scr_h, g_base);
i = ui_3d_viewport_set_gamma(vp, 0);
ui_2d_present();
grab(vp, scr_w, scr_h, g_samp);
d_off = max_delta(g_base, g_samp);
printf("GAMMA aces_vs_off_maxdelta=%d\n", d_off);
ck("output transform (ACES+sRGB) really changes the picture", d_off >= 12, d_off, 12);
ui_3d_viewport_set_gamma(vp, 1);
ui_2d_present();
grab(vp, scr_w, scr_h, g_samp);
d_srgb = max_delta(g_base, g_samp);
printf("GAMMA aces_vs_srgb_maxdelta=%d\n", d_srgb);
ck("ACES is not just sRGB (a real tone curve sits in front of it)", d_srgb >= 10, d_srgb, 10);
ui_3d_viewport_set_gamma(vp, 3);
ui_2d_present();
grab(vp, scr_w, scr_h, g_samp);
ck("back to ACES -> bit-identical", max_delta(g_base, g_samp) == 0 && i == 0,
max_delta(g_base, g_samp), 0);
}
void check_lights(void) {
int i; int j; int md; int back_ok; int bad;
bad = 0;
game_view();
for (i = 0; i < NLIGHT; i = i + 1) {
set_light(vp, i);
ui_2d_present();
grab(vp, scr_w, scr_h, g_base);
ui_3d_viewport_set_light_at(vp, i, L_DX[i], L_DY[i], L_DZ[i], 0, 0.0f);
ui_2d_present();
grab(vp, scr_w, scr_h, g_samp);
md = max_delta(g_base, g_samp);
set_light(vp, i);
ui_2d_present();
grab(vp, scr_w, scr_h, g_samp);
back_ok = (max_delta(g_base, g_samp) == 0);
printf("LIGHT[%d] maxdelta=%d restore_bit_identical=%d\n", i, md, back_ok);
if (!(md >= 5 && back_ok)) bad = bad + 1;
}
ck("all five lights visibly matter (and restore bit-identically)", bad == 0, bad, 0);
j = ui_3d_viewport_set_light_at(vp, 9, 0.0f, 1.0f, 0.0f, C_WHITE, 0.0f);
ck("light index out of range is rejected", j < 0, j, -2);
}
/* 阴影:影子的判据挑的是"**起伏地形**上那一片" —— 平面投影(`#642`)只会把物体压到
* Shadows: the criterion samples a patch on the **rolling** terrain, which the planar path (#642)
* `y = 0` 那一层上,够不着山坡 ⇒ 两条路能分开量。
* can only reach at y = 0, so the two paths separate cleanly. */
int g_shadowA[SGX * SGY];
int g_shadowB[SGX * SGY];
int g_mark;
int g_mark_end;
/* 把物体**沿光线**推进,直到穿过地形 —— 影子的落点就是这么算出来的(不是"哪块变暗了"猜的)。
* March from the caster along the light until it crosses the terrain; that is the landing point. */
void shadow_landing(float cx, float cy, float cz, float lx, float ly, float lz,
float* ox, float* oz) {
float n; float t; float px; float py; float pz;
n = sqrt((double)(lx * lx + ly * ly + lz * lz));
lx = lx / n; ly = ly / n; lz = lz / n;
px = cx; py = cy; pz = cz;
t = 0.0f;
while (t < 240.0f) {
if (py <= terr_h(px, pz)) break;
t = t + 0.2f;
px = cx - lx * t;
py = cy - ly * t;
pz = cz - lz * t;
}
*ox = px;
*oz = pz;
}
/* 一个"不可见的方向标":摆在世界某点、读它的屏幕坐标、然后藏起来。
* An invisible probe: park it at a world point, read its screen position, then hide it. */
void mark_at(float x, float y, float z) {
ui_3d_obj_set_pos(g_mark, x, y, z);
ui_3d_obj_set_visible(g_mark, 1);
ui_2d_present();
g_mark_end = ui_3d_obj_get_screen(g_mark);
ui_3d_obj_set_visible(g_mark, 0);
ui_2d_present();
}
void check_shadow(void) {
int i; int pick; float best; float lx; float ly; float lz;
float sx; float sz; float sx2; float sz2;
int px; int py; int px2; int py2;
int base1; int map1; int plan1; int base2; int map2; int old2;
/* 找一个**影子落在明显抬起的地面上**的金属球 —— 抬起来才说明平面投影够不着。 */
/* Pick the metal sphere whose shadow lands on clearly raised ground. */
pick = 0; best = -99.0f;
for (i = 0; i < NMETAL; i = i + 1) {
float ox; float oz;
shadow_landing(m_x[i], m_y[i] + 3.6f, m_z[i], L_DX[0], L_DY[0], L_DZ[0], &ox, &oz);
if (terr_h(ox, oz) > best) { best = terr_h(ox, oz); pick = i; }
}
printf("SHADOW caster=%d landing_ground=%.2f\n", pick, (double)best);
/* 相机站到**与光线方位垂直**的那一侧:主光与镜像光两处影子都能进画面。 */
/* Stand perpendicular to the light's azimuth so both shadow spots are on screen. */
set_view(m_x[pick] - 16.0f, m_y[pick] + 15.0f, m_z[pick] + 14.0f,
m_x[pick], m_y[pick], m_z[pick]);
ui_3d_viewport_set_light_at(vp, 0, L_DX[0], L_DY[0], L_DZ[0], L_COL[0], L_AMB[0]);
ui_3d_viewport_set_shadow(vp, 0);
ui_3d_viewport_set_shadow_map(vp, 0);
lx = L_DX[0]; ly = L_DY[0]; lz = L_DZ[0];
shadow_landing(m_x[pick], m_y[pick] + 3.6f, m_z[pick], lx, ly, lz, &sx, &sz);
mark_at(sx, terr_h(sx, sz) + 0.25f, sz);
px = g_mark_end & 65535; py = (g_mark_end >> 16) & 65535;
base1 = ui_3d_viewport_get_pixel(vp, px, py);
ui_3d_viewport_set_shadow(vp, 1);
ui_2d_present();
plan1 = ui_3d_viewport_get_pixel(vp, px, py);
ui_3d_viewport_set_shadow(vp, 0);
ui_3d_viewport_set_shadow_map(vp, 1);
ui_2d_present();
map1 = ui_3d_viewport_get_pixel(vp, px, py);
ui_3d_viewport_set_shadow_map(vp, 0);
ui_2d_present();
printf("SHADOW at=(%.1f,%.1f) screen=%d,%d ground=%.2f base=%d planar=%d map=%d\n",
(double)sx, (double)sz, px, py, (double)terr_h(sx, sz),
lum(base1), lum(plan1), lum(map1));
ck("the shadow map puts a shadow on the sloped landing point",
lum(map1) < lum(base1) - 45, lum(map1), lum(base1));
ck("the planar path cannot reach that slope (that pixel stays bit-identical)",
plan1 == base1, plan1, base1);
/* 光转到另一侧:**新的落点变暗、老的落点亮回来**(影子跟着光走)。 */
/* Move the key light across: the new landing point darkens and the old one lights up. */
ui_3d_viewport_set_light_at(vp, 0, 0.0f - lx, ly, 0.0f - lz, L_COL[0], L_AMB[0]);
ui_3d_viewport_set_shadow_map(vp, 0);
ui_2d_present();
base2 = ui_3d_viewport_get_pixel(vp, px, py);
shadow_landing(m_x[pick], m_y[pick] + 3.6f, m_z[pick], 0.0f - lx, ly, 0.0f - lz, &sx2, &sz2);
mark_at(sx2, terr_h(sx2, sz2) + 0.25f, sz2);
px2 = g_mark_end & 65535; py2 = (g_mark_end >> 16) & 65535;
ui_3d_viewport_set_shadow_map(vp, 0);
ui_2d_present();
old2 = ui_3d_viewport_get_pixel(vp, px, py);
ui_3d_viewport_set_shadow_map(vp, 1);
ui_2d_present();
map2 = ui_3d_viewport_get_pixel(vp, px2, py2);
printf("SHADOW moved new_at=(%.1f,%.1f) screen=%d,%d base=%d map=%d old_spot=%d\n",
(double)sx2, (double)sz2, px2, py2, lum(base2), lum(map2), lum(old2));
ck("the shadow follows the light (the old spot lights up, the new one darkens)",
map2 != base2 && lum(old2) > lum(map1) + 30, lum(old2), lum(map1));
ui_3d_viewport_set_light_at(vp, 0, L_DX[0], L_DY[0], L_DZ[0], L_COL[0], L_AMB[0]);
ui_3d_viewport_set_shadow_map(vp, 0);
ui_3d_viewport_set_shadow(vp, 0);
}
/* 镂空:把栅栏摆满画面,数"暗格" —— 缝被丢弃之后暗格应当**明显少掉**。
* Cut-out: fill the view with the fence and count **dark** cells; discarding the holes must
* ⚠ 关掉镂空时,缝里露出来的是贴图自己的 RGB(那两格画的是透明黑)⇒ 暗格变多。
* cut that count appreciably. With the cutoff off the holes show the atlas RGB, which is black. */
void check_cutout(void) {
int i; int n; int dark_on; int dark_off; int back;
n = ui_3d_gltf_object_count(cut_scene);
/* ⚠ 底色要临时**改成亮的**:镂空缝被丢弃之后露出来的是底色,只有底色亮,
* ⚠ Brighten the clear colour for this check: with the holes discarded the clear colour shows
* 才分得清"黑缝"(关着镂空)与"被丢掉"(开着镂空)—— 深色底时两者都是暗的,判据空转。
* through, which is the only way to tell a black hole from a discarded one. */
ui_3d_viewport_set_bg(vp, argb(226, 214, 190));
set_view(36.0f, terr_h(36.0f, 0.0f) + 4.0f, 0.0f,
FENCE_R + 6.0f, terr_h(36.0f, 0.0f) + 4.0f, 0.0f);
ui_2d_present();
dark_on = count_dark(vp, 40, 60, scr_w - 40, scr_h - 60, 110);
for (i = 0; i < n; i = i + 1) {
ui_3d_obj_alpha_test(ui_3d_gltf_object_at(cut_scene, i), 0.0f);
}
ui_2d_present();
dark_off = count_dark(vp, 40, 60, scr_w - 40, scr_h - 60, 110);
printf("CUTOUT dark_with_cutoff=%d dark_without=%d objects=%d\n", dark_on, dark_off, n);
ck("cut-out really discards (the holes are gone, the background shows through)",
dark_off >= 200 && dark_on * 4 <= dark_off, dark_on, dark_off);
for (i = 0; i < n; i = i + 1) {
ui_3d_obj_alpha_test(ui_3d_gltf_object_at(cut_scene, i), 0.5f);
}
ui_2d_present();
back = count_dark(vp, 40, 60, scr_w - 40, scr_h - 60, 110);
ck("cutoff back on -> the count returns exactly", back == dark_on, back, dark_on);
ui_3d_viewport_set_bg(vp, C_DARK);
}
/* mipmap:小地图是**整片地形缩到 104 像素**的俯视图 —— 每像素盖住好几个纹素,
* Mipmap: the minimap squeezes the whole terrain into 104 pixels, so each pixel covers several
* 没有 mip 就会闪成一片噪声(判据量的是亮度极差)。
* texels; without mips it turns to noise, which the criterion measures as a luminance spread. */
void check_mipmap(void) {
int i; int sp_nomip; int sp_mips; int sp_back;
/* 先把小地图上的标记藏起来:果子标记是**自发光**的亮点,会把粗糙度读数抬起来。 */
/* Hide the minimap markers first: the emissive dots would inflate the roughness reading. */
for (i = 0; i < NF; i = i + 1) ui_3d_obj_set_visible(mini_mark[i], 0);
ui_3d_obj_set_visible(mini_player, 0);
ui_3d_obj_sampler(terrain_obj, 0, 0, 1, 1);
ui_3d_obj_sampler(terrain_mini, 0, 0, 1, 1);
ui_2d_present();
sp_nomip = rough_patch(vpmini, mini_w / 2, mini_h / 2, 26, 22);
ui_3d_obj_sampler(terrain_obj, 0, 0, 5, 1);
ui_3d_obj_sampler(terrain_mini, 0, 0, 5, 1);
ui_2d_present();
sp_mips = rough_patch(vpmini, mini_w / 2, mini_h / 2, 26, 22);
printf("MIPMAP rough_no_mip=%d rough_trilinear=%d\n", sp_nomip, sp_mips);
ck("mipmap collapses the far-ground shimmer (trilinear vs point sampling)",
sp_nomip >= 20 && sp_mips * 3 <= sp_nomip * 2, sp_mips, sp_nomip);
ui_3d_obj_sampler(terrain_obj, 0, 0, 1, 1);
ui_3d_obj_sampler(terrain_mini, 0, 0, 1, 1);
ui_2d_present();
sp_back = rough_patch(vpmini, mini_w / 2, mini_h / 2, 26, 22);
ck("minFilter back to linear -> the roughness returns exactly", sp_back == sp_nomip, sp_back, sp_nomip);
{
int r;
r = ui_3d_obj_sampler(terrain_obj, 0, 0, 7, 1);
ck("out-of-range minFilter is rejected (not clamped)", r < 0, r, -2);
}
ui_3d_obj_sampler(terrain_obj, 0, 0, 5, 1);
ui_3d_obj_sampler(terrain_mini, 0, 0, 5, 1);
for (i = 0; i < NF; i = i + 1) ui_3d_obj_set_visible(mini_mark[i], f_taken[i] ? 0 : 1);
ui_3d_obj_set_visible(mini_player, 1);
}
/* PBR 与环境反射:金属球**照得出天**是这一条的全部 —— 关掉 PBR 时天空盒只是背景,
* PBR and environment reflection - the metal sphere **mirroring the sky** is the whole point.
* 对球上的像素一个字节都不该动。
* With PBR off the skybox is only a background and must not move one byte on that sphere. */
void check_pbr_env(void) {
int i; int px; int py; int sc;
int off_col; int noenv_col; int sky_col; int mag_col; int fixed1; int fixed2;
/* 相机贴近金属球,让它占满画面中央 ⇒ 采样点必然落在球面上。 */
/* Get close to the metal sphere so it fills the middle and the probe lands on it. */
set_view(m_x[0] + 6.0f, m_y[0] + 5.2f, m_z[0] + 6.0f, m_x[0], m_y[0] + 3.6f, m_z[0]);
ui_3d_viewport_set_pbr(vp, 1);
ui_3d_viewport_set_skybox(vp, 0);
for (i = 0; i < NMETAL; i = i + 1) ui_3d_obj_set_pbr(metal_obj[i], 1.0f, 0.12f);
sc = ui_3d_obj_get_screen(metal_obj[0]);
px = sc & 65535; py = (sc >> 16) & 65535;
/* 先证明这一格**真的在球上**(把球藏掉,同一格必须变)—— 否则后面全是隔空比。 */
/* First prove the probe is **on the sphere** (hide it and the pixel must change). */
ui_3d_obj_set_visible(metal_obj[0], 0);
ui_2d_present();
off_col = ui_3d_viewport_get_pixel(vp, px, py);
ui_3d_obj_set_visible(metal_obj[0], 1);
ui_2d_present();
noenv_col = ui_3d_viewport_get_pixel(vp, px, py);
ck("the probe really sits on the metal sphere", off_col != noenv_col, off_col, noenv_col);
ui_3d_viewport_set_skybox(vp, sky_tex);
ui_2d_present();
sky_col = ui_3d_viewport_get_pixel(vp, px, py);
ui_3d_viewport_set_skybox(vp, g_magenta);
ui_2d_present();
mag_col = ui_3d_viewport_get_pixel(vp, px, py);
printf("ENV no_env=%d,%d,%d sky=%d,%d,%d magenta_sky=%d,%d,%d\n",
rch(noenv_col), gch(noenv_col), bch(noenv_col),
rch(sky_col), gch(sky_col), bch(sky_col),
rch(mag_col), gch(mag_col), bch(mag_col));
ck("PBR makes the metal sphere reflect the environment (no env -> sky changes it)",
sky_col != noenv_col, sky_col, noenv_col);
/* ★ 反射必须是**有色**的(跟着天空走),不是"整体提亮成灰的"。 */
/* ★ The reflection must be **coloured** by the sky, not a uniform grey lift. */
ck("the reflection is tinted by the sky (a magenta sky gives a magenta sphere)",
rch(mag_col) > gch(mag_col) + 40 && bch(mag_col) > gch(mag_col) + 40
&& bch(sky_col) >= rch(sky_col),
gch(mag_col), rch(mag_col));
/* 反向:**关掉 PBR** 时,换天空盒对球上的像素一个字节都不许动。 */
/* Reverse: with PBR **off**, swapping the skybox must not touch the sphere at all. */
ui_3d_viewport_set_pbr(vp, 0);
ui_3d_viewport_set_skybox(vp, sky_tex);
ui_2d_present();
fixed1 = ui_3d_viewport_get_pixel(vp, px, py);
ui_3d_viewport_set_skybox(vp, g_magenta);
ui_2d_present();
fixed2 = ui_3d_viewport_get_pixel(vp, px, py);
ck("skybox alone (PBR off) does not touch the sphere - it is only a background",
fixed1 == fixed2, fixed1, fixed2);
ui_3d_viewport_set_skybox(vp, sky_tex);
ui_3d_viewport_set_pbr(vp, 1);
}
/* 正交:小地图下"离得远就变小"必须**不发生** —— 同一个标记挪远一倍,屏幕上的像素数不变;
* Orthographic: on the minimap "farther means smaller" must **not** happen - the same marker
* 换成透视立刻会变。
* moved twice as far keeps its pixel count; switching to perspective changes it at once. */
void check_ortho(void) {
int i; int near_n; int far_n; int p_near; int p_far;
int cx; int cy; int ox; int oy; int sc;
ox = scr_w - mini_w - 10;
oy = 10;
for (i = 0; i < NF; i = i + 1) ui_3d_obj_set_visible(mini_mark[i], 0);
ui_3d_obj_set_visible(mini_player, 0);
ui_3d_obj_set_visible(mini_mark[0], 1);
/* 小地图相机临时摆成**斜视**:正交与透视的差别在正俯视下看不出来(远近一样)。 */
/* Tilt the minimap camera for this check: straight down, ortho and perspective look alike. */
ui_3d_viewport_set_camera(vpmini, 0.0f, 26.0f, -22.0f, 0.0f, 0.0f, 14.0f);
ui_3d_viewport_set_fov(vpmini, 50.0f, 0.5f, 4000.0f);
ui_3d_viewport_set_ortho(vpmini, 40.0f);
ui_3d_obj_set_pos(mini_mark[0], 0.0f, terr_h(0.0f, 0.0f) + 4.0f, 0.0f);
ui_2d_present();
sc = ui_3d_obj_get_screen(mini_mark[0]);
cx = (sc & 65535) - ox; cy = ((sc >> 16) & 65535) - oy;
near_n = count_near(vpmini, cx, cy, 16, 255, 60, 60, 90);
/* 把标记挪到 40 单位以外(比相机到原点的距离还长),**大小必须不变** —— 这就是正交的签名。 */
/* Move the marker 40 units away; its size must not change. That is the orthographic signature. */
ui_3d_obj_set_pos(mini_mark[0], 0.0f, terr_h(0.0f, 40.0f) + 4.0f, 40.0f);
ui_2d_present();
sc = ui_3d_obj_get_screen(mini_mark[0]);
cx = (sc & 65535) - ox; cy = ((sc >> 16) & 65535) - oy;
far_n = count_near(vpmini, cx, cy, 16, 255, 60, 60, 90);
/* 换成透视:**标记不动**,同一格再量一次 —— 远的那一个立刻变小。 */
/* Switch to perspective, leave the marker where it is and measure the same box again. */
ui_3d_viewport_set_ortho(vpmini, 0.0f);
ui_2d_present();
p_far = count_near(vpmini, cx, cy, 16, 255, 60, 60, 90);
ui_3d_obj_set_pos(mini_mark[0], 0.0f, terr_h(0.0f, 0.0f) + 4.0f, 0.0f);
ui_2d_present();
sc = ui_3d_obj_get_screen(mini_mark[0]);
cx = (sc & 65535) - ox; cy = ((sc >> 16) & 65535) - oy;
p_near = count_near(vpmini, cx, cy, 16, 255, 60, 60, 90);
printf("ORTHO near=%d far=%d | perspective near=%d far=%d\n", near_n, far_n, p_near, p_far);
ck("orthographic: the same marker keeps its size forty units farther away",
near_n >= 20 && (near_n - far_n) * 100 <= near_n * 20, near_n, far_n);
ck("switching to perspective makes the far marker smaller",
p_far * 100 <= p_near * 65, p_far, p_near);
/* 复位小地图:相机、正交半径、标记可见性。 */
/* Restore the minimap: camera, ortho half-height, marker visibility. */
ui_3d_viewport_set_ortho(vpmini, 52.0f);
ui_3d_viewport_set_camera(vpmini, 0.0f, 120.0f, 0.0f, 0.0f, 0.0f, 0.0f);
ui_3d_viewport_set_fov(vpmini, 45.0f, 1.0f, 4000.0f);
for (i = 0; i < NF; i = i + 1) ui_3d_obj_set_visible(mini_mark[i], f_taken[i] ? 0 : 1);
ui_3d_obj_set_visible(mini_player, 1);
}
void check_rules(void) {
int i; int s0; int moved;
float x0; float z0;
reset_game();
place_car();
/* ① 真的会动:踩满油门跑 0.5 秒(用固定 dt,不依赖真实帧率)。 */
/* (1) It really moves: full throttle for 0.5 s of fixed dt, independent of the real frame rate. */
x0 = p_x; z0 = p_z;
for (i = 0; i < 30; i = i + 1) { in_thr = 1.0f; in_turn = 0.0f; step_game(0.0167f); }
moved = (int)(sqrt((double)((p_x - x0) * (p_x - x0) + (p_z - z0) * (p_z - z0))) * 100.0f);
printf("RULE moved_0.5s=%.2f units\n", (double)moved / 100.0);
ck("the car really moves (throttle for half a second)", moved > 100, moved, 100);
/* ② 收集:把车直接摆到果子上,走一步 ⇒ 记分 +1、果子消失、小地图标记也没了。 */
/* (2) Collection: park the car on a fruit, step once: score +1, the fruit vanishes. */
reset_game();
p_x = f_x[0]; p_z = f_z[0]; p_head = 0.0f; p_speed = 0.0f;
s0 = score;
in_thr = 0.0f; in_turn = 0.0f;
step_game(0.0167f);
printf("RULE collect score %d -> %d taken=%d\n", s0, score, f_taken[0]);
ck("driving onto a fruit scores a point and takes it off the field",
score == s0 + 1 && f_taken[0] == 1, score, s0 + 1);
/* ③ 不重复计分:还站在那儿再走十步,分数不许再动。 */
/* (3) No double counting: stay there for ten more steps and the score must not move. */
s0 = score;
for (i = 0; i < 10; i = i + 1) step_game(0.0167f);
ck("staying on the spot does not score twice", score == s0, score, s0);
/* ④ 结束条件之一:把 8 个果子全收掉 ⇒ 通关。 */
/* (4) End condition one: collect all 8 fruit -> win. */
for (i = 0; i < NF; i = i + 1) {
p_x = f_x[i]; p_z = f_z[i]; p_speed = 0.0f;
step_game(0.0167f);
}
printf("RULE win score=%d state=%d\n", score, state);
ck("collecting every fruit ends the round as a win", score == NF && state == 1, state, 1);
/* ⑤ 结束条件之二:重新开一局,把时钟耗光 ⇒ 超时结束(而不是一直玩下去)。 */
/* (5) End condition two: restart and burn the clock -> the round ends on time. */
reset_game();
for (i = 0; i < 200 && state == 0; i = i + 1) step_game(1.0f);
printf("RULE timeout time_left=%.2f state=%d\n", (double)time_left, state);
ck("running out of the clock ends the round", state == 2, state, 2);
reset_game();
place_car();
game_view();
}
/* ══ 主程序 ═══════════════════════════════════════════════════════════════ */
/* == Main == */
void build_metal(int i) {
float th;
th = 6.28318531f * (float)i / (float)NMETAL + 0.2f;
m_x[i] = 36.0f * cos((double)th);
m_z[i] = 36.0f * sin((double)th);
m_y[i] = terr_h(m_x[i], m_z[i]);
ui_3d_obj_set_pos(ped_obj[i], m_x[i], m_y[i] + 1.1f, m_z[i]);
ui_3d_obj_set_pos(metal_obj[i], m_x[i], m_y[i] + 3.6f, m_z[i]);
ui_3d_obj_set_color(metal_obj[i], C_WHITE);
ui_3d_obj_set_pbr(metal_obj[i], 1.0f, 0.12f);
ui_3d_obj_set_material(metal_obj[i], argb(240, 240, 240), 200.0f);
}
int main(void) {
int i;
int ori;
const char* used_car;
const char* used_fruit;
const char* used_cut;
int gam;
g_fails = 0;
g_objects = 0;
rnd = 20261008;
ui_win_open_ex("3D 漫游寻果 Rover 3D", 0, 0, VML_WIN_ROTATABLE, VML_WIN_NO_GAMEPAD);
ori = ui_scr_get_orientation();
scr_w = ui_scr_get_w_ex(ori, VML_CHROME_NONE);
scr_h = ui_scr_get_h_ex(ori, VML_CHROME_NONE);
if (scr_w <= 0) scr_w = 393;
if (scr_h <= 0) scr_h = 757;
printf("SCREEN=%dx%d\n", scr_w, scr_h);
vp = ui_3d_viewport_create(0, 0, scr_w, scr_h, 0);
if (vp <= 0) {
/* 拿不到 3D 设备就**如实报**,不装作画出来了。 */
/* No 3D device: say so out loud instead of pretending something was drawn. */
printf("NO_3D_DEVICE=1 vp=%d\n", vp);
return 0;
}
mini_w = 108;
mini_h = 108;
vpmini = ui_3d_viewport_create(scr_w - mini_w - 10, 10, mini_w, mini_h, 0);
printf("VIEWPORTS main=%d mini=%d\n", vp, vpmini);
ui_3d_viewport_set_bg(vp, C_DARK);
ui_3d_viewport_set_depth(vp, 1);
ui_3d_viewport_set_fov(vp, 52.0f, 0.5f, 3000.0f);
init_lights();
for (i = 0; i < NLIGHT; i = i + 1) set_light(vp, i);
ui_3d_viewport_set_gamma(vp, 3);
/* 雾只负责"远处淡一点",别把半场都糊掉(60/170 那一版把山脊都洗白了)。 */
/* Fog is atmosphere only; the 60/170 pair washed out whole ridges. */
ui_3d_viewport_set_fog(vp, argb(146, 162, 172), 95.0f, 230.0f);
/* ── 贴图 ─────────────────────────────────────────────────────────────── */
/* -- Textures -- */
ground_tex = make_ground_tex();
sky_tex = make_sky_tex();
/* 判据用的第二张天空盒:**一片纯洋红** —— 金属球照出什么颜色,就是环境给的。 */
/* The criteria's second skybox: solid magenta, so whatever the sphere mirrors came from the env. */
ui_2d_clear(0);
ui_2d_rect(0, 0, 16, 16, argb(255, 24, 236), 1, 0, 0);
ui_2d_present();
g_magenta = ui_2d_get_image(0, 0, 16, 16);
ui_2d_clear(0);
ui_2d_present();
printf("TEXTURES ground=%d sky=%d magenta=%d\n", ground_tex, sky_tex, g_magenta);
ui_3d_viewport_set_skybox(vp, sky_tex);
/* ── 地形(共享给主视口与小地图)──────────────────────────────────────── */
/* -- Terrain, shared by the main viewport and the minimap -- */
gen_terrain(48, 52.0f, 24.0f);
printf("TERRAIN verts=%d tris=%d floats_needed=%d cap=%d\n",
g_vn, g_in / 3, g_vn * 8 + g_in, MB_CAP);
ck("mesh buffer fits the terrain", g_vn * 8 + g_in <= MB_CAP, g_vn * 8 + g_in, MB_CAP);
terrain_mesh = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
terrain_obj = ui_3d_obj_create(vp, terrain_mesh);
terrain_mini = ui_3d_obj_create(vpmini, terrain_mesh);
g_objects = g_objects + 2;
ui_3d_obj_set_tex(terrain_obj, ground_tex);
ui_3d_obj_set_tex(terrain_mini, ground_tex);
/* 地面默认就用**三线性**(mipmap):远处不闪;`check_mipmap` 会把它拨回 1 再量一次。 */
/* Trilinear (mipmap) by default so the far ground does not shimmer; check_mipmap toggles it. */
ui_3d_obj_sampler(terrain_obj, 0, 0, 5, 1);
ui_3d_obj_sampler(terrain_mini, 0, 0, 5, 1);
ui_3d_obj_set_color(terrain_obj, C_WHITE);
ui_3d_obj_set_color(terrain_mini, C_WHITE);
ui_3d_obj_set_pbr(terrain_obj, 0.0f, 0.92f);
ui_3d_obj_set_pbr(terrain_mini, 0.0f, 0.92f);
/* ── 石头(物体数靠它们撑过 256)──────────────────────────────────────── */
/* -- Rocks; these are what push the object count past 256 -- */
gen_sphere(4, 6);
rock_mesh = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
for (i = 0; i < NROCK; i = i + 1) {
float rx; float rz; float s;
rx = (frnd() - 0.5f) * 84.0f;
rz = (frnd() - 0.5f) * 84.0f;
if (rx * rx + rz * rz > FENCE_R * FENCE_R - 4.0f) { i = i - 1; continue; }
rock_obj[i] = ui_3d_obj_create(vp, rock_mesh);
g_objects = g_objects + 1;
s = 0.22f + frnd() * 0.40f;
ui_3d_obj_set_pos(rock_obj[i], rx, terr_h(rx, rz) + s * 0.45f, rz);
ui_3d_obj_set_scale(rock_obj[i], s);
ui_3d_obj_set_color(rock_obj[i], argb(132 + (int)(frnd() * 54.0f), 128 + (int)(frnd() * 50.0f),
116 + (int)(frnd() * 44.0f)));
ui_3d_obj_set_rot(rock_obj[i], frnd() * 360.0f, frnd() * 360.0f, frnd() * 360.0f);
ui_3d_obj_set_pbr(rock_obj[i], 0.0f, 0.85f);
}
/* ── 金属球 + 石座(PBR / 环境反射 / 阴影的载体)───────────────────────── */
/* -- Metal spheres on stone pedestals: the PBR / reflection / shadow carrier -- */
gen_sphere(12, 16);
sphere_mesh = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
gen_box(2.0f, 1.0f, 2.0f);
box_mesh = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
for (i = 0; i < NMETAL; i = i + 1) {
ped_obj[i] = ui_3d_obj_create(vp, box_mesh);
metal_obj[i] = ui_3d_obj_create(vp, sphere_mesh);
g_objects = g_objects + 2;
ui_3d_obj_set_scale(ped_obj[i], 1.1f);
ui_3d_obj_set_scale(metal_obj[i], 1.9f);
ui_3d_obj_set_color(ped_obj[i], argb(168, 160, 148));
ui_3d_obj_set_pbr(ped_obj[i], 0.0f, 0.7f);
build_metal(i);
}
/* ── 小地图的标记(正交俯视;果子 + 玩家)────────────────────────────── */
/* -- Minimap markers, seen under an orthographic top-down camera -- */
gen_flat_quad(3.2f);
marker_mesh = ui_3d_mesh_create(mb_ptr(), g_vn, g_in);
ui_3d_viewport_set_bg(vpmini, argb(14, 18, 14));
ui_3d_viewport_set_depth(vpmini, 1);
ui_3d_viewport_set_camera(vpmini, 0.0f, 120.0f, 0.0f, 0.0f, 0.0f, 0.0f);
ui_3d_viewport_set_fov(vpmini, 45.0f, 1.0f, 4000.0f);
ui_3d_viewport_set_ortho(vpmini, 52.0f);
for (i = 0; i < NLIGHT; i = i + 1) {
ui_3d_viewport_set_light_at(vpmini, i, L_DX[i], L_DY[i], L_DZ[i], L_COL[i], 0.2f);
}
for (i = 0; i < NF; i = i + 1) {
mini_mark[i] = ui_3d_obj_create(vpmini, marker_mesh);
g_objects = g_objects + 1;
ui_3d_obj_set_emissive(mini_mark[i], 1);
ui_3d_obj_set_color(mini_mark[i], argb(255, 60, 60));
}
/* 主视口里也放一个同样的方片,当阴影判据的"不可见方向标"(平时藏着)。 */
/* A twin quad in the main viewport serves as the shadow criteria's invisible probe. */
g_mark = ui_3d_obj_create(vp, marker_mesh);
g_objects = g_objects + 1;
ui_3d_obj_set_visible(g_mark, 0);
mini_player = ui_3d_obj_create(vpmini, marker_mesh);
g_objects = g_objects + 1;
ui_3d_obj_set_emissive(mini_player, 1);
ui_3d_obj_set_color(mini_player, argb(250, 232, 96));
/* ── glTF:真模型 ────────────────────────────────────────────────────── */
/* -- glTF: the real models -- */
used_car = ""; used_fruit = ""; used_cut = "";
for (i = 0; i < 3; i = i + 1) {
car_scene[i] = load_first(vp, kCarPaths, 3, &used_car);
if (car_scene[i] <= 0) break;
car_root[i] = ui_3d_gltf_root(car_scene[i]);
g_objects = g_objects + ui_3d_gltf_object_count(car_scene[i]);
ui_3d_obj_set_scale(car_root[i], CAR_SCALE);
}
for (i = 0; i < NF; i = i + 1) {
fruit_scene[i] = load_first(vp, kFruitPaths, 3, &used_fruit);
if (fruit_scene[i] <= 0) break;
fruit_root[i] = ui_3d_gltf_root(fruit_scene[i]);
g_objects = g_objects + ui_3d_gltf_object_count(fruit_scene[i]);
ui_3d_obj_set_scale(fruit_root[i], FRUIT_SCALE);
ui_3d_obj_set_spin(fruit_root[i], 0.0f, 48.0f, 0.0f);
}
cut_scene = load_first(vp, kCutPaths, 3, &used_cut);
if (cut_scene > 0) g_objects = g_objects + ui_3d_gltf_object_count(cut_scene);
printf("GLTF car=%s fruit=%s cutout=%s\n", used_car, used_fruit, used_cut);
/* ── 摆位 ─────────────────────────────────────────────────────────────── */
/* -- Placement -- */
place_fruits_spots();
printf("FRUITS");
for (i = 0; i < NF; i = i + 1) printf(" (%.1f,%.1f)", (double)f_x[i], (double)f_z[i]);
printf("\n");
for (i = 0; i < NF; i = i + 1) {
ui_3d_obj_set_pos(fruit_root[i], f_x[i], terr_h(f_x[i], f_z[i]) + FRUIT_LIFT, f_z[i]);
ui_3d_obj_set_pos(mini_mark[i], f_x[i], 0.0f, f_z[i]);
}
{
float px2; float pz2;
px2 = 20.0f; pz2 = -26.0f;
ui_3d_obj_set_pos(car_root[1], px2, terr_h(px2, pz2) + CAR_LIFT, pz2);
ui_3d_obj_set_rot(car_root[1], 0.0f, 35.0f, 0.0f);
px2 = -28.0f; pz2 = 16.0f;
ui_3d_obj_set_pos(car_root[2], px2, terr_h(px2, pz2) + CAR_LIFT, pz2);
ui_3d_obj_set_rot(car_root[2], 0.0f, 210.0f, 0.0f);
}
reset_game();
place_car();
game_view();
/* ══ 判据段 ═════════════════════════════════════════════════════════════
* == The criteria ==
* ⚠ 这一段会**故意**把相机、灯光、开关拨来拨去,逐条量完再放回去 —— 所以每一小段
* ⚠ This section deliberately moves the camera, lights and switches around, measuring and then
* 结束前都要 `restore_view()`/复位,否则下一条判据量到的是上一条的残余状态。 */
/* restoring after each item; otherwise the next criterion measures leftovers. */
/* ⚠⚠ **先把动画时钟钉住**(手动挡):果子在自转、宿主每帧都在推进时钟 ⇒ 不钉住的话
* ⚠⚠ **Pin the animation clock first**: the fruit spins and the host advances the clock every
* "同一状态读两次读数一样"这条前提不成立,所有"关掉再打开要逐位还原"的判据全会假红
* frame, so "the same state reads the same twice" fails and every bit-identical restore
* (第一版实测:伽马关掉还原时差了 284,而画面本身是对的)。
* criterion goes falsely red (measured in the first version: 284 off on a correct picture). */
if (ROVER_CHECK) {
ui_3d_viewport_set_time(vp, 0.0f);
ui_3d_viewport_set_time(vpmini, 0.0f);
check_world();
check_gamma();
check_lights();
ui_3d_viewport_set_pbr(vp, 1);
check_shadow();
check_cutout();
check_mipmap();
check_pbr_env();
check_ortho();
check_rules();
/* 时钟交回宿主(`-1` 必须是 float 位模式:写成 int 时宿主按 NaN 读、**静默拒绝**)。 */
/* Hand the clock back to the host; -1 must travel as a float bit pattern or the host rejects it. */
ui_3d_viewport_set_time(vp, -1.0f);
ui_3d_viewport_set_time(vpmini, -1.0f);
}
/* 判据段改过的东西全部复位,回到"正常的游戏状态"。 */
/* Put everything the criteria touched back, so the game runs in its normal state. */
ui_3d_viewport_set_pbr(vp, 1);
ui_3d_viewport_set_gamma(vp, 3);
ui_3d_viewport_set_shadow_map(vp, 0);
ui_3d_viewport_set_shadow(vp, 0);
ui_3d_viewport_set_ortho(vpmini, 52.0f);
ui_2d_clear(0);
ui_2d_present();
/* ══ 跑起来 ═════════════════════════════════════════════════════════════ */
/* == Run == */
frame = 0;
last_ms = ui_time_get_ms();
gam = 3;
{
float dt;
float acc;
while (1) {
int keys_thr; int keys_turn; int now; char buf[96];
/* 输入:方向盘 / WASD(自检模式下由自动驾驶写同一对变量)。 */
/* Input: arrow keys / WASD (in self-check mode the autopilot writes the same pair). */
keys_thr = 0; keys_turn = 0;
if (ROVER_PLAY) {
if (ui_key_is_down(VML_KEY_UP) || ui_key_is_down(87)) keys_thr = 1;
if (ui_key_is_down(VML_KEY_DOWN) || ui_key_is_down(83)) keys_thr = keys_thr - 1;
if (ui_key_is_down(VML_KEY_LEFT) || ui_key_is_down(65)) keys_turn = keys_turn - 1;
if (ui_key_is_down(VML_KEY_RIGHT) || ui_key_is_down(68)) keys_turn = keys_turn + 1;
in_thr = (float)keys_thr;
in_turn = (float)keys_turn;
if (ui_key_is_down(VML_KEY_ESCAPE)) break;
/* 'G' 键切换输出变换。 */
/* 'G' cycles the output transform. */
if (ui_key_is_down(71)) {
gam = gam + 1;
if (gam > 3) gam = 0;
ui_3d_viewport_set_gamma(vp, gam);
}
if (state != 0 && ui_key_is_down(VML_KEY_SPACE)) {
reset_game();
place_car();
game_view();
}
} else {
/* 自动驾驶**一直开到这一局结束**(收满或超时),不是开固定帧数就撂挑子 ——
* The autopilot drives until the round ends, not for a fixed number of frames:
* 原先写到 `AUTO_FRAMES` 就松手,于是车停在半路、剩下几百帧画面一动不动。
* the first version let go after AUTO_FRAMES and the car just sat there. */
if (state == 0) autopilot();
else { in_thr = 0.0f; in_turn = 0.0f; }
}
now = ui_time_get_ms();
dt = (float)(now - last_ms) / 1000.0f;
last_ms = now;
if (dt > 0.5f) dt = 0.5f;
if (dt < 0.0f) dt = 0.0f;
acc = dt;
step_game(acc);
game_view();
/* 小地图:玩家标记跟着走,果子标记随收集熄灭。 */
/* Minimap: the player marker follows, the fruit markers go out as they are taken. */
ui_3d_obj_set_pos(mini_player, p_x, 0.0f, p_z);
ui_2d_clear(0);
ui_2d_text(12, 10, "3D 漫游寻果 Rover 3D", C_HUD, 15, VML_ANCHOR_LEFT);
sprintf(buf, "score %d / %d time %.1f s", score, NF, (double)time_left);
ui_2d_text(12, 32, buf, C_HUD_DIM, 12, VML_ANCHOR_LEFT);
sprintf(buf, "objects %d lights %d", g_objects, NLIGHT);
ui_2d_text(12, 50, buf, C_HUD_DIM, 11, VML_ANCHOR_LEFT);
if (state == 1) ui_2d_text(12, 74, "全部收完!WIN", C_GOOD, 15, VML_ANCHOR_LEFT);
if (state == 2) ui_2d_text(12, 74, "时间到 TIME UP", C_WARN, 15, VML_ANCHOR_LEFT);
if (state != 0 && ROVER_PLAY) ui_2d_text(12, 96, "按空格重来 SPACE", C_HUD, 12, VML_ANCHOR_LEFT);
ui_2d_present();
/* ⚠ **帧节流**:不配这一句,主循环会跑得比屏幕快几个数量级、画面一闪而过。
* ⚠ **Frame throttle**: without it the main loop outruns the display and the picture flickers by.
* 上屏节奏由宿主管(`PaceFrame`),这里只是"等输入最多等多久"。 */
/* The host owns the pacing; this line only means "wait for input at most this long". */
ui_msg_fetch_wait(40);
frame = frame + 1;
if (ROVER_PLAY == 0 && state != 0) break;
if (ROVER_PLAY == 0 && frame >= AUTO_FRAMES * 7) break;
}
}
printf("ROVER-DONE frames=%d score=%d taken=%d state=%d\n", frame, score, score, state);
if (ROVER_CHECK) printf("ROVER3D_GAME %s (fails=%d)\n", g_fails == 0 ? "PASS" : "FAIL", g_fails);
else printf("ROVER3D_GAME SKIP (checks off; not a pass)\n");
return 0;
}