This commit is contained in:
Jeremy Kaplan 2026-08-11 13:39:58 -04:00
commit 9910fd5799
4 changed files with 572 additions and 20 deletions

View file

@ -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"

View file

@ -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>
<body>
<div>
<canvas id="breakout"></canvas>
<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"> <script type="module">
import init from './breakout.js'; import init from './breakout.js';
init(); init();
</script> </script>
</body>
</html> </html>

49
src/debug.rs Normal file
View 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();
}
}
}

View file

@ -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());
} }