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

Уроки · 90

Патрулирование

Давай научим врага ходить по маршруту. Мы расставим на экране точки, и враг обойдёт их по порядку, а потом пойдёт по кругу заново. Точки можно будет добавлять и убирать кликом, а пробел будет включать и выключать патруль. Почти всё нужное мы уже знаем, одна новая мысль только в том, как идти не к одной цели, а по списку.

Точки пути лежат в списке Vec<Point>. Он нужен именно такой, а не HashMap из урока «HashMap»: у HashMap порядок случайный, а нам важно, в каком порядке точки добавили. Первая, вторая, третья.

// Точки пути — в том порядке, в котором их добавили.
let mut path: Vec<Point> = Vec::new();

Point и его методы distance_to, direction_to, plus и times остались такими же, как в уроке «Наводим порядок».

Клик по пустому месту добавляет точку в конец списка. Клик по существующей точке убирает её. Так хватает одной левой кнопки.

Сначала ищем, попал ли клик в какую-нибудь точку. Точка считается нажатой, если расстояние от мыши до её центра меньше радиуса, как в уроке «Пересечение кружков». Номер найденной точки кладём в Option, как в уроке «Option: значения может не быть»: Some(номер), если нашли, и None, если нет:

// Номер точки под курсором. Если такой точки нет — None.
let mut clicked: Option<usize> = None;
for i in 0..path.len() {
// Курсор ближе к центру точки, чем её радиус — попали.
if path[i].distance_to(mouse) < POINT_RADIUS {
clicked = Some(i);
}
}
if let Some(i) = clicked {
// Убираем точку с номером i. Остальные сдвигаются на её место.
path.remove(i);
} else {
// Добавляем новую точку в конец пути.
path.push(mouse);
}

path.remove(i) убирает из списка точку с номером i, а все, что шли после неё, сдвигаются на одно место вперёд. Номера в списке, как в уроке «Ловушка индекса», начинаются с нуля.

Режим патруля хранится в обычной переменной patrolling, а переключаем мы её так же, как паузу в уроке «Пауза»:

// Пробел включает и выключает патрулирование.
if is_key_pressed(KeyCode::Space) {
patrolling = !patrolling;
}

Править путь можно, только пока враг стоит. Иначе при удалении точки номера сдвинулись бы прямо у врага под ногами, и он сбился бы с пути. Поэтому весь код правки лежит внутри if !patrolling && ....

Здесь новая мысль. Враг помнит число target, то есть номер точки, к которой он идёт сейчас. Когда он до неё дошёл, target становится следующим номером. А после последней точки снова идёт первая, поэтому путь получается по кругу. Для этого есть маленькая функция:

// Номер следующей точки пути. После последней снова идёт первая.
fn next_index(index: usize, count: usize) -> usize {
// Следующий номер ещё есть в списке — берём его.
if index + 1 < count {
index + 1
} else {
// Список кончился — начинаем сначала.
0
}
}

Это тот же if как значение, как в уроке «if как значение».

Само движение такое:

// Точка, к которой идём, и направление к ней.
let goal = path[target];
look = enemy.direction_to(goal);
// Шаг за кадр — скорость в пикселях в секунду, умноженная на dt.
let step = ENEMY_SPEED * dt;
if enemy.distance_to(goal) <= step {
// Осталось меньше шага — встаём ровно в точку и берём следующую.
enemy = goal;
target = next_index(target, path.len());
} else {
// Иначе просто делаем шаг к точке.
enemy = enemy.plus(look.times(step));
}

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

Если точек нет, идти некуда, поэтому весь этот код выполняется только при path.len() > 0.

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

