Найти тему
ZDG

Игра Apple на Rust: Графическая прокладка

Оглавление

В этом выпуске я сделаю графический компонент для игры, который будет служить для инициализации графики и рисования различных примитивов. Внутри себя он будет обращаться к библиотеке SDL2.

Предыдущие части: Дженерики, Композиция, Модули, Начальное проектирование, Итоги про память, Что там с памятью, Колхозим интерфейсы, Где у него классы, Поддержка SDL2, Полируем ржавчину

Некоторое время назад я много программировал на языке ActionScript 3 для Adobe Flash. Там была развитая и довольно удобная и продуманная графическая подсистема. Поэтому я назову свой компонент Stage в память о таком же Flash-компоненте.

Stage – это сцена. То есть экран, который видит пользователь. На эту сцену можно добавлять графические элементы или удалять их с неё. Всё просто и логично.

Задача:

  • сделать инициализацию сцены
  • сделать базовое рисование

1. Получение SDL-контекста

Чтобы вообще начать работу с SDL, нужно получить контекст. Это объект, который имеет все необходимые свойства и методы для дальнейших действий.

Укажем, что мы используем внешний ящик sdl2:

extern crate sdl2;

Далее, получим контекст sdl:

let sdl = sdl2::init().unwrap();

И сразу попадаем в непонятное. Какой ещё unwrap()?

Как вы уже догадываетесь, здесь придётся сделать ещё один детур. Дело в том, что в Rust запрещены null-значения. И вот почему:

Во многих языках функция может вернуть null, то есть нулевой указатель. В Python это называется None, суть та же.

Смысл его таков, что мы ждём от функции указатель на какой-то объект, но если вернулся null, значит, что-то пошло не так и указатель пустой. В этом случае мы должны обработать ошибку, типа:

  • if (a === null) { ... }
  • if a is None: ...
  • if (is_null($a)) { ... }

ну и т.д.

Но зачастую получается так, что на обработку ошибки мы забиваем и сразу пишем что-то типа:

a.update();

Программа пытается вызвать метод update() у объекта a, но так как там null, получается ошибка: Null Pointer Exception, и программа падает.

Поэтому Rust заставляет нас делать проверку. Каким образом?

А просто нельзя вернуть null. Но с другой стороны, у null есть своё предназначение и возвращать его когда-то бывает нужно.

Поэтому Rust делает так: возвращается не указатель, а указатель, "завёрнутый" в другой тип, который называется Result.

Это перечислимый тип (enum), то есть он может принимать значения только из определённого списка. В данном случае список состоит из двух других типов: Ok(x) и Error(x). Да-да, вот столько всякой новой херни сразу.

Ok это удачный результат, а Error – неудачный. И там и там в скобках передаётся то значение, которое нужно вернуть в случае успеха или ошибки.

Посмотрим, как завернуть обыкновенное значение в Result:

let x: Result<i32, String> = Ok(5);

Как видим, здесь используются дженерики, то есть таким образом можно заворачивать значения любых типов. В данном случае для Ok используется целочисленный i32, а для Error – строковый String. Значение 5 завернулось в тип Ok, который в свою очередь стал вариантом типа Result.

Вернёмся к sdl2::init(). Этот метод должен отдать нам объект типа Sdl, но так как возможна ошибка, мы получим тип Result<Sdl, String>, где будет содержаться или сам объект, завернутый в Ok(), или строка ошибки, завёрнутая в Error(). Написав:

let sdl = sdl2::init();

Мы не сможем сразу же обратиться к этому объекту и вызвать у него какие-то функции. Потому что его тип на данный момент – Result. Чтобы было наглядней, можно этот тип написать явно:

let sdl_result: Result<Sdl, String> = sdl2::init();

Мы должны развернуть Result и посмотреть, что внутри:

Можно написать, например, так:

if sdl_result.is_ok() ...

Проверив, что sdl_result содержит Ok, можно взять уже его значение:

let sdl: Sdl = sdl_result.ok()

И что вы думали, мы наконец-то получили объект, который запрашивали?

-2

Нет, мы получили ещё один enum-тип Option, который принимает значения Some(x) и None.

None это аналог null, а Some(x) – по-прежнему наше значение x.

И вот теперь мы должны узнать, содержит Option вариант None или вариант Some, и наконец получить своё многострадальное значение.

Не буду углубляться дальше – там надо реально писать два дня.

Так зачем же нужен метод unwrap()? Он как раз и выполняет работу по разворачиванию значения из его обёрток. То есть написав

let sdl: Sdl = sdl2::init().unwrap();

Мы просто получаем то, что нужно – объект типа Sdl. При этом unwrap() может... упасть, и это его задокументированное поведение. То есть да, все усилия по запрещению null были напрасны и это равносильно тому, что мы использовали бы значение sdl без проверки на null.

Но у Rust есть два оправдания на этот счёт:

  • падение произойдёт в строго определённом месте программы, так что причину ошибки не надо будет долго выискивать по всему коду
  • использование unwrap() не рекомендуется. Есть и другие методы, которые не падают.

Как бы то ни было, на данный момент unwrap() это самое простое, чтобы не залазить совсем уж в дебри.

2. Получение видеоподсистемы

Теперь, имея SDL-контекст, мы можем попросить у него видеоподсистему. Контекст заведует и графикой, и звуком, и клавиатурой с мышкой, так что логично, что у него есть несколько подсистем.

let vss: sdl2::VideoSubsystem = sdl.video().unwrap();

Метод sdl.video() возвращает завёрнутый в Result объект типа VideoSubsystem, который опять же разворачивается с помощью unwrap().

3. Создание графического окна

Следующий шаг – создать собственно окно программы, которое увидит пользователь. Сначала нужно создать объект класса WindowBuilder, то есть строитель окон:

let wb = sdl2::video::WindowBuilder::new(&vss, "hello", 800, 600);

Здесь без всяких трюков вызывается конструктор new() структуры WindowBuilder, который требует: ссылку на видеоподсистему (&vss), заголовок окна("hello"), ширину и высоту окна (800, 600).

Затем у этого объекта нужно просто вызвать метод build(), и окно готово:

let window: sdl2::video::Window = wb.build().unwrap();

4. Получение холста

Теперь, когда есть окно, нужно получить у него поверхность для рисования (холст, или Canvas). Для этого зачем-то нужен строитель холстов, т.е. CanvasBuilder. В его конструктор передаём наше окно:

let cb = sdl2::render::CanvasBuilder::new(window);
let canvas = cb.build().unwrap();

5. Рисование

Мне пока нужны только две операции: установить цвет и нарисовать прямоугольник.

Для цвета понадобится структура Color. Цвет можно инициализировать через статический метод Color::RGB():

let color = sdl2::pixels::Color::RGB(255, 0, 0);

где, как обычно, три числа от 0 до 255 обозначают красный, зелёный и синий компоненты.

Устанавливаем для холста текущий цвет рисования:

canvas.set_draw_color(color);

Далее нужна структура прямоугольника Rect. Прямоугольник можно создать с помощью конструктора new():

let rect = sdl2::rect::Rect::new(0, 0, 100, 100);

Чтобы нарисовать прямоугольник, нужно вызвать у холста метод fill_rect(), передав в него rect:

canvas.fill_rect(rect);

Наконец, чтобы всё нарисованное появилось на экране, нужно вызвать метод:

canvas.present();

Это нормальный способ работы SDL и других графических библиотек. Всё рисование делается в буфере, а затем этот буфер целиком отображается на экране.

Stage

У меня есть минимум необходимых функций. Теперь я сделаю структуру Stage. У неё будут свойства для хранения контекста и вспомогательных объектов:

-3

Затем я реализую для Stage три метода: конструктор new(), рисование прямоугольника fill_rect() и обновление экрана update():

-4

И протестирую использование Stage в главной программе:

-5

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

-6

Проблемы на самом деле ещё не закончились. Скажем, я сделал прямоугольник rect и передал его в метод рисования fill_rect(). Казалось бы, всё хорошо, но надо помнить о механизме владения в Rust.

После передачи в fill_rect() я больше не могу использовать rect, так как уже не владею им. И что делать, если мне надо нарисовать один и тот же прямоугольник несколько раз? Например, его надо рисовать в каждом кадре игры.

Очевидный выход использовать ссылку &rect, но проблема в том, что нижележащий SDL-метод требует передачи именно rect без ссылки. То есть получается какая-то глупость, когда я должен каждый раз создавать новый объект Rect, чтобы передать его.

Думаю, на самом деле не всё так плохо, надо просто разобраться. Так что буду разбираться.

Читайте дальше: