Разработка игры на ggez, часть 4: Коллизия
В этой части руководства мы создадим мяч для нашей игры, и заставим его двигаться, а главное отскакивать от стен и наших ракеток.
Для работы с такими понятиями как коллизия, нужна математика. ggez
не реализует собственные математические функции, вместо этого он использует популярную библиотеку nalgebra
. Она предоставляет огромное количество мат. функций и удобные обертки к ним. Но не переживайте, сложного в нашей игре ничего не будет.
Создание мяча
Так как наш мяч будет обладать некоторыми свойствами - создадим под него отдельную структуру, в которую поместим следующее: текущее место положение (в виде координат), радиус, а так же значение скорости. Скорость будет отдельно для X и для Y, объяснение этому будет позже.
Для хранения текущего положения мяча, мы будем использовать nalgebra
и специальный тип - nalgebra::geometry::Point2. Данный тип обладает множеством полезных методов и используется повсеместно в ggez
, он идеально подходит для хранения координат.
const LINE_WIDTH: f32 = 2.0;
const BALL_RADIUS: f32 = 10.0;
const BALL_VELOCITY_X: f32 = 2.0;
const BALL_VELOCITY_Y: f32 = 2.0;
struct Ball {
point: na::Point2<f32>,
radius: f32,
velocity: [f32; 2],
}
struct GameState {
left: Rect,
right: Rect,
ball: Ball,
input: InputState,
}
impl GameState {
fn new() -> Self {
// часть функции опущено
let ball = Ball {
point: na::Point2::new(ARENA_WIDTH * 0.5, ARENA_HEIGHT * 0.5),
radius: BALL_RADIUS,
velocity: [BALL_VELOCITY_X, BALL_VELOCITY_Y],
};
}
}
Теперь с помощью уже известной нам функции draw()
можно нарисовать мяч на экране, для этого воспользуемся функцией new_circle:
fn draw(&mut self, ctx: &mut Context) -> GameResult<()> {
// часть функции опущено
let ball = graphics::Mesh::new_circle(
ctx,
graphics::DrawMode::fill(),
self.ball.point,
self.ball.radius,
0.1,
graphics::WHITE,
)?;
graphics::draw(ctx, &ball, graphics::DrawParam::default())?;
}
У любопытных мог возникнуть вопрос, что такое tolerance? По ссылке вы найдете подробное объяснение, а для нетерпеливых скажу лишь, что чем меньше это число, тем более округлей выглядит наш мяч.
Движение мяча и коллизия
В начале заставим мяч двигаться, что этого дополним нам метод update()
:
// Move the ball
self.ball.point.x += self.ball.velocity[0];
self.ball.point.y += self.ball.velocity[1];
Отмечу что мы используем постоянную скорость, то есть она будет привязана к кол-ву кадров в секунду. Для нашей простой игры это подходящий вариант, но для более масштабных проектов вы должны использовать delta timing.
Теперь займемся коллизией, для начала заставим наш мяч отскакивать от нижней и верхней границы игрового поля. Тут нам пригодится наша скорость по X и Y, которую мы задали ранее. Все что требуется для симуляции отскакивания, то есть изменения вектора движения, так это заменить знак скорости на противоположный. Для X - при приближении к боковым границам, а для Y - к нижней и верхней. Чтобы мяч не “втыкался” в стену мы так же учитываем его радиус. Дополним наш update()
метод:
if (self.ball.point.y - self.ball.radius < 0.0)
|| (self.ball.point.y >= ARENA_HEIGHT - self.ball.radius)
{
self.ball.velocity[1] = -self.ball.velocity[1];
}
Чтобы мяч отталкивался от ракеток, нужно действовать по другому, так как ракетки не только движутся, но и имеют объем. Для этого мы воспользуемся формулой для проверки нахождения точки в прямоугольнике. Точной будет центр мяча, а прямоугольником - ракетка. Выделим этот код в отдельную функцию, не забываем про радиус мяча:
fn ball_in_rect(ball: &Ball, rect: &Rect) -> bool {
let x1 = rect.x - ball.radius;
let y1 = rect.y - ball.radius;
let x2 = rect.x + rect.w + ball.radius;
let y2 = rect.y + rect.h + ball.radius;
ball.point.x >= x1 && ball.point.x <= x2 && ball.point.y >= y1 && ball.point.y <= y2
}
В update()
проверяем коллизию, и если она произошла, то как и в случае с границами, просто меняем скорость на противоположную, но теперь по X:
if ball_in_rect(&self.ball, &self.right) || ball_in_rect(&self.ball, &self.left) {
self.ball.velocity[0] = -self.ball.velocity[0];
}
Осталось последнее. Сейчас если игрок не попадет по мячу, он продолжит двигаться за краями игрового поля, что бы этого не произошло, нужно возвращать его в центр, когда он пересекает боковую границу:
if (self.ball.point.x + self.ball.radius * 2.0 < 0.0)
|| (self.ball.point.x >= ARENA_WIDTH + self.ball.radius * 2.0)
{
self.ball.point.x = ARENA_WIDTH * 0.5;
self.ball.point.y = ARENA_HEIGHT * 0.5;
}
Готово! Теперь мы можем скомпилировать и запустить наш код с помощью cargo run
и проверить, мяч движется и отскакивает от стен и ракеток. В финальном коде мы добавили линию, разделяющую поле пополам.
Полный пример:
use ggez::event::{self, EventHandler, KeyCode, KeyMods};
use ggez::graphics::{self, Rect};
use ggez::timer;
use ggez::{conf, Context, ContextBuilder, GameResult};
use ggez::nalgebra as na;
const ARENA_WIDTH: f32 = 500.0;
const ARENA_HEIGHT: f32 = 500.0;
const PADDLE_WIDTH: f32 = 10.0;
const PADDLE_HEIGHT: f32 = 80.0;
const DESIRED_FPS: u32 = 60;
const MOVE_SPEED: f32 = 1.9;
const LINE_WIDTH: f32 = 2.0;
const BALL_RADIUS: f32 = 10.0;
const BALL_VELOCITY_X: f32 = 2.0;
const BALL_VELOCITY_Y: f32 = 2.0;
struct InputState {
right_yaxis: f32,
left_yaxis: f32,
}
struct Ball {
point: na::Point2<f32>,
radius: f32,
velocity: [f32; 2],
}
struct GameState {
left: Rect,
right: Rect,
ball: Ball,
line: [na::Point2<f32>; 2],
input: InputState,
}
fn ball_in_rect(ball: &Ball, rect: &Rect) -> bool {
let x1 = rect.x - ball.radius;
let y1 = rect.y - ball.radius;
let x2 = rect.x + rect.w + ball.radius;
let y2 = rect.y + rect.h + ball.radius;
ball.point.x >= x1 && ball.point.x <= x2 && ball.point.y >= y1 && ball.point.y <= y2
}
impl EventHandler for GameState {
fn update(&mut self, ctx: &mut Context) -> GameResult<()> {
while timer::check_update_time(ctx, DESIRED_FPS) {
self.left.y = (self.left.y + self.input.left_yaxis * MOVE_SPEED)
.min(ARENA_HEIGHT - PADDLE_HEIGHT)
.max(0.0);
self.right.y = (self.right.y + self.input.right_yaxis * MOVE_SPEED)
.min(ARENA_HEIGHT - PADDLE_HEIGHT)
.max(0.0);
// Move the ball
self.ball.point.x += self.ball.velocity[0];
self.ball.point.y += self.ball.velocity[1];
// Bounce at the top or the bottom of the arena
if (self.ball.point.y - self.ball.radius < 0.0)
|| (self.ball.point.y >= ARENA_HEIGHT - self.ball.radius)
{
self.ball.velocity[1] = -self.ball.velocity[1];
}
// Bounce at the paddles
if ball_in_rect(&self.ball, &self.right) || ball_in_rect(&self.ball, &self.left) {
self.ball.velocity[0] = -self.ball.velocity[0];
}
// Reset
if (self.ball.point.x + self.ball.radius * 2.0 < 0.0)
|| (self.ball.point.x >= ARENA_WIDTH + self.ball.radius * 2.0)
{
self.ball.point.x = ARENA_WIDTH * 0.5;
self.ball.point.y = ARENA_HEIGHT * 0.5;
}
}
Ok(())
}
fn draw(&mut self, ctx: &mut Context) -> GameResult<()> {
graphics::clear(ctx, graphics::BLACK);
let left = graphics::Mesh::new_rectangle(
ctx,
graphics::DrawMode::fill(),
self.left,
graphics::WHITE,
)?;
let right = graphics::Mesh::new_rectangle(
ctx,
graphics::DrawMode::fill(),
self.right,
graphics::WHITE,
)?;
let ball = graphics::Mesh::new_circle(
ctx,
graphics::DrawMode::fill(),
self.ball.point,
self.ball.radius,
0.1,
graphics::WHITE,
)?;
let line = graphics::Mesh::new_line(ctx, &self.line, LINE_WIDTH, graphics::WHITE)?;
graphics::draw(ctx, &left, graphics::DrawParam::default())?;
graphics::draw(ctx, &right, graphics::DrawParam::default())?;
graphics::draw(ctx, &ball, graphics::DrawParam::default())?;
graphics::draw(ctx, &line, graphics::DrawParam::default())?;
graphics::present(ctx)?;
Ok(())
}
fn key_down_event(
&mut self,
ctx: &mut Context,
keycode: KeyCode,
_keymod: KeyMods,
_repeat: bool,
) {
match keycode {
KeyCode::W => {
self.input.left_yaxis = -1.0;
}
KeyCode::S => {
self.input.left_yaxis = 1.0;
}
KeyCode::O => {
self.input.right_yaxis = -1.0;
}
KeyCode::L => {
self.input.right_yaxis = 1.0;
}
KeyCode::Escape => event::quit(ctx),
_ => (),
}
}
fn key_up_event(&mut self, _ctx: &mut Context, keycode: KeyCode, _keymod: KeyMods) {
match keycode {
KeyCode::W | KeyCode::S => {
self.input.left_yaxis = 0.0;
}
KeyCode::O | KeyCode::L => {
self.input.right_yaxis = 0.0;
}
_ => (),
}
}
}
impl GameState {
fn new() -> Self {
let left = Rect::new(
0.0, // x
ARENA_HEIGHT * 0.5 - PADDLE_HEIGHT * 0.5, // y
PADDLE_WIDTH,
PADDLE_HEIGHT,
);
let right = Rect::new(
ARENA_WIDTH - PADDLE_WIDTH, // x
ARENA_HEIGHT * 0.5 - PADDLE_HEIGHT * 0.5, // y
PADDLE_WIDTH,
PADDLE_HEIGHT,
);
let ball = Ball {
point: na::Point2::new(ARENA_WIDTH * 0.5, ARENA_HEIGHT * 0.5),
radius: BALL_RADIUS,
velocity: [BALL_VELOCITY_X, BALL_VELOCITY_Y],
};
let line = [
na::Point2::new(ARENA_WIDTH * 0.5, 0.0),
na::Point2::new(ARENA_WIDTH * 0.5, ARENA_HEIGHT),
];
let input = InputState {
right_yaxis: 0.0,
left_yaxis: 0.0,
};
Self {
left,
right,
ball,
line,
input,
}
}
}
fn main() {
let mut cb = ContextBuilder::new("game01", "author")
.window_setup(conf::WindowSetup::default().title("My game!"))
.window_mode(conf::WindowMode::default().dimensions(ARENA_WIDTH, ARENA_HEIGHT));
let (ctx, event_loop) = &mut cb.build().unwrap();
let mut state = GameState::new();
match event::run(ctx, event_loop, &mut state) {
Ok(_) => println!("Exited cleanly."),
Err(e) => println!("Error occured: {}", e),
}
}