Add debugger for paddle bounce
This commit is contained in:
parent
9910fd5799
commit
eadfba38c5
9 changed files with 751 additions and 532 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
|||
/build/*
|
||||
!/build/index.html
|
||||
/build/*/*
|
||||
!/build/breakout/index.html
|
||||
!/build/paddle/index.html
|
||||
|
||||
/target
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
name = "breakout"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
default-run = "breakout"
|
||||
|
||||
[dependencies]
|
||||
bevy = { version = "0.19.0", features = ["debug_glam_assert"] }
|
||||
|
|
@ -9,6 +10,9 @@ chacha20 = { version = "0.10.1", default-features = false, features = ["rng"] }
|
|||
getrandom = "0.4.3"
|
||||
rand = "0.10.2"
|
||||
|
||||
[[bin]]
|
||||
name = "paddle"
|
||||
|
||||
[features]
|
||||
default = ["debug"]
|
||||
debug = ["bevy/debug"]
|
||||
|
|
|
|||
9
Makefile
9
Makefile
|
|
@ -8,9 +8,12 @@ help: ## List targets in this Makefile
|
|||
|
||||
.PHONY: build
|
||||
build:
|
||||
cargo build --release --target wasm32-unknown-unknown --no-default-features --features wasm
|
||||
wasm-bindgen --target web --out-dir build target/wasm32-unknown-unknown/release/breakout.wasm
|
||||
cargo build --bin breakout --release --target wasm32-unknown-unknown --no-default-features --features wasm
|
||||
wasm-bindgen --target web --out-dir build/breakout target/wasm32-unknown-unknown/release/breakout.wasm
|
||||
|
||||
cargo build --bin paddle --release --target wasm32-unknown-unknown --no-default-features --features wasm
|
||||
wasm-bindgen --target web --out-dir build/paddle target/wasm32-unknown-unknown/release/paddle.wasm
|
||||
|
||||
.PHONY: publish
|
||||
publish:
|
||||
rsync --verbose --checksum --recursive --safe-links --delete --progress build/ root@lab:/www/games/breakout
|
||||
rsync --verbose --checksum --recursive --safe-links --delete --progress build/breakout/ root@lab:/www/games/breakout
|
||||
|
|
|
|||
40
build/paddle/index.html
Normal file
40
build/paddle/index.html
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #222222;
|
||||
color: white;
|
||||
}
|
||||
canvas {
|
||||
background-color: black;
|
||||
}
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
<title>Breakout</title>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<canvas id="paddle"></canvas>
|
||||
<p style="text-align: center;">
|
||||
Use
|
||||
<span class="mono">⬅</span>
|
||||
<span class="mono">➡</span>
|
||||
to move the paddle and
|
||||
<span class="mono">WASD</span>
|
||||
to move the ball.
|
||||
</p>
|
||||
</div>
|
||||
<script type="module">
|
||||
import init from './paddle.js';
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
156
src/bin/paddle.rs
Normal file
156
src/bin/paddle.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
use bevy::ecs::query::QueryData;
|
||||
use bevy::math::bounding::Aabb2d;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use breakout::debug::DebugPlugin;
|
||||
use breakout::*;
|
||||
|
||||
const WORLD_SIZE: Vec2 = Vec2::new(480., 320.);
|
||||
const CAMERA_SCALE: f32 = 2.;
|
||||
|
||||
fn main() {
|
||||
App::new()
|
||||
.insert_resource(ClearColor(Color::BLACK))
|
||||
.add_plugins(DebugPlugin)
|
||||
.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 {
|
||||
focused: false,
|
||||
canvas: Some("#paddle".into()),
|
||||
resize_constraints: WindowResizeConstraints {
|
||||
min_width: CAMERA_SCALE * WORLD_SIZE.x,
|
||||
min_height: CAMERA_SCALE * WORLD_SIZE.y,
|
||||
max_width: CAMERA_SCALE * WORLD_SIZE.x,
|
||||
max_height: CAMERA_SCALE * WORLD_SIZE.y,
|
||||
},
|
||||
..default()
|
||||
}),
|
||||
..default()
|
||||
}))
|
||||
.add_systems(Startup, setup)
|
||||
.add_systems(
|
||||
FixedUpdate,
|
||||
(move_paddle, move_ball, draw_trajectory).chain(),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
const PADDLE_START: Vec3 = Vec3::new(WORLD_SIZE.x / 2., Paddle::SIZE.y / 2. + Paddle::GAP, 0.);
|
||||
|
||||
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.)),
|
||||
Projection::Orthographic(OrthographicProjection {
|
||||
scale: 1. / CAMERA_SCALE,
|
||||
..OrthographicProjection::default_2d()
|
||||
}),
|
||||
));
|
||||
|
||||
commands.spawn(Ball::build(&mut meshes, &mut materials));
|
||||
|
||||
commands.spawn((
|
||||
Paddle,
|
||||
Sprite::from_color(Paddle::COLOR, Vec2::ONE),
|
||||
Transform {
|
||||
translation: PADDLE_START,
|
||||
scale: Paddle::SIZE.extend(1.),
|
||||
..default()
|
||||
},
|
||||
Collider::Aabb(Aabb2d::new(Vec2::ZERO, Paddle::SIZE / 2.)),
|
||||
));
|
||||
}
|
||||
|
||||
pub 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;
|
||||
}
|
||||
|
||||
let delta = v.normalize() * Paddle::SPEED * time.delta_secs();
|
||||
paddle.translation += delta;
|
||||
|
||||
let w = Paddle::SIZE.x / 2.;
|
||||
let x = paddle
|
||||
.translation
|
||||
.x
|
||||
.clamp(PADDLE_START.x - w, PADDLE_START.x + w);
|
||||
paddle.translation.x = x;
|
||||
}
|
||||
|
||||
#[derive(QueryData)]
|
||||
struct PaddleQuery {
|
||||
entity: Entity,
|
||||
transform: &'static Transform,
|
||||
collider: &'static Collider,
|
||||
_paddle: &'static Paddle,
|
||||
}
|
||||
|
||||
#[derive(QueryData)]
|
||||
struct BallQuery {
|
||||
entity: Entity,
|
||||
transform: &'static Transform,
|
||||
_ball: &'static Ball,
|
||||
}
|
||||
|
||||
fn move_ball(
|
||||
time: Res<Time>,
|
||||
input: Res<ButtonInput<KeyCode>>,
|
||||
mut ball: Single<&mut Transform, With<Ball>>,
|
||||
) {
|
||||
let mut v = Vec3::ZERO;
|
||||
if input.pressed(KeyCode::KeyW) {
|
||||
v.y += 1.;
|
||||
}
|
||||
if input.pressed(KeyCode::KeyS) {
|
||||
v.y -= 1.;
|
||||
}
|
||||
if input.pressed(KeyCode::KeyA) {
|
||||
v.x -= 1.;
|
||||
}
|
||||
if input.pressed(KeyCode::KeyD) {
|
||||
v.x += 1.;
|
||||
}
|
||||
|
||||
if v == Vec3::ZERO {
|
||||
return;
|
||||
}
|
||||
|
||||
let delta = v.normalize() * Paddle::SPEED * time.delta_secs();
|
||||
ball.translation += delta;
|
||||
}
|
||||
|
||||
fn draw_trajectory(mut gizmos: Gizmos, paddle: Single<PaddleQuery>, ball: Single<BallQuery>) {
|
||||
let ball_pos = ball.transform.translation.truncate();
|
||||
let paddle_pos = paddle.transform.translation.truncate();
|
||||
|
||||
let collision_point = PADDLE_START.truncate() + Paddle::SIZE.with_x(0.) / 2.;
|
||||
|
||||
let velocity = collision_point - ball_pos;
|
||||
|
||||
let exit_v = velocity.reflect(Vec2::Y);
|
||||
|
||||
let destination_point = paddle_bounce(paddle_pos, collision_point, exit_v);
|
||||
|
||||
gizmos.line_2d(ball_pos, collision_point, LinearRgba::BLUE);
|
||||
gizmos.ray_2d(collision_point, destination_point, LinearRgba::GREEN);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ use bevy::prelude::*;
|
|||
|
||||
use crate::{Brick, GameState, WORLD_SIZE};
|
||||
|
||||
pub(crate) struct DebugPlugin;
|
||||
pub struct DebugPlugin;
|
||||
|
||||
impl Plugin for DebugPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
|
|
|
|||
539
src/lib.rs
Normal file
539
src/lib.rs
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
use bevy::ecs::query::QueryData;
|
||||
pub use bevy::math::bounding::{
|
||||
Aabb2d, Bounded2d, BoundingCircle, BoundingVolume, IntersectsVolume,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
use bevy::sprite::Anchor;
|
||||
use core::f32::consts::PI;
|
||||
|
||||
pub mod debug;
|
||||
|
||||
use crate::debug::DebugPlugin;
|
||||
|
||||
pub struct Breakout;
|
||||
|
||||
impl Plugin for Breakout {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.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);
|
||||
}
|
||||
}
|
||||
|
||||
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 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)]
|
||||
pub struct Ball;
|
||||
|
||||
impl Ball {
|
||||
pub const COLOR: Color = Color::LinearRgba(LinearRgba::GREEN);
|
||||
pub const RADIUS: f32 = 8.;
|
||||
pub const SPAWN_POSITION: Vec2 = Vec2::new(WORLD_SIZE.x / 2., 40.);
|
||||
pub const SERVE_SPEED: f32 = 200.;
|
||||
pub const MAX_SPEED: f32 = 900.;
|
||||
|
||||
pub 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)]
|
||||
pub struct Paddle;
|
||||
|
||||
impl Paddle {
|
||||
pub const COLOR: Color = Color::LinearRgba(LinearRgba::rgb(0., 1., 1.));
|
||||
pub const SIZE: Vec2 = Vec2::new(60., 10.);
|
||||
pub const GAP: f32 = 4.;
|
||||
pub const SPEED: f32 = WORLD_SIZE.x / 2.;
|
||||
}
|
||||
|
||||
#[derive(Component, Debug)]
|
||||
pub enum Collider {
|
||||
Aabb(Aabb2d),
|
||||
Plane(Plane2d),
|
||||
}
|
||||
|
||||
#[derive(QueryData)]
|
||||
struct ColliderData {
|
||||
entity: Entity,
|
||||
transform: &'static Transform,
|
||||
collider: &'static Collider,
|
||||
brick: Has<Brick>,
|
||||
paddle: Has<Paddle>,
|
||||
}
|
||||
|
||||
fn collisions(
|
||||
mut commands: Commands,
|
||||
ball: Single<(&Transform, &mut Velocity), With<Ball>>,
|
||||
colliders: Query<ColliderData, Without<Ball>>,
|
||||
mut score: ResMut<Score>,
|
||||
) {
|
||||
let (transform, mut velocity) = ball.into_inner();
|
||||
let circle = BoundingCircle::new(transform.translation.truncate(), Ball::RADIUS);
|
||||
|
||||
for object in colliders {
|
||||
let tx = object.transform.translation.truncate();
|
||||
|
||||
let target = match object.collider {
|
||||
Collider::Aabb(aabb) => aabb.translated_by(tx),
|
||||
Collider::Plane(plane) => plane.aabb_2d(tx),
|
||||
};
|
||||
if !target.intersects(&circle) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if object.brick {
|
||||
commands.entity(object.entity).despawn();
|
||||
score.0 += Brick::SCORE;
|
||||
}
|
||||
|
||||
let point = target.closest_point(circle.center);
|
||||
let normal = match object.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 object.paddle && normal == Vec2::Y {
|
||||
v = paddle_bounce(tx, point, v);
|
||||
}
|
||||
|
||||
velocity.0 = v.clamp_length_max(Ball::MAX_SPEED);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paddle_bounce(paddle: Vec2, collision: Vec2, velocity: Vec2) -> Vec2 {
|
||||
let offset = (collision.x - paddle.x) / Paddle::SIZE.x;
|
||||
let tilt = (0.).lerp(-PI / 4., offset);
|
||||
|
||||
const MAX_ANGLE: f32 = PI / 12.;
|
||||
let angle = (velocity.to_angle() + tilt).clamp(MAX_ANGLE, PI - MAX_ANGLE);
|
||||
let mag = velocity.length() * (1. + offset.abs() / 2.);
|
||||
|
||||
Vec2::from_angle(angle).rotate(Vec2::X * mag)
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
528
src/main.rs
528
src/main.rs
|
|
@ -1,531 +1,7 @@
|
|||
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);
|
||||
use breakout::Breakout;
|
||||
|
||||
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());
|
||||
App::new().add_plugins(Breakout).run();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue