裁判端两个槽位的语义应该是"队伍一 / 队伍二",而不是"第 N 块发现的板子"。之前 两者混为一谈,出现两个都会被现场误读成故障的现象: 1. **两块槽位显示同一块板。** 每轮都从 `online_cameras()` 取前两个 id 填满两 个槽位,于是只有一块板在场时两边一模一样。裁判会以为两队的图传串了。 2. **板子掉线时画面左右互换。** 低 id 的板子一离线,候选列表缩短,后面那块就 跳到前面的槽位——同样像串线,而实际上只是排序。 规则改成:每轮最多消费一个候选 id;已填且仍在线的槽位不动;掉线的槽位保留它的 id 并显示"离线",等它回来时还在原位。 空槽的提示文案也改准:左槽"等待队伍一的图传"、右槽"等待队伍二的图传",而不是笼 统的"未收到画面"。 顺带修两个假数字:`frames_shown == 0` 时 `stats.width`/`height` 还是默认值 0,界 面上显示成 `0x0`——一个看起来像分辨率的假分辨率;fps 显示 `0.0` 读起来像"板子 卡死了",而真相是"还没收到"。状态行已经写了"已连接,等待画面…",不该再补一个矛 盾的数字。 关于"别把刚掉线的板子又立刻选回同一个槽位"那条过滤:它只在槽位掉线时起作用, 且会跳过当前 id 去找下一个候选,槽位仍然有机会回来。实测唯一那块在推流的板子正 常占用一个槽位,另一槽保留着在线但不推流的真板子——符合预期。
551 lines
19 KiB
Plaintext
551 lines
19 KiB
Plaintext
// Rectangle、VerticalLayout、HorizontalLayout 是语言内置的,无需 import。
|
||
|
||
/* 一块画面的读数。
|
||
*
|
||
* 技术字段(fps / 丢帧 / 数据报数等)在队伍端 release 下**不产出**:Rust 侧
|
||
* `render_camera_stats(..., TEAM_DIAGNOSTICS)` 传 false 时这些字段是空字符串,
|
||
* 界面也不显示它们。裁判端始终显示。 */
|
||
export struct CameraStats {
|
||
// 在线状态
|
||
online: bool,
|
||
status-text: string,
|
||
|
||
// 实时测量
|
||
fps: string,
|
||
resolution: string,
|
||
frame-bytes: string,
|
||
|
||
// 累计
|
||
frames-shown: string,
|
||
frames-dropped: string,
|
||
loss-percent: string,
|
||
datagrams: string,
|
||
|
||
// 帧间隔,用于把"卡"和"帧率低"区分开
|
||
gap-ms: string,
|
||
}
|
||
|
||
/* 一边的血量(4 块装甲板)。
|
||
*
|
||
* `available` 为 false 时界面显示占位符而不是 0:0 是一个看起来像真值的假值,
|
||
* 现场看到 0 会以为对方已经没血了。 */
|
||
export struct TeamHealth {
|
||
team-name: string,
|
||
available: bool,
|
||
// 占位符文案
|
||
placeholder: string,
|
||
// 每块装甲板的血量;available 时有效
|
||
hps: [int],
|
||
}
|
||
|
||
/* 首次运行时的摄像头选择。
|
||
*
|
||
* 队伍端需要绑定一块板子(选定后不可更改),裁判端不需要。 */
|
||
export struct CameraChoice {
|
||
id-text: string,
|
||
addr-text: string,
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 队伍端
|
||
*
|
||
* 队伍终端:一块画面 + 自己那一边的血量。
|
||
*
|
||
* 与裁判端是两套独立布局而不是一套加条件显示:两种场景的布局诉求不同(一边要省
|
||
* 画面空间给视频,一边要均分),共用一套再用 visible 切换会让每处改动都要考虑两种
|
||
* 情况。 */
|
||
export component TeamApp inherits Window {
|
||
title: "cam-client — 队伍";
|
||
// 初始尺寸只作为"用户什么都不做时"的兜底;Rust 侧在 show() 之前按屏幕工作区
|
||
// 覆盖它,所以这里的数字只是无屏幕信息时的退路。
|
||
preferred-width: 1280px;
|
||
preferred-height: 800px;
|
||
// min-* 保证拖到很小时仍然可用:两路并排时没有下限,空的那格会被压没。
|
||
min-width: 900px;
|
||
min-height: 600px;
|
||
|
||
in property <image> frame;
|
||
in property <CameraStats> stats;
|
||
in property <TeamHealth> health;
|
||
|
||
// 选择绑定时显示,其余时候隐藏
|
||
in property <[CameraChoice]> choices;
|
||
in property <bool> choosing;
|
||
|
||
callback pick(int);
|
||
|
||
background: #101014;
|
||
|
||
if !root.choosing: VerticalLayout {
|
||
spacing: 0;
|
||
|
||
// 画面占满其余全部空间。0 的 padding/spacing 是"贴边",让画面尽可能大——
|
||
// 视频终端里每一像素都可能是有用信息。
|
||
VideoPane {
|
||
frame: root.frame;
|
||
empty-text: root.stats.online ? "已连接,等待画面…" : "离线";
|
||
vertical-stretch: 1;
|
||
horizontal-stretch: 1;
|
||
}
|
||
|
||
StatusBar {
|
||
stats: root.stats;
|
||
health: root.health;
|
||
wide: true;
|
||
}
|
||
|
||
// 血量还没有数据源:把这件事写出来,而不是让一排灰块让人猜。
|
||
// release 下这行也保留——它不泄露任何对手信息。
|
||
Rectangle {
|
||
background: #14141a;
|
||
height: 24px;
|
||
|
||
Text {
|
||
text: root.health.placeholder;
|
||
color: #6a6a75;
|
||
font-size: 12px;
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 选择界面
|
||
if root.choosing: VerticalLayout {
|
||
padding: 32px;
|
||
spacing: 14px;
|
||
|
||
Text {
|
||
text: "选择要观看的摄像头";
|
||
color: #e8e8ec;
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
Text {
|
||
text: "选定后不能更改。这里的选择会写入配置文件,重启后仍然生效。";
|
||
color: #8a8a95;
|
||
font-size: 13px;
|
||
wrap: word-wrap;
|
||
}
|
||
|
||
Rectangle { height: 6px; background: transparent; }
|
||
|
||
// TouchArea 而不是给 Rectangle 挂 callback:Rectangle 是基础元素,没有
|
||
// 点击回调;TouchArea 才是带交互的元素。
|
||
for choice[index] in root.choices: Rectangle {
|
||
background: choice.online ? #1c1c22 : #16161b;
|
||
border-width: 1px;
|
||
border-color: #2a2a33;
|
||
height: 56px;
|
||
|
||
TouchArea {
|
||
clicked => {
|
||
root.pick(index);
|
||
}
|
||
}
|
||
|
||
HorizontalLayout {
|
||
padding: 14px;
|
||
spacing: 16px;
|
||
|
||
Text {
|
||
text: choice.id-text;
|
||
color: #e8e8ec;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
vertical-alignment: center;
|
||
horizontal-alignment: left;
|
||
}
|
||
Text {
|
||
text: choice.addr-text;
|
||
color: #8a8a95;
|
||
font-size: 12px;
|
||
vertical-alignment: center;
|
||
horizontal-alignment: left;
|
||
horizontal-stretch: 1;
|
||
}
|
||
Text {
|
||
text: choice.online ? "在线" : "离线";
|
||
color: choice.online ? #7ddc7d : #8a8a95;
|
||
font-size: 12px;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 一块都没发现时给出可操作的提示,而不是一个空列表。
|
||
if root.choices.length == 0: Rectangle {
|
||
background: #1a1418;
|
||
border-width: 1px;
|
||
border-color: #4a2a35;
|
||
height: 64px;
|
||
|
||
Text {
|
||
text: "还没有发现任何摄像头。请确认板子已上电、已连上这个网络,\n然后稍候——板子每 2 秒广播一次在线状态。";
|
||
color: #e0a060;
|
||
font-size: 13px;
|
||
wrap: word-wrap;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 裁判端
|
||
*
|
||
* 裁判终端:两块画面左右并排 + 双方血量。
|
||
*
|
||
* 与队伍端分开:这里的核心诉求是"一眼比出两边的状态",所以两块画面等宽、血量
|
||
* 并排对称。 */
|
||
export component RefereeApp inherits Window {
|
||
title: "cam-client — 裁判系统";
|
||
preferred-width: 1600px;
|
||
preferred-height: 900px;
|
||
min-width: 1000px;
|
||
min-height: 640px;
|
||
|
||
in property <image> frame-a;
|
||
in property <image> frame-b;
|
||
in property <CameraStats> stats-a;
|
||
in property <CameraStats> stats-b;
|
||
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;
|
||
|
||
VideoPane {
|
||
frame: root.frame-a;
|
||
empty-text: root.stats-a.online ? "已连接,等待画面…" : "等待队伍一的图传";
|
||
vertical-stretch: 1;
|
||
horizontal-stretch: 1;
|
||
preferred-width: 480px;
|
||
min-width: 320px;
|
||
}
|
||
|
||
VideoPane {
|
||
frame: root.frame-b;
|
||
// 槽位对应队伍,所以空槽要说清在等谁,而不是笼统的"未收到画面"。
|
||
empty-text: root.stats-b.online ? "已连接,等待画面…" : "等待队伍二的图传";
|
||
vertical-stretch: 1;
|
||
horizontal-stretch: 1;
|
||
preferred-width: 480px;
|
||
min-width: 320px;
|
||
}
|
||
}
|
||
|
||
// 每路画面下方的队伍条:名字、血量、统计。
|
||
//
|
||
// 用与画面列完全相同的宽度策略,所以拖动窗口时文字块不会跟着画面块错位。
|
||
HorizontalLayout {
|
||
padding: 0;
|
||
spacing: 2px;
|
||
|
||
StatusBar {
|
||
stats: root.stats-a;
|
||
health: root.health-a;
|
||
horizontal-stretch: 1;
|
||
preferred-width: 480px;
|
||
min-width: 320px;
|
||
}
|
||
|
||
StatusBar {
|
||
stats: root.stats-b;
|
||
health: root.health-b;
|
||
horizontal-stretch: 1;
|
||
preferred-width: 480px;
|
||
min-width: 320px;
|
||
}
|
||
}
|
||
|
||
// 装甲板还没接上:说清楚,而不是留一排看不懂的灰块。
|
||
Rectangle {
|
||
background: #14141a;
|
||
height: 24px;
|
||
|
||
Text {
|
||
text: root.health-a.placeholder;
|
||
color: #6a6a75;
|
||
font-size: 12px;
|
||
horizontal-alignment: center;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
}
|
||
}
|