fix: 修复打砖块游戏碰撞穿透bug,添加渐进提速机制

This commit is contained in:
lenn
2026-04-29 15:43:56 +08:00
parent 26533f6916
commit 326f07ed4f
23 changed files with 786 additions and 376 deletions

48
src-tauri/Cargo.lock generated
View File

@@ -18,6 +18,7 @@ dependencies = [
"futures-util",
"humantime",
"log",
"ndarray",
"prost",
"prost-types",
"protoc-bin-vendored",
@@ -2441,6 +2442,16 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "matrixmultiply"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]]
name = "memchr"
version = "2.8.0"
@@ -2530,6 +2541,19 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "ndarray"
version = "0.15.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb12d4e967ec485a5f71c6311fe28158e9d6f4bc4a447b474184d0f91a8fa32"
dependencies = [
"matrixmultiply",
"num-complex",
"num-integer",
"num-traits",
"rawpointer",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2595,12 +2619,30 @@ version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -3605,6 +3647,12 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
[[package]]
name = "rawpointer"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
[[package]]
name = "redox_syscall"
version = "0.5.18"

View File

@@ -17,6 +17,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[features]
default = []
devkit = ["dep:tonic", "dep:prost", "dep:prost-types", "dep:async-stream", "dep:dirs"]
multi-dim = ["dep:ndarray"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
@@ -51,6 +52,7 @@ futures-util = "0.3"
uuid = { version = "1", features = ["v4", "serde"] }
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
ndarray = { version = "0.15", optional = true }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"

View File

@@ -2,17 +2,14 @@ syntax = "proto3";
package sensor_stream;
// 传感器数据推送服务 —— Rust 端作为 gRPC client 推送实时帧
service SensorPush {
rpc Upload (stream SensorFrame) returns (UploadResponse);
rpc Upload(stream SensorFrame) returns (stream PztAngleResponse);
}
// 导出后处理服务 —— Rust 导出 CSV 后将路径发给 Python 做梯度过滤
service ExportProcessor {
rpc ProcessFile (ProcessRequest) returns (ProcessResponse);
rpc ProcessFile(ProcessRequest) returns (ProcessResponse);
}
// 一帧传感器数据
message SensorFrame {
uint64 seq = 1;
uint64 timestamp_ms = 2;
@@ -23,27 +20,27 @@ message SensorFrame {
uint32 dts_ms = 7;
}
// 上传确认响应
message UploadResponse {
bool ok = 1;
uint64 frames_received = 2;
string message = 3;
message PztAngleResponse {
uint64 seq = 1;
uint64 timestamp_ms = 2;
float angle = 3;
uint32 dts_ms = 4;
bool ok = 5;
string message = 6;
}
// 导出处理请求
message ProcessRequest {
string csv_path = 1; // 导出的 CSV 文件路径
bool save_as_xlsx = 2; // 是否以 xlsx 保存(删除源 CSV
string csv_path = 1;
bool save_as_xlsx = 2;
}
// 导出处理响应
message ProcessResponse {
bool ok = 1;
string output_path = 2; // 输出文件路径
uint32 groups_used = 3; // 分组数
double mean_value = 4; // 均值
double threshold = 5; // 梯度阈值
uint32 rows_total = 6; // 原始行数
uint32 rows_kept = 7; // 保留行数
string output_path = 2;
uint32 groups_used = 3;
double mean_value = 4;
double threshold = 5;
uint32 rows_total = 6;
uint32 rows_kept = 7;
string message = 8;
}
}

View File

@@ -3,6 +3,8 @@
//! 仅在 `devkit` feature 启用时编译。
use tauri::State;
#[cfg(feature = "devkit")]
use tauri::AppHandle;
use crate::devkit::{DevKitConfig, DevKitState, DevKitStatusSnapshot, ExportProcessResult};
@@ -12,9 +14,13 @@ pub fn devkit_status(state: State<'_, DevKitState>) -> DevKitStatusSnapshot {
}
#[tauri::command]
pub async fn devkit_start(state: State<'_, DevKitState>, port: Option<u16>) -> Result<DevKitStatusSnapshot, String> {
pub async fn devkit_start(
app: AppHandle,
state: State<'_, DevKitState>,
port: Option<u16>,
) -> Result<DevKitStatusSnapshot, String> {
let target_port = port.unwrap_or(50051);
state.start(target_port).await?;
state.start(app, target_port).await?;
Ok(state.status())
}
@@ -44,4 +50,4 @@ pub async fn devkit_process_export(
let config = state.get_config();
let use_xlsx = save_as_xlsx.unwrap_or(config.save_as_xlsx);
state.process_export(&csv_path, use_xlsx).await
}
}

View File

@@ -8,14 +8,24 @@ use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter};
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use serde::{Deserialize, Serialize};
use super::proto::sensor_push_client::SensorPushClient;
use super::proto::export_processor_client::ExportProcessorClient;
use super::proto::{ProcessRequest, SensorFrame};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct DevKitPztAngleEvent {
seq: u64,
timestamp_ms: u64,
dts_ms: u32,
angle: f32,
}
// ── DevKit 配置 ────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -145,7 +155,7 @@ impl DevKitState {
}
/// 启动 gRPC client连接到 Python server 并开始推送数据
pub async fn start(&self, port: u16) -> Result<(), String> {
pub async fn start(&self, app: AppHandle, port: u16) -> Result<(), String> {
if self.running.load(Ordering::SeqCst) {
return Err("AlreadyRunning".into());
}
@@ -161,9 +171,10 @@ impl DevKitState {
let running = Arc::clone(&self.running);
let frame_count = Arc::clone(&self.frame_count);
let app_handle = app.clone();
let handle = tokio::spawn(async move {
if let Err(e) = run_grpc_upload(addr, rx, frame_count).await {
if let Err(e) = run_grpc_upload(app_handle, addr, rx, frame_count).await {
::log::error!("DevKit gRPC upload error: {e:?}");
}
running.store(false, Ordering::SeqCst);
@@ -241,6 +252,7 @@ impl DevKitState {
// ── gRPC Upload Client ─────────────────────────────────────────────
async fn run_grpc_upload(
app: AppHandle,
addr: String,
mut rx: mpsc::Receiver<SensorFrame>,
frame_count: Arc<AtomicU32>,
@@ -255,14 +267,29 @@ async fn run_grpc_upload(
};
let response = client.upload(stream).await?;
let resp = response.into_inner();
let mut inbound = response.into_inner();
::log::info!(
"DevKit upload complete: ok={}, frames={}, msg={}",
resp.ok,
resp.frames_received,
resp.message
);
while let Some(message) = inbound.message().await? {
if message.ok {
let payload = DevKitPztAngleEvent {
seq: message.seq,
timestamp_ms: message.timestamp_ms,
dts_ms: message.dts_ms,
angle: message.angle,
};
::log::debug!(
"python pzt angle: seq={} dts_ms={} angle={:.2}",
message.seq,
message.dts_ms,
message.angle
);
app.emit("devkit_pzt_angle", payload)?;
} else {
::log::warn!("DevKit PZT response error: {}", message.message);
}
}
::log::info!("DevKit upload stream closed");
Ok(())
}
}

View File

@@ -22,11 +22,43 @@ fn start_server_exe(exe_path: &std::path::Path) {
}
match command.spawn() {
Ok(_) => ::log::info!("DevKit Python server started: {}", exe_path.display()),
Ok(_) => ::log::info!("DevKit Python server launched: {}", exe_path.display()),
Err(error) => ::log::warn!("Failed to start DevKit Python server: {error}"),
}
}
#[cfg(feature = "devkit")]
fn is_local_port_open(port: u16) -> bool {
use std::net::{SocketAddr, TcpStream};
use std::time::Duration;
let addr = SocketAddr::from(([127, 0, 0, 1], port));
TcpStream::connect_timeout(&addr, Duration::from_millis(250)).is_ok()
}
#[cfg(feature = "devkit")]
fn find_server_exe(
resource_dir: &std::path::Path,
exe_name: &str,
) -> Option<std::path::PathBuf> {
let mut candidates = Vec::new();
candidates.push(resource_dir.join(exe_name));
if let Ok(current_exe) = std::env::current_exe() {
if let Some(parent) = current_exe.parent() {
candidates.push(parent.join(exe_name));
}
}
if let Ok(current_dir) = std::env::current_dir() {
candidates.push(current_dir.join("src-tauri").join("resources").join(exe_name));
candidates.push(current_dir.join("devkit").join("dist").join(exe_name));
candidates.push(current_dir.join("resources").join(exe_name));
}
candidates.into_iter().find(|path| path.exists())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri::Builder::default()
@@ -56,35 +88,35 @@ pub fn run() {
.path()
.resource_dir()
.unwrap_or_else(|_| std::path::PathBuf::from("./resources"));
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let devkit_port = 50051u16;
#[cfg(target_os = "windows")]
let exe_name = "je-skin-devkit-server.exe";
#[cfg(not(target_os = "windows"))]
let exe_name = "je-skin-devkit-server";
let bundled_exe = resource_dir.join(exe_name);
let fallback_exe = std::env::current_exe()
.ok()
.and_then(|path| path.parent().map(|parent| parent.join(exe_name)));
let server_exe = if bundled_exe.exists() {
Some(bundled_exe)
if is_local_port_open(devkit_port) {
::log::info!(
"DevKit port {} already in use, skipping Python server auto-start",
devkit_port
);
} else {
fallback_exe.filter(|path| path.exists())
};
let server_exe = find_server_exe(&resource_dir, exe_name);
if let Some(exe_path) = server_exe {
start_server_exe(&exe_path);
tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
} else {
::log::info!("DevKit Python server not found, skipping auto-start");
if let Some(exe_path) = server_exe {
start_server_exe(&exe_path);
tokio::time::sleep(std::time::Duration::from_millis(1200)).await;
} else {
::log::info!("DevKit Python server not found, skipping auto-start");
}
}
if let Err(error) = devkit_state_clone.start(50051).await {
if let Err(error) = devkit_state_clone.start(app_handle, devkit_port).await {
::log::warn!("DevKit auto-start failed: {error}");
} else {
::log::info!("DevKit auto-started on 127.0.0.1:50051");
::log::info!("DevKit gRPC client initialized for 127.0.0.1:{devkit_port}");
}
});

View File

@@ -11,6 +11,8 @@ pub mod model;
pub mod serial;
pub mod record;
pub mod utils;
#[cfg(feature = "multi-dim")]
pub mod multi_dim_force;
pub type TestRecording = Recording<TestFrame>;
pub type TactileARecording = Recording<TactileAFrame>;

View File

@@ -0,0 +1,122 @@
use ndarray::Array2;
const TOTAL_PRESSURE_LOW_THRESHOLD: usize = 500;
const COP_STABILITY_FRAMES_REQUIRED: usize = 5;
const SENSOR_ROWS: usize = 12;
const SENSOR_COLS: usize = 7;
pub struct PztProcessor {
first_frame: Option<Vec<f32>>,
first_contact_cop_x: Option<f32>,
first_contact_cop_y: Option<f32>,
contact_initialized: bool,
total_pressure_low_counter: usize,
}
impl PztProcessor {
pub fn new() -> Self {
Self {
first_frame: None,
first_contact_cop_x: None,
first_contact_cop_y: None,
contact_initialized: false,
total_pressure_low_counter: 0,
}
}
fn subtract_baseline(&mut self, current_frame: &[f32]) -> Vec<f32> {
if self.first_frame.is_none() {
self.first_frame = Some(current_frame.to_vec());
}
let baseline = self.first_frame.as_ref().unwrap();
current_frame
.iter()
.zip(baseline.iter())
.map(|(c, b)| (c - b).max(0.0))
.collect()
}
fn reset_cop_state(&mut self) {
self.first_contact_cop_x = None;
self.first_contact_cop_y = None;
self.contact_initialized = false;
self.total_pressure_low_counter = 0;
}
fn compute_pressure_direction(&mut self, frame: &[f32]) -> (f32, f32) {
let frame2d = Array2::from_shape_vec((SENSOR_ROWS, SENSOR_COLS), frame.to_vec()).unwrap();
let total_pressure: f32 = frame2d.sum();
if total_pressure < TOTAL_PRESSURE_LOW_THRESHOLD as f32 {
self.total_pressure_low_counter += 1;
} else {
self.total_pressure_low_counter = 0;
}
if self.total_pressure_low_counter >= COP_STABILITY_FRAMES_REQUIRED {
self.reset_cop_state();
return (0.0, 0.0);
}
if total_pressure == 0.0 {
return (0.0, 0.0);
}
let mut sum_x = 0.0;
let mut sum_y = 0.0;
for r in 0..SENSOR_ROWS {
for c in 0..SENSOR_COLS {
let val = frame2d[(r, c)];
sum_x += val * c as f32;
sum_y += val * r as f32;
}
}
let cop_x = sum_x / total_pressure;
let cop_y = sum_y / total_pressure;
if !self.contact_initialized {
self.first_contact_cop_x = Some(cop_x);
self.first_contact_cop_y = Some(cop_y);
self.contact_initialized = true;
return (0.0, 0.0);
}
let dx = cop_x - self.first_contact_cop_x.unwrap();
let dy = cop_y - self.first_contact_cop_y.unwrap();
(dx, dy)
}
fn compute_vector_angle(x: f32, y: f32) -> (f32, f32) {
let epsilon = 1e-8;
let mag = (x * x + y * y).sqrt();
let mut angle = (y).atan2(x + epsilon).to_degrees();
if angle < 0.0 {
angle += 360.0;
}
(angle, mag)
}
fn compute_pzt_angle(px: f32, py: f32) -> (f32, f32) {
Self::compute_vector_angle(px, -py)
}
pub fn get_pzt_angle(&mut self, adc_data: &[f32]) -> Result<f32, &'static str> {
if adc_data.len() != 84 {
return Err("ADC data length must be 84");
}
let baseline = self.subtract_baseline(adc_data);
let (dx, dy) = self.compute_pressure_direction(&baseline);
let (angle, _) = Self::compute_pzt_angle(dx, dy);
Ok(angle)
}
pub fn reset_baseline(&mut self) {
self.first_frame = None;
self.reset_cop_state();
}
}

View File

@@ -2,12 +2,17 @@ use crate::serial_core::codec::Codec;
use crate::serial_core::codecs::tactile_a::TactileACodec;
use crate::serial_core::frame::{FrameHandler, TactileAFrame, TestFrame};
use crate::serial_core::model::{HudChartState, HudPacket};
#[cfg(feature = "multi-dim")]
use crate::serial_core::multi_dim_force::PztProcessor;
use crate::serial_core::record::Recording;
use crate::serial_core::record::{FrameTiming, RecordedFrame};
#[cfg(feature = "devkit")]
use crate::devkit::{proto::SensorFrame, DevKitState};
use anyhow::Result;
use log::debug;
use std::future::pending;
#[cfg(feature = "devkit")]
use std::sync::atomic::Ordering;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use tauri::{AppHandle, Emitter};
@@ -17,14 +22,19 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::time::{self, Duration, MissedTickBehavior};
use tokio_serial::SerialStream;
use tokio_util::sync::CancellationToken;
#[cfg(feature = "devkit")]
use std::sync::atomic::Ordering;
const AUTO_SUB_INTERVAL: Duration = Duration::from_nanos(16_666_667);
pub enum PollMode<F> {
Disable,
Enabled(Box<dyn PollRequester<F>>),
}
struct PendingSubFrame<F> {
frame: F,
values: Vec<i32>,
}
pub trait SerialFrame: Clone + Send + 'static {
fn dts_ms(&self) -> u64;
@@ -215,10 +225,15 @@ where
it.set_missed_tick_behavior(MissedTickBehavior::Skip);
it
});
let mut poll_sub_interval = time::interval(AUTO_SUB_INTERVAL);
poll_sub_interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
let mut chart_state = HudChartState::new();
let mut buffer = [0u8; 1024];
let mut prune_interval = time::interval(Duration::from_millis(450));
#[cfg(feature = "multi-dim")]
let mut pzt_processor = PztProcessor::new();
let mut pending_sub_frame: Option<PendingSubFrame<F>> = None;
prune_interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
loop {
@@ -246,6 +261,21 @@ where
app.emit("hud_stream", packet)?;
}
}
_ = poll_sub_interval.tick() => {
if let Some(pending) = pending_sub_frame.take() {
let display_values = build_display_values(
&mut chart_state,
pending.values.as_slice(),
);
if let Some(packet) = pending
.frame
.to_hud_packet(&mut chart_state, display_values.as_deref())
{
app.emit("hud_stream", packet)?;
}
}
}
read_result = port.read(&mut buffer) => {
let n = read_result?;
if n == 0 {
@@ -266,25 +296,38 @@ where
.await?
.map(|vals| vals.into_iter().map(Into::into).collect::<Vec<i32>>());
let mut record = recording.lock().map_err(|_| anyhow::anyhow!("recording state poisoned"))?;
record.push(RecordedFrame{
timing: FrameTiming { pts_ms: None, dts_ms: frame.dts_ms() },
let mut record = recording
.lock()
.map_err(|_| anyhow::anyhow!("recording state poisoned"))?;
record.push(RecordedFrame {
timing: FrameTiming {
pts_ms: None,
dts_ms: frame.dts_ms(),
},
frame: frame.clone(),
});
drop(record);
let display_values = if let Some(vals) = decode_res.as_ref() {
let summary = vals.iter().copied().sum::<i32>();
let force = raw_to_g1(summary as u32);
chart_state.record_summary(force as f32);
chart_state.record_pressure_matrix(vals.as_slice());
if let Some(vals) = decode_res {
#[cfg(feature = "multi-dim")]
{
let pzt_values = vals.iter().map(|value| *value as f32).collect::<Vec<f32>>();
if let Ok(angle) = pzt_processor.get_pzt_angle(&pzt_values) {
// debug!("pzt angle: {:.2}", angle);
}
}
#[cfg(feature = "devkit")]
push_devkit_frame(&app, vals.as_slice(), frame.dts_ms(), force);
Some(vec![summary])
} else {
None
};
{
let summary = vals.iter().copied().sum::<i32>();
let force = raw_to_g1(summary as u32);
push_devkit_frame(&app, vals.as_slice(), frame.dts_ms(), force);
}
if let Some(packet) = frame.to_hud_packet(&mut chart_state, display_values.as_deref()) {
pending_sub_frame = Some(PendingSubFrame {
frame: frame.clone(),
values: vals,
});
} else if let Some(packet) = frame.to_hud_packet(&mut chart_state, None) {
app.emit("hud_stream", packet)?;
}
}
@@ -294,6 +337,14 @@ where
Ok(())
}
fn build_display_values(chart_state: &mut HudChartState, values: &[i32]) -> Option<Vec<i32>> {
let summary = values.iter().copied().sum::<i32>();
let force = raw_to_g1(summary as u32);
chart_state.record_summary(force as f32);
chart_state.record_pressure_matrix(values);
Some(vec![summary])
}
#[cfg(feature = "devkit")]
fn push_devkit_frame(app: &AppHandle, values: &[i32], dts_ms: u64, resultant_force: f64) {
let devkit_state = app.state::<DevKitState>();

View File

@@ -37,7 +37,8 @@
"nsis": {
"installMode": "both",
"displayLanguageSelector": false,
"installerIcon": "icons/icon.ico"
"installerIcon": "icons/icon.ico",
"template": "nsis/installer.nsi"
}
},
"resources": [