c/app/level.c · 38878 字节 · 下载原文
/* level.c —— 常用小程序⑦:**水平仪 / 指南针**(第 4 层:`ui_*` 图形界面)
* level.c -- Everyday utility #7: a **spirit level and compass** (layer 4: the `ui_*` graphical interface)
*
* 干什么:一屏两个仪表。上面是**圆形气泡水平仪** —— 外圈 + 中心十字 + 一个随倾斜移动的气泡,
* What it does: two instruments on one screen. Above is a **circular bubble level** -- an outer ring,
* 气泡停进中间那个圈就是水平;右边三行是加速度计三个轴的读数(单位**毫克**,1000 = 1g,
* a crosshair and a bubble that moves with the tilt; the bubble resting in the middle circle means level.
* 放平时 z ≈ 1000),下面一句"水平 / 倾斜 n°"的判定。
* To the right, three lines read the accelerometer axes (in **milli-g**, 1000 = 1g, so lying flat gives
* z ~ 1000), with a "level / tilted n degrees" verdict underneath.
* 下面是**罗盘**:指北针跟着融合姿态的方位角转,旁边是 0-359 的度数。
* Below is a **compass**: the needle follows the heading of the fused attitude, labelled in whole degrees.
* 点「校准」按钮 = `ui_sensor_calibrate(VML_SENS_ACCEL)`(把**当前姿态**当零点)——
* Tapping "Calibrate" calls `ui_sensor_calibrate(VML_SENS_ACCEL)`, making the **current attitude** the
* 躺着玩时"水平"本来就是错的,靠它纠正。
* zero point -- "level" is wrong when you are lying down, and this corrects it.
* 键盘上回车/空格等效于按那个按钮(桌面上不用鼠标也能验)。
* Enter or space does the same thing as the button (so the desktop can be checked without a mouse).
*
* ## ⚠ 手机上才有 —— 桌面(vmlcli)上"这台设备没有传感器"才是**正确输出**
* ## ⚠ Phone-only -- on the desktop (vmlcli) "this device has no sensor" is the **correct output**
*
* 真机上几乎每台手机都有加速度计;而桌面宿主**一个传感器都没有**:实测
* Almost every real phone has an accelerometer, while the desktop host **has none**: measured,
* `ui_sensor_is_available(0)` 返回 0、`ui_sensor_read` 也返回 0。所以本文件的**第一条分支**不是
* `ui_sensor_is_available(0)` returns 0 and `ui_sensor_read` returns 0 too. So the first branch here is
* "读到的值是 0",而是"**这台机器没有这项能力**",界面上必须**明说**,
* not "the value read is 0" but "**this machine lacks the capability**", and the screen must **say so**
* 而不是画一个全 0 的假仪表(那会让用户以为"手机放平了")。
* instead of drawing a fake all-zero gauge (which would make the user think the phone is lying flat).
* 判据是**看图就能分辨**:没有传感器时圆盘里写着「本机没有传感器(手机上才有)」且**不画气泡**;
* The criterion is visible in the PNG at a glance: with no sensor the dial reads "No sensor on this device
* (phones only)" and **no bubble is drawn**.
*
* ## 桌面怎么看到"有传感器"那一半:宿主自带的输入脚本注入,**不用改一行源码**
* ## Seeing the sensor branch on the desktop: the host's input-script injection, with **no source change**
*
* printf '0 accel 0.35 -0.20 1\n0 rotation 0 0 45\n' > in.txt
* vmlcli level.c --vml-home third_party/vml --input in.txt --timeout 40 --frame level.png
*
* `accel` 的单位是 **g**、`rotation` 是**度**(脚本层换算成 SI,见 `CliVmlHost.InjectSensor`);
* `accel` is in **g** and `rotation` in **degrees** (the script layer converts to SI, see
* 换一组数就得到另一张气泡位置不同的图 —— 这就是"气泡真的按 x/y 走"的证据。
* `CliVmlHost.InjectSensor`); changing the numbers gives a second picture with the bubble somewhere
* else, which is the evidence that the bubble really tracks x and y.
* 注入之后 `ui_sensor_is_available` 就报 1("注入过就一直可用",与真机上"它一直有读数"同义)。
* Once injected, `ui_sensor_is_available` reports 1 (injection stays available, matching a real device
* that always has a reading).
*
* ## 踩到的坑(都是这一轮真撞上的,写在这里省得下一个人再撞)
* ## Pitfalls (all hit for real while writing this file; recorded so the next person does not)
*
* ① **`ui_sensor_fetch()` 只有一格缓存,三种传感器共用**(`vmlui.c` 里就三个 `static int`)——
* ① **`ui_sensor_fetch()` has a single cache slot shared by all three sensors** (three `static int`s in
* `ui_sensor_fetch(VML_SENS_ROTATION)` 之后再 `ui_sensor_get_x()`,拿到的是**罗盘**的 x,
* `vmlui.c`) -- call `ui_sensor_get_x()` after fetching the rotation sensor and you get the
* 不是加速度的。所以本文件把顺序钉死:**fetch 谁、立刻读谁的三个数**(见 `poll()`),
* **compass** x, not the accelerometer's. So the order here is nailed down: **fetch one, immediately
* 中间绝不夹另一次 fetch。
* read its three numbers** (see `poll()`), with no other fetch in between.
* ② **桌面注入与程序启动之间有赛跑**:`--input` 的事件由另一个线程按时刻投,程序可能在第一拍
* ② **There is a race between desktop injection and program start**: the `--input` events are delivered
* 就问了 `ui_sensor_is_available`,那一瞬间还没注入 ⇒ 报 0。所以"没有"这个结论**每拍都重问一次**
* by another thread on a timer, and the program may ask `ui_sensor_is_available` in its very first
* (一旦有了就不再问)—— 实测不加这一条时注入那张图仍然是降级画面。
* tick before the injection lands, so it reads 0. Hence "absent" is **re-asked every tick** (and not
* asked at all once present) -- measured: without this, the injected run still produced the degraded
* picture.
* ③ **本文件不链 `libm`**:`sin`/`cos`/`sqrt` 少了 `math.h` 会退化成"把实参原样返回"的隐式声明
* ③ **This file does not link `libm`**: without `math.h`, `sin`/`cos`/`sqrt` fall back to implicit
* (本仓有过实测),所以罗盘指针用一张 24 项的**整数正弦/余弦表**、倾斜量用**整数牛顿法开方**,
* declarations that return their argument unchanged (measured in this repo), so the compass needle
* 全程整数运算 —— 顺带也避开了"浮点当实参传位模式"那类坑。
* uses a 24-entry **integer sine/cosine table** and the tilt uses an **integer Newton square root**,
* all in integer arithmetic -- which also sidesteps the "float argument passed as a bit pattern" trap.
* ④ **"水平"这个判定与气泡位置必须同源**:两处各算一遍就会出现"气泡在中心、却写着倾斜 3°"。
* ④ **The "level" verdict and the bubble position must share one source**; two copies of the rule give
* 这里只有 `tiltMdeg` 一个量,判定与画图都读它(本仓的"同一规则两处实现"老毛病)。
* the contradiction of a bubble at the centre captioned "tilted 3 degrees". Here a single quantity,
* `tiltMdeg`, is read by both (this repo's recurring "one rule, two implementations" bug).
* ⑤ ⚠ **那个判定必须先问"有没有传感器"**:没有传感器时 `tiltMdeg` 恒 0,只看数字会把目标圈
* ⑤ ⚠ **That verdict must ask about the sensor first**: with no sensor `tiltMdeg` is always 0, and
* 画成绿色的"放得很平" —— 正是本文件要避免的谎话。第一版就是这么错的,看图才发现。
* reading the number alone paints the target ring a green "perfectly flat" -- exactly the lie this
* file exists to avoid. The first version did that, and only the picture showed it.
* ⑥ ⚠ **文件级 `static` 标量的初值会被静默丢掉**(2026-10-07 实测):`static int x = -1;` 读到 **0**。
* ⑥ ⚠ **A file-scope `static` scalar silently loses its initializer** (measured 2026-10-07): `static int
* 下面那两张正弦表**是数组**、逐项初始化,实测正常(否则半张表是 0、指针只会往右上角指)。
* x = -1;` reads **0**. The sine tables below are **arrays** with per-element initializers, which
* 完整边界与三个探针见 `battery.c` 文件头坑 ⑥(那里当场撞上了这个坑)。
* measure fine (otherwise half the table would be 0 and the needle would only point up-right). The
* full boundary and the three probes are in pitfall 6 of `battery.c`, which hit this one for real.
*
* 版面:尺寸一律从宿主问(`ui_scr_get_w()/_h()`),颜色是真彩 `0xAARRGGBB`,画完必须 `ui_2d_present()`。
* Layout: every size is asked of the host (`ui_scr_get_w()/_h()`), colours are true colour
* 循环**必须有界**(例程要能被自动验收):空闲 10 秒自己退出,`Esc`/`q` 也退出。
* `0xAARRGGBB`, and the frame must end with `ui_2d_present()`. The loop **must be bounded** (samples have
* 写真正的程序时把 `IDLE_EXIT_MS` 去掉即可。
* to be automatically verifiable): it exits after 10 s of idling, and `Esc`/`q` quits too. Drop
* `IDLE_EXIT_MS` when you write a real program.
*/
#include <vml_ui.h>
#include <stdlib.h> /* itoa --- digits to text via the library */
#define IDLE_EXIT_MS 10000 /* 空闲 10 秒自己退出(验收用) */
/* idle exit after 10 s (for automated checks) */
/* 配色(真彩 AA RRGGBB)*/
/* Colours (true colour AA RRGGBB) */
#define C_BG 0xFF0E1218
#define C_DIAL 0xFF161B24
#define C_RING 0xFF3C4658
#define C_GRID 0xFF2A3346
#define C_TITLE 0xFFE8E8F0
#define C_DIM 0xFF8A94A8
#define C_OK 0xFF4ADE80 /* 水平 */
/* level */
#define C_TILT 0xFFFFD166 /* 倾斜 */
/* tilted */
#define C_ERR 0xFFFF8A80 /* 没有这项能力 */
/* capability absent */
#define C_BUB 0xFF6FC3FF /* 气泡 */
/* the bubble */
#define C_NEEDLE 0xFFE05A4A /* 指北针 */
/* the compass needle */
#define C_BTN 0xFF233046
#define C_BTNOFF 0xFF1B2029
/* 中心目标圈 / 气泡的半径下限(太小的屏幕上也要看得见)*/
/* Floor for the centre target ring and the bubble radius (must stay visible on small screens) */
#define BUB_MIN 6
/* ── 全局状态 ───────────────────────────────────────────────────────── */
/* -- Global state -- */
static int SW; /* 画布宽 */
/* canvas width */
static int SH; /* 画布高 */
/* canvas height */
static int g_pad; /* 页边距 */
/* page margin */
static int lang; /* 界面语言:0 = 中文,1 = English */
/* UI language: 0 = Chinese, 1 = English */
static int hasAcc; /* 这台机器有加速度计吗 */
/* does this machine have an accelerometer */
static int hasRot; /* 有融合姿态(罗盘要靠它)吗 */
/* does it have a fused attitude (what the compass needs) */
static int accX; /* 加速度三轴,毫克(1000 = 1g)*/
/* the three accelerometer axes, in milli-g (1000 = 1g) */
static int accY;
static int accZ;
static int heading; /* 方位角,千分之一度(0 = 北)*/
/* heading, thousandths of a degree (0 = north) */
static int tiltMdeg; /* 倾斜量,千分之一度(**判定与画图共用这一个量**)*/
/* tilt, thousandths of a degree (**the single source for verdict and drawing**) */
/* 几何(画与命中判定**必须用同一份**,见 `hitButton`)*/
/* Geometry (drawing and hit testing **must share this one copy**, see `hitButton`) */
static int dCx;
static int dCy;
static int dR; /* 圆盘中心与半径 */
/* dial centre and radius */
static int cmpCx;
static int cmpCy;
static int cmpR; /* 罗盘中心与半径 */
/* compass centre and radius */
static int tX;
static int tY; /* 右侧读数文本的左上角 */
/* top-left of the readout text column */
static int rowH; /* 读数行高 */
/* readout row height */
static int headY; /* 罗盘下面那行度数 */
/* the heading line under the compass */
static int btnX;
static int btnY;
static int btnW;
static int btnH; /* 校准按钮 */
/* the calibrate button */
/* 24 项的整数正弦/余弦表(千分之一):15 度一格,够画一根指针了。
* A 24-entry integer sine/cosine table (thousandths): 15 degrees per step, plenty for a needle.
* **不用 libm** —— 见文件头坑 ③。
* **No libm** -- see pitfall 3 in the header.
*/
static int sin1000[24] = { 0, 259, 500, 707, 866, 966, 1000, 966, 866, 707, 500, 259, 0, -259, -500, -707, -866, -966, -1000, -966, -866, -707, -500, -259 };
static int cos1000[24] = { 1000, 966, 866, 707, 500, 259, 0, -259, -500, -707, -866, -966, -1000, -966, -866, -707, -500, -259, 0, 259, 500, 707, 866, 966 };
/* ── 小工具 ─────────────────────────────────────────────────────────── */
/* -- Small helpers -- */
/* 整数平方根(牛顿迭代)。浮点那套在这里没必要,还省掉一次 libm 依赖。 */
/* Integer square root (Newton iteration). Floating point is unnecessary here and saves a libm dependency. */
static int isqrt(int v) {
int x;
int y;
if (v <= 0) return 0;
x = v;
y = (x + 1) / 2;
while (y < x) { x = y; y = (x + v / x) / 2; }
return x;
}
/* 整数 → 十进制串(`ui_2d_text` 只收字符串;第 4 层没有 `print_int` 那种东西)。
* Integer to a decimal string (`ui_2d_text` takes strings only; layer 4 has no `print_int`).
* 同样是静态缓冲,别在同一个表达式里连用两次。
* Also a static buffer; never use it twice in one expression.
*/
static char g_i10[16];
static char* numstr(int v) {
itoa(v, g_i10);
return g_i10;
}
/* 千分数 → "-0.250" 形式(符号 + 整数部分 + 三位小数)。本仓惯例:不走 sprintf 那条路。
* Thousandths to "-0.250" (sign, integer part, three decimals). House convention: do not use sprintf.
* ⚠ 返回的是**同一个静态缓冲** —— 别在一个表达式里连用两次(`strcat` 两次会在中间被覆盖)。
* ⚠ It returns **one static buffer** -- never use it twice in one expression (the second call overwrites it).
*/
static char g_f3[24];
static char* fixed3(int v) {
int neg;
int ip;
int fp;
int i;
int n;
char tmp[16];
neg = 0;
if (v < 0) { neg = 1; v = 0 - v; }
ip = v / 1000;
fp = v - ip * 1000;
i = 0;
if (neg != 0) { g_f3[i] = '-'; i = i + 1; }
n = 0;
if (ip == 0) { tmp[n] = '0'; n = n + 1; }
while (ip > 0) { tmp[n] = (char)('0' + ip % 10); ip = ip / 10; n = n + 1; }
while (n > 0) { n = n - 1; g_f3[i] = tmp[n]; i = i + 1; }
g_f3[i] = '.'; i = i + 1;
g_f3[i] = (char)('0' + (fp / 100) % 10); i = i + 1;
g_f3[i] = (char)('0' + (fp / 10) % 10); i = i + 1;
g_f3[i] = (char)('0' + fp % 10); i = i + 1;
g_f3[i] = 0;
return g_f3;
}
/* 十分之一单位 → "20.1"(倾斜角用它,一位小数就够)。同样是静态缓冲。
* Tenths of a unit to "20.1" (used for the tilt angle; one decimal is plenty). Also a static buffer. */
static char g_f1[16];
static char* fixed1(int v) {
int neg;
int ip;
int fp;
int i;
int n;
char tmp[16];
neg = 0;
if (v < 0) { neg = 1; v = 0 - v; }
ip = v / 10;
fp = v - ip * 10;
i = 0;
if (neg != 0) { g_f1[i] = '-'; i = i + 1; }
n = 0;
if (ip == 0) { tmp[n] = '0'; n = n + 1; }
while (ip > 0) { tmp[n] = (char)('0' + ip % 10); ip = ip / 10; n = n + 1; }
while (n > 0) { n = n - 1; g_f1[i] = tmp[n]; i = i + 1; }
g_f1[i] = '.'; i = i + 1;
g_f1[i] = (char)('0' + fp % 10); i = i + 1;
g_f1[i] = 0;
return g_f1;
}
static int clampInt(int v, int lo, int hi) {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
}
/* ── 读传感器 ───────────────────────────────────────────────────────── */
/* -- Reading the sensors -- */
/* 采一次。返回 1 = 画面要重画(读数或"有没有"变了)。
* Samples once. Returns 1 when the screen needs a redraw (a reading or an availability changed).
* ⚠ 顺序不能乱:**fetch 谁就立刻读谁的三个数**(只有一格缓存,见文件头坑 ①)。
* ⚠ The order is not free: **fetch one and read its three numbers at once** (single cache slot, pitfall 1).
* ⚠ "没有"每拍重问(桌面注入与启动之间有赛跑,见坑 ②);"有"了就不再问。
* ⚠ "Absent" is re-asked every tick (the injection race, pitfall 2); once present it is not asked again.
*/
static int poll(void) {
int changed;
int a;
int b;
int c;
changed = 0;
if (hasAcc == 0) { hasAcc = ui_sensor_is_available(VML_SENS_ACCEL); if (hasAcc != 0) changed = 1; }
if (hasRot == 0) { hasRot = ui_sensor_is_available(VML_SENS_ROTATION); if (hasRot != 0) changed = 1; }
if (hasAcc == 1) {
a = ui_sensor_fetch(VML_SENS_ACCEL);
if (a == 1) {
b = ui_sensor_get_x();
c = ui_sensor_get_y();
a = ui_sensor_get_z();
if (b != accX || c != accY || a != accZ) changed = 1;
accX = b;
accY = c;
accZ = a;
}
}
if (hasRot == 1) {
a = ui_sensor_fetch(VML_SENS_ROTATION);
if (a == 1) {
b = ui_sensor_get_z(); /* z = 方位角(x/y 是俯仰与翻滚)*/
/* z = heading (x/y are pitch and roll) */
if (b != heading) changed = 1;
heading = b;
}
}
/* 倾斜量:水平方向的重力分量 → 千分之一度。
* The tilt: the horizontal component of gravity, in thousandths of a degree.
* ⚠ 这是**小角近似**(`sin θ ≈ θ`):约 45° 以内误差 <5%,再大就明显偏小,
* ⚠ This is the **small-angle approximation** (`sin t ~ t`): under ~45 degrees the error is below 5%,
* 到 90° 附近只能报出 57° 左右。要精确就得链 `math.h` 走 `asin` ——
* beyond that it reads low, and near 90 degrees it can only report about 57. Exact values need
* 本文件刻意不链(见坑 ③),水平仪要的恰恰是"接近水平"那一段,所以够用。
* `asin` from `math.h`, which this file deliberately avoids (pitfall 3) -- and a spirit level cares
* about the near-level range anyway, so this is enough.
*/
a = isqrt(accX * accX + accY * accY);
b = (a * 573 + 5) / 10; /* 毫克 → 千分之一度(1 毫克 ≈ 0.0573°)*/
/* milli-g to thousandths of a degree (1 milli-g ~ 0.0573 deg) */
if (hasAcc == 0) b = 0;
if (b != tiltMdeg) changed = 1;
tiltMdeg = b;
return changed;
}
/* ── 版面 ───────────────────────────────────────────────────────────── */
/* -- Layout -- */
static void layout(void) {
int blockTop;
int blockH;
int colH;
int rowsH;
SW = ui_scr_get_w();
SH = ui_scr_get_h();
if (SW <= 0) SW = 360;
if (SH <= 0) SH = 620;
g_pad = SW / 18;
if (g_pad < 10) g_pad = 10;
/* 圆盘半径:**宽度与高度各让一步**取小的那个 ——
* The dial radius: **bounded by both width and height**, whichever is smaller --
* 只看宽度的话,矮屏(横屏)上圆盘会把下面整块挤出去。
* using width alone would push the whole lower block off a short (landscape) screen.
*/
dR = SW / 2 - g_pad * 2;
if (dR > (SH - 300) / 2) dR = (SH - 300) / 2;
if (dR > 130) dR = 130;
if (dR < 44) dR = 44;
dCx = SW / 2;
dCy = g_pad + 54 + dR; /* 标题两行之下 */
/* below the two heading lines */
/* 底下一条带子分两栏:**左罗盘、右读数柱**(省下来的高度留给圆盘)。
* The band below splits into two columns: **compass on the left, readouts on the right** (the height
* 带子的高度取两栏里高的那一栏,两栏都从带子顶部开始排 —— 这样"谁高"都不会把对方挤出去。
* saved goes to the dial). The band is as tall as the taller column and both start at its top, so
* neither can push the other off.
*/
blockTop = dCy + dR + 18;
cmpR = SW / 12;
if (cmpR > 40) cmpR = 40;
if (cmpR < 22) cmpR = 22;
rowH = cmpR; /* 一行读数的高度跟着罗盘走,两栏才对得齐 */
/* the readout row height follows the compass so the two columns line up */
if (rowH > 21) rowH = 21;
colH = cmpR * 2 + 18; /* 圆 + 下面那行度数 */
/* the circle plus the heading line below it */
rowsH = rowH * 4; /* 三行加速度 + 一行判定 */
/* three accelerometer rows plus the verdict */
blockH = (colH > rowsH) ? colH : rowsH;
cmpCx = g_pad + cmpR;
cmpCy = blockTop + cmpR;
headY = blockTop + cmpR * 2 + 8;
tX = cmpCx + cmpR + 14;
tY = blockTop + 1;
btnW = SW - g_pad * 2;
if (btnW > 260) btnW = 260;
btnH = 42;
btnX = (SW - btnW) / 2;
btnY = blockTop + blockH + 14;
/* 按钮与底部的提示行**不许打架**:矮屏上把按钮顶回提示行之上。 */
/* The button must not fight the footer: on a short screen push it back above the footer line. */
if (btnY + btnH > SH - 34) btnY = SH - 34 - btnH;
if (btnY < blockTop + blockH - btnH / 2) btnY = blockTop + blockH - btnH / 2;
}
/* ── 绘制 ───────────────────────────────────────────────────────────── */
/* -- Drawing -- */
static void drawDial(void) {
int bub;
int lim;
int bx;
int by;
int isLevel;
bub = dR / 7;
if (bub < BUB_MIN) bub = BUB_MIN;
/* ⚠ **"水平"必须先问"有没有传感器"**:没有传感器时 `tiltMdeg` 恒 0,
* ⚠ **"Level" must first ask whether there is a sensor**: with none, `tiltMdeg` is always 0,
* 只看数字的话目标圈会亮成**绿色**("放得很平")—— 而那正是本文件要避免的谎话。
* so reading the number alone lights the target ring **green** ("perfectly flat") -- exactly the
* 实测:第一版就是这么画的,看图才发现圆盘里写着"没有传感器"、中间却是一圈绿。
* lie this file exists to avoid. Measured: the first version did that, and only the picture showed
* the dial reading "no sensor" with a green ring in the middle.
*/
isLevel = (hasAcc == 1 && tiltMdeg < 1000) ? 1 : 0; /* 1000 千分之一度 = 1 度以内算"水平" */
/* within 1000 thousandths of a degree = 1 degree */
ui_2d_circle(dCx, dCy, dR, C_DIAL, 1, 0);
ui_2d_circle(dCx, dCy, dR, C_RING, 0, 2);
/* 参照圈:给"偏了多少"一个尺度 */
/* Reference rings: a scale for "how far off" you are */
ui_2d_circle(dCx, dCy, dR / 3, C_GRID, 0, 1);
ui_2d_circle(dCx, dCy, dR * 2 / 3, C_GRID, 0, 1);
/* 十字准线 */
/* Crosshair */
ui_2d_line(dCx - dR, dCy, dCx + dR, dCy, C_GRID, 1);
ui_2d_line(dCx, dCy - dR, dCx, dCy + dR, C_GRID, 1);
/* 中心的目标圈:气泡停进来 = 水平(颜色跟着判定走,与读数同源)*/
/* The centre target ring: a bubble inside it means level (its colour follows the verdict, one source) */
ui_2d_circle(dCx, dCy, bub + 5, isLevel != 0 ? C_OK : C_GRID, 0, 2);
if (hasAcc == 0) {
/* ⚠ **降级分支**:不画气泡,明说"这台机器没有"。
* ⚠ **The degraded branch**: draw no bubble and say plainly that this machine has none.
* 画个居中的全 0 气泡等于告诉用户"你的手机放得很平" —— 那是撒谎。
* An all-zero bubble at the centre would tell the user the phone is perfectly flat -- a lie.
*/
ui_2d_text(dCx, dCy - 14, lang == 0 ? "本机没有传感器" : "No sensor on this device", C_ERR, 16, VML_ANCHOR_CENTER);
ui_2d_text(dCx, dCy + 12, lang == 0 ? "(手机上才有)" : "(phones only)", C_ERR, 13, VML_ANCHOR_CENTER);
return;
}
/* 气泡:加速度计静止时读到的是**世界"上"方向在设备坐标里的表示**(哪条轴朝上)⇒
* The bubble: at rest the accelerometer reads **world "up" expressed in device coordinates** (which
* 气泡往**低**处滚,所以取**负号**(与 tilt.c 的实测结论一致:抬高右边,球往左走)。
* axis points up), so the bubble rolls to the **low** side and the sign is **negated** (matching the
* 写反的症状是"歪这边、气泡往那边",看着像传感器的轴装反了,其实错在这两行。
* measurement in tilt.c: lift the right edge and the ball goes left). Getting it backwards looks like
* mis-mounted sensor axes, but the bug is right here.
*/
lim = dR - bub - 3;
bx = dCx - accX * lim / 1000;
by = dCy - accY * lim / 1000;
/* 只有超过 1g 的剧烈动作才需要夹 —— 真机上很少见,但夹住比画到画布外面好。 */
/* Clamping only matters past 1g, which is rare on a phone, but it beats drawing off-canvas. */
bx = clampInt(bx, dCx - lim, dCx + lim);
by = clampInt(by, dCy - lim, dCy + lim);
ui_2d_circle(bx, by, bub + 3, 0x606FC3FF, 1, 0); /* 半透明的外圈 */
/* translucent outer ring */
ui_2d_circle(bx, by, bub, C_BUB, 1, 0);
}
static void drawCompass(void) {
int deg;
int idx;
int dx;
int dy;
char buf[32];
ui_2d_circle(cmpCx, cmpCy, cmpR, C_DIAL, 1, 0);
ui_2d_circle(cmpCx, cmpCy, cmpR, C_RING, 0, 2);
/* 四个方位的刻度与 N 字 */
/* Ticks for the four cardinal points, plus the N label */
ui_2d_line(cmpCx, cmpCy - cmpR + 2, cmpCx, cmpCy - cmpR + 9, C_RING, 2);
ui_2d_line(cmpCx, cmpCy + cmpR - 2, cmpCx, cmpCy + cmpR - 9, C_RING, 2);
ui_2d_line(cmpCx - cmpR + 2, cmpCy, cmpCx - cmpR + 9, cmpCy, C_RING, 2);
ui_2d_line(cmpCx + cmpR - 2, cmpCy, cmpCx + cmpR - 9, cmpCy, C_RING, 2);
ui_2d_text(cmpCx, cmpCy - cmpR - 13, "N", C_TILT, 12, VML_ANCHOR_CENTER);
if (hasRot == 0) {
/* 罗盘没得画就**明说**,别画一根永远指向北的假指针。 */
/* With no compass, **say so** instead of drawing a needle that always points north. */
ui_2d_text(cmpCx, cmpCy - 7, lang == 0 ? "无" : "n/a", C_ERR, 13, VML_ANCHOR_CENTER);
ui_2d_text(cmpCx, headY, lang == 0 ? "无方位传感器" : "no heading sensor", C_ERR, 11, VML_ANCHOR_CENTER);
return;
}
/* 方位角归一化到 0-359 度(读数可以是负的,C 的取余会留负号,得自己补回来)*/
/* Normalise the heading to 0-359 degrees (the reading may be negative; C's remainder keeps the sign) */
deg = heading / 1000;
deg = deg % 360;
if (deg < 0) deg = deg + 360;
idx = (deg * 24) / 360;
if (idx > 23) idx = 23;
dx = sin1000[idx] * (cmpR - 6) / 1000;
dy = cos1000[idx] * (cmpR - 6) / 1000;
/* 屏幕 y 向下,所以"北朝上"是 `-dy` */
/* Screen y grows downward, so "north is up" means `-dy` */
ui_2d_line(cmpCx, cmpCy, cmpCx - dx / 3, cmpCy + dy / 3, C_DIM, 2); /* 尾巴(南)*/
/* the tail (south) */
ui_2d_line(cmpCx, cmpCy, cmpCx + dx, cmpCy - dy, C_NEEDLE, 3); /* 指向(北)*/
/* the pointing half (north) */
ui_2d_circle(cmpCx, cmpCy, 3, C_TITLE, 1, 0);
/* 度数写在**罗盘正下方固定的一行**(不跟指针走:跟指针走会在指针朝右/朝左时
* Degrees go on a **fixed line right under the compass** (not at the needle tip: a tip-following
* 撞进右边的读数柱里 —— 第一版就是这么画的,两张验收图里一张压住 `x … g`、一张压住圆盘。
* label ran into the readout column when the needle pointed sideways -- the first version did that,
* 固定一行才有"读数"该有的样子,位置也与指针无关。
* and one acceptance picture had it over `x ... g` while the other had it over the dial. A fixed line
* looks like a readout should, and its position does not depend on the needle.
*/
buf[0] = 0;
strcat(buf, numstr(deg));
strcat(buf, lang == 0 ? " 度" : " deg");
ui_2d_text(cmpCx, headY, buf, C_NEEDLE, 12, VML_ANCHOR_CENTER);
}
/* 右侧读数柱:三行加速度 + 一行判定。**判定用的就是 `tiltMdeg`**(与气泡同源)。
* The readout column on the right: three accelerometer rows plus the verdict. **The verdict reads
* `tiltMdeg`** (the same source the bubble uses).
*/
static void drawReadout(void) {
int y;
char buf[64];
int col;
y = tY;
buf[0] = 0;
if (hasAcc == 0) {
ui_2d_text(tX, y, lang == 0 ? "加速度计:无" : "accel: none", C_ERR, 14, VML_ANCHOR_LEFT);
ui_2d_text(tX, y + rowH, lang == 0 ? "读数不可用" : "no reading", C_ERR, 13, VML_ANCHOR_LEFT);
ui_2d_text(tX, y + rowH * 3,
(hasRot == 1) ? (lang == 0 ? "罗盘:有" : "compass: yes")
: (lang == 0 ? "罗盘:无" : "compass: none"),
(hasRot == 1) ? C_DIM : C_ERR, 13, VML_ANCHOR_LEFT);
return;
}
buf[0] = 0; strcat(buf, "x "); strcat(buf, fixed3(accX)); strcat(buf, " g");
ui_2d_text(tX, y, buf, C_TITLE, 14, VML_ANCHOR_LEFT);
buf[0] = 0; strcat(buf, "y "); strcat(buf, fixed3(accY)); strcat(buf, " g");
ui_2d_text(tX, y + rowH, buf, C_TITLE, 14, VML_ANCHOR_LEFT);
buf[0] = 0; strcat(buf, "z "); strcat(buf, fixed3(accZ)); strcat(buf, " g");
ui_2d_text(tX, y + rowH * 2, buf, C_TITLE, 14, VML_ANCHOR_LEFT);
col = (tiltMdeg < 1000) ? C_OK : C_TILT;
buf[0] = 0;
if (tiltMdeg < 1000) {
strcat(buf, lang == 0 ? "水平" : "level");
} else {
strcat(buf, lang == 0 ? "倾斜 " : "tilted ");
strcat(buf, fixed1((tiltMdeg + 50) / 100)); /* 千分之一度 → 十分之一度 */
/* thousandths of a degree to tenths */
strcat(buf, "°");
}
ui_2d_text(tX, y + rowH * 3, buf, col, 15, VML_ANCHOR_LEFT);
}
static void drawButton(void) {
int on;
on = (hasAcc == 1) ? 1 : 0;
ui_2d_rect(btnX, btnY, btnW, btnH, on != 0 ? C_BTN : C_BTNOFF, 1, 0, btnH / 2);
ui_2d_text(SW / 2, btnY + (btnH - 16) / 2,
on != 0 ? (lang == 0 ? "校准(把当前姿态当零点)" : "Calibrate (take this pose as zero)")
: (lang == 0 ? "校准不可用(没有传感器)" : "Calibrate unavailable (no sensor)"),
on != 0 ? C_TITLE : C_DIM, 14, VML_ANCHOR_CENTER);
}
static void draw(void) {
ui_2d_clear(C_BG);
ui_2d_text_styled(SW / 2, g_pad, lang == 0 ? "水平仪 / 指南针" : "Level / Compass",
C_TITLE, 18, VML_ANCHOR_CENTER, VML_FONT_BOLD);
if (hasAcc == 1 && hasRot == 1) {
ui_2d_text(SW / 2, g_pad + 26, lang == 0 ? "加速度计 + 融合姿态" : "accelerometer + fused attitude",
C_DIM, 11, VML_ANCHOR_CENTER);
} else if (hasAcc == 1 || hasRot == 1) {
ui_2d_text(SW / 2, g_pad + 26, lang == 0 ? "只有一部分传感器可用" : "only part of the sensors are available",
C_TILT, 11, VML_ANCHOR_CENTER);
} else {
ui_2d_text(SW / 2, g_pad + 26, lang == 0 ? "本机没有传感器(手机才有)" : "no sensors on this device (phones only)",
C_ERR, 11, VML_ANCHOR_CENTER);
}
drawDial();
drawCompass();
drawReadout();
drawButton();
ui_2d_text(SW / 2, SH - 22,
(hasAcc == 0)
? (lang == 0 ? "点不动也没关系:这台设备本来就没有传感器 Esc / q 退出"
: "nothing to tap: this device has no sensors Esc / q quits")
: (lang == 0 ? "点「校准」或按回车 = 校零 Esc / q 退出"
: "tap Calibrate or press Enter to zero it Esc / q quits"),
C_DIM, 11, VML_ANCHOR_CENTER);
ui_2d_present(); /* ⚠ 不调这句 = 黑屏 */
/* without this call you get a black screen */
}
/* 点在按钮里吗(与 `drawButton` 用的是**同一组** btnX/Y/W/H)*/
/* Did the tap land on the button (the **same** btnX/Y/W/H that `drawButton` uses) */
static int hitButton(int px, int py) {
if (px >= btnX && px < btnX + btnW && py >= btnY && py < btnY + btnH) return 1;
return 0;
}
/* ── 主流程 ─────────────────────────────────────────────────────────── */
/* -- Main flow -- */
int main(void) {
int msg[4];
int t;
int t0;
int done;
lang = ui_scr_get_language(); /* 开局查一次,别每帧调 */
/* queried once, not every frame */
/* "有没有"**开窗之前就能问**,先问再决定画什么。 */
/* Availability is **askable before opening a window**; ask first, then decide what to draw. */
hasAcc = ui_sensor_is_available(VML_SENS_ACCEL);
hasRot = ui_sensor_is_available(VML_SENS_ROTATION);
/* 采样率:这个程序的逻辑是 5 拍/秒(200ms 的等待),60~100ms 采一次足够,
* Sample rate: this program ticks 5 times a second (a 200 ms wait), so 60-100 ms is plenty;
* 还能省电(传感器一开着就在耗电,间隔越大越省)。
* it also saves power (a running sensor draws current, and a longer interval costs less).
*/
if (hasAcc == 1) ui_sensor_set_rate(VML_SENS_ACCEL, 60);
if (hasRot == 1) ui_sensor_set_rate(VML_SENS_ROTATION, 100);
poll(); /* 首帧之前先采一次,否则第一帧画的是"没有传感器" */
/* sample before the first frame, or it renders as "no sensor" */
layout();
ui_win_open_ex(lang == 0 ? "水平仪 / 指南针" : "Level / Compass",
0, 0, VML_WIN_ROTATABLE, VML_WIN_NO_GAMEPAD);
layout();
draw();
t0 = ui_time_get_ms();
done = 0;
while (!done && ui_win_is_closed() == 0) {
t = ui_msg_wait(msg, 200);
if (t == VML_MSG_NONE) {
/* 没有消息**不代表没有变化** —— 传感器是**轮询**的(姿态是连续量,投消息只会淹掉队列),
* No message does **not** mean nothing changed -- sensors are **polled** (attitude is a
* 所以每拍自己采一次,变了才重画(刷新由"变化"驱动,不由节拍驱动)。
* continuous quantity and posting messages would flood the queue), so sample every tick and
* redraw only on change (refresh driven by change, not by a beat).
*/
if (ui_time_get_ms() - t0 > IDLE_EXIT_MS) done = 1;
else if (poll() != 0) draw();
continue;
}
t0 = ui_time_get_ms();
if (t == VML_MSG_KEYDOWN) {
int k = msg[1];
if (k == VML_KEY_ESCAPE || k == 'q' || k == 'Q') done = 1;
else if (k == VML_KEY_ENTER || k == VML_KEY_SPACE) {
if (hasAcc == 1) {
ui_sensor_calibrate(VML_SENS_ACCEL);
poll(); /* ⚠ 校准后**立刻**重读:不重读的话气泡要"慢慢地"才回中,看着像没生效 */
/* ⚠ Re-read **immediately** after calibrating: without it the bubble
* only drifts back to the centre, which looks like nothing happened. */
draw();
}
}
continue;
}
if (t == VML_MSG_TOUCHDOWN || t == VML_MSG_MOUSEDOWN) {
if (hitButton(msg[1], msg[2]) != 0 && hasAcc == 1) {
ui_sensor_calibrate(VML_SENS_ACCEL);
poll();
draw();
}
continue;
}
if (t == VML_MSG_WINDOWORIENT || t == VML_MSG_WINDOWRESIZE) {
layout();
draw();
continue;
}
if (t == VML_MSG_WINDOWCLOSE) done = 1;
}
return 0;
}