Files
ESP32S3_vt/desktop/src-tauri/src/demo.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

377 lines
14 KiB
Rust

//! 演示模式:本地合成画面与血量,喂进**真实的**接收路径。
//!
//! # 为什么不是"假界面"
//!
//! 演示模式不伪造数据给界面看,而是合成**协议数据报**,从
//! [`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()
);
}
}