界面改动都在浏览器预览里逐条核对过(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)通过。
377 lines
14 KiB
Rust
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()
|
|
);
|
|
}
|
|
}
|