Breakout
This commit is contained in:
parent
f83c7c0781
commit
9910fd5799
4 changed files with 572 additions and 20 deletions
|
|
@ -4,7 +4,7 @@ version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bevy = "0.19.0"
|
bevy = { version = "0.19.0", features = ["debug_glam_assert"] }
|
||||||
chacha20 = { version = "0.10.1", default-features = false, features = ["rng"] }
|
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"
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,33 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background-color: #aaaaaa;
|
background-color: #222222;
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
canvas {
|
canvas {
|
||||||
background-color: black;
|
background-color: black;
|
||||||
}
|
}
|
||||||
|
.mono {
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
<title>Pong</title>
|
<title>Breakout</title>
|
||||||
</head>
|
</head>
|
||||||
<script type="module">
|
<body>
|
||||||
import init from './breakout.js';
|
<div>
|
||||||
init();
|
<canvas id="breakout"></canvas>
|
||||||
</script>
|
<p style="text-align: center;">
|
||||||
|
Use
|
||||||
|
<span class="mono">⬅</span>
|
||||||
|
<span class="mono">➡</span>
|
||||||
|
to move and
|
||||||
|
<span class="mono">Space</span>
|
||||||
|
to serve.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<script type="module">
|
||||||
|
import init from './breakout.js';
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
49
src/debug.rs
Normal file
49
src/debug.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::{Brick, GameState, WORLD_SIZE};
|
||||||
|
|
||||||
|
pub(crate) struct DebugPlugin;
|
||||||
|
|
||||||
|
impl Plugin for DebugPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
if !cfg!(feature = "debug") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
draw_world_bounds,
|
||||||
|
log_state_transitions.run_if(state_changed::<GameState>),
|
||||||
|
cheat_codes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_world_bounds(mut gizmos: Gizmos) {
|
||||||
|
gizmos.rect_2d(
|
||||||
|
Isometry2d::from_translation(WORLD_SIZE / 2.),
|
||||||
|
WORLD_SIZE,
|
||||||
|
LinearRgba::RED,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn log_state_transitions(state: Res<State<GameState>>) {
|
||||||
|
info!({ ?state }, "State changed");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cheat_codes(
|
||||||
|
mut commands: Commands,
|
||||||
|
input: Res<ButtonInput<KeyCode>>,
|
||||||
|
bricks: Query<Entity, With<Brick>>,
|
||||||
|
) {
|
||||||
|
if input.pressed(KeyCode::Digit1) {
|
||||||
|
let mut iter = bricks.iter();
|
||||||
|
_ = iter.next();
|
||||||
|
|
||||||
|
for entity in iter {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
512
src/main.rs
512
src/main.rs
|
|
@ -1,11 +1,44 @@
|
||||||
use bevy::camera::ScalingMode;
|
use bevy::math::bounding::{Aabb2d, Bounded2d, BoundingCircle, BoundingVolume, IntersectsVolume};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::sprite::Anchor;
|
||||||
|
use core::f32::consts::PI;
|
||||||
|
|
||||||
pub const WORLD_SIZE: Vec2 = Vec2::new(480., 640.);
|
use crate::debug::DebugPlugin;
|
||||||
|
|
||||||
|
mod debug;
|
||||||
|
|
||||||
|
const WORLD_SIZE: Vec2 = Vec2::new(480., 640.);
|
||||||
|
|
||||||
|
#[derive(States, Debug, Copy, Clone, PartialEq, Eq, Hash, Default)]
|
||||||
|
enum GameState {
|
||||||
|
#[default]
|
||||||
|
Build,
|
||||||
|
Serve,
|
||||||
|
Play,
|
||||||
|
Over,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Resource, Debug, Copy, Clone, PartialEq, Eq, Default)]
|
||||||
|
struct Level(u8);
|
||||||
|
|
||||||
|
#[derive(Resource, Debug, Copy, Clone, PartialEq, Eq, Deref)]
|
||||||
|
struct Lives(u8);
|
||||||
|
|
||||||
|
impl Default for Lives {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Resource, Debug, Copy, Clone, PartialEq, Eq, Default, Deref)]
|
||||||
|
struct Score(u64);
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
App::new()
|
App::new()
|
||||||
.insert_resource(ClearColor(Color::BLACK))
|
.insert_resource(ClearColor(Color::BLACK))
|
||||||
|
.init_resource::<Level>()
|
||||||
|
.init_resource::<Score>()
|
||||||
|
.init_resource::<Lives>()
|
||||||
.add_plugins(DefaultPlugins.set(WindowPlugin {
|
.add_plugins(DefaultPlugins.set(WindowPlugin {
|
||||||
// Until there's a reliable way to hint that a game window should
|
// Until there's a reliable way to hint that a game window should
|
||||||
// float on tiling window managers, the most reliable way to achieve
|
// float on tiling window managers, the most reliable way to achieve
|
||||||
|
|
@ -13,6 +46,7 @@ fn main() {
|
||||||
//
|
//
|
||||||
// https://github.com/rust-windowing/winit/issues/862#issuecomment-2047791401
|
// https://github.com/rust-windowing/winit/issues/862#issuecomment-2047791401
|
||||||
primary_window: Some(Window {
|
primary_window: Some(Window {
|
||||||
|
canvas: Some("#breakout".into()),
|
||||||
resize_constraints: WindowResizeConstraints {
|
resize_constraints: WindowResizeConstraints {
|
||||||
min_width: WORLD_SIZE.x,
|
min_width: WORLD_SIZE.x,
|
||||||
min_height: WORLD_SIZE.y,
|
min_height: WORLD_SIZE.y,
|
||||||
|
|
@ -23,23 +57,475 @@ fn main() {
|
||||||
}),
|
}),
|
||||||
..default()
|
..default()
|
||||||
}))
|
}))
|
||||||
.add_systems(Startup, (setup, greet).chain())
|
.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();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn setup(mut commands: Commands) {
|
fn setup(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut meshes: ResMut<Assets<Mesh>>,
|
||||||
|
mut materials: ResMut<Assets<ColorMaterial>>,
|
||||||
|
) {
|
||||||
commands.spawn((
|
commands.spawn((
|
||||||
Camera2d,
|
Camera2d,
|
||||||
Projection::Orthographic(OrthographicProjection {
|
Transform::from_translation((WORLD_SIZE / 2.).extend(0.)),
|
||||||
scaling_mode: ScalingMode::Fixed {
|
|
||||||
width: WORLD_SIZE.x,
|
|
||||||
height: WORLD_SIZE.y,
|
|
||||||
},
|
|
||||||
..OrthographicProjection::default_2d()
|
|
||||||
}),
|
|
||||||
));
|
));
|
||||||
|
|
||||||
|
commands.spawn(Ball::build(&mut meshes, &mut materials));
|
||||||
|
|
||||||
|
commands.spawn(Wall::top());
|
||||||
|
commands.spawn(Wall::left());
|
||||||
|
commands.spawn(Wall::right());
|
||||||
|
// There's no bottom wall because that's a void plane instead.
|
||||||
|
commands.spawn((
|
||||||
|
VoidPlane(Plane2d::new(Vec2::Y)),
|
||||||
|
Transform::from_translation(Vec3::ZERO),
|
||||||
|
));
|
||||||
|
commands.add_observer(on_void_out);
|
||||||
|
|
||||||
|
commands.spawn((
|
||||||
|
Paddle,
|
||||||
|
Sprite::from_color(Paddle::COLOR, Vec2::ONE),
|
||||||
|
Transform {
|
||||||
|
translation: Vec3::new(WORLD_SIZE.x / 2., Paddle::SIZE.y / 2. + Paddle::GAP, 0.),
|
||||||
|
scale: Paddle::SIZE.extend(1.),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Collider::Aabb(Aabb2d::new(Vec2::ZERO, Paddle::SIZE / 2.)),
|
||||||
|
));
|
||||||
|
|
||||||
|
commands.spawn((
|
||||||
|
ScoreText,
|
||||||
|
Text2d::default(),
|
||||||
|
TextLayout::justify(Justify::Right),
|
||||||
|
Anchor::BOTTOM_RIGHT,
|
||||||
|
Transform::from_translation(Vec3::new(WORLD_SIZE.x - 8., WORLD_SIZE.y - HUD_HEIGHT, 1.)),
|
||||||
|
));
|
||||||
|
|
||||||
|
commands.add_observer(start_game);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn greet() {
|
#[derive(Component, Debug)]
|
||||||
println!("Hello, world!");
|
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