From 9910fd5799f54ef52ce3f7ec3942a3fdeaab8231 Mon Sep 17 00:00:00 2001 From: Jeremy Kaplan Date: Tue, 11 Aug 2026 13:39:58 -0400 Subject: [PATCH] Breakout --- Cargo.toml | 2 +- build/index.html | 29 ++- src/debug.rs | 49 +++++ src/main.rs | 512 +++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 572 insertions(+), 20 deletions(-) create mode 100644 src/debug.rs diff --git a/Cargo.toml b/Cargo.toml index 69b4115..e08b822 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] -bevy = "0.19.0" +bevy = { version = "0.19.0", features = ["debug_glam_assert"] } chacha20 = { version = "0.10.1", default-features = false, features = ["rng"] } getrandom = "0.4.3" rand = "0.10.2" diff --git a/build/index.html b/build/index.html index 3247b71..6367d7a 100644 --- a/build/index.html +++ b/build/index.html @@ -8,16 +8,33 @@ display: flex; align-items: center; justify-content: center; - background-color: #aaaaaa; + background-color: #222222; + color: white; } canvas { background-color: black; } + .mono { + font-family: monospace; + } - Pong + Breakout - + +
+ +

+ Use + + + to move and + Space + to serve. +

+
+ + diff --git a/src/debug.rs b/src/debug.rs new file mode 100644 index 0000000..0635da9 --- /dev/null +++ b/src/debug.rs @@ -0,0 +1,49 @@ +use bevy::prelude::*; + +use crate::{Brick, GameState, WORLD_SIZE}; + +pub(crate) struct DebugPlugin; + +impl Plugin for DebugPlugin { + fn build(&self, app: &mut App) { + if !cfg!(feature = "debug") { + return; + } + + app.add_systems( + Update, + ( + draw_world_bounds, + log_state_transitions.run_if(state_changed::), + cheat_codes, + ), + ); + } +} + +fn draw_world_bounds(mut gizmos: Gizmos) { + gizmos.rect_2d( + Isometry2d::from_translation(WORLD_SIZE / 2.), + WORLD_SIZE, + LinearRgba::RED, + ); +} + +fn log_state_transitions(state: Res>) { + info!({ ?state }, "State changed"); +} + +fn cheat_codes( + mut commands: Commands, + input: Res>, + bricks: Query>, +) { + if input.pressed(KeyCode::Digit1) { + let mut iter = bricks.iter(); + _ = iter.next(); + + for entity in iter { + commands.entity(entity).despawn(); + } + } +} diff --git a/src/main.rs b/src/main.rs index 0f86c27..db7967e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,44 @@ -use bevy::camera::ScalingMode; +use bevy::math::bounding::{Aabb2d, Bounded2d, BoundingCircle, BoundingVolume, IntersectsVolume}; use bevy::prelude::*; +use bevy::sprite::Anchor; +use core::f32::consts::PI; -pub const WORLD_SIZE: Vec2 = Vec2::new(480., 640.); +use crate::debug::DebugPlugin; + +mod debug; + +const WORLD_SIZE: Vec2 = Vec2::new(480., 640.); + +#[derive(States, Debug, Copy, Clone, PartialEq, Eq, Hash, Default)] +enum GameState { + #[default] + Build, + Serve, + Play, + Over, +} + +#[derive(Resource, Debug, Copy, Clone, PartialEq, Eq, Default)] +struct Level(u8); + +#[derive(Resource, Debug, Copy, Clone, PartialEq, Eq, Deref)] +struct Lives(u8); + +impl Default for Lives { + fn default() -> Self { + Self(3) + } +} + +#[derive(Resource, Debug, Copy, Clone, PartialEq, Eq, Default, Deref)] +struct Score(u64); fn main() { App::new() .insert_resource(ClearColor(Color::BLACK)) + .init_resource::() + .init_resource::() + .init_resource::() .add_plugins(DefaultPlugins.set(WindowPlugin { // Until there's a reliable way to hint that a game window should // float on tiling window managers, the most reliable way to achieve @@ -13,6 +46,7 @@ fn main() { // // https://github.com/rust-windowing/winit/issues/862#issuecomment-2047791401 primary_window: Some(Window { + canvas: Some("#breakout".into()), resize_constraints: WindowResizeConstraints { min_width: WORLD_SIZE.x, min_height: WORLD_SIZE.y, @@ -23,23 +57,475 @@ fn main() { }), ..default() })) - .add_systems(Startup, (setup, greet).chain()) + .add_plugins(DebugPlugin) + .init_state::() + .add_systems(Startup, setup) + .add_systems( + FixedUpdate, + ( + (collisions, cleared_level, void_out, move_ball) + .chain() + .run_if(in_state(GameState::Play)), + move_paddle, + show_score, + show_lives.run_if(resource_changed::), + serve_ball.run_if(in_state(GameState::Serve)), + ) + .chain(), + ) + .add_systems(OnEnter(GameState::Build), build_level) + .add_systems(OnEnter(GameState::Over), reset_game) .run(); } -fn setup(mut commands: Commands) { +fn setup( + mut commands: Commands, + mut meshes: ResMut>, + mut materials: ResMut>, +) { commands.spawn(( Camera2d, - Projection::Orthographic(OrthographicProjection { - scaling_mode: ScalingMode::Fixed { - width: WORLD_SIZE.x, - height: WORLD_SIZE.y, - }, - ..OrthographicProjection::default_2d() - }), + Transform::from_translation((WORLD_SIZE / 2.).extend(0.)), )); + + commands.spawn(Ball::build(&mut meshes, &mut materials)); + + commands.spawn(Wall::top()); + commands.spawn(Wall::left()); + commands.spawn(Wall::right()); + // There's no bottom wall because that's a void plane instead. + commands.spawn(( + VoidPlane(Plane2d::new(Vec2::Y)), + Transform::from_translation(Vec3::ZERO), + )); + commands.add_observer(on_void_out); + + commands.spawn(( + Paddle, + Sprite::from_color(Paddle::COLOR, Vec2::ONE), + Transform { + translation: Vec3::new(WORLD_SIZE.x / 2., Paddle::SIZE.y / 2. + Paddle::GAP, 0.), + scale: Paddle::SIZE.extend(1.), + ..default() + }, + Collider::Aabb(Aabb2d::new(Vec2::ZERO, Paddle::SIZE / 2.)), + )); + + commands.spawn(( + ScoreText, + Text2d::default(), + TextLayout::justify(Justify::Right), + Anchor::BOTTOM_RIGHT, + Transform::from_translation(Vec3::new(WORLD_SIZE.x - 8., WORLD_SIZE.y - HUD_HEIGHT, 1.)), + )); + + commands.add_observer(start_game); } -fn greet() { - println!("Hello, world!"); +#[derive(Component, Debug)] +struct Ball; + +impl Ball { + const COLOR: Color = Color::LinearRgba(LinearRgba::GREEN); + const RADIUS: f32 = 8.; + const SPAWN_POSITION: Vec2 = Vec2::new(WORLD_SIZE.x / 2., 40.); + const SERVE_SPEED: f32 = 200.; + const MAX_SPEED: f32 = 900.; + + fn build( + meshes: &mut ResMut>, + materials: &mut ResMut>, + ) -> impl Bundle { + ( + Ball, + Mesh2d(meshes.add(Circle::new(Ball::RADIUS))), + MeshMaterial2d(materials.add(Ball::COLOR)), + Transform::from_translation(Ball::SPAWN_POSITION.extend(0.)), + Velocity(Vec2::ZERO), + ) + } +} + +#[derive(Component, Debug, Copy, Clone, PartialEq, Deref, DerefMut)] +struct Velocity(Vec2); + +#[derive(Component, Debug)] +struct Wall; + +impl Wall { + const COLOR: Color = Color::LinearRgba(LinearRgba::rgb(0.5, 0.5, 0.5)); + const THICKNESS: f32 = 4.; + + const LENGTH_VERTICAL: f32 = WORLD_SIZE.y - HUD_HEIGHT; + + fn top() -> impl Bundle { + ( + Wall, + Sprite::from_color(Wall::COLOR, Vec2::ONE), + Transform { + translation: Vec3::new( + WORLD_SIZE.x / 2., + WORLD_SIZE.y - Wall::THICKNESS / 2. - HUD_HEIGHT, + 0., + ), + scale: Vec3::new(WORLD_SIZE.x, Wall::THICKNESS, 1.), + ..default() + }, + Collider::Plane(Plane2d::new(Vec2::NEG_Y)), + ) + } + + fn left() -> impl Bundle { + let x = Wall::THICKNESS / 2.; + ( + Wall, + Sprite::from_color(Wall::COLOR, Vec2::ONE), + Transform { + translation: Vec3::new(x, Self::LENGTH_VERTICAL / 2., 0.), + scale: Vec3::new(Wall::THICKNESS, Self::LENGTH_VERTICAL, 1.), + ..default() + }, + Collider::Plane(Plane2d::new(Vec2::X)), + ) + } + + fn right() -> impl Bundle { + let x = WORLD_SIZE.x - Wall::THICKNESS / 2.; + ( + Wall, + Sprite::from_color(Wall::COLOR, Vec2::ONE), + Transform { + translation: Vec3::new(x, Self::LENGTH_VERTICAL / 2., 0.), + scale: Vec3::new(Wall::THICKNESS, Self::LENGTH_VERTICAL, 1.), + ..default() + }, + Collider::Plane(Plane2d::new(Vec2::NEG_X)), + ) + } +} + +#[derive(Component, Debug)] +struct Paddle; + +impl Paddle { + const COLOR: Color = Color::LinearRgba(LinearRgba::rgb(0., 1., 1.)); + const SIZE: Vec2 = Vec2::new(60., 10.); + const GAP: f32 = 4.; + const SPEED: f32 = WORLD_SIZE.x / 2.; +} + +#[derive(Component, Debug)] +enum Collider { + Aabb(Aabb2d), + Plane(Plane2d), +} + +fn collisions( + mut commands: Commands, + ball: Single<(&Transform, &mut Velocity), With>, + colliders: Query< + ( + Entity, + &Transform, + &Collider, + Option<&Brick>, + Option<&Paddle>, + ), + Without, + >, + mut score: ResMut, +) { + let (transform, mut velocity) = ball.into_inner(); + let circle = BoundingCircle::new(transform.translation.truncate(), Ball::RADIUS); + + for (entity, transform, collider, brick, paddle) in colliders { + let tx = transform.translation.truncate(); + + let target = match collider { + Collider::Aabb(aabb) => aabb.translated_by(tx), + Collider::Plane(plane) => plane.aabb_2d(tx), + }; + if !target.intersects(&circle) { + continue; + } + + if brick.is_some() { + commands.entity(entity).despawn(); + score.0 += Brick::SCORE; + } + + let point = target.closest_point(circle.center); + let normal = match collider { + Collider::Plane(plane) => plane.normal.as_vec2(), + Collider::Aabb(_) => { + let dist = circle.center - point; + + if dist.x.abs() > dist.y.abs() { + if dist.x < 0. { Vec2::NEG_X } else { Vec2::X } + } else if dist.y < 0. { + Vec2::NEG_Y + } else { + Vec2::Y + } + } + }; + + // Only reflect if the current velocity has a component moving *against* + // the box face's normal vector. This allows the ball to exit the box + // without getting stuck "re-colliding" with it. + // + // Put another way, if the ball is already moving in the same direction + // that the plane would bounce it, that's not a real collision! + if velocity.dot(normal) >= 0. { + continue; + } + + let mut v = velocity.reflect(normal); + + if paddle.is_some() && normal == Vec2::Y { + let offset = (point.x - tx.x) / Paddle::SIZE.x; + let tilt = (0.).lerp(-PI / 4., offset); + + let angle = (v.to_angle() + tilt).clamp(PI / 6., 5. * PI / 6.); + let mag = v.length() * (1. + offset.abs() / 2.); + + v = Vec2::from_angle(angle).rotate(Vec2::X * mag); + } + + velocity.0 = v.clamp_length_max(Ball::MAX_SPEED); + } +} + +fn move_ball(time: Res