Files
ESP32S3_vt/desktop/src-tauri/src/main.rs
T
Horldsence a93ae065cf desktop: 双方血条(角上平行四边形)、归零即击败、去掉受击抖动、裁判端可改队名
界面改动都在浏览器预览里逐条核对过(DOM 尺寸 + 视觉),截图留在 desktop/screenshots/
(该目录不进仓库)。

1. 客户端显示对手的血量
   快照里两端都给两支队伍(顺序固定为队伍号 0/1),界面按 `team` 取那一格,不再靠
   数组下标。顺带修掉一个真 bug:用户端"对手那一格"以前会走 `armor_id`——那是我自己
   绑定的主板,于是右上角显示的是自己的血,比不显示更糟。现在只有"自己那一块"才用
   `armor_id`;同理 `assigned_armor` 里的裁判端槽位指定对用户端不再生效。

2. 血条改到画面两个上角,平行四边形,一条分 5 格
   左上角自己、右上角对手(裁判端两格各贴自己的角,读法一致)。整条 skewX(-15deg),
   右条再 scaleX(-1) 镜像,两条斜边朝外对称、**都从靠中心的一侧开始灭**。一格 20 点血。
   条挂在绝对定位的一层上并 pointer-events:none(那一层盖着画面,而画面要能滚轮缩放、
   拖动),倍率与复位徽标因此从上沿挪到下沿。未接入显示"装甲板未接入"而不是 0。
   注意:不要在 JS 里再反转一次数组——那会和 CSS 的 scaleX(-1) 互相抵消,表现是右条
   从外侧开始灭(代码里写了注释钉住这件事)。

3. 受击画面不抖动
   删掉整幅图(含瞄准器)的位移与 shakeOffset。`HitFeedbackConfig::shake` 字段保留但
   **不再生效**:配置文件是 deny_unknown_fields 的,删字段会让所有写过 shake = true 的
   配置解析失败,而配置解析失败等于程序起不来。默认值改 false,设置面板去掉开关,
   并有单测钉住"旧配置读得进来、默认不抖"。

4. 血量归零算击败,不显示"损毁"
   血条上不再出现"已击毁/损毁",归零的结论放到图传中央:胜利 / 击败 / 平局。结论在
   Rust 侧算(snapshot::resolve_outcomes,4 条单测),规则有意不对称——**输只看自己**
   (自己归零就是输,哪怕对手主板掉线),**赢要看两边**(对手归零且自己还有血),
   两边同时归零是平局。它一直显示到血量被重置,不做几秒淡出,否则截图与回放里就再也
   没有"这局结束了"的痕迹。

5. 裁判端可以改队伍名
   两格画面各自下面一个输入框:回车/失焦生效、Esc 取消、超 12 字截断、留空回落到
   「队伍一 / 队伍二」。命令是 `set_team_name(team, name)`,**一次只改一边**——传整个
   两条的数组就成了"读两条、改一条、写两条",而读来的那两条来自最多 200ms 前的快照,
   紧接着改另一边会把上一次的改动覆盖回去。规范化统一在 `config::sanitize_team_name`
   (合并空白、按字符截断、留空回落),并同样过密码闸门(裁判端本来就没有密码)。
   名字只有一个来源(快照的 `teamNames` / DTO 的 `name`):装甲板表、槽位标签、设置
   面板与"等待图传"的占位文字都跟着改,不再有第二处自己拼"队伍一/队伍二"。

测试:core 80(69→80)、src-tauri 30、protocol 15;`pnpm build`(tsc + vite)通过。
2026-10-14 20:48:12 +08:00

358 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! cam-desktop —— ESP32 图传与装甲板系统的桌面端。
//!
//! 一个进程、两套页面:
//!
//! - **用户端**(`--role team`):一路画面 + 血条 + 瞄准器,绑定一块图传板与一块装甲板。
//! - **裁判端**(`--role referee`,默认):两路画面 + 双方血量 + 装甲板发现列表 + 截图与录制。
//!
//! 界面是 WebView(React 渲染到 canvas),后端是这里的 Rust:UDP 收流、发现、装甲板、
//! 配置与密码、录制。视频不经 IPC,而是走一个只监听回环的 WebSocket(见 [`server`])。
//!
//! # 命令行
//!
//! ```text
//! --role <team|referee> 身份。默认看配置文件,没有配置文件时是 referee
//! --config <路径> 配置文件位置
//! --demo 演示模式:本地合成画面与血量(标了"演示模式")
//! --list 打印发现的摄像头与装甲板后退出
//! -h, --help 显示这段帮助
//! ```
//!
//! **未知参数只记一条日志,不报错**:macOS 从访达启动 .app 时会塞进来一个
//! `-psn_0_123456`,把它当错误处理的表现是"双击图标什么都没发生"。
mod commands;
mod demo;
mod recording;
mod server;
mod state;
use std::error::Error;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use cam_core::config::{self, Config, Role};
use tauri::{Manager, RunEvent, WindowEvent};
use state::AppState;
const USAGE: &str = "\
用法: cam-desktop [选项]
--role <team|referee> 身份。默认看配置文件;没有配置文件时是 referee
--config <路径> 配置文件位置
--demo 演示模式:画面与血量都是本地合成的
--list 打印发现的摄像头与装甲板后退出
-h, --help 显示这段帮助
身份由配置文件记住(~/.config/cam-desktop/config.toml),首次启动会在界面上问。
用户端的绑定改动需要密码;裁判端不需要。
环境变量:
CAM_DEMO=1 等价于 --demo
CAM_CONFIG=<路径> 等价于 --config <路径>
两个环境变量存在的理由很实际:`tauri dev` 要用两个 `--` 才能把参数递给应用
(`tauri dev -- -- --demo`),而写在一行环境变量里不会有人记错。
";
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
if let Err(err) = run() {
log::error!("{err}");
// 走到这里说明连窗口都没起来(配置坏了、回环端口绑不上)。GUI 程序从访达启动时
// 没有终端可看,所以把原因也写进一个旁边的文件——现场排查时这是唯一线索。
let path = config::default_config_path().with_extension("startup-error.txt");
let _ = std::fs::write(&path, format!("{err}\n"));
eprintln!("{USAGE}");
std::process::exit(1);
}
}
/// 命令行参数。全部可选——什么都不给时按配置文件走。
#[derive(Debug, Default)]
struct Args {
role: Option<Role>,
config: Option<PathBuf>,
demo: bool,
list: bool,
help: bool,
}
impl Args {
fn parse(argv: impl Iterator<Item = String>) -> Self {
let mut args = Args {
demo: std::env::var("CAM_DEMO").is_ok_and(|v| v == "1" || v == "true"),
config: std::env::var_os("CAM_CONFIG").map(PathBuf::from),
..Default::default()
};
let mut it = argv.peekable();
while let Some(arg) = it.next() {
match arg.as_str() {
"-h" | "--help" => args.help = true,
"--demo" => args.demo = true,
"--list" => args.list = true,
"--role" => {
args.role = it.next().and_then(|v| Role::parse(&v));
}
"--config" => {
args.config = it.next().map(PathBuf::from);
}
other => {
// 见模块文档:macOS 的 .app 启动参数与 shell 里的笔误都可能出现在这里,
// 但报错的代价(窗口起不来)远大于收益。
log::debug!("ignoring unknown argument {other:?}");
}
}
}
args
}
}
fn run() -> Result<(), Box<dyn Error>> {
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);
// 首次启动时导入旧客户端的配置,并把旧文件改名成 .bak。
let (mut config, notes) = config::load_with_migration(&config_path)?;
// 命令行覆盖配置文件,反过来不成立(只在内存里改)。这样现场可以把一台机器临时当
// 另一台用,而不动文件。
if let Some(role) = args.role {
config.role = role;
}
for note in &notes {
log::info!("{note}");
}
log::info!(
"role={} team={} camera={:?} armor={:?} mirror={} demo={} config={}",
config.role.as_str(),
config.team,
config.camera_id,
config.armor_id,
config.mirror,
args.demo,
config_path.display()
);
let (state, video_socket, armor_socket) =
AppState::new(config_path, config, notes, args.demo)?;
// 视频端口绑不上时没有 socket,接收线程也就没有意义——界面会显示那条说明。
if let Some(socket) = video_socket {
let receiver = Arc::clone(&state.receiver);
std::thread::Builder::new()
.name("udp-reader".into())
.spawn(move || receiver.run(socket))?;
}
if let Some(socket) = armor_socket {
state.armor.spawn(socket);
}
// 顺序有讲究:sink 必须先装好,后台任务才开始推——否则头几帧会掉在空处。
state.install_sinks();
// 视频通道的地址。
//
// 带 token 的那一版只在 debug 级:它是给本机开发者用 `wscat` 连上去看这条通道用的
// ("画面不动"时能立刻分清是后端没推还是前端没接)。info 级只报端口。
log::info!("stream: listening on ws://127.0.0.1:{}", state.port);
log::debug!("stream: {}", state.stream_info().url);
if args.demo {
state.note("演示模式已打开:画面与血量由本机合成,不是场上的板子。");
demo::spawn(Arc::clone(&state));
}
// --list:等一个广播周期,把发现到的东西打出来。
//
// 旧的 Slint 客户端在 release 的用户端直接拒绝这个选项(它会列出场上所有板子的 id
// 与 IP)。现在这条限制落在别处:用户端的发现列表只在**解锁后的设置面板**里放开,
// 而这里的输出只进日志,不进界面。
if args.list {
std::thread::sleep(Duration::from_secs(5));
println!("摄像头:");
for camera in state.snapshot().cameras {
println!(
" {} {} {}",
camera.id_text,
camera.addr,
if camera.online { "在线" } else { "离线" }
);
}
println!("装甲板:");
for board in state.snapshot().armor_boards {
println!(
" {} {} 队伍{} {} 血",
board.id_text,
board.addr,
board.team + 1,
board.hp
);
}
state.recordings.stop_all();
_ = state;
return Ok(());
}
let listener_state = Arc::clone(&state);
let app = tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.manage(Arc::clone(&state))
.invoke_handler(tauri::generate_handler![
commands::stream_info,
commands::get_snapshot,
commands::get_config,
commands::list_media_files,
commands::config_path,
commands::unlock,
commands::lock,
commands::set_password,
commands::set_discovery_open,
commands::bind_camera,
commands::bind_armor,
commands::set_team,
commands::set_role,
commands::set_mirror,
commands::save_hud,
commands::set_team_name,
commands::set_referee_slots,
commands::set_recording_options,
commands::screenshot,
commands::start_recording,
commands::stop_recording,
commands::reveal,
commands::open_file,
commands::clear_config,
commands::quit_app,
commands::refresh,
])
.setup(move |_app| {
listener_state.spawn_background();
Ok(())
})
// 用户端未解锁时拦住"关窗口"与"退出"。
//
// 这是需求里明确要的(退出/关闭也要密码),所以这里把 close 拦下来并让前端弹
// 解锁框。拦不住的路径仍然存在——`kill -9`、强退、拔电源——所以它同样是"防
// 随手误操作",不是安全边界。
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
let state = window.state::<Arc<AppState>>();
if state.config().role.needs_password() && !state.is_unlocked() {
api.prevent_close();
state.push(server::WireMsg::ConfirmQuit);
}
}
})
.build(tauri::generate_context!())?;
app.run(|app, event| {
if let RunEvent::ExitRequested { api, .. } = &event {
let state = app.state::<Arc<AppState>>();
if state.config().role.needs_password() && !state.is_unlocked() {
api.prevent_exit();
state.push(server::WireMsg::ConfirmQuit);
return;
}
}
if let RunEvent::Exit = event {
// 收尾录制:ffmpeg 需要把索引写完,否则留下一个放不了的 mp4。
let state = app.state::<Arc<AppState>>();
let stopped = state.recordings.stop_all();
for dto in stopped {
log::info!(
"退出:0x{:08X} 收了 {} 帧 -> {:?}",
dto.camera,
dto.frames_written,
dto.path
);
}
}
});
Ok(())
}
/// 让 [`Config`] 在日志里可读。
///
/// 实现成自由函数而不是给 `Config` 加 `Display`:那是 cam-core 的公共类型,而"日志里长
/// 什么样"是这个二进制自己的事。
#[allow(dead_code)]
fn describe(config: &Config) -> String {
format!(
"role={} team={} camera={:?} armor={:?}",
config.role.as_str(),
config.team,
config.camera_id,
config.armor_id
)
}
#[cfg(test)]
mod tests {
use super::Args;
fn args(list: &[&str]) -> Args {
Args::parse(list.iter().map(|s| s.to_string()))
}
#[test]
fn defaults_are_all_optional() {
let parsed = args(&[]);
assert!(parsed.role.is_none(), "the config file decides");
assert!(!parsed.demo);
assert!(!parsed.help);
}
#[test]
fn role_and_config_are_read() {
let parsed = args(&["--role", "team", "--config", "/tmp/x.toml"]);
assert_eq!(parsed.role, Some(cam_core::Role::Team));
assert_eq!(parsed.config.unwrap().to_string_lossy(), "/tmp/x.toml");
}
#[test]
fn a_mac_app_launch_argument_does_not_break_startup() {
// 从访达双击 .app 时系统会塞进来这个。把它当错误处理的表现是"双击没反应"。
let parsed = args(&["-psn_0_123456"]);
assert!(parsed.role.is_none());
assert!(!parsed.help);
}
#[test]
fn a_bad_role_value_falls_back_to_the_config_rather_than_dying() {
let parsed = args(&["--role", "teem"]);
assert!(parsed.role.is_none(), "typo must not kill the window");
}
#[test]
fn demo_can_be_turned_on_from_the_command_line() {
assert!(args(&["--demo"]).demo);
}
}