src/main.rs
use macroquad::prelude::*;
// Радиус кружка, которым рисуем точку пути.
const POINT_RADIUS: f32 = 24.0;
// Радиус врага и его скорость в пикселях в секунду.
const ENEMY_RADIUS: f32 = 40.0;
const ENEMY_SPEED: f32 = 150.0;
// Точка на экране. Copy и Clone разрешают копировать точку на ходу.
#[derive(Clone, Copy)]
struct Point {
x: f32,
y: 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 next_index(index: usize, count: usize) -> usize {
if index + 1 < count {
index + 1
} else {
0
}
}
#[macroquad::main("Патрулирование")]
async fn main() {
// Точки пути — в том порядке, в котором их добавили.
let mut path: Vec<Point> = Vec::new();
// Враг: где он стоит и куда смотрит.
let mut enemy = Point { x: screen_width() / 2.0, y: screen_height() / 2.0 };
let mut look = Point { x: 1.0, y: 0.0 };
// Враг сейчас патрулирует? Сначала нет, он стоит.
let mut patrolling = false;
// Номер точки пути, к которой враг идёт сейчас.
let mut target: usize = 0;
loop {
clear_background(BLACK);
// dt (delta time) — сколько секунд прошло между прошлым кадром и этим.
let dt = get_frame_time();
// Пробел включает и выключает патрулирование.
if is_key_pressed(KeyCode::Space) {
patrolling = !patrolling;
}
// Путь можно править, только пока враг стоит. Клик по точке убирает
// её, клик по пустому месту добавляет новую.
if !patrolling && is_mouse_button_pressed(MouseButton::Left) {
// Положение мыши.
let (mouse_x, mouse_y) = mouse_position();
let mouse = Point { x: mouse_x, y: mouse_y };
// Номер точки под курсором. Если такой точки нет — None.
let mut clicked: Option<usize> = None;
for i in 0..path.len() {
if path[i].distance_to(mouse) < POINT_RADIUS {
clicked = Some(i);
}
}
if let Some(i) = clicked {
// Убираем точку с номером i. Остальные сдвигаются на её место.
path.remove(i);
} else {
// Добавляем новую точку в конец пути.
path.push(mouse);
}
}
// Патрулируем: нужна хотя бы одна точка.
if patrolling && path.len() > 0 {
// Если путь успели укоротить, начинаем с первой точки.
if target >= path.len() {
target = 0;
}
// Точка, к которой идём, и направление к ней.
let goal = path[target];
look = enemy.direction_to(goal);
// Шаг за кадр — скорость в пикселях в секунду, умноженная на dt.
let step = ENEMY_SPEED * dt;
if enemy.distance_to(goal) <= step {
// Осталось меньше шага — встаём ровно в точку и берём следующую.
enemy = goal;
target = next_index(target, path.len());
} else {
// Иначе просто делаем шаг к точке.
enemy = enemy.plus(look.times(step));
}
}
// Линии между соседними точками. Последняя соединяется с первой.
for i in 0..path.len() {
let from = path[i];
let to = path[next_index(i, path.len())];
draw_line(from.x, from.y, to.x, to.y, 5.0, GRAY);
}
// Кружки точек с номерами, чтобы было видно порядок.
for i in 0..path.len() {
let point = path[i];
draw_circle(point.x, point.y, POINT_RADIUS, ORANGE);
draw_text(&(i + 1).to_string(), point.x + 30.0, point.y - 30.0, 80.0, WHITE);
}
// Враг и его глазик.
draw_circle(enemy.x, enemy.y, ENEMY_RADIUS, RED);
let eye = enemy.plus(look.times(26.0));
draw_circle(eye.x, eye.y, 11.0, WHITE);
// Подсказка в углу: что сейчас делает враг и что нажать.
let mode = if patrolling { "Patrol" } else { "Edit" };
draw_text(mode, 30.0, 100.0, 100.0, WHITE);
draw_text("Space - patrol / stop", 30.0, 170.0, 60.0, GRAY);
next_frame().await;
}
}

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

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

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

  • path — список точек в порядке добавления. Именно Vec, потому что порядок важен.
  • Клик по точке находит её номер через Option<usize> и убирает path.remove(i). Клик по пустому месту добавляет точку path.push(mouse).
  • Пока patrolling равно true, путь не правится, а враг идёт.
  • target — номер точки, к которой враг идёт сейчас.
  • next_index даёт следующий номер, а после последнего точки возвращает ноль. Её используют и враг, и рисование линий, чтобы замкнуть путь.
  • Если до точки осталось не больше одного шага, враг встаёт ровно в неё и берёт следующую. Иначе делает шаг в её сторону.
  • Направление к цели считается на каждом кадре, потому что цель меняется. look нужен глазику, который смотрит туда, куда идёт враг.
  • Линии рисуются между каждой точкой и следующей, а кружки с номерами поверх них.