В этом выпуске я сделаю графический компонент для игры, который будет служить для инициализации графики и рисования различных примитивов. Внутри себя он будет обращаться к библиотеке 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()
И что вы думали, мы наконец-то получили объект, который запрашивали?
Нет, мы получили ещё один 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. У неё будут свойства для хранения контекста и вспомогательных объектов:
Затем я реализую для Stage три метода: конструктор new(), рисование прямоугольника fill_rect() и обновление экрана update():
И протестирую использование Stage в главной программе:
Последняя строчка задаёт паузу в две секунды перед выходом из программы (да, оно вроде как считает в наносекундах). Никакого цикла ожидания я пока не делал. Программа должна нарисовать незамысловатый зелёный прямоугольник:
Проблемы на самом деле ещё не закончились. Скажем, я сделал прямоугольник rect и передал его в метод рисования fill_rect(). Казалось бы, всё хорошо, но надо помнить о механизме владения в Rust.
После передачи в fill_rect() я больше не могу использовать rect, так как уже не владею им. И что делать, если мне надо нарисовать один и тот же прямоугольник несколько раз? Например, его надо рисовать в каждом кадре игры.
Очевидный выход использовать ссылку &rect, но проблема в том, что нижележащий SDL-метод требует передачи именно rect без ссылки. То есть получается какая-то глупость, когда я должен каждый раз создавать новый объект Rect, чтобы передать его.
Думаю, на самом деле не всё так плохо, надо просто разобраться. Так что буду разбираться.
Читайте дальше: