Files
ESP32S3_vt/client/src/main.rs
T
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

758 lines
28 KiB
Rust

//! ESP32 图传查看器。
//!
//! 两种身份,由 [`Role`] 决定:
//!
//! - **队伍端** —— 一块画面 + 自己那一边的血量。需要绑定一块板子(选定后不可更改)。
//! - **裁判端** —— 两块画面左右并排 + 双方血量。不需要绑定。
//!
//! 用法:
//!
//! ```text
//! cam-client 默认裁判端
//! cam-client --role team --team 1 队伍一
//! cam-client --camera-id 0x3D4D8A9A 绑定到某块板子
//! ```
//!
//! 绑定与身份存在配置文件里(默认 `~/.config/cam-client/config.toml`)。首次运行
//! 队伍端会列出发现的摄像头,选定后写入,之后不再询问。
//!
//! # 为什么血量是占位符
//!
//! 装甲板接口已经预留([`receiver::ArmorSource`] 与 `protocol::ArmorHealth`),但
//! 现在没有实现也没有数据源。界面显示占位符而不是 0——0 看起来像真值,现场看到会
//! 以为对方已经没血了。
mod config;
mod receiver;
use std::error::Error;
use std::net::Ipv4Addr;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use config::{Config, Role};
use receiver::{
decode_frame, ArmorSource, CameraInfo, DecodedFrame, NoArmorYet, Receiver,
SourceStats, ARMOR_BOARDS,
};
slint::include_modules!();
const USAGE: &str = "\
用法: cam-client [选项]
--role <team|referee> 身份。默认 referee(无需配置即可看两路)
--team <0|1> 自己属于哪一边,仅 team 端用
--camera-id <0xID> 绑定到某块板子(MAC 派生,重启不变)
--camera <IP> 向哪块板子发订阅(默认 255.255.255.255 广播)
--config <路径> 配置文件位置
--list 列出发现的摄像头后退出
-h, --help 显示这段帮助
";
/// 事件循环轮询新数据的间隔。
///
/// 不是每帧一次:显示不需要每秒重绘 22 次,更快只是让事件循环和接收路径抢 CPU。
const UI_POLL: Duration = Duration::from_millis(33);
/// 队伍端在 release 下是否显示技术诊断信息。
///
/// 对抗赛里两队都用这套系统看自己小车的图传,所以 release 给现场用、debug 给开发
/// 用。这个开关由**编译模式**决定而不是命令行参数——命令行开关意味着二进制里带着
/// 那套东西,忘开一次就泄露了。
///
/// 裁判端不受它影响:裁判是中立方,双方遥测对判罚有用。
const TEAM_DIAGNOSTICS: bool = cfg!(debug_assertions);
/// 帧率统计窗口。
///
/// 按计数除以墙钟时间,而不是对帧间隔取平均。一次轮询之间可能完成多帧,所以平均间隔
/// 量的是**显示**的刷新快慢而不是**相机**的出帧快慢——那会把上限 22fps 的传感器
/// 报成 36fps。一个笃定的错误数字比没有数字更糟。
const FPS_WINDOW: Duration = Duration::from_secs(2);
/// 某一路的显示状态。
struct View {
/// 显示哪块板子(`None` = 还没有画面)。
sender_id: Option<u32>,
fps: f32,
window_started: Instant,
window_frames: u64,
reported: bool,
}
impl View {
fn new() -> Self {
Self {
sender_id: None,
fps: 0.0,
window_started: Instant::now(),
window_frames: 0,
reported: false,
}
}
/// 累计窗口内的帧数,算出帧率。
fn update_fps(&mut self, stats: &SourceStats) {
let window = self.window_started.elapsed();
if window < FPS_WINDOW {
return;
}
self.fps = (stats.frames_shown - self.window_frames) as f32 / window.as_secs_f32();
self.window_frames = stats.frames_shown;
self.window_started = Instant::now();
}
}
/// 解码后的画面缓存,供事件循环取用。
///
/// 解码线程只处理纯数据(`Vec<u8>` + 尺寸),`slint::Image` 的构造留在事件循环,
/// 因为 `slint::Image` 不是 `Send`。
type DecodedSlots = Arc<Mutex<Vec<(u32, DecodedFrame)>>>;
fn main() -> Result<(), Box<dyn Error>> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let args = Args::parse(std::env::args().skip(1))?;
if args.help {
print!("{USAGE}");
return Ok(());
}
let config_path = args.config.clone().unwrap_or_else(config::default_config_path);
// 命令行覆盖配置文件,这样现场可以临时当另一台机器用而不用改文件。
let mut config = Config::load(&config_path)?;
if let Some(role) = args.role {
config.role = role;
}
if let Some(team) = args.team {
config.team = team;
}
if let Some(id) = args.camera_id {
config.camera_id = Some(id);
}
log::info!(
"role={} team={} camera_id={:?} config={}",
config.role.as_str(),
config.team,
config.camera_id,
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);
let handle = std::thread::spawn(move || for_list.run(socket));
std::thread::sleep(Duration::from_secs(5));
let cameras = discovery.online_cameras();
if cameras.is_empty() {
println!("没有发现摄像头。板子每 2 秒广播一次,再等等或检查网络。");
} else {
println!("发现 {} 块板子:", cameras.len());
for c in cameras {
println!(" 0x{:08X} {} (id 与 MAC 派生,重启不变)", c.id, c.addr);
}
}
rx.shutdown();
let _ = handle.join();
return Ok(());
}
// 血量目前没有数据源。接口已经就位,装甲板接上后换掉这一行即可。
let armor: Arc<dyn ArmorSource> = Arc::new(NoArmorYet);
let decoded: DecodedSlots = Arc::new(Mutex::new(Vec::new()));
// 接收线程。
{
let rx = Arc::clone(&rx);
std::thread::Builder::new()
.name("udp-reader".into())
.spawn(move || {
let Ok(socket) = bind_socket() else {
log::error!("could not bind the socket for the receive loop");
return;
};
rx.run(socket);
})?;
}
// 解码线程:一张 VGA 的 JPEG 要几毫秒,放在收包线程里会挤进数据报之间,造成
// 链路上本不存在的丢帧。
{
let frames = Arc::clone(&frames);
let decoded = Arc::clone(&decoded);
std::thread::Builder::new()
.name("jpeg-decode".into())
.spawn(move || {
loop {
if frames.pending_senders().is_empty() {
std::thread::sleep(Duration::from_millis(5));
continue;
}
for sender_id in frames.pending_senders() {
let Some(frame) = frames.take(sender_id) else {
continue;
};
match decode_frame(&frame.data) {
Some(pixels) => {
let mut slot = decoded.lock().expect("decoded mutex poisoned");
// 同一路只保留最新一张,避免解码已经过时的画面。
slot.retain(|(id, _)| *id != sender_id);
slot.push((sender_id, pixels));
}
None => log::warn!(
"frame {} from 0x{:08X} is not a decodable JPEG ({} bytes)",
frame.frame_id,
frame.sender_id,
frame.data.len()
),
}
}
}
})?;
}
let timer = slint::Timer::default();
// 每个身份一个闭包,而不是一个闭包里再 match 身份。
//
// 两种身份是两个不同的组件类型(TeamApp / RefereeApp),没有共同的超类可以
// 在一个闭包里统一调用 set_frame/set_frame_a。写成两个分支后每边都拿到具体
// 类型,属性赋值直接调用,不再需要 match 身份——那种 match 会让编译器在每个
// 赋值点都检查一遍身份,而它本来就是编译期就定死的。
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);
let armor = Arc::clone(&armor);
let config_path = config_path.clone();
let team = config.team;
let bound = config.camera_id;
let mut view = View::new();
// 绑定回调与刷新定时器都要读这份列表,所以用锁共享,而不是让其中一个
// 把 `choices` 移走。
let choices: Arc<Mutex<Vec<CameraChoice>>> = Arc::new(Mutex::new(Vec::new()));
// 绑定完成时写配置。"选定不可更改"就落在这里:此后配置里已有
// camera_id,界面不再出现选择页。
let choices_for_pick = Arc::clone(&choices);
let pick_handle = ui.on_pick(move |index| {
let Ok(index) = usize::try_from(index) else {
return;
};
let picked = choices_for_pick
.lock()
.expect("choices mutex poisoned")
.get(index)
.map(|c| c.id_text.to_string());
let Some(id_text) = picked else {
return;
};
let Some(id) = parse_id(&id_text).ok() else {
log::error!("could not read back the selected id {id_text:?}");
return;
};
let saved = Config {
role: Role::Team,
team,
camera_id: Some(id),
camera_addr: None,
};
if let Err(err) = saved.save(&config_path) {
log::error!("could not save the binding: {err}");
log::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);
timer.start(slint::TimerMode::Repeated, UI_POLL, move || {
let Some(ui) = weak.upgrade() else {
return;
};
let cameras = discovery.online_cameras();
// 未绑定:只显示选择界面。
let Some(bound) = bound else {
let updated: Vec<CameraChoice> = cameras
.iter()
.map(|c| CameraChoice {
id_text: format!("0x{:08X}", c.id).into(),
addr_text: format!("{} ({:?})", c.addr, c.silence()).into(),
online: c.is_online(),
})
.collect();
*choices_for_poll.lock().expect("choices mutex poisoned") = updated.clone();
ui.set_choices(std::rc::Rc::new(slint::VecModel::from(updated)).into());
ui.set_choosing(true);
return;
};
ui.set_choosing(false);
if view.sender_id != Some(bound) {
view = View::new();
view.sender_id = Some(bound);
}
if let Some(image) = take_decoded(&decoded, bound) {
ui.set_frame(image);
}
let stats = discovery.stats(bound);
view.update_fps(&stats);
// 遥测日志同样按编译模式门控: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,
TEAM_DIAGNOSTICS,
));
ui.set_health(render_health(team, armor.as_ref()));
});
// 绑定回调必须活到窗口关闭。CallbackHandle 是 Copy,但 Drop 会注销
// 回调,所以必须真的把它泄漏掉——之前用 `mem::forget` 编译器提示它对
// Copy 类型无效,那确实无效:第一次点击后回调就被注销了,第二次点击
// 什么也不会发生。
std::boxed::Box::leak(Box::new(pick_handle));
}
Role::Referee => {
let ui = RefereeApp::new()?;
ui.window().set_maximized(true);
ui.show()?;
let weak = ui.as_weak();
let discovery = Arc::clone(&discovery);
let decoded = Arc::clone(&decoded);
let armor = Arc::clone(&armor);
let mut views: Vec<View> = (0..2).map(|_| View::new()).collect();
timer.start(slint::TimerMode::Repeated, UI_POLL, move || {
let Some(ui) = weak.upgrade() else {
return;
};
let cameras = discovery.online_cameras();
// 槽位对应**队伍**,不是"第 N 块发现的板子"。
//
// 两条规则:
//
// 1. 一次只填一个空槽,按 id 顺序。之前两块都空时每轮都从
// `online_cameras()` 取前两个,于是只有一块板在场时两块槽位显示
// 同一块画面——右半边不再是"队伍二",而是队伍一的复制。裁判看到
// 两边一模一样,会以为两队的图传串了。
//
// 2. 已填的槽位不换人。原先是 `ids.take(2)` 后按下标对号入座,低 id
// 的板子一离线,后面那块就跳到前面,画面左右互换,看起来同样
// 像串线。掉线的槽位保留它的 id 并显示"离线",等它回来。
//
// 所以每轮最多消费一个候选 id。
let mut next_candidate = cameras.iter().map(|c| c.id);
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 = Some(id);
}
// 没有可分配的了。掉线的槽位**保留 id**(显示"离线"而不是
// 变回空槽),这样它回来时还在原位。
None => {}
}
}
for (i, view) in views.iter_mut().enumerate() {
let Some(sender_id) = view.sender_id else {
continue;
};
if let Some(image) = take_decoded(&decoded, sender_id) {
if i == 0 {
ui.set_frame_a(image);
} else {
ui.set_frame_b(image);
}
}
let stats = discovery.stats(sender_id);
view.update_fps(&stats);
if !view.reported && stats.frames_shown > 0 {
log_diagnostics(&stats, sender_id);
view.reported = true;
}
// 裁判端始终显示诊断信息:它是中立方,双方遥测对判罚有用。
let view_stats =
render_camera_stats(&cameras, sender_id, &stats, view.fps, true);
if i == 0 {
ui.set_stats_a(view_stats);
} else {
ui.set_stats_b(view_stats);
}
}
ui.set_health_a(render_health(0, armor.as_ref()));
ui.set_health_b(render_health(1, armor.as_ref()));
});
}
}
// Timer 一旦被 drop 就停止,所以要活到进程结束。
let _timer = Box::leak(Box::new(timer));
slint::run_event_loop()?;
rx.shutdown();
Ok(())
}
/// 从解码缓存里取出某一路的最新画面。
///
/// 解码线程只处理纯数据,`slint::Image` 的构造留在这里——`slint::Image` 不是
/// `Send`,跨不了线程。
fn take_decoded(
decoded: &DecodedSlots,
sender_id: u32,
) -> Option<slint::Image> {
let mut slot = decoded.lock().expect("decoded mutex poisoned");
let pos = slot.iter().position(|(id, _)| *id == sender_id)?;
let (_, pixels) = slot.remove(pos);
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(
&pixels.rgba,
pixels.width,
pixels.height,
);
Some(slint::Image::from_rgba8(buffer))
}
/// 命令行参数。
struct Args {
role: Option<Role>,
team: Option<u8>,
camera_id: Option<u32>,
camera: Ipv4Addr,
config: Option<PathBuf>,
list: bool,
help: bool,
}
impl Args {
fn parse(argv: impl Iterator<Item = String>) -> Result<Self, Box<dyn Error>> {
let mut args = Args {
role: None,
team: None,
camera_id: None,
camera: protocol::BROADCAST_ADDR,
config: None,
list: false,
help: false,
};
let mut it = argv.peekable();
while let Some(arg) = it.next() {
match arg.as_str() {
"-h" | "--help" => args.help = true,
"--list" => args.list = true,
"--role" => {
let v = it.next().ok_or("--role needs a value")?;
args.role =
Some(Role::parse(&v).ok_or("role must be `team` or `referee`")?);
}
"--team" => {
let v = it.next().ok_or("--team needs a value")?;
args.team = Some(v.parse().map_err(|_| "--team must be 0 or 1")?);
}
"--camera-id" => {
let v = it.next().ok_or("--camera-id needs a value")?;
args.camera_id = Some(parse_id(&v)?);
}
"--camera" => {
let v = it.next().ok_or("--camera needs a value")?;
args.camera = v
.parse()
.map_err(|_| "--camera must be an IPv4 address")?;
}
"--config" => {
args.config = Some(PathBuf::from(it.next().ok_or("--config needs a path")?));
}
other => {
return Err(format!("unknown argument {other:?}\n\n{USAGE}").into());
}
}
}
Ok(args)
}
}
/// 绑定 UDP 接收 socket。
///
/// 固定端口(而非临时端口)是必须的:板子从订阅的**源端口**学到推流目标,而这个
/// socket 同时承担收包。
fn bind_socket() -> std::io::Result<std::net::UdpSocket> {
let socket = std::net::UdpSocket::bind(std::net::SocketAddrV4::new(
Ipv4Addr::UNSPECIFIED,
protocol::DEFAULT_PORT,
))?;
socket.set_read_timeout(Some(Duration::from_millis(100)))?;
// 广播是发现的前提:板子靠它宣告存在,客户端靠它发起订阅。
let _ = socket.set_broadcast(true);
Ok(socket)
}
/// 解析 `0x1A2B3C4D` 或十进制形式的板子 id。
fn parse_id(text: &str) -> Result<u32, Box<dyn Error>> {
let text = text.trim();
if let Some(hex) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
Ok(u32::from_str_radix(hex, 16)?)
} else {
Ok(text.parse()?)
}
}
/// 一次性诊断,在某一路第一次出画面时记录。
///
/// 不再单列"无法解码"计数:那是链路正常但内容坏了的情形,会在解码线程里逐帧告警,
/// 汇总在这里反而会把它和普通的丢帧混在一起。
fn log_diagnostics(stats: &SourceStats, sender_id: u32) {
log::info!(
"camera 0x{sender_id:08X}: {} frames shown | {} dropped ({:.1}%) | \
{} datagrams | {}x{} {} bytes",
stats.frames_shown,
stats.frames_dropped,
stats.loss_percent(),
stats.datagrams,
stats.width,
stats.height,
stats.last_frame_len,
);
}
/// 把某一路的统计渲染成界面结构。
///
/// `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()
.find(|c| c.id == sender_id)
.map(CameraInfo::is_online)
.unwrap_or(false);
// 从没出过画面时区分"在线但还没收到"和"离线":前者只是刚连上,显示"等待画面"
// 比让人以为板子挂了诚实。
//
// 有画面时显示 id —— 这对调试有用。裁判端和队伍端 debug 都显示;队伍端 release
// 下这一路只有自己绑定的板子,显示它不构成泄露(界面上根本没有别人的板子可选,
// 见 Receiver::restrict_to)。
let status = if stats.frames_shown == 0 {
if online {
"已连接,等待画面…".to_string()
} else {
"离线".to_string()
}
} else {
format!("0x{:08X}", sender_id)
};
CameraStats {
online,
status_text: status.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_default()
.into(),
}
}
/// 渲染一边的血量。
///
/// 现在永远走 `available == false` 的分支,因为还没有数据源。装甲板接上后
/// [`ArmorSource::health`] 返回 `Some`,界面自动显示真实数值,这里不用改。
///
/// 没有数据时用占位符而不是 0:0 看起来像真值,现场看到会以为对方已经没血了。
fn render_health(team: u8, armor: &dyn ArmorSource) -> TeamHealth {
let name = match team {
0 => "队伍一",
_ => "队伍二",
};
match armor.health(team) {
Some(hps) => TeamHealth {
team_name: format!("{name} · 装甲板").into(),
available: true,
placeholder: String::new().into(),
hps: std::rc::Rc::new(slint::VecModel::from(
hps.iter().map(|hp| *hp as i32).collect::<Vec<i32>>(),
))
.into(),
},
None => TeamHealth {
team_name: name.into(),
available: false,
placeholder:
"装甲板未接入:血量接口已预留(protocol::ArmorHealth),当前无数据源".into(),
// 依然给出 4 个格子:界面用 `available` 决定颜色,所以空数组会让占位
// 框整个消失,看起来像布局出错,而不是像"还没有数据"。
hps: std::rc::Rc::new(slint::VecModel::from(vec![0i32; ARMOR_BOARDS])).into(),
},
}
}