This commit is contained in:
Jeremy Kaplan 2026-07-19 21:27:07 -07:00
commit d483541dcb
12 changed files with 884 additions and 1 deletions

3
.gitignore vendored
View file

@ -1 +1,4 @@
/build/*
!/build/index.html
/target /target

18
Cargo.lock generated
View file

@ -1968,6 +1968,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
dependencies = [
"cfg-if",
"cpufeatures",
"rand_core",
]
[[package]] [[package]]
name = "codespan-reporting" name = "codespan-reporting"
version = "0.12.0" version = "0.12.0"
@ -2627,8 +2638,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"js-sys",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"rand_core",
"wasm-bindgen",
] ]
[[package]] [[package]]
@ -4098,6 +4112,9 @@ name = "pong"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"bevy", "bevy",
"chacha20",
"getrandom 0.4.3",
"rand",
] ]
[[package]] [[package]]
@ -4213,6 +4230,7 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
dependencies = [ dependencies = [
"chacha20",
"getrandom 0.4.3", "getrandom 0.4.3",
"rand_core", "rand_core",
] ]

View file

@ -5,6 +5,14 @@ edition = "2024"
[dependencies] [dependencies]
bevy = "0.19.0" bevy = "0.19.0"
chacha20 = { version = "0.10.1", default-features = false, features = ["rng"] }
getrandom = "0.4.3"
rand = "0.10.2"
[features]
default = ["debug"]
debug = ["bevy/debug"]
wasm = ["getrandom/wasm_js"]
# Enable some optimization in development so the game runs well. # Enable some optimization in development so the game runs well.
# Enable more optimization for dependencies because they change less often. # Enable more optimization for dependencies because they change less often.

16
Makefile Normal file
View file

@ -0,0 +1,16 @@
.DEFAULT_GOAL := help
.PHONY: help
help: ## List targets in this Makefile
@awk 'BEGIN { FS = ":|:.*?## "; OFS="\t" }; /^[0-9a-zA-Z_-]+?:/ { print $$1, $$2 }' $(MAKEFILE_LIST) \
| column --separator $$'\t' --table --table-wrap 2 --output-separator ' ' \
| sort --dictionary-order
.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/pong.wasm
.PHONY: publish
publish:
rsync --verbose --checksum --recursive --safe-links --delete --progress build/ root@lab:/www/games/pong

23
build/index.html Normal file
View file

@ -0,0 +1,23 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<style>
body {
height: 100vh;
width: 100vw;
display: flex;
align-items: center;
justify-content: center;
background-color: #aaaaaa;
}
canvas {
background-color: black;
}
</style>
<title>Pong</title>
</head>
<script type="module">
import init from './pong.js';
init();
</script>
</html>

5
clippy.toml Normal file
View file

@ -0,0 +1,5 @@
# Bevy query types can get a bit more complicated than usual Rust code.
type-complexity-threshold = 1000
# Bevy systems sometimes rely on a lot of different entities.
too-many-arguments-threshold = 10

131
src/ball.rs Normal file
View file

@ -0,0 +1,131 @@
use bevy::math::bounding::{Aabb2d, Bounded2d, BoundingVolume, IntersectsVolume};
use bevy::prelude::*;
use crate::hud::Hud;
use crate::{GameState, Player};
pub struct BallPlugin;
impl Plugin for BallPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ServeAngle>()
.add_systems(Startup, spawn_ball)
.add_systems(
FixedUpdate,
(collision, movement)
.chain()
.run_if(in_state(GameState::Game)),
);
}
}
#[derive(Component, Debug, Clone, Copy)]
pub struct Ball;
#[derive(Component, Debug, Clone, Copy, PartialEq, Default, Deref, DerefMut)]
pub struct Velocity(pub Vec2);
#[derive(Resource, Debug, Default, Deref, DerefMut)]
pub struct ServeAngle(pub f32);
#[derive(Event, Debug)]
pub struct Serve;
#[derive(Component)]
pub enum Collision {
Wall(CollisionWall),
Paddle(CollisionPaddle),
}
#[derive(Debug)]
pub struct CollisionWall {
pub plane: Plane2d,
}
#[derive(Debug)]
pub struct CollisionPaddle {
pub normal: Vec2,
pub rect: Aabb2d,
}
pub const BALL_SIZE: f32 = 10.;
const V_MULTIPLIER: f32 = 1.5;
pub const V_MIN: f32 = 80.;
pub const V_MAX: f32 = 500.;
fn spawn_ball(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
commands.spawn((
Mesh2d(meshes.add(Rectangle::from_length(BALL_SIZE))),
MeshMaterial2d(materials.add(ColorMaterial::from_color(Color::WHITE))),
Transform::from_translation(Vec3::ZERO),
Ball,
Velocity(Vec2::ZERO),
));
commands.add_observer(on_serve);
}
fn movement(time: Res<Time>, ball: Single<(&mut Transform, &Velocity), With<Ball>>) {
let (mut transform, velocity) = ball.into_inner();
let delta = velocity.0 * time.delta_secs();
transform.translation += delta.extend(0.);
}
#[derive(Event, Debug)]
pub struct GoalScored {
pub scores_for: Player,
}
fn collision(
ball: Single<(&Transform, &mut Velocity), With<Ball>>,
obstacles: Query<(&Transform, &Collision)>,
) {
let (transform, mut velocity) = ball.into_inner();
let ball = Aabb2d::new(
transform.translation.truncate(),
Vec2::splat(BALL_SIZE / 2.),
);
for (transform, collision) in obstacles {
match collision {
Collision::Wall(CollisionWall { plane }) => {
let aabb = plane.aabb_2d(Isometry2d::from_translation(
transform.translation.truncate(),
));
if ball.intersects(&aabb) {
velocity.0 = velocity.reflect(*plane.normal);
}
}
Collision::Paddle(CollisionPaddle { normal, rect }) => {
let paddle = rect.translated_by(transform.translation.truncate());
if ball.intersects(&paddle) {
let mut v = velocity.reflect(*normal);
v *= V_MULTIPLIER;
v = v.clamp_length(V_MIN, V_MAX);
velocity.0 = v;
}
}
}
}
}
fn on_serve(
_: On<Serve>,
angle: Res<ServeAngle>,
ball: Single<&mut Velocity, With<Ball>>,
hud: Single<&mut Visibility, With<Hud>>,
) {
let mut velocity = ball.into_inner();
velocity.0 = Vec2::from_angle(angle.0) * V_MIN;
let mut hud = hud.into_inner();
*hud = Visibility::Hidden;
}

142
src/field.rs Normal file
View file

@ -0,0 +1,142 @@
use bevy::math::bounding::{Aabb2d, Bounded2d, IntersectsVolume};
use bevy::prelude::*;
use core::f32::consts::PI;
use rand::prelude::*;
use crate::ball::{BALL_SIZE, Ball, Collision, CollisionWall, GoalScored, ServeAngle, Velocity};
use crate::hud::{ScoreP1, ScoreP2};
use crate::{Player, RandomSource, WORLD_HEIGHT, WORLD_WIDTH};
pub struct FieldPlugin;
impl Plugin for FieldPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, spawn_field)
.add_systems(FixedUpdate, scoring);
}
}
#[derive(Component, Debug, Clone, Copy)]
pub struct Wall;
#[derive(Component, Debug, Clone, Copy)]
pub struct Net;
#[derive(Component, Debug, Clone, Copy)]
pub struct Goal {
pub scores_for: Player,
pub plane: Plane2d,
}
fn spawn_field(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
let wall_color = Hsla::gray(0.80);
let wall_segment = Segment2d::new(
Vec2::new(-WORLD_WIDTH / 2., 0.),
Vec2::new(WORLD_WIDTH / 2., 0.),
);
commands.spawn((
Mesh2d(meshes.add(wall_segment)),
MeshMaterial2d(materials.add(ColorMaterial::from_color(wall_color))),
Transform::from_translation(WORLD_HEIGHT / 2. * Vec3::Y),
Wall,
Collision::Wall(CollisionWall {
plane: Plane2d::new(Vec2::NEG_Y),
}),
));
commands.spawn((
Mesh2d(meshes.add(wall_segment)),
MeshMaterial2d(materials.add(ColorMaterial::from_color(wall_color))),
Transform::from_translation(WORLD_HEIGHT / 2. * Vec3::NEG_Y),
Wall,
Collision::Wall(CollisionWall {
plane: Plane2d::new(Vec2::Y),
}),
));
commands.spawn((
Mesh2d(meshes.add(Segment2d::new(
Vec2::new(0., -WORLD_HEIGHT / 2.),
Vec2::new(0., WORLD_HEIGHT / 2.),
))),
MeshMaterial2d(materials.add(ColorMaterial::from_color(Hsla::gray(0.40)))),
Transform::from_translation(Vec3::ZERO),
Net,
));
commands.spawn((
Transform::from_translation(WORLD_WIDTH / 2. * Vec3::NEG_X),
Goal {
scores_for: Player::TWO,
plane: Plane2d::new(Vec2::X),
},
));
commands.spawn((
Transform::from_translation(WORLD_WIDTH / 2. * Vec3::X),
Goal {
scores_for: Player::ONE,
plane: Plane2d::new(Vec2::NEG_X),
},
));
commands.add_observer(on_goal_scored);
}
fn scoring(
mut commands: Commands,
transform: Single<&Transform, With<Ball>>,
goals: Query<(&Goal, &Transform)>,
) {
let ball = Aabb2d::new(
transform.translation.truncate(),
Vec2::splat(BALL_SIZE / 2.),
);
for (goal, transform) in goals {
let aabb = goal.plane.aabb_2d(Isometry2d::from_translation(
transform.translation.truncate(),
));
if ball.intersects(&aabb) {
commands.trigger(GoalScored {
scores_for: goal.scores_for,
})
}
}
}
fn on_goal_scored(
ev: On<GoalScored>,
mut rng: ResMut<RandomSource>,
ball: Single<(&mut Transform, &mut Velocity), With<Ball>>,
mut score1: Single<&mut ScoreP1>,
mut score2: Single<&mut ScoreP2>,
mut serve_angle: ResMut<ServeAngle>,
) {
let (mut transform, mut velocity) = ball.into_inner();
velocity.0 = Vec2::ZERO;
transform.translation = Vec3::ZERO;
match ev.scores_for {
Player::ONE => {
score1.0 += 1;
}
Player::TWO => {
score2.0 += 1;
}
}
// Serve back toward scorer at a random angle.
let dir = match ev.scores_for {
Player::ONE => Dir2::NEG_X,
Player::TWO => Dir2::X,
};
serve_angle.0 = dir.to_angle() + rng.random_range(-PI / 4.0..=PI / 4.0);
}

206
src/hud.rs Normal file
View file

@ -0,0 +1,206 @@
use bevy::prelude::*;
use bevy::sprite::Anchor;
use crate::ball::{GoalScored, Serve};
use crate::{GameState, Player, WORLD_HEIGHT, Winner};
pub struct HudPlugin;
impl Plugin for HudPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, spawn_hud).add_systems(
FixedUpdate,
(run_countdown, show_scores, game_over).run_if(in_state(GameState::Game)),
);
}
}
#[derive(Component, Debug, Copy, Clone, Eq, PartialEq)]
pub struct Hud;
#[derive(Event, Debug)]
pub struct GameReset;
#[derive(Component, Debug, Clone, Copy, Deref, DerefMut)]
pub struct ScoreP1(pub u8);
#[derive(Component, Debug, Clone, Copy, Deref, DerefMut)]
pub struct ScoreP2(pub u8);
#[derive(Component, Debug, Clone, Deref, DerefMut)]
pub struct Countdown(Timer);
#[derive(Component, Debug, Clone)]
pub struct Note;
fn spawn_hud(mut commands: Commands) {
commands.spawn((
Hud,
Node {
width: percent(100),
height: percent(100),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
Transform::from_translation(Vec3::ZERO),
children![
(
ScoreP1(0),
Text2d::new("0"),
TextLayout::justify(Justify::Right),
Anchor::TOP_RIGHT,
Transform::from_translation(Vec3::new(-8., WORLD_HEIGHT / 2., 1.)),
),
(
ScoreP2(0),
Text2d::new("0"),
TextLayout::justify(Justify::Left),
Anchor::TOP_LEFT,
Transform::from_translation(Vec3::new(8., WORLD_HEIGHT / 2., 1.)),
),
(
Countdown(Timer::from_seconds(3., TimerMode::Once)),
Text2d::new(""),
TextLayout::justify(Justify::Center),
Transform::from_translation(Vec3::new(0., -WORLD_HEIGHT / 4., 1.)),
),
(
Note,
Text2d::new(format!("First to {} wins", WINNING_SCORE)),
TextLayout::justify(Justify::Center),
Anchor::BOTTOM_CENTER,
Transform::from_translation(Vec3::new(0., -WORLD_HEIGHT / 2., 1.)),
),
],
));
commands.add_observer(on_goal_scored);
commands.add_observer(on_game_reset);
}
fn run_countdown(
mut commands: Commands,
time: Res<Time>,
countdown: Single<(&mut Text2d, &mut Countdown)>,
) {
let (mut text, mut timer) = countdown.into_inner();
let mut delayed = commands.delayed();
let t = timer.remaining_secs().ceil();
if t == 0. {
text.0 = "Go!".to_string();
} else {
text.0 = t.to_string();
}
if timer.tick(time.delta()).just_finished() {
delayed.secs(0.5).trigger(Serve);
}
}
const WINNING_SCORE: u8 = 5;
fn show_scores(
mut winner: ResMut<Winner>,
mut scores: ParamSet<(
Single<(&mut Text2d, &ScoreP1)>,
Single<(&mut Text2d, &ScoreP2)>,
)>,
) {
{
let (mut text, score) = scores.p0().into_inner();
text.0 = score.to_string();
if score.0 >= WINNING_SCORE {
winner.0 = Some(Player::ONE);
}
}
{
let (mut text, score) = scores.p1().into_inner();
text.0 = score.to_string();
if score.0 >= WINNING_SCORE {
winner.0 = Some(Player::TWO);
}
}
}
fn game_over(
mut commands: Commands,
winner: Res<Winner>,
countdown: Single<&mut Visibility, With<Countdown>>,
mut note: Single<&mut Text2d, With<Note>>,
mut game_state: ResMut<NextState<GameState>>,
) {
if let Some(w) = **winner {
match w {
Player::ONE => note.0 = "Player 1 wins!".to_string(),
Player::TWO => note.0 = "Player 2 wins!".to_string(),
}
let mut countdown = countdown.into_inner();
*countdown = Visibility::Hidden;
game_state.set(GameState::Over);
commands.delayed().secs(3.).trigger(GameReset);
}
}
fn on_goal_scored(
_: On<GoalScored>,
hud: Single<&mut Visibility, With<Hud>>,
mut countdown: Single<&mut Countdown>,
) {
countdown.reset();
let mut hud = hud.into_inner();
*hud = Visibility::Visible;
}
fn on_game_reset(
_: On<GameReset>,
mut game_state: ResMut<NextState<GameState>>,
mut visibility: ParamSet<(
Single<&mut Visibility, With<Hud>>,
Single<&mut Visibility, With<Countdown>>,
)>,
mut texts: ParamSet<(
Single<(&mut Text2d, &mut ScoreP1)>,
Single<(&mut Text2d, &mut ScoreP2)>,
Single<&mut Text2d, With<Note>>,
Single<&mut Text2d, With<Countdown>>,
)>,
) {
{
let (mut text1, mut score1) = texts.p0().into_inner();
score1.0 = 0;
text1.0 = score1.to_string();
}
{
let (mut text2, mut score2) = texts.p1().into_inner();
score2.0 = 0;
text2.0 = score2.to_string();
}
{
let mut note = texts.p2().into_inner();
note.0 = format!("First to {} wins", WINNING_SCORE);
}
{
let mut countdown = texts.p3().into_inner();
countdown.0 = "".to_string();
}
{
let mut countdown = visibility.p0().into_inner();
*countdown = Visibility::Inherited;
}
{
let mut hud = visibility.p1().into_inner();
*hud = Visibility::Inherited;
}
game_state.set(GameState::Menu);
}

View file

@ -1,5 +1,85 @@
use bevy::camera::ScalingMode;
use bevy::prelude::*; use bevy::prelude::*;
use chacha20::ChaCha8Rng;
use crate::ball::BallPlugin;
use crate::field::FieldPlugin;
use crate::hud::HudPlugin;
use crate::menu::MenuPlugin;
use crate::paddle::PaddlePlugin;
mod ball;
mod field;
mod hud;
mod menu;
mod paddle;
pub const WORLD_HEIGHT: f32 = 400.;
pub const WORLD_WIDTH: f32 = 640.;
#[derive(Resource, Debug, Deref, DerefMut)]
pub struct RandomSource(ChaCha8Rng);
#[derive(States, Debug, Clone, Copy, Eq, PartialEq, Hash, Default)]
pub enum GameState {
#[default]
Menu,
Game,
Over,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub enum Player {
ONE,
TWO,
}
pub const YELLOW: LinearRgba = LinearRgba::new(1., 1., 0., 1.);
pub const CYAN: LinearRgba = LinearRgba::new(0., 1., 1., 1.);
pub const COLOR_P1: Color = Color::LinearRgba(YELLOW);
pub const COLOR_P2: Color = Color::LinearRgba(CYAN);
#[derive(Resource, Debug, Default, Deref, DerefMut)]
pub struct Winner(pub Option<Player>);
fn main() { fn main() {
App::new().run(); App::new()
.insert_resource(ClearColor(Color::BLACK))
.insert_resource(RandomSource(rand::make_rng()))
.init_resource::<Winner>()
.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 {
resize_constraints: WindowResizeConstraints {
min_width: WORLD_WIDTH + 1.,
min_height: WORLD_HEIGHT + 1.,
max_width: WORLD_WIDTH + 1.,
max_height: WORLD_HEIGHT + 1.,
},
..default()
}),
..default()
}))
.init_state::<GameState>()
.add_plugins((MenuPlugin, HudPlugin, FieldPlugin, PaddlePlugin, BallPlugin))
.add_systems(Startup, setup)
.run();
}
fn setup(mut commands: Commands) {
commands.spawn((
Camera2d,
Projection::Orthographic(OrthographicProjection {
scaling_mode: ScalingMode::Fixed {
width: WORLD_WIDTH + 1.,
height: WORLD_HEIGHT + 1.,
},
..OrthographicProjection::default_2d()
}),
));
} }

163
src/menu.rs Normal file
View file

@ -0,0 +1,163 @@
use bevy::prelude::*;
use core::f32::consts::PI;
use rand::prelude::*;
use crate::ball::ServeAngle;
use crate::hud::{Countdown, Hud, Note};
use crate::{COLOR_P1, COLOR_P2, GameState, RandomSource, Winner};
pub struct MenuPlugin;
impl Plugin for MenuPlugin {
fn build(&self, app: &mut App) {
app.add_systems(OnEnter(GameState::Menu), show_menu)
.add_systems(
Update,
(button_interactions, button_actions).run_if(in_state(GameState::Menu)),
);
}
}
const TEXT_COLOR: Color = Color::WHITE;
const NORMAL_BUTTON: Color = Color::hsla(0., 0., 0.15, 1.);
const PRESSED_BUTTON: Color = Color::hsla(0., 0., 0.75, 1.);
const HOVERED_BUTTON: Color = Color::hsla(0., 0., 0.50, 1.);
#[derive(Component, Debug, Copy, Clone, Eq, PartialEq, Hash)]
enum MenuButtonAction {
Start,
Exit,
}
#[derive(Component, Debug, Copy, Clone, Eq, PartialEq)]
pub struct Menu;
fn show_menu(mut commands: Commands) {
let button_node = Node {
width: vw(10),
height: vw(5),
margin: UiRect::all(px(8)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
};
commands.spawn((
Menu,
DespawnOnExit(GameState::Menu),
Node {
width: percent(100),
height: percent(100),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
..default()
},
children![
(
Node {
margin: UiRect::all(px(20)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::BLACK),
children![(
Text::new("Pong!"),
TextFont::from_font_size(60.),
TextLayout::justify(Justify::Center)
)]
),
(
Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::WHITE),
children![
(
Button,
button_node.clone(),
BackgroundColor(NORMAL_BUTTON),
MenuButtonAction::Start,
children![(Text::new("Start"), TextColor(TEXT_COLOR))],
),
(
Button,
button_node,
BackgroundColor(NORMAL_BUTTON),
MenuButtonAction::Exit,
children![(Text::new("Exit"), TextColor(TEXT_COLOR))],
)
],
),
(
Node {
margin: UiRect::all(px(10)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::BLACK),
children![
(Text::new("W/S"), TextColor(COLOR_P1)),
(Text::new(" vs. "), TextColor(Color::WHITE)),
(Text::new("Up/Down"), TextColor(COLOR_P2)),
]
),
],
));
}
fn button_interactions(
mut buttons: Query<(&Interaction, &mut BackgroundColor), (Changed<Interaction>, With<Button>)>,
) {
for (interaction, mut color) in &mut buttons {
*color = match interaction {
Interaction::Pressed => PRESSED_BUTTON.into(),
Interaction::Hovered => HOVERED_BUTTON.into(),
Interaction::None => NORMAL_BUTTON.into(),
}
}
}
fn button_actions(
mut rng: ResMut<RandomSource>,
mut buttons: Query<(&Interaction, &MenuButtonAction), (Changed<Interaction>, With<Button>)>,
mut game_state: ResMut<NextState<GameState>>,
mut serve_angle: ResMut<ServeAngle>,
mut winner: ResMut<Winner>,
mut countdown: Single<&mut Countdown>,
hud: Single<&mut Visibility, With<Hud>>,
note: Single<&mut Text2d, With<Note>>,
mut exit: MessageWriter<AppExit>,
) {
let mut hud = hud.into_inner();
let mut note = note.into_inner();
for (interaction, action) in &mut buttons {
if *interaction != Interaction::Pressed {
continue;
}
match action {
MenuButtonAction::Start => {
winner.0 = None;
game_state.set(GameState::Game);
let mut angle = if rng.random() { PI } else { 0. };
angle += rng.random_range(-PI / 4.0..=PI / 4.0);
serve_angle.0 = angle;
note.0 = "".to_string();
countdown.reset();
*hud = Visibility::Visible;
}
MenuButtonAction::Exit => {
exit.write(AppExit::Success);
}
}
}
}

88
src/paddle.rs Normal file
View file

@ -0,0 +1,88 @@
use bevy::{math::bounding::Aabb2d, prelude::*};
use crate::ball::{Collision, CollisionPaddle};
use crate::{COLOR_P1, COLOR_P2, WORLD_HEIGHT, WORLD_WIDTH};
pub struct PaddlePlugin;
impl Plugin for PaddlePlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, spawn_paddles)
.add_systems(FixedUpdate, update_paddles);
}
}
#[derive(Component, Debug, Clone, Copy)]
pub struct Paddle;
#[derive(Component, Debug, Clone, Copy)]
pub struct Controls {
up: KeyCode,
down: KeyCode,
}
const MOVE_SPEED: f32 = 300.; // px/s
const PADDLE_SIZE: Vec2 = Vec2::new(10., 80.);
const SPAWN_1: Vec3 = Vec3::new(-WORLD_WIDTH / 2. + PADDLE_SIZE.x / 2., -100., 0.);
const SPAWN_2: Vec3 = Vec3::new(WORLD_WIDTH / 2. - PADDLE_SIZE.x / 2., 100., 0.);
pub fn spawn_paddles(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
commands.spawn((
Mesh2d(meshes.add(Rectangle::from_size(PADDLE_SIZE))),
MeshMaterial2d(materials.add(ColorMaterial::from_color(COLOR_P1))),
Transform::from_translation(SPAWN_1),
Paddle,
Controls {
up: KeyCode::KeyW,
down: KeyCode::KeyS,
},
Collision::Paddle(CollisionPaddle {
normal: Vec2::X,
rect: Aabb2d::new(Vec2::ZERO, PADDLE_SIZE / 2.),
}),
));
commands.spawn((
Mesh2d(meshes.add(Rectangle::from_size(PADDLE_SIZE))),
MeshMaterial2d(materials.add(ColorMaterial::from_color(COLOR_P2))),
Transform::from_translation(SPAWN_2),
Paddle,
Controls {
up: KeyCode::ArrowUp,
down: KeyCode::ArrowDown,
},
Collision::Paddle(CollisionPaddle {
normal: Vec2::NEG_X,
rect: Aabb2d::new(Vec2::ZERO, PADDLE_SIZE / 2.),
}),
));
}
pub fn update_paddles(
input: Res<ButtonInput<KeyCode>>,
time: Res<Time>,
mut paddles: Query<(&mut Transform, &Controls), With<Paddle>>,
) {
for (mut paddle_transform, controls) in &mut paddles {
let mut direction = Vec2::ZERO;
if input.pressed(controls.up) {
direction.y += 1.;
}
if input.pressed(controls.down) {
direction.y -= 1.;
}
if direction != Vec2::ZERO {
let delta = direction.normalize() * MOVE_SPEED * time.delta_secs();
paddle_transform.translation += delta.extend(0.);
paddle_transform.translation.y = paddle_transform
.translation
.y
.clamp(-WORLD_HEIGHT / 2., WORLD_HEIGHT / 2.);
}
}
}