//! 演示模式:本地合成画面与血量,喂进**真实的**接收路径。 //! //! # 为什么不是"假界面" //! //! 演示模式不伪造数据给界面看,而是合成**协议数据报**,从 //! [`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) { 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, 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) { 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 { // 底色:竖直渐变。队伍一偏青、队伍二偏橙。 // // 直接铺一块裸缓冲再交给 `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, by: u8) -> image::Rgb { 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) { 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) { 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) { 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) { 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) { 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() ); } }