Перейти к содержимому

Уроки · 86

Наводим порядок

Давай посмотрим на код, который получился после урока «Враги появляются сами». Игра работает, но в main стало тесно: всё свалено в один большой loop, и чтобы понять, что где происходит, приходится читать его целиком. Пока программа маленькая, так ещё можно. А когда она разрослась, самое время разложить её на части.

Сразу скажу: короче код не станет, он станет даже длиннее. Зато каждая часть будет делать одно своё дело, и на неё можно смотреть отдельно от остальных. Так проще понять, что происходит. Игра при этом будет работать точно так же, как раньше.

Посмотри на кусок из прошлого урока:

// Сдвигаем врага: сначала по x, потом по y.
enemy.x += enemy.direction_x * ENEMY_SPEED * dt;
enemy.y += enemy.direction_y * ENEMY_SPEED * dt;

x и y тут всё время идут парой, и записывать их приходится каждый раз отдельно. И у направления тоже две части, direction_x и direction_y. Мы уже решали это в уроке «Точка и прямоугольник»: завели Point, одно имя на два числа. Сделаем так же и здесь. И у врага, и у пули будут position и direction:

// Один враг: где он сейчас и куда идёт.
struct Enemy {
position: Point,
direction: Point,
}
// Одна пуля: где она сейчас и куда летит.
struct Bullet {
position: Point,
direction: Point,
}

Над Point появляется одна новая строчка:

// Разрешаем копировать точку на ходу, как обычное число.
#[derive(Clone, Copy)]
struct Point {
x: f32,
y: f32,
}

Обычно значение, которое отдали в функцию, уходит туда насовсем, как в уроке «Владение и ссылки», и дальше им пользоваться нельзя. А эта строчка говорит: точка маленькая, как число, поэтому её можно спокойно копировать. Отдал точку в функцию, а у тебя осталась своя. Так же мы без раздумий передавали в функции обычные числа.

В уроке «Методы» мы повесили distance на Point, и получился метод distance_to. Здесь мы повесим на точку все функции, которые с ней работают:

impl Point {
// Сумма двух точек: x складываем с x, y с y.
fn plus(self, other: Point) -> Point {
Point {
x: self.x + other.x,
y: self.y + other.y,
}
}
// Точка, умноженная на обычное число: и x, и y умножаются на него.
fn times(self, factor: f32) -> Point {
Point {
x: self.x * factor,
y: self.y * factor,
}
}
}

Кроме них в полном коде ниже есть distance_to из урока «Методы», direction_to (бывшая direction) и inside (бывшая inside_rectangle). Новых тут два: plus и times.

plus складывает две точки. Если одна точка — где мы стоим, а другая — на сколько надо сдвинуться, то сумма и есть то место, куда мы придём.

times умножает точку на обычное число. Направление, как мы помним, всегда длиной 1. Умножим его на 25, и получится сдвиг ровно на 25 пикселей в ту же сторону. Раньше мы умножали x и y по отдельности, а теперь это сделано один раз.

Посмотри, как теперь читаются вызовы:

// Куда смотрит турель: направление от центра к мыши.
let aim = center.direction_to(mouse);
// Глазик: центр плюс направление, растянутое на 25 пикселей.
let eye = center.plus(aim.times(25.0));
// Пуля вылетела за край экрана?
if !bullet.position.inside(screen) {
bullets_to_remove.push(bullet_id);
}

Почти как обычная фраза: «из центра направление к мыши», «центр плюс направление, умноженное на 25».

Теперь вынесем из loop то, что делает одно дело и не лезет в остальное: подвинуть пули, подвинуть врагов, нарисовать всё по очереди. Вот, например, движение врагов:

// Двигает каждого врага его собственным направлением.
fn move_enemies(enemies: &mut HashMap<u32, Enemy>, dt: f32) {
for enemy in enemies.values_mut() {
// Шаг за кадр — направление, умноженное на скорость и на dt.
let step = enemy.direction.times(ENEMY_SPEED * dt);
// Сдвигаем врага вперёд на один шаг.
enemy.position = enemy.position.plus(step);
}
}

Список врагов функция получает по ссылке, как в уроке «Владение и ссылки». &mut значит, что функция может менять сам список, а не его копию. Поэтому и вызывать её надо с &mut enemies. А функциям, которые только рисуют, хватает обычной ссылки &enemies: им список нужен лишь для чтения.

Проверку попаданий мы оставили в loop. Она меняет сразу четыре вещи: пули, врагов, счёт и здоровье. Отдельной функции пришлось бы передавать все четыре в скобках, и получился бы длинный список аргументов. К этому мы вернёмся в уроке «Состояние игры в одной структуре».

Зато остальной loop теперь читается как список шагов: прицелиться, дождаться таймера и создать врага, выстрелить, подвинуть, проверить попадания, нарисовать. Движение и рисование теперь по одной строчке на каждое дело.

Замени содержимое src/main.rs на это:

src/main.rs
use macroquad::prelude::*;
use std::collections::HashMap;
use std::f32::consts::PI;
// Здоровье турели: сколько в начале и сколько отнимает один враг.
const MAX_HEALTH: f32 = 100.0;
const ENEMY_DAMAGE: f32 = 20.0;
// Радиус врага и его скорость в пикселях в секунду.
const ENEMY_RADIUS: f32 = 30.0;
const ENEMY_SPEED: f32 = 100.0;
// Пауза между появлением врагов в секундах: в начале игры и самая короткая.
// Каждое очко сокращает паузу на SPEEDUP секунд.
const START_SPAWN_INTERVAL: f32 = 2.0;
const MIN_SPAWN_INTERVAL: f32 = 0.4;
const SPEEDUP: f32 = 0.005;
// Пули: радиус, скорость в пикселях в секунду и пауза между выстрелами
// в секундах.
const BULLET_RADIUS: f32 = 8.0;
const BULLET_SPEED: f32 = 600.0;
const SHOOT_INTERVAL: f32 = 0.2;
// Радиус турели.
const TURRET_RADIUS: f32 = 40.0;
// Сколько очков даёт один убитый враг.
const POINTS_PER_ENEMY: u32 = 10;
// Полоска здоровья: левый верхний угол, длина и высота.
const BAR_X: f32 = 20.0;
const BAR_Y: f32 = 20.0;
const BAR_WIDTH: f32 = 300.0;
const BAR_HEIGHT: f32 = 30.0;
// Счёт под полоской: линия, на которой стоят буквы, размер букв и то,
// насколько число сдвинуто вправо от подписи.
const SCORE_Y: f32 = 130.0;
const SCORE_SIZE: f32 = 80.0;
const SCORE_NUMBER_OFFSET: f32 = 260.0;
// Точка на экране. Copy и Clone разрешают копировать точку на ходу, как
// обычное число: передал в функцию — и она всё ещё твоя.
#[derive(Clone, Copy)]
struct Point {
x: f32,
y: f32,
}
// Прямоугольник: левый верхний угол, ширина и высота.
struct Rect {
x: f32,
y: f32,
width: f32,
height: f32,
}
impl Point {
// Расстояние от этой точки до другой.
fn distance_to(self, other: Point) -> f32 {
let dx = other.x - self.x;
let dy = other.y - self.y;
(dx * dx + dy * dy).sqrt()
}
// Направление от этой точки к другой: вектор длиной 1.
fn direction_to(self, other: Point) -> Point {
let dx = other.x - self.x;
let dy = other.y - self.y;
let distance = (dx * dx + dy * dy).sqrt();
if distance > 0.0 {
Point { x: dx / distance, y: dy / distance }
} else {
Point { x: 1.0, y: 0.0 }
}
}
// Сумма двух точек: x складываем с x, y с y.
fn plus(self, other: Point) -> Point {
Point {
x: self.x + other.x,
y: self.y + other.y,
}
}
// Точка, умноженная на обычное число: и x, и y умножаются на него.
fn times(self, factor: f32) -> Point {
Point {
x: self.x * factor,
y: self.y * factor,
}
}
// Точка внутри прямоугольника?
fn inside(self, rect: Rect) -> bool {
self.x >= rect.x
&& self.x <= rect.x + rect.width
&& self.y >= rect.y
&& self.y <= rect.y + rect.height
}
}
// Один враг: где он сейчас и куда идёт.
struct Enemy {
position: Point,
direction: Point,
}
// Одна пуля: где она сейчас и куда летит.
struct Bullet {
position: Point,
direction: Point,
}
// Враг в случайном месте на краю экрана. Угол случайный, а расстояние от
// турели — половина высоты экрана без радиуса врага, чтобы враг не вылез
// за край.
fn random_enemy(center: Point) -> Enemy {
// Случайный угол от 0 до полного круга.
let angle = rand::gen_range(0.0, 2.0 * PI);
let spawn_distance = screen_height() / 2.0 - ENEMY_RADIUS;
// Направление от турели по углу — как в уроке «Вращение».
let outward = Point { x: angle.cos(), y: angle.sin() };
let position = center.plus(outward.times(spawn_distance));
// Враг идёт к турели по прямой, поэтому направление считаем один раз.
Enemy { position, direction: position.direction_to(center) }
}
// Двигает каждую пулю её собственным направлением.
fn move_bullets(bullets: &mut HashMap<u32, Bullet>, dt: f32) {
for bullet in bullets.values_mut() {
// Шаг за кадр — направление, умноженное на скорость и на dt.
let step = bullet.direction.times(BULLET_SPEED * dt);
bullet.position = bullet.position.plus(step);
}
}
// Двигает каждого врага его собственным направлением.
fn move_enemies(enemies: &mut HashMap<u32, Enemy>, dt: f32) {
for enemy in enemies.values_mut() {
// Шаг за кадр — направление, умноженное на скорость и на dt.
let step = enemy.direction.times(ENEMY_SPEED * dt);
enemy.position = enemy.position.plus(step);
}
}
// Рисует всех врагов.
fn draw_enemies(enemies: &HashMap<u32, Enemy>) {
for enemy in enemies.values() {
draw_circle(enemy.position.x, enemy.position.y, ENEMY_RADIUS, RED);
}
}
// Рисует все пули.
fn draw_bullets(bullets: &HashMap<u32, Bullet>) {
for bullet in bullets.values() {
draw_circle(bullet.position.x, bullet.position.y, BULLET_RADIUS, YELLOW);
}
}
// Рисует турель: тело и глазик.
fn draw_turret(center: Point, eye: Point) {
draw_circle(center.x, center.y, TURRET_RADIUS, BLUE);
draw_circle(eye.x, eye.y, 10.0, WHITE);
}
// Рисует полоску здоровья: серую подложку и зелёную часть поверх.
fn draw_health_bar(health: f32) {
// Доля здоровья от 0 до 1.
let fraction = health / MAX_HEALTH;
draw_rectangle(BAR_X, BAR_Y, BAR_WIDTH, BAR_HEIGHT, GRAY);
draw_rectangle(BAR_X, BAR_Y, BAR_WIDTH * fraction, BAR_HEIGHT, GREEN);
}
// Рисует счёт под полоской: подпись и число отдельными надписями.
fn draw_score(score: u32) {
draw_text("Score:", BAR_X, SCORE_Y, SCORE_SIZE, WHITE);
draw_text(&score.to_string(), BAR_X + SCORE_NUMBER_OFFSET, SCORE_Y, SCORE_SIZE, WHITE);
}
#[macroquad::main("Наводим порядок")]
async fn main() {
// Зерно — текущее время, поэтому при каждом запуске враги появляются
// по-новому.
rand::srand(macroquad::miniquad::date::now() as u64);
// Враги лежат в HashMap, у каждого свой ID. Сначала врагов нет.
let mut enemies: HashMap<u32, Enemy> = HashMap::new();
// ID, который получит следующий враг.
let mut next_enemy_id: u32 = 0;
// Сколько секунд осталось ждать до следующего врага.
let mut spawn_timer: f32 = 0.0;
// Пули — тоже HashMap с ID.
let mut bullets: HashMap<u32, Bullet> = HashMap::new();
// ID, который получит следующая пуля.
let mut next_bullet_id: u32 = 0;
// Сколько секунд осталось ждать до следующего выстрела.
let mut cooldown: f32 = 0.0;
// Текущее здоровье турели и очки игрока.
let mut health = MAX_HEALTH;
let mut score: u32 = 0;
loop {
clear_background(BLACK);
// dt (delta time) — сколько секунд прошло между прошлым кадром и этим.
let dt = get_frame_time();
// Турель стоит в центре экрана.
let center = Point { x: screen_width() / 2.0, y: screen_height() / 2.0 };
// Положение мыши.
let (mouse_x, mouse_y) = mouse_position();
let mouse = Point { x: mouse_x, y: mouse_y };
// Куда смотрит турель — направление от центра к мыши.
let aim = center.direction_to(mouse);
// Глазик — центр турели плюс направление, растянутое на 25 пикселей.
let eye = center.plus(aim.times(25.0));
// Пауза между врагами зависит от очков и не короче MIN_SPAWN_INTERVAL.
let spawn_interval = (START_SPAWN_INTERVAL - score as f32 * SPEEDUP).max(MIN_SPAWN_INTERVAL);
// Таймер появления идёт к нулю.
spawn_timer -= dt;
// Время вышло — на краю экрана появляется новый враг.
if spawn_timer <= 0.0 {
// Новый враг под своим ID.
enemies.insert(next_enemy_id, random_enemy(center));
// Следующий враг получит следующий ID.
next_enemy_id += 1;
// Запускаем отсчёт заново.
spawn_timer = spawn_interval;
}
// Счётчик перезарядки уменьшается на столько секунд, сколько прошло
// с прошлого кадра.
cooldown -= dt;
// Кнопка зажата и ждать больше не нужно — стреляем.
if is_mouse_button_down(MouseButton::Left) && cooldown <= 0.0 {
// Новая пуля из глазика под своим ID.
bullets.insert(next_bullet_id, Bullet { position: eye, direction: aim });
// Следующая пуля получит следующий ID.
next_bullet_id += 1;
// Запускаем отсчёт заново.
cooldown = SHOOT_INTERVAL;
}
// Двигаем пуль и врагов.
move_bullets(&mut bullets, dt);
move_enemies(&mut enemies, dt);
// Удалять во время чтения нельзя, поэтому ID всего лишнего сначала
// собираем в списки: пули на удаление, враги, в которых попали, и
// враги, которые дошли до турели.
let mut bullets_to_remove: Vec<u32> = Vec::new();
let mut enemies_shot: Vec<u32> = Vec::new();
let mut enemies_arrived: Vec<u32> = Vec::new();
for (&bullet_id, bullet) in &bullets {
// Весь экран как прямоугольник — для проверки, что пуля ещё в нём.
let screen = Rect { x: 0.0, y: 0.0, width: screen_width(), height: screen_height() };
// Пуля вылетела за край экрана — на удаление.
if !bullet.position.inside(screen) {
bullets_to_remove.push(bullet_id);
}
// Пуля задела врага — удаляем и пулю, и врага. Круги
// пересекаются, если расстояние меньше суммы радиусов.
for (&enemy_id, enemy) in &enemies {
if bullet.position.distance_to(enemy.position) < BULLET_RADIUS + ENEMY_RADIUS {
bullets_to_remove.push(bullet_id);
enemies_shot.push(enemy_id);
}
}
}
for (&enemy_id, enemy) in &enemies {
// Враг дошёл до турели.
if enemy.position.distance_to(center) < ENEMY_RADIUS + TURRET_RADIUS {
enemies_arrived.push(enemy_id);
}
}
// Чтение закончилось — теперь можно убирать.
for id in bullets_to_remove {
bullets.remove(&id);
}
for id in enemies_shot {
// Если в одного врага попали две пули за один кадр, очки
// даём только раз.
if let Some(_) = enemies.remove(&id) {
score += POINTS_PER_ENEMY;
}
}
for id in enemies_arrived {
// Дошедший враг пропадает и отнимает здоровье, но не ниже нуля.
if let Some(_) = enemies.remove(&id) {
health = (health - ENEMY_DAMAGE).max(0.0);
}
}
// Рисуем всё по порядку: враги, пули, турель, полоска, счёт.
draw_enemies(&enemies);
draw_bullets(&bullets);
draw_turret(center, eye);
draw_health_bar(health);
draw_score(score);
next_frame().await;
}
}

Запусти как обычно:

Окно терминала
cargo run

Играть нужно так же, как в прошлом уроке. Если всё сделано правильно, то разницы ты не заметишь, а она и не нужна: поменялось только устройство кода. Проверь сам:

  • #[derive(Clone, Copy)] над Point разрешает копировать точку, как число: отдал в функцию, а своя копия осталась.
  • Enemy и Bullet хранят position и direction вместо отдельных x, y, direction_x и direction_y.
  • impl Point — методы точки: distance_to, direction_to, plus, times и inside. Вызов читается слева направо: center.direction_to(mouse).
  • plus складывает две точки, а times умножает точку на число. Вместе они двигают точку: position.plus(direction.times(distance)).
  • move_bullets, move_enemies получают список как &mut и меняют его. draw_enemies, draw_bullets только читают, им хватает &.
  • draw_turret, draw_health_bar и draw_score рисуют каждый свою часть экрана.
  • Игра работает как раньше, а код стал длиннее, но разложен по частям. Каждую можно читать и менять отдельно.