use bevy::math::bounding::{Aabb2d, Bounded2d, BoundingCircle, BoundingVolume, IntersectsVolume}; use bevy::prelude::*; use bevy::sprite::Anchor; use core::f32::consts::PI; 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 // that is to define a fixed non-resizable window. // // 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, max_width: WORLD_SIZE.x, max_height: WORLD_SIZE.y, }, ..default() }), ..default() })) .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, mut meshes: ResMut>, mut materials: ResMut>, ) { commands.spawn(( Camera2d, 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); } #[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