initial game prototype

This commit is contained in:
Thilo Behnke
2022-04-15 17:07:43 +02:00
parent 422bb76a0b
commit 046fcded31
19 changed files with 1391 additions and 6 deletions

38
.github/workflows/rust.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Rust
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
env:
CARGO_TERM_COLOR: always
jobs:
build-pong-lib:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Build
run: |
cd pong
cargo build --verbose
- name: Run tests
run: |
cd pong
cargo test --verbose
build-wasm-wrapper:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
(test -x $HOME/.cargo/bin/cargo-install-update || cargo install cargo-update)
(test -x $HOME/.cargo/bin/cargo-generate || cargo install --vers "^0.2" cargo-generate)
cargo install-update -a
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh -s -- -f
- name: Build
run: wasm-pack build
- name: Run tests
run: wasm-pack test

View File

@@ -4,14 +4,26 @@ version = "0.1.0"
authors = ["Thilo Behnke <thilo.behnke@gmx.net>"]
edition = "2018"
[workspace]
members = ["pong"]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies.web-sys]
version = "0.3"
features = [
"console",
]
[features]
default = ["console_error_panic_hook"]
[dependencies]
wasm-bindgen = "0.2.63"
wasm-bindgen = {version = "0.2.63", features = ["serde-serialize"]}
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0.79" }
pong = { path = "pong", version = "0.1.0" }
# The `console_error_panic_hook` crate provides better debugging of panics by
# logging them with `console.error`. This is great for development, but requires
@@ -28,6 +40,7 @@ wee_alloc = { version = "0.4.5", optional = true }
[dev-dependencies]
wasm-bindgen-test = "0.3.13"
rstest = "0.12.0"
[profile.release]
# Tell `rustc` to optimize for small code size.

1
pong/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/.idea

11
pong/Cargo.toml Normal file
View File

@@ -0,0 +1,11 @@
[package]
name = "pong"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
[dev-dependencies]
rstest = "0.12.0"

92
pong/src/collision.rs Normal file
View File

@@ -0,0 +1,92 @@
pub mod collision {
use std::fmt::Debug;
use crate::game_object::game_object::GameObject;
use crate::geom::geom::Vector;
pub struct CollisionDetector {}
impl CollisionDetector {
pub fn new() -> CollisionDetector {
CollisionDetector {}
}
pub fn detect_collisions(&self, objs: Vec<&GameObject>) -> Box<dyn CollisionRegistry> {
if objs.is_empty() {
return Box::new(Collisions::new(vec![]));
}
let mut collisions: Vec<Collision> = vec![];
let mut i = 0;
loop {
let obj = objs[i];
i += 1;
let rest = &objs[i..];
for other in rest.iter() {
let has_collision = obj.bounding_box().overlaps(&other.bounding_box());
if !has_collision {
continue;
}
collisions.push(Collision(obj.id, other.id))
}
if i >= objs.len() {
break;
}
}
let registry = Collisions::new(collisions);
return Box::new(registry);
}
}
pub trait CollisionRegistry : Debug {
fn get_collisions(&self) -> Vec<&Collision>;
fn get_collisions_by_id(&self, id: u16) -> Vec<&Collision>;
}
#[derive(Debug)]
pub struct Collisions {
pub state: Vec<Collision>,
}
impl Collisions {
pub fn new(collisions: Vec<Collision>) -> Collisions {
Collisions { state: collisions }
}
}
impl CollisionRegistry for Collisions {
fn get_collisions(&self) -> Vec<&Collision> {
self.state.iter().collect()
}
fn get_collisions_by_id(&self, id: u16) -> Vec<&Collision> {
self.state
.iter()
.filter(|c| c.0 == id || c.1 == id)
.collect()
}
}
#[derive(Debug, Eq, PartialEq)]
pub struct Collision(pub u16, pub u16);
pub struct CollisionHandler {}
impl CollisionHandler {
pub fn new() -> CollisionHandler {
CollisionHandler {}
}
pub fn handle(&self, obj_a: &mut GameObject, obj_b: &GameObject) {
if !obj_a.is_static {
obj_a.vel.reflect(&obj_b.orientation);
if obj_b.vel != Vector::zero() {
let mut adjusted = obj_b.vel.clone();
adjusted.normalize();
obj_a.vel.add(&adjusted);
}
}
let mut b_to_a = obj_a.pos.clone();
b_to_a.sub(&obj_b.pos);
b_to_a.normalize();
obj_a.pos.add(&b_to_a);
}
}
}

255
pong/src/game_field.rs Normal file
View File

@@ -0,0 +1,255 @@
use std::f64::consts::{FRAC_PI_2, FRAC_PI_4};
use crate::collision::collision::{Collision, CollisionDetector, CollisionHandler, CollisionRegistry, Collisions};
use crate::game_object::game_object::{GameObject, Shape};
use crate::geom::geom::Vector;
use crate::utils::utils::{Logger, NoopLogger};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InputType {
UP,
DOWN,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Input {
pub input: InputType,
pub obj_id: u16,
}
pub struct Field {
pub logger: Box<dyn Logger>,
pub width: u16,
pub height: u16,
pub players: Vec<Player>,
pub balls: Vec<Ball>,
pub bounds: Bounds,
pub collisions: Box<dyn CollisionRegistry>
}
impl Field {
pub fn new(logger: Box<dyn Logger>) -> Field {
let width = 800;
let height = 600;
let mut field = Field {
logger,
width,
height,
players: vec![],
balls: vec![],
bounds: Bounds::new(width, height),
collisions: Box::new(Collisions::new(vec![]))
};
field.add_player(0, 0 + width / 20, height / 2);
field.add_player(1, width - width / 20, height / 2);
field.add_ball(2, width / 2, height / 2);
return field;
}
pub fn mock(width: u16, height: u16) -> Field {
Field {
logger: Box::new(NoopLogger {}),
width,
height,
players: vec![],
balls: vec![],
bounds: Bounds::new(width, height),
collisions: Box::new(Collisions::new(vec![]))
}
}
pub fn add_player(&mut self, id: u16, x: u16, y: u16) {
self.players.push(Player::new(id, x, y, &self));
}
pub fn add_ball(&mut self, id: u16, x: u16, y: u16) {
let ball = Ball::new(id, x, y, &self);
self.balls.push(ball);
}
pub fn tick(&mut self, inputs: Vec<Input>) {
for ball in self.balls.iter_mut() {
if ball.obj.vel == Vector::zero() {
ball.obj.set_vel_x(-2.)
}
}
for player in self.players.iter_mut() {
let input_opt = inputs.iter().find(|input| player.obj.id == input.obj_id);
if let None = input_opt {
player.obj.set_vel_y(0.);
continue;
}
let input = input_opt.unwrap();
match input.input {
InputType::UP => {
player.obj.vel.y = (player.obj.vel.y + 1.).min(5.);
}
InputType::DOWN => {
player.obj.vel.y = (player.obj.vel.y - 1.).max(-5.);
}
};
}
for player in self.players.iter_mut() {
player.obj.update_pos()
}
for ball in self.balls.iter_mut() {
ball.obj.update_pos()
}
let mut objs: Vec<GameObject> = vec![];
objs.extend(
self.players
.clone()
.into_iter()
.map(|p| p.obj)
.collect::<Vec<GameObject>>(),
);
objs.extend(
self.balls
.clone()
.into_iter()
.map(|b| b.obj)
.collect::<Vec<GameObject>>(),
);
objs.extend(
self.bounds.objs
.clone()
.into_iter()
.collect::<Vec<GameObject>>()
);
let collision_detector = CollisionDetector::new();
let collision_handler = CollisionHandler::new();
self.collisions = collision_detector.detect_collisions(objs.iter().collect());
for ball in self.balls.iter_mut() {
let collisions = self.collisions.get_collisions_by_id(ball.obj.id);
if collisions.is_empty() {
continue;
}
let other = match collisions[0] {
Collision(obj_a_id, obj_b_id) if *obj_a_id == ball.obj.id => {
objs.iter().find(|o| o.id == *obj_b_id).unwrap()
}
collision => objs.iter().find(|o| o.id == collision.0).unwrap(),
};
self.logger.log("### BEFORE COLLISION ###");
self.logger.log(&*format!("{:?}", ball.obj));
self.logger.log(&*format!("{:?}", other));
collision_handler.handle(&mut ball.obj, other);
self.logger.log("### AFTER COLLISION ###");
self.logger.log(&*format!("{:?}", ball.obj));
self.logger.log(&*format!("{:?}", other));
self.logger.log("### DONE ###");
}
}
pub fn players(&self) -> Vec<&Player> {
self.players.iter().collect()
}
pub fn balls(&self) -> Vec<&Ball> {
self.balls.iter().collect()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Player {
pub obj: GameObject,
}
impl Player {
pub fn new(id: u16, x: u16, y: u16, field: &Field) -> Player {
Player {
obj: GameObject {
id,
pos: Vector {x: x as f64, y: y as f64},
orientation: Vector::new(0., 1.),
shape: Shape::Rect,
shape_params: vec![field.width / 25, field.height / 5],
vel: Vector::zero(),
is_static: true,
},
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Ball {
pub obj: GameObject,
}
impl Ball {
pub fn new(id: u16, x: u16, y: u16, field: &Field) -> Ball {
Ball {
obj: GameObject {
id,
pos: Vector {x: x as f64, y: y as f64},
orientation: Vector::zero(),
shape: Shape::Circle,
shape_params: vec![field.width / 80],
vel: Vector::zero(),
is_static: false,
},
}
}
}
#[derive(Debug)]
pub struct Bounds {
pub objs: Vec<GameObject>,
}
impl Bounds {
pub fn new(width: u16, height: u16) -> Bounds {
Bounds {
objs: vec![
// top
GameObject {
id: 90,
pos: Vector {x: (width / 2) as f64, y: 0 as f64},
orientation: Vector::new(1., 0.),
shape: Shape::Rect,
shape_params: vec![width, 2],
is_static: true,
vel: Vector::zero(),
},
// bottom
GameObject {
id: 91,
pos: Vector {x: (width / 2) as f64, y: height as f64},
orientation: Vector::new(-1., 0.),
shape: Shape::Rect,
shape_params: vec![width, 2],
is_static: true,
vel: Vector::zero(),
},
// left
GameObject {
id: 92,
pos: Vector {x: 0 as f64, y: (height / 2) as f64},
orientation: Vector::new(0., 1.),
shape: Shape::Rect,
shape_params: vec![2, height],
is_static: true,
vel: Vector::zero(),
},
// right
GameObject {
id: 93,
pos: Vector {x: width as f64, y: (height / 2) as f64},
orientation: Vector::new(0., -1.),
shape: Shape::Rect,
shape_params: vec![2, height],
is_static: true,
vel: Vector::zero(),
},
],
}
}
}

53
pong/src/game_object.rs Normal file
View File

@@ -0,0 +1,53 @@
pub mod game_object {
use crate::geom::geom::{BoundingBox, Vector};
#[derive(Clone, Debug, PartialEq)]
pub enum Shape {
Rect = 0,
Circle = 1,
}
#[derive(Clone, Debug, PartialEq)]
pub struct GameObject {
pub id: u16,
pub pos: Vector,
pub orientation: Vector,
pub shape: Shape,
pub shape_params: Vec<u16>,
pub vel: Vector,
pub is_static: bool,
}
impl GameObject {
pub fn update_pos(&mut self) {
self.pos.add(&self.vel);
// Keep last orientation if vel is now zero.
if self.vel == Vector::zero() {
return;
}
let mut orientation = self.vel.clone();
orientation.normalize();
self.orientation = orientation;
}
pub fn set_vel_x(&mut self, x: f64) {
self.vel.x = x
}
pub fn set_vel_y(&mut self, y: f64) {
self.vel.y = y
}
pub fn bounding_box(&self) -> BoundingBox {
match self.shape {
Shape::Rect => {
BoundingBox::create(&self.pos, self.shape_params[0], self.shape_params[1])
}
Shape::Circle => {
BoundingBox::create(&self.pos, self.shape_params[0] * 2, self.shape_params[0] * 2)
}
}
}
}
}

244
pong/src/geom.rs Normal file
View File

@@ -0,0 +1,244 @@
pub mod geom {
#[derive(Debug, Clone)]
pub struct Vector {
pub x: f64,
pub y: f64,
}
impl Vector {
pub fn zero() -> Vector {
Vector { x: 0., y: 0. }
}
pub fn unit() -> Vector {
let mut vector = Vector { x: 1., y: 1. };
vector.normalize();
vector
}
pub fn new(x: f64, y: f64) -> Vector {
Vector {
x, y
}
}
pub fn normalize(&mut self) {
if self == &Vector::zero() {
return;
}
let length = self.len();
self.x /= length;
self.y /= length;
}
pub fn orthogonal_clockwise(&mut self) {
let updated_x = self.y;
let updated_y = -self.x;
self.x = updated_x;
self.y = updated_y;
}
pub fn orthogonal_counter_clockwise(&mut self) {
let updated_x = -self.y;
let updated_y = self.x;
self.x = updated_x;
self.y = updated_y;
}
pub fn rotate(&mut self, radians: f64) {
let updated_x = self.x * radians.cos() - self.y * radians.sin();
let updated_y = self.x * radians.sin() + self.y * radians.cos();
self.x = updated_x;
self.y = updated_y;
}
pub fn add(&mut self, other: &Vector) {
self.x += other.x;
self.y += other.y;
}
pub fn sub(&mut self, other: &Vector) {
self.x -= other.x;
self.y -= other.y;
}
pub fn invert(&mut self) {
self.x = self.x * -1.;
self.y = self.y * -1.;
}
pub fn dot(&self, other: &Vector) -> f64 {
return self.x * other.x + self.y * other.y
}
pub fn angle(&self, other: &Vector) -> f64 {
let mut self_clone = self.clone();
self_clone.normalize();
let mut other_clone = other.clone();
other_clone.normalize();
let dot = self_clone.dot(&other_clone);
let dot_float = dot as f64;
let acos_res = dot_float.acos();
(acos_res * 100.0).round() / 100.0
}
// r = d - 2 * (d * n) * n
pub fn reflect(&mut self, onto: &Vector) {
let dot = self.dot(onto);
if dot == 0. {
self.invert();
return;
}
let mut orthogonal = self.get_opposing_orthogonal(onto);
let d_dot_n = orthogonal.dot(self);
orthogonal.scalar_multiplication(d_dot_n);
orthogonal.scalar_multiplication(2.);
self.sub(&orthogonal);
}
pub fn get_projection(&self, onto: &Vector) -> Vector {
let mut onto_normalized = onto.clone();
onto_normalized.normalize();
let dot = self.dot(&onto_normalized);
let mut projected = onto_normalized.clone();
projected.scalar_multiplication(dot);
projected
}
pub fn get_opposing_orthogonal(&self, onto: &Vector) -> Vector {
let mut orthogonal1 = onto.clone();
orthogonal1.orthogonal_clockwise();
if self.dot(&orthogonal1) < 0. {
return orthogonal1;
}
let mut orthogonal2 = onto.clone();
orthogonal2.orthogonal_counter_clockwise();
return orthogonal2;
}
pub fn scalar_multiplication(&mut self, n: f64) {
self.x *= n;
self.y *= n;
}
pub fn len(&self) -> f64 {
let distance = self.x.powi(2) + self.y.powi(2);
return (distance as f64).sqrt();
}
}
impl PartialEq for Vector {
fn eq(&self, other: &Self) -> bool {
(self.x * 1000.).round() == (other.x * 1000.).round() &&
(self.y * 1000.).round() == (other.y * 1000.).round()
}
}
pub struct BoundingBox {
top_left: Vector,
top_right: Vector,
bottom_left: Vector,
bottom_right: Vector,
}
impl BoundingBox {
pub fn create_from_coords(x: f64, y: f64, width: u16, height: u16) -> BoundingBox {
let center = Vector::new(x, y);
return BoundingBox::create(&center, width, height)
}
pub fn create(center: &Vector, width: u16, height: u16) -> BoundingBox {
let center_x = center.x;
let center_y = center.y;
let top_left = Vector {
x: center_x - (width as f64 / 2.),
y: center_y + (height as f64 / 2.),
};
let top_right = Vector {
x: center_x + (width as f64 / 2.),
y: center_y + (height as f64 / 2.),
};
let bottom_left = Vector {
x: center_x - (width as f64 / 2.),
y: center_y - (height as f64 / 2.),
};
let bottom_right = Vector {
x: center_x + (width as f64 / 2.),
y: center_y - (height as f64 / 2.),
};
BoundingBox {
top_left,
top_right,
bottom_left,
bottom_right,
}
}
pub fn points(&self) -> Vec<&Vector> {
return vec![
&self.top_left,
&self.top_right,
&self.bottom_left,
&self.bottom_right,
];
}
pub fn vert(&self) -> Range {
Range::new(self.bottom_left.y, self.top_left.y)
}
pub fn hor(&self) -> Range {
Range::new(self.top_left.x, self.top_right.x)
}
pub fn overlaps(&self, other: &BoundingBox) -> bool {
self.vert().overlaps(&other.vert()) && self.hor().overlaps(&other.hor())
}
pub fn is_point_within(&self, point: &Vector) -> bool {
return point.x >= self.top_left.x
&& point.x <= self.top_right.x
&& point.y <= self.top_left.y
&& point.y >= self.bottom_left.y;
}
}
pub struct Range {
min: f64,
max: f64,
}
impl Range {
pub fn new(a: f64, b: f64) -> Range {
if a <= b {
return Range {
min: a, max: b
}
}
return Range {
min: b, max: a
}
}
pub fn overlaps(&self, other: &Range) -> bool {
if self.min >= other.min && self.max <= other.max {
return true;
}
if self.max >= other.min && self.max <= other.max {
return true;
}
if other.min >= self.min && other.max <= self.max {
return true;
}
if other.max >= self.min && other.max <= self.max {
return true;
}
return false;
}
}
}

5
pong/src/lib.rs Normal file
View File

@@ -0,0 +1,5 @@
pub mod collision;
pub mod game_object;
pub mod geom;
pub mod game_field;
pub mod utils;

12
pong/src/utils.rs Normal file
View File

@@ -0,0 +1,12 @@
pub mod utils {
pub trait Logger {
fn log(&self, msg: &str);
}
pub struct NoopLogger {}
impl Logger for NoopLogger {
fn log(&self, msg: &str) {
}
}
}

View File

@@ -0,0 +1,45 @@
use rstest::rstest;
use pong::geom::geom::{BoundingBox, Vector};
#[rstest]
#[case(BoundingBox::create_from_coords(10., 10., 5, 5), Vector::new(10., 10.), true)]
#[case(BoundingBox::create_from_coords(10., 10., 5, 5), Vector::new(8., 8.), true)]
#[case(BoundingBox::create_from_coords(10., 10., 5, 5), Vector::new(20., 20.), false)]
pub fn should_correctly_determine_if_point_is_within_box(
#[case] bounding_box: BoundingBox,
#[case] point: Vector,
#[case] expected: bool,
) {
let res = bounding_box.is_point_within(&point);
assert_eq!(res, expected);
}
#[rstest]
#[case(
BoundingBox::create_from_coords(10., 10., 5, 5),
BoundingBox::create_from_coords(10., 10., 5, 5),
true
)]
#[case(
BoundingBox::create_from_coords(10., 10., 5, 5),
BoundingBox::create_from_coords(8., 8., 5, 5),
true
)]
#[case(
BoundingBox::create_from_coords(10., 10., 5, 5),
BoundingBox::create_from_coords(4.9, 4.9, 5, 5),
false
)]
#[case(
BoundingBox::create_from_coords(10., 10., 5, 5),
BoundingBox::create_from_coords(5., 5., 5, 5),
true
)]
pub fn should_correctly_determine_if_overlap(
#[case] bounding_box_a: BoundingBox,
#[case] bounding_box_b: BoundingBox,
#[case] expected: bool,
) {
let res = bounding_box_a.overlaps(&bounding_box_b);
assert_eq!(res, expected);
}

View File

@@ -0,0 +1,73 @@
use rstest::rstest;
use pong::collision::collision::CollisionHandler;
use pong::game_object::game_object::{GameObject, Shape};
use pong::geom::geom::Vector;
#[rstest]
#[case(
// given
GameObject {id: 1, pos: Vector::zero(), vel: Vector::new(1., 0.), shape: Shape::Rect, shape_params: vec![], is_static: true, orientation: Vector::new(1., 0.)},
GameObject {id: 2, pos: Vector::zero(), vel: Vector::new(0., 0.), shape: Shape::Rect, shape_params: vec![], is_static: true, orientation: Vector::new(0., 1.)},
// expected
GameObject {id: 1, pos: Vector::zero(), vel: Vector::new(1., 0.), shape: Shape::Rect, shape_params: vec![], is_static: true, orientation: Vector::new(1., 0.)},
GameObject {id: 2, pos: Vector::zero(), vel: Vector::new(0., 0.), shape: Shape::Rect, shape_params: vec![], is_static: true, orientation: Vector::new(0., 1.)},
)]
#[case(
// given
GameObject {id: 1, pos: Vector::zero(), vel: Vector::new(1., 0.), shape: Shape::Rect, shape_params: vec![], is_static: false, orientation: Vector::new(1., 0.)},
GameObject {id: 2, pos: Vector::zero(), vel: Vector::new(0., 0.), shape: Shape::Rect, shape_params: vec![], is_static: true, orientation: Vector::new(0., 1.)},
// expected
GameObject {id: 1, pos: Vector::zero(), vel: Vector::new(-1., 0.), shape: Shape::Rect, shape_params: vec![], is_static: false, orientation: Vector::new(1., 0.)},
GameObject {id: 2, pos: Vector::zero(), vel: Vector::new(0., 0.), shape: Shape::Rect, shape_params: vec![], is_static: true, orientation: Vector::new(0., 1.)},
)]
#[case(
// given
GameObject {id: 1, pos: Vector::zero(), vel: Vector::new(-1., 0.), shape: Shape::Rect, shape_params: vec![], is_static: false, orientation: Vector::new(-1., 0.)},
GameObject {id: 2, pos: Vector::zero(), vel: Vector::new(0., 0.), shape: Shape::Rect, shape_params: vec![], is_static: true, orientation: Vector::new(0., 1.)},
// expected
GameObject {id: 1, pos: Vector::zero(), vel: Vector::new(1., 0.), shape: Shape::Rect, shape_params: vec![], is_static: false, orientation: Vector::new(-1., 0.)},
GameObject {id: 2, pos: Vector::zero(), vel: Vector::new(0., 0.), shape: Shape::Rect, shape_params: vec![], is_static: true, orientation: Vector::new(0., 1.)},
)]
#[case(
// given
GameObject {id: 1, pos: Vector::zero(), vel: Vector::new(1., 1.), shape: Shape::Rect, shape_params: vec![], is_static: false, orientation: Vector::new(1., 1.)},
GameObject {id: 2, pos: Vector::zero(), vel: Vector::new(0., 0.), shape: Shape::Rect, shape_params: vec![], is_static: true, orientation: Vector::new(0., 1.)},
// expected
GameObject {id: 1, pos: Vector::zero(), vel: Vector::new(-1., 1.), shape: Shape::Rect, shape_params: vec![], is_static: false, orientation: Vector::new(1., 1.)},
GameObject {id: 2, pos: Vector::zero(), vel: Vector::new(0., 0.), shape: Shape::Rect, shape_params: vec![], is_static: true, orientation: Vector::new(0., 1.)},
)]
#[case(
// given
GameObject {id: 1, pos: Vector::zero(), vel: Vector::new(-2., 0.), shape: Shape::Rect, shape_params: vec![], is_static: false, orientation: Vector::new(-1., 0.)},
GameObject {id: 2, pos: Vector::zero(), vel: Vector::new(0., 0.), shape: Shape::Rect, shape_params: vec![], is_static: true, orientation: Vector::new(0., 1.)},
// expected
GameObject {id: 1, pos: Vector::zero(), vel: Vector::new(2., 0.), shape: Shape::Rect, shape_params: vec![], is_static: false, orientation: Vector::new(-1., 0.)},
GameObject {id: 2, pos: Vector::zero(), vel: Vector::new(0., 0.), shape: Shape::Rect, shape_params: vec![], is_static: true, orientation: Vector::new(0., 1.)},
)]
#[case(
// given
GameObject {id: 1, pos: Vector::zero(), vel: Vector::new(1., 0.), shape: Shape::Rect, shape_params: vec![], is_static: false, orientation: Vector::new(1., 0.)},
GameObject {id: 2, pos: Vector::zero(), vel: Vector::new(0., 1.), shape: Shape::Rect, shape_params: vec![], is_static: true, orientation: Vector::new(0., 1.)},
// expected
GameObject {id: 1, pos: Vector::zero(), vel: Vector::new(-1., 1.), shape: Shape::Rect, shape_params: vec![], is_static: false, orientation: Vector::new(1., 0.)},
GameObject {id: 2, pos: Vector::zero(), vel: Vector::new(0., 1.), shape: Shape::Rect, shape_params: vec![], is_static: true, orientation: Vector::new(0., 1.)},
)]
#[case(
// given
GameObject {id: 1, pos: Vector::zero(), vel: Vector::new(-2., 1.), shape: Shape::Rect, shape_params: vec![], is_static: false, orientation: Vector::new(-1., 0.)},
GameObject {id: 2, pos: Vector::zero(), vel: Vector::new(0., 0.), shape: Shape::Rect, shape_params: vec![], is_static: true, orientation: Vector::new(0., 1.)},
// expected
GameObject {id: 1, pos: Vector::zero(), vel: Vector::new(2., 1.), shape: Shape::Rect, shape_params: vec![], is_static: false, orientation: Vector::new(-1., 0.)},
GameObject {id: 2, pos: Vector::zero(), vel: Vector::new(0., 0.), shape: Shape::Rect, shape_params: vec![], is_static: true, orientation: Vector::new(0., 1.)},
)]
pub fn should_handle_collision(
#[case] mut obj_a: GameObject,
#[case] obj_b: GameObject,
#[case] expected_a: GameObject,
#[case] expected_b: GameObject,
) {
let handler = CollisionHandler {};
handler.handle(&mut obj_a, &obj_b);
assert_eq!(obj_a, expected_a);
assert_eq!(obj_b, expected_b);
}

