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
|
||||
|
|
|
|||
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"
|
||||
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]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.12.0"
|
||||
|
|
@ -2627,8 +2638,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4098,6 +4112,9 @@ name = "pong"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bevy",
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4213,6 +4230,7 @@ version = "0.10.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@ edition = "2024"
|
|||
|
||||
[dependencies]
|
||||
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 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 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() {
|
||||
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