desktop: Tauri v2 后端 + 共享网络层(用户端/裁判端双身份)
把桌面端从 Slint 迁到 Tauri v2,后端先落地,界面待接。拆成三层: - desktop/core(cam-core,67 测试):从旧 client/ 抽出的非界面部分。UDP 接收按 发送方重组、装甲板发现与血量、帧率窗口、槽位分配、配置与密码。 - desktop/src-tauri(27 测试):Tauri 后端。回环 WebSocket 推二进制 JPEG、 每 200ms 一份状态快照、密码闸门、截图、ffmpeg 录制 mp4、演示数据源。 - protocol:新增 ArmorHealth::decode_from,把帧头里的 sender_id 交出来—— 装甲板一直带着 MAC 派生 id 在广播,旧解码路径把它丢了,所以"装甲板下拉可选" 不需要改固件。 几个改对了的旧问题: - 槽位分配:旧实现只排除"槽位自己原来那个 id",于是队伍一的板子一离线,它的槽位 会去拿队伍二那块,两格显示同一块板子。现在候选要排除另一个槽位占着的 id。 - 帧率窗口的基线:掉线又回来的板子带着一个很大的累计帧数回来,从 0 起算会报出 几百 fps。改成 seeded + 用"已结算窗口数"判断有没有读数。 - 密码哈希:password-hash 能解析 `$argon2id$broken` 这种截断串,于是"配置坏了" 会被读成"密码错了"。现在要求盐与摘要都在。 - 录制文件名:同一秒停一次再开一次会用同一个名字,ffmpeg 的 -y 静默覆盖上一段。 现在先收尾上一段再开新的,并且重名往后排号。 - 两道闸门:帧的限制与发现列表的限制分开。设置界面需要列候选摄像头,但**画面** 不需要——旧版把两件事绑在一道闸门上,只能在"能列候选"和"挡住对手"之间二选一。 测试:protocol 15、cam-core 67、desktop 27,其中录制那条真的起 ffmpeg 并用 ffprobe 读回帧数与分辨率(只断言"文件存在"会漏掉空容器)。
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
//! 演示模式:本地合成画面与血量,喂进**真实的**接收路径。
|
||||
//!
|
||||
//! # 为什么不是"假界面"
|
||||
//!
|
||||
//! 演示模式不伪造数据给界面看,而是合成**协议数据报**,从
|
||||
//! [`cam_core::receiver::Receiver::datagram`] 与
|
||||
//! [`cam_core::armor::ArmorHealthStore::datagram`] 进去。也就是说它跑的是同一条真实链路:
|
||||
//!
|
||||
//! ```text
|
||||
//! 合成 JPEG ──分片──▶ Receiver::datagram ──重组──▶ 帧 sink ──▶ WebSocket ──▶ canvas
|
||||
//! 合成血量 ─────────▶ ArmorHealthStore ──▶ HealthTracker ──▶ 命中反馈
|
||||
//! ```
|
||||
//!
|
||||
//! 于是演示模式顺带成了这条链路的端到端验证:分片、重组、发现、统计、掉血派生、
|
||||
//! 两道闸门(用户端只收自己那一块)全都在跑真代码。截图、录制也因此**不需要特殊分支**。
|
||||
//!
|
||||
//! # 它绝不能被误读成真实链路
|
||||
//!
|
||||
//! 只在 `--demo` / `CAM_DEMO=1` 下启动,而且 [`cam_core::snapshot::Snapshot::demo`] 为
|
||||
//! true,界面必须显示"演示模式"标记。板子 id 也刻意取成 `0x0DE00001` 这种一眼能看出
|
||||
//! 是人造的形状。
|
||||
//!
|
||||
//! # 线程
|
||||
//!
|
||||
//! 三个:两路画面各一个(各 15fps 合成 JPEG),加一个广播装甲板血量的。它们活到进程
|
||||
//! 结束、不需要退出条件——和真实的接收线程一样。
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use protocol::{fragment_frame, ArmorHealth, MsgType};
|
||||
|
||||
use crate::state::{
|
||||
AppState, DEMO_ARMOR_A, DEMO_ARMOR_B, DEMO_CAMERA_A, DEMO_CAMERA_B,
|
||||
};
|
||||
|
||||
/// 合成画面的尺寸。与实机一致(VGA),这样界面的缩放/比例在演示下也说得通。
|
||||
const WIDTH: u32 = 640;
|
||||
const HEIGHT: u32 = 480;
|
||||
|
||||
/// 合成帧率。实机是 22fps;15 够看出流畅度,又把 CPU 留给真正要验证的界面。
|
||||
const FPS: u64 = 15;
|
||||
|
||||
/// 装甲板广播周期,与固件一致(200 ms)。
|
||||
const ARMOR_PERIOD: Duration = Duration::from_millis(200);
|
||||
|
||||
/// 演示里一次"命中"打掉多少血。故意取一组差异大的值,好看出闪红强度是不是跟着伤害走。
|
||||
const DAMAGE_CYCLE: [u16; 4] = [3, 7, 12, 25];
|
||||
|
||||
pub fn spawn(state: Arc<AppState>) {
|
||||
log::warn!("演示模式:画面与血量都是本地合成的,不是场上的板子");
|
||||
|
||||
for (camera, team) in [(DEMO_CAMERA_A, 0u8), (DEMO_CAMERA_B, 1u8)] {
|
||||
spawn_camera(Arc::clone(&state), camera, team);
|
||||
}
|
||||
|
||||
spawn_armor(state);
|
||||
}
|
||||
|
||||
/// 一路合成画面:周期广播 Hello(让发现列表有东西),并按 15fps 推分片。
|
||||
fn spawn_camera(state: Arc<AppState>, camera: u32, team: u8) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name(format!("demo-cam-{camera:08X}"))
|
||||
.spawn(move || {
|
||||
let from = SocketAddr::V4(SocketAddrV4::new(
|
||||
Ipv4Addr::new(127, 0, 0, 1),
|
||||
// 端口只是让日志/发现里的地址看起来像回事。
|
||||
41000 + team as u16,
|
||||
));
|
||||
|
||||
let hello = protocol::Hello {
|
||||
sender_id: camera,
|
||||
firmware: 0x0DE0,
|
||||
};
|
||||
|
||||
let frame_interval = Duration::from_micros(1_000_000 / FPS);
|
||||
let mut frame_id: u32 = 0;
|
||||
let mut last_hello = std::time::Instant::now() - Duration::from_secs(10);
|
||||
|
||||
loop {
|
||||
let started = std::time::Instant::now();
|
||||
|
||||
// 板子每 2 秒广播一次在线状态,与固件一致。
|
||||
if last_hello.elapsed() >= Duration::from_secs(2) {
|
||||
state
|
||||
.receiver
|
||||
.datagram(&hello.encode(MsgType::Hello), from);
|
||||
last_hello = std::time::Instant::now();
|
||||
}
|
||||
|
||||
let jpeg = render(camera, team, frame_id);
|
||||
|
||||
for fragment in fragment_frame(camera, frame_id, &jpeg, WIDTH as u16, HEIGHT as u16)
|
||||
{
|
||||
state.receiver.datagram(&fragment.encode(), from);
|
||||
}
|
||||
|
||||
frame_id = frame_id.wrapping_add(1);
|
||||
|
||||
// 按目标周期睡,而不是固定 sleep:合成一张 640x480 的 JPEG 要几毫秒,
|
||||
// 不扣掉这段时间的话实际帧率会明显低于 15,演示看起来像在卡。
|
||||
if let Some(rest) = frame_interval.checked_sub(started.elapsed()) {
|
||||
std::thread::sleep(rest);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 两块装甲板主板:各自按脚本掉血、打空、重置。
|
||||
fn spawn_armor(state: Arc<AppState>) {
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("demo-armor".into())
|
||||
.spawn(move || {
|
||||
let boards = [(DEMO_ARMOR_A, 0u8, 41050u16), (DEMO_ARMOR_B, 1u8, 41051)];
|
||||
|
||||
let mut hp = [100u16, 100u16];
|
||||
// 两块板子的掉血节奏错开,界面上两边的血量不会同时变。
|
||||
let mut ticks = [0u32, 7];
|
||||
let mut hits = [0usize, 1];
|
||||
let mut dead_for = [0u32, 0];
|
||||
|
||||
loop {
|
||||
for (slot, (board, team, port)) in boards.iter().enumerate() {
|
||||
ticks[slot] += 1;
|
||||
|
||||
if hp[slot] == 0 {
|
||||
// 打空之后停一会儿再重置:让界面上"已击毁"的状态真的能被看到。
|
||||
dead_for[slot] += 1;
|
||||
if dead_for[slot] >= 10 {
|
||||
hp[slot] = 100;
|
||||
dead_for[slot] = 0;
|
||||
ticks[slot] = 0;
|
||||
log::info!("demo: 0x{board:08X} 重置为满血(长按 BOOT 的等价动作)");
|
||||
}
|
||||
} else if ticks[slot] % 15 == 0 {
|
||||
// 约 3 秒一次。
|
||||
let damage = DAMAGE_CYCLE[hits[slot] % DAMAGE_CYCLE.len()];
|
||||
hits[slot] += 1;
|
||||
hp[slot] = hp[slot].saturating_sub(damage);
|
||||
}
|
||||
|
||||
let from = SocketAddr::V4(SocketAddrV4::new(
|
||||
Ipv4Addr::new(127, 0, 0, 1),
|
||||
*port,
|
||||
));
|
||||
|
||||
// 主板一次广播四条,整车一条血所以 hp 相同、index 0..3。
|
||||
for index in 0..4u8 {
|
||||
let health = ArmorHealth {
|
||||
team: *team,
|
||||
index,
|
||||
hp: hp[slot],
|
||||
};
|
||||
|
||||
state.armor.datagram(&health.encode_from(*board), from);
|
||||
}
|
||||
}
|
||||
|
||||
std::thread::sleep(ARMOR_PERIOD);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 合成一张 PNG/JPEG 用的画面。
|
||||
///
|
||||
/// 内容刻意是为**验收**挑的:
|
||||
///
|
||||
/// - 两路的底色不同(青 / 橙),一眼能分辨左右两格有没有串;
|
||||
/// - 正中有一组同心圆 + 十字,用来核对瞄准器的偏移校准(偏移为 0 时应该正好套住圆心);
|
||||
/// - 每 40 px 一条淡网格,用来核对缩放与平移;
|
||||
/// - 左上角 16 个方块是二进制帧号,截图里能看出画面在推进;
|
||||
/// - 一个随时间走 Lissajous 的实心圆,用来判断两路是不是同一时刻的画面。
|
||||
fn render(camera: u32, team: u8, frame_id: u32) -> Vec<u8> {
|
||||
// 底色:竖直渐变。队伍一偏青、队伍二偏橙。
|
||||
//
|
||||
// 直接铺一块裸缓冲再交给 `from_raw`,而不是逐像素 `put_pixel`:一帧 30 万像素,
|
||||
// 每像素一次带边界检查的写入在 15fps 下是白白烧掉的 CPU。
|
||||
let tint: [i16; 3] = if team == 0 { [0, 46, 40] } else { [58, 30, 0] };
|
||||
let mut raw = vec![0u8; (WIDTH * HEIGHT * 3) as usize];
|
||||
|
||||
for y in 0..HEIGHT {
|
||||
let shade = (y as i16 * 60 / HEIGHT as i16) as i16;
|
||||
let row = &mut raw[(y * WIDTH * 3) as usize..((y + 1) * WIDTH * 3) as usize];
|
||||
|
||||
for pixel in row.chunks_exact_mut(3) {
|
||||
pixel[0] = (18 + tint[0] / 3 + shade) as u8;
|
||||
pixel[1] = (18 + tint[1] / 3 + shade) as u8;
|
||||
pixel[2] = (22 + tint[2] / 3 + shade) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
let mut img = image::RgbImage::from_raw(WIDTH, HEIGHT, raw)
|
||||
.expect("the buffer is exactly WIDTH * HEIGHT * 3 bytes");
|
||||
|
||||
let (cx, cy) = (WIDTH as i32 / 2, HEIGHT as i32 / 2);
|
||||
let t = frame_id as f32 / FPS as f32;
|
||||
|
||||
// 淡网格:每 40 px。缩放/平移时这组线是最直观的参照。
|
||||
for x in (0..WIDTH as i32).step_by(40) {
|
||||
for y in 0..HEIGHT as i32 {
|
||||
if let Some(pixel) = img.get_pixel_mut_checked(x as u32, y as u32) {
|
||||
*pixel = brighten(*pixel, 12);
|
||||
}
|
||||
}
|
||||
}
|
||||
for y in (0..HEIGHT as i32).step_by(40) {
|
||||
for x in 0..WIDTH as i32 {
|
||||
if let Some(pixel) = img.get_pixel_mut_checked(x as u32, y as u32) {
|
||||
*pixel = brighten(*pixel, 12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 中心的靶心:两个同心圆 + 四臂。瞄准器偏移校准时对着它看。
|
||||
ring(&mut img, cx, cy, 60, image::Rgb([200, 200, 210]));
|
||||
ring(&mut img, cx, cy, 28, image::Rgb([150, 150, 160]));
|
||||
arm(&mut img, cx - 90, cy, cx - 40, cy, image::Rgb([120, 120, 130]));
|
||||
arm(&mut img, cx + 40, cy, cx + 90, cy, image::Rgb([120, 120, 130]));
|
||||
arm(&mut img, cx, cy - 90, cx, cy - 40, image::Rgb([120, 120, 130]));
|
||||
arm(&mut img, cx, cy + 40, cx, cy + 90, image::Rgb([120, 120, 130]));
|
||||
|
||||
// 二进制帧号:16 个方块,最左边是低位。
|
||||
for bit in 0..16i32 {
|
||||
let on = (frame_id >> bit) & 1 == 1;
|
||||
let x = 12 + bit * 14;
|
||||
let color = if on {
|
||||
image::Rgb([120, 255, 160])
|
||||
} else {
|
||||
image::Rgb([52, 52, 60])
|
||||
};
|
||||
|
||||
fill_rect(&mut img, x, 12, 10, 10, color);
|
||||
}
|
||||
|
||||
// Lissajous 圆点:两路同一个 t 下位置不同,用来分辨"这两格是不是同一块板子"。
|
||||
let phase = if team == 0 { 0.0 } else { 1.7 };
|
||||
let dot_x = cx + (250.0 * (t * 0.9 + phase).sin()) as i32;
|
||||
let dot_y = cy + (150.0 * (t * 1.3 + phase).cos()) as i32;
|
||||
disc(&mut img, dot_x, dot_y, 26, image::Rgb([255, 210, 90]));
|
||||
|
||||
// 底部随时间右移的指示条:截图里能判断这一帧是不是较新的。
|
||||
let bar = ((t * 60.0) as i32).rem_euclid(WIDTH as i32 - 80);
|
||||
fill_rect(&mut img, bar, HEIGHT as i32 - 24, 60, 8, image::Rgb([90, 200, 255]));
|
||||
|
||||
// 摄像头 id 的最低位用一个角标表示,进一步区分两路。
|
||||
let corner = if camera == DEMO_CAMERA_A { 0 } else { 1 };
|
||||
fill_rect(
|
||||
&mut img,
|
||||
WIDTH as i32 - 40 - corner * 24,
|
||||
12,
|
||||
18,
|
||||
18,
|
||||
if corner == 0 {
|
||||
image::Rgb([120, 255, 160])
|
||||
} else {
|
||||
image::Rgb([255, 140, 120])
|
||||
},
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 78);
|
||||
encoder
|
||||
.encode_image(&img)
|
||||
.expect("a synthetic RGB image always encodes");
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn brighten(pixel: image::Rgb<u8>, by: u8) -> image::Rgb<u8> {
|
||||
image::Rgb([
|
||||
pixel[0].saturating_add(by),
|
||||
pixel[1].saturating_add(by),
|
||||
pixel[2].saturating_add(by),
|
||||
])
|
||||
}
|
||||
|
||||
fn put(img: &mut image::RgbImage, x: i32, y: i32, color: image::Rgb<u8>) {
|
||||
if x < 0 || y < 0 || x >= WIDTH as i32 || y >= HEIGHT as i32 {
|
||||
return;
|
||||
}
|
||||
|
||||
img.put_pixel(x as u32, y as u32, color);
|
||||
}
|
||||
|
||||
fn fill_rect(img: &mut image::RgbImage, x: i32, y: i32, w: i32, h: i32, color: image::Rgb<u8>) {
|
||||
for dy in 0..h {
|
||||
for dx in 0..w {
|
||||
put(img, x + dx, y + dy, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn disc(img: &mut image::RgbImage, cx: i32, cy: i32, radius: i32, color: image::Rgb<u8>) {
|
||||
for dy in -radius..=radius {
|
||||
for dx in -radius..=radius {
|
||||
if dx * dx + dy * dy <= radius * radius {
|
||||
put(img, cx + dx, cy + dy, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 细圆环(只画一圈,不填充)。
|
||||
fn ring(img: &mut image::RgbImage, cx: i32, cy: i32, radius: i32, color: image::Rgb<u8>) {
|
||||
let steps = 720;
|
||||
for step in 0..steps {
|
||||
let angle = step as f32 * std::f32::consts::TAU / steps as f32;
|
||||
put(
|
||||
img,
|
||||
cx + (radius as f32 * angle.cos()) as i32,
|
||||
cy + (radius as f32 * angle.sin()) as i32,
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 一像素宽的线段(只用来画靶心的四臂,够用就行)。
|
||||
fn arm(img: &mut image::RgbImage, x0: i32, y0: i32, x1: i32, y1: i32, color: image::Rgb<u8>) {
|
||||
let steps = (x1 - x0).abs().max((y1 - y0).abs()).max(1);
|
||||
|
||||
for step in 0..=steps {
|
||||
let f = step as f32 / steps as f32;
|
||||
put(
|
||||
img,
|
||||
x0 + ((x1 - x0) as f32 * f) as i32,
|
||||
y0 + ((y1 - y0) as f32 * f) as i32,
|
||||
color,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_synthetic_frame_is_a_decodable_jpeg_of_the_right_size() {
|
||||
let jpeg = render(DEMO_CAMERA_A, 0, 42);
|
||||
|
||||
assert!(jpeg.starts_with(&[0xFF, 0xD8]), "JPEG SOI");
|
||||
assert!(jpeg.ends_with(&[0xFF, 0xD9]), "JPEG EOI");
|
||||
|
||||
let decoded = image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg)
|
||||
.expect("decodes");
|
||||
|
||||
assert_eq!((decoded.width(), decoded.height()), (WIDTH, HEIGHT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_cameras_look_different() {
|
||||
// 两路画面必须能被一眼分辨,否则"左右两格是不是同一块板子"就看不出来。
|
||||
let a = render(DEMO_CAMERA_A, 0, 10);
|
||||
let b = render(DEMO_CAMERA_B, 1, 10);
|
||||
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_frames_differ() {
|
||||
// 画面必须真的在动,否则截图看不出链路是活的。
|
||||
assert_ne!(render(DEMO_CAMERA_A, 0, 1), render(DEMO_CAMERA_A, 0, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_frame_is_big_enough_to_be_a_realistic_load() {
|
||||
// 太小说明画的内容退化了(比如全是纯色),那分片/带宽的验证就失去意义。
|
||||
let jpeg = render(DEMO_CAMERA_A, 0, 1);
|
||||
assert!(jpeg.len() > 3_000, "suspiciously small: {} bytes", jpeg.len());
|
||||
assert!(
|
||||
jpeg.len() < 200_000,
|
||||
"suspiciously large: {} bytes",
|
||||
jpeg.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user