View File

@@ -0,0 +1,46 @@
use rstest::rstest;
use pong::collision::collision::{Collision, CollisionDetector};
use pong::game_object::game_object::{GameObject, Shape};
use pong::geom::geom::{Vector};
#[rstest]
#[case(vec![], vec![])]
#[case(
vec![
GameObject{id: 1, pos: Vector{x: 50., y: 50.}, shape: Shape::Rect, shape_params: vec![20, 20], vel: Vector::zero(), is_static: false, orientation: Vector::new(1., 1.)},
GameObject{id: 2, pos: Vector{x: 50., y: 50.}, shape: Shape::Rect, shape_params: vec![20, 20], vel: Vector::zero(), is_static: false, orientation: Vector::new(1., 1.)}
],
vec![Collision(1, 2)]
)]
#[case(
vec![
GameObject{id: 1, pos: Vector{x: 60., y: 65.}, shape: Shape::Rect, shape_params: vec![20, 20], vel: Vector::zero(), is_static: false, orientation: Vector::new(1., 1.)},
GameObject{id: 2, pos: Vector{x: 50., y: 50.}, shape: Shape::Rect, shape_params: vec![20, 20], vel: Vector::zero(), is_static: false, orientation: Vector::new(1., 1.)}
],
vec![Collision(1, 2)]
)]
#[case(
vec![
GameObject{id: 1, pos: Vector{x: 50., y: 50.}, shape: Shape::Rect, shape_params: vec![20, 20], vel: Vector::zero(), is_static: false, orientation: Vector::new(1., 1.)},
GameObject{id: 2, pos: Vector{x: 80., y: 80.}, shape: Shape::Rect, shape_params: vec![20, 20], vel: Vector::zero(), is_static: false, orientation: Vector::new(1., 1.)}
],
vec![]
)]
#[case(
vec![
GameObject{id: 1, pos: Vector{x: 50., y: 50.}, shape: Shape::Rect, shape_params: vec![50, 50], vel: Vector::zero(), is_static: false, orientation: Vector::new(1., 1.)},
GameObject{id: 2, pos: Vector{x: 500., y: 50.}, shape: Shape::Rect, shape_params: vec![50, 50], vel: Vector::zero(), is_static: false, orientation: Vector::new(1., 1.)}
],
vec![]
)]
pub fn should_detect_collisions(
#[case] objs: Vec<GameObject>,
#[case] expected_collisions: Vec<Collision>,
) {
let detector = CollisionDetector::new();
let res = detector.detect_collisions(objs.iter().collect());
assert_eq!(
res.get_collisions(),
expected_collisions.iter().collect::<Vec<&Collision>>()
);
}

View File

@@ -0,0 +1,64 @@
#[cfg(test)]
mod game_field_tests {
use pong::game_field::{Field, Input, InputType};
#[test]
fn player_input_update_pos__up() {
let height = 1000;
let mut field = Field::mock(1000, height);
field.add_player(1, 50, height / 2);
let inputs = vec![Input {
input: InputType::UP,
obj_id: 1,
}];
field.tick(inputs);
let players = field.players();
let player = players.first().unwrap();
assert_eq!(player.obj.pos.y, height as f64 / 2. + 1.);
}
#[test]
fn player_input_update_pos__down() {
let height = 1000;
let mut field = Field::mock(1000, height);
field.add_player(1, 50, height / 2);
let inputs = vec![Input {
input: InputType::DOWN,
obj_id: 1,
}];
field.tick(inputs);
let players = field.players();
let player = players.first().unwrap();
assert_eq!(player.obj.pos.y, height as f64 / 2. - 1.);
}
#[test]
fn player_input_update_out_of_bounds__up() {
let height = 1000;
let mut field = Field::mock(1000, height);
field.add_player(1, 50, height - height / 5 / 2);
let inputs = vec![Input {
input: InputType::UP,
obj_id: 1,
}];
field.tick(inputs);
let players = field.players();
let player = players.first().unwrap();
assert_eq!(player.obj.pos.y, height as f64 - height as f64 / 5. / 2.);
}
#[test]
fn player_input_update_out_of_bounds__down() {
let height = 1000;
let mut field = Field::mock(1000, height);
field.add_player(1, 50, height / 5 / 2);
let inputs = vec![Input {
input: InputType::DOWN,
obj_id: 1,
}];
field.tick(inputs);
let players = field.players();
let player = players.first().unwrap();
assert_eq!(player.obj.pos.y, height as f64 / 5. / 2.);
}
}

View File

@@ -0,0 +1,19 @@
use rstest::rstest;
use pong::game_object::game_object::{GameObject, Shape};
use pong::geom::geom::{Vector};
#[rstest]
#[case(Vector::new(100., 100.), Vector::new(-1., 1.), Vector::new(99., 101.))]
pub fn should_update_pos(#[case] start_pos: Vector, #[case] vel: Vector, #[case] expected_pos: Vector) {
let mut obj = GameObject {
id: 1,
pos: Vector::new(start_pos.x as f64, start_pos.y as f64),
vel,
shape: Shape::Rect,
shape_params: vec![],
is_static: false,
orientation: Vector::new(1., 0.)
};
obj.update_pos();
assert_eq!(obj.pos, expected_pos);
}

137
pong/tests/vector_tests.rs Normal file
View File

@@ -0,0 +1,137 @@
use rstest::rstest;
use pong::geom::geom::Vector;
use std::f64::consts::PI;
use std::f64::consts::FRAC_PI_2;
use std::f64::consts::FRAC_PI_4;
#[rstest]
#[case(1., 0., 1.)]
#[case(0., 1., 1.)]
#[case(3., 4., 5.)]
pub fn should_get_correct_length(#[case] x: f64, #[case] y: f64, #[case] expected: f64) {
let vector = Vector { x, y };
let len = vector.len();
assert_eq!(len, expected);
}
#[rstest]
#[case(1., 0., 1., 0.)]
#[case(3., 0., 1., 0.)]
#[case(3., 4., 3. / 5., 4. / 5.)]
pub fn should_normalize_correctly(
#[case] x: f64,
#[case] y: f64,
#[case] expected_x: f64,
#[case] expected_y: f64,
) {
let mut vector = Vector { x, y };
let expected = Vector {
x: expected_x,
y: expected_y,
};
vector.normalize();
assert_eq!(vector, expected);
}
#[rstest]
#[case(Vector::new(1., 1.), Vector::new(2., 2.), 0.)]
#[case(Vector::new(1., 0.), Vector::new(1., 1.), 0.79)]
pub fn should_calculate_angle_correctly(
#[case] vector_a: Vector,
#[case] vector_b: Vector,
#[case] expected_angle: f64
) {
let res = vector_a.angle(&vector_b);
assert_eq!(res, expected_angle);
}
#[rstest]
#[case(Vector::new(1., 0.), Vector::new(0., -1.))]
#[case(Vector::new(0., 1.), Vector::new(1., 0.))]
#[case(Vector::new(7., 7.), Vector::new(7., -7.))]
pub fn should_get_orthogonal_clockwise(
#[case] mut vector: Vector,
#[case] expected: Vector
) {
vector.orthogonal_clockwise();
assert_eq!(vector, expected);
}
#[rstest]
#[case(Vector::new(0., -1.), Vector::new(1., 0.))]
#[case(Vector::new(1., 0.), Vector::new(0., 1.))]
#[case(Vector::new(7., 7.), Vector::new(-7., 7.))]
pub fn should_get_orthogonal_counter_clockwise(
#[case] mut vector: Vector,
#[case] expected: Vector
) {
vector.orthogonal_counter_clockwise();
assert_eq!(vector, expected);
}
#[rstest]
#[case(Vector::new(1., 0.), FRAC_PI_4, Vector::unit())]
pub fn should_correctly_rotate(
#[case] mut vector: Vector,
#[case] radians: f64,
#[case] expected: Vector
) {
vector.rotate(radians);
assert_eq!(vector, expected);
}
#[rstest]
#[case(Vector::new(1., 0.), Vector::new(1., 0.), 1.)]
#[case(Vector::new(1., 0.), Vector::new(0., 1.), 0.)]
#[case(Vector::new(1., 0.), Vector::new(-1., 0.), -1.)]
pub fn should_calculate_dot_product(
#[case] mut vector: Vector,
#[case] mut other: Vector,
#[case] expected: f64
) {
let dot = vector.dot(&other);
assert_eq!(dot, expected);
}
#[rstest]
#[case(Vector::new(0., 1.), Vector::new(1., 0.), Vector::new(0., 0.))]
#[case(Vector::new(1., 0.), Vector::new(1., 0.), Vector::new(1., 0.))]
#[case(Vector::new(-1., 0.), Vector::new(1., 0.), Vector::new(-1., 0.))]
#[case(Vector::new(1., 1.), Vector::new(1., 0.), Vector::new(1., 0.))]
#[case(Vector::new(2., 1.), Vector::new(1., 0.), Vector::new(2., 0.))]
pub fn should_get_projection(
#[case] vector: Vector,
#[case] other: Vector,
#[case] expected: Vector,
) {
let projected = vector.get_projection(&other);
assert_eq!(projected, expected);
}
#[rstest]
#[case(Vector::new(1., 1.), Vector::new(1., 0.), Vector::new(0., -1.))]
#[case(Vector::new(-1., -1.), Vector::new(1., 0.), Vector::new(0., 1.))]
pub fn should_get_opposing_orthogonal(
#[case] vector: Vector,
#[case] onto: Vector,
#[case] expected: Vector,
) {
let orthogonal = vector.get_opposing_orthogonal(&onto);
assert_eq!(orthogonal, expected);
}
#[rstest]
#[case(Vector::new(1., 1.), Vector::new(1., 0.), Vector::new(1., -1.))]
#[case(Vector::new(-1., 1.), Vector::new(1., 0.), Vector::new(-1., -1.))]
#[case(Vector::new(1., -1.), Vector::new(0., 1.), Vector::new(-1., -1.))]
#[case(Vector::new(-1., -1.), Vector::new(0., 1.), Vector::new(1., -1.))]
pub fn should_reflect_vector(
#[case] mut vector: Vector,
#[case] onto: Vector,
#[case] expected: Vector,
) {
vector.reflect(&onto);
assert_eq!(vector, expected);
}

View File

@@ -1,6 +1,24 @@
mod utils;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::cmp::{max, min};
use wasm_bindgen::prelude::*;
use pong::collision::collision::{Collision, CollisionDetector};
use pong::game_field::{Field, Input, InputType};
use pong::game_object::game_object::{GameObject, Shape};
use pong::geom::geom::Vector;
use pong::utils::utils::Logger;
extern crate serde_json;
extern crate web_sys;
// A macro to provide `println!(..)`-style syntax for `console.log` logging.
macro_rules! log {
( $( $t:tt )* ) => {
web_sys::console::log_1(&format!( $( $t )* ).into());
}
}
// When the `wee_alloc` feature is enabled, use `wee_alloc` as the global
// allocator.
@@ -9,11 +27,137 @@ use wasm_bindgen::prelude::*;
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
#[wasm_bindgen]
extern {
fn alert(s: &str);
#[repr(packed)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct GameObjectDTO {
pub id: u16,
pub x: u16,
pub y: u16,
pub shape_param_1: u16,
pub shape_param_2: u16,
}
impl GameObjectDTO {
pub fn from(obj: &GameObject) -> GameObjectDTO {
return GameObjectDTO {
id: obj.id,
x: obj.pos.x as u16,
y: obj.pos.y as u16,
shape_param_1: match obj.shape_params[..] {
[p1, _] => p1,
[p1] => p1,
_ => 0,
},
shape_param_2: match obj.shape_params[..] {
[_, p2] => p2,
_ => 0,
},
};
}
}
#[wasm_bindgen]
pub fn greet() {
alert("Hello, rust-wasm!");
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum InputTypeDTO {
UP,
DOWN,
}
impl InputTypeDTO {
pub fn to_input_type(&self) -> InputType {
match self {
InputTypeDTO::UP => InputType::UP,
InputTypeDTO::DOWN => InputType::DOWN
}
}
}
#[wasm_bindgen]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct InputDTO {
pub input: InputTypeDTO,
pub obj_id: u16,
}
impl InputDTO {
pub fn to_input(&self) -> Input {
return Input {
input: self.input.to_input_type(),
obj_id: self.obj_id,
}
}
}
#[wasm_bindgen]
pub struct FieldWrapper {
field: Field
}
#[wasm_bindgen]
impl FieldWrapper {
pub fn new() -> FieldWrapper {
let field = Field::new(Box::new(WasmLogger {}));
FieldWrapper {
field
}
}
pub fn width(&self) -> u16 {
self.field.width
}
pub fn height(&self) -> u16 {
self.field.height
}
pub fn tick(&mut self, inputs_js: &JsValue) {
let input_dtos: Vec<InputDTO> = inputs_js.into_serde().unwrap();
let inputs = input_dtos.into_iter().map(|i| i.to_input()).collect::<Vec<Input>>();
self.field.tick(inputs);
// log!("{:?}", self.field.collisions);
}
pub fn objects(&self) -> *const GameObjectDTO {
let mut objs = vec![];
objs.append(
&mut self.field
.balls
.iter()
.map(|ball| GameObjectDTO::from(&ball.obj))
.collect::<Vec<GameObjectDTO>>(),
);
objs.append(
&mut self.field
.players
.iter()
.map(|player| GameObjectDTO::from(&player.obj))
.collect::<Vec<GameObjectDTO>>(),
);
objs.append(
&mut self.field
.bounds.objs
.iter()
.map(|bound| GameObjectDTO::from(&bound))
.collect::<Vec<GameObjectDTO>>()
);
objs.as_ptr()
}
pub fn get_state(&self) -> String {
let json = json!(GameObjectDTO {
shape_param_1: 0,
shape_param_2: 0,
x: 10,
y: 10,
id: 1
});
serde_json::to_string(&json).unwrap()
}
}
pub struct WasmLogger {}
impl Logger for WasmLogger {
fn log(&self, msg: &str) {
log!("{}", msg)
}
}

View File

@@ -3,9 +3,24 @@
<head>
<meta charset="utf-8">
<title>Hello wasm-pack!</title>
<style>
body {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
</style>
</head>
<body>
<noscript>This page contains webassembly and javascript content, please enable javascript in your browser.</noscript>
<canvas id="wasm-app-canvas"></canvas>
<script src="./bootstrap.js"></script>
</body>
</html>

View File

@@ -1,3 +1,121 @@
import * as wasm from "wasm-app";
import { FieldWrapper, GameObject } from "wasm-app";
import { memory } from "wasm-app/rust_wasm_bg";
wasm.greet();
const GRID_COLOR = "#CCCCCC";
const field = FieldWrapper.new();
const width = field.width();
const height = field.height();
const canvas = document.getElementById('wasm-app-canvas');
canvas.height = height
canvas.width = width
const ctx = canvas.getContext('2d');
let keysDown = new Set();
console.log(field.get_state())
const renderLoop = () => {
let actions = getInputActions();
field.tick(actions);
render();
requestAnimationFrame(renderLoop);
}
const render = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// drawField();
drawObjects();
}
const drawField = () => {
ctx.beginPath();
ctx.strokeStyle = GRID_COLOR;
ctx.rect(1, 1, field.width - 2, field.height - 2);
ctx.stroke();
}
const drawObjects = () => {
const objects = getObjects();
ctx.beginPath();
objects.forEach(obj => {
ctx.strokeStyle = GRID_COLOR;
// rect
if (obj.shape_2) {
ctx.moveTo(obj.x, obj.y)
ctx.arc(obj.x, obj.y, 10, 0, 2 * Math.PI);
ctx.rect(obj.x - obj.shape_1 / 2, obj.y - obj.shape_2 / 2, obj.shape_1, obj.shape_2);
}
// circle
else {
ctx.moveTo(obj.x, obj.y);
ctx.arc(obj.x, obj.y, obj.shape_1, 0, 2 * Math.PI);
}
})
ctx.stroke();
}
const getObjects = () => {
const objectsPtr = field.objects();
const objects = new Uint16Array(memory.buffer, objectsPtr, 3 * 5 + 4 * 5) // player1, player2, ball + 4x bounds
.reduce((acc, val) => {
if (!acc.length) {
return [[val]]
}
const last = acc[acc.length - 1]
if (last.length === 5) {
return [...acc, [val]]
}
return [...acc.slice(0, -1), [...last, val]]
}, [])
.map(([id, x, y, shape_1, shape_2]) => {
return {id, x, y: height - y, shape_1, shape_2};
});
return objects;
}
const listenToKeys = () => {
const relevantKeys = ['ArrowUp', 'ArrowDown', 'KeyW', 'KeyS']
document.addEventListener('keydown', (e) => {
if (!relevantKeys.includes(e.code)) {
return;
}
keysDown.add(e.code)
})
document.addEventListener('keyup', (e) => {
if (!relevantKeys.includes(e.code)) {
return;
}
keysDown.delete(e.code);
})
}
const getInputActions = () => {
return [...keysDown].map(key => {
switch(key) {
case 'KeyW':
return {input: 'UP', obj_id: 0}
case 'KeyS':
return {input: 'DOWN', obj_id: 0}
case 'ArrowUp':
return {input: 'UP', obj_id: 1}
case 'ArrowDown':
return {input: 'DOWN', obj_id: 1}
default:
return null
}
}).filter(it => !!it);
}
listenToKeys();
render();
requestAnimationFrame(renderLoop);