Add pressure visual modes and breakout game
This commit is contained in:
275
src/app.rs
275
src/app.rs
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::breakout::{BreakoutGame, control_from_matrix};
|
||||||
use crate::connection::ConnectionManager;
|
use crate::connection::ConnectionManager;
|
||||||
use crate::recording::Recorder;
|
use crate::recording::Recorder;
|
||||||
use crate::render::{ActiveMode, FingerMode, HandGatewayMode};
|
use crate::render::{ActiveMode, FingerMode, HandGatewayMode};
|
||||||
@@ -18,6 +19,9 @@ use eframe::{egui, egui_wgpu};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
const DATA_LOG_EVERY_FRAMES: u64 = 30;
|
const DATA_LOG_EVERY_FRAMES: u64 = 30;
|
||||||
|
const SUMMARY_POINTS_PER_SERIES: usize = 42;
|
||||||
|
const MIN_DISPLAY_FORCE_N: f32 = 0.1;
|
||||||
|
const MAX_DISPLAY_FORCE_N: f32 = 25.6;
|
||||||
|
|
||||||
pub struct EskinDesktopApp {
|
pub struct EskinDesktopApp {
|
||||||
connect_panel: FloatingPanelState,
|
connect_panel: FloatingPanelState,
|
||||||
@@ -36,6 +40,13 @@ pub struct EskinDesktopApp {
|
|||||||
matrix_config_panel: FloatingPanelState,
|
matrix_config_panel: FloatingPanelState,
|
||||||
matrix_config: MatrixConfigState,
|
matrix_config: MatrixConfigState,
|
||||||
signal_history: Vec<f32>,
|
signal_history: Vec<f32>,
|
||||||
|
latest_raw_matrix: Vec<u32>,
|
||||||
|
latest_matrix_rows: u32,
|
||||||
|
latest_matrix_cols: u32,
|
||||||
|
breakout_visible: bool,
|
||||||
|
breakout_game: BreakoutGame,
|
||||||
|
context_menu_open: bool,
|
||||||
|
context_menu_pos: egui::Pos2,
|
||||||
active_mode: ActiveMode,
|
active_mode: ActiveMode,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +103,13 @@ impl EskinDesktopApp {
|
|||||||
),
|
),
|
||||||
matrix_config: MatrixConfigState::default(),
|
matrix_config: MatrixConfigState::default(),
|
||||||
signal_history: Vec::with_capacity(128),
|
signal_history: Vec::with_capacity(128),
|
||||||
|
latest_raw_matrix: Vec::new(),
|
||||||
|
latest_matrix_rows: MATRIX_ROWS,
|
||||||
|
latest_matrix_cols: MATRIX_COLS,
|
||||||
|
breakout_visible: false,
|
||||||
|
breakout_game: BreakoutGame::default(),
|
||||||
|
context_menu_open: false,
|
||||||
|
context_menu_pos: egui::pos2(24.0, 48.0),
|
||||||
active_mode: ActiveMode::Finger(FingerMode {
|
active_mode: ActiveMode::Finger(FingerMode {
|
||||||
rows: 12,
|
rows: 12,
|
||||||
cols: 7,
|
cols: 7,
|
||||||
@@ -101,10 +119,7 @@ impl EskinDesktopApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_wgpu_background(&mut self, ui: &mut egui::Ui) {
|
fn draw_wgpu_background_rect(&self, ui: &mut egui::Ui, rect: egui::Rect) {
|
||||||
self.update_pressure_matrix();
|
|
||||||
|
|
||||||
let rect = ui.max_rect();
|
|
||||||
let width = rect.width().max(1.0);
|
let width = rect.width().max(1.0);
|
||||||
let height = rect.height().max(1.0);
|
let height = rect.height().max(1.0);
|
||||||
|
|
||||||
@@ -119,6 +134,50 @@ impl EskinDesktopApp {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_workspace(&mut self, ui: &mut egui::Ui) {
|
||||||
|
if self.breakout_visible {
|
||||||
|
self.draw_split_workspace(ui);
|
||||||
|
} else {
|
||||||
|
self.draw_wgpu_background_rect(ui, ui.max_rect());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_split_workspace(&mut self, ui: &mut egui::Ui) {
|
||||||
|
let screen = ui.max_rect();
|
||||||
|
let content = egui::Rect::from_min_max(
|
||||||
|
screen.left_top() + egui::vec2(0.0, layout::TITLE_BAR_HEIGHT),
|
||||||
|
screen.right_bottom(),
|
||||||
|
);
|
||||||
|
let gutter = 10.0;
|
||||||
|
let half_width = (content.width() - gutter).max(1.0) * 0.5;
|
||||||
|
let left = egui::Rect::from_min_size(content.min, egui::vec2(half_width, content.height()));
|
||||||
|
let right = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(left.right() + gutter, content.top()),
|
||||||
|
content.right_bottom(),
|
||||||
|
);
|
||||||
|
|
||||||
|
ui.painter()
|
||||||
|
.rect_filled(screen, egui::CornerRadius::ZERO, ONE_DARK_PRO.bg);
|
||||||
|
paint_split_viewport_shell(ui, left, "DOTMATRIX", "压力矩阵 / WGPU");
|
||||||
|
paint_split_viewport_shell(ui, right, "BREAKOUT", "压力控制打砖块");
|
||||||
|
|
||||||
|
let left_body = split_viewport_body(left);
|
||||||
|
let right_body = split_viewport_body(right);
|
||||||
|
self.draw_wgpu_background_rect(ui, left_body);
|
||||||
|
|
||||||
|
let breakout_control = control_from_matrix(
|
||||||
|
&self.latest_raw_matrix,
|
||||||
|
self.latest_matrix_rows,
|
||||||
|
self.latest_matrix_cols,
|
||||||
|
);
|
||||||
|
self.breakout_game.draw_viewport(
|
||||||
|
ui.ctx(),
|
||||||
|
right_body,
|
||||||
|
&mut self.breakout_visible,
|
||||||
|
breakout_control,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn update_pressure_matrix(&mut self) {
|
fn update_pressure_matrix(&mut self) {
|
||||||
if let Some(sample) = self.connection.take_latest_sample() {
|
if let Some(sample) = self.connection.take_latest_sample() {
|
||||||
normalize_pressure_sample(
|
normalize_pressure_sample(
|
||||||
@@ -127,14 +186,28 @@ impl EskinDesktopApp {
|
|||||||
sample.cols,
|
sample.cols,
|
||||||
&mut self.pressure_matrix,
|
&mut self.pressure_matrix,
|
||||||
);
|
);
|
||||||
|
self.latest_raw_matrix.clear();
|
||||||
|
self.latest_raw_matrix.extend_from_slice(&sample.matrix);
|
||||||
|
self.latest_matrix_rows = sample.rows;
|
||||||
|
self.latest_matrix_cols = sample.cols;
|
||||||
|
|
||||||
// Feed data to recorder
|
// Feed data to recorder
|
||||||
self.recorder.add_frame(&sample.matrix);
|
self.recorder.add_frame(&sample.matrix);
|
||||||
|
|
||||||
// Update signal history (sum of all pressure values)
|
// JE-Skin summary logic: sum all cells first, then convert raw summary to force.
|
||||||
let total: f32 = sample.matrix.iter().map(|v| *v as f32).sum();
|
let raw_total = sample
|
||||||
self.signal_history.push(total);
|
.matrix
|
||||||
if self.signal_history.len() > 128 {
|
.iter()
|
||||||
|
.fold(0_u64, |sum, value| sum + *value as u64)
|
||||||
|
.min(u32::MAX as u64) as u32;
|
||||||
|
let force = raw_to_g1(raw_total).min(MAX_DISPLAY_FORCE_N);
|
||||||
|
let force = if force <= MIN_DISPLAY_FORCE_N {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
force
|
||||||
|
};
|
||||||
|
self.signal_history.push(force);
|
||||||
|
if self.signal_history.len() > SUMMARY_POINTS_PER_SERIES {
|
||||||
self.signal_history.remove(0);
|
self.signal_history.remove(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,12 +331,15 @@ impl EskinDesktopApp {
|
|||||||
|
|
||||||
fn draw_floating_panels(&mut self, ctx: &egui::Context) {
|
fn draw_floating_panels(&mut self, ctx: &egui::Context) {
|
||||||
// draw_scene_panel(ctx, &mut self.scene_panel);
|
// draw_scene_panel(ctx, &mut self.scene_panel);
|
||||||
if let Some(next_mode) =
|
if let Some(next_mode) = draw_config_panel(
|
||||||
draw_config_panel(ctx, &mut self.config_panel, &mut self.config_state)
|
ctx,
|
||||||
{
|
&mut self.config_panel,
|
||||||
|
&mut self.config_state,
|
||||||
|
&self.connection,
|
||||||
|
) {
|
||||||
self.switch_mode(next_mode);
|
self.switch_mode(next_mode);
|
||||||
}
|
}
|
||||||
draw_stats_panel(ctx, &mut self.stats_panel);
|
draw_stats_panel(ctx, &mut self.stats_panel, &self.signal_history);
|
||||||
draw_export_panel(
|
draw_export_panel(
|
||||||
ctx,
|
ctx,
|
||||||
&mut self.export_panel,
|
&mut self.export_panel,
|
||||||
@@ -273,32 +349,68 @@ impl EskinDesktopApp {
|
|||||||
draw_matrix_config_panel(ctx, &mut self.matrix_config_panel, &mut self.matrix_config);
|
draw_matrix_config_panel(ctx, &mut self.matrix_config_panel, &mut self.matrix_config);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_panel_context_menu(&mut self, ui: &mut egui::Ui) {
|
fn draw_panel_context_menu(&mut self, ctx: &egui::Context) {
|
||||||
let response = ui.interact(
|
let (secondary_clicked, pointer_pos, escape_pressed) = ctx.input(|input| {
|
||||||
ui.max_rect(),
|
(
|
||||||
egui::Id::new("workspace_panel_context_menu"),
|
input.pointer.secondary_clicked(),
|
||||||
egui::Sense::click(),
|
input.pointer.interact_pos(),
|
||||||
);
|
input.key_pressed(egui::Key::Escape),
|
||||||
|
)
|
||||||
response.context_menu(|ui| {
|
|
||||||
ui.label("显示面板");
|
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
panel_restore_item(ui, "配置", &mut self.config_panel);
|
|
||||||
panel_restore_item(ui, "录制", &mut self.export_panel);
|
|
||||||
panel_restore_item(ui, "矩阵", &mut self.matrix_config_panel);
|
|
||||||
panel_restore_item(ui, "统计", &mut self.stats_panel);
|
|
||||||
|
|
||||||
ui.separator();
|
|
||||||
|
|
||||||
if ui.button("全部显示").clicked() {
|
|
||||||
self.config_panel.visible = true;
|
|
||||||
self.export_panel.visible = true;
|
|
||||||
self.matrix_config_panel.visible = true;
|
|
||||||
self.stats_panel.visible = true;
|
|
||||||
ui.close();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if secondary_clicked {
|
||||||
|
self.context_menu_open = true;
|
||||||
|
self.context_menu_pos = pointer_pos.unwrap_or(self.context_menu_pos);
|
||||||
|
}
|
||||||
|
if escape_pressed {
|
||||||
|
self.context_menu_open = false;
|
||||||
|
}
|
||||||
|
if !self.context_menu_open {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut close_menu = false;
|
||||||
|
let response = egui::Area::new(egui::Id::new("workspace_context_menu_area"))
|
||||||
|
.fixed_pos(self.context_menu_pos)
|
||||||
|
.order(egui::Order::Foreground)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||||
|
ui.set_min_width(132.0);
|
||||||
|
ui.label("显示面板");
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
panel_restore_item(ui, "配置", &mut self.config_panel);
|
||||||
|
panel_restore_item(ui, "录制", &mut self.export_panel);
|
||||||
|
panel_restore_item(ui, "矩阵", &mut self.matrix_config_panel);
|
||||||
|
panel_restore_item(ui, "统计", &mut self.stats_panel);
|
||||||
|
if ui.button("打砖块").clicked() {
|
||||||
|
self.breakout_visible = true;
|
||||||
|
close_menu = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
if ui.button("全部显示").clicked() {
|
||||||
|
self.config_panel.visible = true;
|
||||||
|
self.export_panel.visible = true;
|
||||||
|
self.matrix_config_panel.visible = true;
|
||||||
|
self.stats_panel.visible = true;
|
||||||
|
self.breakout_visible = true;
|
||||||
|
close_menu = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let clicked_outside = ctx.input(|input| {
|
||||||
|
input.pointer.primary_clicked()
|
||||||
|
&& input
|
||||||
|
.pointer
|
||||||
|
.interact_pos()
|
||||||
|
.is_some_and(|pos| !response.response.rect.contains(pos))
|
||||||
|
});
|
||||||
|
if close_menu || clicked_outside {
|
||||||
|
self.context_menu_open = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn switch_mode(&mut self, next: SerialMode) {
|
fn switch_mode(&mut self, next: SerialMode) {
|
||||||
@@ -337,6 +449,90 @@ fn log_pressure_sample(raw: &[u32], rows: u32, cols: u32) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn raw_to_g1(raw: u32) -> f32 {
|
||||||
|
const RAW: [u32; 12] = [
|
||||||
|
0, 21382, 108507, 123183, 147405, 171105, 192395, 250443, 231423, 350560, 396616, 429444,
|
||||||
|
];
|
||||||
|
const FORCE_CENTI_N: [f32; 12] = [
|
||||||
|
0.0, 57.0, 257.0, 357.0, 457.0, 557.0, 657.0, 857.0, 1057.0, 1557.0, 2057.0, 2557.0,
|
||||||
|
];
|
||||||
|
|
||||||
|
if raw <= RAW[0] {
|
||||||
|
return FORCE_CENTI_N[0] / 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if raw >= RAW[RAW.len() - 1] {
|
||||||
|
return FORCE_CENTI_N[FORCE_CENTI_N.len() - 1] / 100.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the same lookup behavior as JE-Skin so both apps report the same force.
|
||||||
|
let mut left = 0usize;
|
||||||
|
let mut right = RAW.len() - 1;
|
||||||
|
while left + 1 < right {
|
||||||
|
let mid = (left + right) / 2;
|
||||||
|
if RAW[mid] <= raw {
|
||||||
|
left = mid;
|
||||||
|
} else {
|
||||||
|
right = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw_left = RAW[left] as f32;
|
||||||
|
let raw_right = RAW[right] as f32;
|
||||||
|
let span = (raw_right - raw_left).max(1.0);
|
||||||
|
let ratio = (raw as f32 - raw_left) / span;
|
||||||
|
|
||||||
|
(FORCE_CENTI_N[left] + ratio * (FORCE_CENTI_N[right] - FORCE_CENTI_N[left])) / 100.0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_split_viewport_shell(
|
||||||
|
ui: &egui::Ui,
|
||||||
|
rect: egui::Rect,
|
||||||
|
title: &'static str,
|
||||||
|
subtitle: &'static str,
|
||||||
|
) {
|
||||||
|
let painter = ui.painter();
|
||||||
|
painter.rect_filled(
|
||||||
|
rect,
|
||||||
|
egui::CornerRadius::same(8),
|
||||||
|
egui::Color32::from_rgb(11, 17, 23),
|
||||||
|
);
|
||||||
|
painter.rect_stroke(
|
||||||
|
rect,
|
||||||
|
egui::CornerRadius::same(8),
|
||||||
|
egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft),
|
||||||
|
egui::StrokeKind::Outside,
|
||||||
|
);
|
||||||
|
painter.line_segment(
|
||||||
|
[
|
||||||
|
rect.left_top() + egui::vec2(12.0, 34.0),
|
||||||
|
rect.right_top() + egui::vec2(-12.0, 34.0),
|
||||||
|
],
|
||||||
|
egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft),
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
rect.left_top() + egui::vec2(14.0, 16.0),
|
||||||
|
egui::Align2::LEFT_CENTER,
|
||||||
|
title,
|
||||||
|
egui::FontId::monospace(13.0),
|
||||||
|
ONE_DARK_PRO.accent,
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
rect.left_top() + egui::vec2(122.0, 16.0),
|
||||||
|
egui::Align2::LEFT_CENTER,
|
||||||
|
subtitle,
|
||||||
|
egui::FontId::proportional(12.0),
|
||||||
|
ONE_DARK_PRO.text_dim,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_viewport_body(rect: egui::Rect) -> egui::Rect {
|
||||||
|
egui::Rect::from_min_max(
|
||||||
|
rect.left_top() + egui::vec2(10.0, 44.0),
|
||||||
|
rect.right_bottom() - egui::vec2(10.0, 10.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut PressureFrame) {
|
fn normalize_pressure_sample(raw: &[u32], rows: u32, cols: u32, normalized: &mut PressureFrame) {
|
||||||
const RANGE_MIN: f32 = 0.0;
|
const RANGE_MIN: f32 = 0.0;
|
||||||
const RANGE_MAX: f32 = 7000.0;
|
const RANGE_MAX: f32 = 7000.0;
|
||||||
@@ -369,10 +565,11 @@ impl eframe::App for EskinDesktopApp {
|
|||||||
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
|
|
||||||
self.draw_wgpu_background(ui);
|
self.update_pressure_matrix();
|
||||||
self.draw_panel_context_menu(ui);
|
self.draw_workspace(ui);
|
||||||
self.draw_title_bar(ui, frame);
|
self.draw_title_bar(ui, frame);
|
||||||
self.draw_floating_panels(&ctx);
|
self.draw_floating_panels(&ctx);
|
||||||
|
self.draw_panel_context_menu(&ctx);
|
||||||
|
|
||||||
// Keep repainting while the wgpu background is a realtime viewport.
|
// Keep repainting while the wgpu background is a realtime viewport.
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
|
|||||||
643
src/breakout.rs
Normal file
643
src/breakout.rs
Normal file
@@ -0,0 +1,643 @@
|
|||||||
|
use eframe::egui;
|
||||||
|
|
||||||
|
use crate::style::{self, ACCENT_BLUE, ACCENT_GREEN, ACCENT_ORANGE, ACCENT_RED, ONE_DARK_PRO};
|
||||||
|
|
||||||
|
const BRICK_ROWS: usize = 7;
|
||||||
|
const BRICK_COLS: usize = 12;
|
||||||
|
const PADDLE_W: f32 = 0.24;
|
||||||
|
const PADDLE_H: f32 = 0.030;
|
||||||
|
const PADDLE_Y: f32 = 0.88;
|
||||||
|
const PADDLE_SPEED: f32 = 0.92;
|
||||||
|
const BALL_RADIUS: f32 = 0.010;
|
||||||
|
const BALL_SPEED: f32 = 0.52;
|
||||||
|
const MAX_BALL_SPEED: f32 = 0.86;
|
||||||
|
const PRESSURE_RANGE_MIN: f32 = 0.0;
|
||||||
|
const PRESSURE_RANGE_MAX: f32 = 7000.0;
|
||||||
|
const PAUSE_COOLDOWN: f64 = 0.64;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum BreakoutPhase {
|
||||||
|
Idle,
|
||||||
|
Running,
|
||||||
|
Paused,
|
||||||
|
Over,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Brick {
|
||||||
|
rect: egui::Rect,
|
||||||
|
alive: bool,
|
||||||
|
flash: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone, Copy)]
|
||||||
|
pub struct BreakoutControl {
|
||||||
|
pub axis: f32,
|
||||||
|
pub top_force: f32,
|
||||||
|
pub left_force: f32,
|
||||||
|
pub right_force: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BreakoutGame {
|
||||||
|
phase: BreakoutPhase,
|
||||||
|
bricks: Vec<Brick>,
|
||||||
|
paddle_x: f32,
|
||||||
|
ball_pos: egui::Pos2,
|
||||||
|
ball_vel: egui::Vec2,
|
||||||
|
score: u32,
|
||||||
|
combo: u32,
|
||||||
|
lives: u32,
|
||||||
|
level: u32,
|
||||||
|
last_time: Option<f64>,
|
||||||
|
previous_pause_gesture: bool,
|
||||||
|
pause_locked_until: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BreakoutGame {
|
||||||
|
fn default() -> Self {
|
||||||
|
let mut game = Self {
|
||||||
|
phase: BreakoutPhase::Idle,
|
||||||
|
bricks: Vec::new(),
|
||||||
|
paddle_x: 0.5,
|
||||||
|
ball_pos: egui::pos2(0.5, PADDLE_Y - PADDLE_H - BALL_RADIUS),
|
||||||
|
ball_vel: egui::vec2(0.0, 0.0),
|
||||||
|
score: 0,
|
||||||
|
combo: 0,
|
||||||
|
lives: 3,
|
||||||
|
level: 1,
|
||||||
|
last_time: None,
|
||||||
|
previous_pause_gesture: false,
|
||||||
|
pause_locked_until: 0.0,
|
||||||
|
};
|
||||||
|
game.rebuild_bricks();
|
||||||
|
game
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BreakoutGame {
|
||||||
|
pub fn draw_viewport(
|
||||||
|
&mut self,
|
||||||
|
ctx: &egui::Context,
|
||||||
|
rect: egui::Rect,
|
||||||
|
visible: &mut bool,
|
||||||
|
control: BreakoutControl,
|
||||||
|
) {
|
||||||
|
if !*visible {
|
||||||
|
self.last_time = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.request_repaint();
|
||||||
|
let now = ctx.input(|input| input.time);
|
||||||
|
let dt = self
|
||||||
|
.last_time
|
||||||
|
.map(|last| (now - last) as f32)
|
||||||
|
.unwrap_or(1.0 / 60.0)
|
||||||
|
.clamp(0.0, 1.0 / 30.0);
|
||||||
|
self.last_time = Some(now);
|
||||||
|
|
||||||
|
let mut close_requested = false;
|
||||||
|
egui::Area::new(egui::Id::new("breakout_game_viewport"))
|
||||||
|
.fixed_pos(rect.min)
|
||||||
|
.order(egui::Order::Middle)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.set_min_size(rect.size());
|
||||||
|
ui.set_max_size(rect.size());
|
||||||
|
style::group_frame().show(ui, |ui| {
|
||||||
|
ui.set_min_size(rect.size() - egui::vec2(20.0, 16.0));
|
||||||
|
if self.draw_contents(ui, control, dt, now, true) {
|
||||||
|
close_requested = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if close_requested {
|
||||||
|
*visible = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_contents(
|
||||||
|
&mut self,
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
control: BreakoutControl,
|
||||||
|
dt: f32,
|
||||||
|
now: f64,
|
||||||
|
embedded: bool,
|
||||||
|
) -> bool {
|
||||||
|
let mut close_requested = false;
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.colored_label(
|
||||||
|
ACCENT_BLUE,
|
||||||
|
egui::RichText::new("NEON BREAKOUT").monospace().size(13.0),
|
||||||
|
);
|
||||||
|
ui.label(style::subtle_text("压力矩阵控制 / 左右分区驱动"));
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if embedded && ui.add(style::tag_button("退出分屏")).clicked() {
|
||||||
|
close_requested = true;
|
||||||
|
}
|
||||||
|
if ui.add(style::tag_button("重开")).clicked() {
|
||||||
|
self.reset();
|
||||||
|
self.start();
|
||||||
|
}
|
||||||
|
if ui.add(style::tag_button("暂停")).clicked() {
|
||||||
|
self.toggle_pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(5.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
status_chip(ui, "状态", self.status_text(), status_color(self.phase));
|
||||||
|
status_chip(ui, "分数", self.score.to_string(), ACCENT_GREEN);
|
||||||
|
status_chip(ui, "连击", self.combo.to_string(), ACCENT_ORANGE);
|
||||||
|
status_chip(ui, "生命", self.lives.to_string(), ACCENT_RED);
|
||||||
|
status_chip(ui, "关卡", self.level.to_string(), ACCENT_BLUE);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(7.0);
|
||||||
|
let available = ui.available_size();
|
||||||
|
let game_size = egui::vec2(available.x.max(500.0), (available.y - 6.0).max(330.0));
|
||||||
|
let (rect, response) = ui.allocate_exact_size(game_size, egui::Sense::click());
|
||||||
|
if response.clicked() && self.phase != BreakoutPhase::Running {
|
||||||
|
self.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
let keyboard_axis = ui.input(|input| {
|
||||||
|
(input.key_down(egui::Key::ArrowRight) as i32
|
||||||
|
- input.key_down(egui::Key::ArrowLeft) as i32) as f32
|
||||||
|
});
|
||||||
|
if ui.input(|input| input.key_pressed(egui::Key::Space)) {
|
||||||
|
self.start();
|
||||||
|
}
|
||||||
|
if ui.input(|input| input.key_pressed(egui::Key::P)) {
|
||||||
|
self.toggle_pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
let axis = if keyboard_axis.abs() > 0.0 {
|
||||||
|
keyboard_axis
|
||||||
|
} else {
|
||||||
|
control.axis
|
||||||
|
};
|
||||||
|
self.handle_pressure_gesture(control.top_force, now);
|
||||||
|
self.update(dt, axis);
|
||||||
|
self.paint(ui, rect, control);
|
||||||
|
close_requested
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset(&mut self) {
|
||||||
|
self.phase = BreakoutPhase::Idle;
|
||||||
|
self.paddle_x = 0.5;
|
||||||
|
self.ball_pos = egui::pos2(0.5, PADDLE_Y - PADDLE_H - BALL_RADIUS);
|
||||||
|
self.ball_vel = egui::Vec2::ZERO;
|
||||||
|
self.score = 0;
|
||||||
|
self.combo = 0;
|
||||||
|
self.lives = 3;
|
||||||
|
self.level = 1;
|
||||||
|
self.rebuild_bricks();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start(&mut self) {
|
||||||
|
if self.phase == BreakoutPhase::Over {
|
||||||
|
self.reset();
|
||||||
|
}
|
||||||
|
if self.phase != BreakoutPhase::Running {
|
||||||
|
self.phase = BreakoutPhase::Running;
|
||||||
|
if self.ball_vel == egui::Vec2::ZERO {
|
||||||
|
self.launch_ball();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_pause(&mut self) {
|
||||||
|
self.phase = match self.phase {
|
||||||
|
BreakoutPhase::Running => BreakoutPhase::Paused,
|
||||||
|
BreakoutPhase::Paused => BreakoutPhase::Running,
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_pressure_gesture(&mut self, top_force: f32, now: f64) {
|
||||||
|
let threshold = pressure_pause_threshold();
|
||||||
|
let active = top_force >= threshold;
|
||||||
|
if active && !self.previous_pause_gesture && now >= self.pause_locked_until {
|
||||||
|
if self.phase == BreakoutPhase::Idle || self.phase == BreakoutPhase::Over {
|
||||||
|
self.start();
|
||||||
|
} else {
|
||||||
|
self.toggle_pause();
|
||||||
|
}
|
||||||
|
self.pause_locked_until = now + PAUSE_COOLDOWN;
|
||||||
|
}
|
||||||
|
self.previous_pause_gesture = active;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, dt: f32, axis: f32) {
|
||||||
|
for brick in &mut self.bricks {
|
||||||
|
brick.flash = (brick.flash - dt * 2.4).max(0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.phase != BreakoutPhase::Running {
|
||||||
|
self.ball_pos = egui::pos2(self.paddle_x, PADDLE_Y - PADDLE_H - BALL_RADIUS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.paddle_x = (self.paddle_x + axis.clamp(-1.0, 1.0) * PADDLE_SPEED * dt)
|
||||||
|
.clamp(PADDLE_W * 0.5, 1.0 - PADDLE_W * 0.5);
|
||||||
|
|
||||||
|
self.ball_pos += self.ball_vel * dt;
|
||||||
|
self.resolve_wall_collision();
|
||||||
|
self.resolve_paddle_collision();
|
||||||
|
self.resolve_brick_collision();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn launch_ball(&mut self) {
|
||||||
|
let direction = if self.level % 2 == 0 { -0.24 } else { 0.24 };
|
||||||
|
self.ball_pos = egui::pos2(self.paddle_x, PADDLE_Y - PADDLE_H - BALL_RADIUS);
|
||||||
|
self.ball_vel = egui::vec2(direction, -1.0).normalized() * BALL_SPEED;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_wall_collision(&mut self) {
|
||||||
|
if self.ball_pos.x - BALL_RADIUS <= 0.0 {
|
||||||
|
self.ball_pos.x = BALL_RADIUS;
|
||||||
|
self.ball_vel.x = self.ball_vel.x.abs();
|
||||||
|
} else if self.ball_pos.x + BALL_RADIUS >= 1.0 {
|
||||||
|
self.ball_pos.x = 1.0 - BALL_RADIUS;
|
||||||
|
self.ball_vel.x = -self.ball_vel.x.abs();
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.ball_pos.y - BALL_RADIUS <= 0.0 {
|
||||||
|
self.ball_pos.y = BALL_RADIUS;
|
||||||
|
self.ball_vel.y = self.ball_vel.y.abs();
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.ball_pos.y - BALL_RADIUS > 1.0 {
|
||||||
|
self.lives = self.lives.saturating_sub(1);
|
||||||
|
self.combo = 0;
|
||||||
|
if self.lives == 0 {
|
||||||
|
self.phase = BreakoutPhase::Over;
|
||||||
|
self.ball_vel = egui::Vec2::ZERO;
|
||||||
|
} else {
|
||||||
|
self.ball_pos = egui::pos2(self.paddle_x, PADDLE_Y - PADDLE_H - BALL_RADIUS);
|
||||||
|
self.launch_ball();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_paddle_collision(&mut self) {
|
||||||
|
if self.ball_vel.y <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let paddle = paddle_rect(self.paddle_x);
|
||||||
|
if !circle_hits_rect(self.ball_pos, BALL_RADIUS, paddle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.ball_pos.y = paddle.top() - BALL_RADIUS;
|
||||||
|
let offset = ((self.ball_pos.x - self.paddle_x) / (PADDLE_W * 0.5)).clamp(-1.0, 1.0);
|
||||||
|
let speed = self.ball_vel.length().clamp(BALL_SPEED, MAX_BALL_SPEED);
|
||||||
|
self.ball_vel = egui::vec2(offset * 0.82, -1.0).normalized() * speed;
|
||||||
|
self.combo = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_brick_collision(&mut self) {
|
||||||
|
for brick in &mut self.bricks {
|
||||||
|
if !brick.alive || !circle_hits_rect(self.ball_pos, BALL_RADIUS, brick.rect) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let overlap_x = (self.ball_pos.x + BALL_RADIUS - brick.rect.left())
|
||||||
|
.min(brick.rect.right() - (self.ball_pos.x - BALL_RADIUS));
|
||||||
|
let overlap_y = (self.ball_pos.y + BALL_RADIUS - brick.rect.top())
|
||||||
|
.min(brick.rect.bottom() - (self.ball_pos.y - BALL_RADIUS));
|
||||||
|
if overlap_x < overlap_y {
|
||||||
|
self.ball_vel.x *= -1.0;
|
||||||
|
} else {
|
||||||
|
self.ball_vel.y *= -1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
brick.alive = false;
|
||||||
|
brick.flash = 1.0;
|
||||||
|
self.combo += 1;
|
||||||
|
self.score += 40 + self.combo * 5;
|
||||||
|
let speed = (self.ball_vel.length() * 1.014).min(MAX_BALL_SPEED);
|
||||||
|
self.ball_vel = self.ball_vel.normalized() * speed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.bricks.iter().all(|brick| !brick.alive) {
|
||||||
|
self.level += 1;
|
||||||
|
self.rebuild_bricks();
|
||||||
|
self.launch_ball();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_bricks(&mut self) {
|
||||||
|
self.bricks.clear();
|
||||||
|
let gap_x = 0.012;
|
||||||
|
let gap_y = 0.017;
|
||||||
|
let left = 0.075;
|
||||||
|
let top = 0.10;
|
||||||
|
let total_w = 0.85;
|
||||||
|
let brick_w = (total_w - gap_x * (BRICK_COLS - 1) as f32) / BRICK_COLS as f32;
|
||||||
|
let brick_h = 0.040;
|
||||||
|
|
||||||
|
for row in 0..BRICK_ROWS {
|
||||||
|
for col in 0..BRICK_COLS {
|
||||||
|
let x = left + col as f32 * (brick_w + gap_x);
|
||||||
|
let y = top + row as f32 * (brick_h + gap_y);
|
||||||
|
self.bricks.push(Brick {
|
||||||
|
rect: egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(brick_w, brick_h)),
|
||||||
|
alive: true,
|
||||||
|
flash: 0.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_text(&self) -> &'static str {
|
||||||
|
match self.phase {
|
||||||
|
BreakoutPhase::Idle => "待机",
|
||||||
|
BreakoutPhase::Running => "运行",
|
||||||
|
BreakoutPhase::Paused => "暂停",
|
||||||
|
BreakoutPhase::Over => "结束",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(&self, ui: &egui::Ui, rect: egui::Rect, control: BreakoutControl) {
|
||||||
|
let painter = ui.painter_at(rect);
|
||||||
|
painter.rect_filled(
|
||||||
|
rect,
|
||||||
|
egui::CornerRadius::same(6),
|
||||||
|
egui::Color32::from_rgb(8, 14, 19),
|
||||||
|
);
|
||||||
|
painter.rect_stroke(
|
||||||
|
rect,
|
||||||
|
egui::CornerRadius::same(6),
|
||||||
|
egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.accent, 110)),
|
||||||
|
egui::StrokeKind::Outside,
|
||||||
|
);
|
||||||
|
|
||||||
|
paint_arena_grid(&painter, rect);
|
||||||
|
for (index, brick) in self.bricks.iter().enumerate() {
|
||||||
|
paint_brick(&painter, rect, brick, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
paint_paddle(&painter, rect, self.paddle_x);
|
||||||
|
paint_ball(&painter, rect, self.ball_pos);
|
||||||
|
paint_control_meter(&painter, rect, control);
|
||||||
|
|
||||||
|
if self.phase == BreakoutPhase::Idle
|
||||||
|
|| self.phase == BreakoutPhase::Paused
|
||||||
|
|| self.phase == BreakoutPhase::Over
|
||||||
|
{
|
||||||
|
paint_center_overlay(&painter, rect, self.phase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn control_from_matrix(raw: &[u32], rows: u32, cols: u32) -> BreakoutControl {
|
||||||
|
if raw.is_empty() {
|
||||||
|
return BreakoutControl::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = rows.max(1) as usize;
|
||||||
|
let cols = cols.max(1) as usize;
|
||||||
|
let sample_rows = rows.min(2);
|
||||||
|
let sample_cols = cols.min(2);
|
||||||
|
let avg = |row_start: usize, row_end: usize, col_start: usize, col_end: usize| -> f32 {
|
||||||
|
let mut sum = 0.0;
|
||||||
|
let mut count = 0.0;
|
||||||
|
for row in row_start..row_end {
|
||||||
|
for col in col_start..col_end {
|
||||||
|
let index = row * cols + col;
|
||||||
|
if let Some(value) = raw.get(index) {
|
||||||
|
sum += *value as f32;
|
||||||
|
count += 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if count > 0.0 { sum / count } else { 0.0 }
|
||||||
|
};
|
||||||
|
|
||||||
|
let tl = avg(0, sample_rows, 0, sample_cols);
|
||||||
|
let tr = avg(0, sample_rows, cols.saturating_sub(sample_cols), cols);
|
||||||
|
let bl = avg(rows.saturating_sub(sample_rows), rows, 0, sample_cols);
|
||||||
|
let br = avg(
|
||||||
|
rows.saturating_sub(sample_rows),
|
||||||
|
rows,
|
||||||
|
cols.saturating_sub(sample_cols),
|
||||||
|
cols,
|
||||||
|
);
|
||||||
|
|
||||||
|
let left_force = tl + bl;
|
||||||
|
let right_force = tr + br;
|
||||||
|
let top_force = tl + tr;
|
||||||
|
let span = 1200.0_f32.max((PRESSURE_RANGE_MAX - PRESSURE_RANGE_MIN) * 0.22);
|
||||||
|
let raw_axis = ((right_force - left_force) / span).clamp(-1.0, 1.0);
|
||||||
|
let axis = if raw_axis.abs() < 0.045 {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
raw_axis
|
||||||
|
};
|
||||||
|
|
||||||
|
BreakoutControl {
|
||||||
|
axis,
|
||||||
|
top_force,
|
||||||
|
left_force,
|
||||||
|
right_force,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pressure_pause_threshold() -> f32 {
|
||||||
|
420.0_f32.max(((PRESSURE_RANGE_MAX - PRESSURE_RANGE_MIN).max(1000.0) * 0.07).round())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paddle_rect(paddle_x: f32) -> egui::Rect {
|
||||||
|
egui::Rect::from_center_size(
|
||||||
|
egui::pos2(paddle_x, PADDLE_Y),
|
||||||
|
egui::vec2(PADDLE_W, PADDLE_H),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn circle_hits_rect(center: egui::Pos2, radius: f32, rect: egui::Rect) -> bool {
|
||||||
|
let closest_x = center.x.clamp(rect.left(), rect.right());
|
||||||
|
let closest_y = center.y.clamp(rect.top(), rect.bottom());
|
||||||
|
let dx = center.x - closest_x;
|
||||||
|
let dy = center.y - closest_y;
|
||||||
|
dx * dx + dy * dy <= radius * radius
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_chip(ui: &mut egui::Ui, label: &'static str, value: impl ToString, color: egui::Color32) {
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(color_alpha(ONE_DARK_PRO.panel_deep, 190))
|
||||||
|
.stroke(egui::Stroke::new(1.0, color_alpha(color, 92)))
|
||||||
|
.corner_radius(egui::CornerRadius::same(4))
|
||||||
|
.inner_margin(egui::Margin::symmetric(8, 4))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.colored_label(ONE_DARK_PRO.text_subtle, label);
|
||||||
|
ui.colored_label(color, value.to_string());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_color(phase: BreakoutPhase) -> egui::Color32 {
|
||||||
|
match phase {
|
||||||
|
BreakoutPhase::Idle => ONE_DARK_PRO.text_dim,
|
||||||
|
BreakoutPhase::Running => ACCENT_GREEN,
|
||||||
|
BreakoutPhase::Paused => ACCENT_ORANGE,
|
||||||
|
BreakoutPhase::Over => ACCENT_RED,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_screen(rect: egui::Rect, point: egui::Pos2) -> egui::Pos2 {
|
||||||
|
egui::pos2(
|
||||||
|
rect.left() + point.x * rect.width(),
|
||||||
|
rect.top() + point.y * rect.height(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_screen_rect(rect: egui::Rect, local: egui::Rect) -> egui::Rect {
|
||||||
|
egui::Rect::from_min_max(
|
||||||
|
rect.left_top() + local.min.to_vec2() * rect.size(),
|
||||||
|
rect.left_top() + local.max.to_vec2() * rect.size(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_arena_grid(painter: &egui::Painter, rect: egui::Rect) {
|
||||||
|
// 游戏背景保持纯净,避免网格和外框干扰砖块本身的色块节奏。
|
||||||
|
painter.rect_filled(
|
||||||
|
rect,
|
||||||
|
egui::CornerRadius::same(6),
|
||||||
|
egui::Color32::from_rgb(2, 8, 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
painter.rect_stroke(
|
||||||
|
rect,
|
||||||
|
egui::CornerRadius::same(6),
|
||||||
|
egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.accent, 70)),
|
||||||
|
egui::StrokeKind::Inside,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_brick(painter: &egui::Painter, arena: egui::Rect, brick: &Brick, index: usize) {
|
||||||
|
let rect = to_screen_rect(arena, brick.rect);
|
||||||
|
if brick.alive {
|
||||||
|
let row = index / BRICK_COLS;
|
||||||
|
let col = index % BRICK_COLS;
|
||||||
|
// 参考示例图:纯色矩形砖块,黑色间隔由砖块之间的空隙自然露出。
|
||||||
|
painter.rect_filled(rect, egui::CornerRadius::ZERO, brick_color(row, col));
|
||||||
|
} else if brick.flash > 0.0 {
|
||||||
|
let alpha = (brick.flash * 150.0) as u8;
|
||||||
|
painter.rect_stroke(
|
||||||
|
rect.expand(4.0 * brick.flash),
|
||||||
|
egui::CornerRadius::same(4),
|
||||||
|
egui::Stroke::new(1.4, color_alpha(ONE_DARK_PRO.accent_hot, alpha)),
|
||||||
|
egui::StrokeKind::Outside,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_paddle(painter: &egui::Painter, arena: egui::Rect, paddle_x: f32) {
|
||||||
|
let rect = to_screen_rect(arena, paddle_rect(paddle_x));
|
||||||
|
// 托盘也改成纯色块,和砖块保持同一种视觉语言。
|
||||||
|
painter.rect_filled(rect, egui::CornerRadius::ZERO, ACCENT_BLUE);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_ball(painter: &egui::Painter, arena: egui::Rect, ball_pos: egui::Pos2) {
|
||||||
|
let center = to_screen(arena, ball_pos);
|
||||||
|
let radius = BALL_RADIUS * arena.width().min(arena.height());
|
||||||
|
// 球保留为一个普通实心圆,避免额外高光和光晕。
|
||||||
|
painter.circle_filled(center, radius, egui::Color32::from_rgb(241, 207, 70));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_control_meter(painter: &egui::Painter, arena: egui::Rect, control: BreakoutControl) {
|
||||||
|
let meter_width = arena.width().mul_add(0.30, 0.0).clamp(180.0, 240.0);
|
||||||
|
let meter = egui::Rect::from_min_size(
|
||||||
|
arena.left_bottom() + egui::vec2(18.0, -34.0),
|
||||||
|
egui::vec2(meter_width, 18.0),
|
||||||
|
);
|
||||||
|
painter.rect_filled(
|
||||||
|
meter,
|
||||||
|
egui::CornerRadius::same(4),
|
||||||
|
color_alpha(ONE_DARK_PRO.panel_deep, 220),
|
||||||
|
);
|
||||||
|
painter.rect_stroke(
|
||||||
|
meter,
|
||||||
|
egui::CornerRadius::same(4),
|
||||||
|
egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.border, 120)),
|
||||||
|
egui::StrokeKind::Outside,
|
||||||
|
);
|
||||||
|
let center_x = meter.center().x;
|
||||||
|
painter.line_segment(
|
||||||
|
[
|
||||||
|
egui::pos2(center_x, meter.top()),
|
||||||
|
egui::pos2(center_x, meter.bottom()),
|
||||||
|
],
|
||||||
|
egui::Stroke::new(1.0, ONE_DARK_PRO.border_soft),
|
||||||
|
);
|
||||||
|
let marker_x = center_x + control.axis.clamp(-1.0, 1.0) * meter.width() * 0.45;
|
||||||
|
painter.circle_filled(
|
||||||
|
egui::pos2(marker_x, meter.center().y),
|
||||||
|
5.0,
|
||||||
|
color_alpha(ACCENT_GREEN, 210),
|
||||||
|
);
|
||||||
|
painter.circle_filled(
|
||||||
|
egui::pos2(marker_x, meter.center().y),
|
||||||
|
2.3,
|
||||||
|
color_alpha(egui::Color32::WHITE, 150),
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
meter.right_center() + egui::vec2(10.0, 0.0),
|
||||||
|
egui::Align2::LEFT_CENTER,
|
||||||
|
format!(
|
||||||
|
"L {:>4.0} R {:>4.0} T {:>4.0}",
|
||||||
|
control.left_force, control.right_force, control.top_force
|
||||||
|
),
|
||||||
|
egui::FontId::monospace(10.0),
|
||||||
|
ONE_DARK_PRO.text_dim,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_center_overlay(painter: &egui::Painter, rect: egui::Rect, phase: BreakoutPhase) {
|
||||||
|
painter.rect_filled(
|
||||||
|
rect,
|
||||||
|
egui::CornerRadius::same(6),
|
||||||
|
color_alpha(egui::Color32::BLACK, 82),
|
||||||
|
);
|
||||||
|
let (title, detail) = match phase {
|
||||||
|
BreakoutPhase::Idle => ("按压顶部或点击开始", "左右分区压力控制挡板"),
|
||||||
|
BreakoutPhase::Paused => ("已暂停", "再次按压顶部、P 或点击暂停继续"),
|
||||||
|
BreakoutPhase::Over => ("游戏结束", "点击或空格重开"),
|
||||||
|
BreakoutPhase::Running => ("", ""),
|
||||||
|
};
|
||||||
|
painter.text(
|
||||||
|
rect.center() - egui::vec2(0.0, 10.0),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
title,
|
||||||
|
egui::FontId::proportional(24.0),
|
||||||
|
ONE_DARK_PRO.text,
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
rect.center() + egui::vec2(0.0, 20.0),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
detail,
|
||||||
|
egui::FontId::proportional(13.0),
|
||||||
|
ONE_DARK_PRO.text_dim,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn brick_color(row: usize, col: usize) -> egui::Color32 {
|
||||||
|
const PALETTE: [egui::Color32; 5] = [
|
||||||
|
egui::Color32::from_rgb(46, 178, 104),
|
||||||
|
egui::Color32::from_rgb(73, 188, 220),
|
||||||
|
egui::Color32::from_rgb(234, 199, 73),
|
||||||
|
egui::Color32::from_rgb(232, 125, 64),
|
||||||
|
egui::Color32::from_rgb(237, 68, 62),
|
||||||
|
];
|
||||||
|
|
||||||
|
PALETTE[(col + row * 2) % PALETTE.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn color_alpha(color: egui::Color32, alpha: u8) -> egui::Color32 {
|
||||||
|
egui::Color32::from_rgba_premultiplied(color.r(), color.g(), color.b(), alpha)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use crossbeam_channel::{self, Receiver, Sender, TryRecvError};
|
use crossbeam_channel::{self, Receiver, Sender, TryRecvError};
|
||||||
|
|
||||||
use crate::serial_core::serial::{SerialPortReadWrite, run_serial_loop};
|
use crate::serial_core::serial::{SerialIoStats, SerialPortReadWrite, run_serial_loop};
|
||||||
|
|
||||||
/// Connection state visible to the UI.
|
/// Connection state visible to the UI.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -27,6 +27,7 @@ struct Session {
|
|||||||
cancel_tx: Sender<()>,
|
cancel_tx: Sender<()>,
|
||||||
handle: JoinHandle<()>,
|
handle: JoinHandle<()>,
|
||||||
sample_rx: Receiver<Vec<i32>>,
|
sample_rx: Receiver<Vec<i32>>,
|
||||||
|
stats_rx: Receiver<SerialIoStats>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Thread-safe connection manager that the UI and renderer can share.
|
/// Thread-safe connection manager that the UI and renderer can share.
|
||||||
@@ -34,6 +35,7 @@ pub struct ConnectionManager {
|
|||||||
state: Arc<Mutex<ConnectionState>>,
|
state: Arc<Mutex<ConnectionState>>,
|
||||||
session: Arc<Mutex<Option<Session>>>,
|
session: Arc<Mutex<Option<Session>>>,
|
||||||
latest_sample: Arc<Mutex<Option<PressureSample>>>,
|
latest_sample: Arc<Mutex<Option<PressureSample>>>,
|
||||||
|
stats: Arc<Mutex<SerialIoStats>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConnectionManager {
|
impl ConnectionManager {
|
||||||
@@ -42,6 +44,7 @@ impl ConnectionManager {
|
|||||||
state: Arc::new(Mutex::new(ConnectionState::Disconnected)),
|
state: Arc::new(Mutex::new(ConnectionState::Disconnected)),
|
||||||
session: Arc::new(Mutex::new(None)),
|
session: Arc::new(Mutex::new(None)),
|
||||||
latest_sample: Arc::new(Mutex::new(None)),
|
latest_sample: Arc::new(Mutex::new(None)),
|
||||||
|
stats: Arc::new(Mutex::new(SerialIoStats::default())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +56,21 @@ impl ConnectionManager {
|
|||||||
*self.state.lock().unwrap() = new_state;
|
*self.state.lock().unwrap() = new_state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn stats(&self) -> SerialIoStats {
|
||||||
|
let session = self.session.lock().unwrap();
|
||||||
|
if let Some(ref session) = *session {
|
||||||
|
loop {
|
||||||
|
match session.stats_rx.try_recv() {
|
||||||
|
Ok(stats) => *self.stats.lock().unwrap() = stats,
|
||||||
|
Err(TryRecvError::Empty) => break,
|
||||||
|
Err(TryRecvError::Disconnected) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*self.stats.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
/// Connect to the given serial port and start streaming in a background thread.
|
/// Connect to the given serial port and start streaming in a background thread.
|
||||||
pub fn connect(&self, port_name: &str, rows: u32, cols: u32) {
|
pub fn connect(&self, port_name: &str, rows: u32, cols: u32) {
|
||||||
self.disconnect();
|
self.disconnect();
|
||||||
@@ -64,6 +82,8 @@ impl ConnectionManager {
|
|||||||
let latest_sample = Arc::clone(&self.latest_sample);
|
let latest_sample = Arc::clone(&self.latest_sample);
|
||||||
let (cancel_tx, cancel_rx) = crossbeam_channel::bounded::<()>(1);
|
let (cancel_tx, cancel_rx) = crossbeam_channel::bounded::<()>(1);
|
||||||
let (sample_tx, sample_rx) = crossbeam_channel::bounded::<Vec<i32>>(16);
|
let (sample_tx, sample_rx) = crossbeam_channel::bounded::<Vec<i32>>(16);
|
||||||
|
let (stats_tx, stats_rx) = crossbeam_channel::bounded::<SerialIoStats>(16);
|
||||||
|
*self.stats.lock().unwrap() = SerialIoStats::default();
|
||||||
|
|
||||||
let handle = thread::spawn(move || {
|
let handle = thread::spawn(move || {
|
||||||
let result = run_device_loop(
|
let result = run_device_loop(
|
||||||
@@ -73,6 +93,7 @@ impl ConnectionManager {
|
|||||||
&state,
|
&state,
|
||||||
&cancel_rx,
|
&cancel_rx,
|
||||||
&sample_tx,
|
&sample_tx,
|
||||||
|
&stats_tx,
|
||||||
&latest_sample,
|
&latest_sample,
|
||||||
);
|
);
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
@@ -85,6 +106,7 @@ impl ConnectionManager {
|
|||||||
cancel_tx,
|
cancel_tx,
|
||||||
handle,
|
handle,
|
||||||
sample_rx,
|
sample_rx,
|
||||||
|
stats_rx,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +124,7 @@ impl ConnectionManager {
|
|||||||
|
|
||||||
self.set_state(ConnectionState::Disconnected);
|
self.set_state(ConnectionState::Disconnected);
|
||||||
*self.latest_sample.lock().unwrap() = None;
|
*self.latest_sample.lock().unwrap() = None;
|
||||||
|
*self.stats.lock().unwrap() = SerialIoStats::default();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drain pending samples (non-blocking) and return the last one.
|
/// Drain pending samples (non-blocking) and return the last one.
|
||||||
@@ -143,6 +166,7 @@ fn run_device_loop(
|
|||||||
state: &Arc<Mutex<ConnectionState>>,
|
state: &Arc<Mutex<ConnectionState>>,
|
||||||
cancel_rx: &Receiver<()>,
|
cancel_rx: &Receiver<()>,
|
||||||
sample_tx: &Sender<Vec<i32>>,
|
sample_tx: &Sender<Vec<i32>>,
|
||||||
|
stats_tx: &Sender<SerialIoStats>,
|
||||||
latest_sample: &Arc<Mutex<Option<PressureSample>>>,
|
latest_sample: &Arc<Mutex<Option<PressureSample>>>,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let port = serialport::new(port_name, 921_600)
|
let port = serialport::new(port_name, 921_600)
|
||||||
@@ -154,36 +178,17 @@ fn run_device_loop(
|
|||||||
let mut rw = SerialPortReadWrite::new(port);
|
let mut rw = SerialPortReadWrite::new(port);
|
||||||
*state.lock().unwrap() = ConnectionState::Streaming;
|
*state.lock().unwrap() = ConnectionState::Streaming;
|
||||||
|
|
||||||
// We need to also forward samples to latest_sample
|
run_serial_loop(
|
||||||
let (inner_tx, inner_rx) = crossbeam_channel::bounded::<Vec<i32>>(16);
|
&mut rw,
|
||||||
let latest = Arc::clone(latest_sample);
|
rows as usize,
|
||||||
let outer_tx = sample_tx.clone();
|
cols as usize,
|
||||||
|
cancel_rx,
|
||||||
|
sample_tx,
|
||||||
|
Some(stats_tx),
|
||||||
|
);
|
||||||
|
|
||||||
// Bridge thread: reads from inner channel, forwards to both sample_tx and latest_sample
|
if let Ok(mut latest) = latest_sample.lock() {
|
||||||
let bridge_cancel = cancel_rx.clone();
|
*latest = None;
|
||||||
let bridge_handle = thread::spawn(move || {
|
}
|
||||||
loop {
|
|
||||||
if bridge_cancel.try_recv().is_ok() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
match inner_rx.try_recv() {
|
|
||||||
Ok(vals) => {
|
|
||||||
// Store latest
|
|
||||||
let matrix = vals.iter().map(|v| (*v).max(0) as u32).collect();
|
|
||||||
*latest.lock().unwrap() = Some(PressureSample { matrix, rows, cols });
|
|
||||||
// Forward
|
|
||||||
let _ = outer_tx.try_send(vals);
|
|
||||||
}
|
|
||||||
Err(TryRecvError::Empty) => {
|
|
||||||
std::thread::sleep(Duration::from_millis(1));
|
|
||||||
}
|
|
||||||
Err(TryRecvError::Disconnected) => break,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
run_serial_loop(&mut rw, rows as usize, cols as usize, cancel_rx, &inner_tx);
|
|
||||||
|
|
||||||
let _ = bridge_handle.join();
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
mod app;
|
mod app;
|
||||||
|
mod breakout;
|
||||||
mod connection;
|
mod connection;
|
||||||
mod matrix;
|
mod matrix;
|
||||||
mod model;
|
mod model;
|
||||||
|
|||||||
241
src/render.rs
241
src/render.rs
@@ -68,6 +68,23 @@ const HAND_TIP_MATRICES: [HandTipMatrix; 5] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const HAND_PALM_CHIPS: [HandPalmChip; 2] = [
|
||||||
|
HandPalmChip {
|
||||||
|
center_px: [538.0, 608.0],
|
||||||
|
size_px: [248.0, 82.0],
|
||||||
|
angle_rad: 0.06,
|
||||||
|
rows: 5,
|
||||||
|
cols: 14,
|
||||||
|
},
|
||||||
|
HandPalmChip {
|
||||||
|
center_px: [606.0, 780.0],
|
||||||
|
size_px: [72.0, 214.0],
|
||||||
|
angle_rad: 0.05,
|
||||||
|
rows: 11,
|
||||||
|
cols: 4,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
// Each entry pins one miniature matrix to a fingertip in hand.png.
|
// Each entry pins one miniature matrix to a fingertip in hand.png.
|
||||||
// Coordinates are authored in source-image pixels so they are easy to tune by eye.
|
// Coordinates are authored in source-image pixels so they are easy to tune by eye.
|
||||||
// size_px keeps the same 7:12 aspect as the Finger-mode 7 columns x 12 rows matrix.
|
// size_px keeps the same 7:12 aspect as the Finger-mode 7 columns x 12 rows matrix.
|
||||||
@@ -77,6 +94,16 @@ struct HandTipMatrix {
|
|||||||
angle_rad: f32,
|
angle_rad: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Palm chips follow the hand layout: one horizontal 5x14 matrix and one vertical
|
||||||
|
// 11x4 matrix, rendered as dark inset chip tiles on the palm.
|
||||||
|
struct HandPalmChip {
|
||||||
|
center_px: [f32; 2],
|
||||||
|
size_px: [f32; 2],
|
||||||
|
angle_rad: f32,
|
||||||
|
rows: u32,
|
||||||
|
cols: u32,
|
||||||
|
}
|
||||||
|
|
||||||
impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback {
|
impl egui_wgpu::CallbackTrait for WgpuBackgroundCallback {
|
||||||
fn prepare(
|
fn prepare(
|
||||||
&self,
|
&self,
|
||||||
@@ -117,6 +144,8 @@ pub struct BackgroundRenderResources {
|
|||||||
dot_pipeline: wgpu::RenderPipeline,
|
dot_pipeline: wgpu::RenderPipeline,
|
||||||
hand_membrane_pipeline: wgpu::RenderPipeline,
|
hand_membrane_pipeline: wgpu::RenderPipeline,
|
||||||
hand_dot_pipeline: wgpu::RenderPipeline,
|
hand_dot_pipeline: wgpu::RenderPipeline,
|
||||||
|
hand_palm_chip_pipeline: wgpu::RenderPipeline,
|
||||||
|
hand_palm_dot_pipeline: wgpu::RenderPipeline,
|
||||||
hand_image_bind_group: wgpu::BindGroup,
|
hand_image_bind_group: wgpu::BindGroup,
|
||||||
hand_image_texture: texture::Texture,
|
hand_image_texture: texture::Texture,
|
||||||
|
|
||||||
@@ -127,6 +156,10 @@ pub struct BackgroundRenderResources {
|
|||||||
hand_membrane_instances: Vec<GlyphInstance>,
|
hand_membrane_instances: Vec<GlyphInstance>,
|
||||||
hand_dot_instance_buffer: wgpu::Buffer,
|
hand_dot_instance_buffer: wgpu::Buffer,
|
||||||
hand_dot_instances: Vec<GlyphInstance>,
|
hand_dot_instances: Vec<GlyphInstance>,
|
||||||
|
hand_palm_chip_instance_buffer: wgpu::Buffer,
|
||||||
|
hand_palm_chip_instances: Vec<GlyphInstance>,
|
||||||
|
hand_palm_dot_instance_buffer: wgpu::Buffer,
|
||||||
|
hand_palm_dot_instances: Vec<GlyphInstance>,
|
||||||
render_options: RenderOptions,
|
render_options: RenderOptions,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,6 +485,10 @@ impl BackgroundRenderResources {
|
|||||||
create_hand_membrane_pipeline(device, target_format, &shader, &pipeline_layout);
|
create_hand_membrane_pipeline(device, target_format, &shader, &pipeline_layout);
|
||||||
let hand_dot_pipeline =
|
let hand_dot_pipeline =
|
||||||
create_hand_dot_pipeline(device, target_format, &shader, &pipeline_layout);
|
create_hand_dot_pipeline(device, target_format, &shader, &pipeline_layout);
|
||||||
|
let hand_palm_chip_pipeline =
|
||||||
|
create_hand_palm_chip_pipeline(device, target_format, &shader, &pipeline_layout);
|
||||||
|
let hand_palm_dot_pipeline =
|
||||||
|
create_hand_palm_dot_pipeline(device, target_format, &shader, &pipeline_layout);
|
||||||
|
|
||||||
let _model_pipelines =
|
let _model_pipelines =
|
||||||
create_model_pipelines(device, target_format, &shader, &model_pipeline_layout);
|
create_model_pipelines(device, target_format, &shader, &model_pipeline_layout);
|
||||||
@@ -502,6 +539,29 @@ impl BackgroundRenderResources {
|
|||||||
contents: bytemuck::cast_slice(&hand_dot_instances),
|
contents: bytemuck::cast_slice(&hand_dot_instances),
|
||||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||||
});
|
});
|
||||||
|
let hand_palm_chip_instances = build_hand_palm_chip_instances(
|
||||||
|
hand_image_texture.width as f32,
|
||||||
|
hand_image_texture.height as f32,
|
||||||
|
);
|
||||||
|
let hand_palm_chip_instance_buffer =
|
||||||
|
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("Hand Palm Chip Instance Buffer"),
|
||||||
|
contents: bytemuck::cast_slice(&hand_palm_chip_instances),
|
||||||
|
usage: wgpu::BufferUsages::VERTEX,
|
||||||
|
});
|
||||||
|
let hand_palm_dot_instances = build_hand_palm_dot_instances(
|
||||||
|
rows,
|
||||||
|
cols,
|
||||||
|
hand_image_texture.width as f32,
|
||||||
|
hand_image_texture.height as f32,
|
||||||
|
&[[0.0, 0.0]; PRESSURE_CELL_COUNT],
|
||||||
|
);
|
||||||
|
let hand_palm_dot_instance_buffer =
|
||||||
|
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||||
|
label: Some("Hand Palm Chip Dot Instance Buffer"),
|
||||||
|
contents: bytemuck::cast_slice(&hand_palm_dot_instances),
|
||||||
|
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
layout,
|
layout,
|
||||||
@@ -517,6 +577,8 @@ impl BackgroundRenderResources {
|
|||||||
dot_pipeline,
|
dot_pipeline,
|
||||||
hand_membrane_pipeline,
|
hand_membrane_pipeline,
|
||||||
hand_dot_pipeline,
|
hand_dot_pipeline,
|
||||||
|
hand_palm_chip_pipeline,
|
||||||
|
hand_palm_dot_pipeline,
|
||||||
hand_image_bind_group,
|
hand_image_bind_group,
|
||||||
hand_image_texture,
|
hand_image_texture,
|
||||||
glyph_vertex_buffer,
|
glyph_vertex_buffer,
|
||||||
@@ -526,6 +588,10 @@ impl BackgroundRenderResources {
|
|||||||
hand_membrane_instances,
|
hand_membrane_instances,
|
||||||
hand_dot_instance_buffer,
|
hand_dot_instance_buffer,
|
||||||
hand_dot_instances,
|
hand_dot_instances,
|
||||||
|
hand_palm_chip_instance_buffer,
|
||||||
|
hand_palm_chip_instances,
|
||||||
|
hand_palm_dot_instance_buffer,
|
||||||
|
hand_palm_dot_instances,
|
||||||
render_options,
|
render_options,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -576,6 +642,21 @@ impl BackgroundRenderResources {
|
|||||||
0,
|
0,
|
||||||
bytemuck::cast_slice(&self.hand_dot_instances),
|
bytemuck::cast_slice(&self.hand_dot_instances),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Palm chips reuse the same live 12x7 pressure frame, but draw it as
|
||||||
|
// embedded micro-pixels inside dark chip tiles.
|
||||||
|
self.hand_palm_dot_instances = build_hand_palm_dot_instances(
|
||||||
|
self.rows,
|
||||||
|
self.cols,
|
||||||
|
self.hand_image_texture.width as f32,
|
||||||
|
self.hand_image_texture.height as f32,
|
||||||
|
pressure,
|
||||||
|
);
|
||||||
|
queue.write_buffer(
|
||||||
|
&self.hand_palm_dot_instance_buffer,
|
||||||
|
0,
|
||||||
|
bytemuck::cast_slice(&self.hand_palm_dot_instances),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn paint(&self, render_pass: &mut wgpu::RenderPass<'_>, active_mode: &ActiveMode) {
|
fn paint(&self, render_pass: &mut wgpu::RenderPass<'_>, active_mode: &ActiveMode) {
|
||||||
@@ -623,6 +704,16 @@ impl BackgroundRenderResources {
|
|||||||
render_pass.set_vertex_buffer(0, self.glyph_vertex_buffer.slice(..));
|
render_pass.set_vertex_buffer(0, self.glyph_vertex_buffer.slice(..));
|
||||||
render_pass.set_vertex_buffer(1, self.hand_dot_instance_buffer.slice(..));
|
render_pass.set_vertex_buffer(1, self.hand_dot_instance_buffer.slice(..));
|
||||||
render_pass.draw(0..6, 0..self.hand_dot_instances.len() as u32);
|
render_pass.draw(0..6, 0..self.hand_dot_instances.len() as u32);
|
||||||
|
|
||||||
|
render_pass.set_pipeline(&self.hand_palm_chip_pipeline);
|
||||||
|
render_pass.set_vertex_buffer(0, self.glyph_vertex_buffer.slice(..));
|
||||||
|
render_pass.set_vertex_buffer(1, self.hand_palm_chip_instance_buffer.slice(..));
|
||||||
|
render_pass.draw(0..6, 0..self.hand_palm_chip_instances.len() as u32);
|
||||||
|
|
||||||
|
render_pass.set_pipeline(&self.hand_palm_dot_pipeline);
|
||||||
|
render_pass.set_vertex_buffer(0, self.glyph_vertex_buffer.slice(..));
|
||||||
|
render_pass.set_vertex_buffer(1, self.hand_palm_dot_instance_buffer.slice(..));
|
||||||
|
render_pass.draw(0..6, 0..self.hand_palm_dot_instances.len() as u32);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn visible_instance_count(&self, rows: u32, cols: u32) -> u32 {
|
fn visible_instance_count(&self, rows: u32, cols: u32) -> u32 {
|
||||||
@@ -956,6 +1047,72 @@ fn create_hand_dot_pipeline(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn create_hand_palm_chip_pipeline(
|
||||||
|
device: &wgpu::Device,
|
||||||
|
target_format: &wgpu::TextureFormat,
|
||||||
|
shader: &wgpu::ShaderModule,
|
||||||
|
layout: &wgpu::PipelineLayout,
|
||||||
|
) -> wgpu::RenderPipeline {
|
||||||
|
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("Hand Palm Embedded Chip Pipeline"),
|
||||||
|
layout: Some(layout),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: shader,
|
||||||
|
entry_point: Some("vs_hand_palm_chip"),
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
buffers: &[GlyphVertex::desc(), GlyphInstance::desc()],
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: shader,
|
||||||
|
entry_point: Some("fs_hand_palm_chip"),
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
|
format: *target_format,
|
||||||
|
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState::default(),
|
||||||
|
depth_stencil: None,
|
||||||
|
multisample: wgpu::MultisampleState::default(),
|
||||||
|
multiview_mask: None,
|
||||||
|
cache: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_hand_palm_dot_pipeline(
|
||||||
|
device: &wgpu::Device,
|
||||||
|
target_format: &wgpu::TextureFormat,
|
||||||
|
shader: &wgpu::ShaderModule,
|
||||||
|
layout: &wgpu::PipelineLayout,
|
||||||
|
) -> wgpu::RenderPipeline {
|
||||||
|
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("Hand Palm Embedded Chip Dot Pipeline"),
|
||||||
|
layout: Some(layout),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: shader,
|
||||||
|
entry_point: Some("vs_hand_palm_dot"),
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
buffers: &[GlyphVertex::desc(), GlyphInstance::desc()],
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: shader,
|
||||||
|
entry_point: Some("fs_hand_palm_dot"),
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
|
format: *target_format,
|
||||||
|
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState::default(),
|
||||||
|
depth_stencil: None,
|
||||||
|
multisample: wgpu::MultisampleState::default(),
|
||||||
|
multiview_mask: None,
|
||||||
|
cache: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn build_hand_membrane_instances(image_width: f32, image_height: f32) -> Vec<GlyphInstance> {
|
fn build_hand_membrane_instances(image_width: f32, image_height: f32) -> Vec<GlyphInstance> {
|
||||||
HAND_TIP_MATRICES
|
HAND_TIP_MATRICES
|
||||||
.iter()
|
.iter()
|
||||||
@@ -974,6 +1131,28 @@ fn build_hand_membrane_instances(image_width: f32, image_height: f32) -> Vec<Gly
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_hand_palm_chip_instances(image_width: f32, image_height: f32) -> Vec<GlyphInstance> {
|
||||||
|
HAND_PALM_CHIPS
|
||||||
|
.iter()
|
||||||
|
.map(|chip| {
|
||||||
|
let uv_x = chip.center_px[0] / image_width.max(1.0);
|
||||||
|
let uv_y = chip.center_px[1] / image_height.max(1.0);
|
||||||
|
|
||||||
|
GlyphInstance {
|
||||||
|
// Palm chip shaders read xy as hand.png UV.
|
||||||
|
world_position: [uv_x, uv_y, 0.0, 1.0],
|
||||||
|
// Store chip angle, source-image pixel size, and matrix shape for the shader grid.
|
||||||
|
style: [
|
||||||
|
chip.angle_rad,
|
||||||
|
chip.size_px[0],
|
||||||
|
chip.size_px[1],
|
||||||
|
(chip.rows * 100 + chip.cols) as f32,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
fn build_hand_dot_instances(
|
fn build_hand_dot_instances(
|
||||||
rows: u32,
|
rows: u32,
|
||||||
cols: u32,
|
cols: u32,
|
||||||
@@ -1015,6 +1194,68 @@ fn build_hand_dot_instances(
|
|||||||
instances
|
instances
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_hand_palm_dot_instances(
|
||||||
|
rows: u32,
|
||||||
|
cols: u32,
|
||||||
|
image_width: f32,
|
||||||
|
image_height: f32,
|
||||||
|
pressure: &PressureFrame,
|
||||||
|
) -> Vec<GlyphInstance> {
|
||||||
|
let chip_dot_count: usize = HAND_PALM_CHIPS
|
||||||
|
.iter()
|
||||||
|
.map(|chip| (chip.rows * chip.cols) as usize)
|
||||||
|
.sum();
|
||||||
|
let mut instances = Vec::with_capacity(chip_dot_count);
|
||||||
|
|
||||||
|
for chip in HAND_PALM_CHIPS {
|
||||||
|
let cos = chip.angle_rad.cos();
|
||||||
|
let sin = chip.angle_rad.sin();
|
||||||
|
// Leave a bevel around the chip so the matrix reads as embedded pixels.
|
||||||
|
let active_size = [chip.size_px[0] * 0.72, chip.size_px[1] * 0.76];
|
||||||
|
|
||||||
|
for row in 0..chip.rows {
|
||||||
|
for col in 0..chip.cols {
|
||||||
|
// Current live data is still the device pressure frame. Sample it by
|
||||||
|
// proportion so the palm's 5x14 and 11x4 layouts get coherent values.
|
||||||
|
let source_row = resample_index(row, chip.rows, rows);
|
||||||
|
let source_col = resample_index(col, chip.cols, cols);
|
||||||
|
let index = (source_row * cols + source_col) as usize;
|
||||||
|
let [normalized, display_value] =
|
||||||
|
pressure.get(index).copied().unwrap_or([0.0, 0.0]);
|
||||||
|
|
||||||
|
let local_x =
|
||||||
|
(col as f32 - chip.cols as f32 / 2.0 + 0.5) / chip.cols as f32 * active_size[0];
|
||||||
|
let local_y =
|
||||||
|
(row as f32 - chip.rows as f32 / 2.0 + 0.5) / chip.rows as f32 * active_size[1];
|
||||||
|
|
||||||
|
let x = chip.center_px[0] + local_x * cos - local_y * sin;
|
||||||
|
let y = chip.center_px[1] + local_x * sin + local_y * cos;
|
||||||
|
|
||||||
|
instances.push(GlyphInstance {
|
||||||
|
world_position: [
|
||||||
|
x / image_width.max(1.0),
|
||||||
|
y / image_height.max(1.0),
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
],
|
||||||
|
style: [normalized, display_value, 0.0, 0.0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
instances
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resample_index(index: u32, source_count: u32, target_count: u32) -> u32 {
|
||||||
|
if source_count <= 1 || target_count <= 1 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mapped = index as f32 / (source_count - 1) as f32 * (target_count - 1) as f32;
|
||||||
|
mapped.round().clamp(0.0, (target_count - 1) as f32) as u32
|
||||||
|
}
|
||||||
|
|
||||||
fn build_glyph_instances(
|
fn build_glyph_instances(
|
||||||
rows: u32,
|
rows: u32,
|
||||||
cols: u32,
|
cols: u32,
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ pub struct TactileACodec {
|
|||||||
expected_data_len: usize,
|
expected_data_len: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<u8> for TactileAFrameStatusCode {
|
||||||
|
fn from(value: u8) -> Self {
|
||||||
|
match value {
|
||||||
|
0 => TactileAFrameStatusCode::Success,
|
||||||
|
_ => TactileAFrameStatusCode::Failure,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl TactileACodec {
|
impl TactileACodec {
|
||||||
pub fn new(cols: usize, rows: usize) -> TactileACodec {
|
pub fn new(cols: usize, rows: usize) -> TactileACodec {
|
||||||
Self {
|
Self {
|
||||||
@@ -37,7 +46,7 @@ impl TactileACodec {
|
|||||||
Ok(vals)
|
Ok(vals)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_req_frame(cols: usize, rows: usize) -> TactileAFrame {
|
pub fn build_req_frame(cols: usize, rows: usize) -> anyhow::Result<TactileAFrame> {
|
||||||
let header = [0x55, 0xAA];
|
let header = [0x55, 0xAA];
|
||||||
let payload_len: usize = 9;
|
let payload_len: usize = 9;
|
||||||
let device_addr: u8 = 0x34;
|
let device_addr: u8 = 0x34;
|
||||||
@@ -46,7 +55,7 @@ impl TactileACodec {
|
|||||||
let start_addr: u32 = 7168;
|
let start_addr: u32 = 7168;
|
||||||
let except_data_len: usize = cols * rows * 2;
|
let except_data_len: usize = cols * rows * 2;
|
||||||
let checksum: u8 = 0;
|
let checksum: u8 = 0;
|
||||||
TactileAFrame::Req(TactileAReqFrame {
|
Ok(TactileAFrame::Req(TactileAReqFrame {
|
||||||
meta: TactileAFrameMetaData {
|
meta: TactileAFrameMetaData {
|
||||||
header,
|
header,
|
||||||
payload_len,
|
payload_len,
|
||||||
@@ -57,7 +66,7 @@ impl TactileACodec {
|
|||||||
except_data_len,
|
except_data_len,
|
||||||
checksum,
|
checksum,
|
||||||
},
|
},
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,10 +111,7 @@ impl Codec<TactileAFrame> for TactileACodec {
|
|||||||
self.buffer[10],
|
self.buffer[10],
|
||||||
]);
|
]);
|
||||||
let except_data_len = u16::from_le_bytes([self.buffer[11], self.buffer[12]]) as usize;
|
let except_data_len = u16::from_le_bytes([self.buffer[11], self.buffer[12]]) as usize;
|
||||||
let status = match self.buffer[13] {
|
let status = TactileAFrameStatusCode::from(self.buffer[13]);
|
||||||
0 => TactileAFrameStatusCode::Success,
|
|
||||||
_ => TactileAFrameStatusCode::Failure,
|
|
||||||
};
|
|
||||||
|
|
||||||
if except_data_len != self.expected_data_len {
|
if except_data_len != self.expected_data_len {
|
||||||
log::debug!(
|
log::debug!(
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ pub enum SerialError {
|
|||||||
AlreadyConnected,
|
AlreadyConnected,
|
||||||
StateError,
|
StateError,
|
||||||
NoRecordedData,
|
NoRecordedData,
|
||||||
|
ExportError,
|
||||||
|
ImportError,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for SerialError {
|
impl fmt::Display for SerialError {
|
||||||
@@ -21,6 +23,8 @@ impl fmt::Display for SerialError {
|
|||||||
SerialError::AlreadyConnected => write!(f, "Already Connected"),
|
SerialError::AlreadyConnected => write!(f, "Already Connected"),
|
||||||
SerialError::StateError => write!(f, "State Error"),
|
SerialError::StateError => write!(f, "State Error"),
|
||||||
SerialError::NoRecordedData => write!(f, "No Recorded Data"),
|
SerialError::NoRecordedData => write!(f, "No Recorded Data"),
|
||||||
|
SerialError::ExportError => write!(f, "Export Error"),
|
||||||
|
SerialError::ImportError => write!(f, "Import Error"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct TestFrame {
|
||||||
|
pub header: [u8; 2],
|
||||||
|
pub cmd: u8,
|
||||||
|
pub length: usize,
|
||||||
|
pub payload: Vec<u8>,
|
||||||
|
pub checksum: u8,
|
||||||
|
pub dts_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct TactileAFrameMetaData {
|
pub struct TactileAFrameMetaData {
|
||||||
pub header: [u8; 2],
|
pub header: [u8; 2],
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
use crate::serial_core::codec::Codec;
|
use crate::serial_core::codec::Codec;
|
||||||
use crate::serial_core::codecs::tactile_a::TactileACodec;
|
use crate::serial_core::codecs::tactile_a::TactileACodec;
|
||||||
use crate::serial_core::frame::TactileAFrame;
|
use crate::serial_core::frame::TactileAFrame;
|
||||||
use crate::serial_core::utils::elapsed_millis;
|
use crossbeam_channel::{Receiver, Sender};
|
||||||
use crossbeam_channel::{Receiver, Sender, TryRecvError};
|
|
||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
const POLL_INTERVAL_MS: u64 = 10;
|
const POLL_INTERVAL_MS: u64 = 10;
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct SerialIoStats {
|
||||||
|
pub rx_bytes: u64,
|
||||||
|
pub tx_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
/// Runs the serial polling loop on the calling (background) thread.
|
/// Runs the serial polling loop on the calling (background) thread.
|
||||||
/// Sends decoded pressure matrix data (Vec<i32>) to the output channel.
|
/// Sends decoded pressure matrix data (Vec<i32>) to the output channel.
|
||||||
pub fn run_serial_loop(
|
pub fn run_serial_loop(
|
||||||
@@ -16,12 +21,20 @@ pub fn run_serial_loop(
|
|||||||
cols: usize,
|
cols: usize,
|
||||||
cancel_rx: &Receiver<()>,
|
cancel_rx: &Receiver<()>,
|
||||||
sample_tx: &Sender<Vec<i32>>,
|
sample_tx: &Sender<Vec<i32>>,
|
||||||
|
stats_tx: Option<&Sender<SerialIoStats>>,
|
||||||
) {
|
) {
|
||||||
let session_started_at = Instant::now();
|
let session_started_at = Instant::now();
|
||||||
let mut codec = TactileACodec::new(cols, rows);
|
let mut codec = TactileACodec::new(cols, rows);
|
||||||
let req_frame = TactileACodec::build_req_frame(cols, rows);
|
let req_frame = match TactileACodec::build_req_frame(cols, rows) {
|
||||||
|
Ok(frame) => frame,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("[serial] request frame build error: {err}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
let mut buffer = [0u8; 1024];
|
let mut buffer = [0u8; 1024];
|
||||||
let mut poll_interval = Duration::from_millis(POLL_INTERVAL_MS);
|
let poll_interval = Duration::from_millis(POLL_INTERVAL_MS);
|
||||||
|
let mut io_stats = SerialIoStats::default();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Check cancel
|
// Check cancel
|
||||||
@@ -31,7 +44,10 @@ pub fn run_serial_loop(
|
|||||||
|
|
||||||
// Send poll request
|
// Send poll request
|
||||||
if let Ok(req_bytes) = codec.encode(&req_frame) {
|
if let Ok(req_bytes) = codec.encode(&req_frame) {
|
||||||
let _ = port.write_all(&req_bytes);
|
if port.write_all(&req_bytes).is_ok() {
|
||||||
|
io_stats.tx_bytes += req_bytes.len() as u64;
|
||||||
|
publish_stats(stats_tx, io_stats);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read response with poll interval
|
// Read response with poll interval
|
||||||
@@ -43,6 +59,8 @@ pub fn run_serial_loop(
|
|||||||
|
|
||||||
match port.read(&mut buffer) {
|
match port.read(&mut buffer) {
|
||||||
Ok(n) if n > 0 => {
|
Ok(n) if n > 0 => {
|
||||||
|
io_stats.rx_bytes += n as u64;
|
||||||
|
publish_stats(stats_tx, io_stats);
|
||||||
if let Ok(frames) = codec.decode(&buffer[..n], session_started_at) {
|
if let Ok(frames) = codec.decode(&buffer[..n], session_started_at) {
|
||||||
for frame in frames {
|
for frame in frames {
|
||||||
if let TactileAFrame::Rep(rep) = frame {
|
if let TactileAFrame::Rep(rep) = frame {
|
||||||
@@ -68,6 +86,12 @@ pub fn run_serial_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn publish_stats(stats_tx: Option<&Sender<SerialIoStats>>, stats: SerialIoStats) {
|
||||||
|
if let Some(tx) = stats_tx {
|
||||||
|
let _ = tx.try_send(stats);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Trait abstracting read+write for the serial port
|
/// Trait abstracting read+write for the serial port
|
||||||
pub trait ReadWrite: Send {
|
pub trait ReadWrite: Send {
|
||||||
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize>;
|
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize>;
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
pub fn usize_to_u16_be_bytes(n: usize) -> [u8; 2] {
|
||||||
|
(n as u16).to_be_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn usize_to_u16_le_bytes(n: usize) -> [u8; 2] {
|
||||||
|
(n as u16).to_be_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn u16_to_hex_be_bytes(n: u16) -> [u8; 2] {
|
||||||
|
n.to_be_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn u16_to_hex_le_bytes(n: u16) -> [u8; 2] {
|
||||||
|
n.to_le_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn calc_crc8_smbus(c: &[u8]) -> u8 {
|
||||||
|
let crc8_smbus = crc::Crc::<u8>::new(&crc::CRC_8_SMBUS);
|
||||||
|
crc8_smbus.checksum(c)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn calc_crc8_itu(c: &[u8]) -> u8 {
|
pub fn calc_crc8_itu(c: &[u8]) -> u8 {
|
||||||
let crc8_itu_alg = crc::Crc::<u8>::new(&crc::CRC_8_I_432_1);
|
let crc8_itu_alg = crc::Crc::<u8>::new(&crc::CRC_8_I_432_1);
|
||||||
crc8_itu_alg.checksum(c)
|
crc8_itu_alg.checksum(c)
|
||||||
@@ -8,3 +29,26 @@ pub fn calc_crc8_itu(c: &[u8]) -> u8 {
|
|||||||
pub fn elapsed_millis(start_at: Instant) -> u64 {
|
pub fn elapsed_millis(start_at: Instant) -> u64 {
|
||||||
start_at.elapsed().as_millis() as u64
|
start_at.elapsed().as_millis() as u64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use crate::serial_core::utils::{calc_crc8_itu, calc_crc8_smbus};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crc8_itu() {
|
||||||
|
let req_vec = vec![
|
||||||
|
0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00,
|
||||||
|
];
|
||||||
|
let checksum = calc_crc8_itu(req_vec.as_slice());
|
||||||
|
assert_eq!(checksum, 0x7A);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_crc8_smbus() {
|
||||||
|
let req_vec = vec![
|
||||||
|
0x55, 0xAA, 0x09, 0x00, 0x34, 0x00, 0xFB, 0x00, 0x1C, 0x00, 0x00, 0x18, 0x00,
|
||||||
|
];
|
||||||
|
let checksum = calc_crc8_smbus(req_vec.as_slice());
|
||||||
|
assert_eq!(checksum, 0x2F);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
471
src/ui.rs
471
src/ui.rs
@@ -3,9 +3,10 @@ use eframe::egui;
|
|||||||
use crate::{
|
use crate::{
|
||||||
connection::{ConnectionManager, ConnectionState},
|
connection::{ConnectionManager, ConnectionState},
|
||||||
recording::Recorder,
|
recording::Recorder,
|
||||||
|
serial_core::serial::SerialIoStats,
|
||||||
style::{
|
style::{
|
||||||
self, ACCENT_BLUE, ACCENT_GREEN, ACCENT_ORANGE, ACCENT_RED, METRICS, ONE_DARK_PRO,
|
self, ACCENT_BLUE, ACCENT_GREEN, ACCENT_ORANGE, ACCENT_RED, METRICS, ONE_DARK_PRO,
|
||||||
accent_text, dim_text, group_frame, layout, panel_frame, tag_button,
|
dim_text, group_frame, layout, panel_frame, tag_button,
|
||||||
},
|
},
|
||||||
utils::serial_enum,
|
utils::serial_enum,
|
||||||
};
|
};
|
||||||
@@ -22,6 +23,7 @@ pub struct FloatingPanelState {
|
|||||||
pub struct ConfigPanelState {
|
pub struct ConfigPanelState {
|
||||||
pub mode: SerialMode,
|
pub mode: SerialMode,
|
||||||
pub port: String,
|
pub port: String,
|
||||||
|
pub available_ports: Vec<String>,
|
||||||
pub baud_rate: u32,
|
pub baud_rate: u32,
|
||||||
pub data_bits: u8,
|
pub data_bits: u8,
|
||||||
pub stop_bits: u8,
|
pub stop_bits: u8,
|
||||||
@@ -78,17 +80,24 @@ impl FloatingPanelState {
|
|||||||
|
|
||||||
impl Default for ConfigPanelState {
|
impl Default for ConfigPanelState {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
|
let available_ports = serial_enum().unwrap_or_default();
|
||||||
|
let port = available_ports
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "COM3".to_owned());
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
mode: SerialMode::Finger,
|
mode: SerialMode::Finger,
|
||||||
port: "COM3".to_owned(),
|
port,
|
||||||
baud_rate: 115_200,
|
available_ports,
|
||||||
|
baud_rate: 921_600,
|
||||||
data_bits: 8,
|
data_bits: 8,
|
||||||
stop_bits: 1,
|
stop_bits: 1,
|
||||||
parity: Parity::None,
|
parity: Parity::None,
|
||||||
timeout_ms: 1000,
|
timeout_ms: 1000,
|
||||||
module_addr: 1,
|
module_addr: 1,
|
||||||
connected: false,
|
connected: false,
|
||||||
auto_reconnect: true,
|
auto_reconnect: false,
|
||||||
manual_tx: "01 03 00 00 00 02".to_owned(),
|
manual_tx: "01 03 00 00 00 02".to_owned(),
|
||||||
model_path: "model/default.eskin".to_owned(),
|
model_path: "model/default.eskin".to_owned(),
|
||||||
}
|
}
|
||||||
@@ -307,8 +316,16 @@ pub fn draw_config_panel(
|
|||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
panel: &mut FloatingPanelState,
|
panel: &mut FloatingPanelState,
|
||||||
config: &mut ConfigPanelState,
|
config: &mut ConfigPanelState,
|
||||||
|
connection: &ConnectionManager,
|
||||||
) -> Option<SerialMode> {
|
) -> Option<SerialMode> {
|
||||||
let mut changed_mode = None;
|
let mut changed_mode = None;
|
||||||
|
let conn_state = connection.state();
|
||||||
|
let stats = connection.stats();
|
||||||
|
config.connected = matches!(
|
||||||
|
conn_state,
|
||||||
|
ConnectionState::Connected | ConnectionState::Streaming
|
||||||
|
);
|
||||||
|
|
||||||
draw_floating_panel(ctx, panel, "配置", "config_panel", |ui| {
|
draw_floating_panel(ctx, panel, "配置", "config_panel", |ui| {
|
||||||
ui.set_min_width(560.0);
|
ui.set_min_width(560.0);
|
||||||
|
|
||||||
@@ -318,11 +335,12 @@ pub fn draw_config_panel(
|
|||||||
|
|
||||||
// draw_mode_row(ui, config);
|
// draw_mode_row(ui, config);
|
||||||
ui.separator();
|
ui.separator();
|
||||||
draw_connection_row(ui, config);
|
draw_connection_row(ui, config, connection, conn_state);
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
draw_serial_grid(ui, config);
|
// Legacy serial parameter grid is intentionally kept commented for future hardware:
|
||||||
ui.add_space(8.0);
|
// draw_serial_grid(ui, config);
|
||||||
draw_mode_body(ui, config);
|
// ui.add_space(8.0);
|
||||||
|
draw_mode_body(ui, config, conn_state, stats);
|
||||||
});
|
});
|
||||||
changed_mode
|
changed_mode
|
||||||
}
|
}
|
||||||
@@ -341,16 +359,22 @@ fn draw_mode_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) -> Option<Ser
|
|||||||
}
|
}
|
||||||
// mode_button(ui, &mut config.mode, SerialMode::Model, "模型");
|
// mode_button(ui, &mut config.mode, SerialMode::Model, "模型");
|
||||||
|
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
// Legacy reconnect controls. Current sensors run fixed 921600 baud without auto-reconnect UI.
|
||||||
ui.checkbox(&mut config.auto_reconnect, "自动");
|
// ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
ui.colored_label(dim_text(), "重连");
|
// ui.checkbox(&mut config.auto_reconnect, "自动");
|
||||||
});
|
// ui.colored_label(dim_text(), "重连");
|
||||||
|
// });
|
||||||
});
|
});
|
||||||
|
|
||||||
changed_to
|
changed_to
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_connection_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
|
fn draw_connection_row(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
config: &mut ConfigPanelState,
|
||||||
|
connection: &ConnectionManager,
|
||||||
|
conn_state: ConnectionState,
|
||||||
|
) {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.add(
|
ui.add(
|
||||||
egui::Image::new(egui::include_image!("../static/cpu.png"))
|
egui::Image::new(egui::include_image!("../static/cpu.png"))
|
||||||
@@ -360,48 +384,75 @@ fn draw_connection_row(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
|
|||||||
ui.add_space(METRICS.item_gap);
|
ui.add_space(METRICS.item_gap);
|
||||||
|
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.label(style::value_text(format!("端口 {}", config.port)));
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(style::field_label("端口"));
|
||||||
|
egui::ComboBox::from_id_salt("config_ports")
|
||||||
|
.width(126.0)
|
||||||
|
.selected_text(if config.port.is_empty() {
|
||||||
|
"无可用串口".to_owned()
|
||||||
|
} else {
|
||||||
|
config.port.clone()
|
||||||
|
})
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for port in &config.available_ports {
|
||||||
|
ui.selectable_value(&mut config.port, port.clone(), port.as_str());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.add(style::icon_button(
|
||||||
|
egui::RichText::new("⟳").color(ONE_DARK_PRO.text).size(16.0),
|
||||||
|
METRICS.icon_button,
|
||||||
|
))
|
||||||
|
.on_hover_text("刷新串口")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
refresh_config_ports(config);
|
||||||
|
}
|
||||||
|
});
|
||||||
ui.label(style::value_text(format!("波特率 {}", config.baud_rate)));
|
ui.label(style::value_text(format!("波特率 {}", config.baud_rate)));
|
||||||
let status = if config.connected {
|
ui.colored_label(
|
||||||
"已连接"
|
connection_status_color(conn_state),
|
||||||
} else {
|
connection_status_text(conn_state),
|
||||||
"未连接"
|
);
|
||||||
};
|
|
||||||
let status_color = if config.connected {
|
|
||||||
style::status_color_ok()
|
|
||||||
} else {
|
|
||||||
style::status_color_error()
|
|
||||||
};
|
|
||||||
ui.colored_label(status_color, status);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.add_space(22.0);
|
ui.add_space(22.0);
|
||||||
|
|
||||||
let button_text = if config.connected { "断开" } else { "连接" };
|
let is_connected = matches!(
|
||||||
|
conn_state,
|
||||||
|
ConnectionState::Connected | ConnectionState::Streaming
|
||||||
|
);
|
||||||
|
let button_text = if is_connected { "断开" } else { "连接" };
|
||||||
if ui
|
if ui
|
||||||
.add(if config.connected {
|
.add(if is_connected {
|
||||||
style::danger_button(button_text)
|
style::danger_button(button_text)
|
||||||
} else {
|
} else {
|
||||||
style::primary_button(button_text)
|
style::primary_button(button_text)
|
||||||
})
|
})
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
config.connected = !config.connected;
|
if is_connected {
|
||||||
|
connection.disconnect();
|
||||||
|
} else if !config.port.is_empty() {
|
||||||
|
connection.connect(&config.port, 12, 7);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(18.0);
|
// Legacy reconnect/link-protection indicator:
|
||||||
ui.colored_label(
|
// ui.add_space(18.0);
|
||||||
if config.auto_reconnect {
|
// ui.colored_label(
|
||||||
style::status_color_ok()
|
// if config.auto_reconnect {
|
||||||
} else {
|
// style::status_color_ok()
|
||||||
ONE_DARK_PRO.text_dim
|
// } else {
|
||||||
},
|
// ONE_DARK_PRO.text_dim
|
||||||
if config.auto_reconnect {
|
// },
|
||||||
"链路保护 开"
|
// if config.auto_reconnect {
|
||||||
} else {
|
// "链路保护 开"
|
||||||
"链路保护 关"
|
// } else {
|
||||||
},
|
// "链路保护 关"
|
||||||
);
|
// },
|
||||||
|
// );
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,41 +504,41 @@ fn draw_serial_grid(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_mode_body(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
|
fn draw_mode_body(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
config: &mut ConfigPanelState,
|
||||||
|
conn_state: ConnectionState,
|
||||||
|
stats: SerialIoStats,
|
||||||
|
) {
|
||||||
group_frame().show(ui, |ui| match config.mode {
|
group_frame().show(ui, |ui| match config.mode {
|
||||||
SerialMode::Finger => {
|
SerialMode::Finger => {
|
||||||
ui.horizontal(|ui| {
|
// Legacy module-address controls for older devices:
|
||||||
ui.label(style::field_label("模块地址"));
|
// ui.horizontal(|ui| {
|
||||||
ui.add_sized(
|
// ui.label(style::field_label("模块地址"));
|
||||||
egui::vec2(80.0, METRICS.field_height),
|
// ui.add_sized(
|
||||||
egui::DragValue::new(&mut config.module_addr).range(1..=247),
|
// egui::vec2(80.0, METRICS.field_height),
|
||||||
);
|
// egui::DragValue::new(&mut config.module_addr).range(1..=247),
|
||||||
ui.add_space(16.0);
|
// );
|
||||||
if ui.add(tag_button("读取信息")).clicked() {}
|
// ui.add_space(16.0);
|
||||||
if ui.add(tag_button("探测")).clicked() {}
|
// if ui.add(tag_button("读取信息")).clicked() {}
|
||||||
});
|
// if ui.add(tag_button("探测")).clicked() {}
|
||||||
ui.separator();
|
// });
|
||||||
ui.horizontal(|ui| {
|
draw_status_bytes_row(ui, conn_state, stats);
|
||||||
ui.colored_label(dim_text(), "状态");
|
|
||||||
ui.label("就绪");
|
|
||||||
ui.colored_label(dim_text(), "接收");
|
|
||||||
ui.label("0 字节");
|
|
||||||
ui.colored_label(dim_text(), "发送");
|
|
||||||
ui.label("0 字节");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
SerialMode::Hand => {
|
SerialMode::Hand => {
|
||||||
ui.horizontal(|ui| {
|
// Legacy manual-TX controls for older hand-module debugging:
|
||||||
ui.label(style::field_label("发送"));
|
// ui.horizontal(|ui| {
|
||||||
ui.add_sized(
|
// ui.label(style::field_label("发送"));
|
||||||
egui::vec2(300.0, METRICS.field_height),
|
// ui.add_sized(
|
||||||
egui::TextEdit::singleline(&mut config.manual_tx),
|
// egui::vec2(300.0, METRICS.field_height),
|
||||||
);
|
// egui::TextEdit::singleline(&mut config.manual_tx),
|
||||||
if ui.add(tag_button("发送")).clicked() {}
|
// );
|
||||||
if ui.add(tag_button("清空")).clicked() {
|
// if ui.add(tag_button("发送")).clicked() {}
|
||||||
config.manual_tx.clear();
|
// if ui.add(tag_button("清空")).clicked() {
|
||||||
}
|
// config.manual_tx.clear();
|
||||||
});
|
// }
|
||||||
|
// });
|
||||||
|
draw_status_bytes_row(ui, conn_state, stats);
|
||||||
} // SerialMode::Model => {
|
} // SerialMode::Model => {
|
||||||
// ui.horizontal(|ui| {
|
// ui.horizontal(|ui| {
|
||||||
// ui.label(style::field_label("模型"));
|
// ui.label(style::field_label("模型"));
|
||||||
@@ -502,6 +553,45 @@ fn draw_mode_body(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draw_status_bytes_row(ui: &mut egui::Ui, conn_state: ConnectionState, stats: SerialIoStats) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.colored_label(dim_text(), "状态");
|
||||||
|
ui.label(connection_status_text(conn_state));
|
||||||
|
ui.colored_label(dim_text(), "接收");
|
||||||
|
ui.label(format!("{} 字节", stats.rx_bytes));
|
||||||
|
ui.colored_label(dim_text(), "发送");
|
||||||
|
ui.label(format!("{} 字节", stats.tx_bytes));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_config_ports(config: &mut ConfigPanelState) {
|
||||||
|
if let Ok(ports) = serial_enum() {
|
||||||
|
if !ports.contains(&config.port) {
|
||||||
|
config.port = ports.first().cloned().unwrap_or_default();
|
||||||
|
}
|
||||||
|
config.available_ports = ports;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connection_status_text(state: ConnectionState) -> &'static str {
|
||||||
|
match state {
|
||||||
|
ConnectionState::Disconnected => "未连接",
|
||||||
|
ConnectionState::Connecting => "连接中...",
|
||||||
|
ConnectionState::Connected => "已连接",
|
||||||
|
ConnectionState::Streaming => "采集中",
|
||||||
|
ConnectionState::Error => "连接错误",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn connection_status_color(state: ConnectionState) -> egui::Color32 {
|
||||||
|
match state {
|
||||||
|
ConnectionState::Disconnected => style::status_color_error(),
|
||||||
|
ConnectionState::Connecting => style::status_color_warn(),
|
||||||
|
ConnectionState::Connected | ConnectionState::Streaming => style::status_color_ok(),
|
||||||
|
ConnectionState::Error => style::status_color_error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn icon_button<'a>(
|
pub fn icon_button<'a>(
|
||||||
ui: &mut egui::Ui,
|
ui: &mut egui::Ui,
|
||||||
icon: IconButtonIcon<'a>,
|
icon: IconButtonIcon<'a>,
|
||||||
@@ -578,21 +668,236 @@ fn parity_combo(ui: &mut egui::Ui, config: &mut ConfigPanelState) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw_stats_panel(ctx: &egui::Context, panel: &mut FloatingPanelState) {
|
pub fn draw_stats_panel(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
panel: &mut FloatingPanelState,
|
||||||
|
force_history: &[f32],
|
||||||
|
) {
|
||||||
draw_floating_panel(ctx, panel, "统计", "stats_panel", |ui| {
|
draw_floating_panel(ctx, panel, "统计", "stats_panel", |ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.set_min_width(320.0);
|
||||||
ui.colored_label(accent_text(), "0.030");
|
draw_resultant_force_chart(ui, force_history);
|
||||||
ui.label(style::subtle_text("81m:51s"));
|
|
||||||
});
|
|
||||||
ui.separator();
|
|
||||||
group_frame().show(ui, |ui| {
|
|
||||||
ui.label(style::field_label("帧率 / GPU 信息"));
|
|
||||||
ui.label(style::value_text("边界 589.0us"));
|
|
||||||
ui.label(style::value_text("簇 12.8ms"));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORCE_CHART_MAX_N: f32 = 25.6;
|
||||||
|
|
||||||
|
fn draw_resultant_force_chart(ui: &mut egui::Ui, values: &[f32]) {
|
||||||
|
let latest = values.last().copied().unwrap_or(0.0);
|
||||||
|
let max = values.iter().copied().fold(0.0_f32, f32::max);
|
||||||
|
let active_values = values.iter().copied().filter(|value| *value > 0.0);
|
||||||
|
let min = active_values.fold(None, |best: Option<f32>, value| {
|
||||||
|
Some(best.map_or(value, |current| current.min(value)))
|
||||||
|
});
|
||||||
|
|
||||||
|
group_frame().show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.colored_label(ACCENT_BLUE, "RF");
|
||||||
|
ui.label(style::panel_title("合力"));
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
ui.colored_label(ACCENT_GREEN, format!("{latest:.1} N"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
force_metric(ui, "峰值", max);
|
||||||
|
ui.separator();
|
||||||
|
force_metric(ui, "低值", min.unwrap_or(0.0));
|
||||||
|
ui.separator();
|
||||||
|
ui.colored_label(dim_text(), format!("{} 点", values.len()));
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(6.0);
|
||||||
|
paint_resultant_force_chart(ui, values);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn force_metric(ui: &mut egui::Ui, label: &'static str, value: f32) {
|
||||||
|
ui.colored_label(dim_text(), label);
|
||||||
|
ui.label(style::value_text(format!("{value:.1} N")));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_resultant_force_chart(ui: &mut egui::Ui, values: &[f32]) {
|
||||||
|
const CHART_HEIGHT: f32 = 138.0;
|
||||||
|
const CHART_POINTS: usize = 42;
|
||||||
|
|
||||||
|
let width = ui.available_width().max(280.0);
|
||||||
|
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, CHART_HEIGHT), egui::Sense::hover());
|
||||||
|
let painter = ui.painter_at(rect);
|
||||||
|
let radius = egui::CornerRadius::same(5);
|
||||||
|
|
||||||
|
painter.rect_filled(rect, radius, egui::Color32::from_rgb(10, 18, 22));
|
||||||
|
painter.rect_stroke(
|
||||||
|
rect,
|
||||||
|
radius,
|
||||||
|
egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.accent, 95)),
|
||||||
|
egui::StrokeKind::Outside,
|
||||||
|
);
|
||||||
|
|
||||||
|
let chart_rect = egui::Rect::from_min_max(
|
||||||
|
rect.left_top() + egui::vec2(34.0, 12.0),
|
||||||
|
rect.right_bottom() - egui::vec2(10.0, 24.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
paint_force_grid(&painter, rect, chart_rect);
|
||||||
|
|
||||||
|
if values.is_empty() {
|
||||||
|
painter.text(
|
||||||
|
chart_rect.center(),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
"等待合力数据",
|
||||||
|
egui::FontId::proportional(12.0),
|
||||||
|
ONE_DARK_PRO.text_subtle,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.ctx().request_repaint();
|
||||||
|
|
||||||
|
let time = ui.ctx().input(|input| input.time) as f32;
|
||||||
|
let enter = ui
|
||||||
|
.ctx()
|
||||||
|
.animate_bool(egui::Id::new("resultant_force_chart_enter"), true);
|
||||||
|
let slide_offset = (1.0 - enter) * -chart_rect.width() * 0.65;
|
||||||
|
let plot_rect = chart_rect.translate(egui::vec2(slide_offset, 0.0));
|
||||||
|
|
||||||
|
let mut points = Vec::with_capacity(values.len());
|
||||||
|
let start_slot = CHART_POINTS.saturating_sub(values.len());
|
||||||
|
for (index, value) in values.iter().enumerate() {
|
||||||
|
let denom = (CHART_POINTS - 1).max(1) as f32;
|
||||||
|
let slot = start_slot + index;
|
||||||
|
let x = plot_rect.left() + (slot as f32 / denom) * plot_rect.width();
|
||||||
|
let normalized = (*value / FORCE_CHART_MAX_N).clamp(0.0, 1.0);
|
||||||
|
let y = plot_rect.bottom() - normalized * plot_rect.height();
|
||||||
|
points.push(egui::pos2(x, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
let plot_painter = painter.with_clip_rect(chart_rect.expand2(egui::vec2(1.0, 1.0)));
|
||||||
|
paint_force_sweep(&plot_painter, chart_rect, time);
|
||||||
|
paint_force_area(&plot_painter, &points, plot_rect);
|
||||||
|
|
||||||
|
if points.len() >= 2 {
|
||||||
|
plot_painter.add(egui::Shape::line(
|
||||||
|
points.clone(),
|
||||||
|
egui::Stroke::new(4.0, color_alpha(ONE_DARK_PRO.accent, 45)),
|
||||||
|
));
|
||||||
|
plot_painter.add(egui::Shape::line(
|
||||||
|
points.clone(),
|
||||||
|
egui::Stroke::new(1.7, ONE_DARK_PRO.accent),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(last) = points.last().copied() {
|
||||||
|
let pulse = (time * 1.8).fract();
|
||||||
|
plot_painter.circle_stroke(
|
||||||
|
last,
|
||||||
|
5.0 + pulse * 12.0,
|
||||||
|
egui::Stroke::new(
|
||||||
|
1.0,
|
||||||
|
color_alpha(ACCENT_GREEN, ((1.0 - pulse) * 120.0) as u8),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
plot_painter.circle_filled(last, 3.6, ACCENT_GREEN);
|
||||||
|
plot_painter.circle_filled(last, 1.6, egui::Color32::WHITE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_force_grid(painter: &egui::Painter, rect: egui::Rect, chart_rect: egui::Rect) {
|
||||||
|
for tick in [25.0, 20.0, 15.0, 10.0, 5.0, 0.0] {
|
||||||
|
let normalized = (tick / FORCE_CHART_MAX_N).clamp(0.0, 1.0);
|
||||||
|
let y = chart_rect.bottom() - normalized * chart_rect.height();
|
||||||
|
painter.line_segment(
|
||||||
|
[
|
||||||
|
egui::pos2(chart_rect.left(), y),
|
||||||
|
egui::pos2(chart_rect.right(), y),
|
||||||
|
],
|
||||||
|
egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.border, 78)),
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
egui::pos2(rect.left() + 8.0, y),
|
||||||
|
egui::Align2::LEFT_CENTER,
|
||||||
|
format!("{tick:.0}"),
|
||||||
|
egui::FontId::monospace(9.0),
|
||||||
|
ONE_DARK_PRO.text_dim,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for index in 0..=5 {
|
||||||
|
let x = chart_rect.left() + index as f32 / 5.0 * chart_rect.width();
|
||||||
|
painter.line_segment(
|
||||||
|
[
|
||||||
|
egui::pos2(x, chart_rect.top()),
|
||||||
|
egui::pos2(x, chart_rect.bottom()),
|
||||||
|
],
|
||||||
|
egui::Stroke::new(1.0, color_alpha(ONE_DARK_PRO.border_soft, 42)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
painter.text(
|
||||||
|
egui::pos2(chart_rect.left(), rect.bottom() - 11.0),
|
||||||
|
egui::Align2::LEFT_CENTER,
|
||||||
|
"-4.2s",
|
||||||
|
egui::FontId::monospace(9.0),
|
||||||
|
ONE_DARK_PRO.text_subtle,
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
egui::pos2(chart_rect.right(), rect.bottom() - 11.0),
|
||||||
|
egui::Align2::RIGHT_CENTER,
|
||||||
|
"now",
|
||||||
|
egui::FontId::monospace(9.0),
|
||||||
|
ONE_DARK_PRO.text_subtle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_force_sweep(painter: &egui::Painter, chart_rect: egui::Rect, time: f32) {
|
||||||
|
let sweep = (time * 0.55).fract();
|
||||||
|
let x = chart_rect.left() + sweep * chart_rect.width();
|
||||||
|
let sweep_rect = egui::Rect::from_min_max(
|
||||||
|
egui::pos2(x - 8.0, chart_rect.top()),
|
||||||
|
egui::pos2(x + 8.0, chart_rect.bottom()),
|
||||||
|
);
|
||||||
|
|
||||||
|
painter.rect_filled(
|
||||||
|
sweep_rect,
|
||||||
|
egui::CornerRadius::ZERO,
|
||||||
|
color_alpha(ONE_DARK_PRO.accent, 20),
|
||||||
|
);
|
||||||
|
painter.line_segment(
|
||||||
|
[
|
||||||
|
egui::pos2(x, chart_rect.top()),
|
||||||
|
egui::pos2(x, chart_rect.bottom()),
|
||||||
|
],
|
||||||
|
egui::Stroke::new(1.0, color_alpha(ACCENT_GREEN, 95)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint_force_area(painter: &egui::Painter, points: &[egui::Pos2], plot_rect: egui::Rect) {
|
||||||
|
if points.len() < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for pair in points.windows(2) {
|
||||||
|
let left = pair[0];
|
||||||
|
let right = pair[1];
|
||||||
|
let area = vec![
|
||||||
|
egui::pos2(left.x, plot_rect.bottom()),
|
||||||
|
left,
|
||||||
|
right,
|
||||||
|
egui::pos2(right.x, plot_rect.bottom()),
|
||||||
|
];
|
||||||
|
|
||||||
|
painter.add(egui::Shape::convex_polygon(
|
||||||
|
area,
|
||||||
|
color_alpha(ONE_DARK_PRO.accent, 32),
|
||||||
|
egui::Stroke::NONE,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn color_alpha(color: egui::Color32, alpha: u8) -> egui::Color32 {
|
||||||
|
egui::Color32::from_rgba_premultiplied(color.r(), color.g(), color.b(), alpha)
|
||||||
|
}
|
||||||
|
|
||||||
fn draw_floating_panel(
|
fn draw_floating_panel(
|
||||||
ctx: &egui::Context,
|
ctx: &egui::Context,
|
||||||
panel: &mut FloatingPanelState,
|
panel: &mut FloatingPanelState,
|
||||||
|
|||||||
@@ -508,6 +508,100 @@ fn fs_hand_dot(in: DotVertexOutput) -> @location(0) vec4f {
|
|||||||
return output_color(color, max(core, halo));
|
return output_color(color, max(core, halo));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn chip_pixel_alpha(local: vec2f, half_size: f32, softness: f32) -> f32 {
|
||||||
|
let q = abs(local) - vec2f(half_size, half_size);
|
||||||
|
let dist = length(max(q, vec2f(0.0, 0.0))) + min(max(q.x, q.y), 0.0);
|
||||||
|
return 1.0 - smoothstep(0.0, softness, dist);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HandPalmChipVertexOutput {
|
||||||
|
@builtin(position) clip_position: vec4f,
|
||||||
|
@location(0) local: vec2f,
|
||||||
|
@location(1) grid: vec2f,
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_hand_palm_chip(vertex: DotVertexInput, instance: DotInstanceInput) -> HandPalmChipVertexOutput {
|
||||||
|
let center_px = instance.world_position.xy * u.image.xy;
|
||||||
|
let size_px = instance.style.yz;
|
||||||
|
let angle = instance.style.x;
|
||||||
|
let packed_shape = instance.style.w;
|
||||||
|
let shape_rows = floor(packed_shape / 100.0);
|
||||||
|
let shape_cols = max(packed_shape - shape_rows * 100.0, 1.0);
|
||||||
|
let local_px = vertex.local * size_px * 0.5;
|
||||||
|
let image_uv = (center_px + rotate_2d(local_px, angle)) / max(u.image.xy, vec2f(1.0, 1.0));
|
||||||
|
|
||||||
|
var out: HandPalmChipVertexOutput;
|
||||||
|
out.clip_position = vec4f(hand_image_uv_to_clip(image_uv), 0.0, 1.0);
|
||||||
|
out.local = vertex.local;
|
||||||
|
out.grid = vec2f(shape_cols, max(shape_rows, 1.0));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_hand_palm_chip(in: HandPalmChipVertexOutput) -> @location(0) vec4f {
|
||||||
|
// Dark rounded tile: this is the inset chip body sitting inside the palm surface.
|
||||||
|
let panel = rounded_rect_alpha(in.local, 0.10, 0.040);
|
||||||
|
let inset = rounded_rect_alpha(in.local * vec2f(1.10, 1.08), 0.08, 0.052);
|
||||||
|
let rim = clamp(panel - inset * 0.72, 0.0, 1.0);
|
||||||
|
|
||||||
|
// Inactive chip pixels use the chip's real hand layout:
|
||||||
|
// horizontal 14 columns x 5 rows, or vertical 4 columns x 11 rows.
|
||||||
|
let uv = clamp(in.local * 0.5 + vec2f(0.5, 0.5), vec2f(0.0, 0.0), vec2f(1.0, 1.0));
|
||||||
|
let cell = abs(fract(uv * in.grid) - vec2f(0.5, 0.5));
|
||||||
|
let micro_pixel = 1.0 - smoothstep(0.105, 0.178, length(cell * vec2f(1.04, 0.94)));
|
||||||
|
|
||||||
|
let top_bevel = smoothstep(-0.96, -0.18, -in.local.y) * 0.16;
|
||||||
|
let lower_shadow = smoothstep(0.20, 0.92, in.local.y) * 0.22;
|
||||||
|
let side_bevel = smoothstep(0.58, 0.96, abs(in.local.x)) * 0.12;
|
||||||
|
let scan = (0.5 + 0.5 * sin((uv.y * 36.0 + uv.x * 7.0) * 6.28318)) * 0.026;
|
||||||
|
|
||||||
|
let base = vec3f(0.004, 0.012, 0.018);
|
||||||
|
let glass = vec3f(0.012, 0.048, 0.064);
|
||||||
|
let pixel_color = vec3f(0.075, 0.300, 0.360);
|
||||||
|
let rim_color = vec3f(0.060, 0.560, 0.670);
|
||||||
|
let color = base * (0.92 - lower_shadow)
|
||||||
|
+ glass * (0.52 + top_bevel + side_bevel + scan)
|
||||||
|
+ pixel_color * micro_pixel * 0.70
|
||||||
|
+ rim_color * rim * 0.60;
|
||||||
|
|
||||||
|
let alpha = panel * (0.64 + micro_pixel * 0.18 + rim * 0.20);
|
||||||
|
return output_color(color, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_hand_palm_dot(vertex: DotVertexInput, instance: DotInstanceInput) -> DotVertexOutput {
|
||||||
|
let intensity = saturate(instance.style.x);
|
||||||
|
let shaped = smoothstep(0.0, 1.0, intensity);
|
||||||
|
|
||||||
|
// Palm chip pixels are deliberately smaller than fingertip beads so they read as a chip matrix.
|
||||||
|
let center = hand_image_uv_to_clip(instance.world_position.xy);
|
||||||
|
let pixel_size = u.glyph.x * mix(0.13, 0.25, shaped);
|
||||||
|
let ndc_offset = vertex.local * vec2f(pixel_size / u.viewport.x, pixel_size / u.viewport.y) * 2.0;
|
||||||
|
|
||||||
|
var out: DotVertexOutput;
|
||||||
|
out.clip_position = vec4f(center + ndc_offset, 0.0, 1.0);
|
||||||
|
out.local = vertex.local;
|
||||||
|
out.intensity = intensity;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_hand_palm_dot(in: DotVertexOutput) -> @location(0) vec4f {
|
||||||
|
let intensity = saturate(in.intensity);
|
||||||
|
let pixel = chip_pixel_alpha(in.local, 0.52, 0.070);
|
||||||
|
let glow = circle_alpha(in.local, 0.95, 0.22) * intensity * 0.30;
|
||||||
|
|
||||||
|
let cold = vec3f(0.070, 0.340, 0.360);
|
||||||
|
let active_color = mix(vec3f(0.110, 0.620, 0.420), vec3f(0.460, 1.000, 0.210), smoothstep(0.08, 1.0, intensity));
|
||||||
|
let color = mix(cold, active_color, smoothstep(0.02, 0.72, intensity))
|
||||||
|
* (0.54 + intensity * 1.10)
|
||||||
|
+ vec3f(0.28, 1.0, 0.36) * glow * 0.90;
|
||||||
|
|
||||||
|
let alpha = max(pixel * (0.20 + intensity * 0.76), glow);
|
||||||
|
return output_color(color, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
@vertex
|
@vertex
|
||||||
fn vs_dot(vertex: DotVertexInput, instance: DotInstanceInput) -> DotVertexOutput {
|
fn vs_dot(vertex: DotVertexInput, instance: DotInstanceInput) -> DotVertexOutput {
|
||||||
let center = u.view_proj * vec4f(instance.world_position.xyz, 1.0);
|
let center = u.view_proj * vec4f(instance.world_position.xyz, 1.0);
|
||||||
|
|||||||
Reference in New Issue
Block a user