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, Won, 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, paddle_x: f32, ball_pos: egui::Pos2, ball_vel: egui::Vec2, score: u32, combo: u32, lives: u32, last_time: Option, 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, 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); }); 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.rebuild_bricks(); } fn start(&mut self) { if self.phase == BreakoutPhase::Over || self.phase == BreakoutPhase::Won { 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 matches!( self.phase, BreakoutPhase::Idle | BreakoutPhase::Over | BreakoutPhase::Won ) { 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 = 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.phase = BreakoutPhase::Won; self.ball_vel = egui::Vec2::ZERO; } } 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::Won => "过关", 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::Won || 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 top_gesture_rows = rows.min(1); let top_gesture_cols = (cols / 3).clamp(1, cols); let top_gesture_col_start = (cols - top_gesture_cols) / 2; let top_gesture_col_end = top_gesture_col_start + top_gesture_cols; 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 = avg( 0, top_gesture_rows, top_gesture_col_start, top_gesture_col_end, ); 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::Won => ACCENT_GREEN, 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::Won => ("恭喜过关", "按压顶部、点击或空格重新开始"), 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) }