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

Уроки · 75

Секунды вместо кадров

Если ты открывал урок «Автоматический огонь» на телефоне, то мог заметить, что пули там ползут медленнее, чем на компьютере. На разных устройствах одна и та же программа может выглядеть по-разному. Давай разберёмся, почему так бывает и как сделать, чтобы везде было одинаково.

В предыдущих уроках мы всё считали в кадрах: на каждом кадре пуля сдвигалась на одно и то же число пикселей. Но кадров в секунду у разных устройств получается разное количество.

На компьютере кадр может длиться около 0.016 секунды. Это примерно 60 кадров в секунду. На телефоне кадр может длиться 0.05 секунды, то есть всего 20 кадров в секунду.

Пусть пуля сдвигается на 10 пикселей за кадр. На компьютере за секунду она пройдёт около 600 пикселей, а на телефоне только 200. Кадров в секунду на компьютере больше, поэтому там пуля и летит быстрее.

Чтобы пули везде летели одинаково, нужно знать, сколько времени на самом деле прошло между прошлым кадром и текущим. Для этого в macroquad есть get_frame_time(). Она возвращает число секунд, например 0.016 на компьютере или 0.05 на телефоне. Такое число принято называть dt, от английского delta time, то есть «разница во времени»: насколько сдвинулись часы между двумя кадрами.

Теперь скорость мы задаём не «пикселей за кадр», а «пикселей в секунду». А на каждом кадре сдвигаем пулю на скорость, умноженную на dt:

// dt (delta time) — сколько секунд прошло между прошлым кадром и этим.
let dt = get_frame_time();
// Скорость в пикселях в секунду, умноженная на dt, — это шаг за один кадр.
bullet.x += bullet.direction_x * SPEED * dt;

Возьмём скорость 600 пикселей в секунду. Компьютер за кадр сдвинет пулю на 600 × 0.016, то есть примерно на 10 пикселей. Телефон сдвинет на 600 × 0.05, то есть на 30 пикселей. На телефоне кадров меньше, но каждый шаг больше, поэтому за секунду пуля проходит те же 600 пикселей.

Счётчик перезарядки точно так же считал кадры, а теперь пусть считает секунды. SHOOT_INTERVAL будет временем между выстрелами, например 0.15 секунды. Счётчик уменьшается на dt, то есть на то время, которое прошло с прошлого кадра:

// Счётчик уменьшается на столько секунд, сколько прошло с прошлого кадра.
cooldown -= dt;
// Кнопка зажата и ждать больше не нужно — стреляем и запускаем отсчёт заново.
if is_mouse_button_down(MouseButton::Left) && cooldown <= 0.0 {
cooldown = SHOOT_INTERVAL;
}

Число теперь дробное, и ниже нуля ему можно уходить. «Ноль или меньше» значит то же самое, что и ноль: стрелять можно.

Открой этот урок на телефоне и на компьютере. Теперь пули летят практически одинаково, и турель стреляет практически с одной частотой.

Зажми левую кнопку мыши на турели и води мышью. Меняй скорость пули и паузу между выстрелами:

src/main.rs
use macroquad::prelude::*;
use std::collections::HashMap;
// Пауза между выстрелами в секундах. Чем меньше число, тем чаще выстрелы.
const SHOOT_INTERVAL: f32 = 0.15;
// Скорость пули в пикселях в секунду.
const SPEED: f32 = 600.0;
// Радиус пули.
const BULLET_RADIUS: f32 = 8.0;
// Одна пуля: положение и зафиксированное направление полёта.
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)
}
}
// Своя функция: точка (x, y) внутри прямоугольника (rect_x, rect_y,
// rect_width, rect_height)? Та же проверка, что и в уроке «Прямоугольные
// кнопки».
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() {
// Пули лежат в HashMap, у каждой свой ID — как в уроке «Стрельба по мишеням».
let mut bullets: HashMap<u32, Bullet> = HashMap::new();
// ID, который получит следующая пуля.
let mut next_id: u32 = 0;
// Сколько секунд осталось ждать до следующего выстрела. 0 или меньше — можно стрелять.
let mut cooldown: f32 = 0.0;
loop {
clear_background(BLACK);
// dt (delta time) — сколько секунд прошло между прошлым кадром и этим.
let dt = get_frame_time();
// Турель стоит в центре экрана.
let center_x = screen_width() / 2.0;
let center_y = screen_height() / 2.0;
// Координаты мыши.
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;
// Счётчик уменьшается на столько секунд, сколько прошло с прошлого
// кадра. Если он ушёл ниже нуля — ничего страшного, это тоже
// значит «можно стрелять».
cooldown -= dt;
// Кнопка зажата и ждать больше не нужно — стреляем.
if is_mouse_button_down(MouseButton::Left) && cooldown <= 0.0 {
// Новая пуля из глазика под своим ID.
bullets.insert(
next_id,
Bullet { x: eye_x, y: eye_y, direction_x, direction_y },
);
// Следующая пуля получит следующий ID.
next_id += 1;
// Запускаем отсчёт заново.
cooldown = SHOOT_INTERVAL;
}
// Двигаем каждую пулю её собственным направлением: за кадр она
// проходит скорость, умноженную на время этого кадра.
for bullet in bullets.values_mut() {
bullet.x += bullet.direction_x * SPEED * dt;
bullet.y += bullet.direction_y * SPEED * dt;
}
// Удалять во время чтения нельзя, поэтому ID пуль, которые
// вылетели за край, сначала собираем в список.
let mut bullets_to_remove: Vec<u32> = Vec::new();
for (&bullet_id, bullet) in &bullets {
// Сам экран и есть прямоугольник: от (0, 0) до (screen_width(),
// screen_height()). Пуля снаружи — на удаление.
if !inside_rectangle(bullet.x, bullet.y, 0.0, 0.0, screen_width(), screen_height()) {
bullets_to_remove.push(bullet_id);
}
}
// Чтение закончилось — теперь можно убирать.
for id in bullets_to_remove {
bullets.remove(&id);
}
// Тело турели.
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;
}
}
  • Кадров в секунду у разных устройств получается разное количество, поэтому движение «на столько-то пикселей за кадр» везде выглядит по-разному.
  • dt — это delta time, время между прошлым кадром и текущим в секундах. Его возвращает get_frame_time().
  • SPEED теперь в пикселях в секунду, а шаг за кадр равен SPEED * dt. Чем длиннее кадр, тем больше шаг, и за секунду пуля проходит одно и то же расстояние.
  • cooldown считается в секундах и уменьшается на dt на каждом кадре. Когда он равен нулю или меньше, можно стрелять.
  • SHOOT_INTERVAL задаёт паузу между выстрелами в секундах: чем меньше число, тем чаще выстрелы.