Compare commits

...
4 Commits
Author SHA1 Message Date
Horldsence eafa112959 文档:写防作弊、缩放布局、槽位语义
client/README.md 新增三节:

  防作弊  说明为什么必须在**接收端**挡而不是界面里隐藏——Hello 广播已经带着
          MAC 派生 id 和 IP,对手 tcpdump 一下就有,而数据本来也已收下解码。同时
          明确写出这一版挡不住什么:对手知道 IP 后可以自己发 Subscribe,固件会向
          任何订阅者推流。要真正封住得回固件加配对 token。

  缩放    image-fit 必须跟着 zoom 在 contain/fill 之间切,否则放大不会改变画面;
          缩回 1.0 要归位,否则下次放大从偏移开始像跳了一下。

  槽位    记下"按 id 顺序对号入座"这个做法错在哪,以及为什么会出现画面左右互换。

根 README 排查表加两条:两格宽窄不一的检查点,以及放大后画面没变清晰的原因。
2026-10-05 21:45:24 +08:00
Horldsence 3631a037d8 client: 槽位对应队伍,不再按 id 顺序对号入座
裁判端两个槽位的语义应该是"队伍一 / 队伍二",而不是"第 N 块发现的板子"。之前
两者混为一谈,出现两个都会被现场误读成故障的现象:

1. **两块槽位显示同一块板。** 每轮都从 `online_cameras()` 取前两个 id 填满两
   个槽位,于是只有一块板在场时两边一模一样。裁判会以为两队的图传串了。

2. **板子掉线时画面左右互换。** 低 id 的板子一离线,候选列表缩短,后面那块就
   跳到前面的槽位——同样像串线,而实际上只是排序。

规则改成:每轮最多消费一个候选 id;已填且仍在线的槽位不动;掉线的槽位保留它的
id 并显示"离线",等它回来时还在原位。

空槽的提示文案也改准:左槽"等待队伍一的图传"、右槽"等待队伍二的图传",而不是笼
统的"未收到画面"。

顺带修两个假数字:`frames_shown == 0` 时 `stats.width`/`height` 还是默认值 0,界
面上显示成 `0x0`——一个看起来像分辨率的假分辨率;fps 显示 `0.0` 读起来像"板子
卡死了",而真相是"还没收到"。状态行已经写了"已连接,等待画面…",不该再补一个矛
盾的数字。

关于"别把刚掉线的板子又立刻选回同一个槽位"那条过滤:它只在槽位掉线时起作用,
且会跳过当前 id 去找下一个候选,槽位仍然有机会回来。实测唯一那块在推流的板子正
常占用一个槽位,另一槽保留着在线但不推流的真板子——符合预期。
2026-10-05 21:45:24 +08:00
Horldsence cd8344c698 client: 修裁判端两格不等宽,加滚轮缩放与拖动平移,启动即最大化
**塌缩问题**(用户报的现象):裁判端窗口放大时,左边那格吃掉约 55% 宽度,右边
只有 45%;空的那一侧更极端时会被压成一条缝。

原因不是缺 stretch,而是 `HorizontalLayout` 先按内容的**最大**需求分配、剩下
的才按 stretch 分。左格写着"已连接,等待画面…"(8 个字),右格"未收到画面"
(5 个字),左格就多要十几个像素。而没有画面的一格最小宽度接近 0,可以被压没。

三处一起改:

  1. 占位文字改成绝对定位(给出 x/y),不再参与父级尺寸计算。文字长短本不该
     决定画面大小。
  2. 两格统一 `preferred-width: 480px` + `horizontal-stretch: 1`,需求变成相同,
     于是精确平分。`min-width: 320px` 兜底,保证任何一侧至少有一个能看画的宽度。
  3. 缩放指示与复位按钮也绝对定位。否则它们一出现就改变这一格的尺寸需求,把
     不等宽重新引进来——而且是在用户放大之后才出现,最难排查。

**缩放**:Slint 的 `Image` 没有缩放因子属性,所以把它的 `width`/`height` 按 zoom
放大后放进 `clip: true` 的容器,靠 offset 平移。滚轮改 zoom、拖动改 offset、双击
复位、缩回 1.0 时自动归位(否则下一次放大会从偏移位置开始,看起来像"跳了一下")。

`image-fit` 要跟着 zoom 切:`contain` 在 zoom=1 时保证完整可见且不变形,`fill`
才让画面跟着放大——只用 contain 的话放大不会有任何视觉变化。

**启动即最大化**:Slint 没有"最大化"这个状态,但有 `set_maximized`,它铺满工作区
且保留菜单栏和 Dock(真全屏 `set_fullscreen` 会盖掉它们,裁判终端要切去别的窗口
核对数据)。必须在 `show()` 之前调用,之后调用会被后端当成"用户点了最大化按钮"。

抽出 `VideoPane` 与 `StatusBar` 两个组件给两种身份复用。两者都不加 `export`——
它们只在 .slint 内部使用,加了会触发"doesn't inherit Window. No code will be
generated for it"的警告。

Slint 的两处写法记在这里,都是编译器试出来的:
  - `TouchArea` 的 `pressed` 是**属性**不是回调,写 `pressed => {}` 编译不过。
    拖动要用 `pointer-event(e)` 配 `PointerEventKind.down` / `.move`。
  - `EventResult.accept` 要写全限定;`accept` 单独写无法解析。
2026-10-05 21:28:32 +08:00
Horldsence a16ad8595e client: 队伍端 release 只接收绑定的那一块板子
对抗赛里两队都用这套系统看自己小车的图传。需求是防止对手开第二个窗口连到另一
队的板子。

**关键是这必须做在接收端,而不是界面里。** 我先按"只在 release 下不显示 id 和
IP"理解,但那样等于没做:广播的 Hello 里就有 MAC 派生的 id 和 IP,对手
tcpdump 一下全都知道,而数据本来也已经收下并解码了。隐藏一行文字不阻止任何事。

所以改为 `Receiver::restrict_to`:队伍端 release 且已绑定时,只重组、只解码、
只统计绑定的那一块板子。对手的板子即使把流推到了本机端口(IP 已泄露、端口是
公开常量),数据也不会进入任何管线。

四种情况不限制:裁判端(中立第三方,本来就要两块)、debug 构建(开发时需要
看到全场)、队伍端未绑定(选择界面需要列出候选)、命令行临时覆盖 camera_id。

`--list` 在 release 队伍端直接报错:它会打印场上所有板子的 id 和 IP,正是要防
的那件事。与其列出来再叮嘱"别看",不如不给。

`TEAM_DIAGNOSTICS = cfg!(debug_assertions)` 门控界面上的 fps / 丢帧 / 数据报数
等遥测和 stdout 的诊断日志。裁判端不受影响——它是中立方,双方遥测对判罚有用。

选择界面保留:现场换机器时还要用它重新绑定。数据侧的闸门已经足够。

顺带修一个设计上就错的判断:`is_online()` 看的是 `last_seen`,而它此前只由 Hello
更新,于是出现"画面每秒刷 22 帧、状态栏却显示离线(橙色)"的矛盾状态。正在推
流本身就是最可靠的存活证据,所以收到视频帧也刷新时间戳。
2026-10-05 21:27:36 +08:00
5 changed files with 707 additions and 219 deletions
+2
View File
@@ -318,6 +318,8 @@ ge` 的构造必须留在事件循环。这条约束决定了整个客户端的
| `libc` 找不到 `MAP_FAILED` | `client/.cargo/config.toml` 的 target 没改成宿主三元组 |
| 串口看不到任何输出 | 非 TTY 下 `espflash monitor` 不可用,用 `/tmp/rd2.py` 裸读 |
| WiFi 连不上 | SSID 必须在 2.4GHz。5G 频段看不见 |
| 裁判端两块画面宽窄不一 | 检查那两格是否都写了相同的 `preferred-width`,且占位文字是绝对定位(`x`/`y`)而不是普通布局 |
| 放大后画面没变清晰 | `image-fit` 必须跟着 zoom 在 `contain`/`fill` 之间切。只用 `contain` 的话画面不会跟着放大 |
独立核对线路格式:
+156 -5
View File
@@ -125,13 +125,33 @@ std::rc::Rc::new(slint::VecModel::from(v)).into()
33ms 的轮询间隔不是每帧一次:更快只是让事件循环和接收路径抢 CPU。
### 槽位不会左右跳动
### 槽位一旦占定就不换人
裁判端按 sender_id 排序填两个槽位,**槽位保持稳定**——某块板子临时离线时不会重新
洗牌。如果每次都重新排版,一块板子掉线会让剩下的画面从左边跳到右边,现场看非常像
"两路串了"。
裁判端有两个槽位,但**不要按 id 顺序重新对号入座**。
`view.sender_id != wanted` 才重建 `View`(清零帧率窗口),避免每 33ms 重置一次统计。
原来那版是:
```rust
let ids: Vec<u32> = cameras.iter().map(|c| c.id).take(2).collect();
let wanted = ids.get(i).copied();
```
看起来稳定,其实不是:低 id 的板子一离线,`ids` 就缩短成 `[B]`,B 落到**左**槽 ——
画面左右互换。现场看非常像"两路串了",而实际上只是排序。
现在的规则:还**在线**的槽位保持不动;空槽或已掉线的槽位才从还没被占用的板子里按 id
顺序取第一个。
```rust
let still_here = view.sender_id.is_some_and(|id| cameras.iter().any(|c| c.id == id));
if still_here { continue; }
```
换人时优先挑一个和当前不同的板子,否则会出现"板子短暂掉线又回来,槽位原地打转",
看起来像卡住。
`view.sender_id` 变了才重建 `View`(清零帧率窗口),否则每 33ms 重置一次统计,帧率
永远算不出来。
## 帧率:按计数除以墙钟
@@ -160,6 +180,137 @@ let armor: Arc<dyn ArmorSource> = Arc::new(NoArmorYet);
占位分支依然给出 4 个血格(值全 0),因为界面用 `available` 决定颜色:空数组会让整个
占位框消失,看起来像布局出错,而不是像"还没有数据"。
## 防作弊:release 队伍端只接收绑定的那一块
对抗赛里两队都用这套系统看自己小车的图传,所以要防止对手开第二个窗口连到另一队
的板子。
**关键是这件事必须做在接收端,而不是界面里。**
一开始我按"release 下不显示 id 和 IP"理解,那样等于没做:
- 广播的 Hello 里就有 MAC 派生的 `sender_id` 和 IP。对手 `tcpdump` 一下全都知道,
界面藏不藏毫无关系。
- 数据本来已经收下并解码了。`.slint` 只控制显示,隐藏一行文字不阻止任何事。
所以是 `Receiver::restrict_to`:队伍端 release 且已绑定时,只重组、只解码、只统计
绑定的那一块。对手的板子即使把流推到了本机端口(IP 已泄露,`DEFAULT_PORT` 是公开
常量),数据也不进入任何管线。
```rust
let restrict = if config.role == Role::Team && !TEAM_DIAGNOSTICS {
config.camera_id.filter(|_| args.camera_id.is_none())
} else {
None
};
```
四种情况不限制,各有理由:
| 情况 | 为什么不限制 |
|---|---|
| 裁判端 | 中立第三方,本来就要同时看两块板子 |
| debug 构建 | 开发时需要看到全场 |
| 队伍端未绑定 | 选择界面需要列出候选,否则无从选起 |
| `--camera-id` 覆盖了配置 | 显式指定了要看哪块,是有意的 |
### 遥测按编译模式门控
```rust
const TEAM_DIAGNOSTICS: bool = cfg!(debug_assertions);
```
fps / 丢帧率 / 数据报数 / 帧间隔这些在队伍端 release 下**不产出**(空字符串,界面
也不显示),stdout 的诊断日志同样门控。
由编译模式决定而不是命令行参数:命令行开关意味着二进制里带着那套东西,忘开一次
就泄露了。
裁判端不受影响——双方遥测对判罚有用。
### `--list` 在 release 队伍端直接拒绝
它会打印场上所有板子的 id 和 IP,正是要防的那件事。与其列出来再叮嘱"别看",不如
不给:
```
Error: "--list 需要 debug 构建:它会列出场上所有板子的 id 和 IP。"
```
### 选择界面保留
现场换机器时还要用它重新绑定。数据侧的闸门已经足够,界面列出候选不构成额外泄露
(对手需要的是实时画面,不是 id 字符串)。
### 这挡不住什么
诚实地说:**IP 仍然通过 Hello 广播泄露。** 对手知道你的 IP 之后,可以自己发一个
`Subscribe` 过来——固件会向任何订阅者推流,那道闸门在他的客户端上,不在你的。
真要挡住需要固件侧做配对(首次绑定发一个 token,固件只接受带 token 的订阅),
代价是固件重启就失配。这次没做,因为需求定的是"避免一个队伍通过开其他窗口连接到
其他队伍的图传",而这需要对手主动在自己机器上装这套客户端——纯 UI 或客户端侧的
措施挡不住它。**这一条如果要真正封住,得回固件加 token。**
## 缩放与自适应布局
### 滚轮缩放 + 拖动平移
Slint 的 `Image` 没有缩放因子属性,所以实现是:把 `Image` 的 `width`/`height` 按
`zoom` 放大后放进 `clip: true` 的容器,靠 `offset-x`/`offset-y` 平移。
- 滚轮改 `zoom`(每次 ×1.25,上限 8 倍)
- 拖动改 `offset`(只在 `zoom > 1` 时生效——此时才有可移动的空间)
- 双击复位
- 缩回 1.0 时自动归位
最后一条容易漏:缩回 1.0 后若保留偏移,下一次放大会从偏移位置开始,看起来像
"跳了一下"。
`image-fit` 必须跟着 `zoom` 切:
```
image-fit: root.zoom <= 1.0 ? contain : fill;
```
`contain` 在 `zoom = 1` 时保证完整可见且不变形,`fill` 才让画面跟着放大。**只用
`contain` 的话放大不会有任何视觉变化**——这个 bug 很容易写出来,因为界面上确实
变大了,只是画面没跟上。
裁判端两路的缩放**互相独立**:滚轮只作用于鼠标所在的那一路。
### 为什么两格必须等宽
裁判端曾经放大时左边占 55%、右边 45%;空的那一侧更极端时会被压成一条缝。
原因不是缺 `stretch`。`HorizontalLayout` 先按内容的**最大**需求分配,剩下的才按
stretch 分。左格写"已连接,等待画面…"(8 字),右格"未收到画面"(5 字),左格就
多要十几个像素。而没有画面的一格最小宽度接近 0,可以被压没。
三处一起改:
1. **占位文字改成绝对定位**(给出 `x`/`y`),不参与父级尺寸计算。文字长短本不该
决定画面大小。
2. **两格统一 `preferred-width` + `horizontal-stretch: 1`**,需求变成相同,于是精确
平分。`min-width: 320px` 兜底,保证任何一侧至少有一个能看画的宽度。
3. **缩放指示与复位按钮也绝对定位**。否则它们一出现就改变这一格的尺寸需求,把不
等宽重新引进来——而且是在用户放大之后才出现,最难排查。
`preferred-width` 只接受长度或字面量 `100%`,不接受 `50%`。
### 启动即最大化
```rust
ui.window().set_maximized(true);
ui.show()?;
```
**必须在 `show()` 之前**——之后调用会被后端当成"用户点了最大化按钮",而不是初始
状态。
取 `set_maximized` 而不是 `set_fullscreen`:全屏会盖掉菜单栏和 Dock,切出去要按快
捷键。裁判终端偶尔要切到别的窗口核对数据。
## 内存:解码只留最新一张
解码线程在写入前先 `retain` 掉同一路的旧图(`main.rs`)。
+147 -18
View File
@@ -56,6 +56,15 @@ const USAGE: &str = "\
/// 不是每帧一次:显示不需要每秒重绘 22 次,更快只是让事件循环和接收路径抢 CPU。
const UI_POLL: Duration = Duration::from_millis(33);
/// 队伍端在 release 下是否显示技术诊断信息。
///
/// 对抗赛里两队都用这套系统看自己小车的图传,所以 release 给现场用、debug 给开发
/// 用。这个开关由**编译模式**决定而不是命令行参数——命令行开关意味着二进制里带着
/// 那套东西,忘开一次就泄露了。
///
/// 裁判端不受它影响:裁判是中立方,双方遥测对判罚有用。
const TEAM_DIAGNOSTICS: bool = cfg!(debug_assertions);
/// 帧率统计窗口。
///
/// 按计数除以墙钟时间,而不是对帧间隔取平均。一次轮询之间可能完成多帧,所以平均间隔
@@ -136,14 +145,41 @@ fn main() -> Result<(), Box<dyn Error>> {
config_path.display()
);
// 队伍端在 release 下已经绑定时,把接收端限制到那一块。
//
// 这是本次改动里唯一有实际防护的一处:`.slint` 里不显示 id 只是藏起文字,而
// 广播的 Hello 早就把 MAC 派生的 id 和 IP 送出去了(对手 tcpdump 一下就有)。
// 在**接收端**拒绝才是真的:不重组、不解码、不进 discovery。
//
// 四种情况不限制:
// - 裁判端(中立第三方,本来就要两块板子)
// - debug 构建(开发时要能看到全场)
// - 队伍端还没绑定(选择界面需要列出候选)
// - 命令行临时用别的板子跑(--camera-id 覆盖配置)
let restrict = if config.role == Role::Team && !TEAM_DIAGNOSTICS {
config.camera_id.filter(|_| args.camera_id.is_none())
} else {
None
};
let rx = Arc::new(Receiver::start(
args.camera,
config.role.to_protocol(),
restrict,
)?);
let discovery = rx.discovery();
let frames = rx.frames();
if args.list {
// 队伍端 release 下 `--list` 会列出场上所有板子(包括对手的 id 和 IP),
// 正是要防的那件事。所以这里直接拒绝,而不是列出来再让人别看。
if config.role == Role::Team && !TEAM_DIAGNOSTICS {
return Err(
"--list 需要 debug 构建:它会列出场上所有板子的 id 和 IP。\
release 的队伍端请直接用已绑定的配置运行。"
.into(),
);
}
// 等几个广播周期,让列表有内容。
let socket = bind_socket()?;
let for_list = Arc::clone(&rx);
@@ -236,8 +272,17 @@ fn main() -> Result<(), Box<dyn Error>> {
match config.role {
Role::Team => {
let ui = TeamApp::new()?;
// 启动即最大化。必须在 show() 之前:之后调用会被后端当成"用户点了最大化
// 按钮"。取 set_maximized 而不是 set_fullscreen,因为全屏会盖掉菜单栏和
// Dock,裁判终端要切去别的窗口核对数据。
ui.window().set_maximized(true);
ui.show()?;
// 接收端的限制在构造 Receiver 时已经按配置设好了(见上面 `restrict`),
// 这里不重复设:只有选择界面上新选定的那一次需要改它。
let rx_for_pick = Arc::clone(&rx);
let weak = ui.as_weak();
let discovery = Arc::clone(&discovery);
let decoded = Arc::clone(&decoded);
@@ -288,6 +333,13 @@ fn main() -> Result<(), Box<dyn Error>> {
} else {
log::info!("bound to camera 0x{id:08X}; saved in the config");
}
// 选定之后把接收端也限制到这一块。此后场上别人的板子即使把流推到
// 本机端口,也不会被重组或解码。
//
// 解除限制(传 None)要等到重启:配置里有了 camera_id 就会走绑定
// 分支,限制随之恢复,两者不会不一致。
rx_for_pick.restrict_to(Some(id));
});
let choices_for_poll = Arc::clone(&choices);
@@ -331,12 +383,20 @@ fn main() -> Result<(), Box<dyn Error>> {
let stats = discovery.stats(bound);
view.update_fps(&stats);
if !view.reported && stats.frames_shown > 0 {
// 遥测日志同样按编译模式门控:release 的队伍端不往 stdout 打帧率/丢帧这些
// 能被对手读到的数字。
if TEAM_DIAGNOSTICS && !view.reported && stats.frames_shown > 0 {
log_diagnostics(&stats, bound);
view.reported = true;
}
ui.set_stats(render_camera_stats(&cameras, bound, &stats, view.fps));
ui.set_stats(render_camera_stats(
&cameras,
bound,
&stats,
view.fps,
TEAM_DIAGNOSTICS,
));
ui.set_health(render_health(team, armor.as_ref()));
});
@@ -349,6 +409,7 @@ fn main() -> Result<(), Box<dyn Error>> {
Role::Referee => {
let ui = RefereeApp::new()?;
ui.window().set_maximized(true);
ui.show()?;
let weak = ui.as_weak();
@@ -365,15 +426,47 @@ fn main() -> Result<(), Box<dyn Error>> {
let cameras = discovery.online_cameras();
// 两路按 id 顺序填槽。槽位保持稳定(不会因为某块板子临时离线就重新
// 洗牌),否则画面会左右跳动。
let ids: Vec<u32> = cameras.iter().map(|c| c.id).take(2).collect();
// 槽位对应**队伍**,不是"第 N 块发现的板子"。
//
// 两条规则:
//
// 1. 一次只填一个空槽,按 id 顺序。之前两块都空时每轮都从
// `online_cameras()` 取前两个,于是只有一块板在场时两块槽位显示
// 同一块画面——右半边不再是"队伍二",而是队伍一的复制。裁判看到
// 两边一模一样,会以为两队的图传串了。
//
// 2. 已填的槽位不换人。原先是 `ids.take(2)` 后按下标对号入座,低 id
// 的板子一离线,后面那块就跳到前面,画面左右互换,看起来同样
// 像串线。掉线的槽位保留它的 id 并显示"离线",等它回来。
//
// 所以每轮最多消费一个候选 id。
let mut next_candidate = cameras.iter().map(|c| c.id);
for (i, view) in views.iter_mut().enumerate() {
let wanted = ids.get(i).copied();
if view.sender_id != wanted {
for view in views.iter_mut() {
let still_here = view
.sender_id
.is_some_and(|id| cameras.iter().any(|c| c.id == id));
if still_here {
continue;
}
// 槽位已占且板子还在场上 → 保留(上面已 continue)。到这里说明
// 槽位是空的,或板子掉线了。
let candidate = next_candidate
.next()
// 别把刚掉线的这块又立刻选回同一个槽位:它短暂离线又回来时,
// 画面会原地卡一下才恢复。
.filter(|id| Some(*id) != view.sender_id);
match candidate {
Some(id) => {
*view = View::new();
view.sender_id = wanted;
view.sender_id = Some(id);
}
// 没有可分配的了。掉线的槽位**保留 id**(显示"离线"而不是
// 变回空槽),这样它回来时还在原位。
None => {}
}
}
@@ -398,7 +491,9 @@ fn main() -> Result<(), Box<dyn Error>> {
view.reported = true;
}
let view_stats = render_camera_stats(&cameras, sender_id, &stats, view.fps);
// 裁判端始终显示诊断信息:它是中立方,双方遥测对判罚有用。
let view_stats =
render_camera_stats(&cameras, sender_id, &stats, view.fps, true);
if i == 0 {
ui.set_stats_a(view_stats);
@@ -551,11 +646,15 @@ fn log_diagnostics(stats: &SourceStats, sender_id: u32) {
}
/// 把某一路的统计渲染成界面结构。
///
/// `diagnostics` 为 false 时不产出技术字段——它们留空字符串,界面也不显示。留空而
/// 不是填 0 或假值:0 会被读成"丢帧 0"这种看起来像真值的东西。
fn render_camera_stats(
cameras: &[CameraInfo],
sender_id: u32,
stats: &SourceStats,
fps: f32,
diagnostics: bool,
) -> CameraStats {
let online = cameras
.iter()
@@ -565,6 +664,10 @@ fn render_camera_stats(
// 从没出过画面时区分"在线但还没收到"和"离线":前者只是刚连上,显示"等待画面"
// 比让人以为板子挂了诚实。
//
// 有画面时显示 id —— 这对调试有用。裁判端和队伍端 debug 都显示;队伍端 release
// 下这一路只有自己绑定的板子,显示它不构成泄露(界面上根本没有别人的板子可选,
// 见 Receiver::restrict_to)。
let status = if stats.frames_shown == 0 {
if online {
"已连接,等待画面…".to_string()
@@ -578,17 +681,43 @@ fn render_camera_stats(
CameraStats {
online,
status_text: status.into(),
fps: format!("{fps:.1}").into(),
resolution: format!("{}x{}", stats.width, stats.height).into(),
frame_bytes: format!("{:.1} kB", stats.last_frame_len as f32 / 1024.0).into(),
frames_shown: stats.frames_shown.to_string().into(),
frames_dropped: stats.frames_dropped.to_string().into(),
loss_percent: format!("{:.1}%", stats.loss_percent()).into(),
datagrams: stats.datagrams.to_string().into(),
// 同理:没出过帧时 fps 是 0.0,读起来像"板子卡死了",而真相是"还没收到"。
// 状态行已经写了"已连接,等待画面…",这里不该再补一个矛盾的数字。
fps: (diagnostics && stats.frames_shown > 0)
.then(|| format!("{fps:.1}"))
.unwrap_or_default()
.into(),
// 没出过一个字节的源,`stats.width`/`height` 还是默认值 0,于是显示成
// "0x0"——一个看起来像分辨率的假分辨率。用 frames_shown 把它挡掉。
resolution: (diagnostics && stats.frames_shown > 0)
.then(|| format!("{}x{}", stats.width, stats.height))
.unwrap_or_default()
.into(),
frame_bytes: (diagnostics && stats.frames_shown > 0)
.then(|| format!("{:.1} kB", stats.last_frame_len as f32 / 1024.0))
.unwrap_or_default()
.into(),
frames_shown: diagnostics
.then(|| stats.frames_shown.to_string())
.unwrap_or_default()
.into(),
frames_dropped: diagnostics
.then(|| stats.frames_dropped.to_string())
.unwrap_or_default()
.into(),
loss_percent: diagnostics
.then(|| format!("{:.1}%", stats.loss_percent()))
.unwrap_or_default()
.into(),
datagrams: diagnostics
.then(|| stats.datagrams.to_string())
.unwrap_or_default()
.into(),
gap_ms: stats
.last_interval
.filter(|_| diagnostics)
.map(|d| format!("{:.0} ms", d.as_secs_f32() * 1000.0))
.unwrap_or_else(|| "-".to_string())
.unwrap_or_default()
.into(),
}
}
+73 -1
View File
@@ -185,6 +185,17 @@ impl Discovery {
.unwrap_or_default()
}
/// 只刷新存活时间戳,不改地址或固件版本。
///
/// 收到视频帧时调用:正在推流本身就是最可靠的存活证据。
fn touch(&self, id: u32) {
let mut cameras = self.cameras.lock().expect("cameras mutex poisoned");
if let Some(info) = cameras.get_mut(&id) {
info.last_seen = Instant::now();
}
}
fn note_hello(&self, id: u32, addr: SocketAddrV4, firmware: u16) {
let mut cameras = self.cameras.lock().expect("cameras mutex poisoned");
@@ -239,6 +250,18 @@ pub struct Receiver {
frames: Arc<LatestFrames>,
role: SubscribeRole,
camera: Ipv4Addr,
/// 只处理这一块板子的数据,`None` 表示全都要(裁判端)。
///
/// 队伍端在 release 下设成 `Some(bound)`,这是一道**接收端**的闸门而不只是界面
/// 的:对手的板子即使把流推到了本机端口,也不会被重组、不会进解码队列、不会
/// 出现在任何统计里。
///
/// 为什么必须在这一层而不是靠界面隐藏:`.slint` 只控制显示,而数据已经收下并
/// 解码了。隐藏一行文字不阻止任何事。
///
/// 用 `Mutex` 而不是 `Cell`/`AtomicU32`:接收线程要读它,而绑定回调在主线程上
/// 写它。订阅循环本身已经是串行的,所以锁的持有时间极短。
only_sender: Mutex<Option<u32>>,
stop: Arc<AtomicBool>,
}
@@ -247,7 +270,13 @@ impl Receiver {
///
/// 绑定固定端口(而非临时端口)是必须的:板子是从订阅的**源端口**学到推流目标的,
/// 临时端口也能工作,但固定端口让三台客户端行为一致,也方便抓包排查。
pub fn start(camera: Ipv4Addr, role: SubscribeRole) -> std::io::Result<Self> {
///
/// `only_sender` 为 `Some` 时只接受那一块板子的数据,见 [`Receiver`] 的字段说明。
pub fn start(
camera: Ipv4Addr,
role: SubscribeRole,
only_sender: Option<u32>,
) -> std::io::Result<Self> {
let socket =
UdpSocket::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, DEFAULT_PORT))?;
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
@@ -262,10 +291,24 @@ impl Receiver {
frames: Arc::new(LatestFrames::default()),
role,
camera,
only_sender: Mutex::new(only_sender),
stop: Arc::new(AtomicBool::new(false)),
})
}
/// 绑定之后切换限制目标,或解除限制。
///
/// 队伍端在选择界面里点定一块板子时调用:此前要能看到所有候选(否则无从选
/// 起),此后只看这一块。
pub fn restrict_to(&self, only_sender: Option<u32>) {
*self.only_sender.lock().expect("only_sender mutex poisoned") = only_sender;
}
/// 当前限制到哪一块板子。
fn sender_limit(&self) -> Option<u32> {
*self.only_sender.lock().expect("only_sender mutex poisoned")
}
pub fn discovery(&self) -> Arc<Discovery> {
Arc::clone(&self.discovery)
}
@@ -348,9 +391,22 @@ impl Receiver {
return;
}
let limit = self.sender_limit();
// 板子的在线通告。注意这里不看来源端口:广播来的源端口没有意义,身份由
// 头里的 sender_id 决定。
if let Some(hello) = protocol::Hello::decode(bytes) {
// 限制生效时不记录其他板子。理由不只是"不显示":如果把它们记进
// discovery,`--list` 式的输出、槽位排序、界面上任何一处遍历 cameras()
// 的地方都会间接泄露场上还有谁。
if let (Some(only), SocketAddr::V4(v4)) = (limit, from) {
if hello.sender_id != only {
return;
}
self.discovery.note_hello(hello.sender_id, v4, hello.firmware);
return;
}
if let SocketAddr::V4(v4) = from {
let is_new = !self.discovery.cameras().iter().any(|c| c.id == hello.sender_id);
self.discovery.note_hello(hello.sender_id, v4, hello.firmware);
@@ -372,6 +428,22 @@ impl Receiver {
let id = frag.header.sender_id;
// 视频同样要过闸门。对手的板子一旦把流推到这个端口(IP 已被广播泄露,
// 端口是公开常量),它的数据不会进任何重组器。
if let Some(only) = limit {
if id != only {
return;
}
}
// 视频帧也算存活证据。
//
// `is_online` 看的是 `last_seen`,而它此前只由 Hello 更新。于是出现一个
// 荒唐的状态:画面在每秒刷 22 帧,状态栏却显示"离线"(橙色)。真板子每 2
// 秒广播一次所以不易撞上,但它本身就是设计错误——正在推流的板子比广播更
// 可靠地证明自己活着。
self.discovery.touch(id);
match assemblers
.entry(id)
.or_insert_with(|| FrameAssembler::new(FRAME_TIMEOUT))
+325 -191
View File
@@ -1,6 +1,10 @@
// Rectangle、VerticalLayout、HorizontalLayout 是语言内置的,无需 import。
/* 一块画面的读数。裁判端有两块,队伍端一块。 */
/* 一块画面的读数。
*
* 技术字段(fps / 丢帧 / 数据报数等)在队伍端 release 下**不产出**:Rust 侧
* `render_camera_stats(..., TEAM_DIAGNOSTICS)` 传 false 时这些字段是空字符串,
* 界面也不显示它们。裁判端始终显示。 */
export struct CameraStats {
// 在线状态
online: bool,
@@ -43,9 +47,260 @@ export struct CameraChoice {
online: bool,
}
// ---------------------------------------------------------------- 队伍端
/* ------------------------------------------------------------------ 视频窗格
*
* 一路画面 + 滚轮缩放 + 拖动平移。两种身份共用这个组件。
*
* 缩放的实现方式:把 `Image` 的 `width`/`height` 按 `zoom` 放大后放进一个
* `clip: true` 的容器里,靠 `offset-x`/`offset-y` 平移。Slint 的 `Image` 没有
* 缩放因子属性,所以只能用这个办法——它也顺带让"放大到超出窗口"成为可能。
*
* `image-fit: contain` 让缩放到 1.0 时画面完整可见且不变形;放大后
* `image-fit: fill` 才会跟着缩放,否则画面尺寸固定在 contain 算出的尺寸上,
* 放大看起来毫无反应。
*/
component VideoPane inherits Rectangle {
// 画面
in property <image> frame;
// 没有画面时显示的提示(例如槽位空着)
in property <string> empty-text;
/*
background: #000000;
// 关键:不 clip 的话放大后的画面会溢出到界面其它地方去。
clip: true;
// 缩放与平移。zoom = 1.0 是"完整可见"。
in-out property <float> zoom: 1.0;
in-out property <length> offset-x: 0px;
in-out property <length> offset-y: 0px;
// 放大到几倍为止。再多也看不清了,只是把像素拉大。
in property <float> max-zoom: 8.0;
in property <float> step: 1.25;
// 拖动时的起点,由 TouchArea 记录。
private property <length> drag-start-x: 0px;
private property <length> drag-start-y: 0px;
private property <length> drag-origin-x: 0px;
private property <length> drag-origin-y: 0px;
public function reset-view() {
root.zoom = 1.0;
root.offset-x = 0px;
root.offset-y = 0px;
}
Image {
source: root.frame;
// contain:zoom=1 时完整可见且不变形。
// fill:放大后跟着容器缩放——只用 contain 的话放大不会有任何视觉变化。
image-fit: root.zoom <= 1.0 ? contain : fill;
width: parent.width * root.zoom;
height: parent.height * root.zoom;
x: (parent.width - self.width) / 2 + root.offset-x;
y: (parent.height - self.height) / 2 + root.offset-y;
horizontal-alignment: center;
vertical-alignment: center;
}
// 没有画面时的占位。裁判端两块板子通常只到一块,空槽位要能说清原因,
// 而不是留一块黑。
//
// `absolute` 布局:它不参与父级的尺寸需求计算。之前它不是,于是"已连接,
// 等待画面…"这行字的长度决定了这一格要多少宽度,裁判端两格就永远不等宽
// (实测 55% / 45%)——文字长短本不该影响画面大小。
if root.empty-text != "": Text {
text: root.empty-text;
color: #5a5a64;
font-size: 14px;
horizontal-alignment: center;
vertical-alignment: center;
// 占满整个窗格,文字居中,但不参与布局。
x: 0;
y: parent.height / 2 - self.height / 2;
width: parent.width;
}
// 缩放指示:只在放大时出现,否则一直占着位置。
//
// 同样是绝对定位(给出 x/y),否则它一出现就会改变这一格的尺寸需求,把两路
// 不等宽的问题重新引进来——而且是在用户放大之后才出现,最难排查。
if root.zoom > 1.05: Rectangle {
background: #000000aa;
border-radius: 4px;
width: 92px;
height: 26px;
x: parent.width - self.width - 10px;
y: 10px;
Text {
text: "x" + root.zoom;
color: #d0d0d8;
font-size: 13px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
// reset-view 按钮:只在放大/平移后出现,给一个回到全景的出口。
if root.zoom > 1.05 || root.offset-x != 0px || root.offset-y != 0px: Rectangle {
background: #000000aa;
border-radius: 4px;
width: 76px;
height: 26px;
x: 10px;
y: 10px;
TouchArea {
clicked => {
root.reset-view();
}
}
Text {
text: "复位";
color: #d0d0d8;
font-size: 13px;
horizontal-alignment: center;
vertical-alignment: center;
}
}
// 滚轮与拖动都在最上层。
TouchArea {
scroll-event(event) => {
// delta-y 为正表示向上滚(放大)。
if event.delta-y > 0px {
root.zoom = Math.min(root.zoom * root.step, root.max-zoom);
} else if event.delta-y < 0px {
root.zoom = Math.max(root.zoom / root.step, 1.0);
}
// 缩回 1.0 时顺便归位:此时画面完整可见,平移没有意义,
// 留着会让下一次放大从偏移位置开始,看起来像"跳了一下"。
if root.zoom <= 1.0 {
root.offset-x = 0px;
root.offset-y = 0px;
}
return EventResult.accept;
}
// 拖动平移。
//
// 用 `pointer-event` 而不是 `pressed` / `moved`:TouchArea 的 `pressed` 是
// **属性**(可以读 `self.pressed`)而不是回调,写成 `pressed => {...}` 编译
// 不过。按下(Down)时记起点和当前偏移,移动(Move)时按增量更新。
pointer-event(event) => {
if event.kind == PointerEventKind.down {
root.drag-start-x = self.mouse-x;
root.drag-start-y = self.mouse-y;
root.drag-origin-x = root.offset-x;
root.drag-origin-y = root.offset-y;
} else if event.kind == PointerEventKind.move && self.pressed {
// 只在放大后生效——zoom=1 时画面完整可见,没有可移动的空间。
if root.zoom > 1.0 {
root.offset-x = root.drag-origin-x
+ (self.mouse-x - root.drag-start-x);
root.offset-y = root.drag-origin-y
+ (self.mouse-y - root.drag-start-y);
}
}
return EventResult.accept;
}
double-clicked => {
root.reset-view();
}
// 放大后用抓取光标提示可拖动。
mouse-cursor: root.zoom > 1.0 ? move : default;
}
}
/* 一路画面的下方信息条。两种身份共用。
*
* 技术读数(fps / shown / dropped)在队伍端 release 下为空字符串,`visible`
* 让整块消失而不是留一条空行。 */
component StatusBar inherits Rectangle {
in property <CameraStats> stats;
in property <TeamHealth> health;
// 血格子比状态栏里的字宽,裁判端并排时用
in property <length> cell-width: 34px;
in property <length> cell-height: 12px;
// 队伍端那一条更高一些,因为它独占一行血量
in property <bool> wide: false;
background: #1a1a20;
height: wide ? 72px : 78px;
HorizontalLayout {
padding: 12px;
spacing: 16px;
// 左:队伍名 + 血量格子
VerticalLayout {
padding: 0;
spacing: 5px;
horizontal-stretch: 1;
Text {
text: root.health.team-name;
color: #e8e8ec;
font-size: 14px;
font-weight: 600;
vertical-alignment: center;
}
HorizontalLayout {
padding: 0;
spacing: 5px;
for hp[index] in root.health.hps: Rectangle {
width: root.cell-width;
height: root.cell-height;
border-width: 1px;
border-color: root.health.available ? #4a4a55 : #2f2f38;
background: root.health.available ? #6cc96c : #17171c;
}
}
}
// 右:状态 + 技术读数
//
// VerticalLayout 没有 horizontal-alignment 属性(右对齐要落在里面的 Text
// 上)。给它 preferred-width 而不是 stretch,让它按内容宽度占位。
VerticalLayout {
padding: 0;
spacing: 3px;
preferred-width: root.wide ? 340px : 300px;
Text {
text: root.stats.status-text
+ (root.stats.fps != "" ? " " + root.stats.fps + " fps" : "")
+ (root.stats.resolution != "" ? " " + root.stats.resolution : "");
color: root.stats.online ? #e8e8ec : #e0a060;
font-size: 13px;
horizontal-alignment: right;
}
// 技术读数整行在 release 下消失,不留空行。
if root.stats.frames-shown != "": Text {
text: "shown " + root.stats.frames-shown
+ (root.stats.frames-dropped != ""
? " dropped " + root.stats.frames-dropped
+ " (" + root.stats.loss-percent + ")"
: "")
+ (root.stats.gap-ms != "" ? " gap " + root.stats.gap-ms : "")
+ (root.stats.frame-bytes != "" ? " " + root.stats.frame-bytes : "");
color: root.stats.frames-dropped == "0" ? #7e7e89 : #e0a060;
font-size: 12px;
horizontal-alignment: right;
}
}
}
}
/* ---------------------------------------------------------------- 队伍端
*
* 队伍终端:一块画面 + 自己那一边的血量。
*
* 与裁判端是两套独立布局而不是一套加条件显示:两种场景的布局诉求不同(一边要省
@@ -53,8 +308,13 @@ export struct CameraChoice {
* 情况。 */
export component TeamApp inherits Window {
title: "cam-client — 队伍";
preferred-width: 1000px;
preferred-height: 720px;
// 初始尺寸只作为"用户什么都不做时"的兜底;Rust 侧在 show() 之前按屏幕工作区
// 覆盖它,所以这里的数字只是无屏幕信息时的退路。
preferred-width: 1280px;
preferred-height: 800px;
// min-* 保证拖到很小时仍然可用:两路并排时没有下限,空的那格会被压没。
min-width: 900px;
min-height: 600px;
in property <image> frame;
in property <CameraStats> stats;
@@ -64,101 +324,33 @@ export component TeamApp inherits Window {
in property <[CameraChoice]> choices;
in property <bool> choosing;
// 选中了列表里的第 N 项。
//
// 绑定在这里一次完成:Rust 侧收到回调就写配置文件,所以"选定"当场生效,而不是
// "下次启动才生效"。索引而不是 id,是为了让 Rust 侧回到自己那份列表里取完整
// 信息——.slint 里的模型只是给界面看的。
callback pick(int);
background: #101014;
// 绑定完成前只显示选择界面:没有画面可显示,显示一个空窗口更让人困惑。
if !root.choosing: VerticalLayout {
spacing: 0;
HorizontalLayout {
padding: 0;
spacing: 0;
Rectangle {
background: #0a0a0d;
// 画面占满其余全部空间。0 的 padding/spacing 是"贴边",让画面尽可能大——
// 视频终端里每一像素都可能是有用信息。
VideoPane {
frame: root.frame;
empty-text: root.stats.online ? "已连接,等待画面…" : "离线";
vertical-stretch: 1;
frame := Image {
source: root.frame;
image-fit: contain;
width: parent.width;
height: parent.height;
horizontal-alignment: center;
vertical-alignment: center;
}
}
}
// 底部一条:左边自己这边的血量,右边这一路的统计。
Rectangle {
background: #1c1c22;
height: 72px;
HorizontalLayout {
padding: 14px;
spacing: 24px;
VerticalLayout {
padding: 0;
spacing: 2px;
horizontal-stretch: 1;
Text {
text: root.health.team-name;
color: #e8e8ec;
font-size: 14px;
font-weight: 600;
}
// 装甲板还没接上,所以是一排占位而不是四个数字。
HorizontalLayout {
padding: 0;
spacing: 6px;
for hp in root.health.hps: Rectangle {
width: 34px;
height: 12px;
border-width: 1px;
border-color: root.health.available ? #4a4a55 : #33333c;
background: root.health.available ? #7ddc7d : #1a1a20;
}
}
}
VerticalLayout {
padding: 0;
spacing: 2px;
preferred-width: 320px;
Text {
text: root.stats.status-text + " " + root.stats.fps + " fps "
+ root.stats.resolution + " " + root.stats.frame-bytes;
color: root.stats.online ? #e8e8ec : #e0a060;
font-size: 13px;
}
Text {
text: "shown " + root.stats.frames-shown
+ " dropped " + root.stats.frames-dropped
+ " (" + root.stats.loss-percent + ")"
+ " gap " + root.stats.gap-ms;
color: root.stats.frames-dropped == "0" ? #8a8a95 : #e0a060;
font-size: 12px;
}
}
}
StatusBar {
stats: root.stats;
health: root.health;
wide: true;
}
// 血量还没有数据源:把这件事写出来,而不是让一排灰块让人猜。
// release 下这行也保留——它不泄露任何对手信息。
Rectangle {
background: #14141a;
height: 26px;
height: 24px;
Text {
text: root.health.placeholder;
@@ -173,7 +365,7 @@ export component TeamApp inherits Window {
// 选择界面
if root.choosing: VerticalLayout {
padding: 32px;
spacing: 16px;
spacing: 14px;
Text {
text: "选择要观看的摄像头";
@@ -189,7 +381,7 @@ export component TeamApp inherits Window {
wrap: word-wrap;
}
Rectangle { height: 8px; background: transparent; }
Rectangle { height: 6px; background: transparent; }
// TouchArea 而不是给 Rectangle 挂 callback:Rectangle 是基础元素,没有
// 点击回调;TouchArea 才是带交互的元素。
@@ -252,16 +444,18 @@ export component TeamApp inherits Window {
}
}
// ---------------------------------------------------------------- 裁判端
/* 裁判终端:两块画面左右并排 + 双方血量。
/* ---------------------------------------------------------------- 裁判端
*
* 裁判终端:两块画面左右并排 + 双方血量。
*
* 与队伍端分开:这里的核心诉求是"一眼比出两边的状态",所以两块画面等宽、血量
* 并排对称。 */
export component RefereeApp inherits Window {
title: "cam-client — 裁判系统";
preferred-width: 1400px;
preferred-height: 800px;
preferred-width: 1600px;
preferred-height: 900px;
min-width: 1000px;
min-height: 640px;
in property <image> frame-a;
in property <image> frame-b;
@@ -270,139 +464,79 @@ export component RefereeApp inherits Window {
in property <TeamHealth> health-a;
in property <TeamHealth> health-b;
// 两路的缩放互相独立:滚轮只作用于鼠标所在的一路(见各自窗格上的 TouchArea)。
// 裁判要并排比出两边,所以默认都是 1.0,且不提供联动。
background: #101014;
VerticalLayout {
spacing: 0;
// 两路画面等宽并排,各占一半。
// 两路画面等宽并排。
//
// 之前只写 `horizontal-stretch: 1`,于是**内容多的一格会占更多宽度**:
// HorizontalLayout 先按内容的最大需求分配,剩下的才按 stretch 分。队伍一
// 那格写着"已连接,等待画面…",队伍二只有"未收到画面",于是左边多要了
// 十几个像素,右边就少十几个——实测 55% / 45%。
//
// 空的那一侧被压成一条缝是更极端的同一类问题:没有图 ⇒ 最小宽度接近 0。
//
// 解法是让两格的需求**只由画面决定**,与文字无关:
// preferred-width: 50% 声明"我想要一半",配合 stretch 1 精确平分
// min-width: 320px 兜底,保证任何一侧至少有一个能看画的宽度
// 文字的溢出由 VideoPane 的 clip 裁掉,不参与布局。
HorizontalLayout {
padding: 0;
spacing: 2px;
Rectangle {
background: #0a0a0d;
VideoPane {
frame: root.frame-a;
empty-text: root.stats-a.online ? "已连接,等待画面…" : "等待队伍一的图传";
vertical-stretch: 1;
horizontal-stretch: 1;
frame-a := Image {
source: root.frame-a;
image-fit: contain;
width: parent.width;
height: parent.height;
horizontal-alignment: center;
vertical-alignment: center;
}
preferred-width: 480px;
min-width: 320px;
}
Rectangle {
background: #0a0a0d;
VideoPane {
frame: root.frame-b;
// 槽位对应队伍,所以空槽要说清在等谁,而不是笼统的"未收到画面"。
empty-text: root.stats-b.online ? "已连接,等待画面…" : "等待队伍二的图传";
vertical-stretch: 1;
horizontal-stretch: 1;
frame-b := Image {
source: root.frame-b;
image-fit: contain;
width: parent.width;
height: parent.height;
horizontal-alignment: center;
vertical-alignment: center;
}
preferred-width: 480px;
min-width: 320px;
}
}
// 每路画面下方的队伍条:名字、血量、统计。
//
// 用与画面列完全相同的宽度策略,所以拖动窗口时文字块不会跟着画面块错位。
HorizontalLayout {
padding: 0;
spacing: 2px;
Rectangle {
background: #1c1c22;
height: 78px;
StatusBar {
stats: root.stats-a;
health: root.health-a;
horizontal-stretch: 1;
VerticalLayout {
padding: 10px;
spacing: 4px;
Text {
text: root.health-a.team-name + " " + root.stats-a.status-text
+ " " + root.stats-a.fps + " fps " + root.stats-a.resolution;
color: root.stats-a.online ? #e8e8ec : #e0a060;
font-size: 14px;
font-weight: 600;
preferred-width: 480px;
min-width: 320px;
}
HorizontalLayout {
padding: 0;
spacing: 6px;
for hp in root.health-a.hps: Rectangle {
width: 40px;
height: 14px;
border-width: 1px;
border-color: root.health-a.available ? #4a4a55 : #33333c;
background: root.health-a.available ? #7ddc7d : #1a1a20;
}
}
Text {
text: "shown " + root.stats-a.frames-shown
+ " dropped " + root.stats-a.frames-dropped
+ " (" + root.stats-a.loss-percent + ")"
+ " gap " + root.stats-a.gap-ms;
color: root.stats-a.frames-dropped == "0" ? #8a8a95 : #e0a060;
font-size: 12px;
}
}
}
Rectangle {
background: #1c1c22;
height: 78px;
StatusBar {
stats: root.stats-b;
health: root.health-b;
horizontal-stretch: 1;
VerticalLayout {
padding: 10px;
spacing: 4px;
Text {
text: root.health-b.team-name + " " + root.stats-b.status-text
+ " " + root.stats-b.fps + " fps " + root.stats-b.resolution;
color: root.stats-b.online ? #e8e8ec : #e0a060;
font-size: 14px;
font-weight: 600;
}
HorizontalLayout {
padding: 0;
spacing: 6px;
for hp in root.health-b.hps: Rectangle {
width: 40px;
height: 14px;
border-width: 1px;
border-color: root.health-b.available ? #4a4a55 : #33333c;
background: root.health-b.available ? #7ddc7d : #1a1a20;
}
}
Text {
text: "shown " + root.stats-b.frames-shown
+ " dropped " + root.stats-b.frames-dropped
+ " (" + root.stats-b.loss-percent + ")"
+ " gap " + root.stats-b.gap-ms;
color: root.stats-b.frames-dropped == "0" ? #8a8a95 : #e0a060;
font-size: 12px;
}
}
preferred-width: 480px;
min-width: 320px;
}
}
// 装甲板还没接上:说清楚,而不是留一排看不懂的灰块。
Rectangle {
background: #14141a;
height: 26px;
height: 24px;
Text {
text: root.health-a.placeholder;