Pong
This commit is contained in:
parent
d7f0070a64
commit
d483541dcb
12 changed files with 884 additions and 1 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1 +1,4 @@
|
||||||
|
/build/*
|
||||||
|
!/build/index.html
|
||||||
|
|
||||||
/target
|
/target
|
||||||
|
|
|
||||||
18
Cargo.lock
generated
18
Cargo.lock
generated
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
16
Makefile
Normal 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
23
build/index.html
Normal 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
5
clippy.toml
Normal 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
131
src/ball.rs
Normal 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
142
src/field.rs
Normal 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
206
src/hud.rs
Normal 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);
|
||||||
|
}
|
||||||
82
src/main.rs
82
src/main.rs
|
|
@ -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
163
src/menu.rs
Normal 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
88
src/paddle.rs
Normal 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.);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue