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/*/*
|
||||||
!/build/index.html
|
!/build/breakout/index.html
|
||||||
|
!/build/paddle/index.html
|
||||||
|
|
||||||
/target
|
/target
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
name = "breakout"
|
name = "breakout"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
default-run = "breakout"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bevy = { version = "0.19.0", features = ["debug_glam_assert"] }
|
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"
|
getrandom = "0.4.3"
|
||||||
rand = "0.10.2"
|
rand = "0.10.2"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "paddle"
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["debug"]
|
default = ["debug"]
|
||||||
debug = ["bevy/debug"]
|
debug = ["bevy/debug"]
|
||||||
|
|
|
||||||
9
Makefile
9
Makefile
|
|
@ -8,9 +8,12 @@ help: ## List targets in this Makefile
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build:
|
build:
|
||||||
cargo build --release --target wasm32-unknown-unknown --no-default-features --features wasm
|
cargo build --bin breakout --release --target wasm32-unknown-unknown --no-default-features --features wasm
|
||||||
wasm-bindgen --target web --out-dir build target/wasm32-unknown-unknown/release/breakout.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
|
.PHONY: publish
|
||||||
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};
|
use crate::{Brick, GameState, WORLD_SIZE};
|
||||||
|
|
||||||
pub(crate) struct DebugPlugin;
|
pub struct DebugPlugin;
|
||||||
|
|
||||||
impl Plugin for DebugPlugin {
|
impl Plugin for DebugPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
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::prelude::*;
|
||||||
use bevy::sprite::Anchor;
|
|
||||||
use core::f32::consts::PI;
|
|
||||||
|
|
||||||
use crate::debug::DebugPlugin;
|
use breakout::Breakout;
|
||||||
|
|
||||||
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() {
|
fn main() {
|
||||||
App::new()
|
App::new().add_plugins(Breakout).run();
|
||||||
.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());
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue