// 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 frame; // 没有画面时显示的提示(例如槽位空着) in property empty-text; background: #000000; // 关键:不 clip 的话放大后的画面会溢出到界面其它地方去。 clip: true; // 缩放与平移。zoom = 1.0 是"完整可见"。 in-out property zoom: 1.0; in-out property offset-x: 0px; in-out property offset-y: 0px; // 放大到几倍为止。再多也看不清了,只是把像素拉大。 in property max-zoom: 8.0; in property step: 1.25; // 拖动时的起点,由 TouchArea 记录。 private property drag-start-x: 0px; private property drag-start-y: 0px; private property drag-origin-x: 0px; private property 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 stats; in property health; // 血格子比状态栏里的字宽,裁判端并排时用 in property cell-width: 34px; in property cell-height: 12px; // 队伍端那一条更高一些,因为它独占一行血量 in property 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 frame; in property stats; in property health; // 选择绑定时显示,其余时候隐藏 in property <[CameraChoice]> choices; in property 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 frame-a; in property frame-b; in property stats-a; in property stats-b; in property health-a; in property 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; } } } }