531 lines
15 KiB
Rust
531 lines
15 KiB
Rust
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::<Level>()
|
|
.init_resource::<Score>()
|
|
.init_resource::<Lives>()
|
|
.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::<GameState>()
|
|
.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::<Lives>),
|
|
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<Assets<Mesh>>,
|
|
mut materials: ResMut<Assets<ColorMaterial>>,
|
|
) {
|
|
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<Assets<Mesh>>,
|
|
materials: &mut ResMut<Assets<ColorMaterial>>,
|
|
) -> 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<Ball>>,
|
|
colliders: Query<
|
|
(
|
|
Entity,
|
|
&Transform,
|
|
&Collider,
|
|
Option<&Brick>,
|
|
Option<&Paddle>,
|
|
),
|
|
Without<Ball>,
|
|
>,
|
|
mut score: ResMut<Score>,
|
|
) {
|
|
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<Time>, ball: Single<(&mut Transform, &Velocity), With<Ball>>) {
|
|
let (mut transform, velocity) = ball.into_inner();
|
|
transform.translation += velocity.extend(0.) * time.delta_secs();
|
|
}
|
|
|
|
fn move_paddle(
|
|
time: Res<Time>,
|
|
input: Res<ButtonInput<KeyCode>>,
|
|
mut paddle: Single<&mut Transform, With<Paddle>>,
|
|
) {
|
|
let mut v = Vec3::ZERO;
|
|
if input.pressed(KeyCode::ArrowLeft) {
|
|
v.x -= 1.;
|
|
}
|
|
if input.pressed(KeyCode::ArrowRight) {
|
|
v.x += 1.;
|
|
}
|
|
|
|
if v == Vec3::ZERO {
|
|
return;
|
|
}
|
|
|
|
paddle.translation += v.normalize() * Paddle::SPEED * time.delta_secs();
|
|
|
|
let w = Paddle::SIZE.x / 2.;
|
|
paddle.translation.x = paddle.translation.x.clamp(w, WORLD_SIZE.x - w);
|
|
}
|
|
|
|
fn serve_ball(
|
|
input: Res<ButtonInput<KeyCode>>,
|
|
paddle: Single<&Transform, (With<Paddle>, Without<Ball>)>,
|
|
ball: Single<(&mut Transform, &mut Velocity), With<Ball>>,
|
|
mut game_state: ResMut<NextState<GameState>>,
|
|
) {
|
|
let (mut transform, mut velocity) = ball.into_inner();
|
|
if input.pressed(KeyCode::Space) {
|
|
velocity.0 = Vec2::from_angle(-PI / 6.).rotate(Ball::SERVE_SPEED * Vec2::Y);
|
|
game_state.set(GameState::Play)
|
|
} else {
|
|
transform.translation =
|
|
paddle.translation + Vec3::ZERO.with_y(Paddle::SIZE.y + Ball::RADIUS);
|
|
}
|
|
}
|
|
|
|
#[derive(Event, Debug)]
|
|
struct VoidOut;
|
|
|
|
#[derive(Component, Debug, Deref, DerefMut)]
|
|
struct VoidPlane(Plane2d);
|
|
|
|
fn void_out(
|
|
mut commands: Commands,
|
|
ball: Single<&Transform, With<Ball>>,
|
|
planes: Query<(&VoidPlane, &Transform), Without<Ball>>,
|
|
) {
|
|
let circle = BoundingCircle::new(ball.translation.truncate(), Ball::RADIUS);
|
|
|
|
for (plane, transform) in planes {
|
|
let plane = plane.aabb_2d(transform.translation.truncate());
|
|
|
|
if plane.intersects(&circle) {
|
|
commands.trigger(VoidOut);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_void_out(
|
|
_: On<VoidOut>,
|
|
mut ball: Single<&mut Velocity, With<Ball>>,
|
|
mut game_state: ResMut<NextState<GameState>>,
|
|
mut lives: ResMut<Lives>,
|
|
) {
|
|
ball.0 = Vec2::ZERO;
|
|
lives.0 = lives.0.saturating_sub(1);
|
|
if lives.0 == 0 {
|
|
game_state.set(GameState::Over);
|
|
} else {
|
|
game_state.set(GameState::Serve)
|
|
}
|
|
}
|
|
|
|
#[derive(Component, Debug)]
|
|
struct Brick;
|
|
|
|
impl Brick {
|
|
const SCORE: u64 = 100;
|
|
const SIZE: Vec2 = Vec2::new(50., 20.);
|
|
const GAP: f32 = 10.;
|
|
const COLORS: [Color; 8] = [
|
|
Color::LinearRgba(LinearRgba::rgb(1., 0., 0.)), // red
|
|
Color::LinearRgba(LinearRgba::rgb(1., 0.5, 0.)), // orange
|
|
Color::LinearRgba(LinearRgba::rgb(1., 1., 0.)), // yellow
|
|
Color::LinearRgba(LinearRgba::rgb(0., 1., 0.)), // green
|
|
Color::LinearRgba(LinearRgba::rgb(0., 1., 1.)), // cyan
|
|
Color::LinearRgba(LinearRgba::rgb(0., 0., 1.)), // blue
|
|
Color::LinearRgba(LinearRgba::rgb(0.5, 0., 1.)), // purple
|
|
Color::LinearRgba(LinearRgba::rgb(1., 0., 1.)), // magenta
|
|
];
|
|
|
|
fn build(pos: Vec2, color: Color) -> impl Bundle {
|
|
(
|
|
Brick,
|
|
Sprite::from_color(color, Vec2::ONE),
|
|
Transform {
|
|
translation: pos.extend(0.),
|
|
scale: Brick::SIZE.extend(1.),
|
|
..default()
|
|
},
|
|
Collider::Aabb(Aabb2d::new(Vec2::ZERO, Brick::SIZE / 2.)),
|
|
)
|
|
}
|
|
}
|
|
|
|
fn cleared_level(
|
|
mut level: ResMut<Level>,
|
|
mut ball: Single<&mut Velocity, With<Ball>>,
|
|
mut game_state: ResMut<NextState<GameState>>,
|
|
bricks: Query<(), With<Brick>>,
|
|
) {
|
|
if bricks.is_empty() {
|
|
ball.0 = Vec2::ZERO;
|
|
level.0 += 1;
|
|
game_state.set(GameState::Build);
|
|
}
|
|
}
|
|
|
|
fn build_level(
|
|
mut commands: Commands,
|
|
mut game_state: ResMut<NextState<GameState>>,
|
|
level: Res<Level>,
|
|
) {
|
|
let rows = 3 + level.0;
|
|
|
|
let half_cols = ((WORLD_SIZE.x / 2. / (Brick::SIZE.x + Brick::GAP)) - 1.).floor() as i32;
|
|
|
|
let start = Vec2::new(
|
|
WORLD_SIZE.x / 2.,
|
|
WORLD_SIZE.y - HUD_HEIGHT - Wall::THICKNESS - 3. * Brick::GAP,
|
|
);
|
|
|
|
for r in 0..rows {
|
|
let color = Brick::COLORS[(r as usize) % Brick::COLORS.len()];
|
|
for c in -half_cols..=half_cols {
|
|
let x = start.x + (c as f32) * (Brick::SIZE.x + Brick::GAP);
|
|
let y = start.y - (r as f32) * (Brick::SIZE.y + Brick::GAP);
|
|
commands.spawn(Brick::build(Vec2::new(x, y), color));
|
|
}
|
|
}
|
|
|
|
game_state.set(GameState::Serve);
|
|
}
|
|
|
|
const HUD_HEIGHT: f32 = 24.;
|
|
|
|
#[derive(Component, Debug)]
|
|
struct ScoreText;
|
|
|
|
fn show_score(score: Res<Score>, mut text: Single<&mut Text2d, With<ScoreText>>) {
|
|
text.0 = score.to_string();
|
|
}
|
|
|
|
#[derive(Component, Debug)]
|
|
struct LivesDot(u8);
|
|
|
|
impl LivesDot {
|
|
const RADIUS: f32 = 5.;
|
|
|
|
fn build(
|
|
meshes: &mut ResMut<Assets<Mesh>>,
|
|
materials: &mut ResMut<Assets<ColorMaterial>>,
|
|
n: u8,
|
|
) -> impl Bundle {
|
|
let x = Self::RADIUS * (2. + 3. * n as f32);
|
|
let y = WORLD_SIZE.y - HUD_HEIGHT / 2.;
|
|
(
|
|
LivesDot(n),
|
|
Mesh2d(meshes.add(Circle::new(Self::RADIUS))),
|
|
MeshMaterial2d(materials.add(Ball::COLOR)),
|
|
Transform::from_translation(Vec3::new(x, y, 1.)),
|
|
)
|
|
}
|
|
}
|
|
|
|
fn show_lives(
|
|
mut commands: Commands,
|
|
mut meshes: ResMut<Assets<Mesh>>,
|
|
mut materials: ResMut<Assets<ColorMaterial>>,
|
|
lives: Res<Lives>,
|
|
dots: Query<(Entity, &LivesDot)>,
|
|
) {
|
|
let actual: u8 = dots.iter().len().try_into().expect("small number of lives");
|
|
let expected = **lives;
|
|
|
|
for (entity, dot) in dots {
|
|
let n = dot.0;
|
|
if n >= expected {
|
|
commands.entity(entity).despawn();
|
|
}
|
|
}
|
|
|
|
for n in actual..expected {
|
|
commands.spawn(LivesDot::build(&mut meshes, &mut materials, n));
|
|
}
|
|
}
|
|
|
|
fn reset_game(mut commands: Commands) {
|
|
let mut delayed = commands.delayed();
|
|
delayed.secs(3.).trigger(StartGame);
|
|
}
|
|
|
|
#[derive(Event, Debug)]
|
|
struct StartGame;
|
|
|
|
fn start_game(
|
|
_: On<StartGame>,
|
|
mut commands: Commands,
|
|
mut score: ResMut<Score>,
|
|
mut lives: ResMut<Lives>,
|
|
mut level: ResMut<Level>,
|
|
mut game_state: ResMut<NextState<GameState>>,
|
|
bricks: Query<Entity, With<Brick>>,
|
|
) {
|
|
*score = Score::default();
|
|
*lives = Lives::default();
|
|
*level = Level::default();
|
|
|
|
for brick in bricks {
|
|
commands.entity(brick).despawn();
|
|
}
|
|
|
|
game_state.set(GameState::default());
|
|
}
|