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

Уроки · 72

Стрельба по мишеням

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

Мишеней пятнадцать, и расставляются они один раз, при старте игры — в отличие от пуль, им не нужно появляться по одной в процессе игры. Распределим их равномерно по кругу вокруг турели: угол мишени под номером i растёт на фиксированный шаг — тот же приём, что и START_X + STEP * i в уроке «Цикл for», только шаг здесь — это полный круг, поделённый на количество мишеней:

let angle_step = 2.0 * PI / TARGET_COUNT as f32;
for i in 0..TARGET_COUNT {
let angle = angle_step * (i as f32);
let target_distance = rand::gen_range(min_distance, max_distance);
let target_x = center_x + angle.cos() * target_distance;
let target_y = center_y + angle.sin() * target_distance;
targets.insert(i as u32, (target_x, target_y));
}

Угол через .cos() и .sin() даёт точку на окружности — тот же приём, что и в уроке «Вращение», только там радиус окружности всегда был равен 1, а здесь это дистанция от центра, и она у каждой мишени своя, случайная: min_distance берём равным четверти высоты экрана, max_distance — половине, чтобы мишень никогда не оказалась впритык к турели и никогда не вылезла за верхний или нижний край. Так расстояние у мишеней получается разное, а вот угол между соседними мишенями — всегда один и тот же, и кольцо выходит ровным, без скоплений в одном месте и пустот в другом.

Мишени и пули живут в двух разных HashMap, и у каждой — свой отдельный счётчик ID. Мишени нумеруются один раз при старте, тем же способом, что и в уроке «HashMap» — просто i из цикла создания. А пули появляются по одной, по клику, так что для них нужен растущий счётчик из урока «Автоинкремент».

Оба счётчика начинаются с нуля — и это нормально: мишень с ID 0 и пуля с ID 0 не перепутаются, потому что лежат в разных HashMap. ID мишени ищется только среди мишеней, ID пули — только среди пуль, так что совпадающие числа в двух разных коллекциях друг другу никак не мешают.

На каждом кадре с пулей может случиться одно из двух: она улетает за край экрана или задевает мишень.

За край — та же функция inside_rectangle, что и в уроках «Пули пропадают за краем» и «Настоящий край экрана», только наоборот: там пуля оставалась, пока была внутри экрана, а здесь нужно поймать момент, когда она уже снаружи — просто отрицание через !, как в «Кружки исчезают»:

let outside = !inside_rectangle(bullet.x, bullet.y, 0.0, 0.0, screen_width(), screen_height());

Задела мишень — сумма радиусов больше расстояния между центрами, та же проверка, что и в уроке «Пересечение кружков»:

distance(bullet.x, bullet.y, target_x, target_y) < BULLET_RADIUS + TARGET_RADIUS

Пуле за краем сравнивать себя с мишенями незачем — для этого обычный if/else, как в «Иначе», а не что-то новое.

В уроке «HashMap» для того же правила — «сначала прочитать, потом удалить» — хватало одной переменной, потому что за кадр мог совпасть только один кружок. Здесь иначе: за один кадр могут и вылететь за край, и попасть в мишень сразу несколько пуль. Поэтому вместо одной переменной на коллекцию заводим список, который растёт на один ID при каждом совпадении, — тот же Vec и .push, что и в уроке «Список кружков». Списка два, по одному на bullets и targets, а удаляем по ним уже после того, как чтение обеих HashMap закончилось — опять же как в «HashMap».

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

src/main.rs
use macroquad::prelude::*;
use std::collections::HashMap;
use std::f32::consts::PI;
// Радиус у всех мишеней одинаковый — фиксированный, как в уроке «Константы».
const TARGET_RADIUS: f32 = 55.0;
// Радиус пули — свой, поменьше.
const BULLET_RADIUS: f32 = 8.0;
// Скорость пули — одна и та же на каждом кадре.
const SPEED: f32 = 8.0;
// Сколько мишеней расставляем при старте.
const TARGET_COUNT: usize = 15;
// Одна пуля: положение и зафиксированное направление полёта.
struct Bullet {
x: f32,
y: f32,
direction_x: f32,
direction_y: f32,
}
// Своя функция: по двум точкам считает направление от первой ко второй,
// как в уроке «Свои функции».
fn direction(from_x: f32, from_y: f32, to_x: f32, to_y: f32) -> (f32, f32) {
let dx = to_x - from_x;
let dy = to_y - from_y;
let distance = (dx * dx + dy * dy).sqrt();
if distance > 0.0 {
(dx / distance, dy / distance)
} else {
(1.0, 0.0)
}
}
// Своя функция: расстояние между двумя точками, как в уроке «Расстояние».
fn distance(from_x: f32, from_y: f32, to_x: f32, to_y: f32) -> f32 {
let dx = to_x - from_x;
let dy = to_y - from_y;
(dx * dx + dy * dy).sqrt()
}
// Точка внутри прямоугольника — та же функция, что и в уроке «Пули
// пропадают за краем».
fn inside_rectangle(x: f32, y: f32, rect_x: f32, rect_y: f32, rect_width: f32, rect_height: f32) -> bool {
x >= rect_x && x <= rect_x + rect_width && y >= rect_y && y <= rect_y + rect_height
}
#[macroquad::main("Стрельба по мишеням")]
async fn main() {
// Турель всегда стоит в центре экрана.
let center_x = screen_width() / 2.0;
let center_y = screen_height() / 2.0;
// Дистанция мишеней от турели — не ближе четверти высоты экрана,
// не дальше половины, чтобы ни одна не оказалась за краем.
let max_distance = screen_height() / 2.0;
let min_distance = max_distance / 2.0;
// Угол между соседними мишенями — полный круг, поделённый на их число.
let angle_step = 2.0 * PI / TARGET_COUNT as f32;
// Мишени — фиксированный набор, появляется один раз при старте.
let mut targets: HashMap<u32, (f32, f32)> = HashMap::new();
for i in 0..TARGET_COUNT {
// Угол растёт равномерно, фиксированным шагом (как в уроке
// «Цикл for»), а дистанция от центра у каждой мишени своя, случайная.
let angle = angle_step * (i as f32);
let target_distance = rand::gen_range(min_distance, max_distance);
let target_x = center_x + angle.cos() * target_distance;
let target_y = center_y + angle.sin() * target_distance;
targets.insert(i as u32, (target_x, target_y));
}
// Пули появляются по одной, по клику — свой HashMap и свой счётчик
// ID, никак не связанный со счётчиком мишеней выше.
let mut bullets: HashMap<u32, Bullet> = HashMap::new();
let mut next_id: u32 = 0;
loop {
clear_background(BLACK);
// Координаты мыши.
let (mouse_x, mouse_y) = mouse_position();
// Турель мгновенно смотрит в сторону мыши.
let (direction_x, direction_y) = direction(center_x, center_y, mouse_x, mouse_y);
// Глазик — центр турели плюс направление, растянутое на 25 пикселей.
let eye_x = center_x + direction_x * 25.0;
let eye_y = center_y + direction_y * 25.0;
// Кнопка мыши только что нажата — новая пуля из глазика.
if is_mouse_button_pressed(MouseButton::Left) {
bullets.insert(
next_id,
Bullet { x: eye_x, y: eye_y, direction_x, direction_y },
);
next_id += 1;
}
// Двигаем каждую пулю её собственным направлением.
for bullet in bullets.values_mut() {
bullet.x += bullet.direction_x * SPEED;
bullet.y += bullet.direction_y * SPEED;
}
// Сначала только читаем bullets и targets — удалять во время
// чтения нельзя, поэтому собираем ID на вылет в отдельные списки.
let mut bullets_to_remove: Vec<u32> = Vec::new();
let mut targets_to_remove: Vec<u32> = Vec::new();
for (&bullet_id, bullet) in &bullets {
// Пуля уже вне экрана — сразу на вылет, до мишеней дело не доходит.
let outside = !inside_rectangle(bullet.x, bullet.y, 0.0, 0.0, screen_width(), screen_height());
if outside {
bullets_to_remove.push(bullet_id);
} else {
// Иначе проверяем пересечение с каждой мишенью — сумма
// радиусов, как в уроке «Пересечение кружков».
for (&target_id, &(target_x, target_y)) in &targets {
if distance(bullet.x, bullet.y, target_x, target_y) < BULLET_RADIUS + TARGET_RADIUS {
bullets_to_remove.push(bullet_id);
targets_to_remove.push(target_id);
}
}
}
}
// Теперь, когда чтение закончилось, убираем всё, что попало в списки.
for id in bullets_to_remove {
bullets.remove(&id);
}
for id in targets_to_remove {
targets.remove(&id);
}
// Мишени — все одного цвета, отличного и от турели, и от пуль.
for &(target_x, target_y) in targets.values() {
draw_circle(target_x, target_y, TARGET_RADIUS, RED);
}
// Тело турели.
draw_circle(center_x, center_y, 40.0, BLUE);
// Глазик — смотрит на мышь.
draw_circle(eye_x, eye_y, 10.0, WHITE);
// Пули.
for bullet in bullets.values() {
draw_circle(bullet.x, bullet.y, BULLET_RADIUS, YELLOW);
}
next_frame().await;
}
}

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

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

Веди мышью — турель поворачивается следом. Кликай — летят пули; попавшая в мишень пуля и сама мишень обе пропадают, а пуля, никуда не попавшая, просто исчезает за краем экрана. Попробуй сам:

  • Мишени создаются один раз при старте, в кольце вокруг турели — угол растёт равномерным шагом (как START_X + STEP * i в уроке «Цикл for»), а дистанция от min_distance до max_distance случайная для каждой; оба раскладываются в координаты через .cos()/.sin() (как в уроке «Вращение»).
  • targets: HashMap<u32, (f32, f32)> и bullets: HashMap<u32, Bullet> — две независимые коллекции, у каждой свой набор ID: мишени нумеруются индексом цикла создания (как в уроке «HashMap»), пули — отдельным растущим счётчиком next_id (как в уроке «Автоинкремент»).
  • bullets.values_mut() и targets.values() — доступ только к значениям HashMap, без ключей: здесь не нужен сам ID, чтобы подвинуть или нарисовать пулю и мишень.
  • !inside_rectangle(...) — та же функция, что и в уроках «Пули пропадают за краем» и «Настоящий край экрана», только с отрицанием: там проверяли «ещё внутри», здесь — «уже снаружи».
  • Попадание проверяется той же формулой, что и в уроке «Пересечение кружков» — сумма радиусов больше расстояния между центрами.
  • bullets_to_remove и targets_to_remove собираются во время чтения, а .remove() вызывается только после — та же причина, что и в уроке «HashMap»: нельзя удалять из коллекции, пока её же перебираешь.
  • Если на одну мишень за кадр попало сразу две пули, её ID просто дважды попадёт в targets_to_remove — второй .remove() с тем же ID ничего не делает, мишень уже убрана первым.