/
Автор: Хортон Дж.
Теги: компьютерная графика специализированные и управляющие электронные вычислительные машины дискретного действия программирование информационные технологии компьютерные технологии язык программирования c++
ISBN: 978-601-12-3151-0
Год: 2025
Текст
Создаем игры
и изучаем C++
Третье издание
Джон Хортон
2025
Джон Хортон
Создаем игры и изучаем C++
3-е издание
Перевел с английского А. Ларин
ББК 32.973.23-018.9
УДК 004.92
Хортон Джон
Х82 Создаем игры и изучаем C++. 3-е изд. — Астана: «Спринт Бук», 2025. — 528 с.: ил.
ISBN 978-601-12-3151-0
Мечтаете создавать игры, но не знаете, с чего начать? Книга «Создаем игры и изучаем C++» станет
вашим проводником в мире игровой разработки!
Это издание было адаптировано под Visual Studio 2022, C++20 и библиотеку SFML, оно предлагает
уникальный подход: вы не только освоите язык C++ с нуля, но и примените знания на практике, создав
четыре игры в разных жанрах.
Вы начнете с изучения основ программирования, познакомитесь с ключевыми темами C++: объектно-
ориентированное программирование (ООП), указатели и стандартная библиотека шаблонов (STL). Разберетесь с методами обнаружения коллизий в игровой физике на примере игры Pong. Создавая игры, вы
познакомитесь с массивами вершин, направленным звуком, шейдерами OpenGL, порождением объектов
и многим другим. Вы погрузитесь в игровую механику и реализуете обработку ввода, повышение уровня
персонажа и даже «вражеский» ИИ. Наконец, вы изучите паттерны проектирования игр, чтобы усовершенствовать навыки программирования на C++.
К концу книги вы сможете разрабатывать собственные игры, публиковать их и удивлять аудиторию.
Книга идеально подойдет для новичков в программировании и C++, геймдев-энтузиастов, желающих
освоить SFML и современные методы работы, и тем, кто мечтает создать игру для Steam или портфолио.
Готовы превратить код в захватывающие миры? Создавайте! Программируйте! Вдохновляйте!
16+ (В соответствии с Федеральным законом от 29 декабря 2010 г. № 436-ФЗ.)
ISBN 978-601-12-3151-0
© Packt Publishing 2024.
First published in the English language under the title ‘Beginning C++
Game Programming – Third Edition – (9781835081747)’
© Перевод на русский язык ТОО «Спринт Бук», 2025
© Издание на русском языке, оформление ТОО «Спринт Бук», 2025
Права на издание получены по соглашению с Packt Publishing. Все права защищены. Никакая часть данной книги не может быть воспроизведена в какой бы то ни было форме без письменного разрешения владельцев авторских прав.
Информация, содержащаяся в данной книге, получена из источников, рассматриваемых издательством как надежные. Тем не менее, имея
в виду возможные человеческие или технические ошибки, издательство не может гарантировать абсолютную точность и полноту приводимых
сведений и не несет ответственности за возможные ошибки, связанные с использованием книги. В книге возможны упоминания организаций,
деятельность которых запрещена на территории Российской Федерации, таких как Meta Platforms Inc., Facebook, Instagram и др. Издательство
не несет ответственности за доступность материалов, ссылки на которые вы можете найти в этой книге. На момент подготовки книги к изданию все ссылки на интернет-ресурсы были действующими.
Изготовлено в России. Изготовитель: ТОО «Спринт Бук». Место нахождения и фактический адрес:
010000, Казахстан, город Астана, район Алматы, Проспект Ракымжан Кошкарбаев, д. 10/1, н. п. 18.
Дата изготовления: 08.2025. Наименование: книжная продукция. Срок годности: не ограничен.
Подписано в печать 29.07.25. Формат 70×100/16. Бумага офсетная. Усл. п. л. 42,570. Тираж 1000. Заказ 0000.
Краткое содержание
Предисловие................................................................................................. 16
Глава 1. Введение.............................................................................................................................. 24
Глава 2. Переменные, операторы и условия: анимация спрайтов................................... 66
Глава 3. Строки в C++, время SFML, пользовательский ввод и HUD........................102
Глава 4. Циклы, массивы, операторы switch, перечисления и функции: .
реализация игровых механик.....................................................................................................122
Глава 5. Коллизии, звук и условия завершения игры: приводим игру .
в состояние, чтобы в нее можно было полноценно играть...............................................157
Глава 6. Объектно-ориентированное программирование: приступаем
к работе над игрой Pong................................................................................................................178
Глава 7. AABB-метод обнаружения коллизий и физика: завершение .
работы над игрой Pong..................................................................................................................201
Глава 8. Использование области отображения и класса View в SFML: .
зомби-шутер......................................................................................................................................212
Глава 9. Ссылки, спрайт-листы и массивы вершин в C++..............................................249
Глава 10. Указатели, стандартная библиотека шаблонов и управление .
текстурами.........................................................................................................................................266
Глава 11. Класс TextureHolder и создание орды зомби.....................................................284
Глава 12. Обнаружение коллизий, бонусные предметы и пули.....................................305
Глава 13. Разделение области отображения на слои и реализация HUD..................339
Глава 14. Звуковые эффекты, работа с файлами и завершение игры..........................349
Глава 15. Игра Run.........................................................................................................................360
Глава 16. Звук, игровая логика, межобъектное взаимодействие .
и игровой персонаж........................................................................................................................400
Глава 17. Графика, камеры, действие.......................................................................................427
Глава 18. Платформы, анимация игрового персонажа и элементы управления.......446
Глава 19. Экран меню и дождь...................................................................................................473
Глава 20. Огненные шары и пространственный звук........................................................493
Глава 21. Параллакс и шейдеры................................................................................................513
Оглавление
Предисловие................................................................................................. 16
Для кого эта книга...................................................................................................................... 16
Структура издания..................................................................................................................... 17
Как извлечь максимум пользы из книги............................................................................ 21
Загрузите файлы примеров кода.................................................................................... 21
Скачайте цветные изображения..................................................................................... 21
Условные обозначения............................................................................................................. 22
Об авторе....................................................................................................................................... 23
О научном редакторе................................................................................................................. 23
От издательства........................................................................................................................... 23
Глава 1. Введение.............................................................................................................................. 24
Игры, которые мы будем разрабатывать............................................................................ 25
Timber!..................................................................................................................................... 25
Pong.......................................................................................................................................... 26
Zombie Arena.......................................................................................................................... 26
Run............................................................................................................................................ 27
Почему разработка игр на C++ — отличный способ освоить .
программирование .................................................................................................................... 28
SFML........................................................................................................................................ 30
Microsoft Visual Studio....................................................................................................... 31
А что насчет Mac и Linux.................................................................................................. 32
Установка Visual Studio 2022.......................................................................................... 32
Настройка SFML........................................................................................................................ 34
Создание нового проекта в Visual Studio 2022................................................................. 35
Настройка свойств проекта.............................................................................................. 38
Составляем план игры Timber!.............................................................................................. 41
Ресурсы проекта.......................................................................................................................... 44
Создание собственных звуковых эффектов............................................................... 44
Оглавление 7
Добавление ресурсов в проект........................................................................................ 44
Обзор ресурсов проекта..................................................................................................... 45
Координаты на экране и внутренние координаты.......................................................... 46
Начало работы с кодом............................................................................................................. 47
Комментарий......................................................................................................................... 48
Функция main....................................................................................................................... 48
Оформление и синтаксис.................................................................................................. 49
Возвращение значений из функции.............................................................................. 49
Запуск игры........................................................................................................................... 50
Открытие окна с помощью SFML........................................................................................ 51
Включение функций SFML............................................................................................. 51
ООП, классы и объекты..................................................................................................... 52
Использование пространства имен sf........................................................................... 53
Классы VideoMode и RenderWindow библиотеки SFML..................................... 54
Запуск игры........................................................................................................................... 55
Игровой цикл............................................................................................................................... 55
Цикл while.............................................................................................................................. 56
Комментарии в стиле C............................................................................................................ 57
Ввод, обновление, отрисовка, повтор........................................................................... 57
Обработка нажатия клавиши.......................................................................................... 58
Очистка и отрисовка сцены.............................................................................................. 58
Запуск игры........................................................................................................................... 59
Фон.................................................................................................................................................. 59
Подготовка спрайта с помощью текстуры.................................................................. 59
Двойная буферизация спрайта фона............................................................................ 61
Запуск игры........................................................................................................................... 62
Обработка ошибок..................................................................................................................... 63
Ошибки конфигурации..................................................................................................... 63
Ошибки компиляции......................................................................................................... 63
Ошибки в ссылках............................................................................................................... 63
Баги........................................................................................................................................... 64
Резюме............................................................................................................................................ 64
Часто задаваемые вопросы...................................................................................................... 64
Глава 2. Переменные, операторы и условия: анимация спрайтов................................... 66
Переменные в C++..................................................................................................................... 66
Типы переменных................................................................................................................ 67
Объявление и инициализация переменных............................................................... 68
Как управлять переменными................................................................................................. 72
Арифметические операторы и операторы присваивания в C++......................... 72
Выражения............................................................................................................................. 73
8 Оглавление
Добавление облаков, пчелы и дерева................................................................................... 77
Подготовка дерева............................................................................................................... 77
Подготовка пчелы................................................................................................................ 79
Подготовка облаков............................................................................................................ 80
Отрисовка дерева, пчелы и облаков.............................................................................. 81
Случайные числа........................................................................................................................ 83
Генерация случайных чисел в C++................................................................................ 83
Принятие решений с помощью if и else.............................................................................. 84
Логические операторы....................................................................................................... 84
Конструкция if/else в C++................................................................................................ 85
«Если они пересекут мост, стреляйте в них!»............................................................ 86
«В противном случае сделайте вот что…»................................................................... 86
А что, если…............................................................................................................................ 87
Плавность...................................................................................................................................... 89
Проблема частоты кадров................................................................................................. 89
Решение проблемы частоты кадров с помощью SFML . ....................................... 90
Приводим в движение облака и пчелу................................................................................ 92
Оживляем пчелу................................................................................................................... 92
Движение облаков............................................................................................................... 96
Резюме..........................................................................................................................................100
Часто задаваемые вопросы....................................................................................................100
Глава 3. Строки в C++, время SFML, пользовательский ввод и HUD........................102
Пауза и перезапуск игры........................................................................................................102
Строки в C++.............................................................................................................................105
Объявление строк..............................................................................................................105
Присвоение значения строкам......................................................................................105
Конкатенация строк..........................................................................................................106
Получение длины строки................................................................................................106
Работа со строками с помощью StringStream..........................................................107
Текст и шрифт в SFML . .................................................................................................108
Добавление счета и сообщения...........................................................................................109
Добавление временной шкалы............................................................................................114
Резюме..........................................................................................................................................120
Часто задаваемые вопросы....................................................................................................121
Глава 4. Циклы, массивы, операторы switch, перечисления и функции:
реализация игровых механик.....................................................................................................122
Циклы...........................................................................................................................................122
Цикл while............................................................................................................................123
Прерывание цикла.............................................................................................................125
Цикл for.................................................................................................................................127
Оглавление 9
Массивы.......................................................................................................................................129
Объявление массива.........................................................................................................129
Инициализация элементов массива............................................................................130
Чем массивы полезны для наших игр?......................................................................131
Операторы switch.....................................................................................................................132
Перечисления ...........................................................................................................................134
Начало работы с функциями................................................................................................136
Кто придумал весь этот странный синтаксис и почему он именно такой?.....138
Типы возвращаемых значений функций...................................................................140
Имена функций..................................................................................................................143
Параметры функции.........................................................................................................144
Тело функции......................................................................................................................145
Прототипы функций.........................................................................................................145
Организация функций.....................................................................................................146
Область видимости функций........................................................................................146
И еще немного о функциях............................................................................................147
Создаем ветки............................................................................................................................148
Подготовка веток...............................................................................................................149
Покадровое обновление спрайтов веток ..................................................................150
Отрисовка веток.................................................................................................................152
Перемещение веток...........................................................................................................152
Резюме..........................................................................................................................................155
Часто задаваемые вопросы....................................................................................................155
Глава 5. Коллизии, звук и условия завершения игры: приводим игру .
в состояние, чтобы в нее можно было полноценно играть...............................................157
Подготовка игрового персонажа и других спрайтов....................................................157
Отрисовка персонажа и других спрайтов........................................................................158
Обработка ввода игрока.........................................................................................................159
Настройка начала новой игры.......................................................................................161
Обнаружение удара топором.........................................................................................162
Обнаружение отпускания клавиши............................................................................165
Анимация топора и разрубленных бревен ...............................................................166
Обработка гибели.....................................................................................................................168
Простые звуковые эффекты.................................................................................................171
Как работает звук в SFML..............................................................................................171
Когда воспроизводить звуки..........................................................................................171
Добавление кода для воспроизведения звуков.......................................................171
Улучшение игры и кода.........................................................................................................175
Резюме..........................................................................................................................................176
Часто задаваемые вопросы....................................................................................................177
10 Оглавление
Глава 6. Объектно-ориентированное программирование: приступаем
к работе над игрой Pong................................................................................................................178
Объектно-ориентированное программирование..........................................................178
Инкапсуляция.....................................................................................................................179
Полиморфизм.....................................................................................................................180
Наследование......................................................................................................................180
Зачем использовать ООП...............................................................................................180
Что такое класс...................................................................................................................181
Ракетка для игры в пинг-понг..............................................................................................182
Объявление класса, переменных и функций...........................................................182
Определения функций класса.......................................................................................185
Использование экземпляра класса..............................................................................187
Создание проекта Pong...........................................................................................................188
Создание класса Bat................................................................................................................190
Создание Bat.h....................................................................................................................190
Конструктор.........................................................................................................................191
Еще немного о Bat.h..........................................................................................................192
Создание Bat.cpp................................................................................................................192
Использование класса Bat и написание функции main..............................................195
Резюме..........................................................................................................................................199
Часто задаваемые вопросы....................................................................................................200
Глава 7. AABB-метод обнаружения коллизий и физика: завершение .
работы над игрой Pong..................................................................................................................201
Создание класса Ball...............................................................................................................201
Использование класса Ball....................................................................................................204
Обнаружение коллизий и подсчет очков.........................................................................205
Запуск игры................................................................................................................................209
Оператор spaceship в C++......................................................................................................209
Резюме..........................................................................................................................................210
Часто задаваемые вопросы....................................................................................................211
Глава 8. Использование области отображения и класса View в SFML: .
зомби-шутер......................................................................................................................................212
Планирование и начало игры Zombie Arena...................................................................212
Создание нового проекта................................................................................................214
Ресурсы проекта.................................................................................................................215
Обзор ресурсов...................................................................................................................216
Добавление ресурсов в проект......................................................................................217
ООП и проект Zombie Arena................................................................................................217
Создание игрового персонажа — первый класс.............................................................218
Создание заголовочного файла класса Player.........................................................219
Создание определений функций класса Player.......................................................224
Оглавление 11
Управление игровой камерой с помощью класса View библиотеки SFML........232
Запуск игрового движка........................................................................................................235
Управление файлами кода....................................................................................................238
Пишем код основного игрового цикла..............................................................................240
Резюме..........................................................................................................................................247
Часто задаваемые вопросы....................................................................................................248
Глава 9. Ссылки, спрайт-листы и массивы вершин в C++..............................................249
Ссылки в C++............................................................................................................................249
Массивы вершин и спрайт-листы.......................................................................................252
Что такое спрайт-лист......................................................................................................252
Что такое массив вершин.......................................................................................................254
Создание фона из плиток................................................................................................254
Построение массива вершин..........................................................................................255
Использование массива вершин для отрисовки.....................................................256
Создание случайно генерируемого прокручиваемого фона......................................257
Использование фона...............................................................................................................262
Резюме..........................................................................................................................................265
Часто задаваемые вопросы....................................................................................................265
Глава 10. Указатели, стандартная библиотека шаблонов и управление .
текстурами.........................................................................................................................................266
Что такое указатели.................................................................................................................266
Синтаксис ............................................................................................................................267
Объявление указателя.....................................................................................................268
Инициализация указателя..............................................................................................269
Повторная инициализация указателей......................................................................269
Разыменование указателя...............................................................................................270
Указатели — универсальный и мощный инструмент...........................................271
Указатели и массивы........................................................................................................275
Кратко об указателях........................................................................................................276
Знакомство с STL.....................................................................................................................276
Что такое вектор.................................................................................................................278
Что такое словарь...............................................................................................................280
Ключевое слово auto.........................................................................................................282
Краткое описание STL.....................................................................................................283
Резюме..........................................................................................................................................283
Часто задаваемые вопросы....................................................................................................283
Глава 11. Класс TextureHolder и создание орды зомби.....................................................284
Реализация класса TextureHolder......................................................................................284
Создание заголовочного файла TextureHolder.......................................................285
Создание определений функций TextureHolder....................................................286
Чего мы добились с помощью TextureHolder..........................................................288
12 Оглавление
Создание орды зомби..............................................................................................................288
Создание файла Zombie.h...............................................................................................288
Создание файла Zombie.cpp...........................................................................................291
Использование класса Zombie для создания орды................................................295
Возвращение к жизни орды............................................................................................298
Использование класса TextureHolder для всех текстур.............................................302
Изменение способа получения текстур фона..........................................................302
Изменение способа получения текстуры для класса Player...............................303
Резюме..........................................................................................................................................304
Часто задаваемые вопросы....................................................................................................304
Глава 12. Обнаружение коллизий, бонусные предметы и пули.....................................305
Создание класса Bullet...........................................................................................................305
Создание заголовочного файла Bullet........................................................................306
Создание исходного файла Bullet................................................................................308
Создание функции shoot.................................................................................................309
Дополнительные функции для пули..........................................................................312
Функция update для класса Bullet...............................................................................313
Заставляем пули летать.........................................................................................................314
Подключаем класс Bullet................................................................................................314
Управляющие переменные и массив пуль................................................................314
Перезарядка оружия.........................................................................................................315
Стрельба . .............................................................................................................................317
Обновление пуль в каждом кадре................................................................................318
Отрисовка пуль в каждом кадре...................................................................................318
Прицеливание.....................................................................................................................319
Создание класса для собираемых предметов.................................................................322
Создание заголовочного файла Pickup......................................................................322
Создание определений функций класса Pickup......................................................325
Использование класса Pickup..............................................................................................330
Обнаружение коллизий.........................................................................................................332
Был ли зомби подстрелен?.............................................................................................334
Был ли герой задет зомби?.............................................................................................336
Коснулся ли герой предмета?........................................................................................337
Резюме..........................................................................................................................................338
Часто задаваемые вопросы....................................................................................................338
Глава 13. Разделение области отображения на слои и реализация HUD..................339
Добавление всех объектов Text и HUD...........................................................................339
Обновление HUD.....................................................................................................................342
Отрисовка HUD, главного экрана и меню повышения уровня...............................344
Резюме..........................................................................................................................................347
Оглавление 13
Глава 14. Звуковые эффекты, работа с файлами и завершение игры..........................349
Сохранение и загрузка рекорда...........................................................................................349
Подготовка звуковых эффектов..........................................................................................351
Разрешение игроку повышать уровень и генерация новой волны.........................352
Перезапуск игры.......................................................................................................................354
Воспроизведение остальных звуков............................................................................355
Звуковые эффекты при перезарядке..........................................................................355
Звук выстрела.....................................................................................................................356
Звук при ударе по игровому персонажу....................................................................356
Звук при подборе предмета............................................................................................357
Звук попадания пули по зомби.....................................................................................357
Резюме..........................................................................................................................................358
Часто задаваемые вопросы....................................................................................................359
Глава 15. Игра Run.........................................................................................................................360
Об игре.........................................................................................................................................361
Создание проекта.....................................................................................................................364
Создание функции main.........................................................................................................366
Обработка ввода........................................................................................................................370
Создание класса Factory........................................................................................................374
Продвинутое ООП: наследование и полиморфизм.....................................................375
Наследование......................................................................................................................376
Расширение класса............................................................................................................376
Полиморфизм.....................................................................................................................378
Абстрактные классы: виртуальные и чистые виртуальные функции.............380
Паттерны проектирования....................................................................................................382
Паттерн «Сущность — компонент — система» (Entity Component .
System, ECS)..............................................................................................................................382
Почему сложно управлять большим количеством разнообразных
типов объектов....................................................................................................................382
Использование универсального объекта GameObject для улучшения
структуры кода...................................................................................................................383
Композиция важнее наследования..............................................................................384
Паттерн «Фабрика»..........................................................................................................386
Умные указатели C++......................................................................................................388
Приведение умных указателей.....................................................................................391
Создание класса GameObject.........................................................................................392
Создание класса Component..........................................................................................394
Создание класса Graphics................................................................................................395
Создание класса Update..................................................................................................396
Выполнение кода...............................................................................................................397
Что дальше?.........................................................................................................................398
Резюме..........................................................................................................................................398
14 Оглавление
Глава 16. Звук, игровая логика, межобъектное взаимодействие .
и игровой персонаж........................................................................................................................400
Создание класса SoundEngine..............................................................................................400
Реализация игровой логики ................................................................................................403
Создание класса LevelUpdate........................................................................................403
Создание игрового персонажа.............................................................................................414
Класс PlayerUpdate...........................................................................................................414
Класс PlayerGraphics........................................................................................................418
Создание фабрики для использования всех наших новых классов.......................422
Запоминание текстурных координат . .......................................................................422
Запуск игры................................................................................................................................425
Резюме..........................................................................................................................................426
Глава 17. Графика, камеры, действие.......................................................................................427
Камеры, вызовы функции draw и класс View в SFML................................................427
Создание классов камеры......................................................................................................428
Класс CameraUpdate.........................................................................................................429
Класс CameraGraphics: часть 1......................................................................................433
Класс View............................................................................................................................436
Класс CameraGraphics: часть 2......................................................................................438
Добавление экземпляров камеры в игру..........................................................................442
Запуск игры................................................................................................................................444
Резюме..........................................................................................................................................445
Глава 18. Платформы, анимация игрового персонажа и элементы управления.......446
Создание платформ.................................................................................................................446
Класс PlatformUpdate.......................................................................................................446
Класс PlatformGraphics....................................................................................................451
Создание платформ в фабрике......................................................................................453
Первый запуск игры................................................................................................................455
Добавление функциональности персонажу....................................................................455
Создание элементов управления персонажем.........................................................456
Второй запуск игры.................................................................................................................460
Создание класса Animator.....................................................................................................461
Анимация игрового персонажа............................................................................................464
Третий запуск игры.................................................................................................................471
Резюме..........................................................................................................................................472
Глава 19. Экран меню и дождь...................................................................................................473
Создание интерактивного меню..........................................................................................473
Класс MenuUpdate............................................................................................................474
Класс MenuGraphics.........................................................................................................479
Оглавление 15
Создание меню в фабрике...............................................................................................484
Первый запуск игры................................................................................................................485
Дождь............................................................................................................................................486
Создание класса RainGraphics.......................................................................................486
Создание дождя в фабрике.............................................................................................490
Второй запуск игры.................................................................................................................491
Резюме..........................................................................................................................................492
Глава 20. Огненные шары и пространственный звук........................................................493
Что такое пространственный звук......................................................................................493
Излучатели, затухание и слушатели...........................................................................494
Обработка пространственного звука с помощью SFML............................................494
Обновление класса SoundEngine........................................................................................496
Огненные шары.........................................................................................................................498
Класс FireballUpdate.........................................................................................................499
Класс FireballGraphics......................................................................................................506
Создание огненных шаров в фабрике.........................................................................510
Запуск кода.................................................................................................................................511
Резюме..........................................................................................................................................512
Глава 21. Параллакс и шейдеры................................................................................................513
Знакомство с OpenGL, шейдерами и GLSL....................................................................513
Программируемый конвейер и шейдеры..................................................................513
Создание гипотетического фрагментного шейдера...............................................515
Создание гипотетического вершинного шейдера...................................................516
Завершение работы над классом CameraGraphics........................................................516
Разбор кода в функции draw..........................................................................................519
Создание шейдера для игры.................................................................................................522
Запуск готовой игры.........................................................................................................522
Резюме..........................................................................................................................................524
Дальнейшее чтение..................................................................................................................524
Предисловие
Всегда мечтали создавать собственные игры? С книгой «Создаем игры и изучаем C++» ваша мечта может стать реальностью! Это удобное для начинающих
руководство обновлено и улучшено с учетом новейших возможностей Visual
Studio 2022, библиотеки SFML (Simple Fast Multimedia Library) и современных
методов программирования на C++20. Вас ждет увлекательное погружение в мир
программирования игр: вы создадите четыре полноценные игры — от простой до
более сложной. Среди них — клоны таких популярных игр, как Timberman, Pong,
шутер на выживание среди зомби и игра в жанре бесконечный раннер.
Книга начинается с основ программирования. Вы познакомитесь с такими ключевыми темами C++, как объектно-ориентированное программирование (ООП)
и указатели C++, а также со стандартной библиотекой шаблонов (STL). На примере игры Pong вы узнаете о методах обнаружения коллизий и основах игровой
физики. В процессе разработки вы также изучите такие интересные концепции
программирования игр, как массивы вершин, направленный звук, программируемые шейдеры OpenGL, генерация объектов и многое другое. Вы глубоко погрузитесь в игровые механики и реализуете обработку ввода, систему повышения
уровня игрока и простой ИИ врагов. Наконец, вы познакомитесь с паттернами
проектирования игр, которые помогут вам усовершенствовать навыки программирования на C++.
К концу книги вы получите необходимые знания для самостоятельного создания
захватывающих игр с нуля.
Для кого эта книга
Книга идеально подойдет вам, если у вас нет никакого опыта в области программирования на C++, вам нужен курс для начинающих, чтобы освежить знания, вы
хотите научиться создавать игры или просто изучить с их помощью C++.
Если вы стремитесь опубликовать игру (возможно, в Steam) или просто хотите
поразить друзей, эта книга также окажется полезной.
Структура издания 17
Структура издания
Глава 1. Введение. Здесь описывается путь к написанию захватывающих игр
для персональных компьютеров (ПК) с использованием C++ и SFML на базе
OpenGL. Все основы C++, начиная с переменных, циклов, объектно-ориентированного программирования, STL, функций SFML и новых возможностей C++,
были дополнены и расширены в этом издании. К концу книги вы не только создадите четыре полноценные игры, но и получите глубокие знания основ языка C++.
Глава 2. Переменные, операторы и условия: анимация спрайтов. В этой главе мы
немного порисуем на экране: анимируем облака, которые будут двигаться со
случайной скоростью и на разной высоте, а также пчелу, летящую на переднем
плане. Для этого нам нужно будет изучить некоторые основы C++. Вы узнаете,
как C++ хранит данные в переменных, как манипулировать этими переменными
с помощью операторов и какие конструкции позволяют выполнять разные действия в зависимости от значения переменных. Затем вы сможете применить эти
знания для реализации анимации облаков и пчелы.
Глава 3. Строки в C++, время в SFML, пользовательский ввод и HUD1. Примерно
половину времени мы посвятим изучению работы с текстовыми данными и их
отображению на экране, а оставшуюся часть — таймингу и созданию визуальной
временной шкалы, которая будет побуждать игрока действовать быстрее.
Глава 4. Циклы, массивы, операторы switch, перечисления и функции: реализация
игровых механик. В этой главе, пожалуй, содержится больше информации о C++,
чем в любой другой. Она насыщена фундаментальными концепциями, которые
значительно углубят ваше понимание языка. Здесь также объясняются некоторые
ранее пропущенные сложные темы, такие как функции, игровой цикл и циклы
в целом.
Глава 5. Коллизии, звук и условия завершения игры: приводим игру в состояние,
чтобы в нее можно было полноценно играть. Это заключительный этап работы
над первым проектом. К концу главы у вас будет первая полностью готовая игра.
Как только вы запустите игру Timber!, обязательно прочитайте последний раздел данной главы, поскольку в нем будут предложены способы ее улучшения.
Вот основные темы, которые мы охватим: добавление оставшихся спрайтов,
обработка пользовательского ввода, анимация отлетающего бревна, обработка гибели персонажа, добавление звуковых эффектов и новых возможностей,
а также улучшение игры.
Глава 6. Объектно-ориентированное программирование: приступаем к работе
над игрой Pong. В этой главе будет немного теории, которая даст нам знания,
1
HUD (англ. head-up display) — часть пользовательского интерфейса, где отображается
важная информация о текущем игровом состоянии, например шкала здоровья, количество патронов, карта и т. д.
18 Предисловие
необходимые для ООП. ООП позволяет организовать код в понятные человеком структуры и лучше справляться со сложностью проекта. Мы не будем терять времени и сразу применим теорию на практике. Вы узнаете, как создавать
новые типы данных в C++ и использовать их в качестве объектов, написав свой
первый класс. Для начала мы рассмотрим упрощенный сценарий игры Pong,
чтобы понять основы работы с классами, а затем создадим полноценную игру,
применив изученные принципы.
Глава 7. AABB-метод обнаружения коллизий и физика: завершение работы над
игрой Pong. Здесь мы напишем код нашего второго класса. Несмотря на то что
мяч сильно отличается от ракетки, мы применим те же приемы, чтобы инкапсулировать внешний вид и функциональность мяча внутри класса Ball, как в случае
с ракеткой и классом Bat. Затем мы напишем код для обнаружения коллизий
и подсчета очков. Это может показаться сложным, но SFML значительно упростит задачу.
Глава 8. Использование области отображения и класса View в SFML: зомби-шутер.
В этой главе мы еще больше погрузимся в ООП и познакомимся с классом View,
который позволит разделить наш проект на слои для различных аспектов игры.
Здесь мы реализуем слой для HUD и слой для основного игрового мира. Это необходимо, так как мир игры будет расширяться каждый раз, когда игрок уничтожает очередную волну зомби. В конце концов он станет больше экрана и игроку
придется его прокручивать. Класс View поможет нам сделать так, чтобы текст HUD
не двигался вместе с фоном.
Глава 9. Ссылки, спрайт-листы и массивы вершин в C++. В главе 4 мы говорили
об области видимости. Это концепция, согласно которой переменные, объявленные внутри функции или блока кода, могут быть видны или использованы
только в пределах этой функции или блока. Однако что будет, если нам нужно
взаимодействовать с несколькими комплексными объектами в функции main?
Получается, весь код должен быть в функции main? Но спасение есть.
В этой главе мы изучим ссылки в C++, которые позволят нам работать с переменными и объектами, находящимися вне области видимости. Кроме того, эти
ссылки помогут избежать передачи больших объектов между функциями, что
замедляет работу программы, поскольку копия переменной или объекта создается каждый раз.
Вооружившись новыми знаниями, мы рассмотрим в SFML класс VertexArray,
который позволит быстро и эффективно отрисовывать на экране большие изображения, используя несколько частей одного графического файла. В результате
с помощью ссылок и VertexArray к концу главы мы создадим масштабируемый,
случайно генерируемый и прокручиваемый фон.
Глава 10. Указатели, стандартная библиотека шаблонов и управление текстурами. В этой главе мы узнаем много нового, а также продвинемся в разработке игры.
Структура издания 19
Мы изучим такую фундаментальную тему C++, как указатели. Это переменные,
которые хранят адрес в памяти, обычно они содержат адрес другой переменной.
Указатели немного напоминают ссылки, но они гораздо мощнее. Мы будем использовать их для работы с постоянно растущей ордой зомби.
Мы также познакомимся со стандартной библиотекой шаблонов (STL) — набором классов, позволяющих быстро и легко реализовать общие методы управления
данными.
Глава 11. Класс TextureHolder и создание орды зомби. Разобравшись с основами
STL, воспользуемся этими новыми знаниями для управления текстурами в игре.
Если у нас будет 1000 зомби, вряд ли мы захотим, чтобы каждая копия зомби
загружалась в память графического процессора (GPU).
Мы продолжим углубляться в ООП и применим статическую функцию — функцию, которую можно вызвать без создания экземпляра класса. Заодно узнаем,
как спроектировать класс так, чтобы в нем существовал только один экземпляр.
Это идеальный вариант, если разные части кода должны использовать одни
и те же данные.
Глава 12. Обнаружение коллизий, бонусные предметы и пули. К этому моменту
мы разработали основные визуальные аспекты игры: у нас есть игровой персонаж и арена, полная преследующих его зомби. Однако сейчас они не взаимодействуют друг с другом — зомби могут пройти сквозь игрока, не причинив ему
вреда. Нам нужно реализовать обнаружение коллизий между зомби и игроком.
Если зомби получат способность наносить урон игроку, логично дать ему возможность защищаться. Поэтому мы вооружим его, чтобы он мог стрелять во врагов.
Вдобавок ко всему, мы добавим класс для подбора аптечек и патронов.
Вот что мы реализуем в игре и в каком порядке: стрельбу пулями, прицел и скрытие указателя мыши, генерацию различных предметов, которые игрок сможет
подбирать, и обнаружение коллизий.
Глава 13. Разделение области отображения на слои и реализация HUD. В этой
главе мы увидим реальную ценность класса View библиотеки SFML. Мы добавим
набор объектов класса Text и будем манипулировать ими, как делали это ранее
в проектах Timber! и Pong. Новшеством станет создание HUD с помощью второго
экземпляра View. Таким образом, HUD будет аккуратно располагаться поверх
основной игровой сцены, независимо от того, что происходит с фоном, игроком,
зомби и другими игровыми объектами.
Глава 14. Звуковые эффекты, работа с файлами и завершение игры. Мы почти
закончили работу над проектом. В этой главе мы научимся управлять файлами,
хранящимися на жестком диске, с помощью стандартной библиотеки C++, а также добавим звуковые эффекты. Конечно, мы уже знаем, как добавлять звуки, но
здесь мы обсудим, где именно в коде будут находиться вызовы функции play.
20 Предисловие
Кроме того, мы устраним оставшиеся недочеты, чтобы игра выглядела завершенной. Мы рассмотрим следующие темы: сохранение и загрузка таблицы рекордов
через ввод-вывод файлов, добавление звуковых эффектов, повышение уровня
игрока и генерация новой волны врагов.
Глава 15. Игра Run. Добро пожаловать в финальный проект — игру Run. Это
раннер, в котором цель игрока — бежать вперед по бесконечному уровню с исчезающими платформами и набрать как можно больше очков. В этом проекте мы
изучим множество новых приемов программирования игр и еще больше тем по
C++ для их реализации. Пожалуй, главное отличие этой игры от предыдущих заключается в том, что она будет гораздо более объектно-ориентированной. Мы создадим гораздо больше классов, но код для них будет лаконичным и несложным.
Кроме того, мы построим игру так, что вся функциональность и внешний вид
внутриигровых объектов будут сосредоточены в классах, а основной цикл игры
останется неизменным, независимо от того, что делают игровые объекты. Это
очень важно, поскольку в будущем позволит нам создавать разнообразные
игры, просто проектируя новые компоненты (классы), описывающие поведение
и внешний вид игровых объектов. Это значит, что вы сможете использовать одну
и ту же структуру кода для создания совершенно другой игры по собственному
замыслу. Но это еще не все — впереди нас ждет много интересного!
Глава 16. Звук, игровая логика, межобъектное взаимодействие и игровой персонаж.
В этой главе вы узнаете, как легко и быстро реализовать звуковое сопровождение
игры. Кроме того, всего за полдюжины строк кода мы добавим в игру музыку. Позже в проекте, но не в этой главе, добавим направленный (пространственный) звук.
После этого мы обернем весь наш код, связанный со звуком, в один класс под названием SoundEngine. Затем мы перейдем к созданию игрока. Мы реализуем всю
функциональность основного персонажа, добавив два класса: один из них будет
расширять класс Update, а другой — класс Graphics. Создание новых игровых
объектов путем расширения этих двух классов станет основным подходом к добавлению новых элементов на протяжении всего проекта. Мы также разберем
простой способ взаимодействия объектов друг с другом с помощью указателей.
Глава 17. Графика, камеры, действие. В этой главе мы подробно поговорим
о графической составляющей проекта. Поскольку здесь мы будем писать код
камер, выполняющих отрисовку, самое время рассмотреть и графику. Обратите внимание: в папке graphics всего один файл, и мы до сих пор не вызывали
функцию window.draw. Мы обсудим, почему вызовы draw должны быть сведены
к минимуму, а также реализуем классы Camera. Наконец, мы сможем запустить
игру и увидеть камеры в действии: одну для общего вида, и одну для мини-карты.
Глава 18. Платформы, анимация игрового персонажа и элементы управления.
В этой главе мы займемся программированием платформ, анимацией игрока
и системой управления. На мой взгляд, самая сложная часть уже позади, и впереди нас ждет множество задач с более высоким соотношением вознаграждения
к затраченным усилиям. Надеюсь, данная глава будет интересной: мы создадим
Как извлечь максимум пользы из книги 21
платформы, на которых будет стоять игрок, добавим ему функциональность,
а также реализуем анимацию плавного бега персонажа через класс Animator.
Глава 19. Экран меню и дождь. Здесь мы реализуем две важные функции. Одна
из них — экран меню, который будет информировать игрока о возможностях:
начать, приостановить, перезапустить или завершить игру. Другая — создание
эффекта дождя. Вы можете возразить, что дождь не нужен и не слишком подходит к игре, но это полезный навык, который стоит освоить. Самое интересное
в главе — это то, как мы достигнем обеих целей с помощью классов, унаследованных от Graphics и Update. Мы объединим их в экземпляры GameObject, и они
будут прекрасно работать вместе с другими игровыми сущностями.
Глава 20. Огненные шары и пространственный звук. В этой главе мы добавим все
звуковые эффекты и HUD. Мы уже делали это в двух предыдущих проектах,
но в этот раз все будет немного по-другому. Мы изучим концепцию пространственного звука и увидим, как SFML упрощает его использование. Кроме того,
мы создадим класс для HUD, чтобы инкапсулировать код, отвечающий за вывод
информации на экран.
Глава 21. Параллакс и шейдеры. К концу главы у нас будет полностью готовая
и функциональная игра. Вот что мы сделаем, чтобы завершить работу над проектом: глубже погрузимся в OpenGL, шейдеры и язык GLSL (Graphics Library
Shading Language), доработаем класс CameraGraphics, добавив шейдер и параллакс-эффект для фона, запрограммируем шейдер, взяв за основу сторонний код,
и, наконец, запустим нашу игру.
Как извлечь максимум пользы из книги
Для работы с книгой не требуется никаких предварительных знаний. Вам не нужно уметь программировать — в процессе чтения вы сможете создать четыре
полноценные игры. Если вы любите видеоигры и твердо намерены учиться —
у вас все получится!
Загрузите файлы примеров кода
Примеры кода размещены на GitHub по адресу https://github.com/PacktPublishing/
Beginning-C-Game-Programming-Third-Edition. У нас есть и другие наборы кода, доступные по адресу https://github.com/PacktPublishing/. Ознакомьтесь с ними!
Скачайте цветные изображения
Мы также предоставляем PDF-файл с цветными изображениями снимков экрана и диаграмм, используемых в книге. Вы можете загрузить его отсюда: https://
packt.link/gbp/9781835081747.
22 Предисловие
Условные обозначения
В книге используется ряд типографских обозначений.
Вот такой моноширинный шрифт указывает на код в тексте, имена таблиц базы
данных, папок и файлов, расширения файлов, наименования путей, фиктивные
URL-адреса и пользовательский ввод. Например: «Мой основной каталог проекта — D:\VS Projects\Timber».
Блок кода задается следующим образом:
int playerScore = 0;
char playerInitial = 'J';
float valuePi = 3.141f;
bool isAlive = true;
Когда мы хотим обратить ваше внимание на определенную часть блока кода, соответствующие строки или элементы выделяются шрифтом на сером фоне:
// Сделайте спрайт дерева
Texture textureTree;
textureTree.loadFromFile("graphics/tree.png");
Sprite spriteTree;
spriteTree.setTexture(textureTree);
spriteTree.setPosition(810, 0);
while (window.isOpen())
{
Новые термины и важные слова выделены курсивом.
URL-адреса, слова, которые вы видите на экране, например в меню или диалоговых окнах, оформляются таким шрифтом и отображаются в тексте так: «Выберите
пункт Информация о системе на панели Администрирование».
ПРИМЕЧАНИЕ
Предупреждения или важные заметки выглядят следующим образом.
СОВЕТ
Так обозначается совет или рекомендация.
От издательства 23
Об авторе
Джон Хортон живет в Великобритании и увлекается программированием и играми. Он создает приложения, игры, пишет книги и ведет блог на тему программирования. Является основателем школы Game Code School.
Хочу посвятить эту книгу моим братьям, Рэю и Барри, за их наставления,
пример и поддержку.
О научном редакторе
Йоан Рок — разработчик с более чем четырехлетним стажем в игровой индустрии. Обладая опытом программирования на C++, Йоан специализируется на
разработке игр с использованием Unreal Engine и иногда Blueprints.
За время работы в Limbic Studio Йоан внес значительный вклад в разработку
Park Beyond, ААА-игры, в которой игроки создают собственный парк развлечений и управляют им. Позже Йоан присоединился к Chillchat и работал над
проектом Primorden — многопользовательской игрой на движке Unreal Engine 5
и системе Gameplay Ability. Он сыграл ключевую роль в реализации игровых
механик, способностей монстров и деревьев поведения искусственного интеллекта (ИИ).
В Game Atelier Йоан возглавил разработку пользовательского интерфейса для
еще не анонсированного проекта на основе Unreal Engine 5.3, Common UI и, конечно же, UMG.
От издательства
Ваши замечания, предложения, вопросы отправляйте по адресу comp@sprintbook.kz
(издательство SprintBook, компьютерная редакция).
Мы будем рады узнать ваше мнение!
1
Введение
Давайте окунемся в увлекательный мир разработки игр на C++ с использованием библиотеки SFML (Simple and Fast Multimedia Library) и OpenGL. В этом
издании огромное внимание уделено улучшению и расширению вашего обучения. Оно охватывает основы C++, включая переменные, циклы, ООП, STL,
функциональность SFML и более продвинутые возможности C++. К концу
книги вы не только создадите четыре полноценные игры, но и получите глубокие знания языка C++.
Вот краткий обзор того, что вы найдете в этой главе.
zzСначала мы обсудим четыре игры, которые вы создадите по мере изучения
книги. Первая игра полностью повторяет ту, что была в предыдущем издании.
Она поможет вам изучить основы C++, такие как переменные, циклы и усло
вия. Вторая и третья — улучшенные, модифицированные и доработанные
по сравнению с предыдущим изданием, а четвертая — совершенно новая и, на
мой взгляд, более увлекательная и полезная для обучения, чем две последние
игры предыдущего издания, вместе взятые.
zzМы обсудим, почему C++ является отличным языком для разработки игр
и изучения программирования.
zzЗатем мы рассмотрим библиотеку SFML и ее связь с C++.
zzПоговорим о Microsoft Visual Studio и о том, почему мы будем использовать
именно эту среду разработки.
zzДалее мы настроим среду разработки. Это может показаться немного скучным
занятием, но мы пройдем этот путь шаг за шагом.
zzЗатем мы обсудим дизайн, механику и основные требования первого игрового
проекта — Timber!.
zzДвигаясь дальше, мы напишем первые строки кода на C++ и реализуем пер-
вый этап игры, на котором нарисуем красивый фон. В следующей главе мы
перейдем к более сложным задачам и научимся анимировать изображения.
Знания и навыки, полученные в этой главе, станут прочной основой для
дальнейшего развития и создания полноценной игры.
Игры, которые мы будем разрабатывать 25
zzНаконец, вы узнаете, как справиться с распространенными ошибками кон-
фигурации, компиляции и ссылочной связи, а также багами, которые могут
возникнуть в процессе изучения C++ и программирования игр.
Итак, давайте узнаем больше об играх, которые будем создавать.
Исходный код этой главы вы найдете в репозитории GitHub: https://github.com/
PacktPublishing/Beginning-C-Game-Programming-Third-Edition/tree/main/Timber.
Игры, которые мы будем
разрабатывать
Мы шаг за шагом будем изучать основы высокопроизводительного языка C++,
а затем применять эти знания на практике, добавляя новые функции в наши
игры.
Ниже представлены четыре проекта, рассматриваемых в книге.
Timber!
Первая игра — это захватывающий, динамичный клон чрезвычайно успешной
игры Timberman («Лесоруб»). Наша версия, Timber!, познакомит вас со всеми
основными понятиями C++ в процессе создания полноценной игры. На рис. 1.1
показано, как она будет выглядеть, когда мы закончим работу.
Timberman можно найти на сайте http://store.steampowered.com/app/398710/.
Рис. 1.1. Игра Timber!
26 Глава 1. Введение
Pong
Pong была одной из первых видеоигр. Она представляет собой отличный пример
того, как работают базовые элементы игровой анимации, пользовательский ввод
и обнаружение коллизий. Мы создадим версию этой простой ретроигры, чтобы
изучить концепцию классов и ООП. К концу главы 7 она будет выглядеть, как
показано на рис. 1.2.
Рис. 1.2. Игра Pong
Игрок управляет ракеткой, расположенной в нижней части экрана, и с ее помощью отбивает мяч в верхнюю часть экрана. Вы можете узнать историю Pong
здесь: https://en.wikipedia.org/wiki/Pong.
Zombie Arena
Далее мы создадим динамичный шутер на выживание в стиле «зомби-апокалипсис», похожий на хит Steam под названием Over 9000 Zombies! (http://
store.steampowered.com/app/273500/). У игрока будет огнестрельное оружие, и ему
предстоит отбиваться от постоянно растущих волн зомби (рис. 1.3). Все это
должно происходить в случайно генерируемом мире.
Для реализации данного проекта мы изучим, как ООП позволяет управлять большим объемом кода, делая его легким для написания и поддержки. Вас ждут сотни
врагов, скорострельное оружие, бонусные предметы и герой, уровень которого
можно повышать после каждой волны.
Игры, которые мы будем разрабатывать 27
Рис. 1.3. Игра Zombie Arena
Run
Финальная игра — платформер под названием Run. Она будет наполнена большим количеством функций, которые мы сможем реализовать с помощью приобретенных навыков C++ и упростить благодаря замечательным возможностям
SFML. Взгляните на готовую игру на рис. 1.4.
Рис. 1.4. Игра Run
28 Глава 1. Введение
Среди особенностей можно выделить фотореалистичный шейдерный фон, городской пейзаж с параллакс-эффектом, пространственный (направленный) звук,
мини-карту, анимированного игрового персонажа, погодные эффекты, музыку,
всплывающее меню и многое другое. А самое главное — в готовой игре будет
многократно используемая структура кода, которую вы сможете задействовать
для создания и добавления собственных функций.
Почему разработка игр на C++ — отличный
способ освоить программирование
Заголовок мог бы звучать так: «Зачем использовать игровое программирование
для изучения C++...», потому что, на мой взгляд, C++, программирование игр
и новички — идеальное сочетание. Рассмотрим достоинства C++, сосредоточившись на играх и новичках.
zzСкорость выполнения кода. C++ известен своей высокой производительно-
стью и эффективностью. В разработке игр производительность очень важна.
C++ позволяет писать код, который сопоставим по уровню с машинными
инструкциями центрального (CPU) и графического (GPU) процессоров,
что делает его подходящим для любых ресурсоемких приложений, включая
игры. Это достигается за счет компиляции кода C++ в нативные исполняемые инструкции. Это как раз то, что нам нужно при создании игр с сотнями,
тысячами и даже сотнями тысяч объектов. В главе 21 мы рассмотрим, как C++
может напрямую взаимодействовать с GPU через программы-шейдеры.
zzКросс-платформенность. C++ работает практически везде, а это значит, что
вы можете писать код, который можно компилировать и запускать на разных
платформах без существенных изменений. Примеры из книги создавались
в Windows, но все, что мы изучим и напишем в ней, с небольшими изменения
ми будет работать на macOS и Linux. Язык также широко используется при
разработке игр для современных консолей и даже может быть полезен для
мобильных платформ. Компиляция означает перевод нашего кода на C++
в двоичные машинные инструкции для CPU.
zzМножество игровых движков и библиотек. Многие игровые движки и биб
лиотеки написаны на C++ или предоставляют API для C++. Помимо собственных сетевых функций SFML, язык C++ дает вам доступ к широчайшему
спектру инструментов и ресурсов вроде Unreal Engine, а также к самым быстрым и лучшим графическим библиотекам Vulcan, OpenGL, DirectX и Metal,
библиотекам физики Box2D, инструментам пользовательского интерфейса
IMGUI и таким сетевым библиотекам для совместной и многопользовательской игры, как RakNet, Enet.
zzНизкоуровневый контроль. C++ обеспечивает низкоуровневый контроль
над аппаратными ресурсами, что очень важно для оптимизации произво-
Почему разработка игр на C++ — отличный способ освоить программирование 29
дительности игр. При разработке игр вам может понадобиться управлять
памятью, оптимизировать конвейеры рендеринга и поддерживать контроль
над системой, на которой работает ваша игра, и C++ предлагает все необходимое для этого. Если управление памятью и конвейеры рендеринга звучат
пугающе, могу заверить вас, что в главах 10 и 21 соответственно я рассказываю об этих двух темах в полностью доступной для новичков форме.
Знание того, как управлять этими вещами, позволит вам почувствовать себя
уверенно.
zzДокументация и поддержка. Вокруг разработки игр на C++ существует процветающее сообщество с многочисленными ресурсами, учебными материалами и форумами, которые помогут вам в изучении и решении возникающих
проблем. ChatGPT тоже является отличным помощником.
zzИзучение C++ сопряжено с определенными трудностями, но, если делать это
шаг за шагом, вы легко освоите его и получите истинное удовольствие от процесса. Разработка игр часто включает в себя, казалось бы, сложные алгоритмы,
структуры данных и принципы, но C++ предоставляет такие инструменты,
как STL и классы ООП, которые позволяют уменьшить сложность, разбив код на
управляемые блоки. Мы рассмотрим ООП и STL в главах 6 и 10 соответственно.
zzC++ — это стандарт игровой индустрии. Именно благодаря всем перечисленным достоинствам C++ широко используется в области разработки игр.
Владение C++ облегчает совместную работу с другими разработчиками,
понимание существующих кодовых баз, переход от одного игрового движка
к другому и обеспечивает высокооплачиваемую работу в этой сфере.
Критики скажут, что C++ может иметь более крутую кривую обучения по сравнению с некоторыми другими языками программирования и что если вы новичок
в программировании или разработке игр, то вам стоит начать с более дружественного языка вроде C# (для разработки на Unity) или Python (для простых
игровых проектов), прежде чем погружаться в C++. В этом есть доля правды, но
сегодня это уже не так актуально, как раньше. C++ постоянно развивается, и за
последние годы в нем добавилось множество улучшений, упрощающих обучение
и значительно ускоряющих разработку. Так, за последние десять лет появились
новые ключевые слова, например auto, логический оператор с интригующим названием «космический корабль» (<=>), а также различные языковые конструкции
вроде лямбд, сопрограмм и умных указателей.
Подводя итог, я бы сказал, что отказ от изучения C++ в качестве первого языка
может быть ошибкой. А если вы хотите сделать процесс обучения максимально увлекательным и полезным, то написание на С++ игр — отличный выбор.
Наконец, если вы хотите стать независимым разработчиком или работать в крупной игровой студии, C++ — это то, что вам нужно.
Но если мы только что заявили, что C++ так прекрасен и предоставляет столько
возможностей и библиотек, почему мы выбрали именно SFML?
30 Глава 1. Введение
SFML
SFML, или Simple and Fast Multimedia Library, — это кросс-платформенная мульти
медийная библиотека C++ для создания игр и мультимедиа. Она не единственная
в своем роде, и можно найти аргументы в пользу других библиотек, но SFML
всегда оказывается подходящим выбором.
Во-первых, она написана на объектно-ориентированном C++. Преимущества
ООП на C++ многочисленны, и вы убедитесь в этом по мере чтения.
Во-вторых, SFML проста в освоении и поэтому является хорошим вариантом
для новичков, но в то же время она обладает потенциалом для создания высококачественных 2D-игр на профессиональном уровне. Таким образом, начинающий разработчик может использовать SFML и не беспокоиться о том,
что ему придется работать с новым языком/библиотекой по мере накопления
опыта. А если вы хотите создавать 3D-игры, C++ и SFML — отличное введение
перед переходом на Unreal Engine. К слову, вы можете создавать 3D-игры с помощью SFML и OpenGL, но большинство библиотек SFML ориентированы на
2D, как и данная книга.
Возможно, самым важным преимуществом является то, что большинство современных программ на C++ используют ООП. Во всех учебниках по C++
для начинающих, которые мне приходилось читать, рассказывается об ООП.
ООП — это будущее (и настоящее) программирования почти во всех языках.
Так зачем же, начиная изучение C++, выбирать другой подход?
В SFML есть практически все, что может понадобиться при разработке 2D-игры,
для 3D-игр SFML задействует OpenGL. OpenGL является де-факто бесплатной
графической библиотекой для игр с кросс-платформенной поддержкой. Работая
с SFML, вы автоматически используете OpenGL.
SFML позволяет:
zzсоздавать 2D-графику и анимацию, включая игровые миры с прокруткой
экрана;
zzдобавлять звуковые эффекты и воспроизведение музыки, включая высококачественный направленный звук;
zzобрабатывать ввод с клавиатуры, мыши и геймпада;
zzдобавлять сетевые многопользовательские функции;
zzкомпилировать и запускать на всех основных операционных системах для
настольных ПК, а также на мобильных устройствах один и тот же код!
Обширные исследования не выявили более подходящих способов создания
2D-игр для ПК на C++, даже для опытных разработчиков, особенно если вы
новичок и хотите изучить C++ в увлекательной игровой среде. C++ и SFML —
надежные и проверенные временем инструменты.
Почему разработка игр на C++ — отличный способ освоить программирование 31
Microsoft Visual Studio
Visual Studio — это интегрированная среда разработки (IDE). Она предоставляет
удобный интерфейс со множеством полезных функций, упрощающих процесс
разработки игр и обеспечивающих доступ к продвинутым инструментам. Новичкам особенно пригодятся такие возможности, как завершение кода и подсветка
синтаксиса, которые помогут упростить процесс изучения C++. Visual Studio, пожалуй, является самой продвинутой бесплатной IDE для C++. Microsoft предоставляет ее не из альтруистических побуждений, а для того, чтобы в дальнейшем
побудить вас перейти на платную версию. Так что пока воспользуемся бесплатными возможностями.
Visual Studio предлагает мощный отладчик с такими инструментами, как точки
останова и стек вызовов. Вы можете запускать свою игру в Visual Studio и приостанавливать ее выполнение в выбранной вами точке, чтобы просмотреть
значения, хранящиеся в переменных, и пошагово пройтись по строчкам кода.
Это значительно облегчает понимание работы кода и устранение проблем.
IntelliSense — это инструмент Visual Studio для автоматических подсказок по коду
и проверки ошибок в реальном времени. Он помогает новичкам в C++ быстрее
освоить язык, мгновенно выделяя ошибки и предлагая возможные варианты
исправления. Этот инструмент не только полезен для начинающих, но и существенно ускоряет работу профессиональных разработчиков.
Visual Studio имеет большое и активное сообщество. Существует множество
учебных пособий, форумов и ресурсов, которые помогут новичкам в работе над
проектами на C++ и SFML в Visual Studio.
Кроме того, Visual Studio поддерживает множество продвинутых инструментов. По мере роста ваших знаний и амбиций Visual Studio будет «расти» вместе
с вами. Она интегрируется с популярными системами контроля версий (VCS),
такими как Git, что облегчает управление большими проектами с несколькими
разработчиками. Кроме того, Visual Studio обладает инструментами для профилирования производительности, которые позволяют отслеживать использование
памяти и ресурсов процессора, чтобы улучшать и оптимизировать игры.
Среда Visual Studio почти стала стандартом в индустрии. Будучи одной из самых
распространенных IDE для C++, Visual Studio имеет огромное количество пользователей. Это означает, что новички смогут найти множество онлайн-справок
и учебников по Visual Studio. Кстати, обычно последнее место, где вы будете искать поддержку Visual Studio, — это Microsoft. Кроме того, знание Visual Studio
может оказаться ценным навыком в целом.
Visual Studio скрывает сложность предварительной обработки, компиляции
и компоновки. Все это можно сделать нажатием одной кнопки. Кроме того, эта
среда предоставляет удобный пользовательский интерфейс для написания кода
и управления большим количеством файлов и других ресурсов проекта.
32 Глава 1. Введение
Несмотря на все перечисленные преимущества Visual Studio, стоит отметить,
что любую игру, созданную с ее помощью, вы также можете разработать с использованием инструментов с открытым исходным кодом. Visual Studio лишь
сделает этот процесс проще для новичков. Если в будущем вы решите перейти
на другой набор инструментов, переход будет более плавным благодаря опыту,
полученному с Visual Studio.
Хотя существуют платные версии Visual Studio, стоящие сотни долларов, для
наших нужд достаточно бесплатной Visual Studio 2022 Community Edition.
Это самая актуальная бесплатная редакция Visual Studio на момент написания
книги. Если вы, читая книгу, обнаружили более новую версию, я рекомендую использовать ее, поскольку Visual Studio, как правило, обладает высокой обратной
совместимостью, а также поддерживает единый пользовательский интерфейс на
протяжении многих лет. Это означает, что вы сможете воспользоваться новыми
функциями и при этом продолжать работать с книгой.
А что насчет Mac и Linux
Игры, которые мы создадим, можно будет запускать на Windows, Mac и Linux!
Код идентичен для всех платформ. Однако каждая версия должна быть скомпилирована и скомпонована на той платформе, для которой она предназначена.
Было бы несправедливо утверждать, особенно для начинающих, что эта книга
идеально подходит для пользователей Mac и Linux. Хотя, думаю, если вы являетесь энтузиастом Mac или Linux и хорошо ориентируетесь в своей операционной
системе, у вас все получится. Большинство трудностей, с которыми вы столк
нетесь, будут связаны с первоначальной настройкой среды разработки, SFML
и первого проекта.
В связи с этим я настоятельно рекомендую такие учебные материалы, которые,
надеюсь, заменят примерно следующие десять страниц, вплоть до раздела «Составляем план игры Timber!», после которого книга станет актуальной для всех
операционных систем.
Руководство для пользователей Linux: https://www.sfml-dev.org/tutorials/2.5/startlinux.php.
Руководство для пользователей Mac: https://www.sfml-dev.org/tutorials/2.5/start-osx.php.
Установка Visual Studio 2022
Чтобы начать создавать игры, нам нужно установить Visual Studio. В этом нет
ничего сложного.
Обратите внимание, что со временем Microsoft может изменить название, внешний вид и страницу загрузки Visual Studio. Они могут изменить интерфейс,
и инструкции, приведенные ниже, могут устареть. Однако, по моему опыту,
Почему разработка игр на C++ — отличный способ освоить программирование 33
корпорация старается поддерживать преемственность между версиями программы. Кроме того, параметры, которые мы настраиваем для каждого проекта,
являются основополагающими для C++ и SFML, поэтому, даже если Microsoft
внесет в Visual Studio значительные изменения, вы сможете понять и адаптировать инструкции из этого раздела.
Начнем с установки Visual Studio.
1. Прежде всего вам понадобятся учетная запись Microsoft и данные для входа
в систему. Подойдут также учетные записи Hotmail, Windows, Xbox или MSN.
Если у вас ничего из этого нет, вы можете бесплатно зарегистрироваться
здесь: https://login.live.com/.
2. На момент написания книги последней версией IDE является Visual Studio
2022, так что, надеюсь, данный раздел будет актуальным еще долгое время.
Чтобы начать работу, зайдите на сайт https://visualstudio.microsoft.com/ и найдите
страницу загрузки Visual Studio. На рис. 1.5 показано, как выглядела страница
на момент написания книги.
Рис. 1.5. Страница загрузки Visual Studio
3. Найдите кнопку загрузки Visual Studio и выберите Community 2022 из выпадающего списка. Обратите внимание, что редакции, отличные от Community,
являются платными, а вариант Visual Studio Code нам не подходит. Нажмите
кнопку Save (Сохранить), и загрузка начнется.
4. Когда загрузка завершится, запустите файл, дважды щелкнув на нем. Дайте
разрешение Visual Studio внести изменения на вашем компьютере и дождитесь, пока программа установки загрузит некоторые файлы и подготовит
следующий этап установки.
5. Вскоре вас спросят выбрать, куда установить Visual Studio. Обратите внимание, что на диске должно быть не менее 50 Гбайт свободной памяти. Различные источники в Интернете утверждают, что можно обойтись и меньшим
34 Глава 1. Введение
объемом, однако 50 Гбайт обеспечат вам достаточно места для будущих разработок. Затем найдите опцию Desktop development with C++ (Разработка классических приложений на C++), выберите ее и нажмите кнопку Install (Установить).
Теперь мы готовы перейти к настройке SFML и созданию нашего первого проекта.
Настройка SFML
В этом кратком руководстве вы узнаете, как загрузить файлы SFML, которые
позволят включить функциональность библиотеки SFML в наши проекты.
Кроме того, мы увидим, как использовать DLL-файлы SFML, чтобы наш скомпилированный объектный код мог работать вместе с SFML. Для этого выполните
следующие действия.
1. Перейдите на веб-сайт SFML: http://www.sfml-dev.org/download.php. Нажмите
кнопку Latest stable version (Последняя стабильная версия), как показано на
рис. 1.6.
Рис. 1.6. Загрузка SFML 2.6
2. К тому времени, когда вы будете читать эту книгу, последняя версия почти наверняка изменится. Это не будет иметь значения, если вы правильно
выполните следующий шаг. Мы хотим загрузить 32-битную версию. Это
может показаться нелогичным, потому что у вас, скорее всего, 64-битный
компьютер. Причина в том, что 32-битные приложения могут работать как на
32-битных, так и на 64-битных машинах. Кроме того, нам нужна версия для
Visual Studio 22. Нажмите кнопку Download (Загрузить) (рис. 1.7).
3. Когда загрузка завершится, создайте две папки в корне того же диска, куда вы
установили Visual Studio, и назовите их SFML и VS Projects.
4. Наконец, распакуйте загруженный файл SFML. Сделайте это на рабочем
столе. Мой файл называется SFML2.6.0-windows-vc17-32-bit.zip, но наименование вашего может отличаться (например, если вы скачали более новую
версию SFML). После завершения распаковки вы можете удалить ZIP-архив.
На рабочем столе останется одна папка. Ее имя будет соответствовать редакции SFML, которую вы загрузили. Дважды щелкните на этой папке, чтобы
увидеть ее содержимое; у меня это папка SFML-2.6.0. Теперь снова дважды
щелкните по ней. На рис. 1.8 показано содержимое моей папки SFML. У вас
должно быть то же самое.
Создание нового проекта в Visual Studio 2022 35
Рис. 1.7. Загрузка SFML 17_22
Рис. 1.8. Содержимое папки SFML
Скопируйте все ее содержимое и вставьте его в папку SFML, которую вы создали
в шаге 3. В дальнейшем я буду называть ее просто «ваша папка SFML».
Теперь вы готовы начать использовать C++ и SFML в Visual Studio.
Создание нового проекта в Visual Studio 2022
Поскольку настройка проекта — процесс довольно кропотливый, пройдемся по
нему шаг за шагом.
1. Запустите Visual Studio, дважды щелкнув на его ярлыке на рабочем столе.
Вы увидите окно, как показано на рис. 1.9.
36 Глава 1. Введение
Рис. 1.9. Запуск нового проекта в Visual Studio 2022
2. Нажмите кнопку Create a new project (Создать новый проект). Появится окно
создания нового проекта (рис. 1.10).
Рис. 1.10. Окно создания нового проекта
Создание нового проекта в Visual Studio 2022 37
3. Здесь нам нужно выбрать тип будущего проекта. Мы создадим приложение,
которое не содержит графического интерфейса и таких элементов Windows,
как меню и поля выбора. Поэтому выберите Console App (Консольное приложение) и нажмите кнопку Next (Далее). Появится окно настройки нового
проекта (рис. 1.11).
Рис. 1.11. Окно настройки нового проекта
4. В окне настройки нового проекта введите Timber в поле Project name (Имя
проекта). Обратите внимание, что Visual Studio автоматически установит
такое же имя в поле Solution name (Имя решения).
5. В поле Location (Расположение) укажите путь к папке VS Projects, которую мы
создали ранее. Она будет выступать в качестве места хранения всех файлов
нашего проекта.
6. Установите флажок Place solution and project in the same directory (Поместить решение и проект в одном каталоге).
7. На рис. 1.11 показано, как должно выглядеть окно после заполнения всех полей. Далее нажмите кнопку Create (Создать). Visual Studio сгенерирует проект,
включая некоторый код на C++ (рис. 1.12).
8. Теперь нужно настроить проект для работы с файлами SFML, которые мы
поместили в папку SFML. В главном меню выберите ProjectTimber properties
(ПроектСвойства Timber). Вы увидите окно, как на рис. 1.13.
38 Глава 1. Введение
Рис. 1.12. Редактор кода Visual Studio
Рис. 1.13. Страницы свойств проекта Timber
Кнопки OK (ОК), Cancel (Отмена) и Apply (Применить) могут отображаться неполностью. Скорее всего, это ошибка Visual Studio, связанная с разрешением
моего экрана. Надеюсь, ваши кнопки будут сформированы корректно.
Далее мы приступим к настройке свойств проекта. Поскольку эти шаги довольно
сложны, я расскажу о них в отдельном подразделе.
Настройка свойств проекта
На данном этапе у вас должна быть открыта страница свойств проекта, как показано на рис. 1.13. Теперь мы сконфигурируем некоторые свойства, используя
для этого рис. 1.14 в качестве ориентира.
Создание нового проекта в Visual Studio 2022 39
Рис. 1.14. Настройка свойств проекта
В текущем разделе мы добавим несколько сложных и важных настроек проекта.
Это трудоемкий процесс, но его необходимо повторять для каждого проекта.
Нам нужно указать Visual Studio, где найти специальный тип файла кода из
SFML. Особый тип файла, о котором я говорю, — это заголовочный файл, который определяет формат SFML-кода, чтобы компилятор знал, как обрабатывать
функции SFML. Обратите внимание, что заголовочные файлы отличаются от
основных файлов исходного кода и имеют расширение .hpp . Все это станет
понятнее, когда мы начнем добавлять собственные заголовочные файлы во
втором проекте. Кроме того, нам нужно указать Visual Studio, где расположены
файлы библиотеки SFML. Для этого на странице свойств проекта выполните
следующие шаги.
1. Выберите All Configurations (Все конфигурации) в поле Configuration (Конфигурация) и убедитесь, что в раскрывающемся списке Platform (Платформа) справа
установлено значение Win32.
2. Далее в меню слева выберите C/C++, а затем General (Общие).
3. Найдите поле Additional Include Directories (Дополнительные каталоги включа
емых файлов) и введите букву диска, на котором находится ваша папка SFML,
а затем \SFML\include. Если ваша папка SFML располагается на диске D, то
полный путь будет D:\SFML\include. Измените путь, если вы разместили SFML
на другом диске.
4. Нажмите Apply (Применить), чтобы сохранить настройки.
5. Теперь, все еще находясь в том же окне, выберите Linker (Компоновщик), а затем General (Общие).
6. Найдите поле редактирования Additional Library Directories (Дополнительные каталоги библиотек) и введите букву диска, на котором находится ваша папка
SFML, а затем \SFML\lib. Таким образом, если вы разместили папку SFML на диске D, то полный путь будет выглядеть так: D:\SFML\lib (рис. 1.15). Измените
путь, если файлы SFML располагаются на другом диске.
40 Глава 1. Введение
Рис. 1.15. Дополнительные каталоги библиотеки
7. Нажмите Apply (Применить), чтобы сохранить настройки.
8. Наконец, не закрывая окно, выполните шаги в последовательности, которая
показана на рис. 1.16. Установите выпадающий список Configuration (Конфигурация) (1) в положение Debug, поскольку мы будем запускать и тестировать
наши игры в режиме отладки.
Рис. 1.16. Конфигурация ввода компоновщика
Составляем план игры Timber! 41
9. Выберите Linker (Компоновщик), а затем Input (Ввод) (2).
10. Найдите поле редактирования Additional Dependencies (Дополнительные зависимости) (3), щелкните на нем в крайнем левом углу и наберите следующий
текст: sfml-graphics-d.lib;sfml-window-d.lib;sfmlsystem-d.lib;sfmlnetwork-d.lib;sfml-audio-d.lib;. Будьте внимательны: курсор необходимо
расположить точно в нужном месте, а уже имеющийся текст не должен быть
изменен.
11. Нажмите кнопку OK.
12. Нажмите кнопку Apply (Применить), а затем OK.
Вот и все! Мы успешно настроили Visual Studio и можем переходить к составлению плана нашей первой игры.
Составляем план игры Timber!
Для начала лучше всего воспользоваться карандашом и бумагой. Если вы не знаете, как именно будет работать ваша игра на экране, как вы сможете заставить ее
работать в коде?
Если вы не знакомы с игрой Timberman, рекомендую вам посмотреть несколько видеороликов с ней, чтобы вы могли понять, к чему мы стремимся. Или
поиграйте в нее: она часто продается в Steam по цене менее 1 доллара (http://
store.steampowered.com/app/398710/).
Особенности и объекты игры, определяющие игровой процесс, называются механикой. Основные механики игры следующие:
zzвремя постоянно убывает;
zzвы можете получить дополнительное время, разрубив дерево;
zzрубка дерева вызывает падение веток;
zzигроку необходимо избегать падающих веток;
zzигра продолжается до тех пор, пока не закончится время или игрока не при-
давит веткой.
Ожидать, что на этом этапе вы сможете спланировать код на C++, очевидно, немного наивно. Это лишь первая глава книги. Тем не менее мы рассмотрим игровые ресурсы, которые будут задействованы, и все необходимое для написания
нашего кода на C++. Взгляните на рис. 1.17.
42 Глава 1. Введение
Рис. 1.17. Скриншот игры Timber!
Здесь есть следующие элементы:
zzсчет игрока — каждый раз, когда игрок рубит дерево с помощью клавиш со
стрелками, он получает одно очко;
zzигровой персонаж — каждый раз, когда игрок рубит, персонаж перемещается
или остается с той стороны дерева, которую игрок выбрал, нажимая соответствующую стрелку. Поэтому игрок должен быть внимателен к тому, с какой
стороны он начинает рубку;
zzизображение топора — когда игрок рубит, в руках персонажа появляется
топор;
zzвременная шкала — разрубив дерево, игрок получает дополнительное время,
которое постепенно убывает;
zzопасные ветки — чем быстрее игрок рубит, тем больше времени он выиграет,
но тем быстрее будут падать ветки, а значит, вероятность быть раздавленным
возрастает. Ветки появляются в случайном порядке на вершине дерева и падают с каждым ударом;
zzизображение надгробия — когда игрока раздавит веткой — а происходить это
будет довольно регулярно, — появится могильный камень;
zzизображение бревна — после рубки бревно отлетает в сторону;
Составляем план игры Timber! 43
zzдекоративные объекты — здесь есть три плавающих облака, которые дрейфуют
на случайной высоте и скорости, а также пчела, которая просто летает вокруг;
zzфон — все это происходит на красивом фоне.
В двух словах, игроку нужно лихорадочно рубить,
чтобы набрать очки и уложиться в отведенное время.
Однако чем быстрее он рубит, тем выше шанс погибнуть.
Итак, мы знаем, как выглядит игра, как в нее играть
и основы игровой механики. Теперь мы можем приступить к ее созданию. Выполните следующие шаги.
1. Скопируйте файлы с расширением .dll библиотеки SFML в основную папку проекта. У меня это
D:\VS Projects\Timber. Она была создана Visual
Studio в предыдущем уроке. Файлы, которые нам
нужно скопировать, находятся в вашей папке
SFML\bin. Выделите их, как показано на рис. 1.18.
2. Затем скопируйте и вставьте выделенные файРис. 1.18. Выбор всех
лы в папку проекта (например, в D:\VS Projects\
необходимых файлов
Timber).
3. Теперь проект настроен и готов к работе (см. рис. 1.19).
Рис. 1.19. Где писать код
Внешний вид вашей рабочей области может немного отличаться от того, что показано на рис. 1.19, потому что окна Visual Studio, как и в большинстве других
приложений, можно настраивать. Найдите окно Solution Explorer (Обозреватель
решений) и отрегулируйте его так, чтобы содержимое было четко видно.
Скоро мы сможем приступить к написанию кода, но сперва изучим использу
емые ресурсы проекта.
44 Глава 1. Введение
Ресурсы проекта
Ресурсы, или ассеты (от англ. assets), — это все, что вам нужно для создания игры.
В нашем случае эти ресурсы включают:
zzшрифт для отображения текста на экране;
zzзвуковые эффекты для различных действий, таких как рубка, гибель персо-
нажа и окончание времени;
zzизображения (текстуры), используемые для персонажа, фона, ветвей и других
игровых объектов.
Все необходимые графические элементы и звуки включены в пакет загрузки
книги. Их можно найти в папках Chapter 1/graphics и Chapter 1/sound соответственно.
Шрифт, который используется в примерах, я не предоставил, так как хотел избежать возможных вопросов с лицензией. Однако это не вызовет проблем, поскольку я подробно объясню, где и как выбрать и скачать его.
Создание собственных звуковых эффектов
Звуковые эффекты (FX) можно бесплатно скачать с таких сайтов, как Freesound
(www.freesound.org), но часто лицензия не разрешает задействовать их в коммерческих проектах. Другой вариант — использовать программное обеспечение
с открытым исходным кодом под названием BFXR (www.bfxr.net) для генерации
различных звуковых эффектов, не защищенных авторским правом.
Добавление ресурсов в проект
После того как вы определитесь с тем, какие ресурсы будете применять, можно
приступать к добавлению их в проект. Следуйте дальнейшим инструкциям.
Я предполагаю, что вы используете все ресурсы, которые включены в пакет загрузки книги. Если вы задействуете собственные ресурсы, просто замените соответствующий звуковой или графический файл своим, но под тем же именем.
1. Перейдите в папку проекта, например D:\VS Projects\Timber.
2. Создайте здесь три новые папки и назовите их graphics, sound и fonts.
3. Из пакета загрузки скопируйте все содержимое Chapter 1/graphics в папку
D:\VS Projects\Timber\graphics.
4. Из пакета загрузки скопируйте все содержимое Chapter 1/sound в папку D:\VS
Projects\Timber\sound.
Ресурсы проекта 45
5. Теперь посетите сайт http://www.1001freefonts.com/komika_poster.font и скачайте
шрифт Komika Poster.
6. Распакуйте содержимое загруженного архива и добавьте файл KOMIKAP_.ttf
в папку D:\VS Projects\Timber\fonts.
Рассмотрим эти ресурсы, особенно графические, чтобы лучше понимать, что
будет происходить, когда мы воспользуемся ими в нашем коде C++.
Обзор ресурсов проекта
Графические ресурсы составляют части сцены нашей игры. Если вы посмотрите
на графические ресурсы на рис. 1.20, станет понятно, где именно в игре они будут
применены.
Рис. 1.20. Графические ресурсы
Все звуковые файлы имеют формат .wav. Они содержат звуковые эффекты, которые будут воспроизводиться при определенных событиях в игре. Все они были
сгенерированы с помощью BFXR:
zzchop.wav — звук, напоминающий удар топора по дереву;
zzdeath.wav — звук в стиле ретро, проигрываемый при гибели персонажа;
zzout_of_time.wav — звук, который воспроизводится, когда в игре заканчивается
время.
Мы рассмотрели все ресурсы проекта, поэтому сейчас обсудим разрешение экрана и позиционирование на нем изображений.
46 Глава 1. Введение
Координаты на экране и внутренние
координаты
Прежде чем перейти к написанию кода на C++, давайте немного поговорим
о координатах. Все изображения, которые мы видим на наших мониторах, состоят из пикселей. Пиксели — это наименьшие элементы двумерного цифрового
изображения в растровой графике, крошечные точки, которые имеют свои цвет
и яркость.
Мониторы бывают с разным разрешением, но в качестве примера возьмем типичный монитор с разрешением 1920 пикселей по горизонтали и 1080 пикселей
по вертикали.
Пиксели на экране нумеруются, начиная с левого верхнего угла. Как видно из
рис. 1.21, в нашем примере с разрешением 1920 × 1080 пиксели пронумерованы
от 0 до 1919 по оси X и от 0 до 1079 по оси Y.
Таким образом, конкретное и точное местоположение на экране можно определить по координатам X и Y. Мы создаем игры, рисуя игровые объекты, такие
как фон, персонажи, пули и текст, в конкретных местах экрана, которые определяются координатами пикселей. Например, если мы хотим нарисовать объект в центре экрана с разрешением 1920 × 1080 — это будет позиция (960, 540)
(рис. 1.22).
Рис. 1.21. Координаты на экране
Рис. 1.22. Координаты центра экрана
В дополнение к координатам на экране каждый наш игровой объект будет иметь
собственную систему координат. Как и в системе координат экрана, их внутренние (или локальные) координаты начинаются с точки (0, 0) в левом верхнем
углу объекта.
На рис. 1.22 показано, что точка (0, 0) изображения персонажа размещена в точке
с координатами на экране (960, 540). Визуальный двумерный игровой объект,
Начало работы с кодом 47
такой как персонаж или, например, зомби, называется спрайтом. Спрайт обычно
создается из графического файла. Все спрайты имеют так называемое начало
координат, или точку привязки.
Если мы рисуем спрайт в определенном месте экрана, именно точка привязки
будет расположена в этом конкретном месте. Координаты (0, 0) спрайта — это
его точка привязки (рис. 1.23).
На рис. 1.23 видно, что, хотя мы расположили
персонаж в центральной точке (960, 540) , он
оказался смещен вправо и вниз.
Это важно знать, ведь так мы будем лучше понимать координаты, которые используем для
отрисовки изображений.
Обратите внимание, что в реальном мире есть
огромное множество разрешений экранов и наши
игры должны корректно работать как можно
с большим их количеством. В третьем проекте
мы узнаем, как сделать игры, которые динамически адаптируются практически к любому разрешению. В этом первом проекте мы будем исходить из того, что разрешение экрана составляет
1920 × 1080 пикселей или выше.
Теперь мы можем написать наш первый фрагмент кода на C++ и посмотреть его в действии.
Рис. 1.23. Спрайт
и его точка привязки
Начало работы с кодом
Найдите проект Timber в списке недавних проектов на главном экране Visual
Studio и откройте его, щелкнув на нем кнопкой мыши.
В папке Source Files окна Solution Explorer (Обозреватель решений) откройте файл
Timber.cpp. Расширение .cpp обозначает файл, написанный на языке C++.
Удалите все содержимое окна ввода и добавьте следующий код. Вы можете набрать его вручную или просто скопировать и вставить:
// Отсюда начинается наша игра
int main()
{
return 0;
}
Эта простая программа на C++ — хороший пример для начала. Давайте разберем
ее построчно.
48 Глава 1. Введение
Комментарий
Первая строка кода выглядит следующим образом:
// Отсюда начинается наша игра
Любая строка кода, предваряемая двумя косыми чертами (//), является комментарием и игнорируется компилятором. Таким образом, такая строка кода ничего
не делает. Она нужна для того, чтобы добавить информацию, которая может
оказаться полезной при работе с кодом. Комментарий заканчивается в конце
строки, поэтому все, что находится на следующей строке, не является частью
комментария.
Есть еще один тип комментариев, называемый многострочным, который позволяет оставлять комментарии на нескольких строках. На протяжении всей
книги я буду оставлять сотни комментариев, чтобы добавить контекст и объяснить код.
Функция main
Следующая строка, которую мы видим в нашем коде, выглядит таким образом:
int main()
int — это тип данных. В языке C++ существует множество типов, которые представляют различные виды данных; int обозначает целое число. Запомните эту
фразу, мы вернемся к ней через минуту.
main() — это название фрагмента кода, который следует дальше. Он ограничивается открывающей фигурной скобкой ({) и следующей закрывающей фигурной
скобкой (}).
Итак, все, что находится между этими фигурными скобками, является частью
main. Мы называем такой фрагмент кода функцией.
В каждом проекте на C++ есть функция main, с которой начинается выполнение
всей программы. По мере изучения книги наши игры будут сопровождаться
множеством файлов кода. Однако функция main будет только одна, и какой бы
код мы ни написали, наша игра всегда будет начинаться с первой строки кода,
которая находится внутри функции main.
Пока не обращайте внимания на круглые скобки, которые следуют за именем
функции (). Мы обсудим их в главе 4, где познакомимся с циклами, массивами,
операторами switch, перечислениями и увидим функции в совершенно новом
и более интересном свете.
Давайте внимательно посмотрим на одну-единственную строчку кода в нашей
функции main.
Начало работы с кодом 49
Оформление и синтаксис
Взгляните еще раз на нашу функцию main:
int main()
{
return 0;
}
Мы видим, что внутри main есть только одна строка кода, return 0;. Прежде чем
мы выясним, что делает эта строка кода, обратим внимание, как она представлена.
Это полезно, потому что поможет нам подготовиться к написанию кода, который
будет легко читать и отличать от других частей программы.
Обратите внимание, что строка return 0; сдвинута вправо на одну табуляцию.
Это явно указывает на то, что она является внутренней частью функции main.
По мере роста объема нашего кода мы увидим, что отступы и пробелы очень
важны для сохранения читабельности.
Пунктуация в конце строки, а именно точка с запятой (;), указывает компилятору, что это конец инструкции и что все, что следует за ней, — новая инструкция.
Мы называем инструкцию, завершенную точкой с запятой, оператором.
Компилятору неважно, оставите ли вы новую строку или даже пробел между
точкой с запятой и следующим оператором. Однако если писать все инструкции
на одной строке, это приведет к трудночитаемому коду, а отсутствие точки с запятой вообще вызовет синтаксическую ошибку, и программа не скомпилируется
и не запустится.
Фрагмент кода, часто обозначаемый отступом относительно остального текста,
называется блоком.
Теперь, когда вы знаете, что такое функция main, зачем нужны отступы в коде
и точка с запятой в конце каждого оператора, обсудим, что именно делает оператор return 0;.
Возвращение значений из функции
На самом деле return 0; почти ничего не делает в контексте нашей игры. Тем
не менее концепция очень важна. Добавив ключевое слово return либо само по
себе, либо с каким-то значением, мы указываем программе завершить выполнение и перейти или вернуться к коду, который изначально вызвал функцию.
Часто код, который вызывает функцию, является другой функцией, расположенной где-то в нашем коде. Однако в данном случае функцию main запускает
операционная система. Поэтому при выполнении return 0; происходят выход из
функции main и завершение работы всей программы.
50 Глава 1. Введение
Поскольку после ключевого слова return у нас стоит значение 0, оно также отправляется операционной системе. Мы можем изменить значение 0 на любое
другое, и оно будет отправлено обратно.
На языке программирования это означает, что код, запускающий функцию, вызывает ее, а функция возвращает значение.
Для начала этой информации о функциях достаточно. Подробнее о них мы поговорим в ходе работы над первым проектом. Однако есть еще одна важная деталь,
о которой я хотел бы рассказать сейчас. Помните int из int main()? Это сообщает
компилятору, что функция main должна вернуть значение типа int (целое число).
Мы можем вернуть любое целочисленное значение: 0, 1, 999, 6358 и т. д. Если мы
попытаемся вернуть, например, 12.76, компилятор выдаст ошибку и программа
не запустится.
Функции могут возвращать множество различных типов, включая те, что мы
придумываем сами! Однако тип возвращаемого значения должен быть известен
компилятору.
Благодаря этой небольшой справочной информации о функциях вам будет проще понять последующий материал.
Запуск игры
На этом этапе можно даже запустить игру. Для
этого нажмите кнопку Local Windows Debugger (Локальный отладчик Windows) на панели быстрого
доступа Visual Studio, как показано на рис. 1.24,
или воспользуйтесь клавишей F5.
Рис. 1.24. Кнопка локального
отладчика Windows
Убедитесь, что в выпадающем меню слева от данной кнопки выбрано x86
(рис. 1.25). Это означает, что наша программа будет 32-битной и соответствовать
версии SFML, которую мы загрузили.
Рис. 1.25. Убедитесь, что работаете под управлением x86
При запуске программы просто появится черный экран. Нажмите любую клавишу, чтобы закрыть окно, если этого не произошло автоматически. Данное окно —
консоль C++, которую можно использовать для отладки нашей игры. Сейчас нам
это не нужно. Когда вы запускаете программу, она начинает выполнение с первой
строки функции main, а именно return 0;, после чего сразу же завершает работу
и тут же выходит обратно в операционную систему.
Открытие окна с помощью SFML 51
Теперь у нас есть самая простая программа, которую можно запустить. Далее мы
добавим еще немного кода, чтобы открыть окно, в котором впоследствии будет
отображаться наша игра.
Открытие окна с помощью SFML
Следующий код создаст окно с помощью SFML, в котором в итоге будет запущена
игра Timber!. Окно будет иметь размеры 1920 пикселей в ширину и 1080 пикселей
в высоту и будет полноэкранным (без границ и заголовка).
Добавьте новый код, выделенный здесь, в имеющийся, а затем мы рассмотрим его.
По мере ввода (или копирования и вставки) постарайтесь понять, что происходит:
// Подключаем важные библиотеки
#include <SFML/Graphics.hpp
// Упрощаем использование кода с помощью "using namespace",
// обозначающего пространство имен
using namespace sf;
// Отсюда начинается наша игра
int main()
{
// Создаем объект VideoMode
VideoMode vm(1920, 1080);
// Создаем и открываем окно для игры в полноэкранном режиме
RenderWindow window(vm, "Timber!", Style::Fullscreen);
}
return 0;
Теперь разберем код по частям, чтобы понять, что он делает.
Включение функций SFML
Первое, что мы видим, — это директива #include.
Директива #include указывает Visual Studio включить, или добавить, содержимое
другого файла перед компиляцией. В результате этого другой код, который мы
не писали сами, станет частью нашей программы, когда мы ее запустим. Процесс
добавления кода из других файлов называется препроцессингом, и, что, наверное,
неудивительно, выполняется он препроцессором. Расширение .hpp означает, что
это заголовочный файл.
Поэтому строка #include <SFML/Graphics.hpp> указывает препроцессору включить содержимое файла Graphics.hpp, расположенного в папке SFML — той самой,
которую мы создали при настройке проекта.
52 Глава 1. Введение
Данная строка добавляет код из файла и предоставляет нам доступ к некоторым
возможностям SFML. Как именно это достигается, станет ясно, когда мы начнем
писать собственные файлы кода и работать с директивой #include.
Чаще всего мы будем использовать заголовочные файлы SFML, которые откроют нам интересные возможности для создания игр. Мы также будем прибегать
к #include, чтобы получить доступ к заголовочным файлам стандартной библио
теки C++. Эти заголовочные файлы позволят нам задействовать основные возможности самого языка C++.
На данный момент важно то, что у нас появилась целая куча новых функций
SFML благодаря этой единственной строчке кода.
О том, что делает строка using namespace sf;, мы поговорим чуть позже.
ООП, классы и объекты
Объектно-ориентированное программирование, или ООП, — это широко признанный в мире лучший, если не единственный, профессиональный способ написания кода на большинстве языков программирования. Заметьте, я сказал «на
большинстве», поскольку есть и исключения.
ООП вводит множество концепций программирования, но основополагающими
для всех них являются классы и объекты. Когда мы пишем код, мы стремимся сделать его многократно используемым, удобным для поддержки и безопасным. Для
этого мы структурируем его в виде класса. Как это сделать, вы узнаете в главе 6.
Для начала просто запомните, что, написав свой класс, мы не просто выполняем
этот код как часть нашей программы, а формируем из класса объекты, которые
можно использовать.
Например, если бы нам понадобилось создать 100 NPC (неигровых персонажей)
в виде зомби, мы могли бы тщательно спроектировать и написать класс под названием Zombie, а затем на основе этого класса создать столько объектов зомби,
сколько захотим. Любой объект зомби имел бы одинаковую функциональность
и внутренние типы данных, но при этом каждый из них был бы отдельной и самостоятельной сущностью.
В продолжение гипотетического примера с зомби, но без демонстрации кода мы
можем создать новый объект на основе класса Zombie:
Zombie z1;
Теперь объект z1 — это полностью функционирующий объект Zombie. Затем мы
можем сделать следующее:
Zombie
Zombie
Zombie
Zombie
z2;
z3;
z4;
z5;
Открытие окна с помощью SFML 53
Теперь у нас есть пять экземпляров Zombie, но все они основаны на одном тщательно проработанном классе. Прежде чем вернуться к только что написанному
коду, давайте сделаем еще один шаг вперед. Наши зомби могут содержать как
поведение (определяемое функциями), так и данные, которые могут представлять
здоровье, скорость, местоположение или направление движения зомби. Например, мы могли бы запрограммировать наш класс Zombie, чтобы он позволил нам
использовать наши объекты Zombie:
z1.attack(player);
z2.growl();
z3.headExplode();
Обратите внимание, что весь этот код пока гипотетический. Если вы введете его
в Visual Studio, то просто получите кучу ошибок.
Мы можем спроектировать наш класс так, чтобы использовать данные и поведение наиболее подходящим образом для достижения целей нашей игры. Например, мы могли бы спроектировать класс, чтобы задавать значения данным для
каждого объекта зомби в момент его создания.
Допустим, нам нужно присвоить уникальное имя и скорость в метрах в секунду
при создании каждого зомби. При тщательном подходе к программированию
класса Zombie мы бы написали такой код:
// До заражения Дэйв был олимпийским чемпионом в беге на 100 метров
// Он движется со скоростью 10 метров в секунду
Zombie z1("Dave", 10);
// У Гилл съели обе ноги, прежде чем она заразилась
// Она ползет со скоростью 0,1 метра в секунду
Zombie z2("Gill", 0.1);
Дело в том, что классы практически бесконечно гибкие, и как только мы написали
класс, мы можем использовать его, создав объект или экземпляр. Именно через
классы и объекты, которые создаются на их основе, мы будем использовать всю
мощь SFML. И да, мы также будем писать собственные классы, включая класс
Zombie.
Теперь вернемся к реальному коду, который мы писали.
Использование пространства имен sf
Прежде чем мы перейдем к более подробному обсуждению VideoMode и Ren
derWindow , которые, как вы уже, наверное, догадались, являются классами,
предоставляемыми SFML, разберемся, зачем нужна строка using namespace sf;.
Когда мы создаем класс, мы делаем это в пространстве имен, чтобы отличить
наши классы от тех, которые написали другие. Рассмотрим данный аспект на
примере класса VideoMode.
54 Глава 1. Введение
Вполне возможно, что в такой среде, как Windows, уже существует класс с названием VideoMode. Благодаря пространству имен мы и программисты SFML можем
быть уверены, что имена классов никогда не будут пересекаться.
Полный способ использования класса VideoMode выглядит следующим образом:
sf::VideoMode...
Однако using namespace sf; позволяет нам опустить префикс sf:: во всем коде.
Без этого нам пришлось бы писать sf:: более 100 раз. Это также делает наш код
более читабельным и лаконичным.
Классы VideoMode и RenderWindow библиотеки SFML
Внутри функции main у нас теперь есть два новых комментария и две строки исполняемого кода. Первая выглядит следующим образом:
VideoMode vm(1920, 1080);
В ней создается объект vm из класса VideoMode и устанавливаются два внутренних
значения: 1920 и 1080. Эти значения характеризуют разрешение экрана.
Вторая строка кода выглядит так:
RenderWindow window(vm, "Timber!", Style::Fullscreen);
Здесь создается новый объект window из класса RenderWindow, предоставленного
SFML, а также устанавливаются некоторые значения внутри window.
Объект vm используется для инициализации части window. Важно понимать, что
класс может быть настолько гибким, насколько это предусмотрел его разработчик. И да, некоторые классы могут содержать экземпляры других классов.
На данном этапе не обязательно полностью понимать, как это работает, если вы
осознаете саму концепцию. Мы пишем класс, а затем создаем из него объекты,
которые можно использовать, — примерно так же, как архитектор рисует план
здания. Конечно, вы не можете перенести на чертеж всю свою мебель, детей и собаку, но вы можете построить дом (или много домов) на его основе. В данной
аналогии класс — это чертеж, а объект — дом.
Далее мы используем значение "Timber!", чтобы дать окну имя, и предопределенное значение Style::FullScreen, чтобы сделать наш объект window полноэкранным.
Style::FullScreen — это значение, определенное в SFML. Оно полезно, потому
что нам не придется запоминать целое число, которое внутренний код использует
Игровой цикл 55
для представления полноэкранного режима. Ключевое слово для этого типа значений — constant. Константы и их близкие «родственники» в C++, переменные,
мы рассмотрим в следующей главе.
Запуск игры
На этом этапе вы можете снова запустить игру. Вы увидите, как на мгновение
появится и исчезнет большой черный экран. Это и есть полноэкранное окно размером 1920 × 1080. Сейчас происходит следующее: наша программа запускается,
выполняется с первой строки функции main, создает новое игровое окно, возвращает 0 и сразу же выходит обратно в операционную систему.
Далее мы добавим код, который сформирует основную структуру каждой игры
в книге. Он называется игровым циклом.
Игровой цикл
Давайте обсудим, что мы сделаем в этом разделе. Нам нужен способ оставаться
в программе до тех пор, пока игрок не захочет выйти из нее самостоятельно.
В то же время мы должны четко обозначить, где будут находиться различные
блоки нашего кода по мере продвижения с Timber!. Более того, если мы собираемся предотвратить автоматический выход из игры, нам нужно предусмотреть
способ для игрока сделать это вручную. В противном случае игра будет продолжаться вечно!
Добавьте следующий выделенный код к существующему, а затем мы обсудим
все детали:
int main()
{
// Создаем объект VideoMode
VideoMode vm(1920, 1080);
// Создаем и открываем окно для игры в полноэкранном режиме
RenderWindow window(vm, "Timber!", Style::Fullscreen);
while (window.isOpen())
{
/*
******************************
Обработка ввода игрока
******************************
*/
56 Глава 1. Введение
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
/*
************************
Обновление сцены
************************
*/
/*
***********************
Отрисовка сцены
***********************
*/
// Очищаем все c предыдущего кадра
window.clear();
// Отрисовываем здесь нашу игровую сцену
window.display();
// Отображаем все, что было отрисовано
window.display();
}
return 0;
}
Давайте теперь разберем этот код.
Цикл while
Первое, что мы видим в новом коде, — это
while (window.isOpen())
{
Последнее, что мы видим, — это закрывающая фигурная скобка }. Мы создали
цикл while. Все, что находится между открывающей ({) и закрывающей (}) фигурными скобками цикла while, будет выполняться снова и снова, потенциально
бесконечно.
Обратите внимание на содержимое круглых скобок (...) цикла while:
while (window.isOpen())
Полное объяснение этого кода будет позже, когда мы начнем обсуждать циклы
и условия в главе 4. Пока же важно понять, что, когда объект window закроется,
Комментарии в стиле C 57
выполнение кода выйдет из цикла while и перейдет к следующему оператору.
О том, как именно закрывается окно, я расскажу совсем скоро.
Следующим оператором, конечно же, будет return 0;, который завершает нашу игру.
Теперь мы знаем, что наш цикл while будет многократно выполнять код внутри
него, пока наш объект window остается открытым.
Комментарии в стиле C
Внутри цикла while мы видим, на первый взгляд, нечто похожее на ASCIIграфику:
/*
******************************
Обработка ввода игрока
******************************
*/
СОВЕТ
ASCII-графика — это нишевый, но забавный способ создания изображений с помощью компьютерного текста. Подробнее об этом можно прочитать здесь: https://
en.wikipedia.org/wiki/ASCII_art.
Данный код — это просто другой тип комментария. Он называется комментарием в стиле C и начинается с /*, а заканчивается на */. Все, что находится
между этими символами, представляет собой дополнительную информацию
и не компилируется. Я использовал такой стиль, чтобы четко обозначить, что
мы будем делать в каждом блоке кода. Наверняка вы уже догадались, что любой
код, следующий за этим комментарием, будет связан с обработкой ввода игрока.
Если взглянуть чуть ниже, можно увидеть, что у нас есть еще два комментария
в стиле C, сообщающих, что в этой части кода мы будем обновлять и отрисовывать сцену. Рассмотрим эти блоки более подробно.
Ввод, обновление, отрисовка, повтор
Несмотря на то что в данном проекте используется простейшая версия игрового
цикла, в коде каждой игры должны присутствовать эти этапы.
1. Получение данных ввода от игрока (если они есть).
2. Обновление сцены на основе таких факторов, как искусственный интеллект,
физика или ввод игрока.
58 Глава 1. Введение
3. Отрисовка текущей сцены.
4. Повторение этих шагов с достаточной скоростью, чтобы создать интерактивный, плавный и анимированный игровой мир.
Теперь рассмотрим рабочий код внутри игрового цикла.
Обработка нажатия клавиши
В блоке кода, обозначенном комментарием Обработка ввода игрока, у нас есть
следующий текст:
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
Здесь проверяется, нажата ли в данный момент клавиша Esc. Если да, то выделенный код использует объект window, чтобы закрыть его. Теперь на следующей
итерации цикла while проверка объекта window покажет, что он закрыт, выполнение программы перейдет к коду, расположенному сразу после закрывающей
фигурной скобки цикла while, и игра завершится. Более подробно операторы if
мы рассмотрим в главе 2.
Очистка и отрисовка сцены
В настоящее время в блоке Обновление сцены нет кода, поэтому перейдем к блоку
Отрисовка сцены. Первое, что мы сделаем, — очистим предыдущий кадр анимации:
window.clear();
После выполнения этого кода мы будем рисовать все объекты из игры, но поскольку у нас их пока нет, перейдем к следующей строке:
window.display();
Когда мы рисуем игровые объекты, мы делаем это на скрытой поверхности, готовой к отображению. Код window.display() переключается с ранее отображаемой
поверхности на только что обновленную (ранее скрытую). Таким образом, игрок
никогда не увидит процесс отрисовки, поскольку на поверхность уже добавлены
все спрайты. Такой механизм также предотвращает так называемые разрывы
кадров (tearing) и зовется двойной буферизацией.
Обратите внимание, что все операции по отрисовке и очистке выполняются с помощью нашего объекта window, который был создан на основе класса RenderWindow
библиотеки SFML.
Фон 59
Запуск игры
Запустив игру, вы получите пустое полноэкранное окно, которое будет оставаться
открытым до тех пор, пока вы не нажмете клавишу Esc.
Это хороший прогресс. На данном этапе у нас есть программа, которая открывает окно и зацикливается, ожидая, пока игрок нажмет клавишу Esc для выхода.
Теперь мы можем перейти к добавлению фона в игру.
Фон
Чтобы отобразить графику в нашей игре, нам нужно создать спрайт. Первый
спрайт будет фоном игры. Мы сможем отрисовать его в промежутках между
очисткой окна и его переключением.
Подготовка спрайта с помощью текстуры
Класс RenderWindow библиотеки SFML позволил нам создать объект window, который обеспечивает всю необходимую для окна функциональность.
Сейчас мы рассмотрим еще два класса SFML, которые будут отвечать за спрайты на экране. Один из этих классов, что, наверное, неудивительно, называется
Sprite, а другой — Texture. Текстура — это изображение, хранящееся в видеопамяти графического процессора (GPU).
Объект, созданный на основе класса Sprite, нуждается в объекте класса Texture,
чтобы вести себя как изображение. Добавьте следующий выделенный код:
int main()
{
// Создаем объект VideoMode
VideoMode vm(1920, 1080);
// Создаем и открываем окно для игры в полноэкранном режиме
RenderWindow window(vm, "Timber!", Style::Fullscreen);
// Создаем текстуру для хранения изображения в GPU
Texture textureBackground;
// Загружаем текстуру
textureBackground.loadFromFile("graphics/background.png");
// Создаем спрайт
Sprite spriteBackground;
// Привязываем текстуру к спрайту
spriteBackground.setTexture(textureBackground);
60 Глава 1. Введение
// Устанавливаем позицию spriteBackground, чтобы он покрывал весь экран
spriteBackground.setPosition(0,0);
while (window.isOpen())
{
Обратите внимание, что этот код находится перед циклом while , потому
что он должен выполниться только один раз. Сначала мы создаем объект
textureBackground на основе класса Texture библиотеки SFML:
Texture textureBackground;
После этого мы можем использовать объект textureBackground для загрузки изображения из нашей папки graphics:
textureBackground.loadFromFile("graphics/background.png");
Нам нужно указать только путь graphics/background, так как он является относительным по отношению к рабочему каталогу Visual Studio, где мы создали
папку и добавили изображение.
Далее мы создадим объект под названием spriteBackground из класса Sprite
библиотеки SFML:
Sprite spriteBackground;
Затем мы можем связать объект Texture (textureBackground) с объектом Sprite
(spriteBackground):
spriteBackground.setTexture(textureBackground);
Наконец, мы можем расположить объект spriteBackground в объекте window в позиции (0, 0), то есть в левом верхнем углу:
spriteBackground.setPosition(0,0);
Поскольку размер изображения background.png в папке graphics составляет
1920 × 1080 пикселей, оно аккуратно заполнит весь экран. Обратите внимание,
что предыдущая строка кода не выводит спрайт, она только задает его положение.
Объект spriteBackground теперь можно использовать для отображения фона.
Вы, вероятно, задаетесь вопросом, почему мы действуем таким запутанным способом. Причина кроется в работе видеокарты и OpenGL.
Текстуры занимают память GPU, а память — ограниченный ресурс. Кроме того,
процесс загрузки изображений в память GPU довольно медленный — не настолько, чтобы вы могли наблюдать, как он происходит, или чтобы ваш компьютер
заметно замедлялся во время его работы, но достаточно неспешный, чтобы его
Фон 61
нельзя было выполнять каждый кадр игрового цикла. Поэтому имеет смысл отделить текстуру (textureBackground) от любого кода, который будет изменяться
во время игрового цикла.
Как вы увидите, мы будем перемещать наше изображение с помощью спрайта.
Любые объекты, созданные на основе класса Texture, будут храниться в памяти
GPU и ожидать, пока связанный с ними объект Sprite укажет, где себя показать.
В последующих проектах мы также будем повторно применять один и тот же объект Texture с несколькими разными объектами Sprite, что позволит эффективно
использовать память GPU.
В итоге можно сказать следующее:
zzтекстуры загружаются в GPU довольно медленно;
zzтекстуры обрабатываются быстрее после загрузки в GPU;
zzмы связываем объект Sprite с текстурой;
zzмы управляем положением и ориентацией объектов Sprite (обычно в блоке
Обновление сцены);
zzмы рисуем объект Sprite, который, в свою очередь, отображает связанный
с ним объект Texture (обычно в блоке Отрисовка сцены).
Итак, все, что нам нужно сделать сейчас, — это задействовать систему двойной
буферизации, которая обеспечивается нашим объектом window, чтобы отобразить
новый объект Sprite (spriteBackground).
Двойная буферизация спрайта фона
Наконец, нам нужно нарисовать этот спрайт и связанную с ним текстуру в соответствующем месте игрового цикла.
СОВЕТ
Обратите внимание, что я представляю код, относящийся к одному блоку, без отступов, потому что это позволяет уменьшить количество строк в тексте книги. Отступы
подразумеваются. Ознакомьтесь с файлом кода в пакете загрузки, чтобы увидеть
полный вариант с отступами.
Добавьте следующий выделенный код:
/*
****************************************
Отрисовка сцены
62 Глава 1. Введение
****************************************
*/
// Очищаем все с предыдущего кадра
window.clear();
// Отрисовываем здесь нашу игровую сцену
window.draw(spriteBackground);
// Отображаем все, что было отрисовано
window.display();
В новой строке кода просто используется объект window для отрисовки объекта
spriteBackground, в промежутке между очисткой экрана и отображением только
что нарисованной сцены.
Теперь мы знаем, что такое спрайт, как мы можем связать с ним текстуру, затем
разместить его на экране и, наконец, нарисовать его. Давайте снова запустим игру,
чтобы увидеть результат проделанной работы.
Запуск игры
Если мы запустим программу сейчас, то увидим первые очертания настоящей
игры (рис. 1.26).
Рис. 1.26. Запуск игры
Пока она не претендует на звание игры года, но мы на верном пути!
Рассмотрим возможные ошибки, которые могут возникнуть по мере чтения
главы.
Обработка ошибок 63
Обработка ошибок
В любом проекте неизбежны проблемы и ошибки, и на каком-то этапе работы
с книгой вас наверняка ждут трудности. Сохраняйте спокойствие, помните, что,
какой бы ни была ваша проблема, скорее всего, вы не первый человек в мире,
который столкнулся с ней. Сформулируйте краткий запрос, описывающий вашу
ошибку, а затем введите его в Google или ChatGPT. Вы будете удивлены, как
быстро и точно можно найти решение таким способом, ведь наверняка кто-то
уже сталкивался с подобной проблемой до вас и смог решить ее.
Тем не менее вот несколько советов, которые помогут вам начать работу, если вы
испытываете трудности с реализацией проекта из первой главы.
Ошибки конфигурации
Наиболее вероятной причиной проблем в этой главе будут ошибки конфигурации. Как вы, вероятно, заметили в процессе настройки Visual Studio, SFML
и самого проекта, существует огромное количество имен файлов, папок и настроек, которые должны быть строго заданы. Всего одна неверная настройка
может вызвать несколько ошибок, из текста описания которых неясно, что
именно пошло не так.
Если у вас не отображается даже пустой черный экран, возможно, будет проще
начать все заново. Убедитесь, что все имена файлов и папок подходят для вашей
конкретной конфигурации, а затем запустите самую простую часть кода. Это та
часть, где черный экран всплывает и тут же закрывается. Если вы сможете дойти
до этого этапа, то, скорее всего, проблема не в конфигурации.
Ошибки компиляции
Ошибки компиляции — это, пожалуй, самые распространенные ошибки, с которыми мы будем сталкиваться. Убедитесь, что ваш код идентичен моему, особенно
наличие точек с запятой в конце строк и правильность использования заглавных
и строчных букв в именах классов и объектов. Если ничего не получается, откройте файлы из пакета загрузки и скопируйте код оттуда.
Несмотря на то что в книге могут встречаться опечатки, файлы кода созданы из
реальных рабочих проектов — они точно работают!
Ошибки в ссылках
Ошибки ссылок, скорее всего, вызваны отсутствием .dll -файлов библиотеки
SFML. Убедитесь, что вы скопировали их все в папку проекта.
64 Глава 1. Введение
Баги
Баги возникают, когда ваш код работает, но не так, как вы ожидаете. Отладка
может быть очень увлекательной. Чем больше багов вы устраните, тем лучше
станет ваша игра и тем больше удовольствия вы получите от работы. Главное
правило при устранении багов — найти их как можно раньше! Для этого я рекомендую запускать и тестировать игру каждый раз, когда вы внедряете что-то новое. Чем раньше вы обнаружите баг, тем выше вероятность того, что вы поймете,
какой код вызвал ее. В книге мы будем запускать код, чтобы увидеть результаты
на всех возможных этапах.
Резюме
Это была довольно сложная глава. Действительно, настройка IDE для работы
с библиотекой C++ может быть местами трудной и утомительной. Кроме того,
концепции классов и объектов давно известны своей сложностью для новичков
в программировании.
Однако теперь мы можем сосредоточиться на C++, SFML и разработке игр. По
мере продвижения по книге мы будем все больше узнавать о C++, а также реализовывать различные интересные игровые возможности. При этом мы подробно
рассмотрим такие темы, как функции, классы и объекты, чтобы немного упростить
их понимание.
Мы уже многое сделали в данной главе: набросали базовую программу на C++
с функцией main и создали простой игровой цикл, который обрабатывает ввод
игрока и отображает спрайт (вместе с соответствующей текстурой) на экране.
В следующей главе вы узнаете все, что нужно, чтобы отрисовать на C++ еще несколько спрайтов и анимировать их.
Часто задаваемые вопросы
Вот ответы на вопросы, которые могут у вас возникнуть.
В. Мне трудно усваивать представленный материал. Точно ли я смогу программировать?
О. Настройка среды разработки и ознакомление с концепцией ООП — это, пожалуй, самое сложное, что вам предстоит сделать. Если ваша игра функционирует
(рисует фон), вы готовы перейти к следующей главе.
В. Все эти разговоры об ООП, классах и объектах слишком усложняют процесс
обучения и портят все впечатление.
Часто задаваемые вопросы 65
О. Не переживайте. Вы почувствуете себя увереннее и втянетесь в процесс, когда
мы более подробно начнем разбирать ООП, классы и объекты в главе 6. Пока же
просто примите тот факт, что SFML содержит множество полезных классов и мы
можем использовать этот код, создавая из этих классов объекты.
В. Я совсем не понимаю, что такое функция.
О. Это не страшно. Мы будем постоянно возвращаться к этой теме и в конце
концов основательно изучим функции. Сейчас вам нужно знать только то, что
при вызове функции выполняется ее код, а после завершения его работы (при
достижении оператора return) программа возвращается к тому коду, который
ее вызвал.
2
Переменные, операторы
и условия: анимация
спрайтов
В этой главе мы еще немного порисуем на экране. Мы создадим анимацию облаков, которые будут перемещаться на случайной высоте и с разной скоростью
по фону, и пчелы, которая будет делать то же самое на переднем плане. Для этого
нам нужно будет изучить некоторые основы C++. Вы узнаете, как C++ хранит
данные в переменных, как манипулировать этими переменными с помощью операторов C++ и какие конструкции позволяют выполнять различные действия
в зависимости от значения переменных. Затем сможете применить полученные
знания о классах Sprite и Texture библиотеки SFML для реализации анимации
облаков и пчелы.
Переменные в C++
Переменные — это инструмент, с помощью которого наши игры на C++ хранят
значения или данные и манипулируют ими. Если мы хотим узнать, сколько очков здоровья у игрока, нам нужна переменная. Оставшееся количество
зомби в текущей волне? Тоже переменная. Найти имя игрока в таблице рекордов? Переменная. Игра закончена или еще продолжается? Да, это тоже
переменная.
Переменные — это именованные идентификаторы, которые указывают на определенные места в памяти. Так, мы можем создать переменную numberOfZombies,
и она будет ссылаться на место в памяти, где хранится значение, представляющее
количество оставшихся зомби.
Система адресации памяти в компьютерных системах очень сложна. Языки
программирования используют переменные, чтобы предоставить удобный для
человека способ работы с данными в памяти. Упрощение управления сложной
системой — основная цель языков программирования. Языки отличаются друг
от друга степенью эффективности и удобства. Например, C++ всегда был эффективным, а со временем стал и удобнее.
Переменные в C++ 67
ПРИМЕЧАНИЕ
Язык C++ был создан Бьерном Страуструпом в начале 1980-х годов. C++ развился
из оригинального языка C. Страуструп разработал его, чтобы добавить в C возможности ООП, позволяющие писать более эффективный и поддерживаемый код. За годы
своего существования C++ претерпел множество изменений и улучшений.
Помимо прочего, существуют различные типы переменных. Давайте рассмотрим
те, которые мы будем использовать чаще всего.
Типы переменных
Можно было бы легко посвятить целую главу обсуждению переменных и их типов в C++. Однако на сегодняшний день существует множество статей по данной
теме, поэтому я не стану пересказывать их здесь, так как предполагаю, что вы
хотите как можно быстрее начать создавать игры. Взгляните на табл. 2.1. В ней
приведены наиболее часто используемые в книге типы переменных.
Таблица 2.1. Типы переменных
Тип
Примеры значений
Пояснение
int
-42, 0, 1 и 9826
Целые числа
float
-1.26f, 5.8999996f и 10128.3f
Числа с плавающей точкой
с точностью до 7 знаков
double
925.83920655234 и 1859876.94872535
Числа с плавающей точкой
с точностью до 15 знаков
char
a, b, c, 1, 2 и 3 (всего 128 символов,
включая ?, ~, # и т. д.)
Любой символ из таблицы ASCII
(см. следующую врезку о переменных)
bool
true или false
bool — это логический тип, может быть
только true или false
String
Привет всем! Я — Строка.
Любой текст, начиная от одной буквы
или цифры и заканчивая целой книгой
C++ является строго типизированным языком. Это означает, что в нем жестко
прописаны правила работы с типами. То есть операции между различными типами данных требуют явных преобразований, в противном случае компилятор
выдаст ошибку.
По этим причинам мы должны указывать компилятору, к какому типу относится
переменная, чтобы он мог выделить под нее нужный объем памяти. Кроме того,
68 Глава 2. Переменные, операторы и условия: анимация спрайтов
когда компилятор знает тип переменной, он может проверить, не используется ли
она ошибочным образом. Например, вы не можете провести операцию деления
строки string, используя в качестве делителя переменную типа bool. Хорошей
практикой является использование наиболее подходящего типа для каждой переменной. Однако на деле часто можно обойтись без перевода переменной в более
точный тип. Возможно, вам нужно число с плавающей точкой, имеющее всего пять
значащих цифр? Компилятор не будет «жаловаться», если вы сохраните его как
double. Но, если вы попытаетесь сохранить float или double в int, он преобразует
значение, чтобы оно соответствовало int. Это также приведет к изменению хранимого значения. По мере продвижения по книге я буду объяснять, какой тип переменной лучше использовать в каждом конкретном случае, и будет даже несколько
примеров, где мы намеренно будем конвертировать переменные к другому типу.
Обратите внимание в табл. 2.1, что рядом со всеми значениями типа float добавляется постфикс f. Он указывает компилятору, что значение имеет тип float,
а не double. Значение с плавающей точкой без постфикса f считается double.
Подробнее об этом читайте в следующей врезке о переменных.
Пользовательские типы
Пользовательские типы данных намного сложнее, чем рассмотренные ранее. Когда мы говорим о пользовательских типах в C++, то обычно имеем
в виду классы или перечисления. Мы кратко обсудили классы и связанные
с ними объекты в предыдущей главе. Вскоре мы начнем писать код в отдельном
файле, а иногда и в двух файлах. Тогда мы сможем объявлять, инициализировать и использовать созданные нами классы. То, как определять собственные
типы, мы узнаем в главе 6, а с перечислениями мы познакомимся в главе 4.
Перечисления служат мягким введением в классы, поскольку они позволяют
программисту создавать типы объектов, например типы зомби, предметов или
инопланетных кораблей.
Теперь вернемся к основным типам C++, которые часто называют фундаментальными типами, поскольку они представляют базовые значения, подобные
тем, что мы видели в табл. 2.1.
Объявление и инициализация переменных
Вы уже знаете, что переменные служат для хранения данных и значений, которые
нужны нашим играм для работы. Например, переменная может представлять
количество очков здоровья игрока или его имя. Вы также знаете, что существует
множество типов значений, которые могут хранить эти переменные, например
int, float, bool или заданные пользователем. Однако вы еще не знаете, как использовать переменную.
Создание и подготовка новой переменной состоит из двух этапов: объявления
и инициализации. Рассмотрим каждый из них.
Переменные в C++ 69
Объявление переменных
В C++ мы можем объявить переменные следующим образом:
// Какой счет у игрока?
int playerScore;
// Какая первая буква имени у игрока?
char playerInitial;
// Каково значение числа пи?
float valuePi;
// Игрок жив или мертв?
bool isAlive;
В предыдущем коде мы с помощью ключевых слов int, char, float и bool, обозначающих типы данных, объявили переменные под названием playerScore,
playerInitial, valuePi и isAlive соответственно1. Если вы забыли, что это за
типы, обратитесь к табл. 2.1. Объявляя переменные, мы зарезервировали в памяти
компьютера области подходящего размера для хранения значений соответствующих типов. В данный момент у нас еще нет никаких данных.
Инициализация переменных
Теперь, когда мы объявили переменные с осмысленными именами, можем инициализировать их подходящими значениями:
playerScore = 0;
playerInitial = 'J';
valuePi = 3.141f;
isAlive = true;
Если мы выполним этот код, в памяти компьютера появятся реальные данные.
Если это неочевидно, четыре предыдущие переменные содержат значения нуля,
строчной буквы j, числа с плавающей точкой 3.141 и логического значения true.
Объявление и инициализация за раз
Иногда удобно объединить этапы объявления и инициализации переменной
в один. Если вам известны начальные значения, которые должны быть у переменных, то можно воспользоваться следующим методом:
int playerScore = 0;
char playerInitial = 'J';
float valuePi = 3.141f;
bool isAlive = true;
1
Обратите внимание, что имена переменных начинаются со строчной буквы, а последующие слова, составляющие имя переменной, — с прописной. Это часть стандарта
оформления кода (camelCase), и невероятно важно его придерживаться. — Примеч. пер.
70 Глава 2. Переменные, операторы и условия: анимация спрайтов
Если же значения переменных должны определяться во время выполнения программы, более подходящим будет первый способ, где объявление и инициализация
происходят отдельно. Оба варианта корректны для C++, но, как правило, все
зависит от ситуации.
СОВЕТ
Если вы хотите увидеть полный список типов C++, посетите эту веб-страницу:
http://www.tutorialspoint.com/cplusplus/cpp_data_types.htm. Если хотите узнать
больше о типах float, double и постфиксе f, то прочтите этот материал: http://
www.cplusplus.com/forum/beginner/24483/. Если вас интересуют коды символов ASCII,
то дополнительную информацию вы найдете здесь: http://www.cplusplus.com/doc/
ascii/. Обратите внимание, что данные ссылки предназначены для тех, кто хочет
углубиться в детали, а мы уже обсудили достаточно, чтобы двигаться дальше.
Константы
Иногда нам нужно сделать так, чтобы значение переменной было неизменяемым.
Для этого можно объявить и инициализировать константу с помощью ключевого слова const. Поскольку значение числа пи постоянно, правильнее было бы
использовать константу.
const float PI = 3.141f;
const int NUMBER_OF_ENEMIES = 2000;
В приведенном коде мы гарантируем, что значения переменных PI и NUMBER_OF_
ENEMIES никогда не изменятся. При объявлении констант часто применяется
формат camelCase. Мы же будем записывать имена констант в верхнем регистре,
а слова разделять нижним подчеркиванием.
Когда я говорю, что константа никогда не изменится, я имею в виду, что она
не может быть преобразована кодом в процессе выполнения программы. Однако
вы, как программист, всегда можете задать любое значение для ваших констант
во время инициализации.
//const int PLANETS_IN_SOLAR_SYSTEM = 9;
// Упс! Плутон был переклассифицирован в карликовую планету в 2006 году
const int PLANETS_IN_SOLAR_SYSTEM = 8;
Вы увидите примеры использования констант в главе 4.
Теперь перейдем к еще одному важному способу инициализации переменных.
Унифицированная инициализация
Унифицированная инициализация, или инициализация списком, — это современный способ инициализации переменных, который появился в C++ с выходом
стандарта C++11 в 2011 году. Унифицированная инициализация предлагает
Переменные в C++ 71
более последовательный синтаксис для инициализации переменных и пользовательских типов. Она позволяет инициализировать переменные с помощью
фигурных скобок (таких же, как у функции main), например:
int playerScore{0};
char playerInitial{'J'};
float valuePi{3.141f};
bool isAlive{true};
В этом коде я заменил оператор присваивания (=) фигурными скобками для
каждой переменной. Этот синтаксис является стандартом в современном C++,
и вы часто будете встречать его в современных коммерческих API. Есть несколько
причин, по которым данный метод инициализации менее подвержен ошибкам,
чем «традиционный», который мы будем использовать в книге.
Нет ничего плохого в классическом синтаксисе:
int playerScore = 0;
Оба подхода верны и будут работать. Я просто хотел показать вам синтаксис, с которым вы, возможно, столкнетесь, изучая C++ в других источниках. Кроме того,
мы вернемся к этому стилю в главе 6, когда будем говорить о классах. Не бойтесь
использовать унифицированную инициализацию в процессе обучения. Изменить
все примеры кода не составит труда. Я считаю, что традиционный синтаксис более
удобен для новичков, но если вы собираетесь работать в крупной корпорации, то,
скорее всего, будете прибегать к унифицированной инициализации.
Объявление и инициализация пользовательских типов
Мы уже рассмотрели примеры объявления и инициализации некоторых типов,
определенных в SFML. Благодаря гибкости, с которой можно создавать и определять эти типы (классы), способы их объявления и инициализации также разнообразны. Вот несколько вариантов из предыдущей главы.
Создаем объект типа VideoMode, называем его vm и инициализируем двумя значениями типа int — 1920 и 1080:
// Создаем объект VideoMode
VideoMode vm(1920, 1080);
Создаем объект типа Texture, называем его textureBackground, но не выполняем
инициализацию:
// Создаем текстуру для хранения изображения в GPU
Texture textureBackground;
Обратите внимание, что, даже если мы не задаем конкретные значения для инициализации textureBackground, возможно (на самом деле очень вероятно), что
внутри класса произойдет некоторая настройка переменных. Необходимость или
возможность указания значений для инициализации объекта на этом этапе полностью зависит от того, как написан класс, и данный процесс является невероятно
72 Глава 2. Переменные, операторы и условия: анимация спрайтов
гибким. Это также говорит о том, что создание собственных классов — сложный
процесс. К счастью, у нас будет большая свобода в проектировании типов и классов,
которые нам нужны для игр. Добавьте эту огромную гибкость C++ к мощи классов,
разработанных в SFML, и потенциал наших игр станет практически безграничным!
Далее в книге мы рассмотрим еще несколько пользовательских типов и классов,
предоставляемых SFML. В главе 6 мы будем разрабатывать собственные типы
и классы при реализации игры Pong.
Как управлять переменными
На данном этапе мы точно знаем, что такое переменные, какие бывают их основные типы, как их объявлять и инициализировать. Однако мы все еще не научились с ними работать. Нам нужно уметь манипулировать нашими переменными,
складывать их, вычитать, умножать, делить и, что особенно важно, тестировать их.
Сначала мы разберемся с тем, как ими управлять, а затем рассмотрим, как и зачем их тестировать.
Давайте познакомимся с арифметическими операторами и операторами присваивания в C++.
Арифметические операторы
и операторы присваивания в C++
Для работы с переменными в C++ есть ряд арифметических операторов и операторов присваивания. К счастью, большинство из них интуитивно понятны, а те,
что менее очевидны, легко объяснить.
Для начала рассмотрим табл. 2.2, а затем табл. 2.3.
Таблица 2.2. Арифметические операторы
Арифметический
оператор
Пояснение
+
Оператор сложения используется для вычисления суммы
-
Оператор вычитания используется для вычисления разности
*
Оператор умножения используется для вычисления произведения
/
Оператор деления используется для вычисления частного
%
Оператор остатка от деления используется для вычисления остатка
Как управлять переменными 73
Таблица 2.3. Операторы присваивания
Операторы
присваивания
Пояснение
=
Это уже знакомый нам оператор присваивания. Он используется
для инициализации и задания значения переменной
+=
Сложение, совмещенное с присваиванием. Присваивает левому
операнду сумму левого и правого операндов
-=
Вычитание, совмещенное с присваиванием. Присваивает левому
операнду разность левого и правого операндов
*=
Умножение, совмещенное с присваиванием. Присваивает левому
операнду произведение левого и правого операндов
/=
Деление, совмещенное с присваиванием. Присваивает левому операнду частное левого и правого операндов
++
Инкремент. Увеличивает значение переменной на единицу
--
Декремент. Уменьшает значение переменной на единицу
<=>
«Космический корабль» (spaceship) — это относительно новое
дополнение к языку C++, появившееся в C++20. Он используется
для трехсторонних сравнений. Мы рассмотрим его в одном из последующих проектов
СОВЕТ
Технически все предыдущие операторы, кроме = , –– и ++ , называются составными операторами присваивания, поскольку они включают в себя более одного
оператора.
Теперь, когда мы познакомились с арифметическими операторами и операторами
присваивания, мы можем формировать выражения.
Выражения
Выражения — это комбинации переменных, операторов и значений, которые,
так же как выражения в разговорном языке, являются комбинацией слов и знаков
препинания. С помощью выражений можно получить результат. Более того, мы
можем использовать выражение в тестах. Эти тесты помогут определить, что наш
код должен делать дальше.
74 Глава 2. Переменные, операторы и условия: анимация спрайтов
Присваивание
Сначала рассмотрим несколько простых выражений, которые встречаются в коде
игры.
// Игрок устанавливает новый рекорд
hiScore = score;
Или:
// Устанавливаем счет в 100 очков
score = 100;
В приведенном выше коде мы присваиваем переменной hiScore значение, хранящееся в переменной score. С этого момента hiScore будет содержать значение,
которое ранее было в score. Это можно сделать в конце игры, когда игрок побьет
предыдущий рекорд: сбросить счет до нуля, а затем использовать его для подсчета очков в следующей игре, но hiScore все равно сохранит значение, которое
было в score в момент выполнения hiScore = score. Конечно, если выполнять эту
строку кода в конце каждой игры, существует риск случайно присвоить hiScore
значение, которое не является новым рекордом. Эта проблема возвращает нас
к необходимости тестирования и сравнения значений. Давайте продолжим,
и вскоре мы придем к решению.
Далее рассмотрим оператор сложения, который используется вместе с оператором присваивания:
// Добавляем очки за уничтожение пришельца
score = aliensShot + wavesCleared;
Или:
// Добавляем 100 очков к текущему значению счета
score = score + 100;
Обратите внимание, что вполне допустимо использовать одну и ту же переменную по обе стороны оператора. В первом примере значение переменной score
становится равным сумме значений переменных aliensShot и wavesCleared.
Во втором случае к текущему значению score прибавляется 100, и результат
снова записывается в score. Возможно, будет полезна другая вариация этого
примера:
score = score + pointsPerAlien;
Здесь значение переменной pointsPerAlien добавляется к существующему
значению переменной score. Такая техника использования переменных по обе
стороны оператора очень распространена. Просмотрите код еще раз и убедитесь,
что вы понимаете, что происходит.
Как управлять переменными 75
Аналогичным образом работает и оператор вычитания в сочетании с оператором
присваивания:
// Потеря одного очка жизни
lives = lives − 1;
Или:
// Сколько пришельцев осталось в конце игры
aliensRemaining = aliensTotal − aliensDestroyed;
Рассмотрим, как используется оператор деления:
// Уменьшаем количество оставшихся очков здоровья в зависимости от уровня меча
hitPoints = hitPoints / swordLevel;
Или:
// Возвращаем что-то, но не все, за переработку блока
recycledValueOfBlock = originalValue / 1.1f;
В приведенном примере переменная recycledValueOfBlock должна быть типа
float, чтобы точно хранить результат такого вычисления. Если вам кажется, что
я учу вас детской арифметике, значит, вы уже поняли суть.
Еще один пример оператора присваивания, и мы пойдем дальше:
// Естественно, ответ равен 100
answer = 10 * 10;
Или:
// bigAnswer = 1000, конечно же
bigAnswer = 10 * 10 * 10;
Надеюсь, теперь код не нуждается в пояснениях. В этих примерах мы умножаем
два, а затем три экземпляра числа 10 вместе и присваиваем результаты переменным answer и biggerAnswer соответственно.
Инкремент и декремент
Теперь посмотрим на оператор инкремента в действии. Это изящный способ
прибавить единицу к значению одной из переменных. Код ниже мы уже видели,
и я не буду объяснять его снова, но взгляните на него еще раз:
// Добавляем единицу к myVariable
myVariable = myVariable + 1;
Иногда нет необходимости повторять переменную по обе стороны от оператора.
Так можно сделать код чище и сэкономить немного времени на вводе.
76 Глава 2. Переменные, операторы и условия: анимация спрайтов
Следующий код дает тот же результат, что и предыдущий:
// Гораздо лаконичнее и быстрее
myVariable ++;
Кстати, в названии языка C++ также зашифрован оператор инкремента.
ПРИМЕЧАНИЕ
Интересный факт: вы когда-нибудь задумывались, как C++ получил свое название?
C++ — это расширение языка C. Его изобретатель, Бьерн Страуструп, первоначально
назвал его «Си с классами», но со временем название эволюционировало. Если вам
интересно, почитайте историю C++: http://www.cplusplus.com/info/history/.
Оператор декремента (--) — это, как вы уже догадались, быстрый способ вычесть
единицу из значения.
playerHealth = playerHealth - 1;
Следующий код быстрее, лаконичнее и делает то же самое, что и предыдущий:
playerHealth --;
Давайте взглянем на еще несколько операторов в действии, а затем вернемся
к созданию игры Timber!. Попробуйте разобраться, что происходит во всех последующих строках кода:
int someVariable = 10;
// Умножаем someVariable на 10 и записываем результат обратно в переменную
someVariable *= 10;
// Переменная someVariable теперь равна 100
// Делим someVariable на 5 и записываем результат обратно в переменную
someVariable /= 5;
// Переменная someVariable теперь равна 20
// Прибавляем 3 к someVariable и записываем результат обратно в переменную
someVariable += 3;
// Переменная someVariable теперь равна 23
// Вычитаем 25 из someVariable и записываем результат обратно в переменную
someVariable -= 25;
// Переменная someVariable теперь равна -2
В приведенном коде мы выводим использование инкремента и декремента на
новый уровень, применяя некоторые составные операторы, которые объединяют оператор присваивания с операторами инкремента и декремента. Теперь мы
Добавление облаков, пчелы и дерева 77
не просто прибавляем или вычитаем единицу. Когда мы используем операторы *=, /=, += или –=, мы умножаем, делим, складываем или вычитаем значение,
которое в данный момент хранится в переменной, на число, указанное после
оператора.
Так, в примере с умножением переменная someVariable изначально содержит
значение 10, а код someVariable *= 10 умножает это значение на 10 и записывает
результат обратно в someVariable. Данный синтаксис лаконичный, быстрый
и понятный.
Если какой-то из этих примеров требует дополнительных пояснений, не переживайте: мы будем использовать почти все, что сейчас узнали, чтобы улучшить
нашу игру и заставить изображения двигаться. Пришло время добавить в игру
еще несколько спрайтов.
Добавление облаков, пчелы и дерева
Сначала мы добавим дерево. Это несложно, так как дерево не будет двигаться.
Мы воспользуемся тем же подходом, что и в предыдущей главе, когда рисовали
фон. В следующем разделе мы подготовим спрайты для неподвижного дерева и для
движущихся облаков и пчелы. Затем мы сосредоточимся на перемещении и рисовании пчелы и облаков, поскольку для этого потребуется немного больше знаний C++.
Подготовка дерева
Добавьте следующий выделенный код. Остальная часть кода — это та, которую
мы уже написали. Это должно помочь вам определить, в каком месте набирать
новый код.
int main()
{
// Создаем объект VideoMode
VideoMode vm(1920, 1080);
// Создаем и открываем окно для игры в полноэкранном режиме
RenderWindow window(vm, "Timber!", Style::Fullscreen);
// Создаем текстуру для хранения изображений на GPU
Текстура textureBackground;
// Загружаем текстуру
textureBackground.loadFromFile("graphics/background.png");
// Создаем спрайт
Sprite spriteBackground;
78 Глава 2. Переменные, операторы и условия: анимация спрайтов
// Прикрепляем текстуру к спрайту
spriteBackground.setTexture(textureBackground);
// Устанавливаем позицию spriteBackground так, чтобы он занимал весь экран
spriteBackground.setPosition(0,0);
// Создаем спрайт дерева
Texture textureTree;
textureTree.loadFromFile("graphics/tree.png");
Sprite spriteTree;
spriteTree.setTexture(textureTree);
spriteTree.setPosition(810, 0);
while (window.isOpen())
{
Пять строк кода (не считая комментария), которые мы только что добавили, выполняют следующее:
zzсначала создается объект типа Texture под названием textureTree;
zzдалее в текстуру загружается изображение из файла tree.png;
zzзатем объявляется объект типа Sprite с именем spriteTree;
zzпосле чего textureTree связывается с spriteTree (всякий раз, когда отрисовывается spriteTree, он будет показывать текстуру textureTree, которая
содержит изображение дерева);
zzнаконец, с помощью координат 810 по оси X и 0 по оси Y задается положение
дерева.
Стоит отметить, что координаты 810 и 0 — это проверенные мной значения, которые отлично подходят для выбранного нами разрешения экрана. Я присвоил значения таким образом, чтобы быстро перейти к следующей теме. В «настоящей»
программе на C++ вы, вероятно, присваивали бы значения переменным, чтобы
сделать их использование более понятным. Более того, если значения не должны
меняться (а они не должны), вы, скорее всего, применили бы константу. Подобные переменные можно объявлять вне игрового цикла:
const float TREE_HORIZONTAL_POSITION = 810;
const float TREE_VERTICAL_POSITION = 0;
Затем, в строке кода, где мы рисуем спрайт дерева, вы должны использовать
следующее:
spriteTree.setPosition(TREE_HORIZONTAL_POSITION, TREE_VERTICAL_POSITION);
В данном примере объявление находится непосредственно перед использованием, и я думаю, что функция setPosition достаточно ясно показывает, к чему
относятся значения.
Добавление облаков, пчелы и дерева 79
Я оставляю это упражнение на ваше усмотрение, если вы захотите изменить код,
считая, что использование двух новых постоянных переменных сделает его понятнее. Когда мы пишем код с необъяснимыми значениями, как в данном случае,
это иногда критически называют использованием магических чисел, потому что
они делают что-то, что иногда менее понятно, чем использование переменной
с осмысленным именем. Суть в том, что чем больше и сложнее ваш код, тем
строже вы должны быть со своими стандартами, особенно если вы работаете
в команде. Для краткости я буду иногда прибегать к магическим числам, но, надеюсь, контекст всегда будет понятен.
Перейдем к более интересной части — добавлению пчелы.
Подготовка пчелы
Разница между следующим кодом и кодом дерева небольшая, но важная.
Поскольку пчела должна двигаться, мы также объявляем две переменные,
связанные с пчелой. Добавьте выделенный код в указанное место и попробуйте
понять, как мы можем использовать переменные beeActive и beeSpeed.
// Создаем спрайт дерева
Texture textureTree;
textureTree.loadFromFile("graphics/tree.png");
Sprite spriteTree;
spriteTree.setTexture(textureTree);
spriteTree.setPosition(810, 0);
// Подготавливаем пчелу
Texture textureBee;
textureBee.loadFromFile("graphics/bee.png");
Sprite spriteBee;
spriteBee.setTexture(textureBee);
spriteBee.setPosition(0, 800);
// Движется ли пчела в данный момент?
bool beeActive = false;
// С какой скоростью может летать пчела?
float beeSpeed = 0.0f;
while (window.isOpen())
{
В приведенном выше коде мы создаем пчелу так же, как создавали фон и дерево.
Мы задействуем Texture и Sprite и связываем их между собой.
Обратите внимание на новый код. Здесь есть переменная типа bool для определения того, активна пчела или нет. Помните, что тип bool может иметь значение
либо true, либо false. Пока мы инициализируем beeActive со значением false.
80 Глава 2. Переменные, операторы и условия: анимация спрайтов
Далее мы объявляем новую переменную типа float под названием beeSpeed.
В ней будет храниться значение скорости, с которой наша пчела будет летать по
экрану, в пикселях в секунду.
Скоро мы увидим, как использовать эти две новые переменные. А пока давайте
создадим несколько облаков почти таким же образом.
Подготовка облаков
Добавьте следующий выделенный код. Изучите его и попытайтесь понять, что
он делает, а потом я все объясню.
// Подготавливаем пчелу
Texture textureBee;
textureBee.loadFromFile("graphics/bee.png");
Sprite spriteBee;
spriteBee.setTexture(textureBee);
spriteBee.setPosition(0, 800);
// Движется ли пчела в данный момент?
bool beeActive = false;
// С какой скоростью может летать пчела?
float beeSpeed = 0.0f;
// Создаем три спрайта облаков из одной текстуры
Texture textureCloud;
// Загружаем одну новую текстуру
textureCloud.loadFromFile("graphics/cloud.png");
// Три новых спрайта с одной текстурой
Sprite spriteCloud1;
Sprite spriteCloud2;
Sprite spriteCloud3;
spriteCloud1.setTexture(textureCloud);
spriteCloud2.setTexture(textureCloud);
spriteCloud3.setTexture(textureCloud);
// Размещаем облака в левой
spriteCloud1.setPosition(0,
spriteCloud2.setPosition(0,
spriteCloud3.setPosition(0,
части экрана на разной высоте
0);
250);
500);
// Выясняем, находятся ли облака в данный момент на экране
bool cloud1Active = false;
bool cloud2Active = false;
bool cloud3Active = false;
Добавление облаков, пчелы и дерева 81
// С какой скоростью движется каждое облако?
float cloud1Speed = 0.0f;
float cloud2Speed = 0.0f;
float cloud3Speed = 0.0f;
while (window.isOpen())
{
Единственное, что может показаться странным в только что добавленном коде, —
то, что у нас только один объект типа Texture. Это совершенно нормально, когда
несколько объектов Sprite используют одну текстуру. Как только Texture будет
загружена в память GPU, ее очень быстро можно связать с объектом Sprite.
Только начальная загрузка графики в коде loadFromFile является относительно
медленной операцией. Конечно, если бы мы хотели получить три облака разной
формы, то нам понадобились бы три текстуры.
За исключением небольшой аномалии с совместным использованием текстуры,
в данном коде нет ничего принципиально нового по сравнению с кодом пчелы.
Единственное различие в том, что здесь применяются три спрайта облаков,
три переменные типа bool для определения того, активно ли каждое облако, и три
переменные типа float для хранения значения скорости каждого облака.
Отрисовка дерева, пчелы и облаков
Наконец, мы можем отобразить все элементы на экране, добавив следующий код
в блок отрисовки.
/*
****************************************
Отрисовка сцены
****************************************
*/
// Очищаем все с предыдущего кадра
window.clear();
// Отрисовываем здесь нашу игровую сцену
window.draw(spriteBackground);
// Отрисовка облаков
window.draw(spriteCloud1);
window.draw(spriteCloud2);
window.draw(spriteCloud3);
// Отрисовка дерева
window.draw(spriteTree);
82 Глава 2. Переменные, операторы и условия: анимация спрайтов
// Отрисовка пчелы
window.draw(spriteBee);
// Отображаем все, что было отрисовано
window.display();
Отрисовка трех облаков, пчелы и дерева выполняется так же, как и отрисовка
фона. Обратите внимание на порядок, в котором мы отображаем различные объекты на экране. Все графические элементы в коде необходимо объявлять после
фона, иначе он их перекроет. Так, облака должны быть отрисованы до дерева,
в противном случае они будут выглядеть странно, проплывая перед ним. Однако
пчела может летать как перед деревом, так и позади него. Я решил нарисовать
пчелу перед деревом, чтобы она могла отвлекать нашего дровосека, как это сделала бы настоящая пчела.
Запустите игру и полюбуйтесь тем, что получилось! Некоторые объекты выглядят так, будто собираются участвовать в гонке, в которой пчела должна лететь
задом наперед (рис. 2.1).
Рис. 2.1. Дерево, пчела и облака
С помощью операторов C++ мы могли бы попытаться переместить только что
добавленные изображения, но тут есть пара проблем. Основная состоит в том, что
настоящие облака и пчелы движутся неравномерно. У них нет определенной скорости или местоположения. Все это определяется такими факторами, как ветер
или то, насколько сильно пчела торопится. Для стороннего наблюдателя их путь
и скорость кажутся случайными. Давайте рассмотрим случайность подробнее.
Случайные числа 83
Случайные числа
В играх случайные числа полезны по многим причинам: например, чтобы определить, какую карту получит игрок или сколько единиц урона в определенном диапазоне будет нанесено здоровью противника. Мы будем использовать случайные
числа для определения начального местоположения и скорости пчелы и облаков.
Генерация случайных чисел в C++
Для генерации случайных чисел нам понадобятся дополнительные функции C++.
Пока не добавляйте никакого кода в игру. Давайте просто посмотрим на синтаксис и необходимые шаги на примере гипотетического кода.
Компьютеры не способны генерировать случайные числа. Они могут только
использовать алгоритмы для выбора числа, которое кажется случайным. Чтобы
этот алгоритм не выдавал постоянно одно и то же значение, мы должны запустить
генератор случайных чисел, передав ему зерно (seed). В качестве зерна может
выступать любое целое число, но каждый раз, когда вам потребуется уникальное случайное число, зерно должно быть разным. Посмотрите на код, который
инициализирует генератор случайных чисел.
// Инициализируем генератор случайных чисел
srand((int)time(0));
Данный код получает текущее время от компьютера с помощью функции time(0).
Ее значение передается в функцию srand. В результате в качестве зерна используется текущее время.
Код выглядит немного сложнее из-за необычного синтаксиса (int). Он заключается в преобразовании значения, возвращаемого из time, в тип int. В данной
ситуации это нужно для функции srand.
ПРИМЕЧАНИЕ
Приведение типов — термин, используемый для описания преобразования одного
типа в другой.
Итак, вкратце о предыдущем коде.
1. Получает время с помощью time.
2. Преобразует его в тип int.
3. Передает полученное значение в srand, чтобы инициализировать генератор
случайных чисел.
84 Глава 2. Переменные, операторы и условия: анимация спрайтов
Время, конечно же, не стоит на месте. Это делает функцию time отличным
способом запустить генератор случайных чисел. Однако подумайте, что может
произойти, если мы воспользуемся генератором случайных чисел несколько раз
подряд с такой скоростью, что time вернет одно и то же значение. Мы рассмотрим
и решим эту проблему, когда будем анимировать облака.
На текущем этапе мы можем сгенерировать случайное число в определенном
диапазоне и сохранить его в переменной для последующего использования с помощью такого кода:
// Получаем случайное число и сохраняем его в переменной number
int number = (rand() % 100);
Обратите внимание на странный способ присвоения значения переменной number.
Используя оператор деления с остатком (%) и значение 100, мы вычисляем остаток от деления числа, полученного от rand, на 100. При делении на 100 наибольшее число, которое может получиться в качестве остатка, — 99, а наименьшее — 0.
Таким образом, предыдущий код сгенерирует число от 0 до 99 включительно. Эти
знания пригодятся нам для генерации случайной скорости для пчелы и облаков.
Принятие решений с помощью if и else
Ключевые слова C++ if и else позволяют нам принимать решения. Мы видели
if в действии в предыдущей главе, когда в каждом кадре определяли, нажал ли
игрок клавишу Esc.
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
До сих пор мы видели, как можно использовать арифметические операторы
и операторы присваивания для создания выражений. Теперь познакомимся
с новыми операторами.
Логические операторы
Логические операторы помогают нам принимать решения, создавая выражения,
которые проверяются на значение true или false. На первый взгляд может показаться, что этого недостаточно для принятия сложных решений. Но если углубиться, станет ясно, что все необходимые решения можно принимать с помощью
всего нескольких логических операторов.
В табл. 2.4 представлены наиболее полезные логические операторы. Ознакомьтесь с ними и соответствующими примерами, а затем мы посмотрим, как их использовать.
Принятие решений с помощью if и else 85
Таблица 2.4. Логические операторы
Логический
оператор
Объяснение
==
Оператор сравнения проверяет равенство и возвращает либо true,
либо false. Например, выражение (10 == 9) является false. Очевидно,
что 10 не равно 9
!
Логический оператор НЕ (NOT). Выражение (! (2 + 2 == 5)) является
true, потому что 2 + 2 — это НЕ 5
!=
Это еще один оператор сравнения, который называется «не равно».
Например, выражение (10 != 9) является true, поскольку 10 не равно 9
>
Оператор «больше, чем». Думаю, он вам известен со школы. Так,
выражение (10 > 9) является true, так как 10 больше, чем 9
<
Оператор «меньше, чем». Выражение (9 < 10) является true, так как 9
меньше, чем 10
>=
Оператор «больше или равно». Возвращает true, если первое значение
больше или равно второму, и false, если первое значение меньше
второго. Например, выражение (10 >= 9) и (10 >= 10) является true
<=
Как и предыдущий оператор, этот проверяет два условия, но теперь
меньше или равно. Выражение (10 <= 9) является false, а (10 <= 10) — true
&&
Этот оператор известен как логическое И. Он проверяет две или более
отдельных частей выражения, и для получения результата обе части
должны быть истинными, чтобы получить результат true. Выражение
((10 > 9) && (10 < 11)) равно true, так как обе части удовлетворяют
условию. Выражение ((10 > 9) && (10 < 9)) является false, потому
что только одна часть выражения удовлетворяет условию, а другая нет
||
Оператор логического ИЛИ похож на логическое И, за исключением
того, что для того, чтобы выражение вернуло true, необходимо,
чтобы хотя бы одна из двух или более частей выражения была true.
Выражение ((10 > 9) || (10 < 9)) теперь является true, потому что одна
часть выражения удовлетворяет условию
Давайте познакомимся с ключевыми словами C++ if и else, которые позволят
нам эффективно использовать эти логические операторы.
Конструкция if/else в C++
Сделаем предыдущие примеры менее абстрактными. Для этого мы будем
использовать if и несколько операторов на примере одной истории, чтобы
продемонстрировать их применение. Далее мы рассмотрим выдуманную военную ситуацию, которая, надеемся, будет менее абстрактной, чем предыдущие
примеры.
86 Глава 2. Переменные, операторы и условия: анимация спрайтов
«Если они пересекут мост, стреляйте в них!»
Капитан умирает и, зная, что его оставшиеся подчиненные не слишком опытны,
решает написать программу на C++, чтобы передать свои последние приказы.
Войска должны удерживать одну сторону моста в ожидании подкрепления.
Первая команда, которую капитан хочет довести до сведения своих солдат, звучит
так: «Если они пересекут мост, стреляйте в них!».
Как же смоделировать эту ситуацию в C++? Нам нужна переменная типа bool —
isComingOverBridge. Следующий фрагмент кода предполагает, что переменная
isComingOverBridge была объявлена и инициализирована либо в true, либо в false.
Затем мы можем использовать if следующим образом:
if(isComingOverBridge)
{
// Стреляйте в них
}
Если переменная isComingOverBridge равна true, то некий код внутри фигурных
скобок будет выполнен. Если нет, то программа продолжится после блока if без
выполнения кода внутри него.
«В противном случае сделайте вот что…»
Капитан также хочет сказать своим солдатам, чтобы они оставались на месте,
если враг не идет по мосту.
Теперь мы можем ввести еще одно ключевое слово C++ — else. Если мы хотим
явно сделать что-то, когда if не дает значение true, мы можем использовать else.
Например, чтобы приказать войскам оставаться на месте, если враг не идет по
мосту, мы можем написать такой код:
if(isComingOverBridge)
{
// Стреляйте в них
}
else
{
}
// Удерживайте позицию
Капитан понял, что проблема не так проста, как ему показалось. Что, если враг
перейдет мост, но у него будет слишком много войск? Тогда его отряд будет
Принятие решений с помощью if и else 87
уничтожен. Поэтому он придумал следующий код (в этот раз мы также будем
использовать переменные):
bool isComingOverBridge;
int enemyTroops;
int friendlyTroops;
// Инициализируем предыдущие переменные тем или иным способом
// Теперь if
if(isComingOverBridge && friendlyTroops > enemyTroops)
{
// Стреляйте в них
}
else if(isComingOverBridge && friendlyTroops < enemyTroops)
{
// Взорвите мост
}
else
{
}
// Удерживайте позицию
В приведенном выше коде есть три возможных пути выполнения. Первый — если
враг пытается пересечь мост и дружественные войска превосходят противника
числом:
if(isComingOverBridge && friendlyTroops > enemyTroops)
Второй — если превосходящие вражеские силы переходят через мост:
else if(isComingOveBridge && friendlyTroops < enemyTroops)
И наконец, третий возможный вариант, который будет выполнен, если ни одно
из предыдущих условий не является истинным, обрабатывается последним else
без условия if.
А что, если…
А что будет, если численность вражеских и дружественных войск будет равна?
Этот момент не был учтен явно, и поэтому будет обработан конечным else .
Последнее else предназначено для случаев, когда врагов нет. Думаю, любой капитан будет ожидать, что его войска вступят в бой в такой ситуации. Он мог бы
изменить первое условие if, чтобы учесть эту возможность:
if(isComingOverBridge && friendlyTroops >= enemyTroops)
88 Глава 2. Переменные, операторы и условия: анимация спрайтов
И наконец, последнее опасение капитана заключалось в том, что если враг появится на мосту, размахивая белым флагом, и будет немедленно уничтожен,
то его люди окажутся военными преступниками. Необходимый код на C++
был очевиден. Используя переменную wavingWhiteFlag типа bool, он написал
такой код:
if (wavingWhiteFlag)
{
// Возьмите в плен
}
Но куда поместить этот код, было не так очевидно. В конце концов капитан выбрал следующую вложенную структуру и изменил код для wavingWhiteFlag на
логическое НЕ:
if (!wavingWhiteFlag)
{
// Не сдается, поэтому проверьте все остальное
if(isComingOverTheBridge && friendlyTroops >= enemyTroops)
{
// Стреляйте в них
}
else if(isComingOverTheBridge && friendlyTroops < enemyTroops)
{
// Взорвите мост
}
}
else
{
// Это else для нашего первого if
// Возьмите в плен
{
// Удерживайте позиции
Данный пример показывает, что мы можем вложить операторы if и else друг
в друга, чтобы создать довольно сложные и детальные условия.
Мы могли бы продолжать усложнять решения с помощью if и else, но того,
что мы уже рассмотрели, вполне достаточно для начала. Стоит отметить,
что очень часто существует более одного способа решения проблемы. Правильным обычно является тот, который решает ее наиболее ясным и простым
способом.
Мы приближаемся к тому, чтобы с помощью C++ анимировать облака и пчелу.
Осталось обсудить последний вопрос, и можно будет вернуться к игре.
Плавность 89
Плавность
Прежде чем мы сможем перемещать пчелу и облака, необходимо продумать еще
кое-что. Как мы уже знаем, основной цикл игры выполняется несколько раз, пока
игрок не нажмет клавишу Esc.
Мы также узнали, что C++ и SFML работают чрезвычайно быстро. На самом
деле мой скромный ноутбук выполняет простой игровой цикл (вроде текущего)
примерно пять тысяч раз в секунду. Учитывая это, давайте обсудим проблему обес
печения постоянной и предсказуемой скорости показа каждого кадра анимации.
Проблема частоты кадров
Давайте поговорим о скорости движения пчелы. Предположим, что мы собираемся перемещать ее со скоростью 200 пикселей в секунду. Чтобы преодолеть
расстояние в 1920 пикселей, пчеле потребуется примерно 10 секунд, потому что
10 умножить на 200 — это 2000 (достаточно близко к 1920).
Кроме того, мы знаем, что можем позиционировать любой из наших спрайтов
с помощью setPosition(...,...) . Нам просто нужно заключить координаты X и Y в круглые скобки.
Помимо задания позиции спрайта, мы также можем узнать текущую позицию
спрайта. Например, чтобы получить горизонтальную координату X пчелы, мы
используем следующий код:
float currentPosition = spriteBee.getPosition().x;
Текущая координата X (горизонтальная) пчелы теперь хранится в переменной
currentPosition. Чтобы переместить пчелу вправо, мы добавляем к currentPo
sition соответствующую долю от 200 (наша предполагаемая скорость), деленную
на 5000 (примерное количество кадров в секунду на моем ноутбуке):
currentPosition += 200/5000;
Теперь мы можем использовать setPosition для перемещения нашей пчелы.
Она будет плавно двигаться слева направо на 200, деленное на 5000 пикселей,
в каждом кадре. Но у этого подхода есть две проблемы.
Частота кадров — это количество раз в секунду, которое обрабатывает наш игровой цикл. То есть количество раз, когда мы обрабатываем ввод игрока, обновляем
игровые объекты и рисуем их на экране (мы будем обсуждать вопросы частоты
кадров на протяжении всей книги).
Частота кадров на моем ноутбуке всегда разная. Пчела может выглядеть так, будто она периодически «ускоряется» при движении по экрану, поскольку каждый
кадр выполняется с непостоянной скоростью.
90 Глава 2. Переменные, операторы и условия: анимация спрайтов
Конечно, мы хотим, чтобы наша игра была доступна не только моему ноутбуку,
но и более широкой аудитории! Частота кадров на каждом компьютере будет
отличаться, хотя бы немного. Если у вас старый ПК, пчела будет казаться обвешанной свинцом, а если у вас новейшая игровая система, то, скорее всего, это
будет что-то вроде размытой турбопчелы.
К счастью, данная проблема существует в каждой игре, и SFML предлагает
аккуратное решение на C++. Самый простой способ понять это решение — реализовать его.
Решение проблемы частоты кадров с помощью SFML
Теперь мы будем измерять и использовать частоту кадров для управления нашей игрой. Сначала добавьте следующий код непосредственно перед основным
игровым циклом:
// С какой скоростью движется каждое облако?
float cloud1Speed = 0;
float cloud2Speed = 0;
float cloud3Speed = 0;
// Переменные для управления временем
Clock clock;
while (window.isOpen())
{
В приведенном коде мы объявили объект типа Clock и назвали его clock. Имя
класса начинается с заглавной буквы, а имя объекта (которое мы будем использовать) — со строчной. Имя объекта произвольное, но clock кажется подходящим
именем для обозначения часов. Скоро мы добавим сюда еще несколько переменных, связанных со временем.
Теперь добавьте выделенный код:
/*
****************************************
Обновление сцены
****************************************
*/
// Измеряем время
Time dt = clock.restart();
/*
****************************************
Отрисовка сцены
****************************************
*/
Плавность 91
Функция clock.restart(), как и следовало ожидать, перезапускает clock. Мы
хотим перезапускать clock каждый кадр, чтобы можно было измерить, сколько
времени занимает каждый кадр. Кроме того, функция возвращает количество
времени, прошедшего с момента последнего перезапуска clock.
В результате мы объявляем объект типа Time под названием dt и используем его
для хранения значения, возвращаемого функцией clock.restart().
Теперь у нас есть объект Time с именем dt, который хранит количество времени,
прошедшего с момента последнего обновления сцены и перезапуска часов clock.
Возможно, вы уже поняли, к чему все идет.
Добавим еще немного кода, а затем посмотрим, что можно сделать с помощью dt.
ПРИМЕЧАНИЕ
dt означает delta time (дельту времени), то есть время между двумя обновлениями.
С помощью этих часов мы обновим функциональность игрового движка, чтобы
учитывать время. Теперь наш игровой цикл можно представить в виде схемы,
как на рис. 2.2.
Рис. 2.2. Основной игровой цикл
92 Глава 2. Переменные, операторы и условия: анимация спрайтов
С введением класса Clock наш игровой цикл можно лучше представить с помощью
схемы, показанной на рис. 2.3.
Рис. 2.3. Основной игровой цикл с таймингом
Добавим ключевую часть в наш код, чтобы увидеть, как работает математика.
Теперь мы можем решить проблему непостоянной частоты кадров, обновляя
положение пчелы и облаков относительно времени, которое занимает каждый кадр. Если кадр обрабатывается быстро, мы незначительно перемещаем
пчелу.
Приводим в движение облака и пчелу
Используем время, прошедшее с момента последнего кадра, чтобы вдохнуть
жизнь в пчелу и облака. Это решит проблему необходимости достижения одинаковой частоты кадров на разных ПК.
Оживляем пчелу
Первое, что мы хотим сделать, — поместить пчелу на определенную высоту и задать ей конкретную скорость. Это нужно только тогда, когда пчела неактивна.
Поэтому следующий код мы обернем в блок if. Изучите и добавьте выделенный
код, а затем мы его обсудим.
Приводим в движение облака и пчелу 93
/*
****************************************
Обновление сцены
****************************************
*/
// Измеряем время
Time dt = clock.restart();
// Настройка пчелы
if (!beeActive)
{
// Задаем скорость пчелы
srand((int)time(0));
beeSpeed = (rand() % 200) + 200;
// Задаем высоту полета пчелы
srand((int)time(0) * 10);
float height = (rand() % 500) + 500;
spriteBee.setPosition(2000, height);
beeActive = true;
}
/*
****************************************
Отрисовка сцены
****************************************
*/
Теперь, если пчела неактивна, как и при первом запуске игры, условие if(!bee
Active) будет true, и предыдущий код выполнит следующие действия.
1. Инициализирует генератор случайных чисел.
2. Получит случайное число в диапазоне от 200 до 399 и присвоит результат
beeSpeed.
3. Снова инициализирует генератор случайных чисел.
4. Получит случайное число в диапазоне от 500 до 999 и присвоит результат
новой переменной float с именем height.
5. Установит положение пчелы на 2000 по оси X (чуть в стороне от экрана справа) и на значение, равное высоте height, по оси Y.
6. Установит beeActive в true, чтобы этот код не выполнялся, пока мы снова
не изменим beeActive позже в коде.
Если запустить игру, с пчелой еще ничего не произойдет, но теперь, когда
пчела активна, мы можем написать код, который выполнится, когда beeActive
будет равен true . Это происходит потому, что после блока if(!beeActive)
следует else.
94 Глава 2. Переменные, операторы и условия: анимация спрайтов
ПРИМЕЧАНИЕ
Обратите внимание, что переменная height — это первая переменная, которую мы
объявили внутри игрового цикла. Более того, поскольку она была объявлена внутри
блока if, она «невидима» вне этого блока. Для нашего случая это приемлемо, так
как после установки высоты полета пчелы данная переменная нам больше не понадобится. Такое ограничение называется областью видимости переменной. Более
подробно мы рассмотрим это понятие в главе 4.
Добавьте выделенный код:
// Настройка пчелы
if (!beeActive)
{
// Задаем скорость пчелы
srand((int)time(0) );
beeSpeed = (rand() % 200) + 200;
// Задаем высоту полета пчелы
srand((int)time(0) * 10);
float height = (rand() % 1350) + 500;
spriteBee.setPosition(2000, height);
beeActive = true;
}
else
// Движение пчелы
{
spriteBee.setPosition(
spriteBee.getPosition().x (beeSpeed * dt.asSeconds()),
spriteBee.getPosition().y);
// Достигла ли пчела левого края экрана?
if (spriteBee.getPosition().x < -100)
{
// Готовим ее к появлению в качестве новой пчелы в следующем кадре
beeActive = false;
}
}
/*
****************************************
Отрисовка сцены
****************************************
*/
Приводим в движение облака и пчелу 95
В блоке else происходит следующее.
Функция изменения положения пчелы setPosition использует функцию
getPosition для получения текущей горизонтальной координаты пчелы. Затем
из этой координаты вычитается beeSpeed * dt.asSeconds().
Значение переменной beeSpeed задается в пикселях в секунду и было случайным образом присвоено в предыдущем блоке if . Значение dt.asSeconds()
будет больше 0, но меньше 1 и будет представлять собой время, затраченное на
предыдущий кадр анимации.
Допустим, текущая координата пчелы по горизонтали равна 1000. Теперь представим, что базовый ПК обрабатывает 5000 кадров в секунду. Это означает,
что dt.asSeconds будет равно 0.0002. Далее предположим, что для параметра
beeSpeed было установлено максимальное значение 399 пикселей в секунду.
Тогда код, определяющий значение, которое setPosition использует для горизонтальной координаты, можно объяснить следующим образом:
1000 - 0.0002 * 399
Таким образом, новая позиция пчелы по горизонтали будет равна 999.9202.
Мы видим, что пчела очень плавно смещается влево, с частотой менее пикселя
за кадр. Если частота кадров будет меняться, формула выдаст новое значение.
Если мы запустим тот же код на компьютере, который обрабатывает 100 кадров
в секунду или миллион кадров в секунду, пчела будет двигаться с одинаковой
скоростью.
Функция setPosition использует getPosition().y, чтобы пчела сохраняла ту же
координату по вертикали в течение всего цикла ее активности.
Теперь обсудим заключительную часть кода в блоке else:
// Достигла ли пчела левого края экрана?
if (spriteBee.getPosition().x < -100)
{
// Готовим ее к появлению в качестве новой пчелы в следующем кадре
beeActive = false;
}
Данный код покадрово проверяет (когда beeActive равен true), не скрылась ли
пчела за экраном. Если функция getPosition возвращает значение меньше -100,
значит, пчела исчезла из поля зрения игрока. Когда это происходит, beeActive
устанавливается в false, и на следующем кадре «новая» пчела начинает полет
на новой случайной высоте и с новой случайной скоростью.
Запустите игру и понаблюдайте за тем, как наша пчелка послушно летит спра
ва налево, а затем возвращается обратно, но на новой высоте и с иной ско
ростью.
96 Глава 2. Переменные, операторы и условия: анимация спрайтов
СОВЕТ
Конечно, настоящая пчела вечно торчала бы рядом и донимала вас, пока вы пытаетесь сосредоточиться на рубке дерева. Кроме того, реальная пчела, вероятно,
летала бы хаотично. Не волнуйтесь, с каждым проектом мы будем создавать более
сложные игровые объекты. Главное, чтобы вы научились работать со спрайтами
и текстурами.
Теперь практически аналогичным образом заставим двигаться облака.
Движение облаков
Для начала установим первое облако на конкретной высоте и зададим ему определенную скорость. Это стоит сделать, когда облако неактивно. Следовательно,
следующий код мы обернем в еще один блок if. Изучите и добавьте выделенный
код сразу после кода пчелы, а затем мы его обсудим.
else
// Движение пчелы
{
spriteBee.setPosition(
spriteBee.getPosition().x
(beeSpeed * dt.asSeconds()),
spriteBee.getPosition().y);
// Достигла ли пчела левого края экрана?
если (spriteBee.getPosition().x < -100)
{
// Готовим ее к появлению в качестве новой пчелы в следующем кадре
beeActive = false;
}
}
// Управляем облаками
// Облако 1
if (!cloud1Active)
{
// Задаем скорость облака
srand((int)time(0) * 10);
cloud1Speed = (rand() % 200);
// Задаем высоту облака
srand((int)time(0) * 10);
Приводим в движение облака и пчелу 97
float height = (rand() % 150);
spriteCloud1.setPosition(-200, height);
cloud1Active = true;
}
/*
***********************
Отрисовка сцены
***********************
*/
Единственное различие между только что добавленным кодом и кодом, связанным с пчелой, заключается в том, что мы работаем с другим спрайтом и используем другие диапазоны для случайных чисел. Кроме того, мы умножаем результат,
возвращаемый time(0), на 10, чтобы всегда гарантированно получать разные начальные значения (зерна). Когда мы запрограммируем движение других облаков,
вы увидите, что мы используем * 20 и * 30 соответственно.
Теперь мы можем действовать, когда облако активно. Мы сделаем это в блоке
else. Как и в случае с блоком if, код идентичен коду, связанному с пчелой, за
исключением того, что весь код работает с облаком, а не с пчелой.
// Управляем облаками
if (!cloud1Active)
{
// Задаем скорость облака
srand((int)time(0) * 10);
cloud1Speed = (rand() % 200);
// Задаем высоту облака
srand((int)time(0) * 10);
float height = (rand() % 150);
spriteCloud1.setPosition(-200, height);
cloud1Active = true;
}
else
{
spriteCloud1.setPosition(
spriteCloud1.getPosition().x +
(cloud1Speed * dt.asSeconds()),
spriteCloud1.getPosition().y);
// Достигло ли облако правого края экрана?
if (spriteCloud1.getPosition().x > 1920)
{
98 Глава 2. Переменные, операторы и условия: анимация спрайтов
}
// Готовим его к появлению в качестве нового облака в следующем кадре
cloud1Active = false;
}
/*
***********************
Отрисовка сцены
***********************
*/
Теперь, когда мы знаем, что делать, мы можем продублировать тот же код для
второго и третьего облаков. Добавьте следующий выделенный код сразу после
кода для первого облака:
...
// Облако 2
if (!cloud2Active)
{
// Задаем скорость облака
srand((int)time(0) * 20);
cloud2Speed = (rand() % 200);
// Задаем высоту облака
srand((int)time(0) * 20);
float height = (rand() % 300) - 150;
spriteCloud2.setPosition(-200, height);
cloud2Active = true;
}
else
{
spriteCloud2.setPosition(
spriteCloud2.getPosition().x +
(cloud2Speed * dt.asSeconds()),
spriteCloud2.getPosition().y);
// Проверяем, достигло ли облако правого края экрана
if (spriteCloud2.getPosition().x > 1920)
{
// Готовим его к появлению в качестве нового облака в следующем кадре
cloud2Active = false;
}
}
if (!cloud3Active)
{
// Задаем скорость облака
srand((int)time(0) * 30);
cloud3Speed = (rand() % 200);
Приводим в движение облака и пчелу 99
// Задаем высоту облака
srand((int)time(0) * 30);
float height = (rand() % 450) - 150;
spriteCloud3.setPosition(-200, height);
cloud3Active = true;
}
else
{
spriteCloud3.setPosition(
spriteCloud3.getPosition().x +
(cloud3Speed * dt.asSeconds()),
spriteCloud3.getPosition().y);
// Проверяем, достигло ли облако правого края экрана
if (spriteCloud3.getPosition().x > 1920)
{
// Готовим его к появлению в качестве нового облака в следующем кадре
cloud3Active = false;
}
}
/*
***********************
Отрисовка сцены
***********************
*/
Теперь запустите игру. Вы увидите беспорядочно и непрерывно перемещающиеся по экрану облака, а также пчелу, которая летает справа налево, а затем снова
появляется справа (рис. 2.4).
Рис. 2.4. Плывущие облака
100 Глава 2. Переменные, операторы и условия: анимация спрайтов
СОВЕТ
Не кажется ли вам вся эта работа с облаками и пчелами повторяющейся? Мы разберем, как можно сократить объем кода и сделать его более читабельным. В C++
есть способы работы с несколькими экземплярами переменных или объектов одного
типа. Они называются массивами, и мы изучим их в главе 4. Кроме того, мы увидим,
как можно выполнить один и тот же код, но для разных значений, не переписывая
его несколько раз (как мы делали здесь), с помощью собственных пользовательских
функций. Все это будет рассмотрено в главе 4. Это был сознательный выбор — сосредоточиться на реализации игровых функций, прежде чем вводить еще больше
теории C++. К концу книги вы будете знать, как сделать эту игру гораздо лучше,
чем мы можем на данный момент.
Перед тем как двигаться дальше, я рекомендую вам поэкспериментировать
с кодом из этой главы. Например, заменить файлы текстур на собственные изображения, изменить скорость движения пчелы и облаков или заставить пчелу
двигаться вверх-вниз по экрану, выписывая синусоиду.
Резюме
В этой главе вы узнали, что переменная — это именованное место в памяти, в котором можно хранить значения определенного типа. К таким типам относятся
int, float, double, bool, String и char.
Вы научились объявлять и инициализировать все переменные, необходимые для
хранения данных нашей игры. После того как переменные определены, мы можем манипулировать ими с помощью арифметических операторов и операторов
присваивания, а также использовать их с логическими операторами. С помощью
ключевых слов if и else можно разветвлять выполнение нашего кода в зависимости от текущей ситуации в игре.
Применив все эти новые знания, мы анимировали несколько облаков и пчелу.
В следующей главе мы воспользуемся этими навыками, чтобы добавить элементы
интерфейса (HUD) и дополнительные возможности ввода для игрока, а также
визуально отобразить время с помощью временной шкалы.
Часто задаваемые вопросы
В. Почему мы переводим пчелу в неактивное состояние, когда она достигает
значения –100? Почему не просто ноль, ведь ноль — это левая граница окна?
О. Изображение пчелы имеет ширину 60 пикселей, а точка отсчета находится
в левом верхнем пикселе. Таким образом, если пчела отображается с началом
Часто задаваемые вопросы 101
координат в точке X, равной нулю, игрок все еще видит изображение пчелы.
Устанавливая значение –100, мы гарантируем, что игрок точно не увидит пчелу
на экране.
В. Как узнать, насколько быстро работает мой игровой цикл?
О. Если у вас современная видеокарта NVIDIA, то вы можете настроить GeForce
Experience на отображение частоты кадров. Однако, чтобы измерить ее с помощью собственного кода, нам придется узнать еще несколько дополнительных
концепций. Мы добавим возможность измерять и отображать текущую частоту
кадров в главе 5.
В. В чем разница между оператором присваивания = и оператором равенства ==
в C++?
О. Оператор присваивания (=) используется для присвоения значения переменной. Например, int x = 5 присваивает переменной x значение 5. Оператор
равенства (==) используется для сравнения двух значений на равенство. Например, оператор if (x == 5) проверяет, равно ли значение x числу 5.
В. Как спрайты и текстуры работают в C++ вместе с SFML?
О. В SFML текстура представляет собой изображение, загруженное из файла,
а спрайт — это двумерное изображение, которое можно нарисовать на экране.
Функция setTexture связывает текстуру со спрайтом, обеспечивая отрисовку
изображения на экране. Вы можете изменять позицию, ориентацию и масштаб
спрайта, а SFML эффективно выполняет отрисовку с помощью GPU.
В. Зачем задавать начальное значение генератору случайных чисел в C++?
О. Установка начального значения генератору случайных чисел необходима
для того, чтобы при каждом запуске программы он выдавал разные последовательности случайных чисел. Без этого генератор будет выдавать одну и ту же
последовательность чисел при каждом запуске программы, делая результаты
предсказуемыми, а не случайными. Обычно в качестве начального значения используется текущее время. Это примерно то же самое, что и создание уникальной
карты в игре Minecraft. Позже, в финальном проекте, мы будем использовать
более продвинутые техники для генерации случайных чисел.
3
Строки в C++, время SFML,
пользовательский ввод
и HUD
Почти в каждой игре должен быть текст на экране — будь то счет, субтитры или
другие элементы. Поэтому мы посвятим примерно половину главы работе с текстом и его отображению на экране, а оставшуюся часть — таймингу и тому, как
временная шкала может информировать игрока об оставшемся времени.
По мере того как мы будем развивать эту игру в следующих трех главах, код будет становиться все длиннее и сложнее. Поэтому сейчас самое время подумать
о структуре кода. Мы добавим структуру, чтобы иметь возможность приостанавливать и перезапускать игру.
Пауза и перезапуск игры
Мы добавим код, чтобы при первом запуске игра находилась в состоянии паузы.
Чтобы приступить к игре, игроку придется нажать клавишу Enter. Игра будет
продолжаться до тех пор, пока персонаж не погибнет или у игрока не закончится
время.
В этот момент игра снова перейдет в режим паузы и перезапустится, когда игрок
нажмет Enter.
Давайте разбираться по порядку. Во-первых, объявим новую переменную типа
bool под названием paused вне основного цикла игры и инициализируем ее значением true.
// Переменные для управления временем
Clock clock;
// Отслеживание состояния игры (на паузе или нет)
bool paused = true;
while (window.isOpen())
{
/*
Пауза и перезапуск игры 103
******************************
Обработка ввода игрока
******************************
*/
Теперь у нас есть переменная paused, которая изначально равна true.
Во-вторых, мы добавим еще один оператор if, который проверяет, нажата ли
в данный момент клавиша Enter. Если она нажата, то переменной paused присваивается значение false. Добавьте следующий выделенный код сразу после
нашего кода обработки ввода:
/*
******************************
Обработка ввода игрока
******************************
*/
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
// Старт игры
if (Keyboard::isKeyPressed(Keyboard::Return))
{
paused = false;
}
/*
******************************
Обновление сцены
******************************
*/
Теперь у нас есть переменная типа bool с именем paused, которая изначально
установлена в true, но изменяется на false, когда игрок нажимает клавишу Enter.
На этом этапе нужно, чтобы наш игровой цикл реагировал соответствующим образом, основываясь на текущем значении paused.
Мы поступим следующим образом: обернем блок кода с обновлением сцены,
включая код, который мы написали в предыдущей главе для перемещения пчелы
и облаков, в оператор if.
Обратите внимание, что следующий блок кода будет выполняться только в том
случае, если значение paused не равно true. Или, говоря иначе, игра не будет обновляться, если она приостановлена. Мы также можем обернуть код отрисовки
в аналогичный оператор if, чтобы предотвратить отрисовку сцены на экране.
Как мы знаем, когда большинство игр приостанавливается, сцена остается видимой. Это именно то, что нам нужно.
104 Глава 3. Строки в C++, время SFML, пользовательский ввод и HUD
Внимательно посмотрите на место для добавления нового оператора if и соответствующих ему фигурных скобок ({...}). Если их разместить не там, все будет
работать некорректно.
Добавьте следующий выделенный код. За символами ... я скрыл ранее написанный нами код просто для экономии места. Многоточие — это не настоящий код,
и его не следует добавлять в игру.
/*
************************
Обновление сцены
************************
*/
if (!paused)
{
// Измеряем время
...
...
...
// Проверяем, достигло ли облако правого края экрана
if (spriteCloud3.getPosition().x > 1920)
{
// Готовим его к появлению в качестве нового облака в следующем кадре
cloud3Active = false;
}
}
} // Конец блока if(!paused)
/*
***********************
Отрисовка сцены
***********************
*/
Обратите внимание, что, когда вы помещаете закрывающую фигурную скобку
в новый блок if, Visual Studio аккуратно корректирует все отступы. Однако
в зависимости от настроек Visual Studio этого может и не произойти. Если ваш
код внутри блока if не имеет отступов, вы можете выделить весь код внутри
него, как это делается в любом текстовом редакторе, а затем нажать клавишу
Tab.
Теперь вы можете запустить игру, и все будет статичным, пока вы не нажмете
клавишу Enter. Приступим к добавлению новой функциональности в игру. Нужно
только помнить, что, когда герой погибнет или закончится время игры, необходимо установить переменную paused в true.
Строки в C++ 105
В предыдущей главе мы впервые познакомились со строками C++. Нам нужно
узнать о них больше, чтобы реализовать HUD.
Строки в C++
Вы уже знаете, что строка может содержать алфавитно-цифровые данные: от одного символа до целой книги. Мы не рассматривали объявление, инициализацию
и работу со строками. Так что сделаем это сейчас.
Объявление строк
Объявить строковую переменную очень просто. Мы указываем тип, а затем
имя.
String levelName;
String playerName;
После того как мы объявили переменную типа String, мы можем присвоить ей
значение.
Присвоение значения строкам
Чтобы присвоить строке значение, мы просто указываем имя переменной, оператор присваивания, а затем значение:
levelName = "Dastardly Cave";
playerName = "John Carmack";
Обратите внимание, что значения должны быть заключены в кавычки. Как
и в случае с обычными переменными, мы можем объявлять и присваивать значения в одной строке:
String score = "Score = 0";
String message = "GAME OVER!";
Для полноты картины следует упомянуть, что вы так же можете объявлять
и инициализировать строки с помощью унифицированной инициализации, как
мы обсуждали в главе 2:
// Использование унифицированной инициализации для строки
string playerName{"Rob Hubbard"};
При разработке игр строки в C++ незаменимы для работы с текстовыми данными.
Будь то отображение имен игроков, вывод сообщений или отслеживание рекордов, важно понимать, как работать со строками. Давайте изучим это подробнее,
начав с конкатенации строк.
106 Глава 3. Строки в C++, время SFML, пользовательский ввод и HUD
Конкатенация строк
В следующем примере кода мы используем C++ cout для вывода текста в окно
консоли. Вы можете попробовать это, скопировав и вставив код прямо внутрь
открывающей фигурной скобки функции main текущего проекта, или начать
новый проект. Если вы создаете новый проект, вам не нужно добавлять какиелибо конфигурации SFML, как мы делали в главе 1. Просто создайте консольное
приложение, выберите имя, вставьте код в функцию main и добавьте следующие
директивы для использования string и cout:
#include <iostream>
#include <string>
Ниже представлен полный код; реализуйте его или просто просмотрите, а потом
мы обсудим его.
// Добавьте перед функцией main
#include <iostream>
#include <string>
// Поместите внутрь функции main
std::string playerName = "Player1";
std::string message = "Добро пожаловать в игру, " + playerName + "!";
std::cout << message << std::endl;
В приведенном выше коде демонстрируются работа со строками в C++: инициализируется переменная playerName, потом создается строка message, которая
содержит имя игрока, а затем выводится на экран с помощью std::cout. Обратите
внимание, что в строке, где мы создаем message, строки конкатенируются (то есть
объединяются) с помощью оператора +.
Стоит отметить, что, как и в случае с sf:: в SFML, вы можете опустить все экземпляры std::, добавив строку кода после директивы include, например, так:
using namespace std;
С помощью строк можно сделать гораздо больше, так что давайте продолжим.
Получение длины строки
В следующем коде мы углубимся в мир строк и задействуем функцию length.
Мы немного забегаем вперед, поскольку здесь демонстрируется вызов функции
для экземпляра класса, но, как вы увидите, это интуитивно довольно понятно.
string playerName = "Player1";
int playerNameLength = playerName.length();
cout << "Имя игрока содержит " << playerNameLength << " символов." << endl;
Строки в C++ 107
В приведенном выше коде я опустил все спецификаторы std::, которые присутствовали в предыдущем примере. Если вы хотите опробовать этот код в Visual
Studio, добавьте синтаксис using namespace std после директив include.
Здесь мы объявляем и инициализируем как строку string, так и переменную
типа int. Затем мы используем функцию length(), чтобы вернуть количество
символов в строке и сохранить результат в переменной playerNameLength ,
которая имеет тип int. Далее мы с помощью cout выводим результат в окно
консоли.
Отмечу, что оператор << объединяет блоки вывода.
СОВЕТ
Оператор << является побитовым. Однако C++ позволяет переопределять действия
конкретного оператора в контексте своего класса. В классе iostream оператор <<
переопределен, чтобы работать так, как должен. Вся cложность скрыта в классе. Мы
можем использовать его функциональность, не задумываясь о том, как он работает.
Мы почти готовы добавить в нашу игру новые возможности. Но для начала узнаем,
как изменить наши строковые переменные.
Работа со строками
с помощью StringStream
Мы можем использовать директиву #include <sstream>, чтобы получить дополнительные возможности для работы со строками. Класс sstream позволяет нам
«сложить» несколько строк вместе. Это еще один способ конкатенации.
String part1 = "Привет, ";
String part2 = "Мир!";
sstream ss;
ss << part1 << part2;
// ss теперь содержит "Привет, Мир!"
Кроме того, используя объекты sstream, можно даже конкатенировать строки
с переменными другого типа. Следующий пример показывает, насколько строки
могут быть полезны.
String scoreText = "Счет = ";
int score = 0;
108 Глава 3. Строки в C++, время SFML, пользовательский ввод и HUD
// Позже в коде
score ++;
sstream ss;
ss << scoreText << score;
// ss теперь имеет значение "Счет = 1"
Теперь, зная основы работы со строками в C++ и с sstream, мы можем перейти
к использованию классов SFML для отображения строк на экране.
Текст и шрифт в SFML
Давайте немного поговорим о классах Text и Font в SFML.
Первый шаг для вывода текста на экран — это добавление шрифта. В первой
главе мы добавили файл шрифта в папку проекта. Теперь мы можем загрузить
готовый к использованию шрифт. Код для этого выглядит следующим образом:
Font font;
font.loadFromFile("myfont.ttf");
В приведенном выше коде мы сначала объявляем объект Font, а затем загружаем
в него файл шрифта. Обратите внимание, что myfont.ttf — это гипотетический
шрифт и вы можете использовать любой шрифт, находящийся в папке проекта.
После загрузки шрифта нам понадобится объект Text.
Text myText;
Теперь мы можем настроить наш объект Text. Это включает в себя размер, цвет,
положение на экране, строку String, содержащую сообщение, и, конечно, привязку к нашему объекту шрифта font.
// Задаем текст сообщения
myText.setString("Нажмите Enter, чтобы начать!");
// Устанавливаем размер шрифта для вывода сообщения
myText.setCharacterSize(75);
// Выбираем цвет
myText.setFillColor(Color::White);
// Связываем шрифт с объектом текста
myText.setFont(font);
Стоит отметить, сколько времени SFML экономит нам. Классы Font и Text —
отличные примеры. SFML предоставляет удобные абстракции для работы со
шрифтами и отображения текста. Они значительно упрощают эти задачи по
сравнению с прямым использованием OpenGL.
Добавление счета и сообщения 109
Класс Font библиотеки SFML представляет шрифт, который можно применять
для отображения текста. Он предлагает функции для загрузки шрифтов из файлов, буферов в памяти или системных шрифтов. Класс Text отвечает за отображение текста с помощью заданного шрифта. Он содержит строку для отображения,
шрифт и различные свойства, связанные с текстом.
Библиотека SFML абстрагирует почти все сложности, связанные с рендерингом
текста с помощью OpenGL. Она обрабатывает создание текстур, управление
шейдерами и другие детали OpenGL. Использование SFML позволяет сосредоточиться на создании игры, а не на низкоуровневой математике OpenGL.
SFML была создана Лораном Гомилой. Ее разработка началась примерно
в 2006 году, и за это время библиотека претерпела множество обновлений и улучшений. Преданность Лорана поддержке SFML на протяжении почти двух десятилетий невозможно переоценить. На мой взгляд, она просто невероятна. Я решил
упомянуть об этом, чтобы каждый раз, когда вы без труда рисуете спрайт на
экране, вы думали о неустанных усилиях, которые были приложены.
Теперь вы знаете более чем достаточно, чтобы добавить новые функции в нашу
игру. Добавим в игру Timber! HUD.
Добавление счета и сообщения
HUD (head-up display) в видеоиграх — это часть визуального интерфейса игрока,
которая отображается на переднем плане виртуального игрового пространства,
аналогично дисплею на лобовом стекле в кабине самолета, который помогает
пилотам контролировать полет и состояние воздушного судна.
Следующее, что нам нужно сделать, — это добавить еще одну директиву #include
в начало файла кода. Как мы уже знаем, класс sstream добавляет полезную
функциональность, в частности позволяет сводить строки и другие типы переменных в одну строку.
Добавьте выделенную строку кода.
#include <sstream>
#include <SFML/Graphics.hpp>
using namespace sf;
int main()
{
Далее мы настроим наши объекты Text библиотеки SFML: один для сообщения,
которое мы будем менять в зависимости от состояния игры, а другой для отображения регулярно обновляемого счета.
110 Глава 3. Строки в C++, время SFML, пользовательский ввод и HUD
Следующий код объявляет объекты Text и Font, загружает шрифт, присваивает
его объектам Text, а затем добавляет сообщения String, цвет и размер. Все это
должно быть знакомо из предыдущего раздела. Кроме того, мы добавим новую
переменную типа int под названием score, чтобы хранить счет игрока.
СОВЕТ
Помните, что, если вы выбрали шрифт, отличный от KOMIKAP_.ttf, еще в главе 1,
вам нужно будет изменить этот фрагмент кода, чтобы он соответствовал файлу
.ttf, который находится в папке Visual Studio Stuff/Projects/Timber/fonts.
Добавьте выделенный код, чтобы перейти к обновлению HUD.
// Отслеживание состояния игры (на паузе или нет)
bool paused = true;
// Отрисовка текста
int score = 0;
Text messageText;
Text scoreText;
// Выбираем шрифт
Font font;
font.loadFromFile("fonts/KOMIKAP_.ttf");
// Устанавливаем шрифт сообщения
messageText.setFont(font);
scoreText.setFont(font);
// Устанавливаем текст сообщения
messageText.setString("Нажмите Enter, чтобы начать!");
scoreText.setString("Счет = 0");
// Устанавливаем размер сообщения
messageText.setCharacterSize(75);
scoreText.setCharacterSize(100);
// Выбираем цвет
messageText.setFillColor(Color::White);
scoreText.setFillColor(Color::White);
while (window.isOpen())
{
/*
******************************
Обработка ввода игрока
******************************
*/
Добавление счета и сообщения 111
Следующий код может показаться немного запутанным и даже сложным. Добавьте
новый код, а затем мы изучим его подробнее.
// Выбираем цвет
messageText.setFillColor(Color::White);
scoreText.setFillColor(Color::White);
// Позиционируем текст
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top +
textRect.height / 2.0f);
messageText.setPosition(1920 / 2.0f,1080 / 2.0f);
scoreText.setPosition(20, 20);
while (window.isOpen())
{
/*
******************************
Обработка ввода игрока
******************************
*/
У нас есть два объекта типа Text, которые нужно отобразить на экране. Мы хотим
расположить scoreText в левом верхнем углу с небольшим отступом 20 пикселей по горизонтали и вертикали. Это несложно: мы просто используем sco
reText.setPosition(20, 20).
Позиционирование messageText, однако, сложнее, так как мы хотим расположить
его точно в центре экрана. Изначально это не кажется проблемой, но потом мы
вспоминаем, что начало координат всего, что мы рисуем, находится в левом
верхнем углу. Поэтому, если мы просто разделим ширину и высоту экрана на 2
и применим результат в messageText.setPosition..., то в центре экрана окажется
только левый верхний угол текстового поля.
Чтобы центр текста совпадал с центром экрана, нужно переназначить точку
привязки:
// Позиционируем текст
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top +
textRect.height / 2.0f);
112 Глава 3. Строки в C++, время SFML, пользовательский ввод и HUD
В данном коде сначала объявляется новый объект типа FloatRect с именем
textRect. Объект FloatRect представляет собой прямоугольник с координатами,
заданными числами с плавающей точкой.
Затем в коде используется функция mesageText.getLocalBounds для инициализации textRect координатами прямоугольника, который обрамляет messageText.
Следующая строка кода, которая у нас занимает четыре строки из-за своей длины,
задействует функцию messageText.setOrigin для изменения начала координат
(точки, которая используется для отрисовки) на центр textRect . Разумеется, textRect содержит прямоугольник, точно соответствующий координатам
messageText. Затем выполняется следующая строка кода:
messageText.setPosition(1920 / 2.0f,1080 / 2.0f);
Теперь messageText будет располагаться точно по центру экрана. Мы будем использовать этот же код каждый раз при изменении текста messageText, потому
что изменение сообщения влияет на размер messageText и его точку привязки
нужно пересчитывать.
Далее мы объявляем объект типа stringstream с именем ss. Обратите внимание,
что мы используем полное имя, включая пространство имен std::stringstream.
Мы могли бы избежать этого синтаксиса, добавив using namespace std в начало
файла с кодом. Однако мы этого не делаем, поскольку нечасто прибегаем к его
использованию. Взгляните на код и добавьте его в игру, после чего мы сможем
разобрать его подробнее. Поскольку мы хотим, чтобы данный код выполнялся
только тогда, когда игра активна, убедитесь, что поместили его вместе с другим
кодом, внутри блока if(!paused), как показано в примере.
else
{
spriteCloud3.setPosition(
spriteCloud3.getPosition().x +
(cloud3Speed * dt.asSeconds()),
spriteCloud3.getPosition().y);
// Проверяем, достигло ли облако правого края экрана
if (spriteCloud3.getPosition().x > 1920)
{
// Готовим его к появлению в качестве нового облака в следующем кадре
cloud3Active = false;
}
}
// Обновление текста счета
std::stringstream ss;
ss << "Счет = " << score;
scoreText.setString(ss.str());
Добавление счета и сообщения 113
} // Конец блока if(!paused)
/*
***********************
Отрисовка сцены
***********************
*/
Мы используем ss и специальную функциональность, предоставляемую оператором <<, который конкатенирует переменные в stringstream. Так, код ss << "Счет =
" << score создает строку String, содержащую "Score = " и значение переменной
score, которое добавляется к этой строке. Например, при запуске игры, score
равна 0, поэтому ss будет хранить значение "Счет = 0". Если значение score изменится, ss обновится.
Следующая строка кода просто отображает/устанавливает строку String, содержащуюся в ss, в scoreText:
scoreText.setString(ss.str());
Теперь текст готов к выводу на экран.
Следующий фрагмент кода выводит оба объекта Text (scoreText и messageText).
Однако обратите внимание, что код, рисующий messageText, заключен в оператор if. Это условие гарантирует, что messageText будет отрисован только тогда,
когда игра поставлена на паузу.
Добавьте выделенный ниже код.
// Отрисовка пчелы
window.draw(spriteBee);
// Отрисовка счета
window.draw(scoreText);
if (paused)
{
// Отрисовка сообщения
window.draw(messageText);
}
// Отображаем все, что было отрисовано
window.display();
Теперь мы можем запустить игру и увидеть наш HUD: на экране отобразятся
сообщения СЧЕТ = 0 и Нажмите ENTER, чтобы начать! (рис. 3.1). Последнее исчезнет
после нажатия клавиши Enter.
114 Глава 3. Строки в C++, время SFML, пользовательский ввод и HUD
Рис. 3.1. HUD в действии
Если вы хотите видеть, как обновляется счет, добавьте временную строку кода,
score ++;, в любом месте цикла while(window.isOpen). Сделав это, вы увидите,
как счет очень быстро увеличивается (рис. 3.2)!
Рис. 3.2. Счет
Если вы добавили временный код score ++;, не забудьте удалить его, прежде чем
продолжить.
Добавление временной шкалы
Поскольку время является ключевым элементом игры, важно, чтобы игроки знали
о нем. Они должны быть в курсе, что отведенные им шесть секунд вот-вот истекут. Это вызовет ощущение волнения, что игра скоро закончится, а также чувство
удовлетворения, если они смогут сохранить или увеличить оставшееся время.
Однако просто выводить количество оставшихся секунд на экране мы не будем — это не очень удобно для восприятия (особенно, когда игрок сосредоточен
на ветках) и не позволит эффективно достичь цели.
Добавление временной шкалы 115
Нам нужна временная шкала. Она будет представлять собой простой красный
прямоугольник, отображаемый на экране. В начале игры он будет длинным, но
по мере истечения времени будет быстро уменьшаться. Когда время игрока достигнет 0, временная шкала полностью исчезнет.
Вместе с временной шкалой мы добавим необходимый код для отслеживания
оставшегося времени, а также для реакции на его окончание. Разберемся с этим
шаг за шагом.
Найдите объявление Clock clock; из предыдущего раздела и добавьте выделенный код сразу после него, как показано ниже:
// Переменные для управления временем
Clock clock;
// Временная шкала
RectangleShape timeBar;
float timeBarStartWidth = 400;
float timeBarHeight = 80;
timeBar.setSize(Vector2f(timeBarStartWidth, timeBarHeight));
timeBar.setFillColor(Color::Red);
timeBar.setPosition((1920 / 2) timeBarStartWidth / 2, 980);
Time gameTimeTotal;
float timeRemaining = 6.0f;
float timeBarWidthPerSecond = timeBarStartWidth / timeRemaining;
// Отслеживание состояния игры (на паузе или нет)
bool paused = true;
Сначала мы объявим объект типа RectangleShape с именем timeBar . Rec
tagleShape — это класс библиотеки SFML, который идеально подходит для отрисовки простых прямоугольников.
Далее мы добавим пару переменных типа float : timeBarStartWidth и time
BarHeight. Мы инициализируем их значениями 400 и 80 соответственно. Эти
переменные помогут нам отслеживать размер, который нужно задать для timeBar
в каждом кадре.
Затем мы устанавливаем размер timeBar с помощью функции timeBar.setSize.
Мы не просто передаем две новые переменные типа float. Сначала мы создаем
объект типа Vector2f. Однако мы не даем новому объекту имя. Мы просто инициализируем его двумя переменными float и передаем его прямо в функцию setSize.
СОВЕТ
Vector2f — это класс, в котором хранятся две переменные типа float. Он также
обладает дополнительной функциональностью, которую мы рассмотрим позже.
116 Глава 3. Строки в C++, время SFML, пользовательский ввод и HUD
После этого мы окрашиваем timeBar в красный цвет с помощью функции
setFillColor.
Последнее, что мы делаем с timeBar в этом коде, — задаем его положение. С вертикальной координатой все просто, а вот горизонтальная рассчитывается несколько сложнее:
(1920 / 2) - timeBarStartWidth / 2
Сначала код делит 1920 на 2, а затем и timeBarStartWidth. Наконец, он вычитает
последнее из первого.
В результате TimeBar будет располагаться точно по центру экрана по горизонтали.
Последние три строки кода, о которых пойдет речь, объявляют новый объект Time
с именем gameTimeTotal и две переменные типа float — timeRemaining, которая
инициализируется со значением 6, и timeBarWidthPerSecond, о которой мы поговорим далее.
Переменная timeBarWidthPerSecond инициализируется значением time
BarStartWidth , деленным на timeRemaining . В результате получается ровно
столько пикселей, на сколько должна уменьшаться временная шкала каждую
секунду игры. Это будет полезно при изменении размера timeBar в каждом кадре
игрового цикла.
Очевидно, что нам нужно сбрасывать оставшееся время каждый раз, когда игрок
начинает новую игру. Логично сделать это при нажатии Enter. Одновременно мы
можем сбросить счет score. Добавьте следующий выделенный код:
// Старт игры
if (Keyboard::isKeyPressed(Keyboard::Return))
{
paused = false;
// Сброс времени и счета
score = 0;
timeRemaining = 6;
}
Теперь с каждым кадром мы должны уменьшать оставшееся время и соответствующим образом изменять размер временной шкалы TimeBar. Добавьте выделенный код:
/*
************************
Обновление сцены
************************
*/
if (!paused)
Добавление временной шкалы 117
{
// Измеряем время
Время dt = clock.restart();
// Уменьшаем оставшееся время
timeRemaining -= dt.asSeconds();
// Изменяем размер временной шкалы
timeBar.setSize(Vector2f(timeBarWidthPerSecond *
timeRemaining, timeBarHeight));
// Настройка пчелы
if (!beeActive)
{
// Задаем скорость пчелы
srand((int)time(0) * 10);
beeSpeed = (rand() % 200) + 200;
// Задаем высоту полета пчелы
srand((int)time(0) * 10);
float height = (rand() % 1350) + 500;
spriteBee.setPosition(2000, height);
beeActive = true;
}
else
// Движение пчелы
Здесь мы сначала вычли оставшееся у игрока количество времени из значения,
обозначающего, сколько времени заняло выполнение предыдущего кадра с этим
кодом.
timeRemaining -= dt.asSeconds();
Затем мы изменили размер timeBar:
timeBar.setSize(Vector2f(timeBarWidthPerSecond *
timeRemaining, timeBarHeight));
Значение Vector2F инициализируется произведением timebarWidthPerSecond
и timeRemaining. Это дает точную ширину прямоугольника, соответствующую
оставшемуся у игрока времени. Высота остается неизменной и равна timeBarHeight.
Кроме того, мы должны определить момент, когда закончится время. Пока мы
просто обнаружим, что время истекло, поставим игру на паузу и изменим текст
messageText. Позже мы добавим больше функциональности, а пока перепишите
код:
// Измеряем время
Time dt = clock.restart();
// Уменьшаем оставшееся время
timeRemaining -= dt.asSeconds();
118 Глава 3. Строки в C++, время SFML, пользовательский ввод и HUD
// Изменяем размер временной шкалы
timeBar.setSize(Vector2f(timeBarWidthPerSecond *
timeRemaining, timeBarHeight));
if (timeRemaining <= 0.0f) {
// Ставим игру на паузу
paused = true;
// Меняем сообщение для игрока
messageText.setString("ВРЕМЯ ВЫШЛО!");
// Пересчитываем позицию текста на основе его нового размера
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top +
textRect.height / 2.0f);
messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);
}
// Настройка пчелы
if (!beeActive)
{
// Задаем скорость пчелы
srand((int)time(0) * 10);
beeSpeed = (rand() % 200) + 200;
// Задаем высоту полета пчелы
srand((int)time(0) * 10);
float height = (rand() % 1350) + 500;
spriteBee.setPosition(2000, height);
beeActive = true;
}
else
// Движение пчелы
Разберем этот код:
zzсначала мы проверяем, истекло ли время, с помощью условия if(timeRe
maining <= 0.0f);
zzзатем ставим игру на паузу, установив значение paused в true. Это будет последний раз, когда выполняется блок кода с обновлением сцены (пока игрок
снова не нажмет Enter);
zzдалее мы изменяем сообщение messageText, вычисляем его новый центр, что-
бы установить точку привязки, и позиционируем его в центре экрана.
Добавление временной шкалы 119
Наконец, нам нужно отрисовать timeBar. В следующем коде нет ничего принципиально нового. Просто обратите внимание, что мы отрисовываем временную
шкалу timeBar после дерева, чтобы она не была частично перекрыта:
// Отрисовка счета
window.draw(scoreText);
// Отрисовка временной шкалы
window.draw(timeBar);
if (paused)
{
// Отрисовка сообщения
window.draw(messageText);
}
// Отображаем все, что было отрисовано
window.display();
Теперь вы можете запустить игру, нажать Enter, чтобы начать, и наблюдать, как
временная шкала плавно уменьшается (рис. 3.3).
Рис. 3.3. Исчезновение временной шкалы
Когда время истечет, игра приостановится, и в центре экрана появится надпись
ВРЕМЯ ВЫШЛО!, как показано на рис. 3.4.
120 Глава 3. Строки в C++, время SFML, пользовательский ввод и HUD
Рис. 3.4. Время вышло
Конечно, вы можете снова нажать Enter, чтобы начать игру заново.
Резюме
В этой главе мы изучили строки, текстовые объекты и шрифты SFML. С их помощью мы смогли вывести текст на экран и создать HUD для игрока. Мы также
использовали класс sstream, который позволяет объединять строки и другие
переменные для отображения счета.
Мы познакомились с классом RectangleShape библиотеки SFML, который предоставляет возможность рисовать прямоугольники. Мы использовали объект этого
класса и несколько тщательно продуманных переменных для создания временной
шкалы, которая показывает игроку, сколько времени у него осталось. Когда мы
реализуем рубку дерева и опасные падающие ветки, которые могут раздавить
героя, индикатор времени позволит усилить напряжение и заставить игрока
действовать быстрее.
В следующей главе мы рассмотрим целый ряд новых возможностей C++, включая циклы, массивы, перечисления, функции и еще одну условную конструкцию.
Это позволит нам управлять движением веток дерева, отслеживать их расположение и реализовывать механику повреждения игрового персонажа.
Часто задаваемые вопросы 121
Часто задаваемые вопросы
В. Мне кажется, что позиционирование спрайтов по их левому верхнему углу
иногда может быть неудобным. Есть ли альтернатива?
О. К счастью, вы можете выбрать, какая точка спрайта будет использоваться для
позиционирования (как начало координат), аналогично тому, как мы делали
с messageText с помощью функции setOrigin.
В. Код становится довольно объемным, и мне сложно уследить, где и что находится. Можно ли с этим что-то сделать?
О. Да. В следующей главе мы рассмотрим первый способ организации кода и повышения его читаемости. Мы разберем его, когда будем изучать функции C++.
Кроме того, познакомимся с новым способом работы с несколькими объектами
и переменными одного типа (например, облаками), когда разберем массивы
в C++.
В. Я не могу загрузить шрифт. Как узнать, что происходит за кулисами? Как
проверить, правильно ли я указал путь к файлу и не ошибся ли в имени файла
шрифта?
О. Мы можем обернуть код загрузки шрифта в условный оператор if и включить
в него код обработки ошибок с помощью cout. Вот пример:
if (!font.loadFromFile("arial.ttf")) {
// Если загрузка не удалась, выводим сообщение об ошибке
cout << "Ошибка при загрузке шрифта!";
}
Теперь, если шрифт не загрузится, выполнение кода продолжится, но в консоль
будет выведено сообщение об ошибке. То же самое можно сделать и для загрузки
текстур:
if (!texture.loadFromFile("texture.png")) {
// Если загрузка не удалась, выводим сообщение об ошибке
cout << "Ошибка при загрузке текстуры!";
}
4
Циклы, массивы,
операторы switch,
перечисления и функции:
реализация игровых механик
Эта глава, возможно, содержит больше информации о C++, чем любая другая
в книге. Она наполнена фундаментальными концепциями, которые значительно
ускорят ваше понимание языка. Мы также рассмотрим сложные темы, которые до
сих пор обходили стороной, такие как функции, игровой цикл и циклы в целом.
Как только мы изучим все необходимые элементы языка C++, мы используем полученные знания для реализации основной игровой механики — движения веток
дерева. К концу главы мы будем готовы к заключительному этапу и завершению
работы над игрой Timber!.
Циклы
Добро пожаловать в мир циклов в C++! Циклы — это общие конструкции программирования, характерные не только для C++, которые позволяют повторять
определенный блок кода несколько раз. Они крайне важны для повышения
эффективности и гибкости нашего кода и игр. Возможно, это одна из ключевых
вещей, делающих компьютеры такими полезными: многократное выполнение
одних и тех же действий, но с разными значениями. В C++ существует несколько
типов циклов, каждый из которых служит определенным целям. В данной главе
мы рассмотрим основные виды циклов, а также относительно недавние обновления языка C++, которые влияют на возможности программирования при работе
с циклами. Очевидный пример, который мы уже видели, — это игровой цикл.
Если убрать весь код, наш игровой цикл выглядит так:
while (window.isOpen())
{
}
Давайте рассмотрим его.
Циклы 123
Цикл while
Цикл while довольно прост. Вспомните операторы if и их выражения, которые
оцениваются либо как true, либо как false. Мы можем использовать точно такую же комбинацию операторов и переменных в условных выражениях наших
циклов while.
Как и в случае с операторами if, если выражение истинно, код выполняется.
Однако разница с циклом while заключается в том, что код внутри него будет
выполняться многократно, потенциально бесконечно, пока условие не станет
ложным. Посмотрите на следующий код:
int numberOfZombies = 100;
while(numberOfZombies > 0)
{
// Игрок убивает зомби
numberOfZombies--;
}
// numberOfZombies уменьшается с каждым новым циклом
// NumberOfZOmbies теперь не больше 0
За пределами цикла while объявляется переменная numberOfZombies типа int
и инициализируется значением 100. Затем начинается цикл while. Его условное
выражение numberOfZombies > 0. Следовательно, цикл while будет продолжаться
до тех пор, пока это условие не станет равным false. Это означает, что код внутри
цикла будет выполнен 100 раз.
На первой итерации numberOfZombies равно 100, затем 99, затем 98 и т. д. Но как
только numberOfZOmbies станет равным нулю, условие уже не будет выполняться, программа выйдет из цикла while и продолжит работу после закрывающей
фигурной скобки.
Как и в случае с оператором if, цикл while может не выполниться ни разу. Рассмотрим следующий пример:
int availableCoins = 10;
while(availableCoins > 10)
{
// Некий код здесь
// Не будет выполняться, если переменная availableCoins не больше 10
}
Здесь условие цикла оценивается как false, потому что availableCoins не больше 10. Поэтому цикл не выполняется ни разу.
124 Глава 4. Циклы, массивы, операторы switch, перечисления и функции
Более того, нет никаких ограничений на сложность выражения или количество
кода внутри тела цикла. Мы уже поместили довольно много кода в наш игровой
цикл. Рассмотрим гипотетический вариант игрового цикла:
int playerLives = 3;
int alienShips = 10;
while(playerLives !=0 && alienShips !=0 )
{
// Обработка ввода
// Обновление сцены
// Отрисовка сцены
}
// Продолжаем отсюда, когда playerLives или alienShips равны 0
Предыдущий цикл while будет выполняться до тех пор, пока либо playerLives,
либо alienShips не станет равным нулю. Как только одно из этих событий наступит, выражение примет значение false и программа продолжит выполняться
с первой строки кода после цикла while.
Стоит отметить, что, как только тело цикла начинает выполняться, оно попытается завершиться хотя бы один раз, даже если выражение оценивается как ложное,
поскольку условие проверяется перед началом нового прохода. Например:
int x = 1;
while(x > 0)
{
x--;
// x теперь равно 0, поэтому условие ложно
// Но эта строка все еще выполняется
// и эта
// и эта!
}
// А вот теперь все!
Предыдущее тело цикла будет выполнено один раз. Мы также можем создать
бесконечный цикл while:
int y = 0;
while(true)
{
y++; // Больше... Больше...
cout << y;
}
Если приведенный выше цикл вам непонятен, просто воспринимайте его
буквально. Цикл выполняется, пока его условие истинно, то есть равно true.
Циклы 125
Ну а true — это всегда true, поэтому он будет выполняться постоянно. Значение y
будет выводиться на экран при каждом прохождении цикла, увеличиваясь на
единицу с новой итерацией.
ПРИМЕЧАНИЕ
Тем не менее существует ограничение на размер y. Если вы посмотрите на таблицу типов переменных в главе 2, то заметите, что у int есть максимальный размер. На 32- или
64-разрядных компьютерах, а также в зависимости от марки компилятора int обычно
занимает 16 бит данных и может представлять значения от –32 767 до 32 767. В предшествующем коде y достигнет максимума в 32 767, затем станет равным –32 767,
а спустя 32 767 итераций цикла снова вернется к нулю. Вы можете попробовать это,
создав пустое консольное приложение и вставив предыдущий код в функцию main.
Никаких сложных конфигураций SFML не требуется, просто не забудьте поместить
#include <iostream> в начало кода и using namespace std; перед функцией
main, чтобы использовать cout.
Независимо от того, бесконечен цикл или нет, иногда нам нужен способ выйти
из него раньше, чем позволяет условие цикла. Например, игровой цикл, который
отслеживает, мертв ли игрок или противники, — это хорошо, но что, если игрок
просто хочет завершить цикл досрочно? Вот как это можно сделать.
Прерывание цикла
Мы можем использовать бесконечный цикл, чтобы решить, когда прервать цикл
в его теле, а не в выражении. Для этого применяется ключевое слово break, например:
int z = 0;
while(true)
{
z++; // Постепенно увеличиваем значение переменной z
cout << z;
break; // Прерываем цикл
}
// Код не доходит до этого места
В приведенном коде переменная z сначала равна нулю, затем увеличивается
с помощью z++, после чего ее значение выводится с помощью cout. Однако сразу после этого ключевое слово break заставляет код выйти из цикла. Ключевое
слово break оказывает такое действие, даже если за ним следуют другие строки
кода. Что еще более полезно, так это возможность условного использования break,
о чем мы поговорим далее.
126 Глава 4. Циклы, массивы, операторы switch, перечисления и функции
Вы, наверное, догадались, что в циклах while и других типах циклов можно комбинировать любые инструменты принятия решений в C++ (такие как if, else
и switch, который мы скоро изучим):
int x = 0;
int max = 10;
while(true)// Потенциально бесконечный цикл
{
x++; // Постепенно увеличиваем значение переменной z
if(x == max)// Больше не бесконечно
{
break;
}
}
// код выполняется, пока max не достигнет 10
Этот код демонстрирует контролируемое использование бесконечного цикла,
который завершается при определенном условии (x == max). Он применяется,
когда задачу нужно запускать до тех пор, пока не будет выполнено определенное
условие. В данном случае x увеличивается до тех пор, пока не достигнет значения
max, после чего цикл завершается.
В качестве последнего примера цикла while рассмотрим, как пользователь может
определить момент завершения цикла while. Конечно, мы, как разработчики игр,
определяем формат и время выбора игрока. В приведенном ниже коде я также
ввожу новое ключевое слово, cin. Попробуйте понять, что происходит:
int userInput;
while (true)
{
cout << "Введите положительное число для выхода: ";
cin >> userInput;
if (userInput > 0)
{
break;
}
cout << "Неверный ввод. Попробуйте еще раз.";
}
В этом примере используется цикл while для проверки пользовательского ввода.
Цикл продолжается до тех пор, пока пользователь не введет положительное число, а при выполнении условия активируется break для выхода из цикла.
Обработка пользовательского ввода осуществляется с помощью cin, который
приостанавливает выполнение и ждет, пока пользователь введет число, а затем
нажмет клавишу Enter. Обратите внимание, что оператор, используемый с cin,
указывает в другую сторону (>> вместо <<). Он называется оператором извлечения.
Циклы 127
Код постоянно запрашивает ввод у пользователя, а оператор break завершает
цикл при получении правильного (больше нуля) ввода.
ПРИМЕЧАНИЕ
Считается, что ключевое слово break не следует часто применять, поскольку оно
может затруднить понимание кода. Однако не бойтесь использовать его. Иногда,
пытаясь придумать лучшую форму для цикла, я забываю о break, а потом вспоминаю и понимаю, что это именно то, что мне нужно. Хорошее эмпирическое правило — не пытаться с самого начала проектировать с учетом break, а принять его
как допустимое решение, если оно представляется таковым, тем более когда ясной
альтернативы решения не видно.
Если вы хотите опробовать предыдущий пример, скопируйте его в функцию main
существующего или нового консольного приложения. Никаких сложных конфигураций SFML не требуется, просто не забудьте добавить #include <iostream>
и using namespace std; перед функцией main, чтобы иметь возможность использовать cout и cin.
Для понимания, cin — это объект, который облегчает чтение пользовательского
ввода с консоли. В паре с оператором извлечения (>>) cin позволяет нам интер
активно получать данные ввода во время выполнения программы. Если бы вы
хотели написать текстовую приключенческую игру в стиле 1970–1980-х годов,
то cin, cout, циклы, переменные и условия — это почти все, что бы вам понадобилось; cin — это экземпляр класса, объект. Кто-то другой запрограммировал этот
класс, в данном случае класс istream, а мы создали его экземпляр с помощью
cin и использовали его полезные функции, не заботясь о том, как он работает.
Эта концепция класса/экземпляра/объекта станет более понятной, когда мы
обсудим ее в главе 6.
Мы могли бы еще долго рассматривать различные варианты использования цикла while в C++, но в какой-то момент нам захочется вернуться к созданию игр.
Поэтому перейдем к другому типу цикла.
Цикл for
Цикл for в C++ предназначен для случаев, когда нам нужно перебирать диапазон значений. Он обеспечивает лаконичный способ многократного выполнения
набора инструкций.
Типичный цикл for состоит из трех частей: инициализации, условия и шага
итерации, что позволяет легко управлять его выполнением. Цикл for особенно
полезен, когда количество итераций известно заранее.
128 Глава 4. Циклы, массивы, операторы switch, перечисления и функции
Именно из-за этих трех частей синтаксис цикла for немного сложнее, чем у цикла
while, потому что для его создания требуются три компонента. Сначала посмот
рите на код, а затем мы разберем его:
for(int x = 0; x < 100; x ++)
{
// Здесь находится код, который нужно выполнить 100 раз
}
Вот что делают все части условия нашего цикла for:
zzint x = 0; — объявление и инициализация;
zzx < 100; — условие;
zzx ++ — изменение значения на каждой итерации.
В табл. 4.1 описана каждая из трех ключевых частей.
Таблица 4.1. Описание цикла for
Часть
Описание
Объявление и инициализация
Мы создаем новую переменную int x
и инициализируем ее значением 0
Условие
Как и в других циклах, это условие, которое должно
быть равно true для выполнения цикла
Изменение значения на каждом x ++ означает, что на каждом проходе к x
проходе через цикл
прибавляется единица
Таким образом, приведенный выше код цикла for выполняет итерацию 100 раз.
Он инициализирует переменную цикла x нулем, устанавливает условие цикла,
чтобы цикл продолжался до тех пор, пока x меньше 100, и увеличивает x на единицу на каждой итерации. Блок кода внутри цикла, обозначенный фигурными
скобками, представляет собой задачу, которая должна быть выполнена 100 раз.
Это полезно, когда у вас есть код, который должен запускаться повторно определенное количество раз. В данном случае цикл позволяет писать лаконичный
и понятный код для обработки повторяющейся задачи.
Мы можем изменять цикл for для выполнения множества других задач. Вот еще
один простой пример, который ведет обратный отсчет от 10:
for(int i = 10; i > 0; i--)
{
// обратный отсчет
}
// запуск
Массивы 129
Цикл for управляет инициализацией, проверкой условия и самой переменной.
Мы будем использовать цикл for в нашей игре далее в этой главе. У цикла for
есть и более продвинутые варианты применения, но для их обсуждения нам
нужно изучить еще несколько тем. Одно из таких применений мы рассмотрим
в следующем разделе, когда будем говорить о массивах.
Массивы
Массивы — это структуры данных, которые позволяют хранить коллекции
элементов одного типа данных под одним именем, например someInts, myFloats
или zombieHorde. Массивы предоставляют удобный способ организации данных
и управления ими, позволяя более эффективно и структурированно программировать. Массивы особенно полезны для работы с повторяющимися данными,
такими как списки чисел, символов или игровых объектов. В данном разделе
мы рассмотрим основы работы с массивами, а с более сложными способами их
реализации познакомимся позднее.
Если переменную можно представить как ячейку, в которой хранится значение
определенного типа (например, int, float или char), то массив — как ряд ячеек.
Этот ряд может быть практически любого размера и типа, включая объекты,
созданные из классов. Однако все ячейки должны быть одного типа.
СОВЕТ
Ограничение, связанное с необходимостью использовать один и тот же тип в каждой
ячейке, можно обойти. Мы изучим более продвинутые возможности C++ в финальном проекте.
Если вы думаете, что массивы пригодились бы нам при реализации облаков
в главе 2, то вы совершенно правы. Но для облаков уже слишком поздно, им
суждено навсегда остаться громоздким кодом. А вот ветви деревьев мы будем
реализовывать с помощью массивов. Итак, как создать и использовать массив?
Объявление массива
Мы можем объявить массив переменных типа int следующим образом:
int someInts[10];
Теперь у нас есть массив someInts, который может хранить десять значений типа
int. Однако в данный момент он пуст.
130 Глава 4. Циклы, массивы, операторы switch, перечисления и функции
Единственное отличие от обычных переменных заключается в том, что для работы с отдельными значениями массива используется специальный синтаксис,
известный как индексная нотация. Хотя наш массив называется SomeInts, отдельные элементы не имеют индивидуальных имен:
someInts_AliensRemaining = 99; // Неверно
someInts_Score = 100; // Неверно!
Посмотрим, как сделать это правильно.
Инициализация элементов массива
Чтобы добавить значения к элементам массива, мы можем применить уже знакомый нам синтаксис в сочетании с индексной нотацией. В следующем коде мы
сохраняем значение 99 в первом элементе массива:
someInts[0] = 99;
Чтобы сохранить во втором элементе значение 999, мы напишем такой код:
someInts[1] = 999;
Мы можем сохранить значение 3 в последнем элементе следующим образом:
someInts[9] = 3;
Обратите внимание, что элементы массива всегда начинаются с нуля и заканчиваются на значении, равном размеру массива минус один. Мы можем управлять
значениями, хранящимися в массиве, как и обычными переменными.
Вот как мы складываем первый и второй элементы вместе и сохраняем результат
в третьем:
someInts[2] = someInts[0] + someInts[1];
Массивы также могут легко взаимодействовать с обычными переменными, как,
например, в этом случае:
int a = 9999;
someInts[4] = a;
Быстрая инициализация элементов массива
Мы можем быстро добавлять значения к элементам, как в примере ниже, где используется массив типа float:
float myFloatingPointArray[3] {3.14f, 1.63f, 99.0f};
Теперь значения 3.14, 1.63 и 99.0 хранятся в первом, втором и третьем элементах
соответственно. Помните, что для доступа к этим значениям с помощью индексной нотации мы должны использовать индексы [0], [1] и [2].
Массивы 131
Существуют и другие способы инициализации элементов массива. В следующем
немного абстрактном примере показано применение цикла for для заполнения
массива uselessArray значениями от 0 до 9:
for(int i = 0; i < 10; i++)
{
uselessArray[i] = i;
}
В коде предполагается, что массив uselessArray был ранее инициализирован для
хранения как минимум десяти переменных типа int.
Чем массивы полезны для наших игр?
Мы можем использовать массивы везде, где может быть применена обычная
переменная, например в подобном выражении:
// Массив someArray[4] объявлен и инициализирован значением 9999
for(int i = 0; i < someArray[4]; i++)
{
// Цикл выполнится 9999 раз
}
Пожалуй, самое большое преимущество массивов в игровом коде было упо
мянуто в начале раздела. Массивы могут хранить объекты (экземпляры классов). Представьте, что у нас есть класс Zombie и мы хотим хранить целую
кучу таких объектов. Мы могли бы сделать это следующим образом (гипотетически):
Zombie horde [5] {zombie1, zombie2, zombie3}; // и т. д.
Таким образом, массив horde содержал бы множество экземпляров класса Zombie.
Каждый из них — отдельный, живой (в каком-то смысле), дышащий, самоопределяющийся объект Zombie. Затем мы могли бы в каждом проходе игрового цикла
перебирать массив horde, перемещая зомби и проверяя, не встретились ли их
головы с топором или не удалось ли им поймать игрока.
Если бы мы знали о массивах раньше, они идеально подошли бы для работы с нашими облаками. Мы могли бы обзавестись сотней облаков и написать гораздо
меньше кода, чем получилось для трех наших облаков.
СОВЕТ
Чтобы увидеть модифицированный код для облаков, взгляните на улучшенную версию игры Timber! в пакете загрузок в папке Chapter 5. Или вы можете попробовать
самостоятельно реализовать облака с помощью массивов.
132 Глава 4. Циклы, массивы, операторы switch, перечисления и функции
Лучший способ понять, как работают массивы, — увидеть их в действии. И мы
сделаем это, когда будем реализовывать ветви дерева. Но сначала поговорим
об операторах множественного выбора — switch.
Операторы switch
Вы уже познакомились с ключевым словом if, которое позволяет нам решать,
выполнять ли блок кода на основе результата выражения. Однако иногда в C++
решения можно принимать другими способами. Ключевое слово switch часто
используется как элегантная альтернатива серии вложенных операторов if-else.
Как вы увидите, оно позволяет оценить выражение и направить поток выполнения программы.
Когда нам нужно принять решение, основанное на четком списке возможных
результатов, которые не предполагают сложных комбинаций или широкого диапазона значений, обычно лучше использовать switch:
switch(expression)
{
}
// Здесь будет код
В приведенном примере expression может быть как фактическим выражением,
так и просто переменной. Затем внутри фигурных скобок мы можем принимать
решения на основе результата выражения или значения переменной. Это делается с помощью ключевых слов case и break, например:
case x:
// код для x
break;
case y:
// код для y
break;
Как видно в этом абстрактном примере, каждый случай case указывает на возможный результат, а каждое прерывание break обозначает конец этого случая
и точку, в которой процесс выполнения покидает блок switch. Классический
пример — использование дней недели, как показано ниже:
int dayNumber = 3;
switch (dayNumber)
{
case 1:
Операторы switch 133
}
// Что будет в понедельник
break;
case 2:
// Что будет во вторник
break;
// и т. д.
default:
// код для несуществующего дня
В приведенном коде переменной типа int с именем dayNumber присваивается
значение 3 , обозначающее день недели. Условие switch оценивает значение
dayNumber. Каждый case соответствует определенному дню с блоком кода для
каждого из них.
Однако здесь появилось кое-что новое. Мы также можем использовать ключевое слово default без значения, чтобы запустить некоторый код в случае, если
ни одно из выражений case не оценивается как true. Это немного похоже на
ключевое слово else без выражения, следующее за if:
default: // Обратите внимание, что здесь нет значения
// Сделать что-то, если ни одно из утверждений case не является true
break;
В качестве последнего примера использования switch рассмотрим текстовую
приключенческую игру в стиле ретро, в которой игрок вводит букву n, e, s или
w, чтобы двигаться на север, восток, юг или запад. Для обработки каждого возможного пользовательского ввода можно использовать блок switch:
// Обрабатываем пользовательский ввод в переменной command типа char
char command;
cin >> command;
switch(command){
case 'n':
// Обработка движения
break;
case 'e':
// Обработка движения
break;
case 's':
// Обработка движения
break;
case 'w':
// Обработка движения
break;
134 Глава 4. Циклы, массивы, операторы switch, перечисления и функции
// Другие возможные случаи
default:
// Просьба повторить попытку
break;
}
Лучший способ понять все, что рассмотрели относительно switch, — применить
это на практике вместе с другими новыми концепциями, которые мы изучаем.
Но сначала нам нужно разобраться с перечислениями, которые помогают сделать
наш код более точным.
Перечисления
Перечисление — это список всех возможных значений в логической коллекции.
Перечисления в C++ позволяют… перечислять элементы. Например, если в нашей игре используются переменные, которые могут принимать только определенный диапазон значений, и если эти значения логически могут образовывать
коллекцию или множество, то, вероятно, подойдут перечисления. Они сделают
ваш код более понятным и менее подверженным ошибкам. Скажем, в примере
со switch, где используются дни недели, кто решает, какой день недели первый?
А что, если кто-то подумает, что dayNumber — это что-то другое, и произведет над
ним арифметические операции? Внезапно наша система нумерации дней окажется в полном беспорядке. Перечисления, обладающие областью видимости,
решают эту и другие проблемы.
Чтобы объявить такое перечисление в C++, мы используем два ключевых слова
enum class, далее имя перечисления, а затем значения, которые может содержать
перечисление, заключенные в фигурные скобки.
Рассмотрим на примере. Обратите внимание, что обычно возможные значения
из перечисления объявляются в верхнем регистре:
enum class daysOfWeek { MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
SATURDAY, SUNDAY };
Или более интересный пример в возможном сценарии игры:
enum class zombieTypes { REGULAR, RUNNER, CRAWLER, SPITTER, BLOATER };
На данный момент мы не объявили ни одного экземпляра zombieType , только структуру и метаданные самого типа. Если это звучит странно, подумайте
вот о чем: библиотека SFML предоставляет классы Sprite , RectangleShape
и RenderWindow, но, чтобы использовать любой из этих классов, мы должны были
объявить объект или экземпляр класса.
Перечисления 135
Итак, мы создали новый тип под названием zombieTypes, но у нас нет его экземпляров. Исправим это:
zombieType Rishi = zombieTypes::CRAWLER;
zombieType Suella = zombieTypes::SPITTER;
zombieType Boris = zombieTypes::BLOATER;
/*
*/
Зомби — вымышленные существа, и любое сходство с реальными людьми
совершенно случайно.
Далее представлен предварительный просмотр типа кода, который мы скоро добавим в нашу игру. Мы хотим отслеживать, с какой стороны дерева находится
ветка или герой, поэтому объявим перечисление под названием side:
enum class side { LEFT, RIGHT, NONE };
Расположим игрока слева от дерева:
// Игрок начинает слева
side playerSide = side::LEFT;
Мы можем сделать так, чтобы четвертый уровень массива, содержащего позиции
веток, вообще не имел элементов (напоминаю, что нумерация элементов массива
начинается с нуля):
branchPositions[3] = side::NONE;
Полезно также знать, что мы можем использовать перечисления и в выражениях:
if(branchPositions[5] == playerSide)
{
// Самая нижняя ветка находится на той же стороне, что и герой
// ГЕРОЙ РАЗДАВЛЕН!
}
Более того, мы можем использовать перечисления и в switch. Следующий код
поясняет наш предыдущий пример с днями недели:
daysOfWeek day = daysOfWeek::WEDNESDAY;
switch (day) {
case daysOfWeek::MONDAY:
std::cout << "Сегодня понедельник";
break;
case daysOfWeek::TUESDAY:
std::cout << "Сегодня вторник";
break;
case daysOfWeek::WEDNESDAY:
std::cout << "Сегодня среда";
136 Глава 4. Циклы, массивы, операторы switch, перечисления и функции
}
break;
case daysOfWeek::THURSDAY:
std::cout << "Сегодня четверг";
break;
case daysOfWeek::FRIDAY:
std::cout << "Сегодня пятница";
break;
case daysOfWeek::SATURDAY:
std::cout << "Сегодня суббота";
break;
case daysOfWeek::SUNDAY:
std::cout << "Сегодня воскресенье";
break;
default:
std::cout << "ОЙ, попробуйте еще раз.";
В данном примере вместо int используется перечисление daysOfWeek. Оператор
switch оценивает переменную day, и каждый случай case соответствует определенному дню недели. Как и раньше, default обрабатывает любой «неправильный» день, который может встретиться. В этом примере совершенно очевидно,
что блок кода для среды будет выполнен.
Рассмотрим еще одну важную тему C++, а затем вернемся к работе над игрой.
Начало работы с функциями
Функции — это один из фундаментальных строительных блоков программирования на C++. Ранее я говорил, что вы, вероятно, уже достаточно овладели C++,
чтобы написать приключенческую игру в стиле ретро. Теперь, изучив функции,
вы точно сможете это сделать!
Функции позволяют нам оборачивать многократно используемые части кода
и создавать хорошо организованные программы. Оставшаяся часть этой главы
посвящена функциям, начиная с их базового синтаксиса и заканчивая более
продвинутыми концепциями. Вы получите исчерпывающее понимание о том,
как функции и классы связаны. После этого вы будете готовы перейти к теме
объектно-ориентированного программирования.
Что такое функции в C++? Функция — это набор переменных, выражений
и операторов управления потоком выполнения (циклов и ветвлений). Фактически любой код, который мы изучили, можно использовать в функции. Более
того, весь код, который мы написали до этого момента, был в функции main. Бегло
просмотрев код нашего проекта, мы увидим, что у нас сотни строк кода. Вскоре
мы начнем разделять (модулировать) и организовывать (инкапсулировать) весь
будущий код в управляемые фрагменты.
Начало работы с функциями 137
Я рассматривал идею переписать игру Timber! по мере изучения более эффективных подходов, но решил, что лучше оставить это в качестве упражнения для
тех из вас, кто захочет это сделать.
Первая часть функции, которую мы пишем, называется сигнатурой. Вот пример
сигнатуры функции:
public void ShootLazers(int power, int direction)
Если мы добавим открывающую и закрывающую пару фигурных скобок с кодом,
который выполняет функция, то получим полное определение функции:
public void ShootLazers(int power, int direction)
{
// Пиу-пиу!
}
Затем мы можем применить нашу новую функцию из другой части нашего кода,
например следующим образом:
// Атакуем игрока
shootLazers(50, 180) // Выполняем код в функции
// После завершения функции код продолжится отсюда
Когда мы используем функцию, мы говорим, что вызываем ее. В момент вызова
shootLazers выполнение нашей программы переходит к коду, содержащемуся
в этой функции. Функция будет выполняться до тех пор, пока не достигнет конца или пока не получит команду return. Затем выполнение кода продолжится
с первой строки после вызова функции. Мы уже имели дело с функциями SFML.
Отличие заключается в том, что мы научимся писать и вызывать собственные
функции.
Вот еще один пример функции, дополненный кодом, который заставляет функцию вернуться к месту ее вызова:
int addAToB(int a, int b)
{
int answer = a + b;
return answer;
}
Вызов этой функции может выглядеть следующим образом:
int myAnswer = addAToB(2, 4);
Очевидно, что не стоит писать функции для сложения двух переменных, но
такой упрощенный пример помогает лучше понять работу функций. Сначала
138 Глава 4. Циклы, массивы, операторы switch, перечисления и функции
мы передаем значения 2 и 4. В сигнатуре функции значение 2 присваивается
переменной a, а значение 4 — b.
В теле функции переменные a и b суммируются, а результат используется для
инициализации новой переменной int answer. Строка return answer; возвращает
значение, хранящееся в answer, в вызывающий код, в результате чего myAnswer
инициализируется значением 6.
Обратите внимание, что сигнатуры функций в приведенных примерах немного
отличаются друг от друга. Причина в том, что сигнатура функций C++ довольно
гибкая, что позволяет нам создавать именно те функции, которые нам нужны.
Чтобы понять, как именно сигнатура функции определяет способ вызова функции и должна ли она возвращать значение, давайте разобьем ее на части:
тип возвращаемого значения | имя функции | (параметры)
Вот несколько примеров для каждой из этих частей:
zzтип возвращаемого значения: bool, float, int и т. д., а также любой тип или
выражение C++;
zzимя функции: shootLazers, addAToB и т. д.;
zzпараметры: (int number, bool hitDetected), (int x, int y), (float a, float b).
На этом этапе стоит сделать небольшую паузу и поговорить о дизайне C++, программировании и аппаратном обеспечении.
Кто придумал весь этот странный синтаксис
и почему он именно такой?
Иногда новички в C++ задаются вопросом, почему язык устроен именно так,
и особенно это касается функций (а также ООП). Следует помнить, что синтаксис C++ и функций не был разработан в вакууме. Он был спроектирован с учетом
того, как работает компьютерная система (в частности, процессор).
Как мы уже узнали, в C++ функции помогают нам организовывать и модулировать наш код. Вызов функции происходит за несколько шагов.
Когда функция вызывается, управление программой передается данной функции. Процессор выполняет инструкцию перехода по адресу памяти, связанному
с функцией. Этот адрес памяти скрыт от нас, но на самом деле он содержится
в имени функции, которое мы ей присваиваем.
Далее выполняется этап, называемый прологом функции, который включает в себя
настройку стекового фрейма функции. Он полностью скрыт от программистов,
Начало работы с функциями 139
но это часть того, как процессор обрабатывает вызовы. Здесь хранится текущее
состояние вызывающей функции, часто main, включая адрес возврата и значения
важных регистров процессора, в которых хранятся значения.
На этом этапе наши переменные и параметры функции размещаются в стеке.
Стек (stack) — это область памяти внутри процессора, используемая для динамического хранения информации о вызовах функций, локальных переменных
и данных управления потоком. Параметры функции обычно передаются через регистры процессора или помещаются в стек. Переменные внутри самой функции,
известные как локальные переменные, создаются в стеке и инициализируются.
Далее выполняется код в теле вызванной функции и происходит обращение
к локальным переменным и параметрам.
Перед возвратом из функции выполняется эпилог функции. Эпилог функции —
это набор инструкций, обычно включающий освобождение стекового фрейма
функции и восстановление сохраненного состояния вызывающей функции.
Стековый фрейм освобождается, очищая место для локальных переменных
и параметров. Сохраненное состояние вызывающей функции восстанавливается,
включая адрес возврата.
После эпилога процессор выполняет инструкцию return, передавая управление
обратно вызывающей функции. Возвращаемое функцией значение (если оно
есть) сохраняется в заранее определенном регистре.
Указатель стека — это регистр, который отслеживает вершину стека. Во время
вызова функций указатель стека корректируется для выделения и освобождения
места для локальных переменных и параметров. Это важно, потому что вы можете
вызвать функцию, которая вызывает другую функцию, и так далее. Фактически
большинство сложных приложений, включая игры, будут иметь множество
функций в стеке.
Стек работает по принципу LIFO (Last In, First Out), то есть последний элемент,
помещенный в стек, извлекается первым. Лучшая аналогия, которую я слышал
для визуализации стека, — это стопка тарелок на столе, из которой можно взять
только верхнюю тарелку. Вы всегда можете добавить новую тарелку, но, чтобы
добраться до тарелки в нижней части стопки, каждую тарелку нужно снимать
по отдельности.
Таким образом, когда вызывается функция, процессор использует стек для
управления локальными переменными функции и ее параметрами. Указатель
стека ведет на его вершину, а пролог и эпилог функции выполняют настройку
и очистку стека. Этот процесс позволяет эффективно выполнять множество
вызовов вложенных функций. Понимание взаимодействия между функциями
140 Глава 4. Циклы, массивы, операторы switch, перечисления и функции
и процессором, надеюсь, поможет нам оценить все доработки и улучшения, которые произошли с C++ за полвека, и не относиться слишком критично к синтаксису, который мы вынуждены изучать. Он такой не просто так.
Необязательно понимать, как работает центральный процессор, даже не обязательно знать все вышеописанное, но помните, что C++ — это результат полувековой очень тщательной и продуманной эволюции с начала 1970-х годов,
когда разрабатывался язык программирования C. Это может помочь принять
тот факт, что лучшего способа, вероятно, не существует и что все кажущиеся
несовершенства — это необходимость для эффективного управления великим
чудом современности — центральным процессором. Со временем, если вы продолжите изучать C++, станет очевидно, почему все устроено именно так. Хотя
это необязательно, знание компьютерного оборудования, такого как CPU и GPU,
будет полезно.
Теперь мы готовы рассмотреть каждую часть функции по очереди.
Типы возвращаемых значений функций
Тип возвращаемого значения, как следует из названия, — это тип значения, которое будет возвращено из функции в вызывающий код:
int addAToB(int a, int b){
int answer = a + b;
return answer;
}
В нашем немного скучном, но полезном примере addAtoB тип возвращаемого
значения в сигнатуре — int. Функция addAToB отправляет обратно (возвращает)
вызвавшему ее коду значение, которое помещается в переменную int. Тип возвращаемого значения может быть любым типом, доступным в C++.
Однако функция может вообще не возвращать значение. В этом случае в сигнатуре в качестве возвращаемого типа должно применяться ключевое слово void.
Когда используется ключевое слово void, код в теле функции не пытается вернуть
значение, так как это приведет к ошибке. Однако можно задействовать ключевое
слово return без значения. Вот комбинации типов возвращаемых значений и использования ключевого слова return, которые являются допустимыми:
void doWhatever(){
// наш код
// Я закончил возвращение к вызывающему коду
// возврат не требуется
}
Начало работы с функциями 141
Другой вариант:
void doSomethigCool(){
// наш код
}
// Я могу выполнить его, если не буду пытаться использовать значение
return;
В следующем коде показаны еще примеры возможных функций. Обязательно
читайте комментарии, а также код:
void doYetAnotherThing(){
// какой-то код
if(someCondition){
}
// Если someCondition истинно, возвращаемся к вызывающему коду,
// прежде чем дойдем до конца тела функции
return;
// Больше кода, который может быть выполнен, а может и нет
return;
}
//
//
//
//
Поскольку я нахожусь в конце тела функции
и тип возвращаемого значения — void,
я действительно необязателен, но полагаю,
что я понятнее показываю, что функция завершена
bool detectCollision(Ship a, Ship b){
// Определяем, произошла ли коллизия
if(collision)
{
// Бам!
return true;
}
else
{
// Промах
return false;
}
}
Последний пример функции detectCollision дает нам представление о ближайшем будущем нашего кода на C++ и демонстрирует, что мы также можем
передавать пользовательские типы данных, называемые объектами, в функции
для выполнения над ними вычислений.
142 Глава 4. Циклы, массивы, операторы switch, перечисления и функции
Мы можем вызывать каждую из приведенных выше функций по очереди, например, так:
// Пришло время вызвать несколько функций
doWhatever();
doSomethingCool();
doYetAnotherThing();
if (detectCollision(milleniumFalcon, lukesXWing))
{
// Джедаи обречены!
// Но всегда есть Лея.
// Если только она не была на "Соколе"?
}
else
{
// Жить, чтобы сражаться еще один день
}
// Продолжаем выполнение кода отсюда
Не обращайте внимания на странный синтаксис функции detectCollision ,
мы скоро увидим реальный код, подобный этому. По сути, мы используем возвращаемое значение (true или false) в качестве выражения непосредственно
в операторе if.
Более того, функции можно сложить в стек процессора, если переписать их следующим образом. Я удалил часть кода, например комментарии, и выбрал новые
фрагменты.
Первый — гипотетическая функция main:
int main()
{
// Вызываем doWhatever
doWhatever()
return 0;
}
А вот новая версия doWhatever:
void doWhatever(){
// Вызываем doSomethingCool
doSomethingCool();
}
Следом doSomethingCool:
void doSomethigCool(){
// Вызываем doYetAnotherThing
doYetAnotherThing();
}
return;
Начало работы с функциями 143
И наконец, новая doYetAnotherThing:
void doYetAnotherThing(){
if(someCondition){
return;
}
return;
}
В моем сценарии функция main вызывает doWhatever, которая вызывает doSome
thingCool , а та, в свою очередь, — doYetAnotherThing . На этом этапе все четыре функции, включая main , будут существовать в стеке процессора. Когда
doYetAnotherThing завершится и пройдет через процесс эпилога, она будет удалена
из стека, а управление вернется к doSomethingCool. Затем в стеке останутся только
три функции. Когда код doSomethingCool будет выполнен, она тоже удаляется и так
далее, пока в стеке не останется только main. В конце концов main достигнет своего
оператора return и будет удалена из стека, а наша программа выгрузится из памяти.
ПРИМЕЧАНИЕ
Если функция содержит цикл, то он тоже окажется в стеке. Все выполняется по
тому же принципу LIFO, пока не будет достигнут оператор return. Текущая выполняемая функция удаляется, а вызывающая функция продолжает выполнение.
Этого вполне достаточно, чтобы создать отличную игру, так что давайте продолжим.
Имена функций
При написании собственной функции мы можем присвоить ей практически
любое имя. Однако лучше всего использовать слова, обычно глаголы, которые
четко объясняют, что будет делать функция:
void functionaroonieboonie(int blibbityblob, float floppyfloatything)
{
// код
}
Вышеприведенный вариант вполне корректен и будет работать, но следующие
названия функций гораздо понятнее:
void doSomeVerySpecificTask()
{
// код
}
144 Глава 4. Циклы, массивы, операторы switch, перечисления и функции
int getMySpaceShipHealth()
{
// код
}
void startNewGame()
{
// код
}
Теперь рассмотрим, как передавать значения в функцию.
Параметры функции
Мы знаем, что функция может возвращать результат вызывающему коду. А что,
если нам нужно передать в функцию некоторые данные из вызывающего кода?
В этом нам помогут параметры. Мы уже встречали примеры параметров при
рассмотрении типов возвращаемых значений. Рассмотрим тот же пример, но
подробнее:
int addAToB(int a, int b)
{
int answer = a + b;
return answer;
}
В представленном коде параметрами являются int a и int b. Обратите внимание,
что в первой строке тела функции мы используем a + b, как если бы эти переменные уже были объявлены и инициализированы. Так оно и есть. Указание
параметров в сигнатуре функции означает их объявление, а код, вызывающий
функцию, их инициализирует.
СОВЕТ
Заметьте, что переменные в скобках сигнатуры функции (int a, int b) мы называем параметрами. Когда мы передаем в функцию значения из вызывающего кода,
эти значения называются аргументами. Когда аргументы поступают в функцию, они
используются параметрами для инициализации реальных, пригодных для использования переменных: int returnedAnswer = addAToB(10,5).
Кроме того, как мы уже частично видели в предыдущих примерах, мы не обязаны использовать в параметрах только int, допускается любой тип данных C++.
Мы также можем использовать столько параметров, сколько необходимо для
решения нашей задачи, но хорошая практика — делать список параметров как
можно короче и, следовательно, более управляемым.
Начало работы с функциями 145
Как вы увидите в последующих главах, мы оставили несколько более интересных
вариантов использования функций за рамками этого вводного урока, чтобы сначала изучить смежные концепции C++, прежде чем углубляться в тему функций.
Тело функции
Тело функции — это та часть, которую мы до сих пор заменяли комментариями
вроде:
// код здесь
// какой-то код
Но на самом деле мы уже точно знаем, что здесь нужно делать! Любой код на
C++, с которым мы познакомились, будет работать в теле функции.
Далее мы рассмотрим концепцию прототипов функций.
Прототипы функций
Вы узнали, как писать функции и как их вызывать. Однако есть еще одна вещь,
которую нам нужно сделать, чтобы все работало. Каждая функция должна иметь
прототип. Прототип — это то, что позволяет компилятору узнать о нашей функции. Без прототипа вся игра не скомпилируется. К счастью, прототипы очень
просты.
Мы можем просто повторить сигнатуру функции, дополненную точкой с запятой.
Важно отметить, что прототип должен появляться перед любой попыткой вызвать
или определить функцию. Итак, самый простой пример полностью работоспособной функции выглядит следующим образом (внимательно посмотрите на
комментарии и расположение различных частей функции):
// Прототип
// Обратите внимание на точку с запятой в конце
int addAToB(int a, int b);
int main()
{
// Вызов функции
// Сохраняем результат в переменной answer
int answer = addAToB(2,2);
// Функция вызывается перед определением,
// но это нормально благодаря прототипу
// Выход из main
return 0;
} // Конец main
146 Глава 4. Циклы, массивы, операторы switch, перечисления и функции
// Определение функции
int addAToB(int a, int b)
{
return a + b;
}
Вот что демонстрирует предыдущий код:
zzпрототип находится перед функцией main;
zzвызов функции, как и следовало ожидать, находится внутри функции main;
zzопределение функции находится после/вне функции main.
ПРИМЕЧАНИЕ
Обратите внимание, что мы можем опустить прототип функции и сразу перейти к ее
определению, если оно приводится до использования функции. Однако по мере того,
как наш код начнет становиться длиннее и распределяться по нескольким файлам,
так почти никогда не будет. Мы будем постоянно использовать отдельные прототипы
и определения.
Посмотрим, как можно упорядочить наши функции.
Организация функций
Стоит отметить, что если у нас есть несколько функций, особенно если они
довольно длинные, то наш .cpp файл быстро станет громоздким. Это противоречит одной из целей, для которых предназначены функции. Решение, которое
мы увидим в следующем проекте (в главе 6), заключается в том, что мы сможем
добавить все прототипы наших функций в собственный заголовочный файл
(.hpp или .h), затем написать все наши функции в другом .cpp-файле, а потом
просто добавить еще одну директиву #include... в основной .cpp-файл. Таким
образом, мы можем использовать любое количество функций, не добавляя их код
(прототип или определение) в основной файл.
Область видимости функций
При обсуждении стека процессора мы упоминали идею локальных переменных.
Она связана с темой области видимости функции или переменной. Если мы объявляем переменную в функции либо напрямую, либо в одном из параметров, ее
нельзя использовать за пределами функции — она невидима для остальной части
кода. Более того, любые переменные, объявленные в других функциях, не могут
быть применены внутри функции. В конце концов, они находятся в совершенно
другом стековом фрейме в стеке процессора.
Начало работы с функциями 147
Обмен значениями между кодом функции и вызывающим кодом осуществляется
через параметры (аргументы) и возвращаемое значение.
Когда переменная недоступна, потому что она принадлежит другой функции,
говорят, что она находится вне области видимости.
ПРИМЕЧАНИЕ
Переменные, объявленные внутри любого блока в C++, имеют область видимости
только в пределах этого блока! Это касается и циклов, и блоков if. Переменная, объявленная в начале main, находится в области видимости в любом месте main. Переменная, объявленная в игровом цикле, находится в области видимости только в пределах
игрового цикла и т. д. Переменная, объявленная внутри функции или другого блока,
называется локальной переменной. Чем больше кода мы напишем, тем понятнее это
станет. Каждый раз, когда мы будем сталкиваться в нашем коде с проблемой, связанной с областью видимости, я буду обсуждать ее, чтобы прояснить ситуацию. Одна
из них будет рассмотрена в следующем разделе. Кроме того, есть особенности C++,
которые полностью раскрывают эту тему. Они называются ссылками и указателями,
и мы узнаем о них в главах 9 и 10 соответственно.
И еще немного о функциях
Мы могли бы поговорить о функциях еще, но мы уже знаем достаточно, чтобы реа
лизовать следующую часть нашей игры. И не волнуйтесь, если все технические
термины, такие как параметры, сигнатуры, определения и т. д., еще не до конца
усвоены. Понятия станут яснее, когда мы начнем их применять.
Кроме того, вы, вероятно, заметили, что мы вызывали функции, в том числе
SFML-функции, добавляя имя объекта и точку перед именем функции, как, например, здесь:
spriteBee.setPosition...
window.draw...
// и т. д.
И все же наше обсуждение функций сводилось к тому, что мы вызывали их без
каких-либо объектов. Мы можем писать функции как часть класса или просто
как отдельную функцию, как мы уже видели в данной главе. Когда мы пишем
функцию как часть класса, нам нужен объект этого класса для вызова функции,
а когда у нас есть отдельная функция, нам это не нужно.
Вскоре мы напишем такую функцию, а классы с функциями будем писать
в главе 6. Все, что мы до сих пор знали о функциях, актуально в обоих случаях.
Меняется только контекст.
148 Глава 4. Циклы, массивы, операторы switch, перечисления и функции
Наконец, мы воспользуемся полученными знаниями, чтобы «вырастить» ветви
на нашем дереве.
Создаем ветки
Теперь, как я и обещал на последних 20 страницах, мы применим все новые
методы C++ — циклы, массивы, перечисления и функции, — чтобы нарисовать
и анимировать ветви на нашем дереве.
Добавьте следующий код вне функции main, то есть перед int main():
#include <sstream>
#include <SFML/Graphics.hpp>
using namespace sf;
// Объявление функции
void updateBranches(int seed);
const int NUM_BRANCHES = 6;
Sprite branches[NUM_BRANCHES];
// Где находится герой/ветка?
// Слева или справа
enum class side { LEFT, RIGHT, NONE };
side branchPositions[NUM_BRANCHES];
int main()
{
С помощью этого нового кода мы достигли нескольких целей.
zzСначала мы написали прототип функции под названием updateBranches .
Мы видим, что она не возвращает значение (void) и принимает аргумент int
под названием seed. Скоро мы напишем определение функции и увидим, что
именно она делает.
zzДалее мы объявляем константу типа int с именем NUM_BRANCHES и инициализируем ее значением 6. На дереве будет шесть движущихся ветвей, и вскоре
мы увидим, как NUM_BRANCHES нам пригодится.
zzЗатем мы объявляем массив объектов Sprite под названием branches, который
может хранить шесть экземпляров Sprite.
zzПосле этого мы объявляем новое перечисление side с тремя возможными
значениями: LEFT, RIGHT и NONE. Оно будет использоваться для описания по-
ложения отдельных веток, а также игрового персонажа в нескольких местах
нашего кода.
Создаем ветки 149
zzНаконец, мы инициализировали массив типов side размером NUM_BRANCHES
(6), то есть у нас будет массив branchPositions с шестью значениями в нем.
Каждое из этих значений имеет тип side и может содержать значения LEFT,
RIGHT или NONE.
ПРИМЕЧАНИЕ
Вам наверняка интересно, почему константа, два массива и перечисление были
объявлены вне функции main. Объявив их до main, мы сделали их глобальными.
Другими словами, они имеют область видимости для всей игры. Это означает, что мы
можем обращаться к ним и задействовать их в любом месте функции main и функции
updateBranches. Хорошей практикой является делать переменные как можно более локальными по отношению к месту их использования. Может показаться полезным сделать
все глобальным, но это приводит к трудночитаемому и подверженному ошибкам коду.
Подготовка веток
Теперь мы подготовим наши шесть объектов Sprite и загрузим их в массив branches.
Добавьте выделенный код непосредственно перед нашим игровым циклом:
// Позиционируем текст
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top +
textRect.height / 2.0f);
messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);
scoreText.setPosition(20, 20);
// Подготавливаем пять ветвей
Текстура textureBranch;
textureBranch.loadFromFile("graphics/branch.png");
// Устанавливаем текстуру для каждого спрайта ветки
for (int i = 0; i < NUM_BRANCHES; i++) {
branches[i].setTexture(textureBranch);
branches[i].setPosition(-2000, -2000);
}
// Задаем точку привязки спрайта
// Это позволит вращать его, не меняя его положения
branches[i].setOrigin(220, 20);
while (window.isOpen())
{
150 Глава 4. Циклы, массивы, операторы switch, перечисления и функции
В приведенном выше коде сначала мы объявляем объект Texture библиотеки
SFML и загружаем в него изображение branch.png.
Далее мы создаем цикл for, который устанавливает i в ноль и увеличивает i на
единицу при каждом проходе через цикл, пока i не станет меньше NUM_BRANCHES.
Мы сделали так, потому что NUM_BRANCHES равно 6, а массив branches имеет позиции с 0 по 5.
Внутри цикла for мы устанавливаем текстуру Texture для каждого спрайта
Sprite в массиве branches с помощью setTexture, а затем прячем его за пределами
экрана с помощью setPosition.
Наконец, с помощью setOrigin мы устанавливаем точку привязки (она используется для определения местоположения спрайта при его рисовании) в центр
спрайта. Вскоре мы будем вращать эти спрайты, и расположение начала координат в центре означает, что они будут хорошо вращаться, не смещаясь со своей
позиции.
Покадровое обновление спрайтов веток
В следующем коде мы устанавливаем положение всех спрайтов в массиве branches, основываясь на их положении в массиве и значении side в со
ответствующем массиве branchPositions. Добавьте следующий выделенный
код:
// Обновление текста счета
std::stringstream ss;
ss << "Score: " << score; scoreText.setString(ss.str());
// Обновление спрайтов веток
for (int i = 0; i < NUM_BRANCHES; i++)
{
float height = i * 150;
if (branchPositions[i] == side::LEFT)
{
// Перемещаем спрайт в левую сторону
branches[i].setPosition(610, height);
// Переворачиваем спрайт в другую сторону
branches[i].setRotation(180);
}
else if (branchPositions[i] == side::RIGHT)
{
// Перемещаем спрайт в правую сторону
branches[i].setPosition(1330, height);
Создаем ветки 151
// Переворачиваем спрайт в другую сторону
branches[i].setRotation(0);
}
else
{
}
}
// Скрываем ветку
branches[i].setPosition(3000, height);
} // Конец блока if(!paused)
/*
***********************
Отрисовка сцены
***********************
/*
Код, который мы только что добавили, представляет собой один большой цикл
for, который устанавливает i в ноль и увеличивает i на единицу каждый раз,
когда проходит через цикл, и так до тех пор, пока i не станет меньше 6.
Внутри цикла for новая переменная float с именем height устанавливается в значение i * 150. Это означает, что первая ветка будет иметь высоту 0, вторая — 150, а шестая — 750.
Далее у нас есть структура из блоков if и else. Посмотрите на структуру без кода:
if()
{
}
else if()
{
}
else
{
}
Первое условие if использует массив branchPositions, чтобы определить, должна ли текущая ветка находиться слева. Если да, то он устанавливает соответствующий спрайт из массива branches в позицию на экране, соответствующую левому
краю (610 пикселей) и текущей высоте height. Затем он поворачивает спрайт на
180 градусов, потому что изображение branch.png по умолчанию «висит» справа.
Блок else if выполняется только в том случае, если ветка не находится слева.
Затем эта часть кода использует тот же метод, чтобы проверить, находится ли
ветка справа. Если да, то она отрисовывается справа (1330 пикселей). Затем
ориентация спрайта устанавливается на 0 градусов на тот случай, если до этого
152 Глава 4. Циклы, массивы, операторы switch, перечисления и функции
она была на 180 градусов. Если координата X кажется немного странной, просто
вспомните, что мы установили точку привязки для спрайтов веток в их центр.
В последнем блоке else предполагается, что текущее положение branchPosition
должно быть NONE и скрывает ветку за пределами экрана на расстоянии 3000 пикселей.
В этот момент наши ветки находятся на своих местах и готовы к отрисовке.
Отрисовка веток
Здесь мы используем еще один цикл for , чтобы пройти по всему массиву
branches от 0 до 5 и отрисовать спрайт каждой ветки. Добавьте следующий выделенный код:
// Отрисовка облаков
window.draw(spriteCloud1);
window.draw(spriteCloud2);
window.draw(spriteCloud3);
// Отрисовка веток
for (int i = 0; i < NUM_BRANCHES; i++)
{
window.draw(branches[i]);
}
// Отрисовка дерева
window.draw(spriteTree);
Конечно, мы еще не написали функцию, которая перемещает все ветки. Как
только мы напишем ее, нам также нужно будет решить, когда и как ее вызывать.
Перемещение веток
Мы уже добавили прототип функции перед функцией main. Теперь напишем
фактическое определение функции, которая будет перемещать все ветки вниз на
одну позицию при каждом вызове. Разделим эту функцию на две части, чтобы
было легче ее понять.
Добавьте первую часть функции updateBranches после закрывающей фигурной
скобки функции main:
// Определение функции
void updateBranches(int seed)
{
// Переместите все ветки вниз на одно место
for (int j = NUM_BRANCHES-1; j > 0; j--) {
branchPositions[j] = branchPositions[j - 1];
}
}
Создаем ветки 153
Здесь мы просто перемещаем все ветви вниз на одну позицию, начиная с шестой ветки. Это достигается за счет того, что цикл for считает от 5 до 0. Код
branchPositions[j] = branchPositions[j - 1]; выполняет фактическое перемещение.
Еще один момент, который следует отметить: после того как мы переместили ветку
из позиции 4 в позицию 5, затем из позиции 3 в позицию 4 и так далее, нам нужно
будет добавить новую ветку в позицию 0, которая находится на вершине дерева.
Теперь можем сгенерировать новую ветвь на вершине дерева. Добавьте выделенный код в функцию updateBranches, а затем мы поговорим о ней:
// Определение функции
void updateBranches(int seed)
{
// Перемещаем все ветки вниз на одно место
for (int j = NUM_BRANCHES-1; j > 0; j--) {
branchPositions[j] = branchPositions[j - 1];
}
// Создаем новую ветку в позиции 0
// LEFT, RIGHT или NONE
srand((int)time(0)+seed);
int r = (rand() % 5);
}
switch (r) {
case 0:
branchPositions[0] = side::LEFT;
break;
case 1:
branchPositions[0] = side::RIGHT;
break;
default:
branchPositions[0] = side::NONE;
break;
}
В заключительной части функции updateBranches мы используем целочисленную
переменную seed, которая передается вместе с вызовом функции. Это делается
для того, чтобы гарантировать, что зерно для генерации случайных чисел всегда
будет разным. В следующей главе вы увидим, как это значение получается.
Далее мы генерируем случайное число от 0 до 4 и сохраняем результат в переменной целочисленной переменной r. Теперь мы используем switch с r в качестве
выражения.
Выражения case означают, что если r равно 0, то мы добавляем новую ветвь
слева в верхней части дерева. Если r равно 1, ветка добавляется справа. Если r
равно чему-то другому (2, 3 или 4), то default гарантирует, что на вершину
дерева не будет добавлено ни одной ветви. Благодаря такому балансу между
154 Глава 4. Циклы, массивы, операторы switch, перечисления и функции
левой, правой ветками и отсутствием ветки дерево выглядит реалистично (для
видеоигры), а игра работает стабильно. Вы можете легко изменить код, чтобы
ветки появлялись чаще или реже.
Несмотря на весь этот код для веток, мы все еще не видим ни одну из них в игре.
Это связано с тем, что нам предстоит проделать еще много работы, прежде чем
мы сможем вызвать updateBranches.
Если вы хотите увидеть ветку прямо сейчас, добавьте временный код и вызовите
функцию пять раз с уникальным значением зерна непосредственно перед циклом
игры:
updateBranches(1);
updateBranches(2);
updateBranches(3);
updateBranches(4);
updateBranches(5);
while (window.isOpen())
{
Теперь вы можете увидеть ветки (рис. 4.1). Но чтобы они двигались, нам нужно
вызвать updateBranches.
Рис. 4.1. Ветви на дереве
СОВЕТ
Не забудьте удалить временный код, прежде чем продолжить.
Часто задаваемые вопросы 155
Резюме
В этой главе мы рассмотрели различные типы циклов, такие как for и while.
Изучили массивы для работы с большим количеством переменных и объектов.
Мы также познакомились с перечислениями и оператором switch. Но, вероятно,
больше всего времени мы уделили функциям, которые позволяют нам организовывать и абстрагировать код нашей игры. По мере изучения языка С++ мы будем
более подробно рассматривать функции.
Теперь, когда у нас есть полностью «рабочее» дерево, мы можем приступить
к финальной стадии разработки игры. Вот несколько вопросов, которые могут
у вас возникнуть.
Часто задаваемые вопросы
В. Чем отличается цикл for от цикла while в C++?
О. И for, и while в C++ используются для повторения, но цикл for состоит из
трех частей (инициализация, условие и шаг итерации) и обычно применяется,
когда количество итераций известно заранее. Цикл while, напротив, более гибкий
и используется, когда количество итераций неизвестно.
В. Могут ли функции в C++ возвращать несколько значений?
О. Нет, функция в C++ может напрямую возвращать только одно значение.
Однако допускается симулировать несколько значений, используя параметры,
передаваемые по ссылке или указателю. Подробнее об этом — в следующих
главах.
В. Расскажите вкратце, как стек процессора связан с вызовами функций и цик
лами в C++.
О. Стек — это область памяти, используемая для управления вызовами функций, локальными переменными и потоком в C++. Вызовы функций и циклы
включают в себя выделение и освобождение места в стеке для хранения информации, такой как локальные переменные, параметры и адрес возврата. Эти знания
не являются необходимыми для наших целей, но общее представление о стеке
помогает понять некоторые иначе необъяснимые конструкции, в частности синтаксис функций из этой главы.
В. Когда следует использовать перечисления в C++?
О. Перечисления полезны, когда нужно представить набор именованных
константных значений. Они улучшают читаемость кода и помогают предотвратить использование недопустимых значений и операций. Перечисления
иногда применяются для опций меню в играх или, как в примере из этой главы,
156 Глава 4. Циклы, массивы, операторы switch, перечисления и функции
для дней недели. Если вы видите значение WEDNESDAY, то становится понятно,
что оно обозначает, в то время как значение 3 может определять что угодно,
вплоть до количества пальцев на ногах симпатичного млекопитающего, лаза
ющего по деревьям.
В. Как избежать нежелательного бесконечного цикла в C++?
О. Убедитесь, что условие цикла имеет возможность стать ложным. Например,
в цикле for удостоверьтесь, что условие в итоге будет равно false. В цикле while
убедитесь, что переменная цикла обновляется или что при выполнении определенного условия выполняется оператор break.
5
Коллизии, звук и условия
завершения игры:
приводим игру в состояние,
чтобы в нее можно было
полноценно играть
Это заключительный этап первого проекта. К концу главы у вас будет первая
полноценная игра. Как только вы закончите работу над игрой и запустите ее,
обязательно прочитайте последний раздел этой главы, поскольку в нем будут
предложены способы улучшить игру.
В данной главе мы будем повторно использовать уже изученные концепции C++,
а также рассмотрим SFML.
Подготовка игрового персонажа
и других спрайтов
Добавим код для спрайта игрового персонажа (нашего героя игры), а также еще
несколько спрайтов и текстур одновременно. Следующий код добавляет спрайт
надгробия для случая, когда героя раздавит веткой, спрайт топора и спрайт бревна, которое отлетает каждый раз, когда игрок рубит.
Обратите внимание, что после объекта spritePlayer мы также объявляем переменную типа side с именем playerSide, чтобы отслеживать, где в данный момент
находится герой. Кроме того, мы добавляем несколько дополнительных переменных для объекта spriteLog, включая logSpeedX, logSpeedY и logActive, чтобы
хранить значение скорости движения бревна и проверять, движется ли оно в данный момент. У спрайта топора spriteAxe также есть две связанные константные
переменные float, чтобы помнить идеальные позиции пикселя слева и справа.
Добавьте следующий код непосредственно перед while(window.isOpen()), как мы
уже часто делали ранее. Обратите внимание, что в нижеприведенном листинге
весь код новый, а не только выделенный. Я не предоставил дополнительного контекста для этого блока, так как код while(window.isOpen()) можно легко найти.
Выделенный код — это тот, который мы только что обсудили.
158 Глава 5. Коллизии, звук и условия завершения игры
Добавьте весь этот код непосредственно перед строкой while(window.isOpen())
и мысленно запомните выделенные строки, которые мы вкратце обсудили. Это
облегчит понимание остального кода главы:
// Подготовка игрового персонажа
Texture texturePlayer;
texturePlayer.loadFromFile("graphics/player.png");
Sprite spritePlayer;
spritePlayer.setTexture(texturePlayer);
spritePlayer.setPosition(580, 720);
// Игрок начинает слева
side playerSide = side::LEFT;
// Подготовка надгробия
Texture textureRIP;
textureRIP.loadFromFile("graphics/rip.png");
Sprite spriteRIP;
spriteRIP.setTexture(textureRIP);
spriteRIP.setPosition(600, 860);
// Подготовка топора
Texture textureAxe;
textureAxe.loadFromFile("graphics/axe.png");
Sprite spriteAxe;
spriteAxe.setTexture(textureAxe);
spriteAxe.setPosition(700, 830);
// Выравниваем топор относительно дерева
const float AXE_POSITION_LEFT = 700;
const float AXE_POSITION_RIGHT = 1075;
// Подготовка отлетающего бревна
Texture textureLog;
textureLog.loadFromFile("graphics/log.png");
Sprite spriteLog;
spriteLog.setTexture(textureLog);
spriteLog.setPosition(810, 720);
// Некоторые другие полезные переменные, связанные с бревном
bool logActive = false;
float logSpeedX = 1000;
float logSpeedY = -1500;
Теперь мы можем отрисовать все наши новые спрайты.
Отрисовка персонажа и других спрайтов
Прежде чем добавлять код для перемещения персонажа и использования всех наших новых спрайтов, давайте их отрисуем. Это нужно для того, чтобы, добавляя код
для их обновления, изменения или перемещения, мы могли видеть, что происходит.
Обработка ввода игрока 159
Добавьте выделенный код, чтобы нарисовать четыре новых спрайта:
// Отрисовка дерева
window.draw(spriteTree);
// Отрисовка персонажа
window.draw(spritePlayer);
// Отрисовка топора
window.draw(spriteAxe);
// Отрисовка отлетающего бревна
window.draw(spriteLog);
// Отрисовка надгробия
window.draw(spriteRIP);
// Отрисовка пчелы
window.draw(spriteBee);
Запустите игру, и вы увидите наши новые спрайты в сцене (рис. 5.1).
Рис. 5.1. Новые спрайты в сцене
Сейчас мы очень близки к созданию полноценно функционирующей игры.
Обработка ввода игрока
От движения игрока зависит множество различных вещей. К ним относятся:
zzкогда показывать топор;
zzкогда начинать анимацию бревна;
zzкогда перемещать все ветки вниз.
160 Глава 5. Коллизии, звук и условия завершения игры
Поэтому имеет смысл настроить обработку нажатий клавиш. Как только это
будет сделано, мы сможем собрать все функции, о которых только что говорили,
в одном блоке кода.
Давайте немного подумаем о том, как мы определяем нажатие клавиш. В каждом
кадре мы проверяем, удерживается ли в данный момент нажатой определенная
клавиша.
Если да, то выполняется действие. Если нажата клавиша Esc, осуществляется
выход из игры, а если Enter — ее перезапуск. Пока этого достаточно для наших
нужд.
Однако при таком подходе возникает проблема, когда мы пытаемся обработать
действия, связанные с рубкой дерева. Эта проблема существовала всегда, просто до сих пор она не имела значения. В зависимости от мощности вашего ПК
игровой цикл может выполняться тысячи раз в секунду. Каждый проход игрового цикла при удержании клавиши будет обнаружен, и соответствующий код
выполнится.
Таким образом, каждый раз, когда вы нажимаете Enter, чтобы перезапустить игру,
вы, скорее всего, перезапускаете ее более ста раз. Это связано с тем, что даже
самое короткое нажатие длится долю секунды. Вы можете убедиться в этом,
запустив игру и удерживая нажатой клавишу Enter. Обратите внимание, что временная шкала не меняется. Дело в том, что игра перезапускается снова и снова,
сотни или даже тысячи раз в секунду.
Если мы не будем использовать другой подход для рубки деревьев, то всего один
удар топором приведет к падению всего дерева. Нам нужно быть немного более
изощренными. Мы позволим игроку рубить, а затем, когда он это сделает, отключим код, который обнаруживает нажатие клавиши. Затем мы отследим момент,
когда игрок убирает палец с клавиши, и снова включим обнаружение нажатия.
Вот четкие шаги.
1. Ждем, пока игрок нажмет какую-либо из клавиш со стрелками, чтобы разрубить бревно.
2. Когда игрок рубит, отключаем обнаружение нажатий.
3. Ждем, пока игрок уберет палец с клавиши.
4. Вновь включаем функцию обнаружения.
5. Повторяем с шага 1.
Это может показаться сложным, но с помощью SFML все будет просто. Давайте
реализуем это сейчас, шаг за шагом.
Добавьте выделенную строку кода — она объявляет переменную типа bool
с именем acceptInput, которая будет использоваться для определения того, когда
«слушать» рубку, а когда игнорировать ее:
Обработка ввода игрока 161
float logSpeedX = 1000;
float logSpeedY = -1500;
// Управление вводом игрока
bool acceptInput = false;
while (window.isOpen())
{
Теперь, когда мы настроили функцию, можно перейти к настройке обработки
начала новой игры.
Настройка начала новой игры
Чтобы мы могли работать с рубкой дерева, добавьте выделенный код в блок if,
который запускает новую игру:
/*
******************************
Обработка ввода игрока
******************************
*/
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
// Старт игры
if (Keyboard::isKeyPressed(Keyboard::Return))
{
paused = false;
// Сброс времени и счета
score= 0;
timeRemaining = 6;
// Убираем все ветки
for (int i = 1; i < NUM_BRANCHES; i++)
{
branchPositions[i] = side::NONE;
}
// Скрываем надгробие
spriteRIP.setPosition(675, 2000);
// Перемещаем героя в начальную позицию
spritePlayer.setPosition(580, 720);
acceptInput = true;
}
162 Глава 5. Коллизии, звук и условия завершения игры
/*
************************
Обновление сцены
************************
*/
В приведенном коде мы используем цикл for, чтобы подготовить дерево без
веток. Это справедливо по отношению к герою, так как, если бы игра началась
с ветки прямо над его головой, это могло бы считаться нечестным. Затем мы просто перемещаем надгробие за пределы экрана, а нашего героя — в его стартовую
позицию слева. Последнее, что делает код, — устанавливает acceptInput в true.
Теперь мы готовы принимать нажатия клавиш.
Обнаружение удара топором
Далее можем подготовиться к обработке нажатий клавиш со стрелками. Добавьте
этот простой блок if, который будет выполняться только в том случае, если переменная acceptInput равна true:
// Старт игры
if (Keyboard::isKeyPressed(Keyboard::Return))
{
paused = false;
// Сброс времени и счета
score = 0;
timeRemaining = 5;
// Убираем все ветки
for (int i = 1; i < NUM_BRANCHES; i++)
{
branchPositions[i] = side::NONE;
}
// Скрываем надгробие
spriteRIP.setPosition(675, 2000);
// Перемещаем героя в начальную позицию
spritePlayer.setPosition(675, 660);
acceptInput = true;
}
// Оборачиваем элементы управления персонажем,
// чтобы убедиться, что мы принимаем ввод
if (acceptInput)
{
// Много кода...
}
Обработка ввода игрока 163
/*
************************
Обновление сцены
************************
*/
Теперь внутри блока if , который мы только что создали, вставьте следу
ющий выделенный код для обработки нажатия игроком клавиши со стрелкой
вправо:
// Оборачиваем элементы управления героем
// чтобы убедиться, что мы принимаем ввод
if (acceptInput)
{
// Много кода...
// Сначала обрабатываем нажатие клавиши со стрелкой вправо
if (Keyboard::isKeyPressed(Keyboard::Right))
{
// Убеждаемся, что персонаж находится справа
playerSide = side::RIGHT;
score ++;
// Добавляем дополнительное время
timeRemaining += (2 / score) + .15;
spriteAxe.setPosition(AXE_POSITION_RIGHT,
spriteAxe.getPosition().y);
spritePlayer.setPosition(1200, 720);
// Обновление веток
updateBranches(score);
// Запускаем бревно влево
spriteLog.setPosition(810, 720);
logSpeedX = -5000;
logActive = true;
}
}
acceptInput = false;
// Обработка нажатия клавиши со стрелкой влево
В этом коде происходит довольно много событий, поэтому разберем его. Сначала
мы определяем, рубил ли игровой персонаж с правой стороны дерева. Если да,
то значение playerSide устанавливается в side::RIGHT. Мы будем обращаться
к значению playerSide позже в коде.
164 Глава 5. Коллизии, звук и условия завершения игры
Затем мы добавляем единицу к счету с помощью score++. Следующая строка
кода немного непонятна, поэтому приводим ее еще раз в качестве напоминания:
timeRemaining += (2 / score) + .15;
На самом деле в ней нет ничего сложного. Попробуйте разобраться самостоятельно, прежде чем читать дальше.
Итак, здесь мы добавляем дополнительное время к оставшемуся с помощью
timeRemaining +=.... Таким образом, мы вознаграждаем игрока за его действия.
Однако проблема для игрока заключается в том, что чем выше становится счет,
тем меньше дополнительного времени добавляется. Все дело в выражении
(2 / score). Вы можете поэкспериментировать с этой формулой, чтобы сделать
игру проще или сложнее.
Затем топор перемещается в правую позицию с помощью spriteAxe.setPosition,
и спрайт игрового персонажа также перемещается в правую позицию.
Далее мы вызываем updateBranches, чтобы переместить все ветки вниз на одну
позицию и сгенерировать новую случайную ветку (или пустое пространство) на
вершине дерева.
Затем спрайт бревна spriteLog перемещается в исходное положение, а переменной logSpeedX присваивается отрицательное число, чтобы бревно отлетало влево.
Кроме того, переменная logActive устанавливается в true, чтобы код, который
мы вскоре напишем, анимировал бревно в каждом кадре.
Наконец, acceptInput устанавливается в false. На этом этапе игрок больше
не может рубить. Мы решили проблему слишком частого обнаружения нажатий,
и вскоре увидим, как снова разрешить рубить дерево.
Теперь внутри блока if(acceptInput) добавьте выделенный код для обработки
нажатия игроком клавиши со стрелкой влево:
// Обработка нажатия клавиши со стрелкой влево
if (Keyboard::isKeyPressed(Keyboard::Left))
{
// Убеждаемся, что персонаж находится слева
playerSide = side::LEFT;
score++;
// Добавляем дополнительное время
timeRemaining += (2 / score) + .15;
spriteAxe.setPosition(AXE_POSITION_LEFT,
spriteAxe.getPosition().y);
spritePlayer.setPosition(580, 720);
Обработка ввода игрока 165
// Обновление веток
updateBranches(score);
// Запускаем бревно влево
spriteLog.setPosition(810, 720);
logSpeedX = 5000;
logActive = true;
}
acceptInput = false;
}
Данный код аналогичен предыдущему, за исключением того, что спрайты расположены иначе, а переменной logSpeedX присваивается положительное значение,
чтобы бревно отлетало вправо. Это связано с тем, что значение горизонтальных
координат увеличивается по мере перемещения спрайтов вправо.
Теперь рассмотрим, как определить, что клавиша была отжата.
Обнаружение отпускания клавиши
Чтобы предыдущий код работал и после первого удара топором, нам нужно определить, когда игрок отпускает клавишу, и вернуть значение acceptInput в true.
Данный процесс немного отличается от обработки клавиш, которую мы видели
до сих пор. SFML имеет два разных способа обнаружения ввода с клавиатуры от игрока. С первым способом мы уже познакомились. Он динамический
и мгновенный, именно то, что нам нужно для немедленной реакции на нажатие
клавиши.
В следующем коде применен другой метод. Введите выделенный код после комментария Обработка ввода игрока, а затем мы разберем его:
/*
******************************
Обработка ввода игрока
******************************
*/
Event event;
while (window.pollEvent(event))
{
if (event.type == Event::KeyReleased && !paused)
{
// Снова "слушаем" нажатия клавиш
acceptInput = true;
166 Глава 5. Коллизии, звук и условия завершения игры
}
}
// Скрываем топор
spriteAxe.setPosition(2000,
spriteAxe.getPosition().y);
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
Сначала мы объявляем объект типа Event с именем event. Затем вызываем функцию window.pollEvent, передавая ей наш новый объект event. Функция pollEvent
помещает в объект event данные, которые описывают событие операционной
системы. Это может быть нажатие клавиши, отпускание клавиши, движение
мыши, щелчок кнопкой мыши, действие игрового контроллера или что-то, что
произошло с самим окном (оно было изменено в размерах, перемещено и т. д.).
Причина, по которой мы обернули наш код в цикл while, заключается в том,
что в очереди может храниться множество событий. Функция window.pollEvent
будет загружать их по одному в объект event. При каждом проходе цикла мы
будем проверять, интересует ли нас текущее событие, и реагировать, если это так.
Когда window.pollEvent вернет false, это будет означать, что в очереди больше
нет событий, и цикл while завершится.
Некоторые читатели заметят, что кое-что немного изменилось с момента нашего
первого обсуждения функций. Что происходит, будет полностью объяснено, ко
гда мы будем обсуждать ссылки в главе 9. Вкратце отметим, что можно передать
значение в функцию, а вызванная функция может изменить это значение так, что
новое значение станет доступным вызывающей функции. Это делается с помощью
ссылок, а не путем возврата значения с помощью оператора return.
Условие if (event.type == Event::KeyReleased && !paused) будет выполняться,
если и клавиша была отпущена, и игра не на паузе.
Внутри блока if мы устанавливаем acceptInput в true и скрываем спрайт топора
за пределами экрана.
Вы можете запустить игру прямо сейчас и понаблюдать за движущимся деревом,
топором и анимированным героем. Однако мы еще не закончили.
Анимация топора и разрубленных бревен
Когда герой рубит, logActive устанавливается в true, поэтому мы можем обернуть некоторый код в блок, который будет выполняться только тогда, когда
logActive равно true. Кроме того, каждый удар топором присваивает logSpeedX
положительное или отрицательное число, так что бревно готово начать отлетать
от дерева в правильном направлении.
Обработка ввода игрока 167
Добавьте следующий выделенный код сразу после обновления спрайтов ветки:
// Обновление спрайтов веток
for (int i = 0; i < NUM_BRANCHES; i++)
{
float height = i * 150;
if (branchPositions[i] == side::LEFT)
{
// Перемещаем спрайт в левую сторону
branches[i].setPosition(610, height);
// Переворачиваем спрайт в другую сторону
branches[i].setRotation(180);
}
else if (branchPositions[i] == side::RIGHT)
{
// Перемещаем спрайт в правую сторону
branches[i].setPosition(1330, height);
// Переворачиваем спрайт в другую сторону
branches[i].setRotation(0);
}
else
{
}
}
// Скрываем ветку
branches[i].setPosition(3000, height);
// Обработка отлетающего бревна
if (logActive)
{
spriteLog.setPosition(
spriteLog.getPosition().x +
(logSpeedX * dt.asSeconds()),
spriteLog.getPosition().y +
(logSpeedY * dt.asSeconds()));
}
// Достигло ли бревно правого края?
if (spriteLog.getPosition().x < -100 ||
spriteLog.getPosition().x > 2000)
{
// Готовим его к появлению в качестве нового бревна в следующем кадре
logActive = false;
spriteLog.setPosition(810, 720);
}
} // Конец блока if(!paused)
168 Глава 5. Коллизии, звук и условия завершения игры
/*
***********************
Отрисовка сцены
***********************
*/
Здесь мы устанавливаем позицию спрайта, получая его текущие координаты
по горизонтали и вертикали с помощью getPosition, а затем добавляем к ним
logSpeedX и logSpeedY соответственно, умноженные на dt.asSeconds.
После перемещения спрайта бревна в каждом кадре с помощью блока if проверяется, не скрылся ли спрайт за левым или правым краем экрана. Если это
произошло, бревно перемещается обратно в исходную точку, готовое для следующего удара.
Запустив игру, вы увидите, как бревно отлетает в соответствующую сторону
экрана (рис. 5.2).
Рис. 5.2. Отлетающее бревно
Теперь перейдем к более деликатной теме. Посмотрим, как мы будем обрабатывать проигрыш игрока.
Обработка гибели
Наша игра должна как-то заканчиваться. Здесь есть несколько вариантов: либо
у игрока истечет время (с этим мы уже разобрались), либо героя раздавит веткой.
Поденка — это насекомое, которое живет от нескольких часов до нескольких дней.
Игра в Timber! похожа на жизнь поденки — либо у вас заканчивается время, либо
Обработка гибели 169
вы чувствуете, как ветка судьбы раздавливает ваши надежды! Наш герой может
продержаться всего несколько секунд, и даже опытному игроку будет сложно
выстоять дольше нескольких минут.
К счастью, обнаружить, что героя раздавило, очень просто. Все, что нам нужно знать, — равен ли последний элемент массива branchPositions значению
playerSide. Если да, то герой мертв.
Добавьте выделенный код, который проверяет это, а затем мы обсудим все, что
нужно делать, когда героя раздавило:
// Обработка отлетающего бревна
if (logActive)
{
spriteLog.setPosition(
spriteLog.getPosition().x +
(logSpeedX * dt.asSeconds()),
spriteLog.getPosition().y +
(logSpeedY * dt.asSeconds()));
}
// Достигло ли бревно правого края?
if (spriteLog.getPosition().x < -100 ||
spriteLog.getPosition().x > 2000)
{
// Готовим его к появлению в качестве нового бревна в следующем кадре
logActive = false;
spriteLog.setPosition(800, 600);
}
// Был ли персонаж раздавлен веткой?
if (branchPositions[5] == playerSide)
{
// Смерть
paused = true;
acceptInput = false;
// Отрисовка надгробия
spriteRIP.setPosition(525, 760);
// Скрываем персонажа
spritePlayer.setPosition(2000, 660);
// Изменяем текст сообщения
messageText.setString("РАЗДАВЛЕН!");
// Центрируем сообщение
FloatRect textRect = messageText.getLocalBounds();
170 Глава 5. Коллизии, звук и условия завершения игры
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top + textRect.height / 2.0f);
messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);
}
} // Конец блока if(!paused)
/*
***********************
Отрисовка сцены
***********************
*/
Первое, что делает новый код после гибели героя, — устанавливает значение
paused в true. Теперь цикл завершит этот кадр и не будет запускать часть обновления цикла до тех пор, пока игрок не начнет новую игру.
Далее мы перемещаем надгробие в позицию рядом с местом, где стоял герой,
и скрываем спрайт игрового персонажа за пределами экрана.
Мы задаем строке messageText значение "РАЗДАВЛЕН!", а затем используем обычную технику, чтобы отцентрировать ее на экране.
Теперь вы можете запустить игру и сыграть в нее по-настоящему. На рис. 5.3 показаны финальный счет игрока, надгробие, а также сообщение.
Но есть еще одна проблема. Нет, не в том, что персонаж оставил свой топор в дереве. Только мне кажется, что игра совсем тихая?
Рис. 5.3. Сообщение «РАЗДАВЛЕН!»
Простые звуковые эффекты 171
Простые звуковые эффекты
Мы добавим три звука. Каждый звук будет воспроизводиться при определенном
игровом событии: простой стук, когда герой рубит дерево, мрачный звук проигрыша, когда у игрока заканчивается время, и звук в стиле ретро, когда героя
придавливает веткой.
Как работает звук в SFML
SFML воспроизводит звуковые эффекты с помощью двух разных классов. Первый — это класс SoundBuffer. Он хранит фактические аудиоданные из звукового
файла. Именно SoundBuffer отвечает за загрузку файлов .WAV в оперативную
память компьютера в формате, который может быть воспроизведен без дополнительной работы по декодированию.
Когда мы будем писать код для звуковых эффектов, то увидим, что как только
у нас появится объект SoundBuffer с сохраненным в нем звуком, мы создадим
другой объект типа Sound. Затем мы можем связать этот объект Sound с объектом
SoundBuffer. После этого мы сможем вызвать функцию воспроизведения соответствующего объекта Sound в нашем коде.
Когда воспроизводить звуки
Как мы увидим совсем скоро, код C++ для загрузки и воспроизведения звуков
очень прост. Однако нам нужно подумать о том, когда мы будем вызывать функцию, отвечающую за воспроизведение, и где мы ее разместим в нашем коде.
Звук удара, например, можно вызвать нажатием клавиш со стрелками, звук
смерти — через блок if, который обнаруживает, что героя придавило веткой,
звук окончания времени — из блока if, который проверяет, что timeRemaining
меньше нуля.
А теперь приступим к делу!
Добавление кода для воспроизведения звуков
Сначала мы добавим еще одну директиву #include, чтобы сделать доступными
классы SFML, связанные со звуком. Вставьте выделенный код:
#include <sstream>
#include <SFML/Graphics.hpp>
#include <SFML/Audio.hpp>
using namespace sf;
172 Глава 5. Коллизии, звук и условия завершения игры
Теперь мы объявим три объекта SoundBuffer , загрузим в них три звуковых
файла и свяжем три объекта типа Sound с соответствующими объектами типа
SoundBuffer. Добавьте выделенный код:
// Управление вводом игрока
bool acceptInput = false;
// Подготовка звуков
SoundBuffer chopBuffer;
chopBuffer.loadFromFile("sound/chop.wav");
Sound chop;
chop.setBuffer(chopBuffer);
SoundBuffer deathBuffer;
deathBuffer.loadFromFile("sound/death.wav");
Sound death;
death.setBuffer(deathBuffer);
// Время вышло
SoundBuffer ootBuffer;
ootBuffer.loadFromFile("sound/out_of_time.wav");
Sound outOfTime;
outOfTime.setBuffer(ootBuffer);
while (window.isOpen())
{
Теперь мы можем воспроизвести наш первый звуковой эффект. Добавьте одну
строку кода, как показано ниже, в блок if, который обнаруживает, что игрок нажал клавишу со стрелкой вправо:
// Оборачиваем элементы управления персонажем
// чтобы убедиться, что мы принимаем ввод
if (acceptInput)
{
// Здесь будет больше кода позже...
// Сначала обрабатываем нажатие клавиши со стрелкой вправо
if (Keyboard::isKeyPressed(Keyboard::Right))
{
// Убеждаемся, что герой находится справа
playerSide = side::RIGHT;
score++;
timeRemaining += (2 / score) + .15;
spriteAxe.setPosition(AXE_POSITION_RIGHT,
spriteAxe.getPosition().y);
spritePlayer.setPosition(1120, 660);
// Обновление веток
updateBranches(score);
Простые звуковые эффекты 173
// Запускаем бревно влево
spriteLog.setPosition(800, 600);
logSpeedX = -5000;
logActive = true;
acceptInput = false;
}
// Воспроизведение звука удара
chop.play();
СОВЕТ
Добавьте точно такой же код в конец следующего блока кода, который начинается
с if (Keyboard::isKeyPressed(Keyboard::Left)), чтобы звук удара воспроизводился, когда герой рубит и с левой стороны дерева.
Найдите блок, который обрабатывает ситуацию, когда у игрока заканчивается
время, и добавьте выделенный код, показанный ниже, чтобы воспроизводился
звук, связанный с окончанием времени:
if (timeRemaining <= 0.f) {
// Ставим игру на паузу
paused = true;
// Меняем сообщение для игрока
messageText.setString("ВРЕМЯ ВЫШЛО!");
// Пересчитываем позицию текста на основе его нового размера
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top +
textRect.height / 2.0f);
messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);
// Воспроизводим звук окончания времени
outOfTime.play();
}
Наконец, чтобы воспроизвести звук во время гибели игрового персонажа, добавьте выделенный код в блок if, который выполняется, когда нижняя ветвь
находится на той же стороне, что и персонаж:
// Был ли герой раздавлен веткой?
if (branchPositions[5] == playerSide)
{
// Смерть
paused = true;
174 Глава 5. Коллизии, звук и условия завершения игры
acceptInput = false;
// Отрисовка надгробия
spriteRIP.setPosition(675, 660);
// Скрываем персонажа
spritePlayer.setPosition(2000, 660);
// Изменяем текст сообщения
messageText.setString("РАЗДАВЛЕН!");
// Центрируем сообщение
FloatRect textRect = messageText.getLocalBounds();
messageText.setOrigin(textRect.left +
textRect.width / 2.0f,
textRect.top + textRect.height / 2.0f);
messageText.setPosition(1920 / 2.0f, 1080 / 2.0f);
// Воспроизводим звук, означающий гибель персонажа
death.play();
}
Если у вас возникли проблемы с тем, что звуки не воспроизводятся, наиболее
вероятная причина в том, что звуковые файлы не загружаются. Чтобы определить это, оберните код, который загружает звуки, в блок if.
Сперва добавьте директиву include, которая позволит нам использовать функцию cout <<, как мы делали это в главе 3, когда изучали объединение (конкатенацию) строк. Вот напоминание о том, что нужно добавить в дополнение к существующим директивам include:
#include<iostream>
Оберните каждый вызов функции loadFromFile:
if (!chopBuffer.loadFromFile("sound/chop.wav"))
{
std::cout << "didn't load chop.wav";
}
Теперь вы получите сообщение об ошибке, в котором будет указано, что файл
не загрузился. Если он не загрузился, проверьте следующее:
zzфайл называется точно так же, как указано в коде, — chop.wav;
zzфайл расположен в папке sound;
zzпапка sound находится в корневой папке проекта вместе с файлом C++
Timber.cpp.
Улучшение игры и кода 175
Вот и все! Мы создали первую игру. Давайте обсудим возможные улучшения,
прежде чем перейти ко второму проекту.
Улучшение игры и кода
Взгляните на предложенные улучшения для проекта Timber!. Вы можете опробовать улучшения, взяв их из папки Runnable пакета загрузки.
zzУвеличение производительности кода. В нашем коде есть фрагмент, который
замедляет игру. Для такой простой игры это некритично, но мы можем ускорить
работу, поместив код с sstream в блок, который будет выполняться только время
от времени. В конце концов, нам не нужно обновлять счет тысячи раз в секунду!
zzИспользование отладочной консоли. Добавим еще немного текста, чтобы мы
могли видеть текущую частоту кадров. Как и в случае со счетом, нам не нужно
ее постоянное обновление. Достаточно одного раза в сто кадров.
zzДобавление большего числа деревьев на задний план. Просто добавьте еще
несколько спрайтов деревьев и расположите их в любом удобном для вас
месте (одни ближе к камере, а другие дальше).
zzУлучшение видимости текста HUD. Мы можем нарисовать простые объекты
RectangleShape позади счета и счетчика FPS. Черный цвет с небольшой прозрачностью будет смотреться неплохо.
zzОптимизация кода для облаков. Как уже несколько раз упоминалось, мы
можем использовать наши знания о массивах, чтобы значительно сократить
код для облаков.
Вот код облака с использованием массивов вместо того, чтобы повторять код три
раза, по одному разу для каждого облака:
for (int i = 0; i < NUM_CLOUDS; i++)
{
clouds[i].setTexture(textureCloud);
clouds[i].setPosition(-300, i * 150);
cloudsActive[i] = false;
cloudSpeeds[i] = 0;
}
// Три новых спрайта с одной текстурой
//Sprite spriteCloud1;
//Sprite spriteCloud2;
//Sprite spriteCloud3;
//spriteCloud1.setTexture(textureCloud);
//spriteCloud2.setTexture(textureCloud);
//spriteCloud3.setTexture(textureCloud);
// Размещаем облака за пределами экрана
//spriteCloud1.setPosition(0, 0);
176 Глава 5. Коллизии, звук и условия завершения игры
//spriteCloud2.setPosition(0, 150);
//spriteCloud3.setPosition(0, 300);
// Выясняем, находятся ли облака в данный момент на экране
//bool cloud1Active = false;
//bool cloud2Active = false;
//bool cloud3Active = false;
// С какой скоростью движется каждое облако?
//float cloud1Speed = 0.0f;
//float cloud2Speed = 0.0f;
//float cloud3Speed = 0.0f;
Здесь старый, неиспользуемый код закомментирован, а новый код, основанный
на массивах, находится в верхней части. Обычно ненужный код удаляется, но
я оставил его, чтобы наглядно показать вам масштаб изменений. Вы можете посмотреть весь код улучшенной версии, включая объявление и инициализацию
массива, в файле enhanced.cpp папки Chapter 5.
На рис. 5.4 показана игра с дополнительными деревьями, облаками и прозрачным
фоном для текста.
Рис. 5.4. Усовершенствованная игра
Чтобы увидеть код этих улучшений, загляните в папку Timber Enhanced Version
из пакета загрузки.
Резюме
В данной главе мы добавили изображения и внесли финальные правки в игру
Timber!. Если до чтения этой книги вы не написали ни одной строчки на C++, то
теперь можете похвалить себя: всего за пять скромных глав вы прошли путь от
нулевых знаний до работающей игры.
Часто задаваемые вопросы 177
Однако мы не будем долго задерживаться на поздравлениях, потому что далее
сразу перейдем к более сложным аспектам C++. Хотя следующая игра в некотором смысле проще, чем Timber!, то, что вы узнали о написании собственных
классов, подготовит вас к созданию более сложных и функциональных игр.
Часто задаваемые вопросы
В. Решение с массивом для облаков было более эффективным. Но действительно ли нам нужны три отдельных массива: для определения активности, для
скорости и для самого спрайта?
О. Если мы посмотрим на свойства и переменные, которыми обладают различные объекты, например спрайты, то увидим, что их очень много. Спрайты
имеют позицию, цвет, размер, ориентацию в пространстве и многое другое.
Но было бы просто замечательно, если бы у них также были переменные для ориентации, скорости и, возможно, чего-то еще. Проблема в том, что разработчики
SFML не могут предугадать все способы, которыми мы захотим использовать их
класс Sprite. К счастью, мы можем создавать собственные классы. Например,
мы могли бы создать класс Cloud, который содержит логическое значение для
определения активности и целое число для скорости. Мы даже могли бы дать
нашему классу Cloud объект Sprite библиотеки SFML. Это позволило бы нам
еще больше упростить код для облаков. Мы рассмотрим создание собственных
классов в следующей главе.
6
Объектно-ориентированное
программирование:
приступаем к работе
над игрой Pong
В этой главе будет немного теории, но она даст нам знания, необходимые для
того, чтобы начать использовать объектно-ориентированное программирование
(ООП). ООП помогает организовать наш код в понятные человеку структуры
и справляться со сложностью. Мы не будем терять время и сразу применим
эту теорию на практике, чтобы написать следующий проект — игру Pong. Мы
заглянем за кулисы и узнаем, как создавать новые типы C++, которые можно
использовать в качестве объектов, и напишем наш первый класс. Для начала
рассмотрим упрощенный сценарий игры Pong, чтобы изучить основы классов,
а затем начнем заново и создадим полноценную игру.
Здесь приведены четыре наших проекта для этой книги: https://github.com/
PacktPublishing/Beginning-C-Game-Programming-Third-Edition/tree/main/Pong.
Объектно-ориентированное программирование
ООП — это парадигма программирования, которую можно считать почти стандартным способом написания кода. Правда, существуют подходы к программированию, отличные от ООП, а также языки и библиотеки для создания игр,
не использующие ООП. Однако, поскольку мы начинаем с нуля, нет никаких
причин делать все по-другому.
ООП позволяет:
zzсделать код проще в управлении, изменении или обновлении;
zzсделать код надежнее и ускорить его написание;
zzупростить использование чужого кода (как мы это делаем с SFML).
Мы уже видели третий пункт в действии. Теперь обсудим, что же такое ООП.
ООП предполагает разбиение наших задач на фрагменты, которыми легче
управлять, чем целыми. Каждый фрагмент является самодостаточным, но при
Объектно-ориентированное программирование 179
этом работает с другими частями нашей программы. Более того, он также может быть использован и другими программами. Эти фрагменты мы называем
объектами.
Когда мы планируем и пишем код для объекта, мы делаем это с помощью
класса.
ПРИМЕЧАНИЕ
Класс можно рассматривать как чертеж объекта.
Мы реализуем объект класса. Он называется экземпляром класса. Представьте
себе чертеж дома. Вы не можете жить в нем, но вы можете построить дом по
этому чертежу. Вы строите экземпляр дома. Часто, когда мы проектируем классы
для наших игр, мы создаем их для представления реальных вещей. В следующем
проекте мы напишем классы для ракетки, которой управляет игрок, и мяча, который игрок должен отбивать. Однако ООП — это нечто большее.
ПРИМЕЧАНИЕ
ООП — это способ действия, методология, определяющая лучшие практики.
Три основных принципа ООП — инкапсуляция, полиморфизм и наследование. Это
может показаться сложным, но, если разбирать эти темы поэтапно, все оказывается достаточно понятным.
Инкапсуляция
Инкапсуляция означает защиту внутренней работы вашего кода от вмешательства
кода, который его использует. Этого можно добиться, разрешив доступ только
к тем переменным и функциям, которые вы сами выбрали. Таким образом, ваш
код всегда можно обновить, расширить или улучшить, не затрагивая использующие его программы, при условии, что доступ к открытым частям остается
прежним. В C++ инкапсуляция достигается за счет использования ключевых
слов public и private. Скоро мы увидим их в действии.
Например, при правильной инкапсуляции не имеет значения, если команде
SFML понадобится обновить способ работы класса Sprite. Если сигнатуры функций останутся прежними, не нужно будет беспокоиться о том, что происходит
внутри. Код, который мы написали до обновления, продолжит функционировать
и после него.
180 Глава 6. Объектно-ориентированное программирование
ООП не отменяет необходимости тщательного планирования перед написанием
кода, напротив, инкапсуляция предоставляет способ структурирования кода,
который потенциально может сделать наше планирование более успешным. Это
особенно актуально, если мы работаем в команде.
Полиморфизм
Полиморфизм позволяет нам писать код, меньше зависящий от типов, которыми
мы пытаемся манипулировать. Это сделает наш код более понятным и эффективным. Полиморфизм имеет различные формы. Если объекты, которые мы
создаем, могут быть более чем одного типа, то почему бы не воспользоваться
этим преимуществом? На данный момент полиморфизм может показаться чемто магическим. Классическим примером полиморфизма являются отношения
между различными животными в природе. Что, если мы делаем игру про зоопарк
и создаем целую кучу массивов, функций и переменных для слонов? Довольно
быстро выясняется, что теперь нам нужно написать массивы, функции и переменные для львов, затем тигров и т. д. А что, если бы мы могли написать один
набор массивов, функций и переменных, который работал бы для всех животных?
С помощью полиморфизма мы можем написать код для одного общего объекта
и использовать его во всех наших классах, связанных с зоопарком. Мы будем
применять полиморфизм в финальном проекте, и все станет понятнее.
Наследование
Наследование, как следует из названия, позволяет нам использовать все возможности и преимущества любых классов, включая инкапсуляцию и полиморфизм,
при этом адаптируя их код под наши конкретные задачи. Если бы мы писали
симулятор сельской местности, то, вероятно, могли бы использовать код, связанный с животными, из игры про зоопарк. Мы применим наследование, когда
будем работать с полиморфизмом.
Зачем использовать ООП
При правильном использовании ООП позволяет добавлять новые функции,
не беспокоясь о том, как они взаимодействуют с существующими. Когда вам
нужно изменить класс, его самодостаточная (инкапсулированная) природа
означает, что последствия для других частей программы будут минимальными
или даже нулевыми.
Вы можете использовать чужой код (например, классы SFML), не зная и даже
не заботясь о том, как он работает внутри.
Объектно-ориентированное программирование (и, как следствие, SFML) позволяет разрабатывать игры, использующие такие сложные концепции, как
Объектно-ориентированное программирование 181
несколько камер, многопользовательский режим, OpenGL, направленный звук
и многое другое — и все это без особых усилий.
С помощью наследования можно создать несколько похожих, но разных версий
класса, без необходимости начинать работу с нуля.
Благодаря полиморфизму вы можете продолжать использовать функции, предназначенные для исходного типа объекта, с вашим новым объектом.
Все это означает, что у нас появляется гораздо больше времени, чтобы сосредоточиться на уникальных аспектах наших собственных программ. И, как мы знаем,
C++ с самого начала был разработан с учетом всех этих особенностей ООП.
ПРИМЕЧАНИЕ
Ключом к успеху в ООП и разработке игр (или любого другого программного обеспечения), помимо решимости добиться этого успеха, являются планирование и проектирование. Не столько знание всех тем C++, SFML и ООП поможет вам написать
отличный код, сколько умение применить его. Код в книге представлен в порядке
и манере, которые подходят для изучения различных тем C++ в контексте игр. Искусство и наука структурирования кода называются шаблонами или паттернами проектирования. По мере того как ваш код будет становиться все длиннее и сложнее,
эффективное использование паттернов проектирования будет становиться все более
важным. Хорошая новость заключается в том, что нам не нужно самим изобретать эти
шаблоны. Достаточно лишь знакомиться с ними по мере усложнения наших проектов.
В данном проекте мы разберем базовые классы и инкапсуляцию. По мере продвижения мы станем намного увереннее и начнем использовать наследование,
полиморфизм и другие возможности C++, связанные с ООП.
Что такое класс
Класс — это набор кода, который может содержать функции, переменные, циклы и остальные элементы синтаксиса C++, с которым мы уже познакомились.
Каждый новый класс будет объявлен в отдельном файле .h с тем же именем,
что и класс, а его функции — в файле .cpp. Синтаксис, который мы применим
к определениям в файле .cpp, даст понять, что они являются частью класса, объявленного в файле .h.
ПРИМЕЧАНИЕ
Когда мы используем функцию в классе, это специализированный тип функции, часто
называемый методом. Для простоты я продолжу называть все функции функциями,
но вы можете называть функции наших классов методами, если хотите.
182 Глава 6. Объектно-ориентированное программирование
Как только мы написали класс, мы можем использовать его для создания любого
количества объектов. Помните, что класс — это чертеж, а объекты — это то, что
мы создаем на основе чертежа. Дом — не чертеж, так же как и объект — не класс.
Это объект, созданный на основе класса.
ПРИМЕЧАНИЕ
Объект можно рассматривать как переменную, а класс — как тип.
Конечно, за всеми этими разговорами об ООП и классах мы не рассмотрели
никакого кода. Давайте это исправим.
Ракетка для игры в пинг-понг
Ниже приводится гипотетическое рассуждение о том, как мы могли бы использовать ООП для начала работы над проектом Pong, написав класс Bat. Не добавляйте пока код в проект, так как далее следует упрощенное объяснение теории. Когда
мы дойдем до реального написания кода позже в этой главе, он будет отличаться,
но принципы, которые мы изучим, подготовят нас к успеху.
Начнем с изучения переменных и функций (или методов) в рамках класса.
Объявление класса, переменных и функций
Ракетка — это предмет реального мира, обладающий свойствами, поведением
и определенным внешним видом. Она выполняет конкретную роль: отбивает мяч
при столкновении с ним. Поэтому такая ракетка — отличный первый кандидат
для создания класса.
ПРИМЕЧАНИЕ
Если вы никогда не слышали про игру Pong, то ознакомьтесь с ней по этой ссылке:
https://ru.wikipedia.org/wiki/Pong_(игра).
Рассмотрим гипотетический файл Bat.h:
class Bat
{
private:
// Длина ракетки
int m_Length = 100;
Ракетка для игры в пинг-понг 183
// Толщина ракетки
int m_Height = 10;
// Позиция по оси X
int m_XPosition;
// Позиция по оси Y
int m_YPosition;
public:
void moveRight();
void moveLeft();
};
На первый взгляд, код может показаться немного сложным, но после объяснения
мы увидим, что в нем используется лишь несколько незнакомых нам концепций.
Первое, на что следует обратить внимание, — это то, что новый класс объявляется
с помощью ключевого слова class, за которым следует имя класса, и что все объявление заключено в фигурные скобки и завершается точкой с запятой:
class Bat
{
...
...
};
Теперь посмотрим на объявления переменных и их имена:
// Длина ракетки
int m_Length = 100;
// Толщина ракетки
int m_Height = 10;
// Позиция по оси X
int m_XPosition;
// Позиция по оси Y
int m_YPosition;
Все имена имеют префикс m_. Префикс m_ необязателен, но его проставление
считается хорошей практикой. Переменные, объявленные как часть класса, называются переменными-членами. Префикс m_ дает понять, что мы имеем дело
с переменной класса. Когда мы будем писать функции для наших классов, мы
столкнемся с локальными переменными и параметрами. Тогда префикс m_
окажется полезным. Проставляя префикс, вы сразу даете понять, что эти переменные являются частью класса, и отделяете их от локальных переменных или
параметров. В разных проектах, компаниях и системах существуют разные правила для именования переменных, но использование какого-либо префикса для
переменных-членов является лучшей практикой в отрасли.
Представьте, что у вас есть переменные, не являющиеся членами, в той же области видимости без префикса m_:
int Length = 50; // Не переменная-член
184 Глава 6. Объектно-ориентированное программирование
Теперь, без префикса m_, становится не совсем понятно, является Length членом
класса или нет. Последовательное использование префикса m_ помогает избежать
такой путаницы, способствуя созданию более удобного и понятного кода.
Обратите также внимание, что все переменные находятся в блоке кода с ключевым словом private. Стоит отметить, что тело кода класса разделено на два блока:
private:
// Все, с чем экземпляры не могут взаимодействовать напрямую
public:
// Переменные и функции здесь доступны для пользователя экземпляра
Ключевые слова public и private управляют инкапсуляцией нашего класса. Все,
что в разделе private, не может быть доступно напрямую пользователю экземпляра или объекта класса. Если вы разрабатываете класс для использования другими
пользователями, вряд ли вы захотите, чтобы они могли изменять что-либо по
своему усмотрению. Обратите внимание, что переменные-члены не обязательно
должны быть приватными, но хорошая инкапсуляция достигается за счет того,
что они по возможности остаются таковыми.
Это означает, что наши четыре переменные-члена (m_Length, m_Height, m_XPosition
и m_YPosition) не могут быть напрямую доступны нашему игровому движку из
функции main — только косвенно через код класса. Это и есть инкапсуляция
в действии. Для переменных m_Length и m_Height это легко принять, если нам
не нужно изменять размер ракетки. Однако к переменным-членам m_XPosition
и m_YPosition необходимо обращаться, иначе как мы будем двигать ракетку?
Эта проблема решается с помощью блока кода с ключевым словом public:
void moveRight();
void moveLeft();
Класс предоставляет две функции, которые являются публичными и могут
быть использованы с объектом типа Bat. Когда мы рассмотрим определения
этих функций, то увидим, как именно эти функции управляют приватными
переменными.
В итоге у нас есть набор недоступных (приватных) переменных, которые нельзя
вызвать из функции main. Это хорошо, потому что инкапсуляция делает наш
код менее подверженным ошибкам и более удобным для сопровождения. Затем
мы решаем проблему перемещения ракетки, предоставляя косвенный доступ
к переменным m_XPosition и m_YPosition с помощью двух публичных функций.
Код в функции main может вызывать публичные функции через экземпляр класса, но код внутри функций контролирует, как именно используются переменные.
Ракетка для игры в пинг-понг 185
Мы можем визуализировать информацию о классе,
как показано на рис. 6.1.
На рис. 6.1 верхняя часть представляет собой имя
класса Bat , а средняя включает переменные-члены
класса, перед каждой из которых стоит знак минус,
указывающий на то, что они приватные. Нижний блок
содержит функции-члены класса, перед каждой из
которых стоит знак плюс, указывающий на то, что они
публичные. Это помогает быстро передать информацию об уровнях доступа к членам класса, предоставляя
визуальное представление инкапсуляции в классе.
Рис. 6.1. Информация
о классе Bat
Такой формат представления класса является частью унифицированного языка
моделирования, или UML (Unified Modeling Language). UML — это обширная
тема, которая выходит за рамки книги, но для начала достаточно понимать, что
эти форматы существуют для представления проектных решений в нашем коде
на C++. Вы можете узнать больше о UML на официальном сайте: https://www.
uml.org/.
Теперь рассмотрим определения функций.
Определения функций класса
Определения функций, которые мы напишем, будут располагаться в отдельном
от объявлений класса и функций файле. Мы воспользуемся файлами с тем же
именем, что и класс, и расширением .cpp. Помните, что это делается для того,
чтобы сохранить организованность кода, а также отделить объявления от определений, что может быть полезно, если вы хотите понять, что делает класс (объявления в файле .h), не вдаваясь в детали (определения в файле .cpp). Так, например, следующий код должен находиться в файле с именем Bat.cpp. Взгляните
на следующий код, который содержит всего одну новую концепцию:
#include "Bat.h"
void Bat::moveRight()
{
// Перемещаем ракетку на один пиксель вправо
m_XPosition ++;
}
void Bat::moveLeft()
{
// Перемещаем ракетку на один пиксель влево
m_XPosition --;
}
186 Глава 6. Объектно-ориентированное программирование
Первое, на что следует обратить внимание, — использование директивы include.
Это нужно, чтобы включить объявления классов и функций из файла Bat.h. Благодаря этому код в файле .cpp будет знать об объявлениях в файле .h.
Новая концепция, которую мы здесь видим, — это оператор разрешения области видимости — ::. Поскольку функции принадлежат классу, мы должны
написать сигнатуру немного иначе, чем для обычной функции, не являющейся
членом класса, добавив перед именем функции имя класса и ::, например void
Bat::moveLeft() и void Bat::moveRight.
В этом примере Bat:: перед каждым именем функции указывает, что moveRight
и moveLeft являются функциями-членами класса Bat. Это явно связывает эти
функции с объявлением класса, гарантируя, что компилятор правильно свяжет
их при компиляции.
Использование оператора разрешения области видимости также повышает ясность кода и позволяет избежать конфликтов имен, особенно при работе с несколькими классами или функциями с похожими именами.
ПРИМЕЧАНИЕ
На самом деле мы уже видели оператор разрешения области видимости (всякий раз,
когда объявляли объект класса) и ранее не использовали using namespace.
Обратите внимание, что мы могли бы поместить определения и объявления
функций в один файл:
class Bat
{
private:
// Длина ракетки
int m_Length = 100;
// Толщина ракетки
int m_Height = 10;
// Позиция по оси x
int m_XPosition;
// Позиция по оси y
int m_YPosition;
public:
void Bat::moveRight()
{
// Перемещаем ракетку на один пиксель вправо
m_XPosition ++;
}
void Bat::moveLeft()
{
Ракетка для игры в пинг-понг 187
};
}
// Перемещаем ракетку на один пиксель влево
m_XPosition --;
Однако когда наши классы становятся длиннее (как это будет в нашей игре
Zombie Arena), удобнее поместить определения функций в отдельный файл.
Кроме того, заголовочные файлы считаются публичными и часто используются
в качестве документации для других людей, которые в дальнейшем будут работать с нашим кодом.
Но как использовать класс после того, как мы его создали?
Использование экземпляра класса
Несмотря на весь связанный с классами код, который мы видели, мы еще не использовали ни одного класса. Мы уже знаем, как это сделать, поскольку много
раз обращались к классам SFML.
Сначала мы создадим экземпляр класса Bat:
Bat bat;
Объект bat содержит все переменные, которые мы объявили в Bat.h. Мы просто
не имеем к ним прямого доступа. Однако мы можем перемещать нашу ракетку
с помощью ее публичных функций, например, так:
bat.moveLeft();
Или так:
bat.moveRight();
Помните, что bat — это объект класса Bat, и как таковой он имеет все переменныечлены и все доступные этому классу функции.
Позже мы можем решить сделать нашу игру Pong многопользовательской.
В функции main мы должны будем изменить код так, чтобы в игре было две ракетки, например:
Bat bat;
Bat bat2;
Очень важно понимать, что каждый из этих экземпляров Bat — это отдельный
объект со своим набором переменных, точно так же как спрайты игрока, дерева,
пчелы и топора были отдельными экземплярами класса Sprite библиотеки SFML.
Существуют и другие способы инициализации экземпляра класса, и мы увидим
пример этого, когда будем писать код для настоящего класса Bat.
188 Глава 6. Объектно-ориентированное программирование
Создание проекта Pong
Поскольку настройка проекта — процесс непростой, мы пройдем его шаг за
шагом, как это было в случае с игрой Timber!. Я не буду показывать вам те же
скриншоты, но, поскольку процесс не отличается, вы можете вернуться к главе 1,
если забыли, где расположены различные свойства проекта.
1. Запустите Visual Studio и нажмите на кнопку Create New Project (Создать новый
проект). Или, если у вас все еще открыт проект по игре Timber!, выберите
FileCreateProject (ФайлСоздатьПроект).
2. В появившемся окне выберите Console App (Консольное приложение) и нажмите кнопку Next (Далее). После этого появится окно настройки нового проекта.
3. В окне настройки нового проекта введите Pong в поле Project name (Имя проекта). Обратите внимание, что Visual Studio автоматически установит такое же
имя в поле Solution name (Имя решения).
4. В поле Location (Расположение) укажите путь к папке VS Projects, которую
мы создали в главе 1. Она будет выступать в качестве места хранения всех
файлов нашего проекта.
5. Установите флажок Place solution and project in the same directory (Поместить решение и проект в одном каталоге).
6. После выполнения этих шагов нажмите кнопку Create (Создать). Visual Studio
сгенерирует проект, включая некоторый код на C++ в файле main.cpp.
7. Теперь нужно настроить проект для работы с файлами SFML, которые мы
поместили в папку SFML . В главном меню выберите ProjectPong properties
(ПроектСвойства Pong). На этом этапе у вас должно открыться окно Pong
Property Pages (Страницы свойств проекта Pong).
8. В этом окне выберите All Configurations (Все конфигурации) в поле Configuration
(Конфигурация) и убедитесь, что в раскрывающемся списке Platform (Платформа) установлено значение Win32.
9. Далее в меню слева выберите C/C++, а затем General (Общие).
10. Найдите поле Additional Include Directories (Дополнительные каталоги включа
емых файлов) и введите букву диска, на котором находится ваша папка SFML,
а затем \SFML\include. Если ваша папка SFML располагается на диске D, то
полный путь будет D:\SFML\include. Измените путь, если вы разместили папку
SFML на другом диске.
11. Нажмите Apply (Применить), чтобы сохранить настройки.
12. Теперь, не закрывая окно, выберите Linker (Компоновщик), а затем General
(Общие).
Создание проекта Pong 189
13. Найдите поле редактирования Additional Library Directories (Дополнительные
каталоги библиотек) и введите букву диска, на котором находится ваша папка SFML, а затем \SFML\lib. Таким образом, если вы разместили папку SFML на
диске D, то полный путь будет выглядеть так: D:\SFML\lib. Измените путь,
если файлы SFML располагаются на другом диске.
14. Нажмите Apply (Применить), чтобы сохранить настройки.
15. Далее все в том же окне выполните следующие действия. Выберите в раскрывающемся списке Configuration (Конфигурация) вариант Debug, поскольку мы
будем запускать и тестировать нашу игру Pong в режиме отладки.
16. Выберите Linker (Компоновщик), а затем Input (Ввод).
17. Найдите поле редактирования Additional Dependencies (Дополнительные зависимости), щелкните по нему в крайнем левом углу и наберите следующий текст: sfml-
graphics-d.lib;sfml-window-d.lib;sfmlsystem-d.lib;sfml-network-d.lib;sfmlaudio-d.lib;. Будьте внимательны: курсор нужно расположить точно в начале
18.
19.
20.
21.
22.
текущего содержимого поля редактирования, чтобы не перезаписать уже
имеющийся там текст.
Нажмите кнопку OK.
Нажмите кнопку Apply (Применить), а затем OK.
В главном окне Visual Studio убедитесь, что рядом со списком Debug выбрано
x86, а не x64.
Далее скопируйте файлы библиотеки SFML с расширением .dll в основную
папку проекта. У меня это D:\VS Projects\Pong . Она была создана Visual
Studio в предыдущих шагах. Если вы разместили папку VS Projects в другом
месте, то выполните этот шаг там. Файлы, которые нам нужно скопировать,
находятся в вашей папке SFML\bin. Выделите все файлы в папке SFML\bin.
Затем скопируйте и вставьте выделенные файлы в папку проекта (например,
в D:\VS Projects\Pong).
Теперь проект настроен и готов к работе.
В этой игре мы будем отображать текст для HUD, который покажет счет игрока
и оставшееся количество жизней. Для этого нам понадобится шрифт.
СОВЕТ
Скачайте бесплатный шрифт с сайта http://www.dafont.com/theme.php?cat=302 и распакуйте архив. Вы также можете воспользоваться любым другим шрифтом. Просто
внесите тогда небольшие изменения в код при загрузке шрифта.
190 Глава 6. Объектно-ориентированное программирование
Создайте новую папку с именем fonts в VS Projects\Pong и добавьте файл DSDIGIT.ttf в VS Projects\Pong\fonts.
Теперь мы готовы написать наш первый класс на C++.
Создание класса Bat
Простой пример с ракеткой был хорошим способом познакомить вас с основами
классов. Классы могут быть простыми и короткими, как предыдущий класс Bat,
но они также могут быть длинными и сложными и содержать различные объекты, созданные из других классов. Кроме того, существуют и различные другие
концепции, касающиеся классов, о которых мы вскоре узнаем. Мы также увидим
и напишем функцию-конструктор, которая будет настраивать наши экземпляры
для работы.
Когда дело доходит до создания игр, в гипотетическом классе Bat не хватает нескольких важных вещей. Возможно, он подходит для всех этих приватных переменных-членов и публичных функций, но как мы будем отрисовывать что-либо?
Нашей ракетке из игры Pong нужен спрайт, а в некоторых играх нашим классам
понадобится еще и текстура. Кроме того, нам нужен способ управлять скоростью
анимации всех наших игровых объектов, как мы делали это с пчелой и облаками
ранее. Мы можем включить другие объекты в наш класс так же, как мы включали
их в файл main.cpp. Давайте напишем наш класс Bat, чтобы увидеть, как можно
решить все эти проблемы.
Создание Bat.h
Для начала создадим заголовочный файл. Щелкните правой кнопкой мыши на
Header Files (Файлы заголовков) в окне Solution Explorer (Обозреватель решений)
и выберите ADDNew Item (ДобавитьСоздать элемент). Далее выберите параметр Header File (.h) (Файл заголовка) и введите Bat.h в поле Name (Имя). Нажмите
кнопку Add (Добавить). Теперь мы готовы к написать код в этом файле:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Bat
{
private:
Vector2f m_Position;
// Объект RectangleShape
RectangleShape m_Shape;
float m_Speed = 1000.0f;
bool m_MovingRight = false;
bool m_MovingLeft = false;
Создание класса Bat 191
public:
Bat(float startX, float startY);
FloatRect getPosition();
RectangleShape getShape();
void moveLeft();
void moveRight();
void stopLeft();
void stopRight();
void update(Time dt);
};
Во-первых, обратите внимание на объявление #pragma once в верхней части
файла. Оно предотвращает обработку файла компилятором более одного раза.
По мере усложнения наших игр, когда у нас могут быть десятки классов, это
ускорит время компиляции.
Во-вторых, взгляните на имена переменных-членов, параметры и типы возвращаемых значений функций. У нас есть Vector2f под названием m_Position, который будет хранить горизонтальное и вертикальное положение ракетки игрока.
У нас также есть SFML RectangleShape, который будет представлять ракетку на
экране. Классы RectangleShape и Sprite являются частью графического модуля
SFML и используются для визуализации объектов на экране. RectangleShape
в основном нужен для отрисовки простых прямоугольных фигур, а Sprite — для
текстурированных изображений. Поскольку ракетка в нашей игре представляет
собой простой белый прямоугольник, я выбрал вариант RectangleShape.
Есть два члена типа bool , которые будут отслеживать, в каком направлении
движется ракетка в данный момент, а также переменная типа float с именем
m_Speed, которая указывает количество пикселей в секунду, на которое может
перемещаться ракетка, когда игрок решает сдвинуть ее влево или вправо.
Следующая часть кода требует пояснений, поскольку у нас есть функция Bat,
которая называется точно так же, как и класс. Это конструктор.
Конструктор
В качестве справки: когда класс написан, компилятор создает специальную функцию. Мы не видим эту функцию в нашем коде, но она существует. Она называется
конструктором. Конструктор предоставляется компилятором. Это та функция,
которая была бы вызвана, если бы мы использовали наш гипотетический пример
с классом Bat.
Если нам нужно написать код для подготовки объекта к использованию, лучше
всего сделать это в конструкторе. Когда мы хотим, чтобы конструктор выполнял
нечто-то большее, чем просто создание экземпляра, мы должны заменить конструктор по умолчанию (невидимый), предоставляемый компилятором. Именно
это мы и сделаем с помощью функции конструктора Bat.
192 Глава 6. Объектно-ориентированное программирование
Обратите внимание, что конструктор Bat принимает два параметра типа float.
Для удобства приведем объявление еще раз:
Bat(float startX, float startY);
Это идеально подходит для инициализации положения на экране, когда мы впервые создаем объект Bat. Обратите также внимание, что конструкторы не имеют
возвращаемого типа, даже void.
Вскоре мы воспользуемся функцией-конструктором Bat и списком инициализаторов, чтобы поместить этот игровой объект в исходную позицию. Помните, что
данная функция вызывается в момент объявления объекта типа Bat.
Еще немного о Bat.h
Далее следует функция getPosition, которая возвращает FloatRect, представляющий четыре точки, определяющие прямоугольник. У нас также есть getShape,
которая возвращает RectangleShape. Это нужно для того, чтобы мы могли вернуть
в основной игровой цикл объект m_Shape, чтобы его можно было отрисовать.
Еще у нас есть функции moveLeft, moveRight, stopLeft и stopRight, которые позволяют контролировать, когда и в каком направлении будет двигаться ракетка.
Наконец, у нас имеется функция update , которая принимает параметр Time .
Она будет использоваться для расчета перемещения ракетки в каждом кадре.
Поскольку ракетка и мяч должны двигаться по-разному, имеет смысл инкапсулировать код движения внутри класса. Мы будем вызывать функцию update один
раз в каждом кадре игры из функции main.
ПРИМЕЧАНИЕ
Вы, наверное, догадываетесь, что класс Ball также будет иметь функцию update.
Теперь мы можем заняться файлом Bat.cpp, который будет реализовывать все
определения и использовать переменные-члены.
Создание Bat.cpp
Создадим файл, а затем начнем обсуждать код. Щелкните правой кнопкой мыши
на папке Source Files (Исходные файлы) в окне Solution Explorer (Обозреватель решений) и выберите ADDNew Item (ДобавитьСоздать элемент). Далее выберите
опцию C++ File (.cpp) (Файл C++) и введите Bat.cpp в поле Name (Имя). Нажмите
кнопку Add (Добавить), и наш новый файл будет создан.
Создание класса Bat 193
Для упрощения мы разобьем код этого файла на две части. Сначала напишем
функцию-конструктор Bat:
#include "Bat.h"
// Это конструктор, и он вызывается при создании объекта
Bat::Bat(float startX, float startY) : m_Position(startX, startY)
{
m_Shape.setSize(sf::Vector2f(50, 5));
m_Shape.setPosition(m_Position);
}
В приведенном коде мы включаем файл bat.h. Это делает доступными все функции и переменные, которые были объявлены ранее в bat.h.
Мы реализуем конструктор, потому что нам нужно выполнить некоторую работу
по настройке экземпляра, и конструктора, предоставляемого компилятором по
умолчанию, недостаточно. Помните, что конструктор — это код, который выполняется при инициализации экземпляра Bat.
Обратите внимание, что мы используем синтаксис Bat::Bat в качестве имени
функции, чтобы было понятно, что мы задействуем функцию Bat из класса Bat.
Конструктор принимает два значения типа float: startX и startY. Далее мы видим что-то новое. Сразу после параметров функции следует этот код:
: m_Position(startX, startY)
Это называется списком инициализаторов. Использование списков инициализаторов членов часто считается более эффективным, чем инициализация переменных в теле конструктора, и может быть полезным для определения некоторых типов переменных. В данном случае мы используем сокращенный и более
понятный синтаксис для инициализации переменной m_Position значениями,
переданными в функцию в качестве параметров.
Теперь Vector2f с именем m_Position содержит переданные значения, а поскольку m_Position является переменной-членом, эти значения доступны во всем
классе. Заметим, однако, что m_Position была объявлена как private и не будет
доступна в нашем файле функции main — по крайней мере, напрямую. Скоро мы
увидим, как можно решить эту проблему.
Наконец, в теле конструктора мы инициализируем RectangleShape под названием
m_Shape, задавая его размер и положение. Это отличается от того, как мы писали
код гипотетического класса Bat в разделе «Ракетка для игры в пинг-понг». Класс
Sprite библиотеки SFML имеет удобные переменные для размера и положения,
к которым мы можем обратиться с помощью функций setSize и setPosition,
поэтому нам больше не нужны гипотетические m_Length и m_Height.
194 Глава 6. Объектно-ориентированное программирование
Кроме того, обратите внимание, что нам придется изменить способ инициализации класса Bat (по сравнению с гипотетическим классом Bat) в соответствии
с нашим пользовательским конструктором. Скоро мы увидим этот код.
Нам нужно реализовать оставшиеся пять функций класса Bat. Добавьте следующий код в Bat.cpp после конструктора, который мы только что рассмотрели:
FloatRect Bat::getPosition()
{
return m_Shape.getGlobalBounds();
}
RectangleShape Bat::getShape()
{
return m_Shape;
}
void Bat::moveLeft()
{
m_MovingLeft = true;
}
void Bat::moveRight()
{
m_MovingRight = true;
}
void Bat::stopLeft()
{
m_MovingLeft = false;
}
void Bat::stopRight()
{
m_MovingRight = false;
}
void Bat::update(Time dt)
{
if (m_MovingLeft) {
m_Position.x -= m_Speed * dt.asSeconds();
}
if (m_MovingRight) {
m_Position.x += m_Speed * dt.asSeconds();
}
m_Shape.setPosition(m_Position);
}
Разберем данный код.
Во-первых, у нас есть функция getPosition. Все, что она делает, — возвращает FloatRect вызвавшему ее коду. Строка кода m_Shape.getGlobalBounds возвращает FloatRect, который инициализируется координатами четырех углов
RectangleShape, то есть m_Shape. Мы планируем вызывать эту функцию из функции main, когда будем определять, коснулся ли мяч ракетки.
Использование класса Bat и написание функции main 195
Далее идет функция getShape. Она лишь передает копию m_Shape вызывающему
коду. Это необходимо, чтобы мы могли отрисовать ракетку в функции main. Когда
мы пишем публичную функцию с единственной целью — передать приватные
данные класса, мы называем ее геттером.
Теперь мы можем рассмотреть функции moveLeft, moveRight, stopLeft и stopRight.
Они устанавливают логические переменные m_MovingLeft и m_MovingRight соответствующим образом, чтобы отслеживать текущие намерения игрока по перемещению. Заметьте, однако, что они ничего не делают с экземпляром RectangleShape
или FloatRect, которые определяют позицию. Это как раз то, что нам нужно.
Последняя функция в классе Bat — это update. Мы собираемся вызывать эту
функцию один раз в каждом кадре игры. Функция update будет усложняться по
мере усложнения наших игровых проектов. Пока же нам нужно лишь изменять
m_Position в зависимости от того, движется ли игрок влево или вправо. Обратите
внимание, что для этой подстройки используется та же формула, которую мы
применяли для обновления пчелы и облаков в проекте Timber!. Код умножает
скорость на delta time, а затем прибавляет или вычитает это значение из значения положения. Таким образом ракетка движется относительно того, сколько
времени заняло обновление кадра. Далее код устанавливает положение m_Shape
в соответствии с последними значениями, хранящимися в m_Position.
Наличие функции update в нашем классе Bat, а не в функции main — это инкапсуляция. Вместо того чтобы обновлять позиции всех игровых объектов в функции
main, как мы делали в проекте Timber!, каждый объект будет отвечать за обновление самого себя.
Использование класса Bat
и написание функции main
Переключитесь на файл main.cpp, который был автоматически сгенерирован при
создании проекта. Если у вас был автоматически создан файл с именем Pong.cpp,
вы можете оставить его как есть или щелкнуть правой кнопкой мыши на нем
в Solution Explorer (Обозреватель решений), чтобы переименовать его в main.cpp.
Важно только то, что в нем есть функция main, так как именно с нее начнется выполнение программы. Удалите весь автоматически сгенерированный код в файле
Pong.cpp и добавьте следующий:
#include
#include
#include
#include
"Bat.h"
<sstream>
<cstdlib>
<SFML/Graphics.hpp>
196 Глава 6. Объектно-ориентированное программирование
int main()
{
// Создаем объект VideoMode
VideoMode vm(1920, 1080);
// Создаем и открываем окно для игры в полноэкранном режиме
RenderWindow window(vm, "Pong", Style::Fullscreen);
int score = 0;
int lives = 3;
// Создаем ракетку в нижней центральной части экрана
Bat bat(1920 / 2, 1080 - 20);
// Мы добавим мяч в следующей главе
// Создаем текстовый объект HUD
Text hud;
// Крутой ретрошрифт
Font font;
font.loadFromFile("fonts/DS-DIGIT.ttf");
// Устанавливаем шрифт
hud.setFont(font);
// Делаем его большим и красивым
hud.setCharacterSize(75);
// Выбираем цвет
hud.setFillColor(Color::White);
hud.setPosition(20, 20);
// Таймер для управления временем
Clock clock;
while (window.isOpen())
{
/*
Обработка ввода игрока
**************************************
**************************************
**************************************
*/
/*
Обновление ракетки, мяча и HUD
**************************************
**************************************
**************************************
*/
/*
Отрисовка ракетки, мяча и HUD
**************************************
**************************************
**************************************
*/
}
}
return 0;
Использование класса Bat и написание функции main 197
В приведенном коде структура основного цикла while игры похожа на ту, которую мы использовали в проекте Timber!. Однако первое отличие — это создание
экземпляра класса Bat:
// Создаем ракетку
Bat bat(1920 / 2, 1080 - 20);
Данный фрагмент кода вызывает функцию-конструктор для создания нового
экземпляра класса Bat. Код передает необходимые аргументы и позволяет классу
Bat инициализировать свое положение в центре нижней части экрана. Это идеальное место для стартовой позиции нашей ракетки.
Обратите также внимание, что я использовал комментарии, чтобы указать,
где будет размещен остальной код. Весь он находится внутри игрового цикла,
как и в проекте Timber!:
/*
Обработка ввода игрока
...
/*
Обновление ракетки, мяча и HUD
...
/*
Отрисовка ракетки, мяча и HUD
...
Далее добавьте код в раздел Обработка ввода игрока:
Event event;
while (window.pollEvent(event))
{
if (event.type == Event::Closed)
// Выход из игры при закрытии окна
window.close();
}
// Обработка выхода игрока
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
// Обработка нажатия и отпускания клавиш со стрелками
if (Keyboard::isKeyPressed(Keyboard::Left))
{
bat.moveLeft();
}
else
{
bat.stopLeft();
}
198 Глава 6. Объектно-ориентированное программирование
if (Keyboard::isKeyPressed(Keyboard::Right))
{
bat.moveRight();
}
else
{
bat.stopRight();
}
Приведенный код обрабатывает выход игрока из игры нажатием клавиши Esc,
как и в игре Timber!. Далее идут две структуры if-else, которые обрабатывают
движение ракетки. Давайте разберем первую из них:
if (Keyboard::isKeyPressed(Keyboard::Left))
{
bat.moveLeft();
}
else
{
bat.stopLeft();
}
Данный код определяет, удерживает ли игрок нажатой клавишу со стрелкой влево. Если да, то для экземпляра Bat вызывается функция moveLeft. Ко
гда эта функция запускается, приватная логическая переменная m_MovingLeft
получает значение true. Если же клавиша со стрелкой влево не удерживается,
то вызывается функция stopLeft, а значение m_MovingLeft устанавливается
в false.
Точно такой же процесс повторяется в следующем блоке if-else для обработки
нажатия (или отсутствия нажатия) игроком клавиши со стрелкой вправо.
Затем добавьте следующий код в раздел Обновление ракетки, мяча и HUD:
// Обновление delta time
Time dt = clock.restart();
bat.update(dt);
// Обновление текста HUD
std::stringstream ss;
ss << "Score:" << score << " Lives:" << lives;
hud.setString(ss.str());
Здесь применяется точно такая же техника тайминга, как и в проекте Timber!,
только на этот раз мы вызываем update для экземпляра Bat и передаем delta time.
Помните, что, когда класс Bat получит delta time, он будет использовать это значение для перемещения ракетки, основываясь на ранее полученных инструкциях
по перемещению от игрока и желаемой скорости ракетки.
Резюме 199
Добавьте следующий код в раздел Отрисовка ракетки, мяча и HUD:
window.clear();
window.draw(hud);
window.draw(bat.getShape());
window.display();
В этом коде мы очищаем экран, отрисовываем текст для HUD и используем
функцию bat.getShape, чтобы взять экземпляр RectangleShape из экземпляра Bat
и нарисовать его на экране. Наконец, мы вызываем window.display, как и в предыдущем проекте, чтобы отрисовать ракетку в ее текущей позиции.
Если вы запустите игру на данном этапе, перед вами появится HUD и ракетка
(рис. 6.2), которую можно плавно перемещать влево и вправо с помощью клавиш
со стрелками.
Рис. 6.2. Наша игра Pong с HUD и ракеткой
Поздравляем! Вы написали свой первый полноценный класс.
Резюме
В этой главе мы познакомились с основами ООП: созданием и использованием
классов, инкапсуляцией и переменными-членами. Не переживайте, если некоторые детали, связанные с ООП и классами, не совсем ясны. Мы будем работать
с классами и в других главах, и чем больше мы их напишем, тем понятнее они
станут.
200 Глава 6. Объектно-ориентированное программирование
Кроме того, у нас появилась рабочая ракетка и HUD для нашей игры Pong.
В следующей главе мы создадим класс Ball и заставим мяч прыгать по экрану.
Затем мы сможем добавить обнаружение коллизий и завершить игру.
Часто задаваемые вопросы
В. Я изучал другие языки, и ООП кажется намного проще в C++. Действительно ли это так?
О. Это было введение в ООП и его основные принципы. Это далеко не все.
Мы будем знакомиться с другими концепциями и деталями ООП на протяжении
всей книги.
В. Почему мы используем оператор :: в определениях функций вне объявления
класса?
О. Оператор :: — это оператор разрешения области видимости в C++, применяемый для определения функций вне объявления класса. Когда функции объявляются внутри класса, они неявно ассоциируются с этим классом. Однако при
предоставлении фактической реализации вне класса мы используем ClassName::
перед именем функции, чтобы указать, к какому классу принадлежит функция.
Это обеспечивает правильную ассоциацию и позволяет избежать конфликтов
именования, повышая ясность и удобство работы с кодом.
В. Должны ли переменные-члены инициализироваться в списке инициализаторов членов конструктора или в теле конструктора?
О. Старайтесь по возможности инициализировать переменные-члены в списке
инициализаторов членов конструктора. Такой подход более эффективен, особенно для сложных классов, и гарантирует, что переменные-члены будут инициализированы до выполнения тела конструктора. Однако простая инициализация
в теле конструктора вполне допустима, если она подходит для ваших задач, хотя
всегда лучше отдавать предпочтение списку инициализаторов членов.
7
AABB-метод обнаружения
коллизий и физика:
завершение работы
над игрой Pong
В этой главе мы напишем наш второй класс. Несмотря на то что мяч, очевидно,
сильно отличается от ракетки, мы воспользуемся теми же приемами, чтобы инкапсулировать внешний вид и функциональность мяча внутри класса Ball, как
в случае с ракеткой и классом Bat. Затем мы добавим последние штрихи к игре
Pong, написав код для обнаружения коллизий и подсчета очков. Это может показаться сложным, но, как мы уже привыкли, благодаря SFML все становится
гораздо проще.
Начнем с создания класса, который представляет мяч.
Создание класса Ball
Для начала создадим заголовочный файл. Щелкните правой кнопкой мыши на
Header Files (Файлы заголовков) в окне Solution Explorer (Обозреватель решений)
и выберите ADDNew Item (ДобавитьСоздать элемент). Далее выберите опцию
Header File (.h) (Файл заголовка) и введите Ball.h в поле Name (Имя). Затем нажмите кнопку Add (Добавить).
Добавьте следующий код в файл Ball.h:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Ball
{
private:
Vector2f m_Position;
RectangleShape m_Shape;
float m_Speed = 300.0f;
float m_DirectionX = .2f;
float m_DirectionY = .2f;
202 Глава 7. AABB-метод обнаружения коллизий и физика
public:
Ball(float startX, float startY);
FloatRect getPosition();
RectangleShape getShape();
float getXVelocity();
void reboundSides();
void reboundBatOrTop();
void reboundBottom();
void update(Time dt);
};
Первое, что вы заметите, — это сходство переменных-членов с классом Bat .
Здесь есть переменные-члены для позиции, внешнего вида и скорости, как и для
ракетки игрока, и они имеют те же типы (Vector2f, RectangleShape и float соответственно). У них даже имена одинаковые (m_Position, m_Shape и m_Speed
соответственно). Разница между переменными-членами этого класса в том, что
направление движения обрабатывается двумя переменными float, которые будут отслеживать горизонтальное и вертикальное перемещение. Это m_DirectionX
и m_DirectionY.
Обратите внимание, что нам потребуется написать восемь функций, чтобы «оживить» мяч. Есть конструктор с тем же именем, что и у класса, который мы будем
использовать для инициализации экземпляра Ball. Есть три функции с тем же
именем и назначением, что и у класса Bat. Это getPosition, getShape и update.
Функции getPosition и getShape будут передавать местоположение и внешний
вид мяча в функцию main, а функция update будет вызываться из функции main,
чтобы класс Ball обновлял свою позицию раз в кадр.
Остальные функции управляют направлением движения мяча. Функция re
boundSides будет вызываться из main при обнаружении столкновения с одной из
сторон экрана, функция reboundBatOrTop — в ответ на попадание мяча по ракетке
или верхней границе экрана, а функция reboundBottom — когда мяч ударится
о нижнюю границу экрана.
Конечно, это только объявления, поэтому давайте создадим файл Ball.cpp и напишем необходимый код.
Щелкните правой кнопкой мыши на папке Source Files (Исходные файлы)
в окне Solution Explorer (Обозреватель решений) и выберите ADDNew Item
(ДобавитьСоздать элемент). Далее выберите опцию C++ File (.cpp) (Файл C++)
и введите Ball.cpp в поле Name. Нажмите кнопку Add (Добавить), и наш новый
файл будет создан.
Добавьте следующий код в файл Ball.cpp:
#include "Ball.h"
// Функция-конструктор
Ball::Ball(float startX, float startY) : m_Position(startX,startY)
Создание класса Ball 203
{
}
m_Shape.setSize(sf::Vector2f(10, 10));
m_Shape.setPosition(m_Position);
В приведенном коде мы добавили необходимую директиву include для заголовочного файла класса Ball . Функция-конструктор с тем же именем, что
и у класса, принимает два параметра типа float , которые используются для
инициализации экземпляра Vector2f переменной-члена m_Position с помощью
списка инициализаторов. Затем экземпляру RectangleShape задается размер
с помощью функции setSize, а положение — через setPosition. Используемый
размер — 10 пикселей в ширину и 10 в высоту. Он произвольный, но работает
хорошо. Позиция, разумеется, берется из m_Position экземпляра Vector2f.
Добавьте следующий код после конструктора в функции Ball.cpp:
FloatRect Ball::getPosition()
{
return m_Shape.getGlobalBounds();
}
RectangleShape Ball::getShape()
{
return m_Shape;
}
float Ball::getXVelocity()
{
return m_DirectionX;
}
В этом коде мы пишем три геттер-функции класса Ball . Каждая из них возвращает что-то в функцию main . Первая, getPosition , использует функцию
getGlobalBounds для m_Shape, чтобы вернуть экземпляр FloatRect. Он будет применяться для обнаружения коллизий.
Функция getShape возвращает m_Shape, чтобы ее можно было отрисовать в каждом кадре игрового цикла. Функция getXVelocity сообщает функции main ,
в каком направлении движется мяч, и очень скоро мы увидим, как именно это
нам пригодится. Поскольку нам не нужно получать вертикальную скорость, нет
и соответствующей функции getYVelocity, но если бы она была нужна, мы бы
легко могли ее добавить.
Добавьте следующие функции после предыдущего кода:
void Ball::reboundSides()
{
m_DirectionX = -m_DirectionX;
}
void Ball::reboundBatOrTop()
{
m_DirectionY = -m_DirectionY;
}
204 Глава 7. AABB-метод обнаружения коллизий и физика
void Ball::reboundBottom()
{
m_Position.y = 0;
m_Position.x = 500;
m_DirectionY = -m_DirectionY;
}
В данном коде три функции, чьи имена начинаются с rebound, обрабатывают
событие, которое происходит, когда мяч сталкивается с различными препятствиями. В функции reboundSides значение m_DirectionX инвертируется, в результате чего направление движения мяча по горизонтали изменяется. Функция
reboundBatOrTop делает то же самое, но с m_DirectionY, то есть по вертикали.
Функция reboundBottom перемещает мяч в верхнюю центральную часть экрана
и отправляет его вниз. Это именно то, что нам нужно, когда игрок пропускает
мяч, и последний ударяется о нижнюю границу экрана.
Наконец, для класса Ball добавьте функцию update:
void Ball::update(Time dt)
{
// Обновляем позицию мяча
m_Position.y += m_DirectionY * m_Speed * dt.asSeconds();
m_Position.x += m_DirectionX * m_Speed * dt.asSeconds();
// Перемещаем мяч
m_Shape.setPosition(m_Position);
}
В этом коде переменные-члены m_Position.y и m_Position.x обновляются с использованием соответствующего направления, скорости, темпа и количества времени, которое занял текущий кадр. Затем обновленные значения m_Position применяются для изменения текущей позиции m_Shape экземпляра RectangleShape.
Это та же математика, которую мы использовали для перемещения облаков
и пчелы в первом проекте. Разница в том, что эта логика содержится внутри класса. Если нам когда-нибудь понадобится изменить движение мяча, это повлияет
только на код в классе Ball.
Использование класса Ball
Добавьте приведенный ниже код, чтобы сделать класс Ball доступным в функции
main и «ввести» мяч в игру:
#include "Ball.h"
Добавьте также выделенную строку кода, чтобы объявить и инициализировать
экземпляр класса Ball с помощью функции-конструктора, которую мы недавно
написали:
// Создаем ракетку
Bat bat(1920 / 2, 1080 - 20);
Обнаружение коллизий и подсчет очков 205
// Создаем мяч
Ball ball(1920 / 2, 0);
// Создаем текстовый объект HUD
Text hud;
И следующий код:
/*
Обновление ракетки, мяча и HUD
*********************************************
*********************************************
*********************************************
*/
// Обновление delta time
Time dt = clock.restart();
bat.update(dt);
ball.update(dt);
// Обновление текста HUD
std::stringstream ss;
ss << "Score:" << score << " Lives:" << lives;
hud.setString(ss.str());
В приведенном коде мы просто вызываем update для экземпляра мяча. Мяч будет
соответствующим образом перемещен.
Добавьте следующую выделенную строку в код, как показано ниже, чтобы отрисовать мяч в каждом кадре игрового цикла:
/*
Отрисовка ракетки, мяча и HUD
*********************************************
*********************************************
*********************************************
*/
window.clear();
window.draw(hud);
window.draw(bat.getShape());
window.draw(ball.getShape());
window.display();
Если запустить игру на этом этапе, вы увидите, что мяч появляется в верхней
части экрана и движется вниз. Однако он исчезнет за нижней границей экрана,
потому что мы еще не добавили систему обнаружения коллизий. Давайте это
исправим.
Обнаружение коллизий и подсчет очков
В отличие от игры Timber!, где мы просто проверяли, находится ли ветка в самом нижнем положении на той же стороне, что и игровой персонаж, здесь нам
потребуется математически проверять пересечение мяча с ракеткой или с любой
из четырех сторон экрана.
206 Глава 7. AABB-метод обнаружения коллизий и физика
Для понимания сути рассмотрим гипотетический код, который мог бы реализовать эту возможность. А затем мы обратимся к SFML, чтобы решить эту задачу.
Код для проверки пересечения двух прямоугольников будет выглядеть примерно
так (не используйте данный код, он предназначен только для демонстрации):
if(objectA.getPosition().right > objectB.getPosition().left
&& objectA.getPosition().left < objectB.getPosition().right )
{
// objectA пересекает objectВ по оси Х,
// но они могут находиться на разных высотах
}
if(objectA.getPosition().top < objectB.getPosition().bottom
&& objectA.getPosition().bottom > objectB.getPosition().top )
{
// objectА пересекает objectВ
// По оси Y обнаружено столкновение
}
Первый оператор if проверяет, пересекаются ли objectA и objectВ по горизонтальной оси (X). Он сравнивает правую сторону objectA (objectA.getPosition().right)
с левой стороной objectB (objectB.getPosition().left). Кроме того, он проверяет, находится ли левая сторона objectA слева или справа от objectB. Если оба
условия верны, значит, есть пересечение по оси X.
Второй оператор if, вложенный в ветку первого с истинным результатом, проверяет условия по вертикальной оси (Y). Если первое условие выполнено (есть
пересечение по оси X), код переходит к внутреннему оператору if. Здесь проверяется, пересекаются ли objectA и objectВ по оси Y. Код сравнивает верхнюю сторону objectA (objectA.getPosition().top) с нижней стороной objectВ
(objectB.getPosition().bottom). Кроме того, он распознает, находится ли нижняя сторона objectA ниже верхней стороны objectВ. Если оба условия верны,
значит, есть пересечение по оси Y.
Наконец, если оба условия по осям X и Y истинны, выполняется код внутри
самого вложенного блока. Данный блок указывает, что между objectA и objectВ
была обнаружена коллизия. Это распространенная техника в разработке игр для
проверки пересечения двух объектов вроде игровых персонажей или предметов
как по горизонтали, так и по вертикали.
Такая техника называется AABB (axis-aligned bounding box — «выровненный по
осям ограничивающий параллелепипед»). Она широко применяется в 2D-графике
и разработке игр благодаря своей высокой производительности (то есть скорости). Она не предоставляет точной информации о коллизиях для объектов неправильной формы или окружностей, но даже для них AABB часто используется
в качестве быстрой первоначальной проверки перед проведением более сложных
математических вычислений.
Обнаружение коллизий и подсчет очков 207
Хорошая новость заключается в том, что нам не нужно писать предыдущий
код. Однако мы будем использовать функцию intersects библиотеки SFML,
которая работает с объектами FloatRect. Вспомните классы Bat и Ball: у обоих
была функция getPosition, которая возвращала объект FloatRect, представляющий текущее местоположение объекта. Мы увидим, как можно использовать
getPosition вместе с intersects для обнаружения коллизий.
Добавьте следующий выделенный код в конце раздела update функции main:
/*
Обновление ракетки, мяча и HUD
**************************************
**************************************
**************************************
*/
// Обновление delta time
Time dt = clock.restart();
bat.update(dt);
ball.update(dt);
// Обновление текста HUD
std::stringstream ss;
ss << "Score:" << score << " Lives:" << lives;
hud.setString(ss.str());
// Обработка столкновения мяча с нижней границей экрана
if (ball.getPosition().top > window.getSize().y)
{
// Смена направления движения мяча
ball.reboundBottom();
// Уменьшение количества жизней
lives--;
// Проверка оставшегося количества жизней
if (lives < 1) {
// Сброс счета
score = 0;
// Сброс жизней
lives = 3;
}
}
В приведенном коде первое условие if проверяет, коснулся ли мяч нижней границы экрана:
if (ball.getPosition().top > window.getSize().y)
Если верхний край мяча находится ниже высоты окна, значит, мяч исчез за нижней границей экрана. В ответ на это вызывается функция ball.reboundBottom.
Помните, что в данной функции мяч снова располагается в верхней части экрана.
В этот момент игрок теряет одну жизнь, поэтому переменная lives декрементируется.
208 Глава 7. AABB-метод обнаружения коллизий и физика
Второе условие if проверяет, закончились ли у игрока жизни (lives < 1). Если
это так, то счет обнуляется, количество жизней восстанавливается до трех, и игра
перезапускается. В следующем проекте мы узнаем, как сохранить и отобразить
наивысший результат игрока.
Добавьте следующий код под предыдущим:
// Обработка столкновения мяча с верхней границей экрана
if (ball.getPosition().top < 0)
{
ball.reboundBatOrTop();
// Добавление одного очка к счету игрока
score++;
}
В приведенном коде мы определяем момент попадания верхнего края мяча по
верхней границе экрана. Когда это происходит, игроку начисляется одно очко
и вызывается функция ball.reboundBatOrTop, которая изменяет вертикальное
направление движения мяча и отправляет его обратно вниз.
Добавьте следующий код после предыдущего:
// Обработка столкновения мяча с боковыми границами экрана
if (ball.getPosition().left < 0 ||
ball.getPosition().left + ball.getPosition().width> window.getSize().x)
{
ball.reboundSides();
}
В предыдущем коде условие if обнаруживает столкновение левой стороны мяча
с левым краем экрана или правой стороны мяча с правым краем экрана. В любом
случае вызывается функция ball.reboundSides, и горизонтальное направление
движения меняется.
Добавьте следующий код:
// Мяч столкнулся с ракеткой?
if (ball.getPosition().intersects(bat.getPosition()))
{
// Обнаружение столкновения, изменение направления движения мяча
// и добавление одного очка к счету
ball.reboundBatOrTop();
}
В данном коде функция intersects используется для определения того, столк
нулся ли мяч с ракеткой. Когда это происходит, мы используем ту же функцию,
что и при столкновении с верхней границей экрана, чтобы изменить вертикальное
направление движения мяча.
Оператор spaceship в C++ 209
Запуск игры
Теперь, запустив игру, вы обнаружите, что мяч двигается. Если вы отобьете мяч
ракеткой, счет увеличится, а если промахнетесь — уменьшится количество жизней. Когда количество жизней достигнет 0, счет обнулится, а количество жизней
вернется к значению 3 (рис. 7.1).
Рис. 7.1. Запущенная игра
Оператор spaceship в C++
Поскольку эта глава короткая, я подумал, что ее можно дополнить еще одной
полезной темой. В текущем проекте эта теория нам не понадобится, но хочу рассказать об одном отличном операторе — spaceship, или «космический корабль».
Оператор spaceship, обозначаемый как <=>, является относительно новым дополнением к языку C++, введенным в стандарте C++20. Он используется для трехстороннего сравнения двух объектов, то есть помогает определить, является ли
один объект меньше, больше или равным другому. Данный оператор возвращает
одно из трех значений: <, == или >, указывая на связь между двумя объектами.
Вот как он работает:
zzоператор возвращает отрицательное значение, если его левая часть меньше
правой;
zzвозвращает 0, если левая часть равна правой;
zzвозвращает положительное значение, если его левая часть больше правой.
210 Глава 7. AABB-метод обнаружения коллизий и физика
Например:
int a = 5;
int b = 10;
// Далее используем оператор spaceship
int result = a <=> b;
if (result < 0)
{
// a меньше b
}
else if (result == 0)
{
// a равно b
}
else if (result > 0)
{
// a больше b
}
В приведенном коде мы объявляем два целых числа, a и b, а затем используем
оператор <=>, чтобы сравнить их. Результат сравнения сохраняется в переменной
result типа int. Помните, что возвращаемое значение будет отрицательным,
нулевым или положительным, что означает, что a меньше, равно или больше b
соответственно.
Затем мы проверяем значение result, чтобы определить отношение между a и b,
и реагируем в зависимости от result.
На самом деле этот упрощенный пример кода скрывает некоторую дополнительную информацию. Результат использования оператора <=> на самом деле возвращает новый тип C++ под названием strong_ordering. К счастью, strong_ordering
может быть преобразован в int. Тип strong_ordering представляет собой результат трехстороннего сравнения.
Резюме
Поздравляю: вторая игра завершена! Мы могли бы добавить в эту игру больше
возможностей вроде совместной игры, таблицы рекордов и звуковых эффектов,
но я хотел использовать максимально простой пример, чтобы познакомить вас
с классами и AABB-методом обнаружения коллизий. Теперь, когда в нашем
арсенале разработчика игр есть эти знания, мы можем перейти к более захватывающему проекту и еще более интересным темам в разработке игр.
В следующей главе мы составим план игры Zombie Arena, рассмотрим класс
View библиотеки SFML, который выступает в роли виртуальной камеры в нашем
игровом мире, и напишем еще несколько классов.
Часто задаваемые вопросы 211
Часто задаваемые вопросы
В. Не слишком ли тихая эта игра?
О. Я не стал добавлять звуковые эффекты, потому что хотел сделать код как
можно короче, но при этом показать работу классов и объяснить, как использовать время для плавной анимации всех игровых объектов. Если вы хотите
добавить звуковые эффекты, вам нужно присоединить файлы .wav к проекту,
использовать SFML для загрузки звуков и воспроизводить звук каждый раз,
когда мяч сталкивается с ракеткой или при любом другом событии. Звуками мы
займемся в следующем проекте.
В. Игра слишком легкая! Как ускорить мяч?
О. Есть много способов сделать игру более сложной. Один из простых — добавить строку кода в функцию reboundBatOrTop класса Ball. Например, следующий
код будет увеличивать скорость мяча на 10 % при каждом вызове функции:
// Немного увеличиваем скорость при каждом столкновении
m_Speed = m_Speed * 1.1f;
Мяч будет довольно быстро ускоряться. Затем вам нужно будет придумать способ сбросить скорость к изначальному значению, когда игрок потеряет все свои
жизни. Вы можете создать новую функцию в классе Ball, скажем resetSpeed,
и вызывать ее из main, когда код обнаружит, что у игрока закончились жизни.
В. Назовите одно преимущество и один недостаток AABB-метода обнаружения
коллизий.
О. AABB эффективен с точки зрения вычислений, что делает его подходящим
для таких ресурсоемких приложений, как игры, где необходимо часто проверять
коллизии. Он также прост для понимания, но это уже два преимущества. Однако
он не может предоставить точную информацию о коллизиях для объектов неправильной формы.
8
Использование области
отображения и класса View
в SFML: зомби-шутер
В данном проекте мы будем активно применять принципы ООП. Мы также
изучим класс View библиотеки SFML. Этот универсальный класс позволит нам
легко разделить нашу игру на слои: слой для HUD и слой для основной игры.
Это необходимо, потому что игровой мир будет расширяться после каждой
уничтоженной волны зомби. В итоге он станет больше экрана и его придется
прокручивать. Использование класса View предотвратит прокрутку текста HUD
вместе с фоном.
Исходный код этой главы вы найдете в репозитории GitHub: https://github.com/
PacktPublishing/Beginning-C-Game-Programming-Third-Edition/tree/main/ZombieShooter.
Планирование и начало игры Zombie Arena
На данном этапе, если вы еще не сделали этого, я предлагаю вам посмотреть видео об игре Over 9000 Zombies (http://store.steampowered.com/app/273500/) и Crimson
Land (http://store.steampowered.com/app/262830/). Наша игра, очевидно, не будет
такой глубокой и продвинутой, как эти, но мы реализуем те же базовые наборы
функций и игровые механики, например:
zzHUD, в котором отображаются такая информация, как счет, рекорд, общее
количество патронов и число оставшихся в обойме, уровень здоровья игрока
и количество зомби, которых осталось убить;
zzстрельба на бегу;
zzиспользование клавиш WASD для перемещения по игровому миру и мышь для
прицеливания;
zzпосле каждого уровня игрок будет выбирать «улучшение», которое повлияет
на процесс прохождения игры;
zzигроку нужно будет подбирать боеприпасы и предметы, позволяющие восстановить здоровье;
zzкаждая последующая волна зомби больше предыдущей, а вместе с этим увеличивается и размер арены.
Планирование и начало игры Zombie Arena 213
В игре будет три типа зомби. У них будут разные атрибуты, такие как внешний
вид, здоровье и скорость. Мы назовем их «охотниками», «толстяками» и «ползунами». Один из них будет быстрым, другой — крупным, а третий — медленным.
На рис. 8.1 показаны некоторые функции в действии, а также компоненты и графические ресурсы, из которых состоит игра.
Рис. 8.1. Функции, компоненты и графические ресурсы, из которых состоит игра
Вот более подробная информация о каждом из пронумерованных элементов:
zzсчет и текущий рекорд (эти элементы, как и другие части HUD, будут отрисо-
ваны в отдельном слое, называемом областью отображения, и представлены
экземпляром класса View. Рекорд будет сохранен и выгружен в файл);
zzтекстура для стены вокруг арены (текстура содержится в одном графическом
файле, называемом спрайт-листом, вместе с другими фоновыми текстурами
(элементы 3, 5 и 6));
zzпервая из двух текстур грязи из спрайт-листа;
zzаптечка и обойма (эти предметы игрок может улучшать в перерывах между
волнами зомби);
zzтекстура травы (также из спрайт-листа);
zzвторая текстура грязи из спрайт-листа;
zzкровавое пятно (оставшееся от зомби);
zzнижняя часть HUD (слева направо показаны иконка, обозначающая патроны,
количество патронов в обойме, количество патронов в запасе, шкала здоровья,
текущая волна зомби и количество оставшихся зомби в волне);
zzперсонаж, управляемый игроком;
zzприцел;
zzмедленный, но сильный зомби-толстяк;
214 Глава 8. Использование области отображения и класса View в SFML
zzнемного более быстрый, но слабый зомби-ползун, а также быстрый и слабый
зомби-охотник (к сожалению, мне не удалось запечатлеть последнего на
скриншоте, поскольку они все были убиты).
Итак, нам многое предстоит сделать. Начнем с создания нового проекта.
Создание нового проекта
Поскольку настройка проекта — процесс непростой, мы пройдем его шаг за
шагом, как это было в случае с игрой Timber!. Я не буду показывать вам те же
скриншоты, но поскольку процесс не отличается, вы можете вернуться к главе 1,
если забыли, где расположены различные свойства проекта.
1. Запустите Visual Studio и нажмите кнопку Create New Project (Создать новый проект). Если у вас открыт другой проект, выберите FileCreateProject
(ФайлСоздатьПроект).
2. В появившемся окне выберите Console App (Консольное приложение) и нажмите кнопку Next (Далее). После этого появится окно настройки нового проекта.
3. В окне настройки нового проекта введите Zombie Arena в поле Project name
(Имя проекта).
4. В поле Location (Расположение) укажите путь к папке VS Projects.
5. Установите флажок Place solution and project in the same directory (Поместить решение и проект в одном каталоге).
6. После выполнения предыдущих шагов нажмите кнопку Create (Создать).
7. Теперь нужно настроить проект для работы с файлами SFML, которые мы поместили в папку SFML. В главном меню выберите ProjectZombie Arena properties
(ПроектСвойства Zombie Arena). На этом этапе у вас должно быть открыто
окно Zombie Arena Property Pages (Страницы свойств проекта Zombie Arena).
8. В этом окне выберите All Configurations (Все конфигурации) в поле Configuration
(Конфигурация) и убедитесь, что в раскрывающемся списке Platform (Платформа) установлено значение Win32, а не x64.
9. Далее в меню слева выберите C/C++, а затем General (Общие).
10. Найдите поле Additional Include Directories (Дополнительные каталоги включа
емых файлов) и введите букву диска, на котором находится ваша папка SFML,
а затем \SFML\include. Если ваша папка SFML располагается на диске D, то
полный путь будет D:\SFML\include. Измените путь, если вы разместили SFML
на другом диске.
11. Нажмите кнопку Apply (Применить), чтобы сохранить настройки.
12. Теперь, не закрывая окно, выберите Linker (Компоновщик), а затем General
(Общие).
Планирование и начало игры Zombie Arena 215
13. Найдите поле редактирования Additional Library Directories (Дополнительные
каталоги библиотек) и введите букву диска, на котором находится ваша папка SFML, а затем \SFML\lib. Таким образом, если вы разместили папку SFML на
диске D, то полный путь будет выглядеть так: D:\SFML\lib. Измените путь,
если файлы SFML располагаются на другом диске.
14. Нажмите кнопку Apply (Применить), чтобы сохранить сделанные настройки.
15. Выберите Linker (Компоновщик), а затем Input (Ввод).
16. Найдите поле редактирования Additional Dependencies (Дополнительные зависимости), щелкните по нему в крайнем левом углу и наберите следующий текст: sfmlgraphics-d.lib;sfml-window-d.lib;sfmlsystem-d.lib;sfml-network-d.lib;
sfml-audio-d.lib;. Будьте внимательны: курсор нужно расположить точно
в начале текущего содержимого поля редактирования, чтобы не перезаписать
уже имеющийся там текст.
17. Нажмите кнопку OK.
18. Нажмите кнопку Apply (Применить), затем OK.
19. В главном окне Visual Studio убедитесь, что рядом с выпадающим списком
Debug выбрано x86, а не x64.
Почти все готово к работе. Осталось лишь скопировать файлы библиотеки SFML
с расширением .dll в основную папку проекта.
1. Мой основной каталог проекта — D:\VS Projects\Zombie Arena. Он был создан
Visual Studio в предыдущих шагах. Если вы разместили папку VS Projects
в другом месте, то выполните этот шаг там. Файлы, которые нужно скопировать, находятся в вашей папке SFML\bin. Выделите все файлы в ней.
2. Затем скопируйте и вставьте выделенные файлы в проект (например, в D:\VS
Projects\Zombie Arena).
Теперь проект настроен и готов к работе. Далее мы изучим и добавим ресурсы
проекта.
Ресурсы проекта
Ресурсы в данном проекте более многочисленны и разнообразны, чем в предыдущих. Среди них:
zzшрифты для текста на экране;
zzзвуковые эффекты для различных действий, таких как стрельба, перезарядка
или удар зомби;
zzизображения для игрового персонажа, зомби и спрайт-лист для различных
фоновых текстур.
216 Глава 8. Использование области отображения и класса View в SFML
Все изображения и звуковые эффекты, необходимые для игры, включены в пакет
загрузки. Их можно найти в папках Chapter 8/graphics и Chapter 8/sound соответственно.
Шрифт, который требуется, я не включил в пакет загрузки, так как хотел избежать возможных вопросов с лицензией. Однако это не вызовет проблем, поскольку я подробно объясню, где и как выбрать и скачать его.
Обзор ресурсов
Графические ресурсы составляют части сцены игры Zombie Arena. Взгляните
на них на рис. 8.2. Вам должно быть понятно, где они будут использоваться
в игре.
Рис. 8.2. Графические ресурсы
Однако менее очевидным может быть файл background_sheet.png, который содержит четыре разных изображения. Это спрайт-лист, о котором мы упоминали
ранее. В главе 9 мы увидим, как можно сэкономить память и увеличить скорость
игры с помощью спрайт-листа.
Все звуковые файлы представлены в формате .wav . Это файлы, содержащие
звуковые эффекты, которые будут воспроизводиться при наступлении определенных событий:
zzhit.wav — звук столкновения зомби с игроком;
zzpickup.wav — звук подбора аптечки;
ООП и проект Zombie Arena 217
zzpowerup.wav — воспроизводится, когда игрок прокачивается между волнами
зомби;
zzreload.wav — звук успешной перезарядки;
zzreload_failed.wav — звук неудачной перезарядки;
zzshoot.wav — звук стрельбы;
zzsplat.wav — звук попадания пули в зомби.
Теперь пришло время добавить наши ресурсы в проект.
Добавление ресурсов в проект
В следующих инструкциях предполагается, что вы используете все ресурсы,
которые были предоставлены в пакете загрузки. Если вы задействуете собственные ассеты, просто замените соответствующий звуковой или графический файл
своим и переименуйте его.
1. Перейдите в папку D:\VS Projects\Zombie Arena.
2. Создайте здесь три новые папки и назовите их graphics, sound и fonts.
3. Из загруженного пакета скопируйте все содержимое папки Chapter 8/graphics
в папку D:\VS Projects\Zombie Arena\graphics.
4. Из загруженного пакета скопируйте все содержимое папки Chapter 8/sound
в папку D:\VS Projects\Zombie Arena\sound.
5. Посетите сайт http://www.1001freefonts.com/zombie_control.font и скачайте шрифт
Zombie Control.
6. Извлеките содержимое загруженного архива и добавьте файл zombiecontrol.ttf
в папку D:\VS Projects\Zombie Arena\fonts.
Теперь поговорим о том, как ООП поможет нам в этом проекте, а затем приступим к написанию кода.
ООП и проект Zombie Arena
Первая проблема, с которой мы столкнемся, — это сложность текущего проекта.
Предположим, что у нас есть только один зомби. Вот что нам нужно, чтобы он
функционировал в игре:
zzего положение по горизонтали и вертикали;
zzего размер;
zzнаправление, в котором он смотрит;
zzразличные текстуры для каждого типа зомби;
zzспрайт;
218 Глава 8. Использование области отображения и класса View в SFML
zzразличная скорость для каждого типа зомби;
zzразличное здоровье для каждого типа зомби;
zzотслеживание типа каждого зомби;
zzданные для обнаружения коллизий;
zzискусственный интеллект (чтобы преследовать игрока), который немного
различается для каждого типа зомби;
zzиндикатор того, жив или мертв зомби.
Это предполагает около дюжины переменных для одного зомби и целые массивы
этих переменных для управления ордой зомби. Но как быть со всеми пулями,
бонусными предметами и различными улучшениями уровня? Код из гораздо
более простых игр Timber! и Pong тоже начал становиться неуправляемым,
и легко предположить, что новый более сложный проект будет во много раз хуже!
К счастью, мы применим все навыки ООП, полученные ранее, а также освоим
несколько новых приемов работы с C++.
Создание игрового персонажа — первый класс
Давайте подумаем, что должен делать наш класс Player и что нам для этого нужно.
У класса должна быть информация, с какой скоростью может двигаться герой,
в какой точке игрового мира он сейчас находится и сколько у него здоровья. Поскольку класс Player представлен в виде двухмерного изображения персонажа,
классу понадобятся объекты Sprite и Texture.
Кроме того, хотя причины могут быть неочевидны на данный момент, нашему
классу Player также полезно знать несколько деталей об общей среде, в которой
работает игра. Эти детали включают разрешение экрана, размер плиток, из которых состоит арена, и общий размер текущей арены.
Поскольку класс Player будет полностью отвечать за обновление себя в каждом
кадре (как это было с ракеткой и мячом), ему необходима информация о намерениях игрока в любой момент времени. Например, удерживает ли игрок в данный момент клавишу, отвечающую за движение? Или, например, удерживает ли
игрок в данный момент несколько таких клавиш? Для определения состояния
клавиш W, A, S и D будут использоваться логические переменные.
Очевидно, что нам понадобится довольно большой набор переменных в нашем
новом классе. Учитывая все, что мы знаем об ООП, мы, конечно же, сделаем все
эти переменные приватными. Таким образом, мы должны обеспечить доступ
к ним, где это необходимо, из функции main.
Мы будем использовать множество геттеров, а также несколько функций для настройки нашего объекта. Таких функций довольно много. В этом классе 21 функция.
Создание игрового персонажа — первый класс 219
Сначала это может показаться немного пугающим, но мы рассмотрим их все
и увидим, что большинство из них просто задают или получают значение одной
из приватных переменных.
Есть лишь несколько слегка непонятных функций: update, которая будет вызываться покадрово из функции main, и spawn для инициализации некоторых приватных переменных при каждом появлении персонажа. Как мы увидим, ничего
сложного здесь нет.
Лучший способ продолжить работу — написать заголовочный файл. Это даст
нам возможность увидеть все приватные переменные и изучить все сигнатуры
функций.
СОВЕТ
Обратите особое внимание на возвращаемые значения и типы аргументов, так как
это значительно облегчит понимание кода в определениях функций.
Создание заголовочного файла класса Player
Щелкните правой кнопкой мыши на Header Files (Файлы заголовков) в Solution
Explorer (Обозреватель решений) и выберите AddNew Item (ДобавитьСоздать
элемент). Далее выберите пункт Header File (.h) (Файл заголовка) и введите
Player.h в поле Name (Имя). Затем нажмите кнопку Add (Добавить).
Начнем писать класс Player с добавления объявления:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Player
{
};
Теперь добавим в файл все наши приватные переменные-члены. Основываясь
на том, что мы уже обсудили, попробуйте разобраться, за что отвечает каждая из
них. Мы рассмотрим их по отдельности чуть позже.
class Player
{
private:
const float START_SPEED = 200;
const float START_HEALTH = 100;
// Где находится игровой персонаж
Vector2f m_Position;
// Спрайт
Sprite m_Sprite;
220 Глава 8. Использование области отображения и класса View в SFML
};
// Текстура
// !!Внимание — здесь скоро будут изменения!!
Texture m_Texture;
// Разрешение экрана
Vector2f m_Resolution;
// Размер текущей арены
IntRect m_Arena;
// Размер каждой плитки арены
int m_TileSize;
// Определение направления движения персонажа
bool m_UpPressed;
bool m_DownPressed;
bool m_LeftPressed;
bool m_RightPressed;
// Количество очков здоровья
int m_Health;
// Максимально возможное количество очков здоровья у героя
int m_MaxHealth;
// Когда герой был последний раз ранен
Time m_LastHit;
// Скорость в пикселях в секунду
float m_Speed;
// Далее будут все наши публичные функции
В представленном коде объявлены все наши переменные-члены. Некоторые из
них являются обычными переменными, а некоторые — объектами. Обратите внимание, что все они находятся в разделе private класса и, следовательно, не имеют
прямого доступа извне класса.
Следует также отметить, что мы добавляем префикс m_ ко всем именам непостоянных переменных. Префикс m_ будет напоминать нам при написании определений функций, что это переменные-члены, отличные от локальных переменных,
которые мы будем создавать в некоторых функциях, а также от параметров
функций.
Все используемые переменные просты, например m_Position, m_Texture и m_Sprite,
которые обозначают текущее местоположение, текстуру и спрайт игрока соответственно. Кроме того, каждая переменная (или группа переменных) сопровождается поясняющим комментарием.
Однако зачем именно они нужны и в каком контексте будут использоваться,
может быть не так очевидно. Например, m_LastHit, который является объектом
типа Time, предназначен для записи времени, когда игрок в последний раз получил урон от зомби. Не совсем очевидно, зачем нам может понадобиться эта
информация, но мы скоро об этом поговорим.
По мере того как мы будем собирать остальную часть игры, контекст для каждой
из переменных станет более ясным. Пока же важно ознакомиться с именами и типами данных, чтобы в дальнейшем не испытывать проблем при работе с проектом.
Создание игрового персонажа — первый класс 221
СОВЕТ
Вам не нужно запоминать имена переменных и их типы, поскольку мы будем обсуждать
весь код по мере его использования. Тем не менее вам стоит уделить время тому,
чтобы изучить их. Кроме того, по ходу работы стоит обращаться к этому заголовочному файлу, если что-то покажется непонятным.
Теперь мы можем добавить полный длинный список функций. Взгляните на следующий выделенный код и попробуйте понять, что он делает. Обратите особое
внимание на типы возвращаемых значений, параметры и имена функций. Это
ключ к пониманию кода, который мы будем писать на протяжении всего проекта.
Что они говорят нам о каждой функции? Добавьте следующий выделенный код,
а затем мы рассмотрим его.
// Все наши публичные функции
public:
Player();
void spawn(IntRect arena, Vector2f resolution, int tileSize);
// Вызывается в конце каждой игры
void resetPlayerStats();
// Обработка удара зомби по игровому персонажу
bool hit(Time timeHit);
// Когда персонаж был последний раз ранен
Time getLastHitTime();
// Где находится персонаж
FloatRect getPosition();
// Где находится центр персонажа
Vector2f getCenter();
// В каком направлении смотрит герой
float getRotation();
// Отправляем копию спрайта в основную функцию
Sprite getSprite();
// Следующие четыре функции отвечают за перемещение персонажа
void moveLeft();
void moveRight();
void moveUp();
void moveDown();
// Останавливают движение персонажа
void stopLeft();
void stopRight();
void stopUp();
void stopDown();
// Эта функция будет вызываться каждый кадр
void update(float elapsedTime, Vector2i mousePosition);
// Дает игровому персонажу ускорение
void upgradeSpeed();
// Дает здоровье
void upgradeHealth();
222 Глава 8. Использование области отображения и класса View в SFML
};
// Увеличивает максимально возможное количество здоровья персонажа
void increaseHealthLevel(int amount);
// Сколько здоровья у героя в текущий момент?
int getHealth();
Обратите внимание, что все функции являются публичными. Это означает, что
мы можем вызвать их, используя экземпляр класса из функции main, с помощью
кода, подобного этому:
player.getSprite();
Если предположить, что player — это полностью настроенный экземпляр класса
Player, то предыдущий код вернет копию m_Sprite. Если поместить этот код в реальный контекст, то в функции main мы могли бы написать такой код:
window.draw(player.getSprite());
Данный код отрисует изображение игрового персонажа в его текущем местоположении, как если бы спрайт был объявлен в самой функции main. Именно так
мы поступили с классом Bat в проекте Pong.
Прежде чем мы перейдем к реализации (то есть написанию определений) этих
функций в соответствующем файле с расширением .cpp, рассмотрим каждую из
них подробнее:
zzvoid spawn(IntRect arena, Vector2f resolution, int tileSize): эта функция
подготавливает объект к использованию, в том числе помещает его в исходной
позиции (то есть в точке генерации). Обратите внимание, что она не возвращает никаких данных, но принимает три аргумента. Она получает экземпляр
IntRect под названием arena, который будет обозначать размер и расположение текущего уровня, экземпляр Vector2f, содержащий разрешение экрана,
и int, содержащий размер фоновой плитки;
zzvoid resetPlayerStats : после того как мы дадим игроку возможность повышать уровень (прокачиваться) между волнами, нам также понадобится
и сбрасывать эти способности в начале новой игры;
zzTime getLastHitTime(): возвращает время, когда героя в последний раз ударил
зомби. Мы будем использовать эту функцию при обнаружении коллизий,
и она позволит нам убедиться, что игрок не будет слишком часто наказываться
за контакт с зомби;
zzFloatRect getPosition(): возвращает экземпляр FloatRect, который описывает горизонтальные и вертикальные координаты прямоугольника, содержащего
изображение персонажа. Это также полезно для обнаружения коллизий;
zzVector2f getCenter(): функция, немного отличающаяся от getPosition тем,
что она имеет тип Vector2f и содержит только координаты X и Y центральной
части изображения игрового персонажа;
Создание игрового персонажа — первый класс 223
zzfloat getRotation(): определяет в градусах, в какую сторону повернет герой
в данный момент, 3 часа — это 0 градусов, и значение увеличивается по часовой стрелке;
zzSprite getSprite(): возвращает копию спрайта, представляющего героя;
zzvoid moveLeft(), ..Right(), ..Up(), ..Down(): эти четыре функции не имеют
возвращаемого типа или параметров. Они будут вызываться из функции main,
и класс Player сможет реагировать, когда одна или несколько клавиш WASD
будут нажаты;
zzvoid stopLeft() , ..Right() , ..Up() , ..Down() : эти четыре функции также
не имеют возвращаемого типа и параметров. Они будут вызываться из функции main, и класс Player сможет реагировать, когда одна или несколько клавиш WASD будут отпущены;
zzvoid update(float elapsedTime, Vector2i mousePosition): это единственная
длинная функция во всем классе. Она будет вызываться один раз за кадр из
main и делать все необходимое, чтобы данные объекта player были обновлены и он был готов к обнаружению коллизий и отрисовке. Учтите, что она
не возвращает никаких данных, но получает количество прошедшего времени
с момента последнего кадра, а также экземпляр Vector2i, который должен
содержать координаты курсора/прицела на экране;
ПРИМЕЧАНИЕ
Обратите внимание, что это целочисленные координаты на экране и они отличаются
от внутренних координат с плавающей точкой.
zzvoid upgradeSpeed(): функция, которая может быть вызвана через меню повы-
шения уровня, когда игрок выбирает увеличение скорости персонажа;
zzvoid upgradeHealth(): еще одна функция, которая может быть вызвана через меню повышения уровня, когда игрок выбирает увеличение здоровья
персонажа;
zzvoid increaseHealthLevel(int amount): тонкое, но важное отличие от предыдущей функции заключается в том, что данная функция увеличивает количество
здоровья игрока до текущего максимума. Эта функция будет использоваться,
когда игрок подбирает аптечку;
zzint getHealth(): поскольку уровень здоровья может динамически изменяться, нам нужно иметь возможность определить его текущее значение. Данная
функция возвращает int, в котором оно хранится.
Как и в случае с переменными, теперь должно быть понятно, для чего нужна
каждая из функций. Однако зачем и в каком контексте использовать некоторые
из них, станет ясно только по мере продвижения проекта.
224 Глава 8. Использование области отображения и класса View в SFML
СОВЕТ
Вам не нужно запоминать имена функций, типы возвращаемых значений или параметры, поскольку мы будем обсуждать код по мере его использования. Однако вам
стоит потратить время на то, чтобы просмотреть их вместе с предыдущими объяснениями и подробнее изучить. Кроме того, по ходу работы не стесняйтесь обращаться
к заголовочному файлу, если что-то покажется непонятным.
Теперь перейдем к сути наших функций — определениям.
Создание определений функций класса Player
Наконец, мы можем начать писать код, который выполняет работу нашего класса.
Щелкните правой кнопкой мыши на Source Files (Исходные файлы) в Solution
Explorer (Обозреватель решений) и выберите AddNew Item (ДобавитьСоздать
элемент). Далее выберите пункт C++ File (.cpp) (Файл C++) и введите Player.cpp
в поле Name (Имя). Наконец, нажмите кнопку Add (Добавить).
ПРИМЕЧАНИЕ
Отныне я буду просто просить вас создать новый класс или заголовочный файл. Так
что постарайтесь запомнить предыдущий шаг. Однако вы всегда можете вернуться
сюда, если вам понадобится напоминание.
Теперь мы готовы написать код для нашего первого класса в этом проекте.
Ниже представлены необходимые директивы include , за которыми следует
определение конструктора. Помните, что конструктор будет вызываться при
первом инстанцировании объекта типа Player. Добавьте следующий код в файл
Player.cpp:
#include "player.h"
Player::Player()
: m_Speed(START_SPEED),
m_Health(START_HEALTH),
m_MaxHealth(START_HEALTH),
m_Texture(),
m_Sprite()
{
// Связываем текстуру со спрайтом
// !!Обратите внимание на это место!!
m_Texture.loadFromFile("graphics/player.png");
m_Sprite.setTexture(m_Texture);
Создание игрового персонажа — первый класс 225
}
// Устанавливаем начало спрайта в центр
// для плавного вращения
m_Sprite.setOrigin(25, 25);
В конструкторе, который, конечно же, имеет то же имя, что и класс, и не имеет
возвращаемого типа, мы пишем код, который подготавливает объект Player к использованию.
То есть данный код будет выполняться, когда мы напишем следующий код
в функции main:
Player player;
Не добавляйте пока эту строку кода.
Переменные-члены m_Speed, m_Health, m_MaxHealth, m_Texture и m_Sprite инициализируются в списке инициализаторов. Это считается хорошей практикой
и гарантирует, что члены инициализируются до входа в тело конструктора.
Все, что мы делаем в теле конструктора, — загружаем изображение игрового
персонажа в m_Texture, связываем m_Texture с m_Sprite и устанавливаем начало
координат m_Sprite в центр, (25, 25).
ПРИМЕЧАНИЕ
Обратите внимание на загадочный комментарий // !!Обратите внимание на это
место!!, указывающий на то, что мы еще вернемся к загрузке нашей текстуры
и некоторым важным вопросам, связанным с ней. В конечном счете, мы изменим
способ работы с этой текстурой, когда узнаем немного больше о C++ в главе 10.
Далее мы напишем функцию spawn. Мы создадим только один экземпляр класса Player. Однако нам нужно будет размещать его на текущем уровне для каждой волны. Именно этим и займется функция spawn. Добавьте следующий код
в файл Player.cpp и обязательно изучите детали и прочитайте комментарии:
void Player::spawn(IntRect arena,
Vector2f resolution,
int tileSize)
{
// Размещаем игрового персонажа в центре арены
m_Position.x = arena.width / 2;
m_Position.y = arena.height / 2;
// Копируем детали арены в m_Arena
m_Arena.left = arena.left;
m_Arena.width = arena.width;
m_Arena.top = arena.top;
226 Глава 8. Использование области отображения и класса View в SFML
}
m_Arena.height = arena.height;
// Запоминаем размер плиток на этой арене
m_TileSize = tileSize;
// Сохраняем разрешение для последующего использования
m_Resolution.x = resolution.x;
m_Resolution.y = resolution.y;
Код начинается с инициализации значений m_Position.x и m_Position.y, равных
половине высоты и ширины переданного объекта arena. Это приводит к перемещению игрового персонажа в центр уровня, независимо от его размера.
Далее мы копируем все координаты и размеры arena в m_Arena. Теперь мы можем
использовать m_Arena, например, для предотвращения прохождения героя сквозь
стены. Кроме того, мы копируем переданный экземпляр tileSize в переменнуючлен m_TileSize для той же цели. Мы увидим m_Arena и m_TileSize в действии
в функции update.
Последние две строки из предыдущего кода копируют разрешение экрана
из экземпляра Vector2f с именем resolution, являющегося параметром spawn,
в m_Resolution, которая является переменной-членом Player. Теперь у нас есть
доступ к этим значениям внутри класса Player.
Добавьте очень простой код функции resetPlayerStats:
void Player::resetPlayerStats()
{
m_Speed = START_SPEED;
m_Health = START_HEALTH;
m_MaxHealth = START_HEALTH;
}
С помощью этой функции мы сможем сбросить все улучшения, которые игрок
использовал до гибели героя. Мы не будем писать код, вызывающий функцию
resetPlayerStats, почти до самого конца проекта.
В следующей части кода мы добавим еще две функции. Они будут обрабатывать
событие, которое происходит, когда героя бьет зомби. Мы сможем вызывать
player.hit() и передавать ей текущее игровое время. Или узнать, когда героя
последний раз ударили, вызвав player.getLastHitTime(). Насколько полезны
эти функции, станет ясно, когда у нас появятся зомби.
Добавьте два новых определения в файл Player.cpp, а затем мы рассмотрим код
C++ немного подробнее:
Time Player::getLastHitTime()
{
return m_LastHit;
}
Создание игрового персонажа — первый класс 227
bool Player::hit(Time timeHit)
{
if (timeHit.asMilliseconds()
- m_LastHit.asMilliseconds() > 200)
{
m_LastHit = timeHit;
m_Health -= 10;
return true;
}
else
{
return false;
}
}
Код функции getLastHitTime() очень прост: она возвращает значение, хранящееся в m_LastHit.
Функция hit имеет несколько нюансов. Во-первых, оператор if проверяет, является ли время, переданное в качестве параметра, на 200 миллисекунд больше,
чем время, хранящееся в m_LastHit. Если да, то m_LastHit обновляется с учетом
переданного времени, а из m_Health вычитается 10. Последняя строка кода в этом
операторе if — return true. Обратите внимание, что блок else просто возвращает
false вызывающему коду.
Смысл этой функции в том, что очки здоровья будут отниматься у игрока не более пяти раз в секунду. Помните, что наш игровой цикл может выполняться со
скоростью тысячи итераций в секунду. Без подобного ограничения зомби достаточно было бы находиться в контакте с игровым персонажем только одну секунду, чтобы лишить его всего здоровья. Функция hit контролирует и ограничивает
это явление. Она также сообщает вызывающему коду, было ли зарегистрировано
новое попадание (или нет), возвращая true или false.
Данный код подразумевает, что мы будем обнаруживать коллизии между зомби
и игровым персонажем в функции main. Затем мы вызовем player.hit(), чтобы
определить, нужно ли вычитать очки здоровья.
Далее для класса Player мы реализуем несколько геттер-функций. Они позволят
нам сохранить данные, аккуратно инкапсулированные в классе Player, в то время
как их значения будут доступны функции main.
Добавьте следующий код сразу после предыдущего блока:
FloatRect Player::getPosition()
{
return m_Sprite.getGlobalBounds();
}
Vector2f Player::getCenter()
{
return m_Position;
}
228 Глава 8. Использование области отображения и класса View в SFML
float Player::getRotation()
{
return m_Sprite.getRotation();
}
Sprite Player::getSprite()
{
return m_Sprite;
}
int Player::getHealth()
{
return m_Health;
}
Представленный код очень прост. Каждая из пяти функций возвращает значение
одной из наших переменных-членов. Внимательно посмотрите на каждую из них
и ознакомьтесь с тем, какая функция возвращает то или иное значение.
Следующие восемь коротких функций активируют элементы управления клавиатурой (которые мы будем использовать из функции main), чтобы мы могли
изменять данные, содержащиеся в нашем объекте типа Player. Добавьте код,
представленный ниже, в файл Player.cpp:
void Player::moveLeft()
{
m_LeftPressed = true;
}
void Player::moveRight()
{
m_RightPressed = true;
}
void Player::moveUp()
{
m_UpPressed = true;
}
void Player::moveDown()
{
m_DownPressed = true;
}
void Player::stopLeft()
{
m_LeftPressed = false;
}
void Player::stopRight()
{
m_RightPressed = false;
}
void Player::stopUp()
{
m_UpPressed = false;
}
void Player::stopDown()
{
m_DownPressed = false;
}
Создание игрового персонажа — первый класс 229
В представленном коде первые четыре функции (moveLeft, moveRight, moveUp
и moveDown) устанавливают соответствующие логические переменные (m_Left
Pressed, m_RightPressed, m_UpPressed и m_DownPressed) в true. Остальные же
(stopLeft, stopRight, stopUp и stopDown) выполняют противоположные действия
и устанавливают те же логические переменные в false. Теперь у экземпляра
класса Player может появиться информация о том, какие клавиши WASD были
нажаты, а какие нет.
Следующая функция выполняет всю сложную работу. Функция update будет
вызываться один раз в каждом кадре нашего игрового цикла. Если вы изучили
предыдущие восемь функций и помните, как мы анимировали облака и пчел для
проекта Timber! и ракетку с мячом для игры Pong, вы, вероятно, поймете большую часть следующего кода (добавьте его следом):
void Player::update(float elapsedTime, Vector2i mousePosition)
{
if (m_UpPressed)
{
m_Position.y -= m_Speed * elapsedTime;
}
if (m_DownPressed)
{
m_Position.y += m_Speed * elapsedTime;
}
if (m_RightPressed)
{
m_Position.x += m_Speed * elapsedTime;
}
if (m_LeftPressed)
{
m_Position.x -= m_Speed * elapsedTime;
}
m_Sprite.setPosition(m_Position);
// Удерживаем игрового персонажа на арене
if (m_Position.x > m_Arena.width - m_TileSize)
{
m_Position.x = m_Arena.width - m_TileSize;
}
if (m_Position.x < m_Arena.left + m_TileSize)
{
m_Position.x = m_Arena.left + m_TileSize;
}
if (m_Position.y > m_Arena.height - m_TileSize)
{
m_Position.y = m_Arena.height - m_TileSize;
}
if (m_Position.y < m_Arena.top + m_TileSize)
{
m_Position.y = m_Arena.top + m_TileSize;
}
// Вычисляем, под каким углом повернут персонаж
float angle = (atan2(mousePosition.y - m_Resolution.y / 2,
230 Глава 8. Использование области отображения и класса View в SFML
}
mousePosition.x - m_Resolution.x / 2)
* 180) / 3.141;
m_Sprite.setRotation(angle);
Первая часть предыдущего кода перемещает спрайт игрового персонажа. Четыре
оператора if проверяют, какие из связанных с перемещением логических переменных (m_LeftPressed, m_RightPressed, m_UpPressed или m_DownPressed) имеют
значение true, и изменяют m_Position.x и m_Position.y соответственно. Для расчета величины перемещения используется та же формула, что и в предыдущих
двух проектах: положение (+ или –) скорость * затраченное время.
После этих четырех операторов if вызывается m_Sprite.setPosition и передается m_Position. Теперь спрайт отрегулирован на нужную величину для данного
кадра.
Оставшиеся операторы if проверяют, не выходит ли m_Position.x или m_Posi
tion.y за границы арены, которые были сохранены в m_Arena в функции spawn.
Рассмотрим первый из этих операторов if, чтобы понять суть:
if (m_Position.x > m_Arena.width - m_TileSize)
{
m_Position.x = m_Arena.width - m_TileSize;
}
Данный код проверяет, больше ли m_position.x, чем m_Arena.width, минус размер
плитки (m_TileSize). Как мы увидим при создании фона, этот расчет позволит
обнаружить, что игрок уперся в стену.
Когда оператор if равен true, расчет m_Arena.width - m_TileSize используется
для инициализации m_Position.x. Это означает, что центр графического изображения игрока никогда не сможет пересечь правую стену.
Следующие три оператора if, которые идут следом, делают то же самое, но для
других стен.
Последние две строки в приведенном коде вычисляют и устанавливают угол,
на который повернут (то есть обращен) спрайт игрового персонажа. Эта строка
кода может показаться немного сложной, поэтому давайте разберем ее подробнее:
// Вычисляем, под каким углом повернут игровой персонаж
float angle = (atan2(mousePosition.y - m_Resolution.y / 2,
mousePosition.x - m_Resolution.x / 2)
* 180) / 3.141;
m_Sprite.setRotation(angle);
Вкратце, код вычисляет угол между центром экрана и текущим положением
курсора. Затем он устанавливает ориентацию спрайта, представляющего игрока,
на основе этого угла.
Создание игрового персонажа — первый класс 231
Сначала код вычисляет угол:
atan2(mousePosition.y - m_Resolution.y / 2,
mousePosition.x - m_Resolution.x / 2)
Функция atan2 используется для вычисления угла, образованного воображаемой
линией, проведенной между центром экрана (m_Resolution.x / 2, m_Resolution.y / 2)
и текущим положением мыши (mousePosition.x, mousePosition.y).
Результат этого вычисления выражен в радианах, но SFML работает с градусами.
Следующая часть той же строки кода преобразует радианы в градусы:
* 180
Затем, разделив получившееся значение на число пи, вы получите угол в диапазоне от 0 до 360. Это означает, что угол находится в пределах полного круга.
/ 3.141
Наконец, мы устанавливаем ориентацию спрайта:
m_Sprite.setRotation(angle);
Я сильно упростил работу функции atan, но для этого и существуют функции.
Это мое оправдание, и я его придерживаюсь. Если вы хотите углубиться в математическую библиотеку C++, то можете это сделать.
СОВЕТ
Если вы хотите изучить тригонометрические функции более подробно, перейдите по
следующей ссылке: http://www.cplusplus.com/reference/cmath/.
Последние три функции, которые мы добавим для класса Player, сделают игрока на 20 % быстрее, увеличат его максимальное здоровье на 20 % и повысят его
текущий уровень здоровья на заданное число соответственно.
Добавьте следующий код в конец файла Player.cpp:
void Player::upgradeSpeed()
{
// Увеличение скорости на 20 %
m_Speed += (START_SPEED * .2);
}
void Player::upgradeHealth()
{
// Увеличение максимального здоровья на 20 %
m_MaxHealth += (START_HEALTH * .2);
}
232 Глава 8. Использование области отображения и класса View в SFML
void Player::increaseHealthLevel(int amount)
{
m_Health += amount;
// Но не больше максимального
if (m_Health > m_MaxHealth)
{
m_Health = m_MaxHealth;
}
}
В приведенном коде функции upgradeSpeed() и upgradeHealth() увеличивают
значения, хранящиеся в m_Speed и m_MaxHealth соответственно. Эти значения
повышаются на 20 % путем умножения начальных значений на .2 и добавления
полученного результата к текущим значениям. Данные функции будут вызываться из функции main, когда игрок выберет, какие атрибуты своего персонажа
он хочет улучшить (то есть повысить уровень) между уровнями.
Функция increaseHealthLevel() принимает от main значение int в параметре
amount. Это целочисленное значение будет предоставлено классом Pickup, который мы напишем в главе 12.
Переменная-член m_Health увеличивается на переданное значение. Однако для
игрока есть ограничение. Оператор if проверяет, не превысила ли m_Health
значение m_MaxHealth, и если да, то устанавливает его равным m_MaxHealth. Это
означает, что игрок не может просто получать бесконечное количество здоровья
за счет аптечек. Вместо этого он должен тщательно балансировать улучшения,
которые выбирает между уровнями.
Конечно, наш класс Player не сможет ничего сделать, пока мы не инстанцируем
его и не пропишем в нашем игровом цикле. Прежде чем мы это сделаем, рассмот
рим концепцию игровой камеры.
Управление игровой камерой с помощью
класса View библиотеки SFML
На мой взгляд, класс View в SFML — один из самых полезных. После прочтения книги, если вы будете создавать игры без использования медиа и игровых
библиотек, вы действительно заметите отсутствие View.
Класс View позволяет рассматривать нашу игру как нечто самостоятельное, со
своими свойствами. Что я имею в виду? Когда мы разрабатываем игру, то обычно
пытаемся создать виртуальный мир. Он редко, если вообще когда-либо, измеряется
в пикселях и далеко не всегда имеет размеры, совпадающие с разрешением монитора игрока. Нам нужен способ абстрагировать виртуальный мир, который мы
создаем, чтобы он мог быть любого размера или формы.
Управление игровой камерой с помощью класса View библиотеки SFML 233
Другой способ представить класс View — добавить камеру, через которую игрок
будет видеть часть игрового мира. В большинстве игр используется сразу несколько камер.
Например, рассмотрим кооперативную игру с разделенным надвое экраном.
Или представьте игру, в которой на экране есть небольшая область, представляющая весь игровой мир, но в уменьшенном масштабе, как мини-карта.
Даже если наши игры намного проще, чем два предыдущих примера, и не нуждаются в разделенных экранах или мини-картах, мы, скорее всего, захотим создать
мир, который будет больше экрана, на котором он отображается. Это, конечно
же, относится и к нашей нынешней игре.
Кроме того, если мы постоянно перемещаем игровую камеру, чтобы показать
различные части виртуального мира (обычно для отслеживания игрока), что произойдет с HUD? Если мы отрисовываем счет и другую информацию HUD на
экране, а затем прокручиваем мир, чтобы следить за игроком, элементы HUD
будут перемещаться относительно этой камеры.
Класс View в SFML предоставляет все эти возможности и решает эту проблему с помощью очень простого кода. Хитрость заключается в создании экземпляра View для каждой камеры — возможно, экземпляр View для мини-карты,
экземпляр View для прокручиваемого игрового мира, а затем экземпляр View
для HUD.
Экземпляры View можно перемещать, изменять их размер и ориентацию по мере
необходимости. Так, основной экземпляр View может отслеживать игрока, миникарта может оставаться в углу экрана, а HUD — накладываться на весь экран
и никогда не перемещаться, несмотря на то что основной экземпляр View будет
следовать за игроком.
Рассмотрим код, использующий несколько экземпляров View.
ПРИМЕЧАНИЕ
Этот код используется для знакомства с классом View. Не добавляйте этот код в наш
проект.
Создайте и инициализируйте несколько экземпляров View:
// Создаем область отображения (view) для заполнения экрана 1920 × 1080
View mainView(sf::FloatRect(0, 0, 1920, 1080));
// Создаем область отображения (view) для HUD
View hudView(sf::FloatRect(0, 0, 1920, 1080));
234 Глава 8. Использование области отображения и класса View в SFML
Предыдущий код создает два объекта класса View, которые заполняют экран с разрешением 1920 × 1080. Теперь мы можем проделать некоторую магию с mainView,
оставив hudView нетронутым:
// В части обновления игры
// Можно делать многое с View
// Центрируем область отображения вокруг игрового персонажа
mainView.setCenter(player.getCenter());
// Поворачиваем область отображения на 45 градусов
mainView.rotate(45)
// Обратите внимание, что hudView не затронут предыдущим кодом
Когда мы управляем свойствами экземпляра View, мы делаем это следующим
образом. Когда мы отрисовываем спрайты, текст или другие объекты в области
отображения, то должны явно установить эту область в качестве текущей для
окна:
// Устанавливаем текущую область отображения
window.setView(mainView);
Теперь мы можем отрисовать в этой области все, что захотим:
// Выполняем отрисовку всего, что связано
//с текущей областью отображения
window.draw(playerSprite);
window.draw(otherGameObject);
// и т. д.
Игровой персонаж может находиться в любой точке координат, это не имеет
значения, потому что mainView центрируется вокруг его изображения.
Теперь мы можем отрисовать HUD в hudView. Обратите внимание, что так же,
как мы отображаем отдельные элементы (фон, игровые объекты, текст и т. д.)
в слоях от заднего плана к переднему, мы также рисуем отображение от заднего плана к переднему. Следовательно, HUD отрисовывается после основной
игровой сцены:
// Переключаемся на hudView
window.setView(hudView);
// Отрисовываем все для HUD
window.draw(scoreText);
window.draw(healthBar);
// и т. д.
Наконец, мы можем отобразить окно и все его области отображения для текущего
кадра обычным способом:
window.display();
Запуск игрового движка 235
ПРИМЕЧАНИЕ
Если вы хотите углубиться в SFML View и узнать способы создания разделенных экранов и мини-карты, то лучшее руководство в Интернете находится на официальном
сайте SFML: https://www.sfml-dev.org/tutorials/2.5/graphics-view.php.
Теперь, когда вы узнали о View, начнем писать код функции main нашей игры
и использовать первый экземпляр View на практике. А в главе 13 мы введем второй экземпляр View для HUD и наложим его поверх основного экземпляра View.
Запуск игрового движка
В этой игре нам понадобится немного улучшенный игровой движок в main. У нас
будет перечисление под названием state, с помощью которого мы будем отслеживать текущее состояние игры. Затем, в main, мы обернем части нашего кода
так, чтобы в разных состояниях происходили разные действия.
Когда мы создали проект, Visual Studio сгенерировала для нас файл Zombie
Arena.cpp. Это файл, содержащий нашу функцию main и код, который инстанцирует и управляет всеми нашими классами.
Начнем с уже знакомой функции main и нескольких директив include. Обратите
внимание на добавление директивы include для класса Player.
Удалите весь код, который есть в файле ZombieArena.cpp, и добавьте следующий:
#include <SFML/Graphics.hpp>
#include "Player.h"
using namespace sf;
int main()
{
return 0;
}
Здесь нет ничего нового, за исключением строки #include "Player.h", которая
означает, что теперь мы можем использовать класс Player в нашем коде.
Добавьте следующий выделенный код в начало функции main:
int main()
{
// Игра всегда находится в одном из четырех состояний
enum class State { PAUSED, LEVELING_UP, GAME_OVER, PLAYING };
// Начинаем с состояния GAME_OVER
State state = State::GAME_OVER;
236 Глава 8. Использование области отображения и класса View в SFML
// Получаем разрешение экрана и создаем окно SFML
Vector2f resolution;
resolution.x = VideoMode::getDesktopMode().width;
resolution.y = VideoMode::getDesktopMode().height;
RenderWindow window(
VideoMode(resolution.x, resolution.y),
"Zombie Arena", Style::Fullscreen);
// Создаем объект View библиотеки SFML для основного действия
View mainView(sf::FloatRect(0, 0, resolution.x, resolution.y));
// Здесь находится наш таймер для отслеживания времени
Clock clock;
// Сколько времени прошло в состоянии PLAYING
Time gameTimeTotal;
// Где находится указатель мыши относительно глобальных координат
Vector2f mouseWorldPosition;
// Где находится указатель мыши относительно координат на экране
Vector2i mouseScreenPosition;
// Создаем экземпляр класса Player
Player player;
// Границы арены
IntRect arena;
// Основной игровой цикл
while (window.isOpen())
{
}
}
return 0;
Пробежимся по каждой строке кода, который мы ввели. Внутри функции main
у нас есть следующий код:
// Игра всегда будет находиться в одном из четырех состояний
enum class State { PAUSED, LEVELING_UP, GAME_OVER, PLAYING };
// Начинаем с состояния GAME_OVER state
State state = State::GAME_OVER;
Он создает новое перечисление под названием State, а затем — экземпляр State
под названием state. Перечисление state может иметь одно из четырех значений, как определено в объявлении: PAUSED, LEVELING_UP, GAME_OVER или PLAYING.
Это как раз то, что нам нужно для отслеживания и реагирования на различные
состояния, в которых может находиться игра в любой момент времени. Обратите
внимание, что состояние не может содержать более одного значения одновременно.
Следом идет код:
// Получаем разрешение экрана и создаем окно SFML
Vector2f resolution;
resolution.x = VideoMode::getDesktopMode().width;
resolution.y = VideoMode::getDesktopMode().height;
RenderWindow window(VideoMode(resolution.x, resolution.y),
"Zombie Arena", Style::Fullscreen);
Запуск игрового движка 237
Здесь объявляется экземпляр Vector2f с именем resolution. Мы инициализируем две переменные-члена resolution (x и y), вызывая функцию VideoMode:
:getDesktopMode для ширины width и высоты height. Теперь объект resolution
содержит разрешение монитора, на котором запущена игра. Последняя строка
кода создает новый экземпляр RenderWindow с именем window, используя соответствующее разрешение.
Следующий код создает объект View библиотеки SFML. Он позиционируется
(изначально) в точных координатах пикселей монитора. Если бы мы использовали этот View для отрисовки в текущем положении, это было бы то же самое,
что рисовать окно без области отображения. Однако со временем мы начнем
перемещать эту область, чтобы сфокусироваться на тех частях нашего игрового
мира, которые должен видеть игрок. Затем, когда мы начнем использовать второй
экземпляр View, который остается неподвижным (для HUD), мы увидим, как
этот экземпляр View может отслеживать действия, в то время как другой остается
статичным для отображения HUD:
// Создаем объект View библиотеки SFML для основного действия
View mainView(sf::FloatRect(0, 0, resolution.x, resolution.y));
Далее мы создали экземпляр Clock для отслеживания времени и объект Time под
названием gameTimeTotal, который будет вести подсчет времени, прошедшего
в игре. По мере развития проекта мы также будем вводить больше переменных
и объектов для обработки времени:
// Здесь находится наш таймер для отслеживания времени
Clock clock;
// Сколько времени прошло в состоянии PLAYING
Time gameTimeTotal;
В следующем блоке объявлены два вектора: один, содержащий две переменные
float, называется mouseWorldPosition, а другой, содержащий два целых числа, —
mouseScreenPosition. Указатель мыши — это своего рода аномалия, поскольку
существует в двух разных координатных пространствах. При желании их можно
представить как параллельные вселенные. Когда игрок перемещается по миру,
нам нужно отслеживать, где в этот момент находится курсор.
Эти координаты типа float будут храниться в mouseWorldCoordinates. Конечно, фактические координаты пикселей самого монитора никогда не меняются.
Они всегда будут равны (0, 0) для горизонтального разрешения –1 и вертикального разрешения –1. Мы будем отслеживать положение указателя мыши
относительно этой системы координат с помощью целых чисел, хранящихся
в mouseScreenPosition:
// Где находится указатель мыши относительно глобальных координат
Vector2f mouseWorldPosition;
// Где находится указатель мыши относительно координат на экране
Vector2i mouseScreenPosition;
238 Глава 8. Использование области отображения и класса View в SFML
Наконец, мы используем наш класс Player. Строка кода, представленная ниже,
приведет к выполнению функции-конструктора Player::Player (обратитесь
к файлу Player.cpp, если хотите освежить в памяти эту функцию):
// Создаем экземпляр класса Player
Player player;
Объект IntRect будет содержать начальные горизонтальные и вертикальные
координаты, а также ширину и высоту. После инициализации мы сможем получить доступ к информации о размере и расположении текущей арены с помощью
arena.left, arena.top, arena.width и arena.height:
// Границы арены
IntRect arena;
Последний блок кода — это, конечно же, наш игровой цикл:
// Основной игровой цикл
while (window.isOpen())
{
}
Вы наверняка заметили, что код становится довольно длинным. Об этом неудобстве мы поговорим в следующем разделе.
Управление файлами кода
Одно из преимуществ использования классов и функций заключается в том, что
можно уменьшить длину (количество строк) наших файлов кода. Даже если мы
задействуем более дюжины файлов кода в этом проекте, код в ZombieArena.cpp все
равно станет к концу довольно громоздким. В следующем и последнем проекте
мы рассмотрим еще больше способов управления кодом.
А пока воспользуйтесь следующим советом, чтобы упростить работу. Обратите
внимание, что в левой части редактора кода в Visual Studio есть несколько знаков + и –, один из которых показан на рис. 8.3.
Рис. 8.3. Символы в окне редактора кода Visual Studio
Эти значки появляются для каждого блока кода (if, while, for и т. д.). Вы можете разворачивать и сворачивать эти блоки, щелкая на + и –. Я рекомендую
держать свернутым весь код, который не обсуждается в данный момент. Так
будет гораздо удобнее.
Управление файлами кода 239
Кроме того, мы можем создавать собственные сворачиваемые блоки. Я предлагаю
сделать такой блок для всего кода, расположенного перед основным игровым
циклом. Для этого выделите код, затем щелкните на нем правой кнопкой мыши
и выберите OutliningHide Selection (СхематизацияСкрыть выделение), как показано на рис. 8.4.
Рис. 8.4. Создание сворачиваемого блока
Теперь, щелкая на символах + и –, вы сможете развернуть и свернуть блок. На рис. 8.5
показано, как выглядит код в свернутом виде.
Рис. 8.5. Свернутый код
Такой подход значительно упрощает управление кодом по сравнению с тем,
как это было раньше. Теперь можно приступить к работе с основным игровым
циклом.
240 Глава 8. Использование области отображения и класса View в SFML
Пишем код основного игрового цикла
Как вы можете видеть, последняя часть предыдущего кода — это игровой цикл
(while (window.isOpen()){}). Теперь мы сосредоточимся на нем. В частности,
будем писать код для обработки ввода.
Код, который мы добавим, довольно длинный, но в нем нет ничего сложного.
Скопируйте следующий выделенный код в игровой цикл:
// Основной игровой цикл
while (window.isOpen())
{
/*
***************
Обработка ввода
***************
*/
// Обработка событий через опрос
Event event;
while (window.pollEvent(event))
{
if (event.type == Event::KeyPressed)
{
// Пауза в игре во время PLAYING
if (event.key.code == Keyboard::Return &&
state == State::PLAYING)
{
state = State::PAUSED;
}
// Возобновление игры во время PAUSED
else if (event.key.code == Keyboard::Return &&
state == State::PAUSED)
{
state = State::PLAYING;
// Сброс таймера, чтобы избежать скачка кадра
clock.restart();
}
// Начало новой игры в состоянии GAME_OVER
else if (event.key.code == Keyboard::Return &&
state == State::GAME_OVER)
{
state = State::LEVELING_UP;
}
if (state == State::PLAYING)
{
}
}
}// Завершение опроса событий
}// Конец игрового цикла
Пишем код основного игрового цикла 241
В приведенном коде мы инстанцируем объект типа Event. Мы будем использовать
event, как и в предыдущих проектах, для опроса системных событий. Для этого
мы оборачиваем остальной код из предыдущего блока в цикл while с условием
window.pollEvent(event). Цикл будет повторяться каждый кадр, пока есть события для обработки.
Внутри этого цикла while мы обрабатываем интересующие нас события. Сначала
проверяем события Event::KeyPressed. Если клавиша Enter нажата, когда игра
находится в состоянии PLAYING, то мы переключаем состояние на PAUSED.
Если нажать клавишу Return, когда игра находится в состоянии PAUSED, то мы
перейдем в состояние PLAYING и перезапустим объект clock. Причина, по которой
мы перезапускаем clock после перехода из PAUSED в PLAYING, заключается в том,
что, пока игра находится в состоянии паузы, время не останавливается. Если мы
не перезапустим clock, все наши объекты будут обновлять свои позиции так, как
будто кадр занял очень много времени. Это станет более очевидным, когда мы
добавим остальной код в этот файл.
Затем у нас есть блок else if, который проверяет, была ли нажата клавиша Enter,
когда игра находилась в состоянии GAME_OVER. Если да, то состояние state меняется на LEVELING_UP.
ПРИМЕЧАНИЕ
Обратите внимание, что состояние GAME_OVER — это состояние, в котором отображается главный экран. Таким образом, состояние GAME_OVER — это состояние после смерти
игрового персонажа и когда игрок только запускает игру. Первое, что игрок может
сделать в каждой игре, — выбрать атрибут для улучшения (то есть повысить уровень).
В предыдущем коде есть заключительное условие if, проверяющее, равно ли
состояние PLAYING. Данный блок if пуст, и мы будем добавлять в него код на
протяжении всей работы над проектом.
СОВЕТ
Мы будем добавлять код во множество различных частей этого файла. Поэтому стоит
потратить время на изучение различных состояний, в которых может находиться
наша игра, и того, как мы их обрабатываем. Также будет очень полезно сворачивать
и разворачивать различные блоки if, else и while по мере необходимости.
Потратьте некоторое время на тщательное ознакомление с блоками while, if
и else if, которые мы только что обсудили. Мы будем обращаться к ним регулярно.
242 Глава 8. Использование области отображения и класса View в SFML
Далее, сразу после предыдущего кода и внутри того же игрового цикла, добавьте
следующий выделенный код:
}// Завершение опроса событий
// Обработка выхода из игры
if (Keyboard::isKeyPressed(Keyboard::Escape))
{
window.close();
}
// Оработка WASD во время игры
if (state == State::PLAYING)
{
// Обработка нажатия и отпускания клавиш WASD
if (Keyboard::isKeyPressed(Keyboard::W))
{
player.moveUp();
}
else
{
player.stopUp();
}
if (Keyboard::isKeyPressed(Keyboard::S))
{
player.moveDown();
}
else
{
player.stopDown();
}
if (Keyboard::isKeyPressed(Keyboard::A))
{
player.moveLeft();
}
else
{
player.stopLeft();
}
if (Keyboard::isKeyPressed(Keyboard::D))
{
player.moveRight();
}
else
{
player.stopRight();
}
}// Завершение обработки WASD во время игры
}// Конец игрового цикла
В данном коде мы сначала проверяем, нажал ли игрок клавишу Esc. Если да, окно
игры закроется.
Далее, внутри большого блока if(state == State::PLAYING), мы проверяем каждую из клавиш WASD по очереди. Если клавиша нажата, мы вызываем соответствующую функцию player.move..., если нет — player.stop....
Пишем код основного игрового цикла 243
Этот код гарантирует, что в каждом кадре игровой персонаж будет обновляться с учетом нажатых и ненажатых клавиш WASD. Функции player.move...
и player.stop... хранят эту информацию в логических переменных-членах
(m_LeftPressed, m_RightPressed, m_UpPressed и m_DownPressed). Затем класс Player
реагирует на значение этих логических переменных в каждом кадре в функции player.update, которую мы будем вызывать в блоке обновления игрового
цикла.
Теперь мы в состоянии обрабатывать ввод с клавиатуры, чтобы игрок мог повышать уровень в начале каждой игры и между волнами зомби. Добавьте и изучите
следующий выделенный код, а затем мы его обсудим:
}// Завершение обработки WASD во время игры
// Обработка состояния повышения уровня
if (state == State::LEVELING_UP)
{
// Обработка повышения уровня игрока
if (event.key.code == Keyboard::Num1)
{
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num2)
{
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num3)
{
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num4)
{
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num5)
{
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num6)
{
state = State::PLAYING;
}
if (state == State::PLAYING)
{
// Подготовка уровня
// Мы изменим следующие две строки позже
arena.width = 500;
arena.height = 500;
arena.left = 0;
arena.top = 0;
// Мы изменим эту строку кода позже
int tileSize = 50;
244 Глава 8. Использование области отображения и класса View в SFML
// Появление игрока в центре арены
player.spawn(arena, resolution, tileSize);
// Сброс таймера, чтобы избежать скачка кадра
clock.restart();
}
}// Завершение обработки повышения уровня
}// Конец игрового цикла
В приведенном выше коде, который обернут в проверку того, равно ли текущее
значение state значению LEVELING_UP, мы обрабатываем клавиши 1, 2, 3, 4, 5 и 6.
В блоке if для каждой из них мы просто устанавливаем state в State::PLAYING.
Мы добавим код для работы с каждым вариантом повышения уровня позже
в главе 14.
Этот код выполняет следующие действия:
zzесли состояние state равно LEVELING_UP, ждет нажатия клавиши 1, 2, 3, 4, 5
или 6;
zzпри нажатии изменяет состояние state на PLAYING;
zzкогда состояние state изменяется, все еще внутри блока if (state == State::
LEVELING_UP) будет запущен вложенный блок if(state == State::PLAYING);
zzвнутри этого блока мы задаем местоположение и размер арены, устанавливаем
tileSize равным 50, передаем всю информацию в player.spawn и вызываем
clock.restart.
Теперь у нас есть реальный объект в виде игрового персонажа, который появляется в сцене, «ориентируется» в пространстве и способен реагировать на нажатие
клавиш. Далее мы можем обновлять сцену при каждом проходе цикла.
Не забудьте аккуратно свернуть код из части цикла игры, связанной с обработкой
ввода, поскольку на данный момент мы с ним закончили. Следующий код будет
располагаться в блоке обновления игрового цикла:
}// Завершение обработки повышения уровня
/*
****************
Обновление кадра
****************
*/
if (state == State::PLAYING)
{
// Обновление delta time
Time dt = clock.restart();
// Обновляем общее игровое время
gameTimeTotal += dt;
// Преобразуем delta time в дробь
float dtAsSeconds = dt.asSeconds();
Пишем код основного игрового цикла 245
// Получаем текущее положение указателя мыши
mouseScreenPosition = Mouse::getPosition();
// Преобразуем положение указателя мыши в глобальные
// координаты относительно mainView
mouseWorldPosition = window.mapPixelToCoords(
Mouse::getPosition(), mainView);
// Обновляем игрового персонажа
player.update(dtAsSeconds, Mouse::getPosition());
// Сохраняем новое положение персонажа
Vector2f playerPosition(player.getCenter());
// Центрируем вид вокруг игрового персонажа
mainView.setCenter(player.getCenter());
}// Завершение обновления сцены
}// Конец игрового цикла
Обратите внимание, что код обернут в проверку состояния PLAYING. Мы не хотим,
чтобы данный код выполнялся, если игра была поставлена на паузу, завершилась
или если игрок выбирает, что улучшить.
Сначала мы перезапускаем таймер и сохраняем в переменной dt время, которое
занял предыдущий кадр:
// Обновление delta time
Time dt = clock.restart();
Затем мы добавляем время, которое занял предыдущий кадр, к общему времени
игры, которое хранится в gameTimeTotal:
// Обновляем общее игровое время
gameTimeTotal += dt;
Далее мы инициализируем переменную float с именем dtAsSeconds значением, возвращаемым функцией dt.AsSeconds. Для большинства кадров это будет
дробное число от 0 до 1. Это идеально подходит для передачи в функцию player.
update, чтобы рассчитать, насколько нужно переместить спрайт игрока.
Теперь мы можем инициализировать mouseScreenPosition с помощью функции
MOUSE::getPosition.
ПРИМЕЧАНИЕ
Ваше внимание, вероятно, привлек необычный синтаксис получения положения указателя мыши. Это называется статической функцией. Если мы определяем
функцию в классе с помощью ключевого слова static, мы можем вызывать эту
функцию, используя имя класса и без создания экземпляра класса. В объектноориентированном C++ есть множество подобных особенностей и правил. Мы еще
с ними столкнемся.
246 Глава 8. Использование области отображения и класса View в SFML
Затем мы инициализируем mouseWorldPosition с помощью функции mapPixel
ToCoords библиотеки SFML. Мы обсуждали эту функцию, когда говорили о классе View ранее в данной главе.
Теперь мы можем вызвать player.update и передать dtAsSeconds и положение
указателя мыши при необходимости.
Мы храним новую информацию о центре игрового персонажа в экземпляре
Vector2f под названием playerPosition. В данный момент он не используется,
но позже пригодится нам.
Затем мы можем отцентрировать вид относительно текущей позиции игрового
персонажа с помощью mainView.setCenter(player.getCenter()).
Теперь отрисуем героя на экране. Добавьте следующий выделенный код:
}// Завершение обновления сцены
/*
***************
Отрисовка сцены
***************
*/
if (state == State::PLAYING)
{
window.clear();
// Устанавливаем mainView для отображения в окне
// и отрисовываем все элементы, связанные с ним
window.setView(mainView);
// Отрисовываем игрового персонажа
window.draw(player.getSprite());
}
if (state == State::LEVELING_UP)
{
}
if (state == State::PAUSED)
{
}
if (state == State::GAME_OVER)
{
}
window.display();
}// Конец игрового цикла
return 0;
}
Внутри блока if(state == State::PLAYING) мы очищаем экран, устанавливаем вид
окна на mainView, а затем отрисовываем спрайт игрового персонажа с помощью
window.draw(player.getSprite()).
После обработки всех различных состояний код отображает сцену обычным
способом с помощью window.display();.
Резюме 247
Если вы запустите игру, то увидите, что наш персонаж реагирует в ответ на движение мыши.
СОВЕТ
Вам нужно нажать Enter, чтобы начать игру, а затем выбрать число от 1 до 6, чтобы
смоделировать выбор улучшения. После этого игра начнется.
Вы также можете перемещать героя в пределах (пустой) арены размером
500 × 500 пикселей. На рис. 8.6 показан игровой персонаж в центре экрана.
Рис. 8.6. Одинокий герой игры в центре экрана
Однако вы не можете почувствовать движение, потому что мы не реализовали
фон. Мы сделаем это в следующей главе.
Резюме
Что ж, это была длинная глава! Мы многое сделали: создали наш первый класс
Player для игры Zombie Arena и использовали его в игровом цикле. Мы также познакомились с экземпляром класса View, хотя еще не изучили все его преимущества.
В следующей главе мы отрисуем фон нашей арены с помощью спрайт-листов.
Мы также узнаем о ссылках C++, которые позволяют нам манипулировать переменными, даже если они расположены вне области видимости. «Вне области
видимости» означает, что переменные находятся в другой функции.
248 Глава 8. Использование области отображения и класса View в SFML
Часто задаваемые вопросы
В. Я заметил кое-что странное в коде, который мы писали. А именно в таких
операторах if, как:
if (event.type == Event::KeyPressed)...
Как параметр Event, переданный в функцию pollEvent, в итоге используется?
В конце концов, разве переменные и объекты не имеют область видимости только
в той функции, в которой они объявлены?
О. Причина в ссылках C++. Ссылки в C++ — это переменные, которые выступают
в качестве псевдонимов для других переменных. В обсуждаемом коде нет явных
ссылок. Однако ссылки используются для эффективной передачи объектов
в функции, позволяя избежать ненужного копирования. Поскольку параметр
функции pollEvent определен как ссылка, значения могут быть присвоены
переданному объекту события, которые сохранятся в функции main. Это станет
понятнее в следующей главе, когда будем обсуждать ссылки.
В. Я заметил, что мы написали довольно много функций класса Player, которые
не используем. Почему так?
О. Вместо того чтобы возвращаться к классу Player , мы добавили весь код,
который понадобится нам на протяжении всего проекта. К концу главы 14 мы
воспользуемся всеми этими функциями.
9
Ссылки, спрайт-листы
и массивы вершин в C++
В главе 4 мы говорили об области видимости. Это концепция, согласно которой
переменные, объявленные внутри функции или блока кода, могут быть видны
или использованы только в пределах этой функции или блока. Однако, если нам
нужно беспроблемно взаимодействовать с несколькими сложными объектами
в функции main, текущих знаний об области видимости недостаточно.
В этой главе мы изучим ссылки в C++, которые позволят нам работать с переменными и объектами, находящимися вне области видимости. Кроме того, эти
ссылки помогут избежать передачи больших объектов между функциями, что замедляет работу программы, поскольку копия переменной или объекта создается
при подобной операции каждый раз.
Вооружившись новыми знаниями, мы рассмотрим класс VertexArray библиотеки SFML, который позволит быстро и эффективно отрисовывать на экране
большие изображения, используя несколько фрагментов одного графического
файла. В результате с помощью ссылок и VertexArray к концу главы мы создадим
масштабируемый, случайно генерируемый, прокручиваемый фон.
Ссылки в C++
Когда мы передаем значения в функцию или возвращаем значения из функции,
происходит следующее: создается копия значения, хранящегося в переменной,
и затем передается в функцию, где используется.
Отсюда вытекает следующее:
zzесли мы хотим, чтобы функция постоянно изменяла переменную, эта система
нам не подходит;
zzкогда копия передается в качестве аргумента или возвращается из функции,
расходуются вычислительные ресурсы и память. Для простого int или даже
Sprite это несущественно. Однако для сложного объекта, например целого
игрового мира (или фона), процесс копирования будет серьезно влиять на
производительность нашей игры.
250 Глава 9. Ссылки, спрайт-листы и массивы вершин в C++
Решением этих двух проблем является особый тип переменной, который ссыла
ется на другую переменную. Вот пример, который поможет вам лучше понять это:
int numZombies = 100;
int& rNumZombies = numZombies;
Мы объявляем и инициализируем обычную переменную типа int с именем
numZombies. Затем мы объявляем и инициализируем ссылку на эту переменную
int под названием rNumZombies. Символ &, следующий за типом, указывает на то,
что объявляется ссылка.
ПРИМЕЧАНИЕ
Префикс r в начале имени ссылки необязателен, но полезен для напоминания о том,
что мы имеем дело со ссылкой.
Теперь у нас есть целочисленная переменная numZombies, которая хранит значение 100, и ссылка rNumZombies, которая указывает на numZombies.
Все, что мы делаем с numZombies, можно увидеть через rNumZombies, а все, что мы
делаем с rNumZombies, на самом деле делается с numZombies.
Взгляните на следующий код:
int score = 10;
int& rScore = score;
score ++;
rScore ++;
Здесь мы объявляем переменную типа int с именем score. Затем мы объявляем
ссылку rScore, которая ссылается на score. Помните, что все, что мы делаем
с переменной score, можно выполнить с rScore, и все, что мы делаем с rScore,
делается с score.
Поэтому рассмотрим, что произойдет, если мы увеличим переменную score:
score ++;
Теперь переменная score хранит значение 11 . В дополнение к этому, если
мы выведем rScore, она также выведет 11. Следующая строка кода выглядит
так:
rScore ++;
Таким образом, значение score фактически равно 12, потому что все, что мы
делаем с rScore, делается и с score.
Ссылки в C++ 251
ПРИМЕЧАНИЕ
Больше информации об этом вы найдете в следующей главе, когда мы будем обсуждать указатели. Пока вы можете рассматривать ссылку как хранилище места/адреса
в памяти компьютера. Это то же самое место в памяти, где переменная, на которую
она ссылается, хранит свое значение. Поэтому операция над ссылкой или переменной
имеет точно такой же эффект.
Сейчас гораздо важнее обсудить, зачем нужны ссылки. Есть две причины их использовать, и мы уже упоминали о них.
zzИзменение (чтение) значения переменной или объекта в другой функции,
которая в противном случае находится вне области видимости.
zzПередача в функцию (или возврат из функции) без создания копии (и, следовательно, более эффективно).
Изучите следующий код:
void add(int n1, int n2, int a);
void referenceAdd(int n1, int n2, int& a);
int main()
{
int number1 = 2;
int number2 = 2;
int answer = 0;
add(number1, number2, answer);
// Переменная answer остается равной нулю, так как она передается как копия
// С answer ничего не происходит в области видимости main
referenceAdd(number1, number2, answer);
// Теперь answer равна 4, потому что она была передана по ссылке,
// когда функция referenceAdd выполнила это:
// answer = num1 + num 2;
// Она фактически изменила значение, хранящееся в answer
return 0;
}
// Вот определения двух функций
// Они одинаковы, за исключением того, что вторая передает ссылку в переменную a
void add(int n1, int n2, int a)
{
a = n1 + n2;
// a теперь равна 4
// Но когда функция возвращается, переменная a теряется навсегда
}
void referenceAdd(int n1, int n2, int& a)
{
a = n1 + n2;
// a теперь равна 4
// Но a — это ссылка!
// Таким образом, answer, возвращенная в main, теперь равна 4
}
252 Глава 9. Ссылки, спрайт-листы и массивы вершин в C++
Данный код начинается с прототипов двух функций: add и referenceAdd. Функция add принимает три переменные типа int, а функция referenceAdd — две,
а также ссылку типа int.
Когда вызывается функция add и в нее передаются переменные number1, number2
и answer, создаются копии этих значений, и новые переменные, локальные для
add (то есть n1, n2 и a), изменяются. В результате answer, возвращенная в main,
остается равной нулю.
При вызове функции referenceAdd переменные number1 и number2 снова передаются по значению. Однако answer передается по ссылке. Когда ссылке a присваивается сумма значений n1 и n2, по факту она присваивается answer в функции main.
Очевидно, что использовать ссылку для чего-то настолько простого нам никогда
не понадобилось бы. Однако это демонстрирует механику передачи по ссылке.
Итак, подведем итог тому, что узнали о ссылках.
В предыдущем коде было показано, как с помощью ссылки можно изменить
значение переменной в одной области видимости, используя код в другой. Передача по ссылке не только чрезвычайно удобна, но и очень эффективна, поскольку
не происходит копирования. Наш пример с ссылкой на переменную типа int немного неоднозначен, так как тип int настолько мал, что реального выигрыша от
эффективности нет. Чуть позже мы воспользуемся ссылкой для передачи целого
уровня, и тогда польза от нее станет более ощутимой.
ПРИМЕЧАНИЕ
Однако со ссылками есть одна загвоздка! Вы должны присвоить ссылку переменной
в момент ее создания. Это означает, что она не такая гибкая. Пока не беспокойтесь
об этом. В следующей главе мы подробнее рассмотрим ссылки наряду с их более
гибкими (и немного более сложными) «родственниками», такими как указатели.
А теперь обсудим массивы вершин и спрайт-листы.
Массивы вершин и спрайт-листы
Мы почти готовы приступить к реализации прокручивающегося фона. Осталось
только узнать о массивах вершин и спрайт-листах.
Что такое спрайт-лист
Спрайт-лист — это набор изображений, либо кадров анимации, либо отдельных
графических элементов, содержащихся в одном файле изображения. Взгляните
на рис. 9.1. На нем изображен спрайт-лист с четырьмя отдельными изображения
ми, которые будут использоваться для фона в нашей игре.
Массивы вершин и спрайт-листы 253
Библиотека SFML позволяет нам загружать спрайт-лист как обычную текстуру,
точно так же как мы до сих пор делали это для любой другой текстуры. Когда мы
загружаем несколько изображений как одну текстуру, графический процессор
гораздо эффективнее справляется с обработкой.
ПРИМЕЧАНИЕ
Современный ПК способен справиться с этими четырьмя текстурами без использования спрайт-листа. Однако стоит освоить эти техники, поскольку игры становятся
все более требовательными к аппаратному обеспечению.
Спрайт-лист еще называют текстурным атласом. Обычно разница между спрайт-листом
и текстурным атласом заключается в том, что первый содержит несколько кадров для
одного «объекта», например персонажа или фона, и эти кадры обычно скопмонованы
равномерно, как у нас. Текстурный атлас же чаще всего состоит из текстур для нескольких объектов, возможно, целого уровня или даже всей игры, и, вероятно, будет
содержать текстуры разных размеров и располагаться не так равномерно. Кроме того,
к атласу часто прилагается текстовый файл с данными, описывающими названия,
расположение и размеры отдельных текстур. Игра будет использовать этот файл для
получения нужных ей изображений. Независимо от того, как вы назовете графический
файл, наличие нескольких изображений в одном файле ускоряет их загрузку и доступ
к ним во время игры.
Когда мы отрисовываем изображение из спрайт-листа, нам нужно убедиться, что
мы ссылаемся на точные пиксельные координаты той части спрайтового листа,
которая нам нужна (рис. 9.2).
Рис. 9.1. Спрайт-лист
Рис. 9.2. Пиксельные координаты спрайт-листа
254 Глава 9. Ссылки, спрайт-листы и массивы вершин в C++
На рис. 9.2 каждая часть (плитки) помечена координатами и их положением
в спрайт-листе. Эти координаты называются текстурными. Мы будем использовать их в нашем коде, чтобы нарисовать именно те части, которые нам нужны.
Что такое массив вершин
Прежде всего необходимо задать вопрос: что такое вершина? Вершина (vertex) —
это одна графическая точка, то есть координата. Эта точка определяется положением по горизонтали и вертикали. Массив вершин — это целая коллекция
вершин.
В библиотеке SFML каждая вершина в массиве вершин также имеет цвет и связанную с ним дополнительную вершину (то есть пару координат), называемую
текстурными координатами. Текстурные координаты — это положение изображения, которое мы хотим использовать в спрайт-листе. Позже мы увидим,
как можно позиционировать изображение и выбирать часть спрайт-листа для
отображения в каждой позиции, и все это с помощью одного массива вершин.
Класс VertexArray библиотеки SFML может содержать различные типы наборов вершин, но каждый отдельный VertexArray должен содержать только один.
Мы используем тот тип набора, который подходит для конкретного случая.
Распространенные сценарии в видеоиграх включают следующие типы примитивов (но не ограничиваются ими):
zzточка — отдельные вершины;
zzлиния — пары вершин, образующих линии;
zzтреугольник — три вершины, образующие треугольник. Наиболее часто ис-
пользуемый тип (тысячами экземпляров) для сложных 3D-моделей, а также
для создания простого прямоугольника, такого как спрайт;
zzчетырехугольник — четыре вершины, образующие прямоугольник. Это удобный инструмент для отображения прямоугольных областей из спрайт-листа.
В этом проекте мы будем использовать четырехугольники, потому что они идеально подходят для прямоугольных спрайтов.
Создание фона из плиток
Фон в нашей игре будет состоять из случайно расположенных квадратных изображений. Представьте себе плитку на полу.
В данном проекте мы будем использовать массивы с четырьмя вершинами на
набор. Каждая вершина будет частью набора из четырех (то есть четырехугольника) и станет определять один угол плитки нашего фона, а каждая текстурная
координата — содержать соответствующее значение, основанное на конкретном
изображении из спрайт-листа.
Массивы вершин и спрайт-листы 255
Рассмотрим пример кода. Это не совсем тот код, который мы будем использовать
в проекте, но он подобен и позволит нам изучить массивы вершин, прежде чем
мы перейдем к фактической реализации.
Построение массива вершин
Как и при создании экземпляра класса, мы объявляем наш новый объект.
Следующий код объявляет новый объект типа VertexArray, который мы назовем
background:
// Создаем массив вершин
VertexArray background;
Мы хотим сообщить нашему экземпляру VertexArray, какой тип примитива будем использовать. Помните, что точки, линии, треугольники и четырехугольники
имеют разное количество вершин. Задав экземпляру VertexArray конкретный
тип, мы сможем определять начало каждого примитива. В нашем случае нам
нужны четырехугольники. Вот код, который позволит это сделать:
// Указываем тип примитива
background.setPrimitiveType(Quads);
Как и в случае с обычными массивами C++, у экземпляра VertexArray должен
быть определенный размер. Класс VertexArray более гибкий, чем обычный массив. Он позволяет нам изменять его размер во время работы игры. Размер можно
было бы задать одновременно с объявлением, но наш фон должен расширяться
с каждой волной. Класс VertexArray предоставляет такую возможность с помощью функции resize. Вот код, который установит размер нашей арены равным
10 на 10 плиток:
// Устанавливаем размер массива вершин
background.resize(10 * 10 * 4);
В предыдущей строке кода первая цифра 10 — это ширина, вторая — высота,
а 4 — количество вершин в четырехугольнике. Мы могли бы просто передать 400,
но такой пример вычислений позволяет понять, что мы делаем. Когда мы будем
писать код проекта по-настоящему, мы объявим переменные для каждой части
вычислений.
Теперь у нас есть экземпляр VertexArray, готовый к настройке сотен вершин. Вот
как мы задаем координаты позиций для первых четырех вершин (то есть первого
четырехугольника):
// Устанавливаем позиции
background[0].position =
background[1].position =
background[2].position =
background[3].position =
каждой вершины в текущем четырехугольнике
Vector2f(0, 0);
Vector2f(49, 0);
Vector2f(49, 49);
Vector2f(0, 49);
256 Глава 9. Ссылки, спрайт-листы и массивы вершин в C++
Вот как мы устанавливаем текстурные координаты этих же вершин, чтобы использовать первое изображение в спрайт-листе.
Эти координаты в файле изображения — от (0, 0) (в левом верхнем углу) до (49, 49)
(в правом нижнем углу):
// Устанавливаем текстурные координаты каждой вершины
background[0].texCoords = Vector2f(0, 0);
background[1].texCoords = Vector2f(49, 0);
background[2].texCoords = Vector2f(49, 49);
background[3].texCoords = Vector2f(0, 49);
Если бы мы хотели установить текстурные координаты для второго изображения
в спрайт-листе, то написали бы код следующим образом:
// Устанавливаем текстурные координаты каждой вершины
background[0].texCoords = Vector2f(0, 50);
background[1].texCoords = Vector2f(49, 50);
background[2].texCoords = Vector2f(49, 99);
background[3].texCoords = Vector2f(0, 99);
Конечно, если мы будем определять каждую вершину по отдельности, то потратим много времени на настройку даже простой арены размером 10 на 10.
Когда мы реализуем наш фон по-настоящему, мы придумаем набор вложенных
циклов for, которые будут проходить через каждый четырехугольник, выбирать
случайное фоновое изображение и назначать соответствующие текстурные координаты.
Код должен быть довольно «умным». Он должен знать, когда плитка является
крайней, чтобы использовать изображение стены из спрайт-листа. Потребуется
использовать и соответствующие переменные, которые знают положение каждой
фоновой плитки в спрайт-листе, а также общий размер требуемой арены.
Мы поместим весь код в отдельную функцию и отдельный файл, а также сделаем экземпляр VertexArray пригодным для использования в main с помощью
ссылки C++.
Но рассмотрим эти детали позже. Вы, наверное, заметили, что мы ни разу не связали с массивом вершин текстуру (спрайт-лист). Давайте узнаем, как это сделать.
Использование массива вершин для отрисовки
Теперь, когда мы подготовили вершины и текстурные координаты, мы готовы
к отрисовке на экране. В следующем коде показано, как можно загрузить спрайтлист в качестве текстуры:
// Загружаем текстуру для нашего массива вершин фона
Texture textureBackground;
textureBackground.loadFromFile("graphics/background_sheet.png");
Создание случайно генерируемого прокручиваемого фона 257
Затем мы можем отрисовать весь массив VertexArray одним вызовом draw:
// Отрисовка фона
window.draw(background, &textureBackground);
Предыдущий код гораздо эффективнее, чем отрисовка каждой плитки в виде
отдельного спрайта.
ПРИМЕЧАНИЕ
Прежде чем мы продолжим, обратите внимание на префикс & перед texture
Background. Вы можете подумать, что это имеет отношение к ссылкам. На самом
деле мы передаем адрес памяти экземпляра Texture, а не сам экземпляр Texture.
Подробнее об этом мы узнаем в следующей главе.
Теперь мы можем использовать наши знания о ссылках и массивах вершин для
реализации следующего этапа проекта Zombie Arena — случайно генерируемого
прокручиваемого фона.
Создание случайно генерируемого
прокручиваемого фона
В этом разделе мы напишем функцию, которая будет создавать фон в отдельном
файле. Мы обеспечим доступность фона (в области видимости) для функции
main, используя ссылку на массив вершин.
Остальные функции, обменивающиеся данными с main, мы поместим в собственных файлах .cpp . Прототипы этих функций мы предоставим в новом
заголовочном файле, который включим (с помощью директивы #include )
в ZombieArena.cpp.
Создайте новый заголовочный файл ZombieArena.h и добавьте в него следующий
выделенный код, включая прототип функции:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
int createBackground(VertexArray& rVA, IntRect arena);
Данный код позволяет нам написать определение функции createBackground.
Чтобы соответствовать прототипу, определение функции должно возвращать
значение типа int, а в качестве параметров принимать ссылку на VertexArray
и объект IntRect.
258 Глава 9. Ссылки, спрайт-листы и массивы вершин в C++
Создайте новый файл под названием CreateBackground.cpp и поместите в него
следующий код, представляющий сигнатуру вершин:
#include "ZombieArena.h"
int createBackground(VertexArray& rVA, IntRect arena)
{
// Все, что мы делаем с rVA, мы в действительности делаем
// с background (в функции function)
}
// Какого размера каждая плитка/текстура
const int TILE_SIZE = 50;
const int TILE_TYPES = 3;
const int VERTS_IN_QUAD = 4;
int worldWidth = arena.width / TILE_SIZE;
int worldHeight = arena.height / TILE_SIZE;
// Какой тип примитива мы используем?
rVA.setPrimitiveType(Quads);
// Устанавливаем размер массива вершин
rVA.resize(worldWidth * worldHeight * VERTS_IN_QUAD);
// Стартуем с начала массива вершин
int currentVertex = 0;
return TILE_SIZE;
В теле функции (внутри фигурных скобок) мы объявляем и инициализируем
три новые константы int для хранения значений, на которые мы будем ссылаться
далее в функции. Это TILE_SIZE, TILE_TYPES и VERTS_IN_QUAD.
Константа TILE_SIZE обозначает размер в пикселях каждой плитки в спрайт-листе.
Константа TILE_TYPES указывает количество различных плиток в спрайтлисте. Мы можем добавить больше плиток в наш спрайт-лист и изменить
TILE_TYPES, и код, который мы собираемся написать, все равно будет работать.
VERTS_IN_QUAD означает, что в каждом четырехугольнике четыре вершины.
Использование этой константы менее чревато ошибками, чем постоянное введение числа 4.
Затем мы объявляем и инициализируем две переменные типа int: worldWidth
и worldHeight. Они обозначают ширину и высоту мира в плитках, а не пикселях.
Переменные worldWidth и worldHeight инициализируются путем деления высоты
и ширины переданной арены на константу TILE_SIZE.
Далее мы впервые используем нашу ссылку. Помните, что все, что мы делаем
с rVA, касается и переданной переменной, которая находится в области видимости
функции main (или будет находиться, когда мы ее напишем).
Потом мы подготавливаем массив вершин к использованию четырехугольников
с помощью rVA.setType, а затем делаем его нужного размера с помощью rVA.
resize. В функцию resize мы передаем результат worldWidth * worldHeight *
VERTS_IN_QUAD, что соответствует точному количеству вершин, которое будет
у нашего массива, когда мы закончим его подготовку.
Создание случайно генерируемого прокручиваемого фона 259
Последняя строка кода объявляет переменную currentVertex и инициализирует
ее нулем. Мы будем использовать currentVertex при проходе по массиву вершин,
инициализируя все вершины.
Теперь мы можем написать первую часть вложенного цикла for, который подготовит массив вершин. Добавьте следующий выделенный код и, основываясь на
том, что мы узнали о массивах вершин, попробуйте понять, что он делает:
// Стартуем с начала массива вершин
int currentVertex = 0;
for (int w = 0; w < worldWidth; w++)
{
for (int h = 0; h < worldHeight; h++)
{
// Устанавливаем позиции каждой вершины в текущем четырехугольнике
rVA[currentVertex + 0].position =
Vector2f(w * TILE_SIZE, h * TILE_SIZE);
rVA[currentVertex + 1].position =
Vector2f((w * TILE_SIZE) + TILE_SIZE, h * TILE_SIZE);
rVA[currentVertex + 2].position =
Vector2f((w * TILE_SIZE) + TILE_SIZE, (h * TILE_SIZE)
+ TILE_SIZE);
rVA[currentVertex + 3].position =
Vector2f((w * TILE_SIZE), (h * TILE_SIZE)
+ TILE_SIZE);
// Позиция, готовая для следующих четырех вершин
currentVertex = currentVertex + VERTS_IN_QUAD;
}
}
return TILE_SIZE;
}
Код, который мы только что добавили, проходит через массив вершин с помощью
вложенного цикла for, который сначала проходит через первые четыре вершины:
currentVertex + 1, currentVertex + 2 и т. д.
Мы обращаемся к каждой вершине в массиве, используя индексную нотацию
rvA[currentVertex + 0]... и т. д. Благодаря индексной нотации мы вызываем
функцию position — rvA[currentVertex + 0].position....
В функцию position передаются горизонтальная и вертикальная координаты
каждой вершины. Мы можем определить эти координаты программно с помощью
комбинации w, h и TILE_SIZE.
Далее мы подготавливаем переменную currentVertex для последующего прохода
по вложенному циклу for, увеличивая ее на четыре позиции (то есть добавляя
четыре) с помощью кода currentVertex = currentVertex + VERTS_IN_QUAD.
260 Глава 9. Ссылки, спрайт-листы и массивы вершин в C++
Конечно, все это только устанавливает координаты наших вершин, а не назна
чает текстурные координаты из спрайт-листа. Именно этим мы и займемся
теперь.
Чтобы было совершенно ясно, куда вставлять новый код, я показал его в контексте вместе со всем кодом, который мы написали чуть раньше:
for (int w = 0; w < worldWidth; w++)
{
for (int h = 0; h < worldHeight; h++)
{
// Устанавливаем позиции каждой вершины в текущем четырехугольнике
rVA[currentVertex + 0].position =
Vector2f(w * TILE_SIZE, h * TILE_SIZE);
rVA[currentVertex + 1].position =
Vector2f((w * TILE_SIZE) + TILE_SIZE, h * TILE_SIZE);
rVA[currentVertex + 2].position =
Vector2f((w * TILE_SIZE) + TILE_SIZE, (h * TILE_SIZE)
+ TILE_SIZE);
rVA[currentVertex + 3].position =
Vector2f((w * TILE_SIZE), (h * TILE_SIZE)
+ TILE_SIZE);
// Определяем позицию в текстуре для текущего четырехугольника
// Это могут быть трава, камень, куст или стена
if (h == 0 || h == worldHeight-1 ||
w == 0 || w == worldWidth-1)
{
// Используем текстуру стены
rVA[currentVertex + 0].texCoords =
Vector2f(0, 0 + TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 1].texCoords =
Vector2f(TILE_SIZE, 0 + TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 2].texCoords =
Vector2f(TILE_SIZE, TILE_SIZE + TILE_TYPES * TILE_SIZE);
}
rVA[currentVertex + 3].texCoords =
Vector2f(0, TILE_SIZE + TILE_TYPES * TILE_SIZE);
// Позиция, готовая для следующих четырех вершин
currentVertex = currentVertex + VERTS_IN_QUAD;
}
}
return TILE_SIZE;
}
Создание случайно генерируемого прокручиваемого фона 261
Данный код устанавливает координаты в спрайт-листе, с которыми связана
каждая вершина. Обратите внимание на несколько длинное условие if. Оно
проверяет, является ли текущий четырехугольник одним из самых первых или
самых последних четырехугольников на арене. Если да (один из первых или
последних), это означает, что он является частью границы. Затем мы можем
применить простую формулу, используя TILE_SIZE и TILE_TYPES, чтобы выбрать
текстуру стены из спрайт-листа.
Массив и член texCoords инициализируются для каждой вершины по очереди,
чтобы назначить соответствующий угол текстуры стены в спрайт-листе.
Следующий код обернут в блок else. То есть он будет проходить через вложенный цикл for каждый раз, когда четырехугольник не будет представлять собой
плитку границы или стены. Добавьте следующий выделенный код к имеющемуся:
// Определяем позицию в текстуре для текущего четырехугольника
// Это могут быть трава, камень, куст или стена
if (h == 0 || h == worldHeight-1 ||
w == 0 || w == worldWidth-1)
{
// Используем текстуру стены
rVA[currentVertex + 0].texCoords =
Vector2f(0, 0 + TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 1].texCoords =
Vector2f(TILE_SIZE, 0 +
TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 2].texCoords =
Vector2f(TILE_SIZE, TILE_SIZE +
TILE_TYPES * TILE_SIZE);
rVA[currentVertex + 3].texCoords =
Vector2f(0, TILE_SIZE +
TILE_TYPES * TILE_SIZE);
}
else
{
// Используем случайную текстуру пола
srand((int)time(0) + h * w - h);
int mOrG = (rand() % TILE_TYPES);
int verticalOffset = mOrG * TILE_SIZE;
rVA[currentVertex + 0].texCoords =
Vector2f(0, 0 + verticalOffset);
rVA[currentVertex + 1].texCoords =
Vector2f(TILE_SIZE, 0 + verticalOffset);
rVA[currentVertex + 2].texCoords =
Vector2f(TILE_SIZE, TILE_SIZE + verticalOffset);
262 Глава 9. Ссылки, спрайт-листы и массивы вершин в C++
rVA[currentVertex + 3].texCoords =
Vector2f(0, TILE_SIZE + verticalOffset);
}
// Позиция, готовая для следующих четырех вершин
currentVertex = currentVertex + VERTS_IN_QUAD;
}
}
return TILE_SIZE;
}
Представленный код начинается с инициализации генератора случайных чисел
с помощью формулы, которая будет отличаться при каждом проходе через цикл.
Затем переменная mOrG инициализируется числом от 0 до TILE_TYPES. Это именно
то, что нам нужно для случайного выбора одного из типов плитки.
ПРИМЕЧАНИЕ
mOrG означает «грязь или трава» (mud or grass). Название произвольное.
Далее мы объявляем и инициализируем переменную verticalOffset, умножая
mOrG на TileSize. Теперь у нас есть вертикальная точка отсчета внутри спрайтлиста для начальной высоты случайно выбранной текстуры для текущего четырехугольника.
Мы используем простую формулу, включающую TILE_SIZE и verticalOffset,
чтобы назначить точные координаты каждого угла текстуры соответствующей
вершине.
Теперь применим нашу новую функцию в игровом движке.
Использование фона
Мы уже сделали все самое сложное, так что следующие три шага будут простыми.
Нам потребуется:
zzсоздать массив вершин (VertexArray);
zzинициализировать его после повышения уровня с каждой волной;
zzотрисовать его в каждом кадре.
Прежде чем мы добавим новый код, файл ZombieArena.cpp должен «узнать» о новом файле ZombieArena.h. Добавьте директиву include в верхнюю часть файла
ZombieArena.cpp, как показано ниже:
#include "ZombieArena.h"
Использование фона 263
Теперь скопируйте следующий выделенный код, чтобы объявить экземпляр
VertexArray с именем background, и загрузите файл background_sheet.png в качестве текстуры:
// Создаем экземпляр класса Player
Player player;
// Границы арены
IntRect arena;
// Создаем фон
VertexArray background;
// Загружаем текстуру для нашего массива вершин фона
Texture textureBackground;
textureBackground.loadFromFile("graphics/background_sheet.png");
// Основной игровой цикл
while (window.isOpen())
Добавьте следующий код для вызова функции createBackground , передавая
background как ссылку, а arena — по значению. Обратите внимание, что в выделенном коде мы также изменили способ инициализации переменной tileSize:
if (state == State::PLAYING)
{
// Подготовка уровня
// Позже мы изменим следующие две строки
arena.width = 500;
arena.height = 500;
arena.left = 0;
arena.top = 0;
// Передаем массив вершин по ссылке
// в функцию createBackground
int tileSize = createBackground(background, arena);
// Позже мы изменим эту строку кода
// int tileSize = 50;
// Генерируем игрового персонажа в центре арены
player.spawn(arena, resolution, tileSize);
// Сбрасываем таймер, чтобы избежать скачка кадра
clock.restart();
}
Обратите внимание, что мы заменили строку int tileSize = 50 , потому что
получаем это значение непосредственно из возвращаемого значения функции
createBackground.
СОВЕТ
Для большей ясности кода в будущем вам следует удалить строку int tileSize = 50
и связанный с ней комментарий. Я просто закомментировал ее, чтобы дать новому
коду более понятный контекст.
264 Глава 9. Ссылки, спрайт-листы и массивы вершин в C++
Наконец, пришло время для отрисовки. Это очень просто. Все, что нужно сделать, — вызвать window.draw и передать экземпляр VertexArray вместе с адресом
памяти текстуры textureBackground:
/*
***************
Отрисовка сцены
***************
*/
if (state == State::PLAYING)
{
window.clear();
// Устанавливаем mainView для отображения в окне
// и отрисовываем все связанные с ним элементы
window.setView(mainView);
// Отрисовываем фон
window.draw(background, &textureBackground);
// Отрисовываем игрового персонажа
window.draw(player.getSprite());
}
ПРИМЕЧАНИЕ
Если вам интересно, что делает странный символ & перед textureBackground, то
все станет ясно в следующей главе.
Если вы запустите игру на данном этапе, то увидите изображение, как показано
на рис. 9.3. Не забудьте нажать Enter и выбрать клавишу с цифрой, чтобы закрыть
наше временно невидимое меню.
Рис. 9.3. Фон
Часто задаваемые вопросы 265
Обратите внимание, как плавно скользит и вращается спрайт игрового персонажа
в пределах арены. Хотя текущий код в функции main отрисовывает небольшую
арену, функция CreateBackground может сгенерировать арену любого размера.
Мы увидим арены больше экрана в главе 14.
Резюме
В этой главе вы познакомились со ссылками в C++ — специальными переменными, выступающими в качестве псевдонимов для других переменных. Когда
мы передаем переменную по ссылке, а не по значению, то все, что мы делаем со
ссылкой, происходит с переменной в вызывающей функции.
Вы также узнали о массивах вершин и создали такой массив, заполненный четырехугольниками, чтобы отрисовать плитки из спрайт-листа в качестве фона.
Однако вы наверняка заметили, что в нашей игре про зомби пока нет ни одного
зомби. Мы исправим это в следующей главе, изучив STL и указатели C++.
Часто задаваемые вопросы
В. Можете ли вы снова кратко объяснить, что такое ссылки?
О. Ссылки должны быть инициализированы сразу, и их нельзя изменить для
ссылки на другую переменную. Используйте ссылки с функциями, чтобы не работать с копией переменной. Это полезно и эффективно, так как позволяет
избежать создания копий и помогает нам легче абстрагировать код в функции.
В. Какое основное преимущество использования ссылок?
О. Передача по ссылке быстрее, чем копирование.
10
Указатели, стандартная
библиотека шаблонов
и управление текстурами
В этой главе мы обсудим много нового и продвинемся дальше в разработке нашей игры. Сначала мы изучим указатели — переменные, которые хранят адрес
в памяти. Обычно это адрес другой переменной. Да, по описанию немного похоже на ссылку, но вскоре вы убедитесь, что указатели гораздо мощнее и лучше
использовать их для работы с постоянно растущей ордой зомби.
Вы также познакомитесь со стандартной библиотекой шаблонов (STL), которая
представляет собой коллекцию классов, позволяющих быстро и легко реализовывать общие методы управления данными.
Что такое указатели
Концепция указателей довольно проста.
ПРИМЕЧАНИЕ
Указатель — это переменная, которая хранит адрес в памяти.
Вот и все! Однако синтаксис указателей вполне может вызвать разочарование
у новичков. Не беспокойтесь, шаг за шагом мы разберем каждый фрагмент кода
с указателями. После этого вы сможете начать непрерывный процесс их освоения.
Позже, в финальном проекте, вы познакомитесь с умными указателями, которые
в определенном смысле упрощают то, что мы собираемся изучить, но являются
менее гибкими.
Я редко говорю, что заучивание фактов, цифр или синтаксиса — лучший способ
обучения. Однако запомнить краткий, но крайне важный синтаксис, связанный
с указателями, пожалуй, стоит. Это гарантирует, что информация настолько глубоко укоренится в нашем сознании, что мы никогда ее не забудем. Затем мы поговорим о том, зачем вообще нужны указатели, и рассмотрим их связь со ссылками.
Что такое указатели 267
ПРИМЕЧАНИЕ
В этом разделе мы узнаем об указателях больше, чем нужно для данного проекта.
В следующем проекте мы будем использовать указатели более активно. И, даже несмотря на это, мы лишь коснемся верхушки айсберга этой темы.
В предыдущей главе мы узнали, что при передаче значений в функцию или возвращении их из функции мы фактически создаем совершенно новый тип переменной, но он точно такой же, как и предыдущий. Мы создаем копию значения,
которое передается в функцию или возвращается из нее.
ПРИМЕЧАНИЕ
Если тип переменной — это дом, а его содержимое — значение, которое он хранит,
то указатель — это адрес дома.
Вероятно, вам начинает казаться, что указатели не отличаются от ссылок. Однако
указатели гораздо более гибкие и мощные, и у них есть свои особые и уникальные сценарии применения. Для этих особых и уникальных сценариев требуется
особый и уникальный синтаксис. Сначала рассмотрим его.
Синтаксис
С указателями связаны два основных оператора: оператор получения адреса
переменной (&) и оператор разыменования (*). Рассмотрим различные способы
использования этих операторов с указателями.
Первое, что вы заметите, — это то, что оператор адреса совпадает с оператором
ссылки. Новичкам в C++ стоит запомнить, что операторы делают разные вещи
в зависимости от контекста. Если вы смотрите на код с указателями и вам кажется, что вы сходите с ума, знайте: с вами все в порядке! Просто нужно обратить
внимание на контекст.
В суть работы указателей сложно вникнуть сразу, но если внимательно изу
чить контекст, то можно понять, что происходит. Разберем эти вопросы на практике.
СОВЕТ
Прежде чем приступить к работе, убедитесь, что вы запомнили эти два оператора.
268 Глава 10. Указатели, стандартная библиотека шаблонов и текстуры
Объявление указателя
Чтобы объявить новый указатель, мы используем оператор разыменования вместе с типом переменной, адрес которой будет хранить указатель. Прежде чем мы
поговорим об указателях, взгляните на следующий код:
// Объявляем указатель для хранения адреса переменной типа int
int* pHealth;
Здесь объявляется новый указатель с именем pHealth, который может хранить
адрес переменной типа int. Заметьте, я сказал «может». Как и другие переменные, указатель также должен быть инициализирован значением, чтобы правильно его использовать.
Имя pHealth, как и другие переменные, является произвольным.
ПРИМЕЧАНИЕ
К именам переменных, являющихся указателями, принято добавлять префикс p. Это
значительно упрощает ориентацию в коде.
Пробелы вокруг оператора разыменования необязательны, поскольку C++ редко
обращает внимание на пробелы в синтаксисе. Тем не менее их использование
рекомендуется, так как это улучшает читаемость. Взгляните на следующие три
строки кода, которые выполняют одно и то же действие.
Данный формат мы видели в предыдущем примере (оператор разыменования
рядом с типом):
int* pHealth;
Здесь по обе стороны от оператора разыменования есть пробелы:
int * pHealth;
А тут оператор разыменования указан рядом с именем указателя:
int *pHealth;
Стоит знать о таких вариантах написания, чтобы при чтении, например, чужого
кода вы могли в нем ориентироваться. В книге мы всегда будем использовать
первый вариант.
Так же как обычная переменная может содержать данные только соответствующего типа, указатель должен хранить адрес переменной соответствующего
типа.
Что такое указатели 269
Указатель на тип int не должен содержать адрес переменной типа String, Zombie,
Player, Sprite, float или любого другого, кроме int.
Посмотрим, как можно инициализировать указатели.
Инициализация указателя
Разберемся, как можно получить адрес переменной в указателе. Взгляните на
следующий код:
// Обычная переменная типа int с именем health
int health = 5;
// Объявляем указатель для хранения адреса переменной типа int
int* pHealth;
// Инициализируем pHealth, чтобы он хранил адрес переменной health,
// используя оператор получения адреса
pHealth = &health;
Здесь мы объявляем переменную int с именем health и инициализируем ее значением 5. Логично, хотя мы раньше не обсуждали, что эта переменная должна находиться где-то в памяти нашего компьютера. У нее должен быть адрес в памяти.
Мы можем получить доступ к этому адресу с помощью оператора &. Взгляните на
последнюю строку предыдущего кода. Инициализируем pHealth адресом health:
pHealth = &health;
Наш указатель pHealth теперь содержит адрес обычной переменной health типа int.
ПРИМЕЧАНИЕ
На языке C++ мы говорим, что pHealth указывает на health.
Мы можем использовать pHealth, передавая его в функцию, чтобы функция
могла работать с health, как мы это делали со ссылками.
Однако это еще не все, что мы собирались делать с указателями, поэтому давайте
посмотрим на их повторную инициализацию.
Повторная инициализация указателей
Указатель, в отличие от ссылки, можно повторно инициализировать, чтобы он
указывал на другой адрес. Взгляните на следующий код:
// Обычная переменная типа int с именем health
int health = 5;
int score = 0;
270 Глава 10. Указатели, стандартная библиотека шаблонов и текстуры
// Объявляем указатель для хранения адреса переменной типа int
int* pHealth;
// Инициализируем pHealth, чтобы он хранил адрес переменной health
pHealth = &health;
// Повторно инициализируем pHealth, чтобы он хранил адрес score
pHealth = &score;
Теперь pHealth указывает на переменную score типа int.
Конечно, имя нашего указателя, pHealth, теперь стало неоднозначным, и, возможно, его следовало бы назвать pIntPointer. Главное, что нужно понять, — мы
можем сделать это.
На данном этапе мы еще не использовали указатель ни для чего, кроме простого
указания (хранения адреса памяти). Посмотрим, как мы можем получить доступ
к значению, хранящемуся по адресу, на который ведет указатель.
Разыменование указателя
Мы знаем, что указатель хранит адрес в памяти. Если бы мы выводили этот адрес
в нашей игре, например, на HUD после его объявления и инициализации, он
мог бы выглядеть примерно так: 9876.
Это просто значение, представляющее собой адрес в памяти. В различных операционных системах и типах аппаратного обеспечения диапазон этих значений
может отличаться. В контексте книги нам никогда не понадобится напрямую
управлять адресами. Нас интересует только то, какое значение хранится по адресу, на который ведет указатель.
Фактические адреса, используемые переменными, определяются во время выполнения игры, поэтому невозможно заранее узнать адрес переменной и, следовательно, значение, хранящееся в указателе, пока мы пишем код игры.
Мы можем получить доступ к значению, хранящемуся по адресу, на который
ведет указатель, с помощью оператора разыменования (*).
Да, это тот же самый символ, который мы используем для объявления указателей. Но не забывайте про контекст, он очень важен. Следующий код работает
с некоторыми переменными напрямую и через указатели. Попробуйте разобраться в нем.
// Обычные переменные типа int
int score = 0;
int hiScore = 10;
// Объявляем 2 указателя, которые будут хранить адреса переменных типа int
int* pIntPointer1;
int* pIntPointer2;
// Инициализируем pIntPointer1, присваивая ему адрес переменной score
pIntPointer1 = &score;
Что такое указатели 271
// Инициализируем pIntPointer2, присваивая ему адрес переменной hiScore
pIntPointer2 = &hiScore;
// Прибавляем 10 к score напрямую
score += 10;
// Теперь переменная score равна 10
// Прибавляем 10 к score, используя pIntPointer1
*pIntPointer1 += 10;
// Теперь score равна 20 — новый рекорд
// Присваиваем новое значение hiScore, используя только указатели
*pIntPointer2 = *pIntPointer1;
// Теперь и hiScore, и score равны 20
В приведенном коде мы объявляем две переменные типа int — score и hiScore
и инициализируем их значениями 0 и 10 соответственно. Затем мы объявляем
два указателя типа int: pIntPointer1 и pIntPointer2. Мы инициализируем их на
том же этапе, что и объявляем, чтобы они содержали адреса переменных score
и hiScore соответственно.
Далее мы прибавляем 10 к score обычным способом: score += 10. Используя оператор разыменования указателя, мы можем получить доступ к значению, хранящемуся по адресу, на который он указывает. Следующий код изменил значение,
хранящееся в переменной, на которую указывает pIntPointer1:
// Прибавляем 10 к score, используя pIntPointer1
*pIntPointer1 += 10;
// Теперь score равна 20 — новый рекорд
В последней части кода мы разыменовываем оба указателя, чтобы присвоить
значение, на которое указывает pIntPointer1, значению, на которое указывает
pIntPointer2:
// Присваиваем новое значение hiScore, используя только указатели
*pIntPointer2 = *pIntPointer1;
// Теперь и hiScore, и score равны 20
В результате score и hiScore теперь равны 20.
Указатели — универсальный и мощный инструмент
С помощью указателей можно сделать гораздо больше. Вот лишь несколько полезных возможностей.
Динамическое выделение памяти
Все указатели, которые мы рассматривали до сих пор, ведут на адреса памяти, область видимости которых ограничена функцией, в которой они созданы. Поэтому
если мы объявим и инициализируем указатель на локальную переменную, то после возвращения функции этот указатель, локальная переменная и адрес памяти
исчезнут. Они выйдут за пределы области видимости.
272 Глава 10. Указатели, стандартная библиотека шаблонов и текстуры
До сих пор мы использовали фиксированный объем памяти, который заранее
определялся перед запуском игры. Более того, этой памятью управляла операционная система, а переменные создавались и удалялись при вызове и возврате
из функций. Нам нужен способ выделять память, которая остается доступной до
тех пор, пока мы сами не решим ее освободить.
Когда мы объявляем переменные (включая указатели), они размещаются в области памяти, называемой стеком. Мы обсуждали, как работает стек, когда
рассматривали добавление и удаление функций, их параметров и локальных
переменных в главе 4. Существует и другая область памяти — куча (heap). Она
также выделяется и контролируется операционной системой, но, в отличие от
стека, память в куче может выделяться во время выполнения программы.
ПРИМЕЧАНИЕ
Память в куче не имеет привязки к конкретной функции. Возврат из функции не удаляет память из кучи.
Эта особенность дает нам огромные возможности. Имея доступ к памяти, которая ограничена только ресурсами компьютера, мы можем создавать игры
с огромным количеством объектов. В нашем случае — с ордами зомби. Однако,
как сказал бы дядя Питера Паркера (Человека-паука), «с большой силой приходит большая ответственность».
Рассмотрим, как использовать указатели для управления памятью в куче, а также
как освободить эту память, когда она нам больше не нужна.
Следующий код создает указатель, который будет вести на область памяти
в куче:
int* pToInt = nullptr;
В данной строке кода мы объявляем указатель так же, как и раньше, но инициализируем его в nullptr, а не для указания на переменную. Это хорошая практика.
Представьте, что вы разыменовываете указатель (то есть изменяете значение по
адресу, на который он указывает), не зная, на что он указывает. Это равносильно
тому, как если бы вы пошли в тир, завязали кому-то глаза, покрутили его на месте
и сказали стрелять. Устанавливая указатель на nullptr, мы гарантируем, что он
не может нанести вред.
Когда мы готовы запросить память в куче, мы используем ключевое слово new:
pToInt = new int;
Теперь pToInt содержит адрес области памяти в куче, достаточной для хранения
значения типа int.
Что такое указатели 273
ПРИМЕЧАНИЕ
Вся выделенная память освобождается только после завершения программы. Однако
в рамках выполнения нашей игры эта память никогда не будет освобождена, пока
мы сами этого не сделаем. Если мы будем продолжать забирать память из кучи,
не отдавая ее обратно, в конце концов она закончится и игра зависнет или завершит
работу с ошибкой.
Маловероятно, что мы когда-нибудь исчерпаем всю память, периодически занимая участки размером с int. Но если в вашей программе есть функция или цикл,
которые запрашивают память и эта функция или цикл регулярно выполняются
на протяжении всей игры, то в конечном счете игра будет тормозить, а потом
и вовсе аварийно завершится. Более того, если мы выделяем много объектов
в куче и неправильно ими управляем, то такая ситуация может возникнуть довольно быстро.
Следующая строка кода возвращает (удаляет) память в куче, на которую ранее
указывал pToInt:
delete pToInt;
Теперь память, на которую раньше указывал pToInt, больше не принадлежит нам,
и мы можем делать с ней все, что захотим. Мы должны принять меры предосторожности. Хотя память была передана обратно операционной системе, pToInt
все еще хранит ее адрес.
Следующая строка кода гарантирует, что pToInt не может быть использован для
попыток манипулирования или доступа к этой памяти:
pToInt = nullptr;
ПРИМЕЧАНИЕ
Если указатель ведет на недопустимый адрес, он называется недействительным или
висячим указателем. Если вы попытаетесь разыменовать висячий указатель и вам
повезет, игра просто аварийно завершится и выдаст ошибку нарушения доступа к памяти. Если не повезет, то вы создадите баг, который будет крайне сложно обнаружить.
Более того, если мы выделили память в куче, которая должна существовать дольше,
чем функция, мы должны сохранить указатель на нее. В противном случае возникнет
утечка памяти. То есть память останется выделенной, но мы потеряем к ней доступ.
В C++ существуют умные указатели, которые помогают избежать утечек памяти.
Они часто являются наиболее подходящим выбором, но, прежде чем их применять,
важно разобраться с обычными указателями. Кроме того, есть вещи, которые можно
сделать только с обычным указателем.
274 Глава 10. Указатели, стандартная библиотека шаблонов и текстуры
Теперь мы знаем, как объявлять указатели и выделять для них память в куче.
Мы можем управлять памятью, на которую они указывают, и получать к ней
доступ, разыменовывая их. Мы также можем возвращать память в кучу, когда
закончим с ней работать, и знаем, как избежать проблемы висячих указателей.
В следующих разделах рассмотрим еще несколько преимуществ использования
указателей.
Передача указателя в функцию
Чтобы передать указатель в функцию, нужно написать функцию, в прототипе
которой есть указатель, как в следующем коде:
void myFunction(int *pInt)
{
// Разыменовываем указатель и увеличиваем значение,
// хранящееся по адресу, на который он указывает
*pInt ++
return;
}
Предыдущая функция просто разыменовывает указатель и прибавляет единицу
к значению, хранящемуся по указанному адресу.
Теперь мы можем использовать эту функцию и передавать адрес переменной или
другой указатель на переменную в явном виде:
int someInt = 10;
int* pToInt = &someInt;
myFunction(&someInt);
// someInt теперь равна 11
myFunction(pToInt);
// someInt теперь равна 12
Как показано в предыдущем коде, внутри функции мы управляем переменной
из вызывающего кода и можем делать это с помощью адреса переменной или
указателя на нее, поскольку оба действия сводятся к одному и тому же.
Указатели также могут указывать на экземпляры класса.
Объявление и использование указателя на объект
Указатели предназначены не только для обычных переменных. Мы также можем
объявлять указатели на пользовательские типы, такие как наши классы. Вот как
мы объявим указатель на объект типа Player:
Player player;
Player* pPlayer = &Player;
Что такое указатели 275
Мы даже можем получить доступ к функциям-членам объекта Player напрямую
через указатель, как показано ниже:
// Вызываем функцию-член класса Player
pPlayer->moveLeft()
Обратите внимание на одно очень важное отличие: обращение к функции с помощью указателя на объект, а не через сам объект использует оператор ->.
Оператор -> в C++ называют оператором доступа к члену или просто стрелочным оператором. Он используется для доступа к членам класса через указатель
на этот класс. Оператор -> является сокращенной записью для разыменования
указателя на объект и одновременного доступа к члену этого объекта.
В данном проекте нам не понадобится использовать указатели на объекты, но мы
изучим их более тщательно перед тем, как они потребуются нам, что произойдет
в финальном проекте. Давайте рассмотрим еще одну новую тему, связанную
с указателями.
Указатели и массивы
У массивов и указателей есть нечто общее. Имя массива — это адрес в памяти.
Точнее, имя массива — это адрес в памяти первого элемента в этом массиве.
Другими словами, имя массива указывает на его первый элемент. Лучший способ
понять это — продолжить чтение и рассмотреть следующий пример.
Мы можем создать указатель на тип, который содержит массив, а затем применить этот указатель тем же способом, используя точно такой же синтаксис, как
и для массива:
// Объявляем массив из 100 элементов типа int
int arrayOfInts[100];
// Объявляем указатель на int и инициализируем его
// адресом первого элемента массива arrayOfInts
int* pToIntArray = arrayOfInts;
// Используем pToIntArray так же, как и arrayOfInts
arrayOfInts[0] = 999;
// Первый элемент arrayOfInts теперь равен 999
pToIntArray[0] = 0;
// Первый элемент arrayOfInts теперь равен 0
Это также означает, что функция, прототип которой принимает указатель,
принимает массивы того типа, на который ведет указатель. Мы воспользуемся
этим фактом, когда будем создавать нашу постоянно увеличивающуюся орду
зомби.
276 Глава 10. Указатели, стандартная библиотека шаблонов и текстуры
СОВЕТ
Что касается связи между указателями и ссылками, то компилятор действительно использует указатели при реализации наших ссылок. Это означает, что ссылки — просто
удобный инструмент (который использует указатели «под капотом»). Ссылки можно
сравнить с автоматической коробкой передач, которая хороша и удобна для езды по городу, в то время как указатели — с механической — более сложной, но при правильном
использовании обеспечивающей лучшие результаты, производительность и гибкость.
Кратко об указателях
Указатели иногда бывает очень сложно понять. На самом деле наше обсуждение
указателей было лишь введением в тему. Единственный способ разобраться в них —
практика. Вот все, что вам нужно знать об указателях, чтобы завершить этот проект:
zzуказатели — это переменные, хранящие адрес памяти;
zzмы можем передавать указатели на функции, чтобы напрямую управлять
значениями из области видимости вызывающей функции внутри самой вызываемой функции;
zzимена массивов хранят адрес памяти первого элемента (мы можем передать
этот адрес как указатель, потому что это именно то, чем он является);
zzмы можем использовать указатели для указания на память в куче. Это означает, что мы можем динамически выделять большие объемы памяти во время
работы игры.
ПРИМЕЧАНИЕ
Существует множество способов использования указателей. Мы узнаем об умных
указателях в финальном проекте, как только привыкнем к обычным указателям.
Осталось рассмотреть еще одну тему, прежде чем мы сможем снова приступить
к работе над проектом Zombie Arena.
Знакомство с STL
Библиотека стандартных шаблонов (STL) — это коллекция контейнеров данных
и методов управления данными, которые мы помещаем в эти контейнеры. Если
быть более точным, то это способ хранения различных типов переменных и классов C++ и манипулирования ими.
Знакомство с STL 277
Мы можем рассматривать различные контейнеры как настраиваемые и более совершенные массивы. STL является частью C++. Это не опциональная
вещь, которую нужно настраивать, как SFML. Библиотека STL реализует
код, который нам и практически каждому программисту на C++ обязательно
понадобится, по крайней мере, в какой-то момент, а возможно, и довольно
регулярно.
Если бы нам пришлось писать собственный код для хранения данных и управления ими, то вряд ли мы написали бы его так же эффективно, как люди, создавшие STL.
Таким образом, применяя STL, мы гарантируем, что для управления нашими
данными мы используем лучший код из всех возможных. Даже в SFML задействован STL. Например, в классе VertexArray.
Все, что нам нужно сделать, — выбрать правильный тип контейнера из доступных. Типы контейнеров в STL:
zzвектор (vector ): это как массив, но с дополнительными возможностями.
Он поддерживает динамическое изменение размера, сортировку и поиск.
Это, пожалуй, самый полезный контейнер. Мы рассмотрим пример кода
с векторами далее;
zzсписок (list): контейнер, позволяющий упорядочивать данные;
zzсловарь (map): ассоциативный контейнер, который хранит данные в виде пар
«ключ — значение». В этом случае один элемент данных является «ключом»
для поиска другого. Словарь может увеличиваться и уменьшаться, а также
поддерживает поиск;
zzмножество (set): контейнер, гарантирующий уникальность каждого элемента.
В игре Zombie Arena мы будем использовать словарь.
СОВЕТ
Если вы хотите узнать, от какой работы избавляет нас замечательная библиотека STL, то взгляните на этот учебник, в котором реализовано то, что должен делать
контейнер-список: http://www.sanfoundry.com/cpp-program-implement-single-linkedlist/. Обратите внимание, что в учебнике реализована только самая простая версия
контейнера-списка.
Библиотека STL сэкономит нам много времени и позволит в итоге получить
более качественную игру. Давайте подробнее рассмотрим, как использовать
экземпляр vector.
278 Глава 10. Указатели, стандартная библиотека шаблонов и текстуры
Что такое вектор
Вектор в C++ — это динамический массив, позволяющий хранить коллекцию элементов и управлять ею. Он представляет собой гибкий и изменяемый
по размеру контейнер, похожий на массив, но с дополнительными возможностями, которые делают его мощным инструментом для работы с коллекциями
данных.
Объявление вектора
Чтобы объявить вектор, мы используем класс-шаблон vector из стандартной
библиотеки шаблонов (STL). Например, так:
// Добавляем заголовочный файл vector в проект
#include <vector>
vector<int> numbers;
Здесь numbers — это вектор, который может хранить целые числа. Однако, как
и массивы, векторы разрешается использовать для хранения элементов любого
типа данных.
Добавление данных в вектор
Добавим в наш вектор несколько целых чисел:
numbers.push_back(42);
numbers.push_back(73);
numbers.push_back(10);
Теперь наш вектор numbers содержит три целых числа: 42, 73 и 10.
Доступ к данным в векторе
Мы можем получить доступ к элементам вектора, используя тот же синтаксис,
что и для массивов:
int firstNumber = numbers[0]; // Доступ к первому элементу (42)
int secondNumber = numbers[1]; // Доступ ко второму элементу (73)
И мы можем удалить данные из вектора.
Удаление данных из вектора
Удаление элементов из вектора может быть выполнено различными методами.
Например, для удаления первого элемента:
numbers.erase(numbers.begin());
Знакомство с STL 279
Теперь numbers содержат только два элемента: 73 и 10. Это работает, потому что
numbers.begin указывает на первый элемент, а функция erase делает именно
то, что следует из ее названия. Все эти функции доступны, потому что numbers —
это экземпляр вектора.
Проверка размера вектора
Чтобы узнать, сколько элементов содержится в векторе, можно воспользоваться
методом size:
int size = numbers.size(); // Размер теперь равен 2
Функция size возвращает количество элементов в векторе и сохраняет результат
в переменной int size.
Перебор элементов вектора
Мы можем применить цикл для перебора всех элементов вектора. Вот пример
с обычным циклом for:
for (vector<int>::iterator it = numbers.begin(); it != numbers.end();
it ++)
{
*it += 1; // Увеличиваем каждый элемент на 1
}
Однако мы можем упростить эту задачу, используя ключевое слово auto:
for (auto it = numbers.begin(); it != numbers.end(); ++it)
{
*it += 1; // Увеличиваем каждый элемент на 1
}
Ключевое слово auto помогает сократить код, позволяя компилятору самостоя
тельно определить тип. Тип vector<int>::iterator — это переменная цикла,
которая инициализируется значением numbers.begin(). До тех пор пока эта переменная не равна numbers.end, мы продолжаем увеличивать ее с помощью it++.
Как мы увидим, когда будем говорить о словаре, формат этих циклов довольно
гибкий. Такой лаконичный синтаксис улучшает читаемость кода, поскольку
программистам больше не требуется явно указывать сложные типы итераторов,
что приводит к созданию более чистых и интуитивно понятных структур циклов,
а это, безусловно, является преимуществом.
Векторы универсальны и широко используются в C++ благодаря их способности динамически изменять размер и простому синтаксису. Они предоставляют
удобный способ эффективного управления коллекциями данных. В финальном
проекте мы будем использовать векторы для работы с игровыми объектами.
Но сначала рассмотрим словарь.
280 Глава 10. Указатели, стандартная библиотека шаблонов и текстуры
Что такое словарь
Словарь — это контейнер, который способен динамически изменяться в размере.
Мы можем с легкостью добавлять и удалять элементы. Что делает класс map
особенным по сравнению с другими контейнерами в STL, так это способ доступа
к данным внутри него.
Данные в экземпляре map хранятся парами. Рассмотрим ситуацию, когда вы входите в учетную запись, возможно, с именем пользователя и паролем. Контейнер
map идеально подходит для поиска имени пользователя и последующей проверки
значения связанного с ним пароля.
Контейнер map также подойдет для таких вещей, как названия и номера счетов
или, возможно, названия компаний и цены акций.
Обратите внимание, что при использовании map библиотеки STL мы сами определяем тип значений, которые образуют пары «ключ — значение». Это могут быть
экземпляры string и int, например номера счетов, экземпляры string и другие
экземпляры string вроде имен пользователей и паролей, а также пользовательские типы, например объекты.
Далее привожу реальный код, чтобы познакомить вас со словарем.
Объявление словаря
Вот как мы можем объявить словарь:
map<string, int> accounts;
Предыдущая строка кода объявляет новый словарь map под названием accounts,
где ключом являются объекты типа string, каждый из которых ссылается на
значение типа int.
Теперь мы можем хранить пары «ключ — значение» типа string, которые ссылаются на значения типа int. Далее мы рассмотрим, как это сделать.
Добавление данных в словарь
Добавим пару «ключ — значение» в учетные записи:
accounts["John"] = 1234567;
Сейчас в map есть запись, доступ к которой можно получить по ключу John. Следующий код добавляет еще две записи в accounts:
accounts["Smit"] = 7654321;
accounts["Larissa"] = 8866772;
Теперь в нашем словаре map три записи. Посмотрим, как получить доступ к элементам словаря.
Знакомство с STL 281
Поиск данных в словаре
Чтобы получить доступ к данным, достаточно воспользоваться ключом. Например, мы можем присвоить значение, хранящееся в ключе Smit, новой переменной
accountNumber типа int следующим образом:
int accountNumber = accounts["Smit"];
Теперь переменная accountNumber типа int хранит значение 7654321. Мы можем
делать с данным значением все, что можно делать с этим типом.
Удаление данных из словаря
Извлечение значений из словаря также не вызывает затруднений. Следующая
строка кода удаляет ключ John и связанное с ним значение:
accounts.erase("John");
Рассмотрим еще несколько вещей, которые можно сделать с помощью словаря.
Проверка размера словаря
Чтобы узнать знать, сколько пар «ключ — значение» содержится в нашем словаре,
достаточно воспользоваться следующей строкой кода:
int size = accounts.size();
Переменная size типа int теперь имеет значение 2. Это происходит потому, что
в переменной accounts хранятся значения для Smit и Larissa, потому что мы
удалили John.
Проверка наличия ключей в словаре
Наиболее важной особенностью словаря map является возможность находить
в нем значение по ключу. Мы можем проверить наличие или отсутствие определенного ключа следующим образом:
if(accounts.find("John") != accounts.end())
{
// Этот код не выполнится, потому что John был удален
}
if(accounts.find("Smit") != accounts.end())
{
// Этот код выполнится, потому что Smit есть в словаре
}
Значение != accounts.end используется для определения наличия или отсутствия ключа. Если искомого ключа в map нет, то результатом оператора if
будет accounts.end.
Посмотрим, как можно проверить или использовать все значения в map, пройдя
по словарю циклом.
282 Глава 10. Указатели, стандартная библиотека шаблонов и текстуры
Перебор пар «ключ — значение» в словаре
Мы видели, как можно использовать цикл for для перебора всех значений массива. Но что, если мы хотим сделать что-то подобное со словарем?
Следующий код показывает, как мы можем просмотреть каждую пару «ключ —
значение» в словаре accounts и увеличить каждый из номеров на единицу:
for (map<string,int>::iterator it = accounts.begin();
it != accounts.end();
++ it)
{
it->second += 1;
}
Условие цикла for — это, пожалуй, самая интересная часть данного кода. Первая
часть условия самая длинная: map<string,int>::iterator it = accounts.begin().
Она станет более понятной, если разбить ее на части.
map<string,int>::iterator — это тип. Мы объявляем iterator, который подходит
для map с парами string и int.
Имя итератора — it. Мы присваиваем ему значение, возвращаемое функцией
accounts.begin(). Теперь в итераторе it хранится первая пара «ключ — значение»
из словаря accounts.
Остальная часть цикла for работает следующим образом: it != accounts.end()
означает, что цикл будет продолжаться до тех пор, пока не достигнет конца словаря, а ++it просто переходит к следующей паре при каждом проходе через цикл.
Внутри цикла for выражение it->second обращается к значению пары «ключ —
значение», а += 1 добавляет единицу к этому значению. Обратите внимание, что
мы можем получить доступ к ключу (который является первой частью пары
«ключ — значение») с помощью it->first.
Вы могли заметить, что синтаксис для цикла по словарю довольно массивен.
В C++ есть способ его сократить.
Ключевое слово auto
Код, приведенный в условии цикла for, нельзя назвать лаконичным, особенно
в части map<string,int>::iterator. Мы используем ключевое слово auto, чтобы
сократить предыдущий код, например, так:
for (auto it = accounts.begin(); it != accounts.end(); ++ it)
{
it->second += 1;
}
Ключевое слово auto указывает компилятору на автоматическое определение
типа. Это будет особенно полезно при написании классов.
Часто задаваемые вопросы 283
Краткое описание STL
Как и почти все концепции C++, которые мы рассматривали в книге, STL — это
обширная тема. Только одной STL посвящены целые книги. Однако на данный
момент мы знаем достаточно, чтобы создать класс, который использует словарь
STL для хранения объектов Texture библиотеки SFML. Затем мы можем получить текстуры, которые можно извлекать и загружать, используя имя файла
в качестве ключа пары «ключ — значение».
Причина, по которой мы пошли на этот дополнительный уровень сложности, а не
просто продолжили использовать класс Texture так же, как делали до сих пор,
станет понятной по мере продвижения по книге.
Резюме
В данной главе мы рассмотрели указатели и обсудили, что они представляют
собой переменные, которые хранят адрес памяти объекта определенного типа.
Более глубокое понимание этого понятия начнет раскрываться по мере изучения
возможностей указателей.
Мы поговорили о библиотеке STL и, в частности, о классе map, реализовали класс,
который будет хранить все наши текстуры, а также предоставлять доступ к ним.
В следующей главе мы воспользуемся полученными в этой главе знаниями:
создадим орду зомби с помощью указателей и массивов, изучим удобный способ работы с текстурами для спрайтов, используя словари. Мы также немного
углубимся в ООП и применим статическую функцию, которую можно вызвать
без экземпляра класса.
Часто задаваемые вопросы
В. В чем разница между указателями и ссылками?
О. Указатели — это как усиленные ссылки. Указатели можно изменять, чтобы они
указывали на различные переменные (адреса в памяти), а также на динамически
выделяемую память в куче.
В. А что насчет массивов и указателей?
О. Имена массивов на самом деле являются постоянными указателями на их
первый элемент.
11
Класс TextureHolder
и создание орды зомби
Теперь, когда мы изучили основы STL, мы сможем использовать эти новые знания для управления всеми текстурами в игре. Ведь если у нас есть 1000 зомби,
не хотелось бы загружать в GPU копию изображения каждого из них.
Мы также немного углубимся в ООП и используем статическую функцию —
функцию класса, которую можно вызвать без экземпляра класса. В то же время
мы разберемся, как спроектировать класс, чтобы в нем существовал только один
экземпляр. Это идеальный вариант, когда нам нужно убедиться, что разные части
нашего кода работают с одними и теми же данными.
Реализация класса TextureHolder
Тысячи зомби представляют собой новую проблему. Загрузка, хранение и использование такого количества различных текстур занимают много памяти
и требуют повышенной вычислительной мощности. Мы создадим новый тип
класса, который решит эту задачу и позволит нам хранить только одну копию
каждой текстуры.
Мы также напишем класс таким образом, чтобы у него был только один экземпляр. Такой класс называется синглтоном или одиночкой.
ПРИМЕЧАНИЕ
«Синглтон» — это паттерн проектирования. Паттерн проектирования — это метод
структурирования кода, который доказал свою эффективность.
Более того, наш класс можно будет использовать в любом месте игрового кода
непосредственно через имя класса, без доступа к экземпляру. Это особый тип
класса, называемый статическим.
Реализация класса TextureHolder 285
Создание заголовочного файла TextureHolder
Создадим новый заголовочный файл. Щелкните правой кнопкой мыши на Header
Files (Файлы заголовков) в окне Solution Explorer (Обозреватель решений) и выберите AddNew Item (ДобавитьСоздать элемент). Далее выберите пункт Header
File (.h) (Файл заголовка), а затем в поле Name (Имя) введите TextureHolder.h.
Добавьте следующий код в файл TextureHolder.h:
#pragma once
#ifndef TEXTURE_HOLDER_H
#define TEXTURE_HOLDER_H
#include <SFML/Graphics.hpp>
#include <map>
using namespace sf;
using namespace std;
class TextureHolder
{
private:
// Контейнер map из STL,
// который хранит связанные пары String и Texture
map<string, Texture> m_Textures;
// Указатель того же типа, что и сам класс,
// один-единственный экземпляр
static TextureHolder* m_s_Instance;
public:
TextureHolder();
static Texture& GetTexture(string const& filename);
};
#endif
Обратите внимание, что у нас есть директива include для map библиотеки STL.
Мы объявляем экземпляр map, который содержит тип string и тип Texture библио
теки SFML, а также пары «ключ — значение». Он называется m_Textures.
Далее идет строка:
static TextureHolder* m_s_Instance;
Здесь мы объявляем статический указатель на объект типа TextureHolder с именем m_s_Instance. Это означает, что у класса TextureHolder есть объект того же
типа, что и он сам. Мало того, поскольку он статический, его можно применять
через сам класс без экземпляра класса. Когда мы будем писать код в соответствующем файле с расширением .cpp, мы увидим, как это можно использовать.
В части public находится прототип функции-конструктора TextureHolder .
Конструктор не принимает аргументов и, как обычно, не имеет возвращаемого типа. Это то же самое, что и конструктор по умолчанию. Мы собираемся
286 Глава 11. Класс TextureHolder и создание орды зомби
переопределить конструктор по умолчанию с помощью определения, которое
заставит наш синглтон работать так, как мы хотим.
У нас есть еще одна функция, которая называется GetTexture. Давайте снова посмотрим на ее сигнатуру и проанализируем, что именно происходит:
static Texture& GetTexture(string const& filename);
Обратите внимание, что функция возвращает ссылку на Texture . То есть
GetTexture возвращает ссылку, что эффективно, так как позволяет не создавать
копию изображения, которое может быть большим. Обратите также внимание, что
функция объявлена как static. Это означает, что ее можно использовать без экземпляра класса. В качестве параметра функция принимает string как константную
ссылку. Это дает двойной эффект. Во-первых, операция выполняется эффективно,
а во-вторых, поскольку ссылка является константой, ее нельзя изменить.
Далее мы перейдем к написанию определений функций для TextureHolder.
Создание определений функций TextureHolder
Теперь мы можем создать новый файл .cpp, который будет содержать определение функции. Это позволит нам увидеть причины появления новых типов функций и переменных. Щелкните правой кнопкой мыши на Source Files (Исходные
файлы) в окне Solution Explorer (Обозреватель решений) и выберите AddNew Item
(ДобавитьСоздать элемент). Далее выберите пункт C++ File (.cpp) (Файл C++)
и в поле Name (Имя) введите TextureHolder.cpp. Наконец, нажмите кнопку Add
(Добавить).
Добавьте следующий код:
#include "TextureHolder.h"
// Подключаем функцию assert
#include <assert.h>
TextureHolder* TextureHolder::m_s_Instance = nullptr;
TextureHolder::TextureHolder()
{
assert(m_s_Instance == nullptr);
m_s_Instance = this;
}
Мы инициализируем наш указатель типа TextureHolder значением nullptr. Код
в конструкторе assert(m_s_Instance == nullptr) гарантирует, что m_s_Instance
равен nullptr. Если это не так, игра завершится. Затем m_s_Instance = this присваивает указателю экземпляр this. Теперь рассмотрим, где выполняется этот
код. Код находится в конструкторе. Конструктор — это метод, позволяющий
создавать экземпляры объектов из классов. Таким образом, у нас есть указатель
на TextureHolder, который ведет на единственный экземпляр самого себя.
Реализация класса TextureHolder 287
Добавьте последний фрагмент кода в файл TextureHolder.cpp. Здесь больше
комментариев, чем кода:
Texture& TextureHolder::GetTexture(string const& filename)
{
// Получаем ссылку на m_Textures, используя m_s_Instance
auto& m = m_s_Instance->m_Textures;
// auto эквивалентно map<string, Texture>
// Создаем итератор для хранения пары "ключ — значение" (kvp)
// и ищем нужную пару kvp,
// используя переданное имя файла
auto keyValuePair = m.find(filename);
// auto эквивалентно map<string, Texture>::iterator
}
// Мы нашли совпадение?
if (keyValuePair != m.end())
{
// Да
// Возвращаем текстуру,
// вторую часть пары "ключ — значение" — текстуру
return keyValuePair->second;
}
else
{
// Имя файла не найдено
// Создаем новую пару "ключ — значение", используя имя файла
auto& texture = m[filename];
// Загружаем текстуру из файла как обычно
texture.loadFromFile(filename);
// Возвращаем текстуру в вызывающий код
return texture;
}
Первое, что вы заметите в приведенном коде, — это ключевое слово auto. Оно
было описано в предыдущем разделе.
СОВЕТ
Если вы хотите узнать, какие типы были заменены на auto, то взгляните на комментарии сразу после каждого использования auto . Вы также можете навести
курсор на ключевое слово auto в Visual Studio и увидеть всплывающую подсказку,
показывающую полный тип.
В начале кода мы получаем ссылку на m_textures. Затем мы пытаемся получить
итератор к паре «ключ — значение», представленной переданным именем файла
(filename ). Если мы находим совпадение по ключу, то возвращаем текстуру
288 Глава 11. Класс TextureHolder и создание орды зомби
с помощью return keyValuePair->second. В противном случае мы добавляем
текстуру в словарь map, а затем возвращаем ее вызывающему коду.
Признаться, класс TextureHolder ввел много новых понятий (синглтоны, статические функции, константные ссылки, ключевые слова this и auto) и синтаксиса.
Добавьте к этому тот факт, что мы только что узнали об указателях и STL, и код
этого раздела мог бы показаться немного пугающим.
Так стоило ли все это того?
Чего мы добились с помощью TextureHolder
Дело в том, что теперь, когда у нас есть этот класс, мы можем свободно использовать текстуры из любого места в нашем коде и не беспокоиться о том, что у нас
закончится память или закроется доступ к какой-либо текстуре в определенной
функции или классе. Скоро мы увидим, как работать с TextureHolder.
Создание орды зомби
Теперь, когда у нас появился класс TextureHolder, который гарантирует, что наши
текстуры зомби доступны и загружаются в GPU только один раз, мы можем заняться созданием целой орды зомби.
Мы будем хранить зомби в массиве. Поскольку процесс создания и генерации
орды зомби включает в себя довольно много строк кода, это хороший кандидат для абстрагирования в отдельную функцию. Скоро мы напишем функцию
CreateHorde, но сначала, конечно, нам нужен класс Zombie.
Создание файла Zombie.h
Первым шагом к созданию класса, представляющего зомби, является ввод переменных-членов и прототипов функций в заголовочный файл.
Щелкните правой кнопкой мыши на Header Files (Файлы заголовков) в окне Solution
Explorer (Обозреватель решений) и выберите AddNew Item (ДобавитьСоздать
элемент). Далее выберите пункт Header File (.h) (Файл заголовка), а затем в поле
Name (Имя) введите Zombie.h.
Добавьте следующий код в файл Zombie.h:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
Создание орды зомби 289
class Zombie
{
private:
// Скорость каждого типа зомби
const float BLOATER_SPEED = 40;
const float CHASER_SPEED = 80;
const float CRAWLER_SPEED = 20;
// Очки здоровья каждого типа зомби
const float BLOATER_HEALTH = 5;
const float CHASER_HEALTH = 1;
const float CRAWLER_HEALTH = 3;
// Создаем небольшое отклонение в скорости для каждого зомби
const int MAX_VARRIANCE = 30;
const int OFFSET = 101 - MAX_VARRIANCE;
// Где находится этот зомби?
Vector2f m_Position;
// Спрайт для зомби
Sprite m_Sprite;
// Как быстро этот зомби может перемещаться?
float m_Speed;
// Сколько очков здоровья у этого зомби?
float m_Health;
// Жив ли он?
bool m_Alive;
};
// Прототипы публичных функций
В этом коде объявлены все приватные переменные-члены класса Zombie. В верхней части кода у нас есть три константные переменные для хранения скорости
каждого типа зомби: очень медленный ползун (CRAWLER), немного более быстрый
толстяк (BLOATER ) и довольно скоростной охотник (CHASER ). Мы можем экспериментировать со значениями этих констант, чтобы сбалансировать уровень
сложности игры. Стоит также отметить, что эти три значения используются
только в качестве начального значения скорости каждого типа зомби. Позднее
мы будем изменять скорость каждого зомби на небольшой процент от этих значений. Это не позволит зомби одного типа сбиваться в кучу, преследуя игрока.
Следующие три константы определяют уровень здоровья для каждого типа
зомби. Обратите внимание, что самыми живучими являются толстяки, за ними
следуют ползуны. В целях баланса зомби-охотника будет легче всего убить.
Еще у нас есть другие две константы — MAX_VARRIANCE и OFFSET. Они помогут нам
определить индивидуальную скорость каждого зомби. Как именно, мы увидим,
когда будем писать код для файла Zombie.cpp.
Затем мы объявляем несколько переменных, которые должны показаться знакомыми, потому что у нас были очень похожие переменные в классе Player :
290 Глава 11. Класс TextureHolder и создание орды зомби
m_Position, m_Sprite, m_Speed и m_Health — они отвечают за позицию, спрайт,
скорость и здоровье объекта зомби соответственно.
Наконец, мы объявляем логическую переменную m_Alive, которая будет иметь
значение true, когда зомби жив и охотится, и false, когда его здоровье упадет до
нуля и он превращается в кровавое пятно на нашем красивом фоне.
Теперь мы можем завершить работу над файлом Zombie.h. Добавьте прототипы
функций, выделенные в следующем коде:
// Жив ли он?
bool m_Alive;
};
// Прототипы публичных функций
public:
// Обработка попадания пули в зомби
bool hit();
// Проверка, жив ли зомби
bool isAlive();
// Генерация нового зомби
void spawn(float startX, float startY, int type, int seed);
// Возвращаем прямоугольник, представляющий позицию зомби
FloatRect getPosition();
// Возвращаем копию спрайта для отрисовки
Sprite getSprite();
// Обновляем зомби каждый кадр
void update(float elapsedTime, Vector2f playerLocation);
Функцию hit мы вызываем каждый раз, когда в зомби попадает пуля. Затем функция выполняет необходимые действия, например, отнимает очки здоровья у зомби
(уменьшив значение m_Health) или убивает его (установив m_Alive в false).
Функция isAlive возвращает логическое значение, которое позволяет вызывающему коду определить, жив зомби или мертв. Мы не хотим выполнять
обнаружение коллизий или лишать игрока здоровья за то, что он прошел по
кровавому пятну.
Функция spawn, которую мы рассмотрим в следующем разделе, принимает начальную позицию, тип зомби, а также зерно для использования в генерации
случайных чисел.
Как и в классе Player, в классе Zombie есть функции getPosition и getSprite для
получения прямоугольника, представляющего собой пространство, занимаемое
зомби, и спрайта, который можно отрисовать в каждом кадре.
Последний прототип в предыдущем коде — это функция update. Мы могли бы
догадаться, что она будет получать время, прошедшее с последнего кадра, но
Создание орды зомби 291
также обратите внимание, что она получает вектор Vector2f под названием
playerLocation. Данный вектор действительно будет содержать точные координаты центра игрока. Вскоре мы увидим, как можно использовать его для преследования игрока.
Теперь перейдем к файлу.cpp для создания определений функций.
Создание файла Zombie.cpp
Щелкните правой кнопкой мыши на Source Files (Исходные файлы) в окне Solution
Explorer (Обозреватель решений) и выберите AddNew Item (ДобавитьСоздать
элемент). Далее выберите пункт C++ File (.cpp) (Файл C++) и в поле Name (Имя)
введите Zombie.cpp. Наконец, нажмите кнопку Add (Добавить).
Скопируйте следующий код в файл Zombie.cpp:
#include "zombie.h"
#include "TextureHolder.h"
#include <cstdlib>
#include <ctime>
using namespace std;
Сначала мы добавляем необходимые директивы include , а затем — using
namespace std. Возможно, вы помните несколько случаев, когда мы добавляли
к объявлениям объектов префикс std:: . Директива using означает, что нам
не нужно этого делать.
Теперь добавьте следующий код, который является определением функции spawn:
void Zombie::spawn(float startX, float startY, int type, int seed)
{
switch (type)
{
case 0:
// Толстяк
m_Sprite = Sprite(TextureHolder::GetTexture("graphics/bloater.png"));
m_Speed = BLOATER_SPEED;
m_Health = BLOATER_HEALTH;
break;
case 1:
// Охотник
m_Sprite = Sprite(TextureHolder::GetTexture("graphics/chaser.png"));
m_Speed = CHASER_SPEED;
m_Health = CHASER_HEALTH;
break;
292 Глава 11. Класс TextureHolder и создание орды зомби
case 2:
// Ползун
m_Sprite = Sprite(TextureHolder::GetTexture("graphics/crawler.png"));
m_Speed = CRAWLER_SPEED;
m_Health = CRAWLER_HEALTH;
break;
}
// Изменяем скорость, чтобы сделать зомби уникальным
// Каждый зомби уникален. Создаем модификатор скорости
srand((int)time(0) * seed);
// Где-то между 80 и 100
float modifier = (rand() % MAX_VARRIANCE) + OFFSET;
// Выражаем это как дробное число от 0 до 1
modifier /= 100; // Теперь значение между 0.7 и 1
m_Speed *= modifier;
}
// Инициализируем его местоположение
m_Position.x = startX;
m_Position.y = startY;
// Устанавливаем начало координат спрайта в его центр
m_Sprite.setOrigin(25, 25);
// Устанавливаем его позицию
m_Sprite.setPosition(m_Position);
Первое, что делает функция, — переключает пути выполнения на основе значения int, переданного в качестве параметра. Внутри блока switch есть case для
каждого типа зомби. В зависимости от типа зомби инициализируются конкретные текстуры, скорость и здоровье, которые присваиваются соответствующим
переменным-членам.
СОВЕТ
Мы могли бы использовать перечисление для различных типов зомби. Попробуйте
улучшить свой код, когда проект будет завершен.
Интересно, что для назначения текстуры мы прибегаем к статической функции
TextureHolder::GetTexture. Это означает, что сколько бы зомби мы ни сгенерировали, в памяти GPU будут находиться максимум три текстуры.
Следующие три строки кода (за исключением комментариев) выполняют такие
действия:
zzинициализируют генератор случайных чисел с помощью переменной seed,
переданной в качестве параметра;
zzобъявляют и инициализируют переменную modifier через функцию rand
и константы MAX_VARRIANCE и OFFSET (в результате мы получим дробное зна-
Создание орды зомби 293
чение от 0 до 1, которое можно использовать, чтобы сделать скорость каждого
зомби уникальной);
zzтеперь мы можем умножить m_Speed на modifier, и у нас будет зомби, чья
скорость находится в пределах MAX_VARRIANCE процента от константы, определенной для скорости этого типа зомби.
После определения скорости мы присваиваем переданной позиции значения
startX и startY в m_Position.x и m_Position.y соответственно.
Последние две строки кода устанавливают начало координат спрайта в его центр
и используют вектор m_Position для задания позиции спрайта.
Теперь добавьте следующий код для функции hit в файл Zombie.cpp:
bool Zombie::hit()
{
m_Health--;
if (m_Health < 0)
{
// Мертв
m_Alive = false;
m_Sprite.setTexture(TextureHolder::GetTexture("graphics/blood.png"));
return true;
}
// Ранен, но еще жив
return false;
}
Функция hit проста и понятна: уменьшает m_Health на единицу, а затем проверяет, стала ли m_Health меньше нуля.
Если да, то функция устанавливает m_Alive в false, меняет текстуру зомби на
кровавое пятно и возвращает true вызывающему коду, чтобы тот знал, что зомби
теперь мертв. Если зомби выжил, функция возвращает false.
Добавьте следующие три геттер-функции, которые просто возвращают значение
вызывающему коду:
bool Zombie::isAlive()
{
return m_Alive;
}
FloatRect Zombie::getPosition()
{
return m_Sprite.getGlobalBounds();
}
Sprite Zombie::getSprite()
{
return m_Sprite;
}
294 Глава 11. Класс TextureHolder и создание орды зомби
Предыдущие три функции не требуют пояснений, за исключением, возможно,
getPosition, которая использует m_Sprite.getLocalBounds для получения экземпляра FloatRect, который затем возвращается в вызывающий код.
Наконец, для класса Zombie нам нужно добавить код для функции update. Изучи
те следующий код и добавьте его в наш файл:
void Zombie::update(float elapsedTime, Vector2f playerLocation)
{
float playerX = playerLocation.x;
float playerY = playerLocation.y;
// Обновляем переменные, отвечающие за позиции зомби
if (playerX > m_Position.x)
{
m_Position.x = m_Position.x + m_Speed * elapsedTime;
}
if (playerY > m_Position.y)
{
m_Position.y = m_Position.y + m_Speed * elapsedTime;
}
}
if (playerX < m_Position.x)
{
m_Position.x = m_Position.x - m_Speed * elapsedTime;
}
if (playerY < m_Position.y)
{
m_Position.y = m_Position.y - m_Speed * elapsedTime;
}
// Перемещаем спрайт
m_Sprite.setPosition(m_Position);
// Поворачиваем спрайт в правильном направлении
float angle = (atan2(playerY - m_Position.y, playerX –
m_Position.x) * 180) / 3.141;
m_Sprite.setRotation(angle);
Мы копируем playerLocation.x и playerLocation.y в локальные переменные
playerX и playerY.
Далее следуют четыре оператора if. Они проверяют, находится ли зомби слева,
справа, выше или ниже текущей позиции игрового персонажа.
Эти операторы if при значении true корректируют значения m_Position.x
и m_Position.y зомби, используя обычную формулу: скорость, умноженную на
время, прошедшее с последнего кадра, — m_Speed * elapsedTime.
После выполнения этих операторов m_Sprite перемещается на новое место.
Далее мы используем тот же расчет, что и ранее для героя и указателя мыши, но
на этот раз для зомби и героя. Данный расчет позволяет определить угол, необходимый для того, чтобы зомби был направлен в сторону нашего героя.
Создание орды зомби 295
Наконец, для этой функции и класса мы вызываем m_Sprite.setRotation, чтобы
фактически повернуть спрайт зомби. Помните, что эта функция будет вызываться
для каждого зомби (который жив) в каждом кадре игры.
Но нам нужна целая орда зомби.
Использование класса Zombie для создания орды
Теперь, когда у нас есть класс для создания живого, атакующего и способного
умереть (снова!) зомби, мы хотим породить целую орду таких зомби.
Для достижения данной цели мы напишем отдельную функцию и будем использовать указатель, чтобы ссылаться на нашу орду, которая будет объявлена в main,
но настроена в другой области видимости.
Откройте файл ZombieArena.h в Visual Studio и добавьте следующие выделенные
строки кода:
#pragma once
#include "Zombie.h"
using namespace sf;
int createBackground(VertexArray& rVA, IntRect arena);
Zombie* createHorde(int numZombies, IntRect arena);
Теперь, когда у нас есть прототип, мы можем написать определение функции.
Создайте новый файл с расширением .cpp, щелкнув правой кнопкой мыши на
Source Files (Исходные файлы) в окне Solution Explorer (Обозреватель решений),
и выберите AddNew Item (ДобавитьСоздать элемент). Далее выберите пункт
C++ File (.cpp) (Файл C++) и в поле Name (Имя) введите CreateHorde.cpp. Наконец,
нажмите кнопку Add (Добавить).
Скопируйте следующий код в файл CreateHorde.cpp:
#include "ZombieArena.h"
#include "Zombie.h"
Zombie* createHorde(int numZombies, IntRect arena)
{
Zombie* zombies = new Zombie[numZombies];
int maxY = arena.height 20;
int minY = arena.top + 20;
int maxX = arena.width 20;
int minX = arena.left + 20;
for (int i = 0; i < numZombies; i++)
{
// С какой стороны должен появиться зомби
srand((int)time(0) * i);
int side = (rand() % 4);
float x, y;
switch (side)
296 Глава 11. Класс TextureHolder и создание орды зомби
{
case 0:
// Слева
x = minX;
y = (rand() % maxY) + minY;
break;
case 1:
// Справа
x = maxX;
y = (rand() % maxY) + minY;
break;
case 2:
// Сверху
x = (rand() % maxX) + minX;
y = minY;
break;
case 3:
// Снизу
x = (rand() % maxX) + minX;
y = maxY;
break;
}
// Толстяк, охотник или ползун
srand((int)time(0) * i * 2);
int type = (rand() % 3);
// Создаем нового зомби в массиве
zombies[i].spawn(x, y, type, i);
}
}
return zombies;
Во-первых, мы добавили уже знакомые нам директивы include:
#include "ZombieArena.h"
#include "Zombie.h"
Далее следует сигнатура функции. Обратите внимание, что функция должна возвращать указатель на объект Zombie. Мы создадим массив объектов Zombie и, как
только закончим создание орды, вернем массив. Когда мы возвращаем массив,
на самом деле мы возвращаем адрес первого элемента массива. Это, как вы уже
знаете, то же самое, что и указатель. Сигнатура также показывает, что у нас есть
два параметра. Первый, numZombies, представляет собой количество зомби в текущей орде, а второй, arena, — это IntRect, содержащий размер текущей арены,
на которой нужно создать орду.
После сигнатуры функции мы объявляем указатель на тип Zombie с именем
zombies и инициализируем его адресом памяти первого элемента массива, который мы динамически выделяем в куче:
Создание орды зомби 297
Zombie* createHorde(int numZombies, IntRect arena)
{
Zombie* zombies = new Zombie[numZombies];
Следующая часть кода просто копирует крайние точки арены в maxY, minY, maxX
и minX. Мы вычитаем по 20 пикселей справа и снизу и прибавляем по 20 пикселей
сверху и слева. Эти четыре локальные переменные используются для позиционирования каждого зомби. Мы сделали корректировку на 20 пикселей, чтобы
зомби не появлялись поверх стен:
int
int
int
int
maxY
minY
maxX
minX
=
=
=
=
arena.height - 20;
arena.top + 20;
arena.width - 20;
arena.left + 20;
Далее следует цикл for , который перебирает все объекты Zombie в массиве
zombies, начиная с нуля и заканчивая numZombies:
for (int i = 0; i < numZombies; i++)
Первое, что делает код внутри цикла for, — инициализирует генератор случайных
чисел, а затем генерирует случайное число от нуля до трех. Это число сохраняется
в переменной side. Переменная side нужна, чтобы решить, в какой части арены
появится зомби: слева, сверху, справа или снизу. Мы также объявляем две переменные x и y типа int. Они будут временно хранить фактические горизонтальные
и вертикальные координаты текущего зомби:
// С какой стороны должен появиться зомби
srand((int)time(0) * i);
int side = (rand() % 4);
float x, y;
Внутри цикла for есть блок switch с четырьмя операторами case . Обратите
внимание, что операторы case имеют номера 0, 1, 2 и 3, а аргументом в операторе
switch является side. Внутри каждого блока case инициализируются x и y одним
предопределенным значением (либо minX, maxX, minY или maxY) и одним случайно
сгенерированным. Взгляните на комбинации каждого предопределенного и случайного значения. Вы увидите, что они подходят для случайного расположения
зомби по левому, верхнему, правому или нижнему краям. В результате каждый
зомби может появиться в произвольном месте на внешней стороне арены:
switch (side)
{
case 0:
// Слева
x = minX;
y = (rand() % maxY) + minY;
break;
298 Глава 11. Класс TextureHolder и создание орды зомби
}
case 1:
// Справа
x = maxX;
y = (rand() % maxY) + minY;
break;
case 2:
// Сверху
x = (rand() % maxX) + minX;
y = minY;
break;
case 3:
// Снизу
x = (rand() % maxX) + minX;
y = maxY;
break;
Внутри цикла for мы снова запускаем генератор случайных чисел и генерируем
случайное число от 0 до 2. Мы сохраняем это число в переменной type, которая
определяет, будет ли текущий зомби охотником, толстяком или ползуном.
Затем мы вызываем функцию spawn для текущего объекта Zombie в массиве
zombies. Напомним, что аргументы, передаваемые в функцию spawn, определяют
начальное местоположение зомби и его тип. Произвольное на первый взгляд
значение i передается в качестве уникального зерна, которое случайным образом
изменяет скорость зомби в пределах допустимого диапазона. Это предотвращает
скопление зомби в одну кучу:
// Толстяк, охотник или ползун
srand((int)time(0) * i * 2);
int type = (rand() % 3);
// Создаем нового зомби в массиве
zombies[i].spawn(x, y, type, i);
Цикл for повторяется один раз для каждого зомби, управляемого значением
в numZombies, а затем мы возвращаем массив. Массив, напомню еще раз, — это
просто адрес первого элемента самого себя. Массив динамически выделяется
в куче, поэтому он сохраняется и после возврата функции:
return zombies;
Теперь мы можем оживить наших зомби.
Возвращение к жизни орды
У нас есть класс Zombie и функция для создания случайно появляющейся орды
зомби, а также синглтон TextureHolder, который позволяет хранить всего три
текстуры, необходимые нам для десятков или даже тысяч зомби. Теперь мы можем добавить орду в наш игровой движок в функции main.
Создание орды зомби 299
Добавьте следующий выделенный код, чтобы включить класс TextureHolder. Затем
прямо внутри main мы инициализируем единственный экземпляр TextureHolder,
который можно будет использовать из любого места нашей игры:
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
using namespace sf;
int main()
{
// Здесь создается экземпляр TextureHolder
TextureHolder holder;
// Игра всегда находится в одном из четырех состояний
enum class State { PAUSED, LEVELING_UP, GAME_OVER, PLAYING };
// Начинаем с состояния GAME_OVER
State state = State::GAME_OVER;
Следующие несколько строк выделенного кода объявляют управляющие переменные для некоторого количества зомби в начале волны, количества зомби, которых еще предстоит убить, и, конечно же, указатель на Zombie с именем zombies,
который мы инициализируем как nullptr:
// Создаем фон
VertexArray background;
// Загружаем текстуру для нашего массива вершин фона
Texture textureBackground;
textureBackground.loadFromFile("graphics/background_sheet.png");
// Готовимся к орде зомби
int numZombies;
int numZombiesAlive;
Zombie* zombies = nullptr;
// Основной игровой цикл
while (window.isOpen())
Далее, в блоке PLAYING, вложенном в блок LEVELING_UP, мы добавим код, который
делает следующее:
zzинициализирует numZombies значением 10 (по мере развития проекта это
значение будет динамически изменяться в зависимости от текущего номера
волны);
zzосвобождает всю ранее выделенную память (в противном случае каждый
новый вызов createHorde будет занимать все больше памяти, не освобождая
память предыдущей орды);
zzвызывает createHorde и присваивает zombies возвращенный адрес памяти;
zzинициализирует zombiesAlive значением numZombies, потому что на данный
момент мы еще не убили ни одного зомби.
300 Глава 11. Класс TextureHolder и создание орды зомби
Добавьте следующий выделенный код:
if (state == State::PLAYING)
{
// Подготовка уровня
// Позже мы изменим следующие две строки
arena.width = 500;
arena.height = 500;
arena.left = 0;
arena.top = 0;
// Передаем массив вершин по ссылке
// в функцию createBackground
int tileSize = createBackground(background, arena);
// Генерируем героя в центре арены
player.spawn(arena, resolution, tileSize);
// Создаем орду зомби
numZombies = 10;
// Освобождаем ранее выделенную память (если она существует)
delete[] zombies;
zombies = createHorde(numZombies, arena);
numZombiesAlive = numZombies;
// Сбрасываем таймер, чтобы избежать скачка кадра
clock.restart();
}
Теперь добавьте следующий выделенный код в файл ZombieArena.cpp:
/*
****************
Обновление кадра
****************
*/
if (state == State::PLAYING)
{
// Обновление delta time
Time dt = clock.restart();
// Обновляем общее игровое время
gameTimeTotal += dt;
// Преобразуем delta time в дробь
float dtAsSeconds = dt.asSeconds();
// Получаем текущее положение указателя мыши
mouseScreenPosition = Mouse::getPosition();
// Преобразуем положение указателя мыши в глобальные
// координаты относительно mainView
mouseWorldPosition = window.mapPixelToCoords(
Mouse::getPosition(), mainView);
// Обновляем игрового персонажа
player.update(dtAsSeconds, Mouse::getPosition());
// Сохраняем новое положение персонажа
Vector2f playerPosition(player.getCenter());
// Центрируем вид вокруг игрового персонажа
mainView.setCenter(player.getCenter());
// Перебираем всех зомби и обновляем их
for (int i = 0; i < numZombies; i++)
Создание орды зомби 301
{
if (zombies[i].isAlive())
{
zombies[i].update(dt.asSeconds(), playerPosition);
}
}
}// Завершение обновления сцены
Весь новый код выше просто перебирает массив зомби, проверяет, жив ли текущий
зомби, и, если да, вызывает его функцию update с необходимыми аргументами.
Добавьте следующий код, чтобы отрисовать всех зомби:
/*
***************
Отрисовка сцены
***************
*/
if (state == State::PLAYING)
{
window.clear();
// Устанавливаем mainView для отображения в окне
// и отрисовываем все элементы, связанные с ним
window.setView(mainView);
// Отрисовываем фон
Window.draw(background, &textureBackground);
// Отрисовываем зомби
for (int i = 0; i < numZombies; i++)
{
window.draw(zombies[i].getSprite());
}
// Отрисовываем игрового персонажа
window.draw(player.getSprite());
}
В предыдущем коде мы перебираем всех зомби и вызываем функцию getSprite,
чтобы функция draw могла выполнить свою работу. Мы не проверяем, жив ли
зомби, потому что, даже если зомби мертв, мы хотим отрисовать кровавое пятно.
В конце функции main нам нужно удалить наш указатель. Несмотря на то что это
считается хорошей практикой, технически это не так уж и важно, потому что игра
вот-вот завершится и операционная система вернет себе всю память, которая использовалась после оператора return 0:
} // Конец игрового цикла
// Очищаем ранее выделенную память (если она существует)
delete[] zombies;
return 0;
}
Если вы запустите игру на данном этапе, то увидите, как зомби появляются по
краям арены. Они сразу же направятся к герою с разной скоростью. Ради интереса
я увеличил размер арены и довел количество зомби до 1000, как видно на рис. 11.1.
302 Глава 11. Класс TextureHolder и создание орды зомби
Рис. 11.1. Арена и зомби
Это плохо кончится!
Обратите внимание, что вы также можете приостанавливать и возобновлять
натиск орды нажатием клавиши Enter благодаря коду, который мы написали
в главе 8.
Некоторые наши классы по-прежнему используют экземпляр Texture напрямую.
Давайте исправим это.
Использование класса TextureHolder
для всех текстур
Поскольку у нас есть класс TextureHolder, мы можем применить его для загрузки
всех наших текстур. Внесем несколько небольших изменений в существующий
код, который загружает текстуры для фонового спрайта и игрового персонажа.
Изменение способа получения текстур фона
В файле ZombieArena.cpp найдите следующий код:
// Загружаем текстуру для нашего массива вершин фона
Texture textureBackground;
textureBackground.loadFromFile("graphics/background_sheet.png");
Удалите выделенный ранее код и замените его следующим выделенным кодом,
который использует наш новый класс TextureHolder:
Использование класса TextureHolder для всех текстур 303
// Загружаем текстуру для нашего массива вершин фона
Texture textureBackground = TextureHolder::GetTexture(
"graphics/background_sheet.png");
Обновим способ получения текстуры для класса Player.
Изменение способа получения текстуры
для класса Player
В файле Player.cpp, внутри конструктора, найдите этот код:
#include "player.h"
Player::Player()
: m_Speed(START_SPEED),
m_Health(START_HEALTH),
m_MaxHealth(START_HEALTH),
m_Texture(),
m_Sprite()
{
// Связываем текстуру со спрайтом
// !!Обратите внимание на это место!!
m_Texture.loadFromFile("graphics/player.png");
m_Sprite.setTexture(m_Texture);
}
// Устанавливаем начало спрайта в центр
// для плавного вращения
m_Sprite.setOrigin(25, 25);
Удалите выделенный выше код и замените его следующим выделенным кодом,
который использует наш новый класс TextureHolder. Кроме того, добавьте директиву include, чтобы включить в файл заголовок TextureHolder:
#include "player.h"
#include "TextureHolder.h"
Player::Player()
{
m_Speed = START_SPEED;
m_Health = START_HEALTH;
m_MaxHealth = START_HEALTH;
// Связываем текстуру со спрайтом
// !!Обратите внимание на это место!!
m_Sprite = Sprite(TextureHolder::GetTexture("graphics/player.png"));
// Устанавливаем начало спрайта в центр
// для плавного вращения
m_Sprite.setOrigin(25, 25);
}
ПРИМЕЧАНИЕ
С этого момента мы будем использовать класс TextureHolder для загрузки всех текстур.
304 Глава 11. Класс TextureHolder и создание орды зомби
Резюме
Мы создали класс TextureHolder для хранения всех изображений, используемых
спрайтами в качестве текстур, и написали класс Zombie, который можно повторно
задействовать для создания любого количества зомби.
Вы, наверное, заметили, что зомби не выглядят очень опасными. Они просто
проносятся сквозь героя, не оставляя на нем ни царапины. На данный момент
это хорошо, потому что у него пока нет возможности защититься.
В следующей главе мы создадим еще два класса: один — для боеприпасов и апте
чек, а другой — для пуль, которыми стреляет игрок. После этого мы научимся
обнаруживать коллизии, чтобы пули и зомби наносили урон, а игрок мог собирать
различные предметы.
Часто задаваемые вопросы
В. Не могли бы вы напомнить мне о ключевом слове new и утечках памяти?
О. Когда мы используем память в свободном хранилище с помощью ключевого
слова new, она сохраняется даже после того, как функция, в которой она была
создана, завершила выполнение и все локальные переменные исчезли. Когда мы
заканчиваем использовать память, мы должны освободить ее. Поэтому, если мы
задействуем память, которую хотим сохранить даже после завершения жизненного цикла функции, мы должны убедиться, что сохранили указатель на нее, иначе
произойдет утечка памяти. Это все равно что сложить все свои вещи в доме, а потом забыть, где мы живем! Когда мы возвращаем массив zombies из createHorde,
мы как бы передаем «эстафетную палочку» (адрес памяти) от createHorde к main.
Это как сказать: «Хорошо, вот ваша орда зомби; теперь вы за нее отвечаете». А
мы бы не хотели, чтобы в нашей оперативной памяти бегали просочившиеся
туда зомби! Поэтому мы должны помнить о необходимости вызывать delete для
указателей на динамическое выделение памяти.
12
Обнаружение коллизий,
бонусные предметы
и пули
На данный момент мы реализовали основные визуальные аспекты нашей игры.
У нас есть игровой персонаж, который бегает по арене, полной преследующих
его зомби. Проблема в том, что они не взаимодействуют друг с другом. Зомби
может пройти сквозь нашего героя, не оставив на нем ни царапины. Нам нужно
исправить это.
Кроме того, справедливо будет вооружить героя, чтобы он мог отбиваться от
зомби. Мы дадим ему пистолет и патроны. Затем нам нужно будет убедиться,
что пули могут нанести урон зомби и убить их.
Если мы пишем код обнаружения коллизий для пуль, зомби и игрового персонажа, самое время добавить класс для подбора аптечек и боеприпасов.
Создание класса Bullet
Для визуального представления пули воспользуемся классом RectangleShape
библиотеки SFML. Мы создадим класс Bullet, который будет содержать член
RectangleShape , а также другие данные и функции. Затем мы добавим пули
в нашу игру в несколько шагов.
1. Для начала мы напишем код в файле Bullet.h.
2. Далее создадим файл Bullet.cpp, который, конечно же, будет содержать определения всех функций класса Bullet.
3. Наконец, в функции main мы объявим целый массив пуль. Мы также реализуем систему управления стрельбой оставшимися у героя патронами и перезарядки.
Начнем с шага 1.
306 Глава 12. Обнаружение коллизий, бонусные предметы и пули
Создание заголовочного файла Bullet
Чтобы создать новый заголовочный файл, щелкните правой кнопкой мыши на
Header Files (Файлы заголовков) в окне Solution Explorer (Обозреватель решений)
и выберите AddNew Item (ДобавитьСоздать элемент). Далее выберите пункт
Header File (.h) (Файл заголовка), а затем в поле Name (Имя) введите Bullet.h.
Скопируйте следующие приватные переменные-члены вместе с объявлением
класса Bullet в файл Bullet.h:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Bullet
{
private:
// Где находится пуля?
Vector2f m_Position;
// Как выглядит каждая пуля?
RectangleShape m_BulletShape;
// Летит ли эта пуля в данный момент?
bool m_InFlight = false;
// С какой скоростью летит пуля?
float m_BulletSpeed = 1000;
// Какую долю пикселя пуля проходит
// по горизонтали и вертикали за каждый кадр?
// Эти значения будут вычислены на основе m_BulletSpeed
float m_BulletDistanceX;
float m_BulletDistanceY;
};
// Определяем границы, чтобы пуля не летела бесконечно
float m_MaxX;
float m_MinX;
float m_MaxY;
float m_MinY;
// Здесь начинаются прототипы публичных функций
В приведенном коде первым членом является Vector2f с именем m_Position,
который будет хранить местоположение пули в игровом мире.
Далее мы объявляем RectangleShape под названием m_BulletShape, поскольку мы
используем простое изображение без текстуры для каждой пули, как это было
с временной шкалой в Timber!.
Затем объявляется логическая переменная m_InFlight, которая отслеживает,
летит пуля в данный момент или нет. Это позволит нам решить, нужно ли
вызывать функцию update каждый кадр и запускать проверку обнаружения
коллизий.
Создание класса Bullet 307
Логическая переменная m_BulletSpeed хранит значение скорости, с которой
будет двигаться пуля, в пикселях в секунду. Она инициализируется условным
значением 1000.
Далее мы объявляем еще две переменные типа float — m_BulletDistanceX
и m_BulletDistanceY. Поскольку вычисления для перемещения пули немного
сложнее, чем для перемещения зомби или игрового персонажа, будет полезно
иметь эти две переменные. Они послужат для определения горизонтальных
и вертикальных изменений положения пули в каждом кадре.
Наконец, у нас есть еще четыре float -переменные (m_MaxX , m_MinX , m_MaxY
и m_MinY), которые впоследствии будут инициализированы для хранения максимального, минимального, горизонтального и вертикального положения пули.
Вероятно, необходимость некоторых из этих переменных не сразу очевидна, но
это станет яснее, когда мы увидим каждую из них в действии в файле Bullet.cpp.
Теперь добавьте все прототипы публичных функций в файл Bullet.h:
};
// Здесь начинаются прототипы публичных функций
public:
// Конструктор
Bullet();
// Останавливает пулю
void stop();
// Возвращает значение m_InFlight
bool isInFlight();
// Запускает новую пулю
void shoot(float startX, float startY,
float xTarget, float yTarget);
// Сообщает вызывающему коду, где находится пуля
FloatRect getPosition();
// Возвращает фактическую форму (для отрисовки)
RectangleShape getShape();
// Обновляет пулю каждый кадр
void update(float elapsedTime);
Разберем каждую из функций, а затем перейдем к написанию кода их определений.
В первую очередь, у нас есть функция Bullet, которая, конечно же, является
конструктором. В этой функции мы настроим каждый экземпляр Bullet, готовый
к действию.
Функция stop будет вызвана, когда пулю нужно остановить.
Функция isInFlight возвращает логическое значение и используется для проверки, летит ли пуля в данный момент.
308 Глава 12. Обнаружение коллизий, бонусные предметы и пули
О назначении функции shoot можно догадаться по ее названию, но то, как она
будет работать, заслуживает отдельного обсуждения. Пока же просто отметим,
что она принимает четыре параметра типа float, которые будут передаваться
в функцию. Они представляют собой начальное горизонтальное и вертикальное
положение пули (где находится персонаж), а также вертикальную и горизонтальную позицию цели (где находится прицел).
Функция getPosition возвращает FloatRect, который представляет местоположение пули. Эта функция будет использоваться для обнаружения коллизий
с зомби. Возможно, вы помните из главы 10, что у зомби тоже есть функция
getPosition.
Функция getShape возвращает объект типа RectangleShape. Как мы уже говорили,
каждая пуля визуально представлена объектом RectangleShape. Поэтому функция getShape будет использоваться для получения копии текущего состояния
RectangleShape для его отрисовки.
Наконец функция update принимает параметр float, представляющий долю
секунды, прошедшую с момента последнего вызова update. Метод update будет
изменять положение пули в каждом кадре.
Теперь перейдем к написанию определений этих функций.
Создание исходного файла Bullet
Теперь мы можем создать новый файл .cpp , который будет содержать определения функций. Щелкните правой кнопкой мыши на Source Files (Исходные
файлы) в окне Solution Explorer (Обозреватель решений) и выберите AddNew Item
(ДобавитьСоздать элемент). Далее выберите пункт C++ File (.cpp) (Файл C++)
и в поле Name (Имя) введите Bullet.cpp. Наконец, нажмите кнопку Add (До
бавить).
Скопируйте следующий код, предназначенный для директив include и конструктора в файл Bullet.cpp. Мы знаем, что это конструктор, потому что функция
имеет то же имя, что и класс:
#include "bullet.h"
// Конструктор
Bullet::Bullet()
{
m_BulletShape.setSize(sf::Vector2f(2, 2));
}
Единственное, что нужно сделать конструктору Bullet, — это задать размер
m_BulletShape, который является объектом RectangleShape. Код устанавливает
размер 2 × 2 пикселя.
Создание класса Bullet 309
Создание функции shoot
Далее мы напишем более сложную функцию shoot. Добавьте следующий код
в файл Bullet.cpp:
void Bullet::shoot(float startX, float startY, float targetX, float targetY)
{
// Отслеживаем пулю
m_InFlight = true;
m_Position.x = startX;
m_Position.y = startY;
// Вычисляем уклон траектории полета
float gradient = (startX - targetX) / (startY - targetY);
// Делаем отрицательным любое значение уклона меньше нуля
if (gradient < 0)
{
gradient *= -1;
}
// Вычисляем соотношение между x и y
float ratioXY = m_BulletSpeed / (1 + gradient);
// Устанавливаем "скорость" по горизонтали и вертикали
m_BulletDistanceY = ratioXY;
m_BulletDistanceX = ratioXY * gradient;
// Задаем пуле правильное направление
if (targetX < startX)
{
m_BulletDistanceX *= -1;
}
if (targetY < startY)
{
m_BulletDistanceY *= -1;
}
// Устанавливаем максимальную дистанцию 1000 пикселей в любом направлении
float range = 1000;
m_MinX = startX - range;
m_MaxX = startX + range;
m_MinY = startY - range;
m_MaxY = startY + range;
}
// Размещаем пулю в начальной позиции для отрисовки
m_BulletShape.setPosition(m_Position);
Чтобы разобраться с функцией shoot, разделим ее на части и обсудим код, который мы только что добавили.
Для начала поговорим о сигнатуре. Функция shoot получает начальную и целевую позиции пули по горизонтали и вертикали. Вызывающий код предоставит
эти значения на основе положения спрайта игрового персонажа и прицела:
void Bullet::shoot(float startX, float startY, float targetX, float targetY)
310 Глава 12. Обнаружение коллизий, бонусные предметы и пули
Внутри функции shoot мы устанавливаем m_InFlight равным true и позиционируем пулю с помощью параметров startX и startY:
// Отслеживаем пулю
m_InFlight = true;
m_Position.x = startX;
m_Position.y = startY;
Теперь воспользуемся тригонометрией, чтобы определить уклон траектории
движения пули. Взгляните на код еще раз:
// Вычисляем уклон траектории полета
float gradient = (startX - targetX) / (startY - targetY);
// Делаем отрицательным любое значение уклона меньше нуля
if (gradient < 0)
{
gradient *= -1;
}
// Вычисляем соотношение между x и y
float ratioXY = m_BulletSpeed / (1 + gradient);
// Устанавливаем "скорость" по горизонтали и вертикали
m_BulletDistanceY = ratioXY;
m_BulletDistanceX = ratioXY * gradient;
Код рассчитывает движение пули к цели. Он корректирует путь пули как по горизонтали, так и по вертикали в зависимости от углового коэффициента. Это необходимо, потому что, если угол наклона очень большой, пуля может достичь
горизонтальной цели до того, как она достаточно продвинется по вертикали,
или, наоборот, при меньших углах наклона. По сути, код гарантирует, что пуля
проходит правильные горизонтальные и вертикальные расстояния с постоянной
скоростью, в соответствии с уклоном траектории полета.
Вычисление уклона в функции shoot
Вот код, который вычисляет значение уклона:
float gradient = (startX - targetX) / (startY - targetY);
Он рассчитывается по двум точкам (startX, startY) и (targetX, targetY). Сначала код вычисляет разность координат до цели по горизонтали, потом по вертикали, а затем делит результат первого вычисления на второй, чтобы получить
отношение, представляющее собой угол.
Делаем значение уклона положительным в функции shoot
Вот код, о котором идет речь. Он прост, но важен для нашего решения:
if (gradient < 0)
{
gradient *= -1;
}
Создание класса Bullet 311
Код гарантирует, что значение уклона всегда будет положительным. Если он
изначально отрицательный, знак минус убирается. Это необходимо, потому что
передаваемые координаты могут быть как отрицательными, так и положительными, а мы хотим, чтобы величина прогрессии в каждом кадре была положительной.
Умножение на –1 просто превращает отрицательное число в положительное,
потому что минус, умноженный на минус, дает плюс.
Вычисление отношения между X и Y
в функции shoot
Рассмотрим следующую строку кода:
float ratioXY = m_BulletSpeed / (1 + gradient)
Часть 1 + gradient добавляет единицу к вычисленному значению уклона. Это
делается для того, чтобы предотвратить деление на ноль и убедиться, что знаменатель не равен нулю.
Выражение m_BulletSpeed / (1 + gradient) вычисляет соотношение между
горизонтальной и вертикальной составляющими движения пули. Числитель
( m_BulletSpeed ) представляет собой общую скорость пули, а знаменатель
(1 + gradient) корректирует эту скорость в зависимости от уклона траектории
полета.
Если траектория полета имеет крутой восходящий уклон (большое положительное
значение уклона), знаменатель становится больше, а числовое отношение уменьшается. Таким образом большая часть скорости пули распределяется по вертикали.
Если траектория полета имеет крутой нисходящий уклон (большое отрицательное значение уклона), знаменатель становится меньше, а числовое отношение
увеличивается. Это означает, что большая часть скорости пули распределяется
по горизонтали.
Наконец, результат этой операции сохраняется в переменной ratioXY, которая
теперь содержит значение, представляющее собой числовое отношение между
горизонтальным и вертикальным расстояниями, которые должна преодолеть
пуля, исходя из рассчитанного значения уклона и заданной скорости пули.
Еще немного о функции shoot
Следующие две строки кода завершают обработку пули:
m_BulletDistanceY = ratioXY;
m_BulletDistanceX = ratioXY * gradient;
Эти строки определяют, на какое расстояние должна переместиться пуля по вертикали (m_BulletDistanceY) и горизонтали (m_BulletDistanceX), основываясь на
ранее рассчитанном отношении и значении уклона.
312 Глава 12. Обнаружение коллизий, бонусные предметы и пули
Несмотря на все эти вычисления, фактическое направление движения будет
определяться в функции update. В ней мы просто будем складывать или вычитать
полученные положительные значения.
Следующая часть кода намного проще. Мы просто задаем максимальную дистанцию, которую пуля может преодолеть. Нам не нужно, чтобы пуля летела бесконечно. В функции update мы проверим, не вышла ли пуля за границы:
// Устанавливаем максимальную дистанцию 1000 пикселей в любом направлении
float range = 1000;
m_MinX = startX - range;
m_MaxX = startX + range;
m_MinY = startY - range;
m_MaxY = startY + range;
Следующий код перемещает спрайт пули в ее начальное положение. Мы используем функцию setPosition объекта Sprite, как уже часто делали ранее:
// Размещаем пулю в начальной позиции для отрисовки
m_BulletShape.setPosition(m_Position);
Теперь мы закончили работу с функцией shoot.
Дополнительные функции для пули
У нас есть четыре простые функции: stop, isInFlight, getPosition и getShape.
Давайте добавим их:
void Bullet::stop()
{
m_InFlight = false;
}
bool Bullet::isInFlight()
{
return m_InFlight;
}
FloatRect Bullet::getPosition()
{
return m_BulletShape.getGlobalBounds();
}
RectangleShape Bullet::getShape()
{
return m_BulletShape;
}
Функция stop просто устанавливает переменную m_InFlight в false. Функция
isInFlight возвращает текущее значение этой же переменной. Таким образом,
функция shoot запускает пулю, stop останавливает ее, а isInFlight информирует
нас о текущем состоянии.
Создание класса Bullet 313
Функция getPosition возвращает FloatRect. Вскоре мы увидим, как можно
использовать FloatRect каждого игрового объекта для обнаружения коллизий.
Наконец, функция getShape возвращает RectangleShape, чтобы мы могли отрисовывать пулю один раз в каждом кадре.
Функция update для класса Bullet
Последняя функция, которую нам нужно реализовать, прежде чем мы сможем
воспользоваться объектами Bullet, — это update. Добавьте следующий код:
void Bullet::update(float elapsedTime)
{
// Обновляем переменные позиции пули
m_Position.x += m_BulletDistanceX * elapsedTime;
m_Position.y += m_BulletDistanceY * elapsedTime;
// Перемещаем пулю
m_BulletShape.setPosition(m_Position);
// Пуля вышла за пределы диапазона?
if (m_Position.x < m_MinX || m_Position.x > m_MaxX ||
m_Position.y < m_MinY || m_Position.y > m_MaxY)
{
m_InFlight = false;
}
}
В функции update мы используем m_BulletDistanceX и m_BulletDistanceY ,
умноженные на время, прошедшее с последнего кадра, чтобы переместить пулю.
Помните, что значения этих двух переменных были рассчитаны в функции shoot
и представляют собой значение уклона, необходимое для перемещения пули под
точно заданным углом. Затем мы применяем функцию setPosition для перемещения RectangleShape.
Последнее, что делает функция update, — проверяет, не вышла ли пуля за пределы максимальной дальности. Немного запутанный оператор if проверяет
m_Position.x и m_Position.y на соответствие максимальным и минимальным
значениям, которые были рассчитаны в функции shoot. Эти значения хранятся
в переменных m_MinX, m_MaxX, m_MinY и m_MaxY.
Код также проверяет, находится ли m_Position (.x и .y) за пределами указанной
прямоугольной области, определяемой m_MinX, m_MaxX, m_MinY и m_MaxY. Помните,
что значения m_Min... задают самую дальнюю точку, до которой может долететь
текущая пуля. Если позиция находится за пределами этой области, переменная
m_InFlight принимает значение false и пуля останавливается.
Если тест выдает true, то m_InFlight устанавливается равной false.
Теперь класс Bullet готов. Далее мы рассмотрим, как можно стрелять в функции main.
314 Глава 12. Обнаружение коллизий, бонусные предметы и пули
Заставляем пули летать
В следующих разделах мы сделаем пули пригодными для использования с помощью этих шести шагов.
1. Добавим необходимую директиву include для класса Bullet.
2. Добавим несколько управляющих переменных и массив для хранения экземпляров Bullet.
3. Обработаем нажатие клавиши R для перезарядки.
4. Обработаем нажатие левой кнопки мыши для выстрела.
5. Будем покадрово обновлять пули, находящиеся в полете.
6. Будем покадрово отрисовывать пули, находящиеся в полете.
Подключаем класс Bullet
Добавьте директиву include, чтобы сделать класс Bullet доступным:
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
#include "Bullet.h"
using namespace sf;
Переходим к следующему шагу.
Управляющие переменные и массив пуль
Вот несколько переменных для отслеживания размера обоймы, количества запасных патронов, оставшихся патронов в обойме, текущего темпа стрельбы
(изначально один выстрел в секунду) и времени с момента последнего выстрела.
Добавьте следующий выделенный код:
// Приготовимся к появлению орды зомби
int numZombies;
int numZombiesAlive;
Zombie* zombies = NULL;
// 100 пуль должно хватить
Bullet bullets[100];
int currentBullet = 0;
int bulletsSpare = 24;
int bulletsInClip = 6;
int clipSize = 6;
float fireRate = 1;
Заставляем пули летать 315
// Когда в последний раз была нажата кнопка выстрела?
Time lastPressed;
// Основной игровой цикл
while (window.isOpen())
Далее обработаем нажатие клавиши R, которая используется для перезарядки.
Перезарядка оружия
Теперь мы обработаем ввод игрока, связанный со стрельбой. Начнем с нажатия клавиши R для перезарядки оружия. Мы сделаем это с помощью события
SFML.
Добавьте следующий выделенный код. Он показан с большим количеством контекста, чтобы вы точно понимали, куда его поместить:
// Обработка событий
Event event;
while (window.pollEvent(event))
{
if (event.type == Event::KeyPressed)
{
// Пауза в игре
if (event.key.code == Keyboard::Return &&
state == State::PLAYING)
{
state = State::PAUSED;
}
// Возобновление игры
else if (event.key.code == Keyboard::Return &&
state == State::PAUSED)
{
state = State::PLAYING;
// Сброс таймера, чтобы избежать скачка кадра
clock.restart();
}
// Начало новой игры из состояния GAME_OVER
else if (event.key.code == Keyboard::Return &&
state == State::GAME_OVER)
{
state = State::LEVELING_UP;
}
if (state == State::PLAYING)
{
// Перезарядка
if (event.key.code == Keyboard::R)
{
if (bulletsSpare >= clipSize)
{
316 Глава 12. Обнаружение коллизий, бонусные предметы и пули
// Много патронов. Перезаряжаем.
bulletsInClip = clipSize;
bulletsSpare -= clipSize;
}
}
}
else if (bulletsSpare > 0)
{
// Осталось немного патронов
bulletsInClip = bulletsSpare;
bulletsSpare = 0;
}
else
{
// Здесь будет больше кода позже
}
}
}// Завершение опроса событий
Предыдущий код вложен в часть цикла обработки событий игры (while(win
dow.pollEvent)) и находится внутри блока, который выполняется только тогда,
когда игра действительно идет (if(state == State::Playing)). Нам явно не нуж-
но, чтобы игрок перезаряжал оружие, когда игра завершена или приостановлена,
и, обернув новый код, мы достигаем этой цели.
Первым делом проверяется нажатие клавиши R с помощью if (event.key.code
== Keyboard::R). Как только мы обнаружили, что клавиша R была нажата, выполняется остальной код. Вот структура блоков if, else if и else:
if(bulletsSpare >= clipSize)
...
else if(bulletsSpare > 0)
...
else
...
Эта структура позволяет нам обрабатывать три возможных сценария:
zzигрок нажал R , и у него в запасе больше патронов, чем может вместить
обойма. В этом случае обойма пополняется, а количество боеприпасов
уменьшается;
zzу игрока есть запасные патроны, но их недостаточно для полной перезарядки.
В этом случае обойма заполняется всеми доступными патронами, а счетчик
количества запасных патронов обнуляется;
zzигрок нажал R, но у него совсем нет запасных патронов. Для этого сценария
нам не нужно изменять переменные. Однако в таком случае мы воспроизведем звуковой эффект (сделаем это в главе 14), поэтому оставим пустой блок
else наготове.
Теперь реализуем стрельбу.
Заставляем пули летать 317
Стрельба
Здесь мы обработаем нажатие левой кнопки мыши, чтобы выпустить пулю.
Добавьте следующий выделенный код:
if (Keyboard::isKeyPressed(Keyboard::D))
{
player.moveRight();
}
else
{
player.stopRight();
}
// Обработка выстрела
if (Mouse::isButtonPressed(sf::Mouse::Left))
{
if (gameTimeTotal.asMilliseconds()
- lastPressed.asMilliseconds()
> 1000 / fireRate && bulletsInClip > 0)
{
// Передаем центр игрового персонажа и прицела в функцию shoot
bullets[currentBullet].shoot(
player.getCenter().x, player.getCenter().y,
mouseWorldPosition.x, mouseWorldPosition.y);
currentBullet++;
if (currentBullet > 99)
{
currentBullet = 0;
}
lastPressed = gameTimeTotal;
bulletsInClip--;
}
}// Конец обработки выстрела
}// Завершение обработки WASD во время игры
Весь предыдущий код обернут в оператор if , который выполняется всякий раз при нажатии левой кнопки мыши, то есть if (Mouse::isButtonPres
sed(sf::Mouse::Left)). Обратите внимание, что код будет выполняться многократно, даже если игрок просто удерживает кнопку. Код, который мы сейчас
рассмотрим, управляет темпом стрельбы.
В приведенном коде мы проверяем, прошло ли достаточное количество времени с момента последнего выстрела (разница между общим временем игры
gameTimeTotal и временем последнего выстрела lastPressed), и больше ли оно,
чем 1000 (количество миллисекунд в одной секунде), деленное на текущую скорострельность, и есть ли у игрока хотя бы один патрон в обойме.
Если проверка проходит успешно, выполняется код, который фактически отвечает за стрельбу. Выстрелить очень просто, потому что всю сложную работу
мы проделали в классе Bullet. Мы лишь вызываем shoot для текущей пули из
массива bullets и передаем текущие координаты игрового персонажа и прицела.
Все остальное выполняет код в функции shoot класса Bullet.
318 Глава 12. Обнаружение коллизий, бонусные предметы и пули
Все, что мы должны делать, — это следить за массивом пуль. Мы увеличили
значение переменной currentBullet. Затем с помощью функции if (current
Bullet > 99) нам нужно проверить, выпустили ли мы последнюю пулю (99). Если
да, значение currentBullet устанавливается равным 0. Если это была не последняя пуля, то следующий патрон будет готов к выстрелу, когда темп стрельбы
позволит это сделать и игрок нажмет левую кнопку мыши.
Наконец, в приведенном коде мы сохраняем время выстрела в переменную
lastPressed и уменьшаем количество пуль в обойме bulletsInClip.
Теперь мы можем покадрово обновлять каждую пулю.
Обновление пуль в каждом кадре
Добавьте следующий выделенный код, чтобы пройтись циклом по массиву
bullets, проверить, летит ли пуля в данный момент, и если да, то вызвать ее
функцию update:
// Перебираем всех зомби и обновляем их
for (int i = 0; i < numZombies; i++)
{
if (zombies[i].isAlive())
{
zombies[i].update(dt.asSeconds(), playerPosition);
}
}
// Обновляем все пули, которые находятся в полете
for (int i = 0; i < 100; i++)
{
if (bullets[i].isInFlight())
{
bullets[i].update(dtAsSeconds);
}
}
}// Завершение обновления сцены
Наконец, отрисуем все пули.
Отрисовка пуль в каждом кадре
Добавьте следующий выделенный код, чтобы пройтись циклом по массиву
bullets, проверить, летит ли пуля в данный момент, и если да, то отрисовать ее:
/*
***************
Отрисовка сцены
***************
*/
Заставляем пули летать 319
if (state == State::PLAYING)
{
window.clear();
// Устанавливаем mainView для отображения в окне
// и отрисовываем все элементы, связанные с ним
window.setView(mainView);
// Отрисовываем фон
window.draw(background, &textureBackground);
// Отрисовываем зомби
for (int i = 0; i < numZombies; i++)
{
window.draw(zombies[i].getSprite());
}
for (int i = 0; i < 100; i++)
{
if (bullets[i].isInFlight())
{
window.draw(bullets[i].getShape());
}
}
// Отрисовываем игрового персонажа
window.draw(player.getSprite());
}
Запустите игру, чтобы протестировать стрельбу. Обратите внимание, что вы
можете сделать шесть выстрелов, прежде чем вам понадобится нажать R для
перезарядки. Очевидно, что не хватает визуальных индикаторов количества
патронов в обойме и в запасе. Другая проблема заключается в том, что у игрока
могут очень быстро закончиться патроны, тем более что пули не имеют никакой
останавливающей силы. Они летят прямо сквозь зомби. Добавьте к этому тот
факт, что игроку приходится целиться с помощью указателя мыши, а не перекрестия, и станет ясно, что нам есть над чем работать.
Далее мы заменим указатель мыши на перекрестие (прицел), а затем добавим
несколько предметов, которые можно собрать для пополнения боезапаса и здоровья. Наконец, в этом разделе мы реализуем обнаружение коллизий, чтобы пули
и зомби наносили урон, а игрок мог собирать предметы.
Прицеливание
Добавить перекрестие (прицел) очень просто и требует лишь одного нового понятия. Скопируйте следующий выделенный код:
// 100 пуль должно хватить
Bullet bullets[100];
int currentBullet = 0;
int bulletsSpare = 24;
int bulletsInClip = 6;
320 Глава 12. Обнаружение коллизий, бонусные предметы и пули
int clipSize = 6;
float fireRate = 1;
// Когда в последний раз была нажата кнопка выстрела?
Time lastPressed;
// Скрываем указатель мыши и заменяем его прицелом
window.setMouseCursorVisible(true);
Sprite spriteCrosshair;
Texture textureCrosshair = TextureHolder::GetTexture("graphics/crosshair.png");
spriteCrosshair.setTexture(textureCrosshair);
spriteCrosshair.setOrigin(25, 25);
// Основной игровой цикл
while (window.isOpen())
Сначала мы вызываем функцию setMouseCursorVisible на нашем объекте window.
Затем загружаем Texture, объявляем экземпляр Sprite и инициализируем его как
обычно. Кроме того, мы устанавливаем начало координат спрайта в его центр,
чтобы было удобнее и проще направлять пули.
Теперь нам нужно обновлять прицел в каждом кадре с учетом глобальных координат мыши. Добавьте следующую выделенную строку кода, которая использует
вектор mouseWorldPosition для установки положения перекрестия в каждом
кадре:
/*
****************
Обновление кадра
****************
*/
if (state == State::PLAYING)
{
// Обновление delta time
Time dt = clock.restart();
// Обновляем общее игровое время
gameTimeTotal += dt;
// Преобразуем delta time в дробь
float dtAsSeconds = dt.asSeconds();
// Получаем текущее положение указателя мыши
mouseScreenPosition = Mouse::getPosition();
// Преобразуем положение указателя мыши в глобальные
// координаты относительно mainView
mouseWorldPosition = window.mapPixelToCoords(
Mouse::getPosition(), mainView);
// Устанавливаем прицел
spriteCrosshair.setPosition(mouseWorldPosition);
// Обновляем игрового персонажа
player.update(dtAsSeconds, Mouse::getPosition());
Далее, как вы уже, наверное, догадались, мы можем отрисовать прицел в каждом
кадре. Добавьте следующую выделенную строку кода в соответствующее место.
Эта строка не нуждается в объяснении, но ее положение после всех остальных
игровых объектов очень важно, чтобы она отрисовывалась поверх них:
Заставляем пули летать 321
/*
***************
Отрисовка сцены
***************
*/
if (state == State::PLAYING)
{
window.clear();
// Устанавливаем mainView для отображения в окне
// и отрисовываем все элементы, связанные с ним
window.setView(mainView);
// Отрисовываем фон
window.draw(background, &textureBackground);
// Отрисовываем зомби
for (int i = 0; i < numZombies; i++)
{
window.draw(zombies[i].getSprite());
}
for (int i = 0; i < 100; i++)
{
if (bullets[i].isInFlight())
{
window.draw(bullets[i].getShape());
}
}
// Отрисовываем игрового персонажа
window.draw(player.getSprite());
// Отрисовываем прицел
window.draw(spriteCrosshair);
}
Запустив игру, вы увидите, что вместо указателя мыши теперь прицел, как показано на рис. 12.1.
Рис. 12.1. Перекрестие вместо указателя мыши
322 Глава 12. Обнаружение коллизий, бонусные предметы и пули
Обратите внимание, что пули летят точно через центр перекрестия. Механизм
стрельбы аналогичен тому, как если бы игрок мог выбирать: стрелять от бедра
или прицеливаться. Если игрок держит указатель мыши близко к центру, он может быстро стрелять и поворачиваться, но при этом должен внимательно следить
за положением зомби вдалеке.
Кроме того, игрок может прицелиться прямо в голову зомби и совершить точный
выстрел, однако, если зомби атакует с другого направления, игроку придется
двигать мышь обратно на большее расстояние.
Интересным улучшением игры могло бы стать добавление небольшого отклонения пуль от цели в случайном направлении. Возможно, этот разброс можно
было бы уменьшить с помощью улучшения между волнами.
Создание класса для собираемых предметов
В этом разделе мы создадим класс Pickup, который будет содержать член Sprite,
а также другие данные и функции. Мы добавим собираемые предметы в нашу
игру всего за несколько шагов.
1. Для начала мы создадим файл Pickup.h. В нем будут описаны все подробности
о данных-членах и прототипах функций.
2. Затем мы напишем файл Pickup.cpp, который, конечно же, будет содержать
определения всех функций класса Pickup. По ходу дела я объясню, как именно
будет работать и управляться объект типа Pickup.
3. Наконец, мы воспользуемся классом Pickup в функции main, чтобы сгенерировать, обновить и отрисовать собираемые предметы.
Начнем с шага 1.
Создание заголовочного файла Pickup
Чтобы создать новый заголовочный файл, щелкните правой кнопкой мыши на
Header Files (Файлы заголовков) в окне Solution Explorer (Обозреватель решений)
и выберите AddNew Item (ДобавитьСоздать элемент). Далее выберите параметр Header File (.h) (Файл заголовка), а затем в поле Name (Имя) введите Pickup.h.
Скопируйте следующий код в файл Pickup.h:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
class Pickup
{
private:
Создание класса для собираемых предметов 323
// Начальное значение для предмета
const int HEALTH_START_VALUE = 50;
const int AMMO_START_VALUE = 12;
const int START_WAIT_TIME = 10;
const int START_SECONDS_TO_LIVE = 5;
// Спрайт, представляющий этот предмет
Sprite m_Sprite;
// Арена, на которой существует предмет
IntRect m_Arena;
// Ценность предмета
int m_Value;
};
// Тип предмета или бонуса (1 – здоровье, 2 – боеприпасы)
// 1 = health, 2 = ammo
int m_Type;
// Обработка появления и исчезновения
bool m_Spawned;
float m_SecondsSinceSpawn;
float m_SecondsSinceDeSpawn;
float m_SecondsToLive;
float m_SecondsToWait;
// Здесь начинаются прототипы публичных функций
В предыдущем коде объявлены все приватные переменные класса Pickup. Хотя
их названия должны быть вполне интуитивно понятны, может быть неочевидно,
зачем вообще нужны многие из них. Давайте разберем их:
zzconst int HEALTH_START_VALUE = 50: константа, которая используется для уста-
новки начального значения всех аптечек. Это значение будет применяться
для инициализации переменной m_Value, которой нужно будет управлять на
протяжении всей игры;
zzconst int AMMO_START_VALUE = 12: константа, которая используется для установки начального значения всех подбираемых патронов. Это значение будет
применяться для инициализации переменной m_Value, которую можно изменять в течение игры;
zzconst int START_WAIT_TIME = 10: данная переменная определяет, через какое
время предмет снова появится после подбора. Используется для инициализации переменной m_SecondsToWait, которую можно изменять в течение игры;
zzconst int START_SECONDS_TO_LIVE = 5 : константа, которая определяет, как
долго предмет может существовать до его подбора. Как и предыдущие три
переменные, она также имеет связанную с ней непостоянную переменную
(m_SecondsToLive в данном случае), которую можно изменять в течение игры;
zzSprite m_Sprite: спрайт для визуального представления объекта;
zzIntRect m_Arena: здесь будет храниться размер текущей арены, чтобы предмет
генерировался в корректном месте;
324 Глава 12. Обнаружение коллизий, бонусные предметы и пули
zzint m_Value: какова ценность предмета? Это значение используется, когда
игрок повышает уровень здоровья или боеприпасов;
zzint m_Type: это будет либо 1, либо 2 для здоровья или патронов соответственно.
Мы могли бы использовать перечисление, но это кажется излишеством для
всего двух вариантов;
zzbool m_Spawned: проверяет, лежит ли предмет в данный момент на арене;
zzfloat m_SecondsSinceSpawn: проверяет, сколько времени прошло с момента
генерации предмета;
zzfloat m_SecondsSinceDeSpawn: проверяет, сколько времени прошло с тех пор,
как предмет исчез;
zzfloat m_SecondsToLive: длительность нахождения предмета на арене перед
исчезновением;
zzfloat m_SecondsToWait: время, которое должно пройти до повторного появления предмета.
СОВЕТ
Обратите внимание, что большая часть сложности этого класса связана с переменным
временем генерации и возможностью улучшения. Если бы бонусные предметы просто появлялись заново после подбора и имели фиксированное значение, это был бы
очень простой класс. Нам нужно, чтобы предметы можно было улучшать, чтобы игрок
был вынужден разрабатывать стратегию для прохождения волн.
Далее добавьте следующие прототипы публичных функций в файл Pickup.h:
// Здесь начинаются прототипы публичных функций
public:
Pickup(int type);
// Подготавливаем новый предмет
void setArena(IntRect arena);
void spawn();
// Проверяем позицию предмета
FloatRect getPosition();
// Получаем спрайт для отрисовки
Sprite getSprite();
// Обновляем предмет каждый кадр
void update(float elapsedTime);
// Этот предмет сейчас на арене?
bool isSpawned();
// Считываем полезность предмета
int gotIt();
// Улучшаем каждый предмет
void upgrade();
};
Создание класса для собираемых предметов 325
Давайте вкратце поговорим о каждом определении функций:
zzпервая функция является конструктором и названа в честь класса (обратите
внимание, что она принимает целочисленное значение для инициализации
типа бонусного предмета: увеличение уровня здоровья или боеприпасов);
zzфункция setArena принимает IntRect. Эта функция будет вызываться для
каждого экземпляра Pickup в начале очередной волны. Таким образом, объекты Pickup будут «знать» области, в которых они могут появляться;
zzфункция spawn, конечно же, будет обрабатывать генерацию бонусного предмета;
zzфункция getPosition, как и в классах Player, Zombie и Bullet, возвращает
экземпляр FloatRect , который представляет текущее положение объекта
в игровом мире;
zzфункция getSprite возвращает объект Sprite, который позволяет отрисовывать предмет один раз в каждом кадре;
zzфункция update принимает время, затраченное на предыдущий кадр. Она
использует это значение для обновления своих приватных переменных и принятия решений о том, когда появляться и исчезать;
zzфункция isSpawned возвращает логическое значение, которое позволяет вызывающему коду узнать, находится ли предмет на арене в данный момент;
zzфункция gotIt будет вызываться при обнаружении столкновения с игроком.
После этого код класса Pickup может подготовиться к генерации предмета
в нужное время. Обратите внимание, что она возвращает значение int, чтобы
вызывающий код знал «ценность» бонуса в виде очков здоровья или патронов;
zzфункция update будет вызвана, когда игрок решит повысить уровень свойств
подбираемого предмета во время фазы повышения уровня в игре.
Теперь, когда мы рассмотрели переменные-члены и прототипы функций, вам
будет легко следить за тем, как мы пишем код определений функций.
Создание определений функций класса Pickup
Можем создать новый файл .cpp , который будет содержать определения
функций. Щелкните правой кнопкой мыши на Source Files (Исходные файлы) в окне Solution Explorer (Обозреватель решений) и выберите AddNew Item
(ДобавитьСоздать элемент). Далее выберите пункт C++ File (.cpp) (Файл C++)
и в поле Name (Имя) введите Pickup.cpp. Наконец, нажмите кнопку Add (Добавить).
Скопируйте следующий код в файл Pickup.cpp:
#include "Pickup.h"
#include "TextureHolder.h"
Pickup::Pickup(int type): m_Type{type}
{
326 Глава 12. Обнаружение коллизий, бонусные предметы и пули
}
// Связываем текстуру со спрайтом
if (m_Type == 1)
{
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/health_pickup.png"));
// Ценность бонусного предмета
m_Value = HEALTH_START_VALUE;
}
else
{
m_Sprite = Sprite(TextureHolder::GetTexture(
"graphics/ammo_pickup.png"));
// Ценность бонусного предмета
m_Value = AMMO_START_VALUE;
}
m_Sprite.setOrigin(25, 25);
m_SecondsToLive = START_SECONDS_TO_LIVE;
m_SecondsToWait = START_WAIT_TIME;
В приведенном коде мы добавили привычные директивы include и конструктор Pickup. Мы знаем, что это конструктор, потому что он имеет то же имя, что
и класс.
Конструктор принимает параметр типа int с именем type, и первое, что делает
код, — присваивает значение, полученное из type, переменной m_Type. Затем
следует блок if else, который проверяет, равна ли m_Type единице. Если да, то
m_Sprite связывается с текстурой аптечки, а m_Value устанавливается в HEALTH_
START_VALUE.
Если m_Type не равен 1, блок else связывает текстуру боеприпасов с m_Sprite
и присваивает значение AMMO_START_VALUE переменной m_Value.
Затем код устанавливает начало координат m_Sprite в центр с помощью функции
setOrigin и присваивает START_SECONDS_TO_LIVE и START_WAIT_TIME переменным
m_SecondsToLive и m_SecondsToWait соответственно.
Конструктор успешно подготовил объект Pickup к использованию. Теперь давайте добавим функцию setArena:
void Pickup::setArena(IntRect arena)
{
// Копируем данные арены в m_Arena бонусного предмета
m_Arena.left = arena.left + 50;
m_Arena.width = arena.width - 50;
m_Arena.top = arena.top + 50;
m_Arena.height = arena.height - 50;
spawn();
}
Создание класса для собираемых предметов 327
Функция setArena просто копирует значения из переданного объекта arena, но
изменяет их на + 50 для левой и верхней границ и на – 50 для правой и нижней.
Теперь объект Pickup знает область, в которой он может появляться. Затем функция setArena вызывает собственную функцию spawn, чтобы завершить подготовку
к отрисовке и обновлению каждого кадра.
Добавьте следующий код функции spawn после функции setArena:
void Pickup::spawn()
{
// Генерация в случайном месте
srand((int)time(0) / m_Type);
int x = (rand() % m_Arena.width);
srand((int)time(0) * m_Type);
int y = (rand() % m_Arena.height);
m_SecondsSinceSpawn = 0;
m_Spawned = true;
m_Sprite.setPosition(x, y);
}
Функция spawn делает все необходимое для подготовки бонусного предмета. Сначала она инициализирует генератор случайных чисел и получает случайное число
для позиции объекта по вертикали и горизонтали. Обратите внимание, что она
использует переменные m_Arena.width и m_Arena.height в качестве диапазонов
для возможных горизонтальных и вертикальных положений.
Переменная m_SecondsSinceSpawn устанавливается равной 0, чтобы сбросить
время, разрешенное до исчезновения предмета. Переменная m_Spawned устанавливается в true, чтобы при вызове isSpawned из main мы получали положительный ответ. Наконец, m_Sprite перемещается в позицию с помощью setPosition,
готовая к отрисовке на экране.
В следующем блоке кода у нас есть три простые геттер-функции. Функция
getPosition возвращает FloatRect текущей позиции m_Sprite, getSprite возвращает копию самой m_Sprite, а isSpawned возвращает true или false, в зависимости от того, сгенерирован ли объект в данный момент.
Добавьте и изучите код, который мы только что обсудили:
FloatRect Pickup::getPosition()
{
return m_Sprite.getGlobalBounds();
}
Sprite Pickup::getSprite()
{
return m_Sprite;
}
328 Глава 12. Обнаружение коллизий, бонусные предметы и пули
bool Pickup::isSpawned()
{
return m_Spawned;
}
Далее мы напишем функцию gotIt. Она будет вызываться из main, когда игрок коснется предмета (поднимет его). Добавьте функцию gotIt после функции isSpawned:
int Pickup::gotIt()
{
m_Spawned = false;
m_SecondsSinceDeSpawn = 0;
return m_Value;
}
Функция gotIt устанавливает m_Spawned в false, чтобы мы знали, что больше не нужно отрисовывать и проверять на коллизии. Переменная m_Seconds
SinceDespawn устанавливается в ноль, чтобы отсчет времени до повторного
появления бонусного предмета начался заново. Значение m_Value затем возвращается вызывающему коду, чтобы он мог обработать добавление дополнительных
боеприпасов или аптечек в зависимости от ситуации.
После этого нам нужно написать функцию update, которая связывает воедино
многие переменные и функции, с которыми мы уже познакомились. Добавьте
функцию update:
void Pickup::update(float elapsedTime)
{
if (m_Spawned)
{
m_SecondsSinceSpawn += elapsedTime;
}
else
{
m_SecondsSinceDeSpawn += elapsedTime;
}
// Нужно ли скрыть предмет?
if (m_SecondsSinceSpawn > m_SecondsToLive && m_Spawned)
{
// Убираем предмет и перемещаем его в другое место
m_Spawned = false;
m_SecondsSinceDeSpawn = 0;
}
// Должен ли предмет появиться?
if (m_SecondsSinceDeSpawn > m_SecondsToWait && !m_Spawned)
{
// Генерируем предмет и сбрасываем таймер
spawn();
}
}
Создание класса для собираемых предметов 329
Функция update разделена на четыре блока, которые выполняются в каждом
кадре:
zzблок if выполняется, если значение m_Spawned равно true. Данный блок кода
добавляет время текущего кадра к m_SecondsSinceSpawned, который отслежи-
вает, как долго предмет находился на арене;
zzблок else выполняется, если m_Spawned равно false. Этот блок добавляет время
текущего кадра к m_SecondsSinceDeSpawn, который отслеживает, сколько времени предмет находился на арене с момента своего последнего исчезновения;
zzеще один блок if выполняется, если предмет генерировался дольше, чем следовало: if (m_SecondsSinceSpawn > m_SecondsToLive && m_Spawned). Данный блок
устанавливает m_Spawned в false и обнуляет m_SecondsSinceDeSpawn. Теперь
блок 2 будет работать до тех пор, пока не настанет время новой генерации;
zzзаключительный блок if выполняется, если время ожидания с момента исчезновения предмета превысило установленное значение, а сам предмет в данный
момент отсутствует на арене: if (m_SecondsSinceDeSpawn > m_SecondsToWait &&
!m_Spawned). Когда этот блок будет выполнен, настанет время снова сгенерировать бонусный предмет и вызвать функцию spawn.
Эти четыре проверки управляют скрытием и отображением предмета. Наконец,
добавьте определение для функции upgrade:
void Pickup::upgrade()
{
if (m_Type == 1)
{
m_Value += (HEALTH_START_VALUE * .5);
}
else
{
m_Value += (AMMO_START_VALUE * .5);
}
// Предметы появляюся чаще и "живут" дольше
m_SecondsToLive += (START_SECONDS_TO_LIVE / 10);
m_SecondsToWait -= (START_WAIT_TIME / 10);
}
Функция upgrade проверяет тип предмета (аптечка или боеприпас), а затем добавляет к m_Value 50 % от их начального (соответствующего) значения. Следующие
две строки после блока if else увеличивают время, в течение которого предметы
будут оставаться на арене, и уменьшают время ожидания между их появлениями.
Эта функция вызывается, когда игрок решает повысить уровень подбираемых
предметов во время состояния LEVELING_UP.
Наш класс Pickup готов к использованию.
330 Глава 12. Обнаружение коллизий, бонусные предметы и пули
Использование класса Pickup
После всей проделанной работы по реализации класса Pickup мы можем приступить к написанию кода в игровом движке, чтобы добавить в игру несколько
бонусных предметов.
Добавьте директиву include в файл ZombieArena.cpp:
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
#include "Bullet.h"
#include "Pickup.h"
using namespace sf;
В следующем коде мы добавляем два экземпляра Pickup: один с именем Health
Pickup, а другой — AmmoPickup. В конструктор мы передаем значения 1 и 2 соответственно, чтобы они инициализировались как предметы правильного типа.
Добавьте следующий выделенный код, который мы только что рассмотрели:
// Скрываем указатель мыши и заменяем его прицелом
window.setMouseCursorVisible(true);
Sprite spriteCrosshair;
Texture textureCrosshair = TextureHolder::GetTexture(
"graphics/crosshair.png");
spriteCrosshair.setTexture(textureCrosshair);
spriteCrosshair.setOrigin(25, 25);
// Создаем пару бонусных предметов
Pickup healthPickup(1);
Pickup ammoPickup(2);
// Основной игровой цикл
while (window.isOpen())
В состоянии LEVELING_UP обработки клавиатуры добавьте следующие выделенные
строки во вложенный блок кода PLAYING:
if (state == State::PLAYING)
{
// Подготовка уровня
// Позже мы изменим следующие две строки
arena.width = 500;
arena.height = 500;
arena.left = 0;
arena.top = 0;
// Передаем массив вершин по ссылке
// в функцию createBackground
int tileSize = createBackground(background, arena);
// Генерируем игрового персонажа в центре арены
player.spawn(arena, resolution, tileSize);
Использование класса Pickup 331
}
// Настраиваем предметы
healthPickup.setArena(arena);
ammoPickup.setArena(arena);
// Создаем орду зомби
numZombies = 10;
// Очищаем ранее выделенную память (если она существует)
delete[] zombies;
zombies = createHorde(numZombies, arena);
numZombiesAlive = numZombies;
// Сбрасываем таймер, чтобы избежать скачка кадра
clock.restart();
Приведенный код просто передает значение arena в функцию setArena каждого
бонуса. Теперь область появления подбираемых предметов задана. Этот код выполняется для каждой новой волны, поэтому по мере увеличения размера арены
объекты Pickup будут обновляться.
Следующий код просто вызывает функцию update для каждого объекта Pickup
на каждом кадре:
// Перебираем всех зомби и обновляем их
for (int i = 0; i < numZombies; i++)
{
if (zombies[i].isAlive())
{
zombies[i].update(dt.asSeconds(), playerPosition);
}
}
// Обновляем все пули, которые находятся в полете
for (int i = 0; i < 100; i++)
{
if (bullets[i].isInFlight())
{
bullets[i].update(dtAsSeconds);
}
}
// Обновляем бонусные предметы
healthPickup.update(dtAsSeconds);
ammoPickup.update(dtAsSeconds);
}// Завершение обновления сцены
Следующий код в части отрисовки игрового цикла проверяет, находится ли
в данный момент предмет на арене, и, если да, отображает его:
// Отрисовываем игрового персонажа
window.draw(player.getSprite());
// Отрисовываем предметы, если они сгенерированы
if (ammoPickup.isSpawned())
{
window.draw(ammoPickup.getSprite());
}
332 Глава 12. Обнаружение коллизий, бонусные предметы и пули
}
if (healthPickup.isSpawned())
{
window.draw(healthPickup.getSprite());
}
// Отрисовываем прицел
window.draw(spriteCrosshair);
Теперь вы можете запустить игру и увидеть, как предметы появляются и исчезают (рис. 12.2). Однако обратите внимание, что вы пока не можете их подбирать.
Рис. 12.2. Отображение и исчезновение бонусных предметов
Теперь, когда в нашей игре есть все необходимые объекты, самое время заставить
их взаимодействовать (сталкиваться) друг с другом.
Обнаружение коллизий
Если мы будем знать, когда определенные объекты в нашей игре касаются других объектов, то сможем соответствующим образом реагировать на это событие.
В наших классах мы уже добавили функции, которые будут вызываться при
столкновении объектов. Вот они:
zzфункция hit в классе Player вызывается, когда зомби сталкивается с героем;
zzфункция hit в классе Zombie вызывается при попадании пули в зомби;
zzфункция gotIt в классе Pickup вызывается, когда герой касается предмета.
Обнаружение коллизий 333
Если необходимо, просмотрите предыдущие разделы, чтобы освежить в памяти
принцип работы каждой из этих функций. Все, что нам нужно сделать сейчас, —
это обнаружить коллизии и вызвать соответствующие функции.
Для обнаружения коллизий мы воспользуемся методом пересечения прямо
угольников. Этот тип обнаружения очень прост (особенно с SFML). Мы применим
ту же технику, что и в игре Pong. На рис. 12.3 показано, как можно достаточно
точно представлять зомби и игрового персонажа с помощью прямоугольника.
Рис. 12.3. Прямоугольник, представляющий зомби и игрового персонажа
Весь код, связанный с обнаружением коллизий, будет находиться в конце блока
обновления нашей игры.
Нам нужно знать ответы на следующие три вопроса.
1. Был ли зомби подстрелен?
2. Был ли герой задет зомби?
3. Коснулся ли герой бонусного предмета?
Сперва добавим пару переменных для score и hiscore. Мы сможем изменять их,
когда зомби будет убит. Добавьте следующий код:
// Создаем пару бонусов
Pickup healthPickup(1);
Pickup ammoPickup(2);
// Об игре
int score = 0;
int hiScore = 0;
// Основной игровой цикл
while (window.isOpen())
Начнем с обнаружения столкновения пули с зомби.
334 Глава 12. Обнаружение коллизий, бонусные предметы и пули
Был ли зомби подстрелен?
Следующий код может показаться сложным, но когда мы разберем его, то увидим,
что в нем нет ничего, с чем мы раньше не сталкивались. Добавьте следующий код
сразу после вызова для обновления бонусов для каждого кадра:
// Обновляем бонусные предметы
healthPickup.update(dtAsSeconds);
ammoPickup.update(dtAsSeconds);
// Обнаружение коллизий
// Был ли зомби подстрелен?
for (int i = 0; i < 100; i++)
{
for (int j = 0; j < numZombies; j++)
{
if (bullets[i].isInFlight() &&
zombies[j].isAlive())
{
if (bullets[i].getPosition().intersects
(zombies[j].getPosition()))
{
// Останавливаем пулю
bullets[i].stop();
// Регистрируем попадание и проверяем, было ли это убийством
if (zombies[j].hit())
{
// Не просто попадание, но и убийство
score += 10;
if (score >= hiScore)
{
hiScore = score;
}
numZombiesAlive--;
// Когда все зомби мертвы (снова)
if (numZombiesAlive == 0) {
state = State::LEVELING_UP;
}
}
}
}
}
}// Конец обработки подстреленного зомби
В следующем разделе мы снова рассмотрим весь код обнаружения коллизий
зомби и пуль. Мы будем разбирать его по частям. Обратите внимание на структуру вложенных циклов for в предыдущем листинге (с удаленным кодом), как
показано здесь:
// Обнаружение коллизий
// Был ли зомби подстрелен?
for (int i = 0; i < 100; i++)
{
Обнаружение коллизий 335
}
for (int j = 0; j < numZombies; j++)
{
...
...
...
}
Мы перебираем все пули (от 0 до 99) для каждого зомби (от 0 до «меньше, чем»
numZombies). Внутри вложенных циклов for мы делаем следующее.
1. Проверяем, находится ли текущая пуля в полете и жив ли текущий зомби,
с помощью следующего кода:
if (bullets[i].isInFlight() && zombies[j].isAlive())
2. Если зомби жив и пуля летит, мы проверяем пересечение прямоугольников
с помощью следующего кода:
if (bullets[i].getPosition().intersects(zombies[j].getPosition()))
3. Если пуля и зомби столкнулись, выполняем ряд шагов, описанных далее.
Останавливаем пулю с помощью следующего кода:
// Останавливаем пулю
bullets[i].stop();
4. Регистрируем попадание в текущего зомби, вызвав его функцию hit. Обратите внимание, что функция hit возвращает логическое значение, которое
сообщает вызывающему коду, умер ли зомби. Это показано в следующей
строке кода:
// Регистрируем попадание и проверяем, было ли это убийством
if (zombies[j].hit()) {
5. Внутри этого блока if, который определяет, когда зомби мертв, а не просто
ранен, делаем следующее:
добавляем 10 к score;
обновляем hiScore, если счет игрока достиг или превысил текущий рекорд;
уменьшаем количество живых зомби numZombiesAlive на единицу;
проверяем, все ли зомби мертвы, с помощью (numZombiesAlive == 0), и, если
да, изменяем состояние state на LEVELING_UP.
Вот блок кода внутри if(zombies[j].hit()), который мы только что рассмотрели:
// Не только попадание, но и убийство
score += 10;
if (score >= hiScore)
{
336 Глава 12. Обнаружение коллизий, бонусные предметы и пули
hiScore = score;
}
numZombiesAlive--;
// Когда все зомби мертвы (снова)
if (numZombiesAlive == 0)
{
state = State::LEVELING_UP;
}
С зомби и пулями разобрались. Теперь, запустив игру, вы увидите кровь. Конечно, счет не отобразится, пока мы не внедрим HUD.
Был ли герой задет зомби?
Этот код намного короче и проще, чем предыдущий:
}// Конец обработки подстреленного зомби
// Был ли герой задет зомби?
for (int i = 0; i < numZombies; i++)
{
if (player.getPosition().intersects
(zombies[i].getPosition()) && zombies[i].isAlive())
{
if (player.hit(gameTimeTotal))
{
// Здесь будет больше кода позже
}
if (player.getHealth() <= 0)
{
state = State::GAME_OVER;
}
}
}// Конец обработки касания игрового персонажа
Используя цикл for для перебора всех зомби, мы определяем, столкнулся ли
зомби с героем. Для каждого зомби код применяет функцию intersects для проверки на столкновение с игровым персонажем. Если столкновение произошло,
мы вызываем player.hit. Затем проверяем, мертв ли герой, вызывая player.
getHealth. Если здоровье героя равно или меньше нуля, мы меняем состояние
state на GAME_OVER.
Вы можете запустить игру и протестировать работу обнаружения коллизий.
Однако без HUD и звуковых эффектов сложно определить, что этот процесс
происходит. Кроме того, нам нужно еще немного поработать над перезагрузкой
игры, когда герой умер и начинается новая игра.
Обнаружение коллизий 337
Так что, хотя игра и работает, результаты пока нельзя назвать впечатляющими.
Мы исправим это в следующих двух главах.
Коснулся ли герой предмета?
Код обнаружения коллизий между игровым персонажем и каждым из двух типов
предметов показан ниже:
}// Конец обработки касания игрового персонажа
// Коснулся ли герой аптечки
if (player.getPosition().intersects
(healthPickup.getPosition()) && healthPickup.isSpawned())
{
player.increaseHealthLevel(healthPickup.gotIt());
}
// Коснулся ли герой боеприпасов
if (player.getPosition().intersects
(ammoPickup.getPosition()) && ammoPickup.isSpawned())
{
bulletsSpare += ammoPickup.gotIt();
}
}// Конец обновления сцены
Два простых оператора if в представленном коде проверяют, коснулся ли игровой персонаж healthPickup или ammoPickup.
Если герой подобрал аптечку, то функция player.increaseHealthLevel использует значение, возвращенное функцией healthPickup.gotIt, чтобы увеличить
здоровье игрока.
Если были подобраны боеприпасы, то переменная bulletsSpare увеличивается
на значение, возвращаемое из ammoPickup.gotIt.
СОВЕТ
Теперь, запустив игру, вы можете убивать зомби и собирать предметы! Обратите
внимание, что, когда ваше здоровье станет равным нулю, игра перейдет в состояние
GAME_OVER и приостановится. Чтобы перезапустить ее, вам нужно нажать Enter, а затем число от 1 до 6. Когда мы реализуем HUD, главный экран и меню повышения
уровня, эти шаги будут интуитивно понятны и просты для игрока. А сделаем это мы
уже в следующей главе.
338 Глава 12. Обнаружение коллизий, бонусные предметы и пули
Резюме
Эта глава была насыщенной, и мы многого достигли. Мы не только добавили
в игру пули и возможность собирать предметы с помощью двух новых классов,
но и сделали так, чтобы все объекты корректно взаимодействовали, обнаруживая
столкновения друг с другом.
Несмотря на эти достижения, нам нужно проделать еще большую работу, чтобы
настроить начало новой игры и предоставить игроку обратную связь через HUD.
В следующей главе мы создадим HUD.
Часто задаваемые вопросы
В. Есть ли более эффективные способы обнаружения коллизий?
О. Да. Существует множество других способов обнаружения коллизий, включая
следующие:
zzвы можете разделить объекты на несколько прямоугольников, чтобы они
лучше соответствовали форме спрайта. В C++ можно проверять тысячи прямоугольников в каждом кадре. Это особенно актуально, если вы используете
такие методы, как проверка соседей, чтобы уменьшить количество проверок,
необходимых для каждого кадра;
zzдля круглых объектов можно применить метод пересечения их радиусов;
zzдля неравномерных многоугольников можно использовать алгоритм вычисления числа пересечений.
При желании вы можете ознакомиться со всеми этими техниками, перейдя по
следующим ссылкам:
zzпроверка соседей: http://gamecodeschool.com/essentials/collision-detection-neighborchecking/;
zzметод пересечения радиусов: http://gamecodeschool.com/essentials/collision-detectionradius-overlap/;
zzалгоритм определения числа пересечений: http://gamecodeschool.com/essentials/
collision-detection-crossing-number/.
13
Разделение области
отображения на слои
и реализация HUD
В этой главе мы увидим реальную ценность класса View библиотеки SFML. Мы
добавим набор объектов Text библиотеки SFML и будем управлять ими, как
делали это раньше в проектах Timber! и Pong. Новым будет то, что мы отрисуем
HUD с помощью второго экземпляра View. Таким образом, HUD будет аккуратно располагаться поверх основного игрового действия, независимо от того, что
происходит на фоне.
Добавление всех объектов Text и HUD
В этой главе мы будем работать с несколькими строками. Это нужно для того,
чтобы оформить HUD и меню повышения уровня необходимым текстом.
Добавьте дополнительную директиву include в файл ZombieArena.cpp, как показано ниже, чтобы мы могли создать несколько объектов sstream:
#include <sstream>
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
#include "Bullet.h"
#include "Pickup.h"
using namespace sf;
Затем добавьте этот довольно длинный, но понятный фрагмент кода:
int score = 0;
int hiScore = 0;
// Для главного экрана и экрана завершения игры
Sprite spriteGameOver;
Texture textureGameOver = TextureHolder::GetTexture("graphics/background.png");
spriteGameOver.setTexture(textureGameOver);
spriteGameOver.setPosition(0, 0);
// Создаем область отображения для HUD
340 Глава 13. Разделение области отображения на слои и реализация HUD
View hudView(sf::FloatRect(0, 0, 1920,1080));
// Создаем спрайт для иконки патронов
Sprite spriteAmmoIcon;
Texture textureAmmoIcon = TextureHolder::GetTexture(
"graphics/ammo_icon.png");
spriteAmmoIcon.setTexture(textureAmmoIcon);
spriteAmmoIcon.setPosition(20, 980);
// Загружаем шрифт
Font font;
font.loadFromFile("fonts/zombiecontrol.ttf");
// Пауза
Text pausedText;
pausedText.setFont(font);
pausedText.setCharacterSize(155);
pausedText.setFillColor(Color::White);
pausedText.setPosition(400, 400);
pausedText.setString("Press Enter \nto continue");
// Игра окончена
Text gameOverText;
gameOverText.setFont(font);
gameOverText.setCharacterSize(125);
gameOverText.setFillColor(Color::White);
gameOverText.setPosition(250, 850);
gameOverText.setString("Press Enter to play");
// Повышение уровня
Text levelUpText;
levelUpText.setFont(font);
levelUpText.setCharacterSize(80);
levelUpText.setFillColor(Color::White);
levelUpText.setPosition(150, 250);
std::stringstream levelUpStream;
levelUpStream <<
"1- Повышение скорострельности" <<
"\n2- Повышение размера обоймы (при следующей перезарядке)" <<
"\n3- Повышение максимального уровня здоровья" <<
"\n4- Повышение скорости передвижения" <<
"\n5- Улучшение аптечки" <<
"\n6- Улучшение боеприпасов";
levelUpText.setString(levelUpStream.str());
// Патроны
Text ammoText;
ammoText.setFont(font);
ammoText.setCharacterSize(55);
ammoText.setFillColor(Color::White);
ammoText.setPosition(200, 980);
// Счет
Text scoreText;
scoreText.setFont(font);
scoreText.setCharacterSize(55);
scoreText.setFillColor(Color::White);
scoreText.setPosition(20, 0);
// Рекорд
Text hiScoreText;
Добавление всех объектов Text и HUD 341
hiScoreText.setFont(font);
hiScoreText.setCharacterSize(55);
hiScoreText.setFillColor(Color::White);
hiScoreText.setPosition(1400, 0);
std::stringstream s;
s << "Hi Score:" << hiScore;
hiScoreText.setString(s.str());
// Оставшиеся зомби
Text zombiesRemainingText;
zombiesRemainingText.setFont(font);
zombiesRemainingText.setCharacterSize(55);
zombiesRemainingText.setFillColor(Color::White);
zombiesRemainingText.setPosition(1500, 980);
zombiesRemainingText.setString("Zombies: 100");
// Номер волны
int wave = 0;
Text waveNumberText;
waveNumberText.setFont(font);
waveNumberText.setCharacterSize(55);
waveNumberText.setFillColor(Color::White);
waveNumberText.setPosition(1250, 980);
waveNumberText.setString("Wave: 0");
// Шкала здоровья
RectangleShape healthBar;
healthBar.setFillColor(Color::Red);
healthBar.setPosition(450, 980);
// Основной игровой цикл
while (window.isOpen())
Этот код очень прост и не содержит ничего нового. По сути, он создает множество объектов Text библиотеки SFML. Он назначает им цвета и размеры, а затем
форматирует их позиции с помощью функций, которые мы уже видели.
Самое главное, что нужно отметить, — это то, что мы создаем еще один объект View
с именем hudView и инициализируем его в соответствии с разрешением экрана.
Как мы уже видели, основной объект View прокручивается, следуя за игровым
персонажем. В отличие от него, мы никогда не будем перемещать hudView. В результате, если мы переключимся на эту область отображения перед отрисовкой
элементов HUD, то создадим эффект, при котором игровой мир будет прокручиваться под неподвижным HUD.
СОВЕТ
Представьте, что вы кладете прозрачное стекло с различными надписями на нем на
экран телевизора. Телевизор продолжит показывать динамичные ролики как обычно,
а текст на стекле останется на том же месте, независимо от того, что происходит
на экране. В следующем проекте мы рассмотрим эту концепцию подробнее, когда
создадим игру в жанре платформер с движущимися игровыми объектами.
342 Глава 13. Разделение области отображения на слои и реализация HUD
Обратите также внимание, что значение рекорда никак не устанавливается.
В следующей главе вы узнаете, как это осуществить.
Еще один момент, который стоит отметить: мы объявляем и инициализируем
RectangleShape с именем healthBar, который сыграет роль визуального представления оставшегося количества очков здоровья игрока. Это будет работать почти так
же, как и временная шкала в проекте Timber!, только вместо времени — здоровье.
В предыдущем коде есть новый экземпляр Sprite под названием ammoIcon, который дает контекст для статистики пуль и обойм, которые мы будем отрисовывать
в левом нижнем углу экрана.
Хотя в большом блоке кода, который мы только что добавили, нет ничего нового или технически сложного, обязательно ознакомьтесь с деталями — особенно
с именами переменных, — чтобы было легче понять остальную часть этой главы.
Теперь рассмотрим обновление переменных HUD.
Обновление HUD
Как и следовало ожидать, мы будем обновлять переменные HUD в измененной
части нашего кода. Однако мы не планируем делать это в каждом кадре, поскольку такой шаг замедлил бы наш игровой цикл и в целом не имеет смысла.
В качестве примера рассмотрим сценарий, когда игрок убивает зомби и получает
дополнительные очки. Не имеет значения, обновляется ли объект Text, в котором
хранится счет, за одну тысячную, одну сотую или даже одну десятую секунды.
Игрок не заметит никакой разницы. Это означает, что нет причин пересоздавать
строки для объектов Text в каждом кадре.
Таким образом, мы можем определить время, когда и как часто мы обновляем
HUD. Добавьте следующие выделенные переменные:
// Шкала здоровья
RectangleShape healthBar;
healthBar.setFillColor(Color::Red);
healthBar.setPosition(450, 980);
// Когда мы в последний раз обновляли HUD?
int framesSinceLastHUDUpdate = 0;
// Как часто (в кадрах) мы должны обновлять HUD
int fpsMeasurementFrameInterval = 1000;
// Основной игровой цикл
while (window.isOpen())
Обновление HUD 343
В предыдущем коде у нас есть переменные для отслеживания количества кадров
с момента последнего обновления HUD и интервала, измеряемого в кадрах, который мы хотели бы выждать между обновлениями HUD.
Теперь мы можем использовать эти новые переменные и обновлять HUD для
каждого кадра. Однако мы не увидим, как изменяются все элементы HUD, пока
не задействуем последние переменные, такие как wave, в следующей главе.
Добавьте следующий выделенный код в обновленный блок игрового цикла, как
показано ниже:
// Коснулся ли игрок боеприпасов
if (player.getPosition().intersects
(ammoPickup.getPosition()) && ammoPickup.isSpawned())
{
bulletsSpare += ammoPickup.gotIt();
}
// Изменяем размер шкалы здоровья
healthBar.setSize(Vector2f(player.getHealth() * 3, 50));
// Увеличиваем количество кадров с момента последнего обновления
framesSinceLastHUDUpdate++;
// Пересчитываем каждые fpsMeasurementFrameInterval кадров
if (framesSinceLastHUDUpdate > fpsMeasurementFrameInterval)
{
// Обновляем текст HUD
std::stringstream ssAmmo;
std::stringstream ssScore;
std::stringstream ssHiScore;
std::stringstream ssWave;
std::stringstream ssZombiesAlive;
// Обновляем текст, отображающий количество оставшихся патронов
ssAmmo << bulletsInClip << "/" << bulletsSpare;
ammoText.setString(ssAmmo.str());
// Обновляем текст, показывающий счет
ssScore << "Score:" << score;
scoreText.setString(ssScore.str());
// Обновляем текст, показывающий рекорд
ssHiScore << "Hi Score:" << hiScore;
hiScoreText.setString(ssHiScore.str());
// Обновляем текст, показывающий волну
ssWave << "Wave:" << wave;
waveNumberText.setString(ssWave.str());
// Обновляем текст, отражающий количество оставшихся зомби
ssZombiesAlive << "Zombies:" << numZombiesAlive;
zombiesRemainingText.setString(ssZombiesAlive.str());
framesSinceLastHUDUpdate = 0;
}// Конец обновления HUD
}// Завершение обновления сцены
344 Глава 13. Разделение области отображения на слои и реализация HUD
В новом коде мы обновляем размер спрайта HealthBar, а затем увеличиваем значение параметра framesSinceLastHUDUpdate.
Далее следует блок if, который проверяет, не превышает ли framesSinceLast
HUDUpdate желаемый интервал, который хранится в fpsMeasurementFrameInterval.
Сначала мы объявляем объект stringstream для каждой строки, которую нужно
задать в объекте Text. Затем мы поочередно используем каждый из этих объектов stringstream и с помощью функции setString заносим результат в соответствующий объект Text. Наконец, перед выходом из блока if переменная
framesSinceLastHUDUpdate обнуляется, чтобы отсчет мог начаться заново.
Теперь при отрисовке сцены новые значения отобразятся в HUD игрока.
Отрисовка HUD, главного экрана
и меню повышения уровня
Весь код в следующих трех блоках кода находится в фазе отрисовки нашего
игрового цикла. Все, что нам нужно сделать, — это отобразить соответствующие
объекты Text в соответствующих состояниях в блоке отрисовки основного игрового цикла.
В состояние PLAYING добавьте следующий выделенный код:
// Отрисовываем прицел
window.draw(spriteCrosshair);
// Отрисовываем игрового персонажа
window.draw(player.getSprite());
// Переключаемся на HUD
window.setView(hudView);
// Отрисовываем все элементы HUD
window.draw(spriteAmmoIcon);
window.draw(ammoText);
window.draw(scoreText);
window.draw(hiScoreText);
window.draw(healthBar);
window.draw(waveNumberText);
window.draw(zombiesRemainingText);
}
if (state == State::LEVELING_UP)
{
}
Обратите внимание на то, что мы переключаемся на HUD. Это приводит к тому,
что все элементы HUD отрисовываются в точных позициях на экране, которые
мы задали для каждого из них. Они никогда не будут перемещаться, потому что
мы не меняем вид HUD.
Отрисовка HUD, главного экрана и меню повышения уровня 345
В блок LEVELING_UP добавьте следующий код:
if (state == State::LEVELING_UP)
{
window.draw(spriteGameOver);
window.draw(levelUpText);
}
Для PAUSED добавьте такой код:
if (state == State::PAUSED)
{
window.draw(pausedText);
}
В блок GAME_OVER добавьте следующий выделенный код:
if (state == State::GAME_OVER)
{
window.draw(spriteGameOver);
window.draw(gameOverText);
window.draw(scoreText);
window.draw(hiScoreText);
}
Запустив игру, вы увидите, как обновляется наш HUD во время игрового процесса, как показано на рис. 13.1.
Рис. 13.1. Обновление HUD во время игры
346 Глава 13. Разделение области отображения на слои и реализация HUD
На рис. 13.2 показан рекорд и счет на главном экране/экране завершения игры.
Рис. 13.2. Рекорд и счет на экране
На рис. 13.3 мы видим текст, который сообщает игроку о возможностях повышения уровня, хотя эти варианты пока ничего не дают.
Рис. 13.3. Текст, сообщающий игроку
о возможностях повышения уровня
Резюме 347
На рис. 13.4 показано сообщение, которое выводится во время паузы и предлагает
игроку начать новую игру.
Рис. 13.4. Сообщение с предложением начать новую игру
ПРИМЕЧАНИЕ
Класс View библиотеки SFML гораздо мощнее, чем может продемонстрировать простой HUD. Чтобы понять потенциал класса View и простоту его использования, ознакомьтесь с соответствующим учебником на сайте SFML по адресу https://www.sfml-dev.
org/tutorials/2.5/graphics-view.php. Кроме того, в финальном проекте мы воспользуемся
несколькими экземплярами View для создания мини-карты.
Очень приятно видеть, как наша игра обретает форму. Меню является своего рода
связующим элементом, который объединяет все остальные части и делает игру
удобной. Но нам еще многое предстоит сделать, так что продолжим.
Резюме
Это была короткая и простая глава. Мы рассмотрели, как отображать значения,
хранящиеся в переменных разных типов, с помощью sstream, а затем научились
отрисовывать их поверх основного игрового действия с помощью второго объекта
View библиотеки SFML.
348 Глава 13. Разделение области отображения на слои и реализация HUD
Работа над игрой близится к завершению. Мы добавили и увидели, как обновлять HUD, включая экран повышения уровня и главный экран. На всех иллюстрациях к данной главе показана небольшая арена, которая не задействует
весь монитор.
В следующей главе, заключительной для этого проекта, мы добавим последние штрихи, такие как повышение уровня, звуковые эффекты и сохранение
рекорда. После этого арена сможет увеличиться до ширины экрана и даже
больше.
14
Звуковые эффекты,
работа с файлами
и завершение игры
Мы почти закончили данный проект. В этой короткой главе будет показано,
как манипулировать файлами, хранящимися на жестком диске, с помощью
стандартной библиотеки C++, и добавить звуковые эффекты. Конечно, мы уже
знаем, как добавлять звуковые эффекты, но обсудим, где именно в коде будут находиться вызовы функции play. Мы также решим несколько оставшихся задач,
чтобы сделать игру завершенной.
Сохранение и загрузка рекорда
Файловый ввод/вывод (I/O) — это больше техническая тема. К счастью для нас,
поскольку это довольно распространенное требование в программировании, существует библиотека, которая берет на себя всю эту сложность. Как и конкатенация строк для нашего HUD, это стандартная библиотека C++, предоставляющая
необходимую функциональность через fstream.
Сперва мы подключим fstream:
#include <sstream>
#include <fstream>
#include <SFML/Graphics.hpp>
#include "ZombieArena.h"
#include "Player.h"
#include "TextureHolder.h"
#include "Bullet.h"
#include "Pickup.h"
using namespace sf;
Теперь добавьте новую папку с именем gamedata в папку ZombieArena. Затем создайте в ней новый файл scores.txt. Именно в нем мы будем сохранять рекорд
игрока. Вы можете открыть этот файл и добавить в него счет. Только убедитесь,
что это довольно низкое значение, чтобы мы могли легко проверить, приводит ли
его превышение к сохранению нового рекорда. Не забудьте закрыть файл, иначе
игра не сможет получить к нему доступ.
350 Глава 14. Звуковые эффекты, работа с файлами и завершение игры
В следующем коде мы создадим объект ifstream под названием inputFile
и передадим в него папку и файл, которые мы только что создали, в качестве
параметра для его конструктора.
Блок if(inputFile.is_open()) проверяет, существует ли файл и готов ли он
к чтению. Затем мы помещаем содержимое файла в hiScore и закрываем файл.
Добавьте следующий выделенный код:
// Счет
Text scoreText;
scoreText.setFont(font);
scoreText.setCharacterSize(55);
scoreText.setColor(Color::White);
scoreText.setPosition(20, 0);
// Загружаем рекорд из текстового файла
std::ifstream inputFile("gamedata/scores.txt");
if (inputFile.is_open())
{
// >> Считываем данные
inputFile >> hiScore;
inputFile.close();
}
// Рекорд
Text hiScoreText;
hiScoreText.setFont(font);
hiScoreText.setCharacterSize(55);
hiScoreText.setColor(Color::White);
hiScoreText.setPosition(1400, 0);
std::stringstream s;
s << "Рекорд:" << hiScore;
hiScoreText.setString(s.str());
Теперь мы можем обработать сохранение потенциально нового рекорда. В блоке,
который обрабатывает ситуацию, когда здоровье игрока меньше или равно нулю,
нам нужно создать объект ofstream с именем outputFile, записать значение
hiScore в текстовый файл, а затем закрыть файл, как показано ниже:
// Был ли игрок задет зомби
for (int i = 0; i < numZombies; i++)
{
if (player.getPosition().intersects
(zombies[i].getPosition()) && zombies[i].isAlive())
{
if (player.hit(gameTimeTotal))
{
// Здесь будет больше кода позже
}
if (player.getHealth() <= 0)
{
state = State::GAME_OVER;
std::ofstream outputFile("gamedata/scores.txt");
// << Записываем данные
Подготовка звуковых эффектов 351
outputFile << hiScore;
outputFile.close();
}
}
}// Конец обработки касания игрока
Вы можете сыграть в игру, и ваш рекорд сохранится. Обратите внимание, что при
повторном прохождении ваш рекорд сохраняется.
В следующем разделе мы добавим звуковые эффекты.
Подготовка звуковых эффектов
Здесь мы создадим все объекты SoundBuffer и Sound, которые понадобятся для
работы с различными звуковыми эффектами.
Начните с добавления необходимых директив #include:
#include
#include
#include
#include
#include
#include
#include
#include
#include
<sstream>
<fstream>
<SFML/Graphics.hpp>
<SFML/Audio.hpp>
"ZombieArena.h"
"Player.h"
"TextureHolder.h"
"Bullet.h"
"Pickup.h"
Теперь добавьте семь объектов SoundBuffer и Sound, которые загружают и обрабатывают семь звуковых файлов, которые мы подготовили в главе 8:
// Когда мы в последний раз обновляли HUD?
int framesSinceLastHUDUpdate = 0;
// Когда было последнее обновление
Time TimeSinceLastUpdate;
// Как часто (в кадрах) мы должны обновлять HUD
int fpsMeasurementFrameInterval = 1000;
// Подготавливаем звук удара зомби по герою
SoundBuffer hitBuffer;
hitBuffer.loadFromFile("sound/hit.wav");
Sound hit;
hit.setBuffer(hitBuffer);
// Подготавливаем звук попадания пули в зомби
SoundBuffer splatBuffer;
splatBuffer.loadFromFile("sound/splat.wav");
Sound splat;
splat.setBuffer(splatBuffer);
// Подготавливаем звук выстрела
SoundBuffer shootBuffer;
shootBuffer.loadFromFile("sound/shoot.wav");
Sound shoot;
shoot.setBuffer(shootBuffer);
352 Глава 14. Звуковые эффекты, работа с файлами и завершение игры
// Подготавливаем звук перезарядки
SoundBuffer reloadBuffer;
reloadBuffer.loadFromFile("sound/reload.wav");
Sound reload;
reload.setBuffer(reloadBuffer);
// Подготавливаем звук неудачной перезарядки
SoundBuffer reloadFailedBuffer;
reloadFailedBuffer.loadFromFile("sound/reload_failed.wav");
Sound reloadFailed;
reloadFailed.setBuffer(reloadFailedBuffer);
// Подготавливаем звук "прокачки"
SoundBuffer powerupBuffer;
powerupBuffer.loadFromFile("sound/powerup.wav");
Sound powerup;
powerup.setBuffer(powerupBuffer);
// Подготавливаем звук подбора предмета
SoundBuffer pickupBuffer;
pickupBuffer.loadFromFile("sound/pickup.wav");
Sound pickup;
pickup.setBuffer(pickupBuffer);
// Основной игровой цикл
while (window.isOpen())
Теперь семь звуковых эффектов готовы к воспроизведению. Осталось разобраться, где в нашем коде будет находиться каждый из вызовов функции play.
Разрешение игроку повышать уровень
и генерация новой волны
В следующем коде мы позволяем игроку повышать уровень между наплывами
зомби. Благодаря уже проделанной работе это легко реализовать.
Добавьте следующий выделенный код в состояние LEVELING_UP, где мы обрабатываем ввод игрока:
// Обрабатываем состояние LEVELING_UP
if (state == State::LEVELING_UP)
{
// Обрабатываем повышение уровня игрока
if (event.key.code == Keyboard::Num1)
{
// Увеличиваем скорострельность
fireRate++;
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num2)
{
// Увеличиваем размер обоймы
clipSize += clipSize;
state = State::PLAYING;
Разрешение игроку повышать уровень и генерация новой волны 353
}
if (event.key.code == Keyboard::Num3)
{
// Увеличиваем показатели здоровья
player.upgradeHealth();
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num4)
{
// Увеличиваем скорость передвижения
player.upgradeSpeed();
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num5)
{
// Улучшаем аптечку
healthPickup.upgrade();
state = State::PLAYING;
}
if (event.key.code == Keyboard::Num6)
{
// Улучшаем боеприпасы
ammoPickup.upgrade();
state = State::PLAYING;
}
if (state == State::PLAYING)
{
Теперь игрок может повышать уровень после каждой волны зомби. Однако мы
пока не можем увеличить количество зомби или размер уровня.
В следующей части состояния LEVELING_UP, сразу после только что добавленного
кода, измените код, который запускается при переходе из состояния LEVELING_UP
в PLAYING.
Ниже приводится полный код. Я выделил строки, которые либо являются новыми, либо были немного изменены:
if (event.key.code == Keyboard::Num6)
{
ammoPickup.upgrade();
state = State::PLAYING;
}
if (state == State::PLAYING)
{
// Увеличиваем номер волны
wave++;
// Подготавливаем уровень
// Позже мы изменим следующие две строки
arena.width = 500 * wave;
arena.height = 500 * wave;
arena.left = 0;
arena.top = 0;
354 Глава 14. Звуковые эффекты, работа с файлами и завершение игры
// Передаем массив вершин по ссылке
// в функцию createBackground
int tileSize = createBackground(background, arena);
// Генерируем игрового персонажа в центре арены
player.spawn(arena, resolution, tileSize);
// Настраиваем бонусные предметы
healthPickup.setArena(arena);
ammoPickup.setArena(arena);
// Создаем орду зомби
numZombies = 5 * wave;
// Очищаем ранее выделенную память (если она существует)
delete[] zombies;
zombies = createHorde(numZombies, arena);
numZombiesAlive = numZombies;
// Воспроизводим звук "прокачки"
powerup.play();
// Сбрасываем таймер, чтобы избежать скачка кадра
clock.restart();
}
}// Конец обработки повышения уровня
Код начинается с увеличения переменной wave. Затем в код вносятся изменения, чтобы количество зомби и размер арены зависели от нового значения wave.
Это также полезно, потому что игра, вероятно, была сложноватой с десятью
зомби на небольшой площади. Теперь она будет начинаться с пяти. Наконец,
мы добавляем вызов powerup.play(), чтобы воспроизвести звуковой эффект
«прокачки героя».
Перезапуск игры
Мы уже определили размер арены и количество зомби, используя переменную wave. Мы также должны сбросить переменные, связанные с патронами и оружием, и обнулить значения переменных wave и score в начале новой игры.
Найдите следующий код в разделе обработки событий игрового цикла и добавьте
выделенный код, как показано здесь:
// Начало новой игры в состоянии GAME_OVER
else if (event.key.code == Keyboard::Return &&
state == State::GAME_OVER)
{
state = State::LEVELING_UP;
wave = 0;
score = 0;
// Подготавливаем оружие и патроны для новой игры
currentBullet = 0;
bulletsSpare = 24;
bulletsInClip = 6;
Перезапуск игры 355
}
clipSize = 6;
fireRate = 1;
// Сбрасываем статистику игрока
player.resetPlayerStats();
Теперь игроки могут участвовать в игре, становясь все более могущественными
по мере увеличения числа зомби на постоянно расширяющейся арене. Игра продолжается до тех пор, пока герой не погибнет, после чего начинается заново.
Воспроизведение остальных звуков
Добавим остальные вызовы функции play. Мы рассмотрим каждый из них по
отдельности, поскольку точное определение их места в коде имеет решающее
значение для их воспроизведения в нужный момент.
Звуковые эффекты при перезарядке
Добавьте следующий выделенный код в три конкретных места, чтобы воспроизвести соответствующий звук успешной или неудачной перезарядки, когда игрок
нажимает клавишу R:
Tif (state == State::PLAYING)
{
// Перезарядка
if (event.key.code == Keyboard::R)
{
if (bulletsSpare >= clipSize)
{
// Много патронов. Перезаряжаем
bulletsInClip = clipSize;
bulletsSpare -= clipSize;
reload.play();
}
else if (bulletsSpare > 0)
{
// Осталось немного патронов
bulletsInClip = bulletsSpare;
bulletsSpare = 0;
reload.play();
}
else
{
// Здесь будет больше кода позже
reloadFailed.play();
}
}
}
356 Глава 14. Звуковые эффекты, работа с файлами и завершение игры
Теперь игрок будет получать звуковой отклик при перезарядке или попытке
перезарядить оружие. Перейдем к воспроизведению звука выстрела.
Звук выстрела
Добавьте следующий выделенный вызов shoot.play() в конец кода, который
обрабатывает нажатие игроком левой кнопки мыши:
// Обработка выстрела
if (sf::Mouse::isButtonPressed(sf::Mouse::Left))
{
if (gameTimeTotal.asMilliseconds()
- lastPressed.asMilliseconds()
> 1000 / fireRate && bulletsInClip > 0)
{
// Передаем центр игрового персонажа и прицела в функцию shoot
bullets[currentBullet].shoot(
player.getCenter().x, player.getCenter().y,
mouseWorldPosition.x, mouseWorldPosition.y);
currentBullet++;
if (currentBullet > 99)
{
currentBullet = 0;
}
lastPressed = gameTimeTotal;
shoot.play();
bulletsInClip--;
}
}// Конец обработки выстрела
Теперь игра будет воспроизводить звук стрельбы. Далее мы добавим звук удара
зомби по игровому персонажу.
Звук при ударе по игровому персонажу
В следующем коде мы обернули вызов функции hit.play в цикл, проверя
ющий, возвращает ли функция player.hit значение true. Помните, что функция
player.hit проверяет, был ли зарегистрирован удар за последние 100 миллисекунд. Это позволит быстро воспроизвести повторяющийся звук удара таким
образом, чтобы он не превратился в сплошной шум.
Добавьте вызов hit.play, как выделено в следующем коде:
// Был ли герой задет зомби
for (int i = 0; i < numZombies; i++)
{
if (player.getPosition().intersects
(zombies[i].getPosition()) && zombies[i].isAlive())
{
if (player.hit(gameTimeTotal))
Перезапуск игры 357
{
// Здесь будет больше кода позже
hit.play();
}
if (player.getHealth() <= 0)
{
state = State::GAME_OVER;
std::ofstream OutputFile("gamedata/scores.txt");
OutputFile << hiScore;
OutputFile.close();
}
}
}// Конец обработки касания игрового персонажа
Игрок услышит зловещий звук удара зомби, и этот звук будет повторяться около
пяти раз в секунду, если зомби продолжит бить нашего героя. Логика для этого
содержится в функции hit класса Player.
Звук при подборе предмета
Когда игровой персонаж подбирает аптечку, мы воспроизводим «обычный» звук.
Однако когда он подбирает боеприпасы, мы воспроизводим звук перезарядки.
Добавьте два вызова для воспроизведения звуков в соответствующий код обнаружения коллизий:
// Коснулся ли игровой персонаж аптечки
if (player.getPosition().intersects
(healthPickup.getPosition()) && healthPickup.isSpawned())
{
player.increaseHealthLevel(healthPickup.gotIt());
// Воспроизводим звук
pickup.play();
}
// Коснулся ли игровой персонаж боеприпасов
if (player.getPosition().intersects
(ammoPickup.getPosition()) && ammoPickup.isSpawned())
{
bulletsSpare += ammoPickup.gotIt();
// Воспроизводим звук
reload.play();
}
Звук попадания пули по зомби
Добавьте вызов splat.play в конце фрагмента кода, который обнаруживает столкновение пули с зомби:
// Был ли зомби подстрелен?
for (int i = 0; i < 100; i++)
{
358 Глава 14. Звуковые эффекты, работа с файлами и завершение игры
for (int j = 0; j < numZombies; j++)
{
if (bullets[i].isInFlight() &&
zombies[j].isAlive())
{
if (bullets[i].getPosition().intersects
(zombies[j].getPosition()))
{
// Останавливаем пулю
bullets[i].stop();
// Регистрируем попадание и проверяем, было ли это убийством
if (zombies[j].hit()) {
// Не просто попадание, но и убийство
score += 10;
if (score >= hiScore)
{
hiScore = score;
}
numZombiesAlive--;
// Когда все зомби мертвы (снова)
if (numZombiesAlive == 0) {
state = State::LEVELING_UP;
}
}
// Воспроизводим звук попадания пули по зомби
splat.play();
}
}
}
}// Конец обработки подстреленного зомби
Теперь вы можете полноценно поиграть и понаблюдать, как с каждой волной
увеличиваются количество зомби и площадь арены. Не забывайте при этом выбирать улучшения.
Поздравляю!
Резюме
Мы завершили работу над игрой Zombie Arena. Это было довольно долгое
и увлекательное путешествие. Мы изучили целый ряд основных концепций
C++, таких как ссылки, указатели, ООП и классы. Кроме того, мы применили
библиотеку SFML для управления областями отображения, массивами вершин
и обнаружением коллизий. Мы узнали, как использовать спрайт-листы, чтобы
уменьшить количество вызовов window.draw и повысить частоту кадров. С помощью указателей C++, STL и ООП мы создали класс-синглтон для управления
нашими текстурами.
Часто задаваемые вопросы 359
Часто задаваемые вопросы
В. Несмотря на использование классов, я замечаю, что код становится очень
длинным и неуправляемым.
О. Одна из самых больших проблем — это организация нашего кода. По мере
изучения C++ мы также узнаем способы сделать код более управляемым и в целом менее объемным. Мы сделаем это в следующем, финальном проекте. К концу
книги вы будете знать о нескольких стратегиях, которые можно применить для
управления кодом.
В. Звуковые эффекты кажутся немного плоскими и нереалистичными. Как их
можно улучшить?
О. Один из способов значительно улучшить восприятие звука игроком — сделать
его направленным. Вы также можете изменять громкость в зависимости от расстояния от источника звука до игрового персонажа. В следующем проекте мы
воспользуемся расширенными возможностями библиотеки SFML для работы со
звуком. Еще один распространенный прием — каждый раз менять тональность
звука выстрела, чтобы сделать его более реалистичным и менее монотонным.
15
Игра Run
Добро пожаловать в финальный проект! Run — бесконечный раннер, где игровому персонажу нужно бежать, по пути стараясь удержаться на платформах.
В данном проекте мы изучим множество новых техник программирования
игр и еще больше концепций C++ для их реализации. Пожалуй, самое главное
улучшение этой игры по сравнению с предыдущими заключается в том, что
она будет гораздо более объектно-ориентированной, чем все остальные. В ней
будет множество классов, но большинство файлов кода для этих классов будут
короткими и несложными.
Более того, мы создадим игру, в которой функциональность и внешний вид
всех внутриигровых объектов будут вынесены в классы, что позволит оставлять
основной игровой цикл неизменным, независимо от того, что делают игровые
объекты. Благодаря этому можно разрабатывать разнообразные игры, просто
создавая новые отдельные компоненты (классы), описывающие поведение
и внешний вид необходимых игровых объектов. Это значит, что вы можете использовать одну и ту же структуру кода для создания совершенно разных игр.
Готовый код для этой главы можно найти в папке Run.
Вот что нас ждет в текущей главе:
zzописание того, что именно представляет собой игра и как в нее играть;
zzсоздание проекта обычным способом и написание самой простой функции
main за всю книгу;
zzобсуждение и написание нового способа обработки ввода игрока путем
делегирования определенных обязанностей отдельным игровым сущностям
(объектам) и прослушивания сообщений от нового класса InputDispatcher;
zzразработка класса Factory , который будет отвечать за «знание» того, как
собирать все различные компоненты, которые мы создадим, в рабочие экземпляры GameObject;
zzизучение умных указателей C++, позволяющих передать ответственность за
управление памятью компилятору;
Об игре 361
zzизучение наследования и полиморфизма в C++;
zzнаписание кода для ключевого класса GameObject (вы не поверите, насколько
это просто);
zzсоздание класса Component, который будет содержать экземпляры GameObject
(опять же, это довольно просто);
zzнаписание кода для классов Graphics и Update , которые будут типами
Component (это станет понятнее, когда мы разберемся с наследованием и поли
морфизмом);
zzв конце главы у нас будет функционирующий игровой цикл, который обрабатывает ввод игрока и отрисовывает пустой экран, готовый к наполнению
контентом, который мы будем разрабатывать до конца книги.
Прежде всего нам нужно понять, что мы собираемся делать. В то же время я представлю все новые концепции программирования игр, которые мы
изучим.
Исходный код этой главы вы найдете в репозитории GitHub: https://github.com/
PacktPublishing/Beginning-C-Game-Programming-Third-Edition/tree/main/Run.
Об игре
Run — это очень простая игра. Фактически это минимальный набор игровых объектов, который я смог придумать, чтобы считать игру полноценной. Я разработал
ее не ради развлекательного геймплея, а чтобы продемонстрировать универсальную систему, которая может пригодиться вам при разработке игр. Считаю, что
данный проект идеально подходит для того, чтобы вы могли добавить новое поведение, правила и способы взаимодействия игрока с игровым миром в собственный дизайн. Или, что еще лучше, после того как вы узнаете, как это работает, вы
сможете разработать совершенно новую игру, используя эту систему.
Система, которую мы построим, представляет собой версию паттерна программирования «Сущность — компонент» (Entity Component). Мы обсудим
его подробнее, когда будем говорить о наследовании и полиморфизме. А пока
посмотрим на игру.
На рис. 15.1 изображено простое игровое меню. Игрок может нажать клавишу
Esc, чтобы начать или приостановить игру, и клавишу F1, чтобы выйти из игры.
Когда игра начинается, в левом верхнем углу экрана запускается таймер. Напомню, цель игры — бежать вперед и продержаться как можно дольше, успевая
запрыгивать на новые платформы, которые постоянно появляются на пути,
и стараясь не упасть при исчезновении платформы под ногами. Если игровой
персонаж упадет, игра закончится и снова появится меню.
362 Глава 15. Игра Run
Рис. 15.1. Меню игры
Взгляните на рис. 15.2: на нем изображен игровой персонаж в центре экрана.
Кажется, что из-под его ног вырывается огонь. Это эффект, показывающий, что
игрок совершает рывок. Кроме того, на этом рисунке вы можете видеть простой
эффект дождя.
Рис. 15.2. Дождь в игре
Если игровой персонаж падает с платформы, можно нажать клавишу W, чтобы
он взмыл вверх. Кроме того, во время рывка персонаж способен перемещаться
влево и вправо, если нажимать клавиши A и D соответственно. Имейте в виду, что
Об игре 363
при рывке горизонтальное перемещение происходит медленнее и исчезающие
платформы будут настигать игрока гораздо быстрее. Бег также осуществляется
с помощью клавиш A и D. Обратите внимание, что вы почти всегда будете бежать
вправо с помощью клавиши D. Пробел используется для прыжков между платформами. Рывок — это краткосрочная экстренная мера, если вы недопрыгнули до
платформы, а не стратегия для победы в игре. Бег и прыжки — быстрые и ведут
к выживанию, рывок вверх — медленнее и ведет к смерти.
На рис. 15.2 вы также видите огненный шар слева от игрового персонажа. Огненные шары сбивают его с ног и вынуждают совершать рывок, чтобы выжить.
Огненные шары появляются случайным образом на протяжении всей игры и могут прилетать как слева, так и справа. Поскольку огненные шары очень быстрые,
игрок получит два предупреждения о приближающейся опасности. Во-первых,
справа или слева от персонажа будет раздаваться ревущий звук (здесь мы используем пространственный звук). Во-вторых, обратите внимание на область
в нижней центральной части экрана, которая показывает игровой мир гораздо
шире, чем основной экран. Игрок сможет взглянуть на эту мини-карту и понять,
находится ли на пути огненный шар.
Посмотрите на рис. 15.3, чтобы увидеть некоторые другие особенности игры.
Вероятно, если вы смотрите на черно-белое изображение в книге, то не совсем
понимаете, о чем идет речь. На этом рисунке есть что-то вроде фона с ночным
городским пейзажем. Фон будет прокручиваться в зависимости от направления
движения игрока, но двигаться гораздо медленнее, чем платформы на переднем
плане. Это называется эффектом параллакса. Он создает впечатление, что вдалеке находится город.
Рис. 15.3. Параллакс
364 Глава 15. Игра Run
На рис. 15.4 мы видим полное изменение внешнего вида фона. С помощью
шейдеров OpenGL мы можем добиться почти фотографического 3D-эффекта
фона.
Рис. 15.4. Игровой шейдер
Удивительно, но мы добавим этот эффект всего несколькими строчками кода
на C++, хотя сам шейдер — довольно сложная вещь, которую мы скачаем со
специализированного сайта. Мы изучим, как работают шейдеры, как их использовать и что они собой представляют, но мы не будем углубляться в их
создание.
Создание проекта
Для начала создайте новый проект, поместите его в папку VS Projects, присвойте ему имя Run и скопируйте в папку проекта папки fonts, graphics, music,
shaders и sound вместе с их содержимым. Мы обсудим содержимое этих папок
позднее. Если сравнивать с предыдущими проектами, то здесь в игровых ресурсах есть несколько существенных различий: шейдеры, музыка, а также тот
факт, что в папке graphics располагается всего один файл, содержащий все
визуальные эффекты. Файлы в папке shaders — это пустые файлы-заглушки,
готовые к тому, что позже в проект будет скопирован и вставлен какой-нибудь
код.
Создание проекта 365
Я создал рабочий проект для всех глав, чтобы вы могли посмотреть, как должен
выглядеть код к концу каждой главы. Вы увидите папки проекта с названиями
Run, Run2, Run3 и т. д. То есть вы можете найти готовый код для этой главы в папке Run.
Самое главное — оставшаяся часть книги построена так, что вам не придется
создавать новый проект для каждой главы!
Настройте свойства проекта, как мы делали это ранее. Далее следует краткое
напоминание о том, как это сделать. За изображениями и более подробной информацией обратитесь к главе 1. Теперь выполните следующие шаги.
1. Для настройки проекта на использование файлов SFML, которые мы поместили в папку SFML, в главном меню выберите ProjectRun properties (Про
ектСвойства Run). На этом этапе у вас должно быть открыто окно Run
Property Pages (Страницы свойств проекта Run).
2. В этом окне выберите All Configurations (Все конфигурации) в поле Configuration
(Конфигурация) и убедитесь, что в раскрывающемся списке Platform (Платформа) установлено значение Win32, а не x64.
3. Далее в меню слева выберите C/C++, а затем General (Общие).
4. Найдите поле Additional Include Directories (Дополнительные каталоги включаемых файлов) и введите букву диска, на котором находится ваша папка
SFML, а затем \SFML\include. Если ваша папка SFML располагается на диске D,
то полный путь будет D:\SFML\include. Измените путь, если вы разместили
папку SFML на другом диске.
5. Теперь, не закрывая окно, выберите Linker (Компоновщик), а затем General
(Общие).
6. Найдите поле редактирования Additional Library Directories (Дополнительные каталоги библиотек) и введите букву диска, на котором находится ваша папка
SFML, а затем \SFML\lib. Таким образом, если вы разместили папку SFML на
диске D, то полный путь будет выглядеть так: D:\SFML\lib. Измените путь,
если файлы SFML располагаются на другом диске.
7. Выберите Linker (Компоновщик), а затем Input (Ввод).
8. Найдите поле редактирования Additional Dependencies (Дополнительные зависимости), щелкните на нем в крайнем левом углу и наберите следующий текст: sfml-
graphics-d.lib;sfml-window-d.lib;sfmlsystem-d.lib;sfml-network-d.lib;
sfml-audio-d.lib;. Будьте внимательны: курсор нужно расположить точно
в начале текущего содержимого поля редактирования, чтобы не перезаписать
уже имеющийся там текст.
366 Глава 15. Игра Run
9. Нажмите кнопку OK.
10. Нажмите кнопку Apply (Применить), а затем OK.
11. В главном окне Visual Studio убедитесь, что в списке Debug выбрано x86, а не
x64.
12. Наконец, скопируйте все файлы sfml-...-d-2.dll в каталог проекта, где ... —
ссылка на audio, graphics, network, system и window.
Теперь мы перейдем к коду на C++.
Создание функции main
Ниже приведен весь код функции main. Это также весь игровой цикл. Здесь нет
обнаружения коллизий, логики паузы, запуска или остановки игры, спрайтов или
текстур, шрифтов или звуков — только одна строка связана с обработкой ввода.
В этом проекте практически все будет представлено в виде игровых объектов:
камеры, огненные шары, платформы, игровой персонаж, меню и даже игровая
логика и дождь. Как именно это реализуется, будет объяснено по ходу проекта.
А пока давайте немного поработаем с кодом.
Добавьте следующий код в файл run.cpp вашего проекта:
#pragma once
#include "SFML/Graphics.hpp"
#include <vector>
#include "GameObject.h"
#include "Factory.h"
#include "InputDispatcher.h"
using namespace std;
using namespace sf;
int main()
{
// Создаем полноэкранное окно
RenderWindow window(
VideoMode::getDesktopMode(),
"Booster", Style::Fullscreen);
// VertexArray для хранения всех изображений
VertexArray canvas(Quads, 0);
// Этот объект может отправлять события любому объекту
InputDispatcher inputDispatcher(&window);
// Все будет игровым объектом
// Этот вектор будет содержать их все
vector <GameObject> gameObjects;
// Этот класс обладает всем необходимым для создания
// игровых объектов, которые выполняют множество разных задач
Factory factory(&window);
Создание функции main 367
// Этот вызов передаст вектор игровых объектов, холст для отрисовки
// и диспетчер ввода в фабрику для настройки игры
factory.loadLevel(gameObjects,
canvas,
inputDispatcher);
// Часы для отслеживания времени
Clock clock;
// Цвет, который мы используем для фона
const Color BACKGROUND_COLOR(100, 100, 100, 255);
// Это игровой цикл.
// Нам не нужно его дополнять.
// Посмотрите, как все просто и коротко!
while (window.isOpen())
{
// Измеряем время, затраченное на этот кадр
float timeTakenInSeconds =
clock.restart().asSeconds();
// Обрабатываем ввод игрока
inputDispatcher.dispatchInputEvents();
// Очищаем предыдущий кадр
window.clear(BACKGROUND_COLOR);
// Обновляем все игровые объекты
for (auto& gameObject : gameObjects)
{
gameObject.update(timeTakenInSeconds);
}
// Отрисовываем все игровые объекты на холсте
for (auto& gameObject : gameObjects)
{
gameObject.draw(canvas);
}
// Показываем новый кадр
window.display();
}
}
return 0;
Обратите внимание, что в Visual Studio отображаются три ошибки. Это связано
с тем, что мы ссылаемся на три класса, которые еще не существуют. Отсутствующие классы — это InputDispatcher, GameObject и Factory. Мы скоро напишем
эти классы, но сначала давайте обсудим код, а точнее его отсутствие.
Код начинается с обычной директивы include библиотеки SFML и еще одной для класса vector . Это подразумевает, что в нашем коде будет вектор.
368 Глава 15. Игра Run
Мы воспользуемся вектором для хранения всех наших игровых объектов. Кроме
того, у нас есть три директивы include для GameObject, Factory и InputDispatcher,
которые будут вызывать ошибки до тех пор, пока мы не напишем эти классы:
#pragma once
#include "SFML/Graphics.hpp"
#include <vector>
#include "GameObject.h"
#include "Factory.h"
#include "InputDispatcher.h"
using namespace std;
using namespace sf;
Обратите внимание на первую часть функции main:
int main()
{
// Создаем полноэкранное окно
RenderWindow window(
VideoMode::getDesktopMode(),
"Booster", Style::Fullscreen);
// VertexArray для хранения всех изображений
VertexArray canvas(Quads, 0);
// Этот объект может отправлять события любому объекту
InputDispatcher inputDispatcher(&window);
// Все будет игровым объектом
// Этот вектор будет содержать их все
vector <GameObject> gameObjects;
// Этот класс обладает всем необходимым для создания
// игровых объектов, которые выполняют множество различных задач
Factory factory(&window);
// Этот вызов передаст вектор игровых объектов, холст для отрисовки
// и диспетчер ввода в фабрику для настройки игры
factory.loadLevel(gameObjects,
canvas,
inputDispatcher);
// Часы для отслеживания времени
Clock clock;
// Цвет, который мы используем для фона
const Color BACKGROUND_COLOR(100, 100, 100, 255);
В приведенном коде мы создали экземпляр RenderWindow, как и во всех наших
проектах прежде, а также экземпляр VertexArray с именем canvas. Мы называем
Создание функции main 369
его canvas, потому что он будет буквально холстом (canvas в переводе с англ.
«холст») для всей игры. В данном случае все игровые объекты добавляются
в VertexArray каждый кадр игры, а затем применяется canvas для отрисовки.
Далее мы объявляем экземпляр нашего будущего класса InputDispatcher. Скоро
мы увидим его в действии в основном игровом цикле. Пока же просто обратите
внимание, что мы передаем адрес RenderWindow в его конструктор. Далее мы
объявляем вектор экземпляров GameObject. Как уже говорилось, каждая сущность в нашей игре будет содержаться в экземпляре GameObject. Как именно
это возможно, мы узнаем по ходу работы. Следующие две строки кода объявляют экземпляр нашего будущего класса Factory (который также получает
указатель на RenderWindow), а затем вызывается factory.loadLevel. Функция
loadLevel требует вектор игровых объектов, «холст» для рисования и экземпляр
InputDispatcher. Класс Factory будет частью нашего игрового движка, которая
собирает множество экземпляров GameObject в правильном порядке, а затем помещает их в вектор, готовый к использованию в игровом цикле.
Наконец, в коде, который мы сейчас рассматриваем, мы объявляем часы для обработки времени обновления и цвет для временного фона.
Взгляните еще раз на главный цикл:
while (window.isOpen())
{
// Измеряем время, затраченное на этот кадр
float timeTakenInSeconds =
clock.restart().asSeconds();
// Обрабатываем ввод игрока
inputDispatcher.dispatchInputEvents();
// Очищаем предыдущий кадр
window.clear(BACKGROUND_COLOR);
// Обновляем все игровые объекты
for (auto& gameObject : gameObjects)
{
gameObject.update(timeTakenInSeconds);
}
// Отрисовываем все игровые объекты на холсте
for (auto& gameObject : gameObjects)
{
gameObject.draw(canvas);
}
}
// Показываем новый кадр
window.display();
370 Глава 15. Игра Run
В данном коде представлен обычный цикл while, который постоянно обновляет и отрисовывает игровые объекты до тех пор, пока окно не будет закрыто.
Длительность цикла фиксируется в переменной timeTakenInSeconds, а затем мы
видим нечто новое.
Экземпляр inputDispatcher вызывает функцию dispatchInputEvents. Внутри
этой функции, которую мы вскоре напишем, все события ввода передаются
любому игровому объекту, который ранее объявил о своей заинтересованности. Класс Factory отвечает за то, чтобы игровые объекты могли подключаться
к inputDispatcher, а затем каждый игровой объект обрабатывает те входные
данные, которые его интересуют. Таким образом, игровой персонаж будет обрабатывать движение, меню — паузу, запуск и выход из игры, а игровой объект
камеры — прокрутку колеса мыши для увеличения или уменьшения масштаба
мини-карты.
Затем следуют два цикла for, которые перебирают все игровые объекты в векторе, сначала вызывая update , а затем draw . Как только холст обновляется,
window.display() показывает всю игру в ее текущем состоянии.
Затем функция main завершается:
return 0;
}
Теперь, когда мы увидели, к чему стремимся, давайте напишем два класса, чтобы
заставить работать новую, более гибкую систему ввода.
Обработка ввода
В предыдущем коде вы заметите явную нехватку кода обработки ввода. Это связано с тем, что каждый игровой объект отвечает за обработку собственных событий ввода. Наиболее заметным является игровой объект, связанный с игроком,
который будет обрабатывать ввод для движения.
Игровой объект, связанный с меню, будет управлять запуском, приостановкой
и выходом из игры, а объект камеры — мини-картой, которую игрок сможет приближать и отдалять. На рис. 15.5 продемонстрирована эта схема.
Для достижения этого мы создадим класс InputDispatcher. Он, как мы видели
в функции main, будет содержать один экземпляр, получающий все события ввода
от операционной системы и затем передающий их нескольким экземплярам класса
InputReceiver, которые заранее регистрируются в экземпляре InputDispatcher
во время выполнения функции loadLevel в классе Factory, перед началом основного игрового цикла. Все экземпляры InputReceiver будут находиться внутри соответствующего игрового объекта, знающего, какие события ввода отслеживать
и как их обрабатывать.
Обработка ввода 371
Рис. 15.5. Схема обработки ввода
Создайте новый класс с именем InputDispatcher и добавьте следующий код
в InputDispatcher.h:
#pragma once
#include "SFML/Graphics.hpp"
#include "InputReceiver.h"
using namespace sf;
class InputDispatcher
{
private:
RenderWindow* m_Window;
vector <InputReceiver*> m_InputReceivers;
public:
InputDispatcher(RenderWindow* window);
void dispatchInputEvents();
void registerNewInputReceiver(InputReceiver* ir);
};
Не обращайте внимания на ошибку. Как мы уже говорили, она возникает из-за
того, что мы ссылаемся на класс InputReceiver, который еще не существует.
В приведенном коде мы объявляем указатель на экземпляр RenderWindow и вектор указателей InputReceiver. Каждый кадр этого вектора будет итерироваться,
372 Глава 15. Игра Run
а ввод, полученный окном, — передаваться. У нас есть три функции: конструктор,
который установит класс; функция dispatchEvents, вызываемая из игрового цикла каждый кадр; функция registerNewInputReceiver, добавляющая экземпляры
InputReceiver в вектор указателей InputReceiver.
Конечно, когда мы увидим реализацию этих функций, все станет намного понятнее. Добавьте следующий код в InputDispatcher.cpp:
#include "InputDispatcher.h"
InputDispatcher::InputDispatcher(RenderWindow* window)
{
m_Window = window;
}
void InputDispatcher::dispatchInputEvents()
{
sf::Event event;
while (m_Window->pollEvent(event))
{
//if (event.type == Event::KeyPressed &&
// event.key.code == Keyboard::Escape)
//{
// m_Window->close();
//}
for (const auto& ir : m_InputReceivers)
{
ir->addEvent(event);
}
}
}
void InputDispatcher::registerNewInputReceiver(InputReceiver* ir)
{
m_InputReceivers.push_back(ir);
}
Опять же, в классе есть несколько ошибок, связанных с отсутствием класса
InputReceiver.
В приведенном коде конструктор инициализирует указатель на RenderWindow.
В функции dispatchInputEvents экземпляр RenderWindow используется для опроса всех событий так же, как мы делали это во всех предыдущих проектах. Затем
все экземпляры InputDispatcher итерируются в векторе, и их функции addEvent
вызываются с последним событием. В этой функции есть закомментированный
код, которым мы воспользуемся позже. Функция registerNewInputReceiver
позволяет вызывающему ее коду передать указатель на InputReceiver и, таким
образом, получить все обновления. Помните, что класс Factory получает экземпляр InputDispatcher, когда вызывается его функция loadLevel. Функция
loadLevel создаст все экземпляры InputReceiver, зарегистрирует их с помощью
Обработка ввода 373
функции register... и поместит экземпляры InputReceiver в соответствующие
экземпляры GameObject.
Напишем код для InputReceiver, чтобы увидеть другую сторону этой системы.
Создайте новый класс с именем InputReceiver.h и поместите в него следующий
код:
#pragma once
#include <SFML/Graphics.hpp>
using namespace sf;
using namespace std;
class InputReceiver
{
private:
vector<Event> mEvents;
public:
void addEvent(Event event);
vector<Event>& getEvents();
void clearEvents();
};
Обратите внимание, что все ошибки в классе InputDispatcher исчезли.
У нас есть вектор событий SFML, готовый принимать события ввода в каждом кадре от диспетчера ввода. Функция addEvent принимает новое событие,
getEvents возвращает весь вектор, заполненный событиями, а clearEvents очищает вектор, чтобы события из предыдущих итераций не накапливались со временем, а присутствовали только последние события из текущего цикла.
Добавьте следующий код в файл InputReceiver.cpp:
#include "InputReceiver.h"
void InputReceiver::addEvent(Event event)
{
mEvents.push_back(event);
}
vector<Event>& InputReceiver::getEvents()
{
return mEvents;
}
void InputReceiver::clearEvents()
{
mEvents.clear();
}
В этом коде функция addEvent использует pushback, чтобы добавить экземпляр
Event в вектор. Функция getEvents возвращает весь вектор вызывающему коду.
Наконец, функция clearEvents очищает вектор, чтобы он был готов к приему
новых событий в следующей итерации игрового цикла. В следующей главе мы
374 Глава 15. Игра Run
увидим, что соответствующие классы будут содержать экземпляр InputReceiver
и вызывать каждую из этих функций по очереди.
Теперь напишем первую версию класса Factory.
Создание класса Factory
Создайте новый класс с именем Factory. В файле Factory.h добавьте следующий
код:
#pragma once
#include <vector>
#include "GameObject.h"
#include "SFML/Graphics.hpp"
using namespace sf;
using namespace std;
class InputDispatcher;
class Factory
{
private:
RenderWindow* m_Window;
public:
Factory(RenderWindow* window);
void loadLevel(
vector <GameObject>& gameObjects,
VertexArray& canvas,
InputDispatcher& inputDispatcher);
Texture* m_Texture;
};
Здесь объявлен класс Factory, а также приватный указатель на RenderWindow.
Обратите внимание, что он будет инициализирован для указания на один
и тот же экземпляр RenderWindow, что и в функции main, а также на тот же экземпляр RenderWindow из класса InputDispatcher. Есть три функции: конструктор,
который получает адрес RenderWindow; функция loadLevel, принимающая вектор
экземпляров GameObject; массив вершин VertexArray для отрисовки и указатель
на экземпляр InputDispatcher. Мы также объявляем экземпляр Texture библио
теки SFML. Взгляните на следующие строки кода. Они взяты из функции main
и приведены здесь в качестве напоминания о том, как вызываются конструктор
Factory и функция loadLevel.
Factory factory(&window);
factory.loadLevel(gameObjects,
canvas,
inputDispatcher);
После этого краткого напоминания перейдем к коду функций класса Factory.
Добавьте следующий код в файл Factory.cpp:
Продвинутое ООП: наследование и полиморфизм 375
#include "Factory.h"
#include <iostream>
using namespace std;
Factory::Factory(RenderWindow* window)
{
m_Window = window;
m_Texture = new Texture();
if (!m_Texture->loadFromFile("graphics/texture.png"))
{
cout << "Текстура не загружена";
return;
}
}
void Factory::loadLevel(
vector<GameObject>& gameObjects,
VertexArray& canvas,
InputDispatcher& inputDispatcher)
{
}
В приведенном выше конструкторе мы инициализируем указатель RenderWindow,
чтобы всегда иметь к нему доступ из этого класса, в частности из функции
loadLevel. Кроме того, мы загружаем файл .png в экземпляр текстуры. Данный
файл содержит все графические ресурсы для всех игровых объектов. В следующей главе обсудим, почему мы поступаем именно так. Если коротко, то отрисовывать один VertexArray гораздо быстрее, чем десятки экземпляров Sprite
библиотеки SFML, как мы делали раньше.
Функция loadLevel пока остается пустой. Наша цель — избавиться от ошибок
в коде к концу главы, чтобы затем уверенно двигаться вперед с каждой новой
главой.
В нашем коде все еще есть несколько ошибок, но все они связаны с отсутствием
класса GameObject. Чтобы исправить это, нам нужно глубже изучить C++. Далее
мы рассмотрим современный способ работы с указателями, а также некоторые
более продвинутые концепции ООП. Следующие два раздела подготовят нас
к созданию класса GameObject, после чего приведенный выше код сможет работать без ошибок.
Продвинутое ООП: наследование
и полиморфизм
В данном разделе мы познакомимся с более сложными концепциями ООП —
наследованием и полиморфизмом. Эти новые знания помогут нам реализовать
игровые объекты и компоненты нашей игры.
376 Глава 15. Игра Run
Наследование
Мы уже видели, как можно использовать сторонний код, инстанцируя объекты
из классов библиотеки SFML. Но ООП позволяет нам пойти еще дальше.
Что, если есть класс, который содержит множество полезных функций, но не совсем подходит для наших нужд? В такой ситуации мы можем унаследовать подобный класс. Как видно из названия, наследование означает, что мы можем использовать все возможности и преимущества сторонних классов, включая инкапсуляцию,
и при этом адаптировать или расширять код под наши задачи. В данном проекте
мы будем наследовать и расширять некоторые из собственных классов.
Посмотрим пример кода, в котором используется наследование.
Расширение класса
Рассмотрим на примере класса, как мы можем расширить его.
Сначала мы определяем класс, от которого будем наследоваться. Это ничем
не отличается от того, как мы создавали другие классы. Взгляните на объявление
гипотетического класса Soldier:
class Soldier
{
private:
// Сколько урона может выдержать солдат
int m_Health;
int m_Armour;
int m_Range;
int m_ShotPower;
public:
void
void
void
void
};
setHealth(int h);
setArmour(int a);
setRange(int r);
setShotPower(int p);
В приведенном коде мы определили класс Soldier. У него есть четыре приватные переменные: m_Health, m_Armour, m_Range и m_ShotPower. Он также включает
четыре публичные функции: setHealth, setArmour, setRange и setShotPower. Нам
не нужно видеть определения этих функций, они просто инициализируют соответствующую переменную, исходя из их названия.
Мы также можем представить, что полностью реализованный класс Soldier будет гораздо более детальным. В нем, вероятно, будут такие функции, как shoot
и goProne. Если бы мы реализовали класс Soldier в SFML-проекте, он мог бы
иметь объект Sprite, а также функции update и getPosition.
Продвинутое ООП: наследование и полиморфизм 377
Простой сценарий, который мы представили здесь, подходит для изучения наследования. Теперь посмотрим на кое-что новое: наследование от класса Soldier.
Посмотрите на следующий код, особенно на выделенную часть:
class Sniper : public Soldier
{
public:
// Конструктор, специфичный для Sniper
Sniper::Sniper();
};
Добавив : public Soldier в объявление класса Sniper, мы указываем, что Sniper
наследует от Soldier. Но что это значит? Sniper — это Soldier. У него есть все
переменные и функции Soldier. Однако наследование — это нечто большее.
Обратите также внимание, что в предыдущем коде мы объявили конструктор
Sniper. Этот конструктор уникален для Sniper. Мы не только унаследовали его
от Soldier, но и расширили. Вся функциональность (определения) класса Soldier
будет обрабатываться классом Soldier, но определение конструктора Sniper
должно быть обработано классом Sniper.
Вот как может выглядеть гипотетическое определение конструктора Sniper:
// В Sniper.cpp
Sniper::Sniper()
{
setHealth(10);
setArmour(10);
setRange(1000);
setShotPower(100);
}
Мы можем написать множество других классов, которые являются расширениями класса Soldier , например Commando и Infantryman . У каждого из
них могут быть не только точно такие же переменные и функции, но и уни
кальный конструктор, который инициализирует эти переменные в соответствии с конкретным типом Soldier . У Commando могут быть очень высокие
m_Health и m_ShotPower , но очень маленький m_Range , а Infantryman может
быть чем-то между Commando и Sniper с усредненными значениями для каждой
переменной.
Теперь благодаря ООП мы можем моделировать объекты реального мира, включая их иерархии. Для этого потребуется создавать подклассы, расширять и наследовать другие классы.
Терминология, которую нам стоит усвоить, такова: класс, который расширяется, — это суперкласс, а класс, который наследуется от суперкласса, — это подкласс.
Мы также можем говорить о родительском и дочернем классах.
378 Глава 15. Игра Run
ПРИМЕЧАНИЕ
Возможно, вы задаетесь вопросом: а зачем нужно наследование? Дело в том, что
мы можем написать общий код один раз, обновить его в родительском классе, и все
классы, которые наследуют от него, также обновятся. Кроме того, подкласс может
использовать только публичные и защищенные переменные и функции экземпляра.
Таким образом, при правильном проектировании это также способствует достижению
целей инкапсуляции.
«Защищенные»? Да. Для переменных и функций класса существует спецификатор доступа, который называется protected. Можно считать, что переменные
protected — это что-то среднее между public и private. Вот краткое описание
спецификаторов доступа, а также более подробная информация о спецификаторе
protected:
zzпубличные (public) переменные и функции могут быть доступны и использо-
ваны кем угодно, у кого есть экземпляр класса;
zzприватные (private) переменные и функции могут быть доступны (использованы) только внутреннему коду класса, но не напрямую из экземпляра. Это
хорошо для инкапсуляции, и когда нам нужно получить доступ или изменить
приватные переменные, мы можем предоставить публичные геттер- и сеттерфункции (например, getSprite). Если мы расширяем класс, у которого есть
приватные переменные и функции, то дочерний класс не может напрямую
получить доступ к приватным данным своего родителя;
zzзащищенные (protected) переменные и функции — это почти то же самое, что
и приватные. Они не могут быть доступны напрямую из экземпляра класса.
Однако могут быть использованы любым классом, который расширяет класс,
в котором они объявлены. Таким образом, они как бы являются частными, за
исключением дочерних классов.
Чтобы лучше понять, что такое защищенные переменные и функции и как они
могут быть полезны, давайте сначала рассмотрим другую тему ООП.
Полиморфизм
Полиморфизм позволяет писать код, менее зависимый от типов, с которыми
мы работаем. Наш код становится более понятным и эффективным. Полиморфизм означает «множество форм». Если объекты, которые мы программируем,
могут быть более чем одного типа, то мы можем воспользоваться этим преимуществом.
Продвинутое ООП: наследование и полиморфизм 379
ПРИМЕЧАНИЕ
Если свести полиморфизм к простейшему определению, то он означает следующее:
любой подкласс может быть использован как часть кода, использующего суперкласс.
То есть мы можем писать понятный, легко модифицируемый код. Кроме того, мы
можем писать код для суперкласса и быть уверенными, что независимо от того,
сколько раз он будет унаследован, в рамках определенных параметров код все равно
останется рабочим.
Рассмотрим пример. Предположим, мы хотим применить принципы полиморфизма при разработке игры по управлению зоопарком, где мы должны ухаживать за животными. Вероятно, нам понадобится некая функция feed. Мы также
можем захотеть передать в функцию feed экземпляр животного, которого нужно
накормить.
В зоопарке, конечно же, есть много животных, таких как львы, слоны и трехпалые ленивцы. С нашими новыми знаниями о наследовании в C++ имеет смысл
написать класс Animal, от которого наследуются все различные виды животных.
Если мы хотим написать функцию (feed ), в которую в качестве параметра
можно передать Lion, Elephant и ThreeToedSloth, может показаться, что для
каждого типа Animal нужно написать свою функцию feed. Однако мы можем
обойтись полиморфными функциями с полиморфными типами возвращаемых
значений и аргументами. Взгляните на следующее определение гипотетической
функции feed:
void feed(Animal& a)
{
a.decreaseHunger();
}
Эта функция принимает в качестве параметра ссылку на Animal, что означает, что
в нее можно передать любой объект, созданный на основе класса, расширяющего
Animal.
Следовательно, вы можете написать код сегодня и создать другой подкласс
через неделю, месяц или год, и те же самые функции и структуры данных будут
работать. Кроме того, мы можем навязать нашим подклассам набор правил относительно того, что они могут и не могут делать, а также как они это делают.
Таким образом, хороший дизайн на одном этапе может повлиять на него на
других этапах. Но захотим ли мы когда-нибудь создать экземпляр конкретного
животного?
380 Глава 15. Игра Run
Абстрактные классы: виртуальные
и чистые виртуальные функции
Абстрактный класс — это класс, который нельзя инстанцировать, и, следовательно, он не может быть превращен в объект.
ПРИМЕЧАНИЕ
Термин, который нам, возможно, стоит упомянуть, — это конкретный класс. Конкретным называется любой класс, который не является абстрактным. Другими словами,
все классы, которые мы написали до сих пор, были конкретными и могут быть инстанцированы в виде объектов.
Получается, что это код, который никогда не будет использован? Но это все равно
что заплатить архитектору за проект дома, а потом никогда его не построить!
Если мы или разработчик класса хотим заставить его пользователей наследоваться от него, прежде чем использовать свой класс, они могут сделать класс
абстрактным. В этом случае мы не сможем создать из него объект. Следовательно, мы должны сначала унаследовать от него, а затем создать объект из подкласса.
Для этого мы можем сделать функцию чистой виртуальной и не давать ей никакого определения. Тогда эта функция должна быть переопределена (переписана)
в любом классе, который от нее наследуется.
Рассмотрим пример. Мы можем сделать класс абстрактным, добавив в него чистую виртуальную функцию, как в абстрактном классе Animal, которая может
выполнять только общее действие makeNoise:
class Animal
private:
// Здесь приватные данные
public:
void virtual makeNoise() = 0;
// Здесь другие публичные данные
};
Как видите, мы добавили ключевое слово virtual перед объявлением функции
и = 0 после него. Теперь любой класс, который расширяется/наследуется от
Animal, должен переопределить функцию makeNoise. Это может иметь смысл,
поскольку разные типы животных издают разные звуки. И, создав чистую виртуальную функцию, мы гарантируем, что она будет работать, потому что должна,
иначе код не скомпилируется.
Продвинутое ООП: наследование и полиморфизм 381
Абстрактные классы также полезны, потому что иногда нужен класс, который
можно использовать как полиморфный тип, но при этом нам нужно гарантировать, что он никогда не будет использоваться как объект. Например, Animal сам
по себе не имеет смысла. Мы сейчас имеем в виду типы животных. Мы не говорим: «Взгляните на это милое пушистое белое животное!» или «Вчера мы
ходили в зоомагазин и купили животное и лежанку для него». Это слишком
абстрактно.
Таким образом, абстрактный класс — это своего рода шаблон, который может
быть использован любым классом, расширяющим его (наследующим от него).
Если бы мы создавали игру в стиле Industrial-Empire, в которой игрок управляет
предприятиями и их сотрудниками, нам понадобился бы класс Worker, например,
и его расширения для создания Miner, Steelworker, OfficeWorker и, конечно,
Programmer. Но что именно делает обычный Worker? Зачем нам вообще его создавать?
Ответ заключается в том, что мы не хотим инстанцировать его, но можем использовать его как полиморфный тип, чтобы передавать различные подклассы
Worker между функциями и иметь структуры данных, способных хранить все
типы работников.
Все чистые виртуальные функции должны быть переопределены любым классом, который расширяет родительский класс, содержащий чистую виртуальную
функцию. То есть абстрактный класс может предоставлять некоторую общую
функциональность, которая будет доступна во всех его подклассах. Например,
класс Worker может иметь переменные-члены m_AnnualSalary, m_Productivity
и m_Age, а также функцию getPayCheck, которая не является чистой виртуальной
и одинакова во всех подклассах, в то время как функция doWork является чистой
виртуальной и должна быть переопределена, поскольку все различные типы
Worker будут выполнять функцию doWork совершенно по-разному.
ПРИМЕЧАНИЕ
Кстати, виртуальная функция, в отличие от чистой виртуальной, может быть переопределена по желанию. Вы объявляете виртуальную функцию так же, как и чистую
виртуальную, только без добавления = 0 в конце. В текущем игровом проекте мы
воспользуемся несколькими чистыми виртуальными функциями.
Если что-то из этого — виртуальные, чистые виртуальные или абстрактные функции — неясно, то с практикой придет понимание. Для начала давайте поговорим
о паттернах проектирования.
382 Глава 15. Игра Run
Паттерны проектирования
Я полагаю, что если вы собираетесь создавать сложные масштабные игры на C++,
то паттерны проектирования станут важной частью вашего обучения на ближайшие месяцы и годы. Ниже я лишь кратко ознакомлю вас с этой жизненно
важной темой.
Паттерн проектирования, или шаблон, — это многократно используемое решение определенной проблемы программирования. На самом деле в большинстве
игр (включая Run) используется несколько паттернов проектирования. Мы не собираемся изобретать велосипед, а просто воспользуемся уже существующими
паттернами, чтобы решить проблему нашего постоянно расширяющегося кода.
Многие паттерны проектирования довольно сложны и требуют дополнительного
изучения, выходящего за рамки книги, если вы хотите их освоить. Далее будет
представлено упрощенное описание одного из ключевых паттернов, связанных
с разработкой игр. Рекомендую вам продолжить изучение, чтобы реализовать
паттерны более комплексно.
Паттерн «Сущность — компонент — система»
(Entity Component System, ECS)
Сейчас мы потратим пять минут на то, чтобы погрузиться в пучину кажущейся
на первый взгляд неразрешимой задачи. Затем мы увидим, как паттерн «Сущность — компонент» нам в этом поможет.
Почему сложно управлять большим количеством
разнообразных типов объектов
В предыдущих проектах мы создавали класс для каждого объекта. У нас были
такие классы, как Bat, Ball, Crawler и Zombie. Затем в функции update мы обновляли их, а в функции draw — отрисовывали. Каждый объект сам решает, как
происходят обновление и отрисовка.
Мы могли бы просто использовать эту же структуру и для Run. Это сработало бы,
но мы пытаемся научиться чему-то более управляемому, чтобы наши игры могли
расти в сложности.
Другая проблема такого подхода заключается в том, что мы не можем воспользоваться преимуществами наследования. Например, все зомби, пули и игровой
персонаж из игры про зомби отрисовываются одинаково, но если мы не изменим
свой подход, то в итоге получим три функции draw с почти идентичным кодом.
Паттерн «Сущность — компонент — система» (Entity Component System, ECS) 383
В будущем, если мы внесем изменения в способ вызова функции draw или обработки изображений, нам потребуется обновить все три класса.
Должен быть вариант получше.
Использование универсального объекта GameObject
для улучшения структуры кода
Если бы каждый объект, игровой персонаж, зомби и все пули были одного общего типа, то мы могли бы упаковать их в vector и пройтись через все их функции
update, а затем через все их функции draw. Именно этим и занимается функция
main в проекте Run.
Один из способов сделать это — наследование. На первый взгляд наследование
может показаться идеальным решением. Мы могли бы создать абстрактный
класс GameObject, а затем расширить его классами Player, Zombie и Bullet.
Функция draw, которая идентична во всех трех классах, осталась бы в родительском классе, и у нас не было бы проблемы с дублированием кода. Отлично!
Однако дело в разнообразии некоторых игровых объектов. Например, все типы
объектов двигаются по-разному. Пули летят в определенном направлении, зомби
движутся к главному герою игры, а он реагирует на нажатия клавиш.
Как бы мы вложили такое разнообразие в функцию update, чтобы она могла
управлять этим движением? Возможно, мы воспользовались бы чем-то вроде
этого:
update(){
switch(objectType){
case 1:
// Вся логика игрового персонажа
break;
case 2:
// Вся логика зомби
break;
case 3:
// Вся логика пули
break;
}
}
Одна только функция update была бы больше, чем весь класс GameEngine!
Как вы, возможно, помните из раздела «Продвинутое ООП: наследование и полиморфизм», наследуя от класса, мы также можем переопределять конкретные
384 Глава 15. Игра Run
функции. Это означает, что для каждого типа объектов может быть своя версия
функции update. Однако, к сожалению, и у такого подхода есть недостаток.
Движок GameEngine должен «знать», какой тип объекта он обновляет, или, по
крайней мере, иметь возможность запросить экземпляр GameObject, который он
обновляет, чтобы вызвать правильную версию функции update. Что действительно необходимо, так это чтобы GameObject каким-то образом внутренне выбирал,
какая версия функции update требуется.
К сожалению, даже та часть решения, которая, казалось бы, работает, рассыпается
при ближайшем рассмотрении. Я сказал, что код в функции draw одинаков для
всех трех объектов и поэтому функция draw может быть частью родительского
класса и использоваться всеми подклассами, а нам не придется писать три отдельные функции draw. Но что произойдет, если мы введем новый объект, который
нужно отрисовать по-другому, например анимированного летающего зомби?
В этом случае решение с draw тоже развалится.
Теперь, когда мы обозначили возникающие проблемы, пришло время рассмот
реть решение, которым мы воспользуемся в проекте Run.
Нам нужен новый подход к созданию всех игровых объектов.
Композиция важнее наследования
Принцип Composition Over Inheritance (композиция важнее наследования) относится к идее объединения одних объектов с другими. Впервые эта концепция
была предложена в книге «Паттерны объектно-ориентированного проектирования» Эриха Гамма, Ричарда Хелма и др.
Что, если бы мы могли написать целый класс (в отличие от функции), который бы управлял тем, как объект отрисовывается? Тогда для всех классов с одинаковой отрисовкой мы могли бы создать экземпляр этого специального класса
внутри GameObject, а для объектов, которые нужно отрисовывать по-другому,
можно было бы использовать другой объект отрисовки. Затем, когда GameObject
делает что-то по-другому, мы просто объединяем его с другими классами, связанными с отрисовкой или обновлением, в соответствии с его потребностями. Все
общие черты наших объектов могут выиграть от использования одного и того же
кода, в то время как все различия могут выиграть от того, что они не только инкапсулированы, но и абстрагированы от базового класса.
Однако композиция не заменяет наследование, и все, что вы узнали в разделе
«Продвинутое ООП: наследование и полиморфизм», остается в силе. Тем не менее там, где это возможно, старайтесь прибегать к композиции вместо наследования. В проекте Run мы будем делать и то и другое.
Класс GameObject — это сущность (entity), а классы, из которых он будет состоять
и которые будут выполнять такие действия, как обновление его позиции и отри-
Паттерн «Сущность — компонент — система» (Entity Component System, ECS) 385
совка на экране, — это компоненты (components), поэтому паттерн и называется
«Сущность — компонент» (Entity-Component).
Взгляните на рис. 15.6. На нем представлен паттерн «Сущность — компонент»
в том виде, в котором мы будем реализовывать его в данном проекте.
Рис. 15.6. Код паттерна «Сущность — компонент»
Здесь слева — код нашей функции main, который перебирает вектор GameObject,
вызывая сначала update, а затем draw для каждого экземпляра по очереди. На
схеме видно, что экземпляр GameObject состоит из нескольких экземпляров
Component. Существует несколько различных классов, унаследованных от класса Component, включая UpdateComponent и GraphicsComponent. Кроме того, могут
быть и другие специфические классы. Например, классы BulletUpdateComponent
и ZombieUpdateComponent могут быть унаследованными от UpdateComponent. Эти
классы будут управлять тем, как объект обновляет себя в каждом кадре игры. Это
отлично подходит для инкапсуляции, потому что нам теперь не нужны большие
блоки switch, чтобы разделять разные объекты.
Когда мы используем композицию вместо наследования для создания группы
классов, представляющих поведение или алгоритмы, это называется паттерном
«Стратегия». Вы можете применить на практике все, что узнали здесь, и называть это паттерном «Стратегия». «Сущность — компонент» — это менее известная, но более специфичная реализация, поэтому мы и называем ее именно
так. Разница между ними чисто теоретическая, но, если хотите изучить вопрос
глубже, не стесняйтесь обращаться к ChatGPT. Хорошим ресурсом для дальнейшего изучения паттернов игрового программирования является https://
gameprogrammingpatterns.com.
Паттерн «Сущность — компонент», наряду с использованием композиции, на
первый взгляд выглядит отлично, но влечет за собой ряд проблем. То есть наш
новый класс GameObject должен будет «знать» обо всех различных типах ком-
386 Глава 15. Игра Run
понентов и всех типах объектов в игре. Как же он будет добавлять к себе все
необходимые компоненты?
Давайте посмотрим на решение.
Паттерн «Фабрика»
Действительно, если у нас будет универсальный класс GameObject, который может
быть чем угодно: пулей, героем, захватчиком или чем-то еще, то нам придется
написать некую логику, которая «знает», как создавать эти невероятно гибкие
экземпляры GameObject и составлять их из нужных компонентов. Однако добавление всего этого кода в сам класс GameObject сделает его чрезвычайно громоздким и сведет на нет всю идею использования паттерна «Сущность — компонент».
Нам понадобится конструктор, который будет делать что-то вроде этого гипотетического кода GameObject:
class GameObject
{
UpdateComponent* m_UpdateComponent;
GraphicsComponent* m_GraphicsComponent;
// Другие компоненты
// Конструктор
GameObject(string type){
if(type == "invader")
{
m_UpdateComp = new InvaderUpdateComponent();
m_GraphicsComponent = new StdGraphicsComponent();
}
else if(type =="ufo")
{
m_UpdateComponent = new UFOUpdateComponentComponent();
m_GraphicsComponent = new AnimGraphicsComponent();
}
// и т. д.
...
}
};
Класс GameObject должен знать не только о том, какие компоненты подходят
к тому или иному экземпляру GameObject, но и о том, какие компоненты не нужны,
например компоненты, связанные с вводом для управления игровым персонажем.
Для проекта Run мы могли бы сделать это и даже преодолеть все сложности, но
для более крупной игры мы, скорее всего, утонем в коде.
Класс GameObject также должен будет понимать всю эту логику. Все преимущества и эффективность, полученные от использования композиции в паттерне
«Сущность — компонент», будут потеряны.
Паттерн «Сущность — компонент — система» (Entity Component System, ECS) 387
Более того, что будет, если мы решим, что нам нужен новый тип объекта, скажем,
новый враг, который телепортируется к главному герою, делает выстрел, а затем
снова телепортируется? Можно написать новый класс GraphicsComponent, возможно, TeleportGraphicsComponent, который «знает», когда он видим и невидим,
а также новый UpdateComponent, например TeleportUpdateComponent, который
обрабатывает телепортацию вместо обычного движения. Но основная проблема
в том, что нам придется добавить огромное множество новых операторов if
в конструктор класса GameObject.
На самом деле ситуация еще хуже. Что, если мы решим, что обычные объекты
теперь могут телепортироваться? Всем GameObject теперь потребуется больше,
чем просто другой тип класса GraphicsComponent. Нам придется вернуться в класс
GameObject, чтобы снова отредактировать все эти операторы if.
Можно придумать множество сценариев, и все они приведут к тому, что класс
GameObject будет разрастаться. Паттерн «Фабрика» является решением всех
этих проблем, связанных с классом GameObject, и идеальным партнером паттерна
«Сущность — компонент».
СОВЕТ
Эта реализация паттерна «Фабрика» сильно упрощена. Почему бы вам не поискать
в Интернете информацию об этом паттерне, чтобы улучшить данный проект после
того, как мы завершим его?
Проектировщик игры предоставляет спецификацию для каждого типа объектов
в игре, а программист разрабатывает класс-фабрику, который будет создавать
экземпляры GameObject на основе этих спецификаций. Если вдруг появятся новые идеи для сущностей, нам останется лишь запросить новую спецификацию.
Иногда это будет означать добавление новой «производственной линии» в фабрику, которая использует существующие компоненты, а иногда — написание
новых компонентов или, возможно, обновление существующих. Суть в том, что
неважно, насколько изобретателен проектировщик игры: GameObject и функция
main останутся неизменными.
В своей простейшей форме (как наш класс Factory) класс Factory обладает информацией, необходимой для подготовки игровых объектов и соответствующих
им компонентов для игрового цикла.
В коде Factory происходят инстанцирование текущего типа объекта и добавление к нему соответствующих компонентов (классов). Огненный шар, игровой
персонаж, платформа и другие объекты имеют комбинации разных и одинаковых компонентов.
388 Глава 15. Игра Run
Одни игровые объекты вроде дождя будут иметь только изображения, а другие —
только обновления, например менеджер уровней, который управляет логикой
игры.
Когда мы используем композицию, может быть не совсем ясно, какой класс отвечает за память. Давайте еще немного изучим язык C++, чтобы понять, как нам
управлять памятью более просто.
Умные указатели C++
Умные указатели — это классы, которые мы можем использовать для получения
тех же функций, что и обычные указатели, но с дополнительной особенностью:
они сами отвечают за свое удаление. В том виде, в котором мы использовали указатели до сих пор, очистка собственной памяти не представляла для нас проблемы,
но по мере усложнения кода, особенно когда вы выделяете память в одном классе,
а используете ее в другом, становится гораздо менее понятно, какой класс отвечает
за освобождение памяти, когда она больше не нужна. Как класс или функция могут узнать, завершил ли другой класс или функция работу с выделенной памятью?
Решение — умные указатели. Существует несколько типов умных указателей.
Мы рассмотрим два наиболее популярных. Ключ к успеху при работе с умными
указателями — использование правильного типа.
Первый тип, который мы рассмотрим, — это общие указатели (shared pointers).
Общие указатели
Способ, с помощью которого общий указатель может безопасно освобождать
память, на которую он указывает, заключается в подсчете количества различных
ссылок на область памяти. Если вы передаете указатель в функцию, счетчик
увеличивается на единицу. Если вы помещаете указатель в вектор, счетчик увеличивается на единицу. Если функция возвращается, счетчик уменьшается на
единицу. Если вектор выйдет из области видимости или для него будет вызвана
функция clear, умный указатель уменьшит счетчик ссылок на единицу. Когда
счетчик ссылок достигает нуля, на эту область памяти больше ничего не указывает, и класс умного указателя вызывает delete. Все виды умных указателей
реализованы с использованием обычных указателей внутри системы. Мы просто
получаем преимущество, не заботясь о том, где и когда вызывать delete. Рассмотрим пример использования общего умного указателя.
Следующий код создает новый общий умный указатель myPointer, который будет
указывать на экземпляр MyClass:
shared_ptr<MyClass> myPointer;
Паттерн «Сущность — компонент — система» (Entity Component System, ECS) 389
shared_ptr<MyClass> — это тип, а myPointer — его имя. Код, представленный
ниже, показывает, как мы можем инициализировать myPointer:
myPointer = make_shared<MyClass>();
Вызов make_shared внутренне вызывает new для выделения памяти. Круглые
скобки () — это вызов конструктора. Если бы конструктор класса MyClass принимал, например, параметр int, то предыдущий код выглядел бы следующим
образом:
myPointer = make_shared<MyClass>(3);
Число 3 в приведенном коде — это произвольный пример.
Конечно, при необходимости вы можете объявить и инициализировать общие
умные указатели в одной строке кода, как показано ниже:
shared_ptr<MyClass> myPointer = make_shared<MyClass>();
Поскольку myPointer — это shared_ptr, у него есть внутренний счетчик ссылок,
который отслеживает, сколько ссылок указывает на выделенную область памяти.
Если мы создаем копию указателя, счетчик ссылок увеличивается.
Создание копии указателя включает передачу указателя в другую функцию, помещение его в vector, map или другую структуру либо просто копирование.
Мы можем использовать умный указатель с тем же синтаксисом, что и обычный.
Иногда очень легко забыть, что это не простой указатель. Следующий код вызывает функцию myFunction для myPointer:
myPointer->myFunction();
Использование общего умного указателя приводит к некоторым издержкам: наш
код работает медленнее и использует больше памяти. В конце концов, умному
указателю нужна переменная для отслеживания количества ссылок, и он должен
проверять значение счетчика каждый раз, когда ссылка выходит из области видимости. Однако эти издержки незначительны и становятся проблемой только
в самых экстремальных ситуациях, поскольку большая их часть происходит во
время создания умных указателей. Как правило, мы создаем умные указатели
вне игрового цикла. Вызов функции по умному указателю так же эффективен,
как и по обычному.
Иногда мы знаем, что нам понадобится только одна ссылка на умный указатель,
и в этой ситуации лучшим вариантом будут уникальные указатели (unique
pointers).
390 Глава 15. Игра Run
Уникальные указатели
Когда мы знаем, что нам нужна только одна ссылка на область памяти, мы можем воспользоваться уникальным умным указателем. Уникальные указатели
не имеют тех издержек, которые, как я уже говорил, есть у общих указателей.
Кроме того, если вы попытаетесь сделать копию уникального указателя, компилятор предупредит нас и код либо не скомпилируется, либо аварийно завершится с ошибкой. Это очень полезная функция, которая может уберечь нас от
случайного копирования указателя, который не был предназначен для этого.
Возможно, вы задаетесь вопросом, сможем ли мы в таком случае передать указатель в функцию или даже поместить его в структуру данных вроде вектора.
Чтобы выяснить это, давайте рассмотрим код для уникальных умных указателей
и узнаем, как они работают.
Следующий код создает уникальный умный указатель myPointer, который указывает на экземпляр MyClass:
unique_ptr<MyClass> myPointer = make_unique<MyClass>();
Теперь предположим, что нам нужно добавить unique_ptr к vector. Первое, что
нужно отметить, — vector должен быть правильного типа. Код ниже объявляет
vector, который содержит уникальные указатели на экземпляры MyClass:
vector<unique_ptr<MyClass>> myVector;
Вектор называется myVector, и все, что вы в него помещаете, должно иметь уникальный указатель на MyClass. Но разве я не говорил, что уникальные указатели
нельзя копировать? Когда мы знаем, что нам нужна только одна ссылка на область памяти, мы должны использовать unique_ptr. Однако это не означает, что
ссылку нельзя перемещать. Вот пример:
// Используем move(), потому что иначе
// вектор получит КОПИЮ, что запрещено
mVector.push_back(move(myPointer));
// mVector.push_back(myPointer); // Не скомпилируется!
В приведенном коде функция move может быть применена для помещения уникального умного указателя в вектор. Обратите внимание, что при использовании
функции move вы не даете компилятору разрешения нарушить правила и скопировать уникальный указатель: вы перекладываете ответственность с переменной
myPointer на экземпляр myVector. Если вы попытаетесь использовать переменную
myPointer после этого момента, код выполнится и игра аварийно завершится,
выдав сообщение об ошибке Null pointer access violation. Следующий код приведет
к сбою:
unique_ptr<MyClass> myPointer = make_unique<MyClass>();
vector<unique_ptr<MyClass>> myVector;
Паттерн «Сущность — компонент — система» (Entity Component System, ECS) 391
// Используем move(), потому что иначе
// вектор получит КОПИЮ, что запрещено
mVector.push_back(move(myPointer));
// mVector.push_back(myPointer); // Не скомпилируется!
myPointer->myFunction();// СБОЙ!
Точно такие же правила действуют при передаче уникального указателя в функцию; используйте функцию move для передачи ответственности. Мы рассмотрим
некоторые из этих сценариев снова, а также несколько других, когда чуть позже
вернемся к проекту Run.
Приведение умных указателей
Нам часто нужно будет упаковывать умные указатели производных классов
в структуры данных или параметры функций базового класса, например на все
различные производные классы Component. В этом и заключается суть полиморфизма. Умные указатели позволяют достичь этого с помощью приведения. Но
что делать, если впоследствии нам понадобится получить доступ к функциональности или данным производного класса?
Хорошим примером является работа с компонентами внутри игровых объектов.
У нас будет абстрактный класс Component, а от него будут унаследованы такие
классы, как GraphicsComponent, UpdateComponent и другие.
Допустим, мы хотим передавать в функции общие классы компонентов и при
этом использовать функциональность их производных классов. Но если все
компоненты хранятся как экземпляры базового класса Component , то может
показаться, что это невозможно сделать. Приведение от базового класса к производному решает данную проблему.
Следующий код приводит myComponent, который является экземпляром базового
класса Component, к экземпляру класса UpdateComponent, после чего мы можем
вызвать функцию update:
shared_ptr<UpdateComponent> myUpdateComponent =
static_pointer_cast<UpdateComponent>
(MyComponent);
Перед знаком = объявляется новый указатель shared_ptr на экземпляр Update
Component . После знака = функция static_pointer_cast в угловых скобках
<UpdateComponent> указывает тип, к которому нужно привести, а в круглых скобках — экземпляр, который нужно преобразовать, —(MyComponent).
Теперь мы можем использовать все функции класса UpdateComponent, включая
функцию update, которую в нашем проекте можно вызвать следующим образом:
myUpdateComponent->update(fps);
392 Глава 15. Игра Run
Существуют два способа приведения одного умного указателя к другому: с помощью static_pointer_cast, как мы только что видели, и через dynamic_pointer_
cast. Разница между ними в том, что dynamic_pointer_cast применяется, если вы
не уверены, что приведение сработает. Когда вы прибегаете к dynamic_pointer_
cast, то можете проверить, сработал ли он, посмотрев, является ли результат
нулевым указателем. А static_pointer_class обычно используется, когда вы
уверены, что результат будет нужного типа. Мы несколько раз применим static_
pointer_cast в проекте Run. Давайте вернемся к созданию нашей игры.
Создание класса GameObject
Класс GameObject зависит от класса Component, а класс Component — от классов
Graphics и Update, так что давайте напишем все четыре класса.
Помните, что при обсуждении системы компонентов сущностей мы говорили
о классах Component и о том, что GraphicsComponent, UpdateComponent и так далее
будут унаследованными от Component. Для удобства представления кода мы сократим GraphicsComponent до Graphics, а UpdateComponent — до Update.
Создайте класс под названием GameObject. В файл GameObject.h добавьте следующий код:
#pragma once
#include "SFML/Graphics.hpp"
#include "Component.h"
#include <vector>
using namespace sf;
using namespace std;
class GameObject
{
private:
vector <shared_ptr<Component>> m_Components;
public:
void addComponent(shared_ptr<Component> newComponent);
void update(float elapsedTime);
void draw(VertexArray& canvas);
};
В коде есть ошибки, поскольку отсутствует класс Component. Как вы, наверное,
уже догадались, мы его скоро напишем.
В приведенном коде у нас есть вектор для хранения экземпляров Component.
Мы не будем добавлять в вектор абстрактные экземпляры Component, а добавим
только унаследованные экземпляры Graphics и Update. Для этого у нас имеется
функция addComponent.
У нас также есть функции update и draw. Мы уже видели, как эти две функции
вызываются из основного игрового цикла. Вот код из игрового цикла в функции
main в качестве напоминания (вам не нужно добавлять его снова):
Паттерн «Сущность — компонент — система» (Entity Component System, ECS) 393
// Обновляем все игровые объекты
for (auto& gameObject : gameObjects)
{
gameObject.update(timeTakenInSeconds);
}
// Отрисовываем все игровые объекты на холсте
for (auto& gameObject : gameObjects)
{
gameObject.draw(canvas);
}
Надеюсь, вы видите, как вся эта система собирается воедино.
Давайте напишем три функции класса GameObject. Добавьте в файл GameObject.cpp
следующий код:
#include "GameObject.h"
#include "SFML/Graphics.hpp"
#include <iostream>
#include "Update.h"
#include "Graphics.h"
using namespace std;
using namespace sf;
void GameObject::addComponent(
shared_ptr<Component> newComponent)
{
m_Components.push_back(newComponent);
}
void GameObject::update(float elapsedTime)
{
for (auto component : m_Components)
{
if (component->m_IsUpdate)
{
static_pointer_cast<Update>
(component)->update(elapsedTime);
}
}
}
void GameObject::draw(VertexArray& canvas)
{
for (auto component : m_Components)
{
if (component->m_IsGraphics)
{
static_pointer_cast<Graphics>
(component)->draw(canvas);
}
}
}
В этом файле упоминаются три еще не написанных класса, которые вызывают
ошибки: Component, Update и Graphics (Update и Graphics будут получены из
Component). Мы разберемся с этим позже.
394 Глава 15. Игра Run
В приведенном коде функция addComponent содержит всего одну строку кода,
которая задействует функцию push_back вектора для добавления нового экземпляра производного компонента в вектор m_Components.
Функция Update также коротка и проста. Сначала код перебирает все компоненты:
for (auto component : m_Components)
{
Затем он проверяет, является ли текущий компонент компонентом обновления:
if (component->m_IsUpdate)
{
Наконец, если проверка возвращает true, вызывается функция update, и экземпляр выполняет свою версию функции update. Помните, что это может быть что
угодно в нашей игре: игрок, огненный шар, меню и т. д.
Функция draw делает то же самое, что и update, за исключением того, что она
ищет графический компонент и вызывает функцию draw.
Предыдущий код подразумевает, что класс Component будет иметь логические
переменные m_IsUpdate и m_IsGraphics. Теперь давайте напишем класс Component.
Создание класса Component
Класс Component — самый короткий класс в книге. В нем нет никаких функций. Он существует только для того, чтобы быть расширенным. Фактически
мы оставим файл Component.cpp пустым. Однако обратите внимание, что мы
немного расширяем простой пример компонентов сущностей, рассмотренный
ранее. Классы Graphics и Update будут расширять Component. Component будет
полиморфным типом, а Graphics и Update — абстрактными классами (с чистыми
виртуальными функциями), от которых будут расширяться все используемые
классы нашей игры. Создайте класс Component и добавьте в файл Component.h
следующий код:
#pragma once
#include <iostream>
using namespace std;
class Component
{
public:
bool m_IsGraphics = false;
bool m_IsUpdate = false;
};
В приведенном выше коде мы создаем класс Component и добавляем две публичные переменные его членов. Логические переменные m_IsGraphics и m_IsUpdate
Паттерн «Сущность — компонент — система» (Entity Component System, ECS) 395
будут установлены при добавлении нового компонента и проверены перед обновлением или отрисовкой. Вот и все.
Файл Component.cpp останется пустым, потому что в нем нет никакой функциональности. Вы можете удалить Component.cpp, если хотите.
Однако классы, которые расширяют Component, содержат гораздо больше. Давайте
сначала напишем класс Graphics, а затем перейдем к классу Update.
Создание класса Graphics
В этом разделе мы создадим два класса с именами Graphics и Update, которые наследуют от Component. Было бы логичнее, если бы мы назвали их GraphicsComponent
и UpdateComponent, но слово component довольно длинное. Время от времени
я буду называть Graphics и Update компонентами, потому что они таковыми
являются, даже если это не отражено в их именах.
Создайте класс Graphics, базовым классом которого является Component. Вы можете добавить Component в поле Base class (Базовый класс) диалогового окна
New class (Добавить класс), и немного кода автоматически сгенерируется для вас.
Однако простое добавление следующего кода в Graphics.h даст точно такой же
эффект:
#pragma once
#include "Component.h"
#include <SFML/Graphics.hpp>
using namespace sf;
class Update;
class Graphics :
public Component
{
private:
public:
Graphics();
virtual void assemble(
VertexArray& canvas,
shared_ptr<Update> genericUpdate,
IntRect texCoords) = 0;
virtual void draw(VertexArray& canvas) = 0;
};
В приведенном коде нет переменных, есть только две публичные функции.
Внимательно посмотрите на функции: в начале их объявления стоит ключевое
слово virtual, а в конце — = 0. Любой класс, расширяющий этот класс, должен
реализовать (дать определение) эти две функции. Первая чистая виртуальная
функция интерфейса Graphics — это функция assemble. В дальнейшем мы напишем множество классов, расширяющих класс Graphics, включая PlayerGraphics,
396 Глава 15. Игра Run
RainGraphics и PlatformGraphics. Каждый из них будет предоставлять свою
собственную реализацию функции assemble. Это полезно, потому что все они
должны быть собраны немного по-разному.
Прежде чем мы продолжим, обратите внимание на сигнатуру функции assemble.
Во-первых, там есть ссылка на VertexArray, которая позволит добавлять текстурные координаты для требуемых изображений. Есть также общий указатель на
экземпляр Update. Мы увидим, как с его помощью получить необходимые данные
из экземпляра Update, который соответствует текущему экземпляру Graphics. Для
доступа к функциям соответствующего дочернего класса мы применим статическое приведение, как обсуждалось в разделе «Приведение умных указателей».
Наконец, у нас есть экземпляр IntRect библиотеки SFML, который содержит
текстурные координаты для этого объекта. Функция assemble будет вызвана
в функции loadLevel класса Factory.
Функция draw получает VertexArray во время итераций в основном игровом
цикле, что позволяет ей обновлять свою позицию.
Добавьте следующий код в файл Graphics.cpp:
#include "Graphics.h"
Graphics::Graphics()
{
m_IsGraphics = true;
}
В приведенном выше коде конструктор выполняет только одно действие: устанавливает значение переменной m_IsGraphics в true . При создании любого
экземпляра, унаследованного от Graphics, компилятор всегда будет вызывать
этот конструктор, гарантируя, что публичная переменная, объявленная в классе
Component, будет установлена корректно. Помните, что данное значение проверяется в коде GameObject перед попыткой вызвать функцию draw.
Создание класса Update
Создайте класс Update, базовым классом которого является Component (можете
использовать поле Base class (Базовый класс), если хотите).
В файле Update.h добавьте следующий код:
#pragma once
#include "Component.h"
#include "SFML/Graphics.hpp"
class LevelUpdate;
class PlayerUpdate;
class Update :
public Component
Паттерн «Сущность — компонент — система» (Entity Component System, ECS) 397
{
private:
public:
Update();
virtual void assemble(
shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate) = 0;
virtual void update(float timeSinceLastUpdate) = 0;
};
В приведенном коде у нас есть две чистые виртуальные функции: assemble
и update. Функция assemble будет использоваться классом Factory в функции
loadLevel. Из сигнатуры видно, что assemble задействует два общих указателя:
на экземпляр LevelUpdate и на экземпляр PlayerUpdate. Мы еще не написали
код для них, но все экземпляры, унаследованные от Update, должны отслеживать
состояние игры, которое будет контролироваться классом LevelUpdate (также
полученным из Update), а также состояние игрового персонажа, контролируемое
классом PlayerUpdate.
Мы избегаем обращения к этим двум несуществующим классам, добавляя предварительные объявления в начало файла Update.h, как показано далее:
class LevelUpdate;
class PlayerUpdate;
Если бы мы попытались воспользоваться любым из этих общих указателей до
реализации классов, код бы не выполнился, но простое добавление их в сигнатуру
функции работает корректно благодаря предварительным объявлениям.
Добавьте следующий код в файл Update.cpp:
#include "Update.h"
Update::Update()
{
m_IsUpdate = true;
}
Здесь мы применяем ту же технику, что и в классе Graphics, чтобы убедиться,
что родительский класс Component устанавливает соответствующую логическую
переменную, чтобы класс GameObject мог знать, какой тип компонента (Graphics
или Update) он использует в данный момент.
Выполнение кода
Теперь код не содержит ошибок. Однако, запустив его, мы получим только
серый экран. Кроме того, мы не можем легко остановить программу. Нажмите
Ctrl+Alt+Delete, выберите Run.exe, а затем нажмите End Task (Завершить задачу),
чтобы принудительно остановить программу.
398 Глава 15. Игра Run
Чтобы устранить это неудобство, уберите символы комментариев в следующем
коде в файле InputDispatcher.cpp:
//if (event.type == Event::KeyPressed &&
//
event.key.code == Keyboard::Escape)
//{
//
m_Window->close();
//}
Это позволит экземпляру InputDispatcher обрабатывать нажатие клавиши Esc.
Класс InputDispatcher должен заниматься только диспетчеризацией сообщений
ввода, но мы схитрим, пока не реализуем наши классы, связанные с меню, позже
в проекте. Теперь вы можете запустить программу, полюбоваться на серый экран
и нажать Esc для выхода.
Что дальше?
Нам нужно обсудить новый способ работы с графикой в данном проекте, и мы
сделаем это в главе 17. Когда я говорю «новый», я имею в виду новый в рамках
книги, поскольку эта техника уходит корнями в первые годы разработки игр.
Если вы заглянете в папку graphics, то увидите там только одно изображение.
Более того, мы до сих пор ни разу не вызывали функцию window.draw в нашем
коде. Мы обсудим, почему вызовы функции draw должны быть сведены к минимуму, а также реализуем классы, связанные с камерой, которые будут решать
эту задачу за нас.
Причина, по которой мы откладываем эту тему, заключается в том, что для обсуждения необходимо иметь некоторый рабочий код. Конечно, массивы вершин
и текстурные координаты для нас не в новинку, поскольку мы использовали
их для фона в проекте про зомби. Поэтому в следующей главе мы начнем реализовывать игровую логику и первую часть классов, связанных с игровым
персонажем. Кроме того, поскольку мы уже работали со звуком, мы напишем
класс SoundManager с дополнительной возможностью проигрывать короткую
повторяющуюся мелодию.
Теперь давайте вспомним все, что мы сделали и изучили в этой главе.
Резюме
Сперва мы рассмотрели, что представляет собой новая игра и как в нее играть.
Затем мы создали проект и написали самую короткую функцию main (основной
игровой цикл) за всю книгу!
Резюме 399
Далее мы разобрали новый способ обработки ввода данных игроком, делегируя
определенные обязанности отдельным игровым сущностям/объектам и заставляя
их слушать сообщения от нового класса InputDispatcher.
Мы написали класс Factory, который должен отвечать за «знание» того, как
собирать все различные компоненты, создаваемые нами, в наследуемые типы,
прежде чем они будут помещены внутри экземпляров GameObject.
Вы узнали о наследовании, полиморфизме и умных указателях для передачи ответственности за управление памятью компилятору.
Затем мы написали ключевой класс GameObject. Класс Component, являющийся
родительским классом для почти всех остальных классов, которые мы создадим,
будет содержать экземпляры GameObject. Мы также написали классы Graphics
и Update, которые будут производными/расширенными/дочерними классами
Component.
В следующей главе мы добавим звук и игровую логику, а также узнаем о меж
объектном взаимодействии.
16
Звук, игровая логика,
межобъектное
взаимодействие
и игровой персонаж
В данной главе мы быстро реализуем звуковое сопровождение для нашей игры.
Мы уже делали это раньше, так что будет несложно. Фактически всего за полдюжины строк кода мы добавим к нашим звуковым возможностям еще и музыку.
Позже в проекте (но не в текущей главе) мы реализуем направленный (пространственный) звук. На этот раз, однако, мы обернем весь наш код, связанный со звуком, в один класс под названием SoundEngine. Как только у нас появится звук, мы
перейдем к работе над игроком. Мы создадим всю функциональность персонажа
игрока, просто добавив два класса: один — расширяющий Update, другой — расширяющий Graphics. Создание новых игровых объектов путем расширения этих
двух классов будет основным способом реализации почти всего остального в игре.
Мы также увидим простой способ взаимодействия объектов друг с другом с помощью указателей. Готовый код главы 16 можно найти в папке Run2.
Создание класса SoundEngine
Из предыдущего проекта вы, наверное, помните, что весь код, связанный со звуком, занимал довольно много строк. Теперь представьте, что нам понадобится
еще больше кода, когда мы добавим пространственный звук в главе 20, — он
станет еще объемнее. Чтобы сделать наш код управляемым, создадим класс для
управления всеми звуковыми эффектами и музыкой.
Весь этот код будет очень хорошо знаком. Даже новая функция воспроизведения
музыки должна показаться интуитивно знакомой благодаря тому, что мы делали
в других играх. Создайте новый класс SoundEngine. В файл SoundEngine.h добавьте следующий код:
#pragma once
#include <SFML/Audio.hpp>
Создание класса SoundEngine 401
using namespace sf;
class SoundEngine
{
private:
static Music music;
static SoundBuffer m_ClickBuffer;
static Sound m_ClickSound;
static SoundBuffer m_JumpBuffer;
static Sound m_JumpSound;
public:
SoundEngine();
static SoundEngine* m_s_Instance;
static bool mMusicIsPlaying;
static void startMusic();
static void pauseMusic();
static void resumeMusic();
static void stopMusic();
static void playClick();
static void playJump();
};
В этом коде у нас есть объект Music библиотеки SFML, SoundBuffer и объект
Sound для каждого звукового эффекта, который мы собираемся воспроизвести.
В блоке public есть функции для запуска, паузы, остановки и возобновления музыки, а также две функции для воспроизведения каждого из звуковых эффектов.
После того как мы увидим, как это работает, добавить в игру любое количество
звуковых эффектов не составит труда.
В класс SoundEngine.cpp добавьте следующий код:
#include "SoundEngine.h"
#include <assert.h>
SoundEngine* SoundEngine::m_s_Instance = nullptr;
bool SoundEngine::mMusicIsPlaying = false;
Music SoundEngine::music;
SoundBuffer SoundEngine::m_ClickBuffer;
Sound SoundEngine::m_ClickSound;
SoundBuffer SoundEngine::m_JumpBuffer;
Sound SoundEngine::m_JumpSound;
SoundEngine::SoundEngine()
{
assert(m_s_Instance == nullptr);
m_s_Instance = this;
m_ClickBuffer.loadFromFile("sound/click.wav");
m_ClickSound.setBuffer(m_ClickBuffer);
m_JumpBuffer.loadFromFile("sound/jump.wav");
m_JumpSound.setBuffer(m_JumpBuffer);
}
void SoundEngine::playClick()
{
m_ClickSound.play();
}
void SoundEngine::playJump()
402 Глава 16. Звук, игровая логика, межобъектное взаимодействие и персонаж
{
m_JumpSound.play();
}
void SoundEngine::startMusic()
{
music.openFromFile("music/music.wav");
m_s_Instance->music.play();
m_s_Instance->music.setLoop(true);
mMusicIsPlaying = true;
}
void SoundEngine::pauseMusic()
{
m_s_Instance->music.pause();
mMusicIsPlaying = false;
}
void SoundEngine::resumeMusic()
{
m_s_Instance->music.play();
mMusicIsPlaying = true;
}
void SoundEngine::stopMusic()
{
m_s_Instance->music.stop();
mMusicIsPlaying = false;
}
Звуковые эффекты реализованы так же, как и в предыдущих проектах, за исключением того, что теперь мы инкапсулировали их в класс. Буферы и звуки
загружаются и ассоциируются в конструкторе, а соответствующие функции вызывают воспроизведение в соответствующем экземпляре Sound.
Разберемся, как работает музыка. Экземпляры Music не имеют буферов. Технически говоря, вы могли бы загрузить музыкальный файл в обычный объект Sound,
но поскольку музыка обычно намного длиннее звукового эффекта, это не дало бы
хороших результатов. Поэтому в SFML предусмотрен класс Music. В функции
startMusic вы можете увидеть, что мы используем функцию openFromFile. Это
подготавливает файл к потоковой передаче, а не к загрузке целиком. Затем мы
вызываем функцию music.play, которая начинает потоковую передачу и воспроизведение музыки. Далее мы вызываем music.setLoop и передаем значение
true, благодаря чему музыка будет повторяться снова и снова.
В функциях pauseMusic, resumeMusic и stopMusic мы вызываем функции pause,
play и stop, предоставленные библиотекой SFML соответственно. Обратите внимание, что мы также установили логическое значение m_MusicIsPLaying, чтобы
отслеживать состояние музыки.
Нам предстоит добавить еще немного кода в менеджер звука, когда будем обсуждать направленный звук. Это нужно, чтобы мы могли слышать, откуда летят
огненные шары — слева или справа.
Реализация игровой логики 403
Реализация игровой логики
Чтобы управлять игровой логикой, мы инкапсулируем ее в игровой объект в самом ядре игры и обеспечим необходимые коммуникационные связи с другими
игровыми объектами. Эти связи будут в виде указателей на ключевые значения.
Например, все объекты будут иметь указатель на игровой объект, связанный
с логикой, чтобы знать, в частности, когда игра поставлена на паузу.
Идея размещения игровой логики в отдельном классе интересна. Рассмотрим сценарий, когда в вашей игре должно быть три разных игровых режима. Представьте,
сколько операторов if, else и else if потребовалось бы, если бы мы включили
всю эту логику в функцию main. Таким образом, фабрика может просто выбрать
игровой объект в зависимости от установленного игроком режима игры. Хотя
в данной игре будет только один игровой режим, как только вы увидите код,
создание иного набора логики в другом классе будет тривиальным.
Обратите внимание, что класса LevelGraphics не будет, потому что он нам не нужен. Позже в проекте, когда мы создадим игровой объект с эффектом дождя, мы
столкнемся с классом RainGraphics, который расширяет Graphics, однако необходимости в объекте, унаследованном от Update, не будет. Большинство игровых
объектов, которые мы создадим, будут иметь компонент обновления и компонент,
связанный с графикой. Суть в том, что это гибкая система.
Создание класса LevelUpdate
Создайте новый класс LevelUpdate, который использует Update в качестве базового класса. Добавьте следующий код в файл LevelUpdate.h:
#pragma once
#include "Update.h"
using namespace sf;
using namespace std;
class LevelUpdate : public Update
{
private:
bool m_IsPaused = true;
vector <FloatRect*> m_PlatformPositions;
float* m_CameraTime = new float;
FloatRect* m_PlayerPosition;
float m_PlatformCreationInterval = 0;
float m_TimeSinceLastPlatform = 0;
int m_NextPlatformToMove = 0;
int m_NumberOfPlatforms = 0;
int m_MoveRelativeToPlatform = 0;
bool m_GameOver = true;
void positionLevelAtStart();
public:
void addPlatformPosition(FloatRect* newPosition);
void connectToCameraTime(float* cameraTime);
404 Глава 16. Звук, игровая логика, межобъектное взаимодействие и персонаж
};
bool* getIsPausedPointer();
int getRandomNumber(int minHeight, int maxHeight);
// Из Update : Component
void update(float fps) override;
void assemble(
shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate)
override;
В приведенном коде есть много переменных-членов:
zzm_IsPaused — логическая переменная, которая просто отслеживает, приостановлена ли игра (любой игровой объект, которому нужно знать это, получит
указатель на это значение);
zzm_PlatformPositions — это вектор, содержащий указатели на экземпляры
FloatRect. Эти экземпляры будут содержать положение и размер всех платформ в игре. Важно отметить, что после инициализации указатели будут напрямую указывать на значения в игровых объектах, связанных с платформами. Это означает, что данный класс может напрямую управлять платформами.
Мы увидим, как это достигается, когда будем писать код для платформ и для
функций данного класса;
zzm_CameraTime — это простая переменная типа float , хранящая значение
времени, в течение которого длится текущая игровая сессия. Это ключевой
показатель успеха для игрока. Вскоре мы выведем его на экран в левом верхнем углу;
zzm_PlayerPosition — это указатель на экземпляр FloatRect, в котором хранится
положение игрового персонажа. Поскольку он указывает непосредственно на
класс, связанный с персонажем, класс LevelUpdate сможет принимать решения
на основе текущего местоположения персонажа, например, о том, не слишком ли он отстал и не закончилась ли игра;
zzm_PlatformCreationInterval — переменная типа float, содержащая значение
времени ожидания между созданием новых платформ. Как мы вскоре увидим, мы не создаем новые платформы, а просто повторно используем набор
платформ. Интервал будет основан на длине ранее использованной/новой
платформы;
zzm_TimeSinceLastPlatform — переменная типа float, которая работает в связке с m_PlatformCreationInterval. Когда m_TimeSinceLastPlatform равна или
больше m_PlatformCreationInterval, значит, пришло время создать/использовать другую платформу перед предыдущей;
zzm_NextPlatformToMove — переменная типа int , представляющая позицию
в векторе позиций платформ, которую нужно использовать повторно;
zzm_NumberOfPlatforms — переменная типа int, которая хранит количество созданных платформ. Код работает как с очень маленьким числом, например 5,
Реализация игровой логики 405
так и с гораздо большим, например 500. Главное отличие заключается в том,
что наименьшее эффективное число, которое делает игру удобной, а код
эффективным, мы будем использовать в функции loadLevel класса Factory;
zzm_MoveRelativeToPlatform — переменная типа int, которая представляет позицию в векторе позиций платформ, относительно которой будет перемещена
следующая платформа (подумайте о том, что вы бежите по платформе, а бежать уже некуда, и тут как раз вовремя появляется следующая платформа.
Эта следующая платформа должна быть в доступной позиции относительно
предыдущей);
zzm_GameOver — логическая переменная, отслеживающая, закончилась ли игра
или, при первом выполнении, что игра еще не началась (это отличие от
m_IsPaused).
Теперь рассмотрим функции:
zzpositionLevelAtStart — это приватная функция, которая устанавливает начальное положение всех игровых объектов перед каждой игрой;
zzaddPlatformPosition получает указатель floatrect с именем newPosition
и позиционирует отдельные платформы;
zzconnectToCameraTime принимает указатель типа float, который может синхронизироваться с m_CameraTime. Именно с помощью этого механизма мы
будем обновлять текст на экране, отображающий время для игрока. Текст
будет отрисовываться с помощью экземпляра Text библиотеки SFML в классе
CameraGraphics, который мы напишем в следующей главе;
zzgetIsPausedPointer возвращает указатель на логическую переменную
m_IsPaused. Это позволяет получить доступ к информации о том, приостановлена ли игра, любой части нашего кода, которая в ней нуждается;
zzgetRandomNumber принимает два значения и возвращает случайное число
между ними. Подобный код мы будем встречать на протяжении всего проекта.
Наиболее распространенный вариант применения этой функции в данном
классе — определение места расположения платформ при их повторном использовании.
Наконец, у нас есть две переопределенные функции, которые наследуются от
класса Update:
zzфункция update получает время, затраченное на выполнение последнего цикла
игры (как и в других наших играх, это будет иметь решающее значение для
определения времени выполнения всех действий в функции update);
zzфункция assemble, как было описано ранее, будет использоваться в фабрике
для подготовки компонента к использованию (после того как закончим писать
класс LevelUpdate, мы создадим несколько классов, связанных с игроком; затем мы увидим, как использовать функцию assemble в классе Factory).
406 Глава 16. Звук, игровая логика, межобъектное взаимодействие и персонаж
Далее мы добавим код в файл LevelUpdate.cpp. Поскольку функций довольно
много, мы будем добавлять и объяснять их по частям. Для начала добавьте следующий код в файл LevelUpdate.cpp:
#include "LevelUpdate.h"
#include<Random>
#include "SoundEngine.h"
#include "PlayerUpdate.h"
using namespace std;
void LevelUpdate::assemble(
shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate)
{
m_PlayerPosition = playerUpdate->getPositionPointer();
// временная строка
SoundEngine::startMusic();
}
void LevelUpdate::connectToCameraTime(float* cameraTime)
{
m_CameraTime = cameraTime;
}
void LevelUpdate::addPlatformPosition(FloatRect* newPosition)
{
m_PlatformPositions.push_back(newPosition);
m_NumberOfPlatforms++;
}
bool* LevelUpdate::getIsPausedPointer()
{
return &m_IsPaused;
}
Здесь мы добавили необходимые директивы include. Обратите внимание, что
среди директив include и функций есть ошибки, так как мы еще не написали
код для игрока.
В функции assemble мы вызываем playerUpdate->getPositionPointer. Это означает, что, когда мы создадим класс PlayerUpdate, мы также создадим и функцию
getPositionPointer. Мы сделаем это чуть позже, но сейчас важно отметить, что
экземпляр LevelUpdate всегда сможет видеть позицию игрового персонажа.
Далее, в качестве временной меры, мы вызываем startMusic, чтобы впервые
услышать музыку. В конце концов, игровой объект, связанный с меню, будет
управлять запуском, приостановкой и выключением музыки.
В функции connectToCameraTime мы инициализируем m_CameraTime адресом
памяти, содержащимся в cameraTime. Мы вызовем эту функцию, когда она нам
понадобится.
Функция addPlatformPosition использует push_back для добавления переданной
позиции платформы в вектор. Каждый раз, когда мы создаем новую платформу
Реализация игровой логики 407
в фабрике, мы будем вызывать эту функцию. Мы также увеличиваем переменную m_NumberOfPlatforms, чтобы отслеживать количество платформ.
Функция getPausedPointer возвращает адрес логической переменной m_IsPaused,
предоставляя постоянный доступ к состоянию игры для любого кода, который
запрашивает и сохраняет этот адрес.
Затем добавьте функцию positionLevelAtStart в файл LevelUpdate.cpp:
void LevelUpdate::positionLevelAtStart()
{
float startOffset = m_PlatformPositions[0]->left;
for (int I = 0; i < m_NumberOfPlatforms; ++i)
{
m_PlatformPositions[i]->left = i * 100 + startOffset;
m_PlatformPositions[i]->top = 0;
m_PlatformPositions[i]->width = 100;
m_PlatformPositions[i]->height = 20;
}
m_PlayerPosition->left =
m_PlatformPositions[m_NumberOfPlatforms / 2]->left + 2;
m_PlayerPosition->top =
m_PlatformPositions[m_NumberOfPlatforms / 2]->top - 22;
m_MoveRelativeToPlatform = m_NumberOfPlatforms - 1;
m_NextPlatformToMove = 0;
}
В функции positionLevelAtStart первая строка кода инициализирует переменную типа float с именем startOffset, получая левую координату первой
платформы в векторе. Далее код перебирает все платформы в векторе от нуля
до m_NumberOfPlatforms. Каждая итерация цикла позиционирует платформу на
i * 100 + startOffset единиц стартового смещения по горизонтали, ноль единиц
по вертикали, 100 единиц по ширине и 20 по высоте. Это можно было бы сделать
в фабрике, но, когда игрок начнет вторую попытку, платформы, скорее всего,
будут разбросаны повсюду. В результате все платформы выстраиваются в линию
друг за другом без изменений в размере или высоте. Это своего рода легкий старт
для игрока перед тем, как они начнут организовываться случайным образом.
Две строки кода после цикла for отвечают за позиционирование персонажа на левом крае средней платформы в векторе с помощью [m_NumberOfPlatforms / 2]. Магические числа +2 по горизонтали и -22 по вертикали используются для того, чтобы
убедиться, что ноги игрового персонажа расположены строго на этой платформе.
Попробуйте улучшить данный код, после того как мы напишем класс Factory.
Следующая строка кода инициализирует m_MoveRelativeToPlatform последней
платформой в векторе. Это имеет смысл, потому что мы хотим размещать новые
платформы за правым краем самой дальней платформы. Последняя строка кода
устанавливает первую платформу в векторе в качестве следующего кандидата
408 Глава 16. Звук, игровая логика, межобъектное взаимодействие и персонаж
на перемещение. Это означает, что платформа, находящаяся в самом дальнем
левом углу, будет перемещена в самый дальний правый угол, а игровой персонаж
появится по центру.
Затем добавьте функцию getRandomNumber. Она делает именно то, что следует из
ее названия, и мы применим ее, когда нам понадобится сгенерировать случайное
значение между двумя переданными в нее значениями. Добавьте следующий код
в файл LevelUpdate.cpp:
int LevelUpdate::getRandomNumber(int minHeight, int maxHeight)
{
#include <random>
// Инициализация генератора случайных чисел текущим временем
random_device rd;
mt19937 gen(rd());
// Определение равномерного распределения для заданного диапазона
uniform_int_distribution<int>
distribution(minHeight, maxHeight);
// Генерация случайного числа в указанном диапазоне
int randomHeight = distribution(gen);
return randomHeight;
}
Эта функция представляет собой более современный способ генерации случайных чисел, чем тот, который мы использовали в предыдущих играх.
Первая строка создает случайный объект random_device с именем rd, который
используется для инициализации генератора случайных чисел. random_device —
это источник недетерминированных случайных чисел, часто основанных на
аппаратных значениях. Это гораздо более надежный метод, чем те, которые мы
применяли ранее.
Затем генератор псевдослучайных чисел Вихрь Мерсена (Mersenne Twister)
(mt19937) инициализируется зерном (seed) случайного устройства (rd). Вихрь
Мерсена — это широко используемый алгоритм для генерации качественных
случайных чисел.
Далее экземпляр uniform_int_distribution с именем distribution создает объект
равномерного распределения для генерации целых чисел в указанном диапазоне
(от minHeight до maxHeight включительно). Класс uniform_int_distribution гарантирует, что каждое целое число в диапазоне имеет равную вероятность быть
выбранным.
Код distribution(gen) генерирует случайное целое число, используя ранее заданное распределение и генератор Мерсена. Результат сохраняется в переменной
randomHeight.
Наконец, случайно сгенерированное число возвращается в вызывающий код.
Все, что вам нужно помнить, — это то, что если вы вызовете эту функцию, то
Реализация игровой логики 409
получите действительно случайное значение в диапазоне между двумя переданными значениями.
Последняя функция класса LevelUpdate — это функция update. Напомним, что
функция update вызывается каждый кадр классом GameObject, который, в свою
очередь, вызывается каждый кадр игровым циклом. Это довольно сложная функция, обрабатывающая всю игровую логику. Попробуйте изучить ее структуру
самостоятельно, а затем мы вместе разберем, как она работает.
Добавьте функцию update в класс LevelUpdate. Я рекомендую скопировать или
вручную набрать всю функцию целиком, так как, если писать ее по частям, очень
легко перепутать структуру:
void LevelUpdate::update(float timeSinceLastUpdate)
{
if (!m_IsPaused)
{
if (m_GameOver)
{
m_GameOver = false;
*m_CameraTime = 0;
m_TimeSinceLastPlatform = 0;
int platformToPlacePlayerOn;
positionLevelAtStart();
}
*m_CameraTime += timeSinceLastUpdate;
m_TimeSinceLastPlatform += timeSinceLastUpdate;
if (m_TimeSinceLastPlatform > m_PlatformCreationInterval)
{
m_PlatformPositions[m_NextPlatformToMove]->top =
m_PlatformPositions[m_MoveRelativeToPlatform]->top +
getRandomNumber(-40, 40);
// Расстояние до следующей платформы
// Больший разрыв, если ниже предыдущей
if (m_PlatformPositions[m_MoveRelativeToPlatform]->top
< m_PlatformPositions[m_NextPlatformToMove]->top)
{
m_PlatformPositions[m_NextPlatformToMove]->left =
m_PlatformPositions[m_MoveRelativeToPlatform]->left +
m_PlatformPositions[m_MoveRelativeToPlatform]->width +
getRandomNumber(20, 40);
}
else
{
m_PlatformPositions[m_NextPlatformToMove]->left =
m_PlatformPositions[m_MoveRelativeToPlatform]->left +
m_PlatformPositions[m_MoveRelativeToPlatform]->width +
getRandomNumber(0, 20);
}
410 Глава 16. Звук, игровая логика, межобъектное взаимодействие и персонаж
m_PlatformPositions[m_NextPlatformToMove]->width =
getRandomNumber(20, 200);
m_PlatformPositions[m_NextPlatformToMove]->height =
getRandomNumber(10, 20);
// Время до создания следующей платформы
// на основе ширины только что созданной
m_PlatformCreationInterval =
m_PlatformPositions[m_NextPlatformToMove]->width / 90;
m_MoveRelativeToPlatform = m_NextPlatformToMove;
m_NextPlatformToMove++;
if (m_NextPlatformToMove == m_NumberOfPlatforms)
{
m_NextPlatformToMove = 0;
}
}
m_TimeSinceLastPlatform = 0;
// Проверка, убежал ли персонаж от самой дальней платформы
bool laggingBehind = true;
for (auto platformPosition : m_PlatformPositions)
{
if (platformPosition->left < m_PlayerPosition->left)
{
laggingBehind = false;
break; // Хотя бы одна платформа позади игрового персонажа
}
else
{
}
}
}
}
laggingBehind = true;
if (laggingBehind)
{
m_IsPaused = true;
m_GameOver = true;
SoundEngine::pauseMusic();
}
Мы разобьем этот длинный код на несколько частей, но, пожалуйста, обязательно изучите его целиком. Особое внимание уделите структуре операторов if
и циклов. Функция update получает время выполнения предыдущей итерации
основного игрового цикла в переменной timeSinceLastUpdate.
Реализация игровой логики 411
Первая часть функции update задает структуру, которая запускается только тогда,
когда игра не приостановлена. Все остальное, что мы обсуждаем, происходит внутри
этого оператора if, то есть ничего не происходит, когда игра поставлена на паузу:
if (!m_IsPaused)
{
Затем в функции update следует код, который выполняется только тогда, когда
игра завершена, — другими словами, когда игрок либо только что запустил приложение, либо персонаж только что погиб и игра еще не перезапустилась:
if (m_GameOver)
{
m_GameOver = false;
*m_CameraTime = 0;
m_TimeSinceLastPlatform = 0;
positionLevelAtStart();
}
Здесь переменная m_GameOver устанавливается в false, таймер сбрасывается,
время с момента создания последней платформы обнуляется, и вызывается
функция positionLevelAtStart, которую мы уже обсудили. В итоге данный блок
кода выполняется только один раз и делает все необходимое для запуска новой
игры (после выполнения остального кода).
Далее в функции update следует код:
*m_CameraTime += timeSinceLastUpdate;
m_TimeSinceLastPlatform += timeSinceLastUpdate;
if (m_TimeSinceLastPlatform > m_PlatformCreationInterval)
{
m_PlatformPositions[m_NextPlatformToMove]->top =
m_PlatformPositions[m_MoveRelativeToPlatform]->top
+ getRandomNumber(-40, 40);
...
В приведенном коде переменная m_CameraTime увеличивается на время, прошедшее с момента последнего вызова update. Это время, которое в итоге будет показано игроку. Аналогичным образом увеличивается и переменная m_
TimeSinceLastPlatform.
Далее следует оператор if, который выполняется, когда m_TimeSinceLastPlatform
превышает m_PlatformCreationInterval. Другими словами, когда пришло время
переместить платформу из зоны за игровым персонажем в зону перед ним. Затем
платформа, находящаяся дальше всего за персонажем (m_NextPlatformToMove),
случайным образом позиционируется относительно платформы, располагающейся дальше всего перед ним (m_MoveRelativeToPlatform), но на этом этапе
корректируется только высота.
412 Глава 16. Звук, игровая логика, межобъектное взаимодействие и персонаж
Кроме того, внутри этого оператора if есть структура if-else, которая отвечает
за горизонтальное позиционирование. Взгляните на нее еще раз:
// Расстояние до следующей платформы
// Больший разрыв, если ниже предыдущей
if (m_PlatformPositions[m_MoveRelativeToPlatform]->top
< m_PlatformPositions[m_NextPlatformToMove]->top)
{
m_PlatformPositions[m_NextPlatformToMove]->left =
m_PlatformPositions[m_MoveRelativeToPlatform]->left
+ m_PlatformPositions[m_MoveRelativeToPlatform]
->width + getRandomNumber(20, 40);
}
else
{
m_PlatformPositions[m_NextPlatformToMove]->left =
m_PlatformPositions[m_MoveRelativeToPlatform]->left +
m_PlatformPositions[m_MoveRelativeToPlatform]
->width + getRandomNumber(0, 20);
}
В предыдущих операторах if-else часть if проверяет, насколько далеко по вертикали предыдущая строка кода расположила следующую платформу. Если она
ниже, то выполняется код в блоке if, а если выше — в блоке else. Код, связанный
с else, использует меньшие значения для расстояния между платформами по
горизонтали, что имеет смысл, поскольку, если платформа расположена выше
платформы, на которой находится персонаж, расстояние для прыжка будет меньше.
Далее выполняется следующий код:
m_PlatformPositions[m_NextPlatformToMove]->width =
getRandomNumber(20, 200);
m_PlatformPositions[m_NextPlatformToMove]->height =
getRandomNumber(10, 20);
// Время до создания следующей платформы
// на основе ширины только что созданной
m_PlatformCreationInterval =
m_PlatformPositions[m_NextPlatformToMove]->width / 90;
m_MoveRelativeToPlatform = m_NextPlatformToMove;
m_NextPlatformToMove++;
if (m_NextPlatformToMove == m_NumberOfPlatforms)
{
m_NextPlatformToMove = 0;
}
m_TimeSinceLastPlatform = 0;
Здесь выбирается случайная ширина для новой платформы, затем случайная высота, а потом количество времени, основанное на случайно выбранной ширине,
инициализируется в m_PlatformCreationInterval. Следующая строка увеличивает позицию в векторе для следующей платформы, которую нужно переместить,
Реализация игровой логики 413
а оператор if проверяет, выходит ли это значение за пределы последней позиции
в векторе, и, если да, устанавливает значение на ноль (первая запись в векторе).
Последняя строка кода выше устанавливает m_TimeSinceLastPlatform в ноль,
чтобы мы могли продолжать добавлять время, затраченное на каждую итерацию
цикла, пока в конце концов не переместим еще одну платформу и не повторим
все заново.
На данном этапе завершается работа блока if (m_TimeSinceLastPlatform >
m_PlatformCreationInterval).
После if(!m_Paused) и всей функции update код проверяет, догнали ли героя
исчезающие платформы (и, следовательно, проиграл ли он):
// Проверка, убежал ли игровой персонаж от самой дальней платформы
bool laggingBehind = true;
for (auto platformPosition : m_PlatformPositions)
{
if (platformPosition->left < m_PlayerPosition->left)
{
laggingBehind = false;
break; // Хотя бы одна платформа позади игрового персонажа
}
else
{
laggingBehind = true;
}
}
if (laggingBehind)
{
m_IsPaused = true;
m_GameOver = true;
SoundEngine::pauseMusic();
}
В данном коде логическая переменная laggingBehind устанавливается равной
true. Далее цикл for проходит по каждой позиции платформы, проверяя, не находится ли какая-либо из платформ слева от героя (и, следовательно, позади него).
Если хотя бы одна платформа находится позади, то переменная laggingBehind
устанавливается в false.
Если laggingBehind остается равной true, это означает, что все платформы впереди и игра считается завершенной: она ставится на паузу, переменная m_GameOver
устанавливается в true, а музыка приостанавливается.
Вскоре мы создадим меню, которое позволит игроку перезапустить игру после
проигрыша.
Мы завершили объяснение и написание функции update, но пока не готовы запустить наш код, так как в нем есть ошибки, связанные с классом PlayerUpdate.
Кроме того, мы не создали ни одного экземпляра класса LevelUpdate.
414 Глава 16. Звук, игровая логика, межобъектное взаимодействие и персонаж
Далее мы напишем базовые классы игрового персонажа, создав объект из Update
под названием PlayerUpdate и объект из Graphics с именем PlayerGraphics. Затем,
чтобы закончить код для этой главы, мы добавим код в фабрику, которая соберет
все эти различные компоненты и поместит их в экземпляры GameObject, которые
мы можем обрабатывать в каждом кадре игры. Кроме того, мы используем класс
InputReceiver вместе с классом PlayerUpdate и увидим, как управление игровым
персонажем передается связанным с ним классам.
Создание игрового персонажа
В этом разделе мы начнем создавать управляемого игроком персонажа. Мы отобразим его на экране, но затем вернемся к классам PlayerUpdate и PlayerGraphics,
чтобы добавить анимацию и управление с клавиатуры.
Создайте два новых класса: PlayerUpdate и PlayerGraphics, которые используют
Update и Graphics в качестве базового класса соответственно. После следующих
двух разделов у нас будет видимый, но не полностью функционирующий игровой персонаж.
Класс PlayerUpdate
Начнем с определения класса PlayerUpdate. Добавьте следующий код в Player
Update.h:
#pragma once
#include "Update.h"
#include "InputReceiver.h"
#include <SFML/Graphics.hpp>
using namespace sf;
class PlayerUpdate :
public Update
{
private:
const float PLAYER_WIDTH = 20.f;
const float PLAYER_HEIGHT = 16.f;
FloatRect m_Position;
bool* m_IsPaused = nullptr;
float m_Gravity = 165;
float m_RunSpeed = 150;
float m_BoostSpeed = 250;
InputReceiver m_InputReceiver;
Clock m_JumpClock;
bool m_SpaceHeldDown = false;
Создание игрового персонажа 415
float m_JumpDuration = .50;
float m_JumpSpeed = 400;
public:
bool
bool
bool
bool
bool
m_RightIsHeldDown = false;
m_LeftIsHeldDown = false;
m_BoostIsHeldDown = false;
m_IsGrounded;
m_InJump = false;
FloatRect* getPositionPointer();
bool* getGroundedPointer();
void handleInput();
InputReceiver* getInputReceiver();
// Из Update : Component
void assemble(
shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate)
override;
void update(float fps) override;
};
В приведенном коде у нас множество переменных и пять функций. Давайте
перечислим их:
zzконстанты типа float с именами PLAYER_WIDTH и PLAYER_HEIGHT определяют
ширину и высоту персонажа в игровых единицах соответственно;
zzэкземпляр FloatRect под названием m_Position хранит местоположение
игрового персонажа. Вскоре мы увидим, что этот экземпляр будет использоваться всеми игровыми объектами, которым он нужен. Платформы будут
обращаться к нему для обнаружения коллизий, а класс LevelUpdate, как мы
видели в предыдущем разделе, — для определения, отстает ли персонаж от
платформ настолько, что игра считается законченной;
zzлогический указатель m_IsPaused применяется для подключения к переменной
класса LevelUpdate, которая отвечает за приостановку игры;
zzтри переменные типа float — m_Gravity, m_RunSpeed и m_BoostSpeed: первая —
это значение силы, которая тянет героя вниз, когда тот не стоит на платформе,
а также регулирует силу подъема при рывке, вторая задает скорость передвижения героя влево или вправо, а третья определяет скорость, с которой
он может двигаться вверх при рывке;
zzэкземпляр InputReceiver с именем m_InputReceiver является экземпляром
класса InputReceiver . Вскоре мы увидим, как класс Factory подключает
m_InputReceiver к InputDispatcher, что позволяет классу PlayerUpdate получать доступ ко всем событиям клавиатуры и мыши;
416 Глава 16. Звук, игровая логика, межобъектное взаимодействие и персонаж
zzm_JumpClock — экземпляр Clock — логическая переменная m_SpaceHeldDown,
переменные с плавающей точкой m_JumpDuration и m_JumpSpeed — мы будем
использовать их все, чтобы регулировать дальность и продолжительность
прыжка игрового персонажа;
zzпервой публичной переменной является логическая m_RightIsHeldDown, за
ней следуют другие: m_LeftIsHeldDown , m_BoostIsHeldDown , m_IsGrounded
и m_InJump. Все эти значения могут быть установлены и сброшены в зависимости от того, как игрок взаимодействует с клавиатурой. Затем на них можно
реагировать в функции update;
zzфункция getPositionPointer возвращает указатель FloatRect, который предоставляет доступ к m_Position для любого другого класса, которому это нужно.
Данная функция будет вызываться в фабрике теми классами, которым это
необходимо;
zzфункция getGroundedPointer возвращает указатель на логическую переменную, которая сообщает, находится ли персонаж на платформе, как это
определяется логической переменной m_IsGrounded;
zzфункция handleInput будет использовать экземпляр InputReceiver для
обработки всех данных ввода, получаемых в каждом кадре от экземпляра
InputDispatcher в основном игровом цикле;
zzфункция getInputReceiver возвращает указатель на экземпляр InputReceiver.
Для ее реализации потребуется всего одна строка кода, но она позволит экземпляру InputDispatcher в функции main делиться всеми событиями с классом
PlayerUpdate;
zzфункция assemble — это наша реализация чистой виртуальной функции из
класса Update. В качестве параметров она принимает shared_ptr<LevelUpdate>
levelUpdate и shared_ptr<PlayerUpdate> playerUpdate. Это позволяет подготовить класс PlayerUpdate к работе, вызвав любые публичные функции класса
LevelUpdate. Конечно, передавать PlayerUpdate самому себе не обязательно,
но это упрощает реализацию системы компонентов сущностей;
zzфункция update — это наша реализация чистой виртуальной функции из
класса Update, которая просто получает время, затраченное игровым циклом
на выполнение. То, что мы делаем с этим временем внутри функции, будет
более интересным.
Поскольку нам предстоит добавить довольно много кода в PlayerUpdate.cpp,
мы сделаем это в три шага. Сперва добавьте следующий код в PlayerUpdate.cpp:
#include "PlayerUpdate.h"
#include "SoundEngine.h"
#include "LevelUpdate.h"
FloatRect* PlayerUpdate::getPositionPointer()
{
return &m_Position;
}
Создание игрового персонажа 417
bool* PlayerUpdate::getGroundedPointer()
{
return &m_IsGrounded;
}
InputReceiver* PlayerUpdate::getInputReceiver()
{
return &m_InputReceiver;
}
Здесь мы добавляем необходимые директивы include. Функция getPosition
Pointer возвращает адрес экземпляра FloatRect, в котором хранится позиция
игрока. Функция getGroundedPointer возвращает адрес логической функции,
которая определяет, стоит ли игровой персонаж в данный момент на платформе.
Интересно, что платформы будут определять и устанавливать это логическое значение (используя данный указатель), а класс PlayerGraphics будет использовать
его для принятия решений об анимации (используя этот указатель). Функция
getInputReceiver возвращает указатель на экземпляр InputReceiver, что позволяет InputDispatcher подключаться и передавать все необходимые данные
о событиях.
Теперь добавьте следующий код:
void PlayerUpdate::assemble(
shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate)
{
SoundEngine::SoundEngine();
m_Position.width = PLAYER_WIDTH;
m_Position.height = PLAYER_HEIGHT;
m_IsPaused = levelUpdate->getIsPausedPointer();
}
Во второй части кода PlayerUpdate мы написали функцию assemble. Обратите
внимание, что, как уже говорилось, параметр PlayerUpdate не используется. Класс
SoundEngine инициализирован и готов воспроизводить звуки, позиция и высота
инициализированы, и, что самое интересное, общий указатель LevelUpdate применяется для вызова функции getIsPausedPointer и инициализации m_IsPaused.
Теперь класс PlayerUdate всегда может проверить, не приостановлена ли игра.
Далее вам нужно добавить третий и последний фрагменты кода (в этой главе)
для PlayerUpdate.cpp:
void PlayerUpdate::handleInput()
{
m_InputReceiver.clearEvents();
}
void PlayerUpdate::update(float timeTakenThisFrame)
{
handleInput();
}
418 Глава 16. Звук, игровая логика, межобъектное взаимодействие и персонаж
В третьей и последней частях кода для файла PlayerUpdate.cpp функция
handleInput вызывает функцию clearEvents для экземпляра m_InputReceiver.
Данное действие должно очищать события, чтобы подготовить их к следующей
итерации цикла. Пока это ничего не дает, потому что мы еще не получили ни одного события, но мы займемся этим в главе 18. Наконец, мы добавили функцию
обновления update. Она лишь вызывает функцию, которую мы только что написали. Однако в главе 18 мы запрограммируем полностью функционирующего
и реагирующего на события персонажа игры.
Класс PlayerGraphics
Мы заложили основу поведения персонажа, управляемого игроком. Далее мы
пропишем внешний вид, расширив класс Graphics классом PlayerGraphics .
Как и в случае с классом PlayerUpdate, мы просто начнем с базовых элементов
и будем развивать их по мере продвижения проекта. Добавьте следующий код
в PlayerGraphics.h:
#pragma once
#include "Graphics.h"
// Мы вернемся к этому позже
// class Animator;
class PlayerUpdate;
class PlayerGraphics : public Graphics
{
private:
FloatRect* m_Position = nullptr;
int m_VertexStartIndex = -999;
// Мы вернемся к этому позже
//Animator* m_Animator;
IntRect* m_SectionToDraw = new IntRect;
IntRect* m_StandingStillSectionToDraw = new IntRect;
std::shared_ptr<PlayerUpdate> m_PlayerUpdate;
const int BOOST_TEX_LEFT = 536;
const int BOOST_TEX_TOP = 0;
const int BOOST_TEX_WIDTH = 69;
const int BOOST_TEX_HEIGHT = 100;
bool m_LastFacingRight = true;
public:
// Из Component : Graphics
void assemble(VertexArray& canvas,
shared_ptr<Update> genericUpdate,
IntRect texCoords) override;
void draw(VertexArray& canvas) override;
};
Обратите внимание на пару закомментированных ссылок на класс Animator.
Позже, в главе 18, мы анимируем нашего героя, чтобы он выглядел так, будто бежит. Мы также анимируем пламя на огненных шарах. Чтобы иметь возможность
запускать код без ошибок, в предыдущем коде класс Animator закомментирован.
Создание игрового персонажа 419
В приведенном коде у нас есть следующие объявления переменных и функций:
zzпеременная FloatRect m_Position устанавливается в значение nullptr
и представляет позицию персонажа. Переменная типа int с именем m_
VertexStartIndex будет содержать позицию в массиве VertexArray, с которой
начнется четырехугольник, представляющий персонажа. Таким образом, когда
придет время перемещать персонажа, мы будем знать, что интересующие нас
вершины будут иметь индексы m_VertexStartIndex, m_VertexStartIndex+1,
m_VertexStartIndex+2 и m_VertexStartIndex+3;
zzпеременная m_SectionToDraw — это указатель на IntRect. Мы планируем хранить в ней целочисленные текстурные координаты внутри текстурного атласа
для текущего кадра анимации персонажа. Класс Animate будет изменять эти
значения по мере необходимости. Мы рассмотрим класс Animate в главе 18;
zzуказатель IntRect m_StandingStillSectionToDraw содержит текстурные координаты, когда персонаж стоит на месте;
zzпеременная shared_ptr<PlayerUpdate> m_PlayerUpdate — это указатель на
экземпляр PlayerUpdate. Наличие указателя на этот экземпляр позволит вызывать все публичные функции и читать все публичные переменные класса
PlayerUpdate;
zzконстанты int BOOST_TEX_LEFT, BOOST_TEX_TOP, BOOST_TEX_WIDTH и BOOST_TEX_
HEIGHT инициализируются координатами кадра анимации, изображающего
рывок игрового персонажа, в текстурном атласе;
zzлогическая переменная m_LastFacingRight инициализируется значением true
и будет отслеживать, в какую сторону обращен персонаж. Это понадобится
для корректной анимации при смене направления движения;
zzфункция assemble — это переопределенная реализация класса Graphics. Функция assemble принимает ссылку на VertexArray с именем canvas и указатель
shared_ptr<Update> под названием genericUpdate. В каждом унаследованном
от Update классе мы увидим, как преобразовать его в конкретный вариант
Update, чтобы получить доступ к его публичным функциям. Функция assemble
также получает экземпляр IntRect с именем texCoords, который будет содержать текстурные координаты для изображения в текстурном атласе;
zzфункция draw принимает ссылку на VertexArray, и вызывается каждый кадр.
Это позволяет функции draw обрабатывать перемещение координат вершин
или текстур при каждом обновлении кадра в игре.
Реализуем все эти функции и воспользуемся переменными, которые мы обсуждали. Добавьте следующий код в PlayerGraphics.cpp:
#include "PlayerGraphics.h"
#include "PlayerUpdate.h"
void PlayerGraphics::assemble(
VertexArray& canvas,
shared_ptr<Update> genericUpdate,
420 Глава 16. Звук, игровая логика, межобъектное взаимодействие и персонаж
{
IntRect texCoords)
m_PlayerUpdate =
static_pointer_cast<PlayerUpdate>(genericUpdate);
m_Position =
m_PlayerUpdate->getPositionPointer();
m_VertexStartIndex = canvas.getVertexCount();
canvas.resize(canvas.getVertexCount() + 4);
canvas[m_VertexStartIndex].texCoords.x =
texCoords.left;
canvas[m_VertexStartIndex].texCoords.y =
texCoords.top;
canvas[m_VertexStartIndex + 1].texCoords.x =
texCoords.left + texCoords.width;
canvas[m_VertexStartIndex + 1].texCoords.y =
texCoords.top;
canvas[m_VertexStartIndex + 2].texCoords.x =
texCoords.left + texCoords.width;
canvas[m_VertexStartIndex + 2].texCoords.y =
texCoords.top + texCoords.height;
canvas[m_VertexStartIndex + 3].texCoords.x =
texCoords.left;
canvas[m_VertexStartIndex + 3].texCoords.y =
texCoords.top + texCoords.height;
}
void PlayerGraphics::draw(VertexArray& canvas)
{
const Vector2f& position =
m_Position->getPosition();
const Vector2f& scale =
m_Position->getSize();
canvas[m_VertexStartIndex].position =
position;
canvas[m_VertexStartIndex + 1].position =
position + Vector2f(scale.x, 0);
canvas[m_VertexStartIndex + 2].position =
position + scale;
canvas[m_VertexStartIndex + 3].position =
position + Vector2f(0, scale.y);
}
Часть приведенного кода может показаться знакомой, потому что мы присваиваем координаты вершин и текстур экземпляру VertexArray библиотеки SFML, как
мы делали это для фона в игре про зомби. Мы сделаем нечто подобное в каждом
классе, полученном из Graphics.
Однако здесь мы работаем в другом контексте, поэтому давайте проверим, как
выполняется весь код, который мы только что добавили, разделив его на четыре
части.
Создание игрового персонажа 421
Во-первых, у нас есть сигнатура этой функции:
void PlayerGraphics::assemble(
VertexArray& canvas,
shared_ptr<Update> genericUpdate,
IntRect texCoords)
{
...
...
}
Первая часть файла PlayerGraphics.cpp — это сигнатура функции assemble.
Напомним, что функция assemble является переопределенной реализацией
класса Graphics. Она принимает ссылку на VertexArray с именем canvas, умный
указатель shared_ptr<Update> под названием genericUpdate, а также экземпляр
IntRect с именем texCoords, который будет содержать текстурные координаты
для изображений в текстурном атласе.
Внутри фигурных скобок функции assemble у нас есть следующий код:
m_PlayerUpdate = static_pointer_cast<PlayerUpdate>(genericUpdate);
m_Position = m_PlayerUpdate->getPositionPointer();
m_VertexStartIndex = canvas.getVertexCount();
canvas.resize(canvas.getVertexCount() + 4);
canvas[m_VertexStartIndex].texCoords.x = texCoords.left;
canvas[m_VertexStartIndex].texCoords.y = texCoords.top;
canvas[m_VertexStartIndex + 1].texCoords.x =
texCoords.left + texCoords.width;
canvas[m_VertexStartIndex + 1].texCoords.y = texCoords.top;
canvas[m_VertexStartIndex + 2].texCoords.x =
texCoords.left + texCoords.width;
canvas[m_VertexStartIndex + 2].texCoords.y =
texCoords.top + texCoords.height;
canvas[m_VertexStartIndex + 3].texCoords.x = texCoords.left;
canvas[m_VertexStartIndex + 3].texCoords.y =
texCoords.top + texCoords.height;
Здесь используется функция static_pointer_cast для преобразования экземпляра базового класса Update в экземпляр дочернего класса PlayerUpdate, а затем
результат сохраняется в m_PlayerUpdate.
Далее мы инициализируем m_VertexStartIndex вызовом canvas.getVertexCount
и добавляем в массив вершин достаточно места для еще одного четырехугольника
через вызов canvas.resize. В следующих восьми строках кода мы инициализируем все текстурные координаты персонажа, управляемого игроком, в массиве
VertexArray , используя IntRect texCoords , который был передан в качестве
параметра.
422 Глава 16. Звук, игровая логика, межобъектное взаимодействие и персонаж
И наконец, у нас есть этот код:
void PlayerGraphics::draw(VertexArray& canvas)
{
const Vector2f& position = m_Position->getPosition();
const Vector2f& scale = m_Position->getSize();
canvas[m_VertexStartIndex].position = position;
canvas[m_VertexStartIndex + 1].position =
position + Vector2f(scale.x, 0);
canvas[m_VertexStartIndex + 2].position = position + scale;
canvas[m_VertexStartIndex + 3].position =
position + Vector2f(0, scale.y);
}
Третья и последняя часть файла PlayerGraphics.cpp — это функция draw. Пока
мы просто используем функцию m_Position.getPosition для инициализации
экземпляра Vector2f с именем position и функцию getSize для инициализации
Vector2f с именем scale. Затем мы задействуем position и scale, чтобы установить позиции вершин игрового персонажа в массиве VertexArray.
Мы завершим обновление и обработку ввода класса PlayerUpdate, а также анимацию и класс Animator для класса PlayerGraphics, когда добавим классы, связанные с камерой, чтобы видеть персонажа должным образом, а также платформы,
по которым он будет бегать.
Создание фабрики для использования
всех наших новых классов
Factory — это важный класс. В нем мы создадим все наши умные указатели на производные экземпляры Update и Graphics. Мы будем вызывать все конструкторы
и собирать реализации функций assemble, одновременно передавая различные необходимые указатели, которые уже написали. Например, именно через Factory мы
передадим указатель на персонажа и положение платформ экземпляру LevelUpdate.
Запоминание текстурных координат
Прежде всего добавим код в файл Factory.h в блок private:
const
const
const
const
const
const
const
const
const
const
const
int PLAYER_TEX_LEFT = 0;
int PLAYER_TEX_TOP = 0;
int PLAYER_TEX_WIDTH = 80;
int PLAYER_TEX_HEIGHT = 96;
float CAM_VIEW_WIDTH = 300.f;
float CAM_SCREEN_RATIO_LEFT = 0.f;
float CAM_SCREEN_RATIO_TOP = 0.f;
float CAM_SCREEN_RATIO_WIDTH = 1.f;
float CAM_SCREEN_RATIO_HEIGHT = 1.f;
int CAM_TEX_LEFT = 610;
int CAM_TEX_TOP = 36;
Создание фабрики для использования всех наших новых классов 423
const
const
const
const
const
const
const
const
const
const
const
const
const
const
const
const
const
const
const
const
const
const
const
const
int CAM_TEX_WIDTH = 40;
int CAM_TEX_HEIGHT = 30;
float MAP_CAM_SCREEN_RATIO_LEFT = 0.3f;
float MAP_CAM_SCREEN_RATIO_TOP = 0.84f;
float MAP_CAM_SCREEN_RATIO_WIDTH = 0.4f;
float MAP_CAM_SCREEN_RATIO_HEIGHT = 0.15f;
float MAP_CAM_VIEW_WIDTH = 800.f;
float MAP_CAM_VIEW_HEIGHT = MAP_CAM_VIEW_WIDTH / 2;
int MAP_CAM_TEX_LEFT = 665;
int MAP_CAM_TEX_TOP = 0;
int MAP_CAM_TEX_WIDTH = 100;
int MAP_CAM_TEX_HEIGHT = 70;
int PLATFORM_TEX_LEFT = 607;
int PLATFORM_TEX_TOP = 0;
int PLATFORM_TEX_WIDTH = 10;
int PLATFORM_TEX_HEIGHT = 10;
int TOP_MENU_TEX_LEFT = 770;
int TOP_MENU_TEX_TOP = 0;
int TOP_MENU_TEX_WIDTH = 100;
int TOP_MENU_TEX_HEIGHT = 100;
int RAIN_TEX_LEFT = 0;
int RAIN_TEX_TOP = 100;
int RAIN_TEX_WIDTH = 100;
int RAIN_TEX_HEIGHT = 100;
Мы применим все новые значения констант по ходу работы над проектом. Переменные представляют собой текстурные координаты всех изображений в текстурном атласе. Обычно такие значения загружаются из файла, но для наших целей
это вполне подходит.
Наконец, чтобы использовать эти новые классы, инстанцируем и настроим их
в нашей фабрике.
Добавьте следующие директивы в Factory.cpp, чтобы мы могли воспользоваться
нашими вновь созданными классами:
#include
#include
#include
#include
#include
"Factory.h"
"LevelUpdate.h"
"PlayerGraphics.h"
"PlayerUpdate.h"
"InputDispatcher.h"
Добавьте этот код в функцию loadLevel в файле Factory.cpp, чтобы создать экземпляр LevelUpdate внутри объекта GameObject экземпляр:
// Создаем игровой объект уровня
GameObject level;
shared_ptr<LevelUpdate> levelUpdate = make_shared<LevelUpdate>();
level.addComponent(levelUpdate);
gameObjects.push_back(level);
Код инстанцирует экземпляр GameObject с именем level. Затем мы создаем общий указатель типа LevelUpdate, а после этого вызываем функцию addComponent
424 Глава 16. Звук, игровая логика, межобъектное взаимодействие и персонаж
для level и передаем в нее экземпляр LevelUpdate . Наконец, мы вызываем
функцию push_back для нашего вектора gameObjects. Это важный шаг, потому
что теперь у нас есть функционирующий экземпляр GameObject, который будет
обрабатываться в каждом кадре игрового цикла. Вы могли заметить, что мы еще
не вызвали функцию assemble. Скоро мы этим займемся.
Добавьте следующий код, чтобы создать экземпляр PlayerGraphics и Player
Update внутри экземпляра GameObject для функции loadLevel:
// Создаем объект игрового персонажа
GameObject player;
shared_ptr<PlayerUpdate> playerUpdate =
make_shared<PlayerUpdate>();
playerUpdate->assemble(levelUpdate, nullptr);
player.addComponent(playerUpdate);
inputDispatcher.registerNewInputReceiver(
playerUpdate->getInputReceiver());
shared_ptr<PlayerGraphics> playerGraphics =
make_shared<PlayerGraphics>();
playerGraphics->assemble(canvas, playerUpdate,
IntRect(PLAYER_TEX_LEFT, PLAYER_TEX_TOP,
PLAYER_TEX_WIDTH, PLAYER_TEX_HEIGHT));
player.addComponent(playerGraphics);
gameObjects.push_back(player);
// Передаем LevelUpdate информацию о персонаже
levelUpdate->assemble(nullptr, playerUpdate);
В предыдущем коде мы создаем еще один экземпляр GameObject под названием
player и общий указатель PlayerUpdate с именем playerUpdate. Мы вызываем
функцию assemble на playerUpdate и передаем необходимые параметры, которые
включают общий указатель LevelUpdate, но мы передаем nullptr вместо указателя PlayerUpdate. Это, как уже говорилось, связано с упрощением используемой
нами системы «сущность — компонент». Класс PlayerUpdate, очевидно, не нуждается в копии самого себя.
Затем мы вызываем функцию addComponent для player и registerNewInputReceiver
для экземпляра InputDispatcher. Обратите внимание, что мы передаем требуемое значение, вызывая getInputReceiver на экземпляре PlayerUpdate .
На данный момент у нас есть еще один экземпляр GameObject, почти готовый
к итерации в игровом цикле в векторе gameObjects, а также установлена связь
со всеми событиями операционной системы, предоставляемыми библиотекой
SFML. Теперь мы можем перейти к экземпляру класса PlayerGraphics.
Мы инстанцируем экземпляр PlayerGraphics и вызываем функцию assemble,
передавая в нее VertexArray, экземпляр LevelUpdate и текстурные координаты.
Затем мы добавляем экземпляр GraphicsComponent в экземпляр GameObject с по-
Запуск игры 425
мощью функции addComponent и вызываем push_back, чтобы добавить GameObject
персонажа, управляемого игроком, в вектор gameObjects.
Последняя строка кода вызывает функцию assemble на levelUpdate, потому что
раньше мы не могли этого сделать, так как экземпляра PlayerUpdate еще не существовало.
Запуск игры
Если мы запустим игру на данном этапе, то по-прежнему получим пустой серый экран. Это происходит потому, что мы не отрисовываем наш VertexArray.
В следующей главе мы рассмотрим, как это сделать, чтобы создать обычный вид,
а также мини-карту. А пока просто добавьте следующую выделенную строку кода
в функцию main в файле Run.cpp прямо перед вызовом window.display:
// Временный код до следующей главы
window.draw(canvas, factory.m_Texture);
// Показываем новый кадр
window.display();
Теперь если вы запустите игру и очень внимательно посмотрите в левый верхний
угол экрана, то заметите крошечное статичное изображение игрового персонажа.
Я не предоставил скриншот, потому что оно слишком маленькое. Вы также, вероятно, обратили внимание на проигрываемый короткий музыкальный фрагмент.
Если во время тестирования кода вы предпочитаете работать в тишине, просто
удалите эти две строки из функции assemble в классе LevelUpdate, поскольку они
все равно были добавлены только для тестирования:
// Временная строка
SoundEngine::startMusic();
Мы научимся управлять воспроизведением и остановкой музыки, когда создадим
меню для нашей игры.
Если вы хотите поближе рассмотреть изображение персонажа, временно отредактируйте функцию assemble в файле PlayerUpdate.cpp, чтобы увеличить его
размер, как показано ниже:
void PlayerUpdate::assemble(
shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate)
{
SoundEngine::SoundEngine();
m_Position.width = PLAYER_WIDTH * 10;
m_Position.height = PLAYER_HEIGHT * 10;
m_IsPaused = levelUpdate->getIsPausedPointer();
}
426 Глава 16. Звук, игровая логика, межобъектное взаимодействие и персонаж
Запустите игру, и вы сможете увидеть четкое изображение персонажа в левом
верхнем углу экрана, как показано на рис. 16.1.
Рис. 16.1. Увеличенное изображение персонажа
Обязательно удалите * 10 из двух ранее отредактированных строк кода после
тестирования.
Мы могли бы легко добавить код, чтобы сделать персонажа управляемым, но
глава и так получилась довольно длинной. Мы займемся этим через пару глав,
когда настроим игровые объекты камеры и сможем лучше видеть его.
Резюме
В этой главе мы многого достигли: создали класс, связанный со звуком, который
также воспроизводит музыку в цикле, и класс, который обрабатывает всю игровую логику и инкапсулирует ее в GameObject, который выполняется один раз за
игровой цикл.
Мы заложили основы для игрового персонажа с помощью графического компонента и компонента обновления, созданных в рамках игрового объекта. В этом
и заключается суть системы «сущность — компонент». Данный процесс будет
повторяться для каждого типа сущностей в нашей игре.
Мы продолжили писать код фабрики, которая отвечает за сборку всех различных
игровых объектов и обмен соответствующими данными между ними.
В следующей главе мы сосредоточимся на графике и отрисовке, создав классы CameraGraphics и CameraUpdate, которые также будут унаследованными от
Graphics и Update.
17
Графика, камеры,
действие
Нам нужно подробно обсудить, как будет работать графика в этом проекте.
Поскольку в данной главе мы будем писать код для камер, выполняющих отрисовку, сейчас самое время поговорить и о графике. Если вы заглянете в папку
graphics, то обнаружите там всего одно изображение. Более того, мы еще ни разу
не вызывали в нашем коде window.draw. Мы обсудим, почему вызовы draw должны быть сведены к минимуму, а также реализуем классы Camera, которые будут
решать эту задачу за нас. Наконец, к концу главы мы сможем запустить игру и увидеть камеры в действии: одну для общего вида, и одну для мини-карты.
Готовый код для этой главы находится в папке Run3.
Камеры, вызовы функции draw
и класс View в SFML
Во всех наших предыдущих проектах все сущности в играх (за одним исключением) были графически представлены спрайтами. Это нормально, когда нужно
отрисовать несколько десятков или даже сотен сущностей. Скорость, с которой
SFML может отрисовывать каждый кадр нашей игры, напрямую зависит от
количества вызовов window.draw. Это не является недостатком SFML, а скорее
связано с тем, как OpenGL использует видеокарту.
Причина в том, что каждый раз, когда мы вызываем draw, происходит довольно
много скрытых от пользователя событий, чтобы настроить OpenGL для отрисовки. Цитирую сайт SFML:
«...каждый вызов [к draw] включает установку набора состояний OpenGL,
сброс матриц, изменение текстур и т. д. Все это требуется даже для простой
отрисовки двух треугольников (спрайта)».
Поэтому использование массива вершин и отрисовка нескольких изображений
одним вызовом draw — это очень хорошая идея. Это означает, что нам нужно
изменить подход к работе с графикой в целом. Вместо спрайтов для каждого
428 Глава 17. Графика, камеры, действие
игрового объекта у нас теперь будет начальный индекс в массиве вершин, а вместо сотен спрайтов и множества текстур — один массив вершин и одна текстура,
содержащая все изображения. Кроме того, каждый раз, когда мы выводили на
экран счет, время или любой другой текст, мы также выполняли вызов draw.
Классы SFML, связанные с текстом, настолько полезны, что мы не будем отказываться от них. В данном проекте мы будем использовать класс SFML Text
только в одном месте — для отображения времени в левом верхнем углу экрана.
Текст, относящийся к меню, статичен и не требует вычислений, поэтому он отрисовывается из изображений в текстурном атласе. Если вам интересно, как минимизировать вызовы draw при обилии текста, то просто обрабатывайте его так же,
как и обычные изображения, и предоставьте текстурный атлас с буквами, цифрами
от 0 до 9 и любыми знаками препинания, которые вам нужны. Это, конечно, посложнее, чем использование класса Text.
Все, что для этого требуется, — чтобы каждый символ (цифра, буква и т. д.),
рисуемый в текущем кадре, имел индекс в массиве вершин (и соответствующие
координаты). Мы уже работали с массивом вершин для фона игры про зомби
и с текстурными координатами в сочетании с индексом массива вершин для изображения персонажа в предыдущей главе.
Теперь мы напишем код для камер, которые будут обрабатывать вызов функции
draw, когда это необходимо. Камер будет две:
zzобычная камера — для двойного вызова функции draw (один раз для массива
вершин и один раз для таймера в левом верхнем углу);
zzкамера мини-карты — для одинарного вызова draw для отображения зоны
действия, показывающей игровой мир с другого ракурса.
Для этого каждой камере потребуются доступ к одному и тому же экземпляру
RenderWindow, а также настройки экземпляра View библиотеки SFML, определяющего положение камеры на экране, соотношение длины и ширины, а также
уровень масштабирования для достижения нужного эффекта.
Создание классов камеры
Мы создадим две камеры, и у каждой из них будет производный класс Update
и производный класс Graphics. Класс CameraUpdate будет обрабатывать движение
камеры за персонажем и взаимодействие с операционной системой через экземпляр InputReceiver. Классу CameraGraphics предстоит обрабатывать все изображения, обращаясь к данным CameraUpdate и храня копию текстурного атласа,
экземпляр RenderWindow и объект Text библиотеки SFML. Позже, в главе 21, мы
введем еще несколько функций (и вызовов draw), чтобы создать параллакс-фон
и красивый шейдерный эффект.
Создание классов камеры 429
Класс CameraUpdate
Создадим два новых класса для представления камер: CameraUpdate, который наследует от Update, и CameraGraphics, базовым классом которого является Graphics.
Как мы уже привыкли, эти классы будут обернуты в экземпляр GameObject для
использования в нашем игровом цикле.
Добавьте следующий код в файл CameraUpdate.h:
#pragma once
#include "Update.h"
#include "InputReceiver.h"
#include <SFML/Graphics.hpp>
using namespace sf;
class CameraUpdate :
public Update
{
private:
FloatRect m_Position;
FloatRect* m_PlayerPosition;
bool m_ReceivesInput = false;
InputReceiver* m_InputReceiver = nullptr;
public:
FloatRect* getPositionPointer();
void handleInput();
InputReceiver* getInputReceiver();
// Из Update : Component
void assemble(shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate) override;
void update(float fps) override;
};
В приведенном коде объявлен прямоугольник типа FloatRect с именем m_Posi
tion для хранения положения камеры. Прямоугольник типа float идеально
подходит для хранения длины и ширины положения камеры в пространстве,
в отличие от целочисленных позиций в пикселях.
Переменная с именем m_PlayerPosition типа FloatRect является указателем
и будет инициализирована адресом экземпляра типа FloatRect в классе Player
Update. Это позволит классу CameraUpdate следовать за персонажем по всему
игровому миру.
Логическая переменная m_ReceivesInput полезна тем, что только одна из наших
камер будет получать входные данные. Нам не нужно беспокоиться о дополнительных издержках на получение и обработку ввода для основной камеры, только
для камеры мини-карты. Это связано с тем, что основная камера просто следует
за персонажем и не управляется игроком. По умолчанию эта логическая переменная инициализируется значением false.
430 Глава 17. Графика, камеры, действие
Указатель m_InputReceiver используется для регистрации в InputDispatcher.
По умолчанию он имеет значение nullptr, поскольку, как уже говорилось, он
нужен только одной из двух наших камер.
Функция getPositionPointer позволит классу CameraGraphics отслеживать класс
CameraUpdate, возвращая адрес экземпляра типа FloatRect, который определяет
вид камеры. Таким образом, класс CameraUpdate будет отслеживать персонажа,
а класс CameraGraphics — этот класс.
handleInput будет вызываться один раз на каждой итерации игрового цикла из
функции update, но только в той камере, которая в ней нуждается.
Функция getInputReceiver вызывается из фабрики классом InputDispatcher.
Затем она может получить доступ и сохранить указатель на InputReceiver
в CameraUpdate.
assemble подготовит этот класс к работе. Напомним, что параметрами являются
shared_ptr<LevelUpdate> с именем levelUpdate и shared_ptr<PlayerUpdate> под
названием playerUpdate. Это первая функция, которую мы переопределяем из
класса Update. Как именно мы используем эти параметры, увидим очень скоро.
Функция update вызывается один раз за кадр и получает значение, равное длительности основного игрового цикла. Теперь мы можем перейти к реализации
этих функций и использованию всех этих переменных.
Для начала добавьте в файл CameraUpdate.cpp следующий код:
#include "CameraUpdate.h"
#include "PlayerUpdate.h"
FloatRect* CameraUpdate::getPositionPointer()
{
return &m_Position;
}
void CameraUpdate::assemble(
shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate)
{
m_PlayerPosition =
playerUpdate->getPositionPointer();
}
InputReceiver* CameraUpdate::getInputReceiver()
{
m_InputReceiver = new InputReceiver;
m_ReceivesInput = true;
return m_InputReceiver;
}
Функция getPositionPointer просто возвращает адрес переменной m_Position.
Функция assemble сохраняет адрес позиции персонажа в m_PlayerPosition, чтобы
к ней можно было всегда обращаться.
Создание классов камеры 431
Функция getInputReceiver инициализирует новый экземпляр InputReceiver,
устанавливает переменную m_ReceivesInput в true, а затем возвращает адрес
вновь созданного экземпляра. Эффект этой функции заключается в том, что наш
код будет инициализировать и передавать экземпляр InputReceiver только в том
случае, если мы его вызовем. Таким образом, в классе Factory мы можем легко
выбирать, какие камеры обрабатывают ввод, а какие нет. Если установить переменную m_ReceivesInput в true (значение по умолчанию равно false), каждый экземпляр класса будет знать, обрабатывает он ввод или нет. Короче говоря, каждый
класс будет предупрежден и подготовлен к тому, чтобы обрабатывать ввод или нет.
Далее добавьте следующий код в CameraUpdate.cpp сразу после предыдущего:
void CameraUpdate::handleInput()
{
m_Position.width = 1.0f;
for (const Event& event : m_InputReceiver->getEvents())
{
// Обработка события прокрутки колеса мыши для масштабирования
if (event.type == sf::Event::MouseWheelScrolled)
{
if (event.mouseWheelScroll.wheel ==
sf::Mouse::VerticalWheel)
{
// Изменение коэффициента масштабирования на основе дельты
m_Position.width *=
(event.mouseWheelScroll.delta > 0)
? 0.95f : 1.05f;
}
}
m_InputReceiver->clearEvents();
}
}
В приведенном коде функция handleInput отслеживает (прослушивает) прокрутку колеса мыши. Значение переменной m_Position.width установлено в 1 (скоро
мы увидим почему). Следующий код перебирает все события, как и в каждой
игре до сих пор, с той лишь разницей, что мы перехватываем события вызовом
функции m_InputReceiver->getEvents.
Внутри цикла событий нас интересует только одно событие — sf::Event::Mou
seWheelScrolled. При его обнаружении выполняется оператор if:
if (event.mouseWheelScroll.wheel == sf::Mouse::VerticalWheel)
Данный оператор проверяет, было ли прокручено колесо мыши, и если да, то
выполняется следующая строка кода:
m_Position.width *=
(event.mouseWheelScroll.delta > 0)
? 0.95f : 1.05f;
432 Глава 17. Графика, камеры, действие
Здесь изменяется значение m_Position.width в зависимости от направления прокрутки колеса мыши.
Значение, хранящееся в event.mouseWheelScroll.delta, описывает величину, на
которую было прокручено колесо мыши. Если значение положительное, значит,
колесико было прокручено вверх, если отрицательное — вниз.
Выражение (event.mouseWheelScroll.delta > 0) — это тернарный условный оператор. Он проверяет, больше ли значение 0. Если да, то выражение оценивается
как true, в противном случае — как false. В зависимости от результата оператора
выбирается одно из двух значений:
zzесли delta > 0, то есть колесико мыши было прокручено вверх, выбирается 0.95f;
zzесли delta <= 0, то есть колесико мыши было прокручено вниз, выбирается 1.05f.
Выбранное значение (0.95f или 1.05f) затем умножается на переменную m_Posi
tion.width, которую мы ранее инициализировали значением 1. Если результат
больше 0, m_Position.width уменьшается на 5 %, а если меньше — увеличивается на 5 %. Если колеса прокрутки не касались, то значение остается равным 1.
Что мы будем делать с этим значением в функции update, увидим совсем скоро.
Последняя строка кода в функции handleInput очищает все события, готовясь
к следующему кадру игры.
Наконец, для класса CameraUpdate добавьте функцию update в CameraUpdate.cpp:
void CameraUpdate::update(float fps)
{
if (m_ReceivesInput)
{
handleInput();
m_Position.left = m_PlayerPosition->left;
m_Position.top = m_PlayerPosition->top;
}
else
{
m_Position.left = m_PlayerPosition->left;
m_Position.top = m_PlayerPosition->top;
m_Position.width = 1;
}
}
Функция update проверяет, получает ли данный экземпляр ввод. Если да, она
вызывает handleInput. Это означает, что все экземпляры CameraUpdate, для которых мы вызываем getInputReceiver, выполнят этот код. В блоке if значения
переменных m_Position, left и top устанавливаются в то же положение, что
и у персонажа. Обратите внимание, что ключевая часть кода отсутствует — мы не задаем ширину. Таким образом, значение, которое мы задали для m_Position.width
Создание классов камеры 433
в функции handleInput, сохраняется. Когда класс CameraGraphics будет устанавливать параметры для экземпляра View библиотеки SFML в каждом кадре, это
приведет к эффекту масштабирования в зависимости от прокрутки колеса мыши.
В блоке else мы делаем то же самое, что и в блоке if, но дополнительно устанавливаем m_Position.width в значение 1. Когда блок else выполнится, класс
CameraGraphics не будет вызывать никакого масштабирования. Теперь перейдем
к классу CameraGraphics.
Класс CameraGraphics: часть 1
Мы видели, как работает класс CameraUpdate, как он условно реагирует на прокрутку колеса мыши и хранит это движение. Мы также увидели, как он хранит
позицию игрока в своих переменных left и top. Кроме того, мы узнали, что этот
класс (CameraGraphics) будет использовать все эти значения.
Добавьте следующий код в CameraGraphics.h:
#pragma once
#include "SFML/Graphics.hpp"
#include "Graphics.h"
using namespace sf;
class CameraGraphics :
public Graphics
{
private:
RenderWindow* m_Window;
View m_View;
int m_VertexStartIndex = -999;
Texture* m_Texture = nullptr;
FloatRect* m_Position = nullptr;
bool m_IsMiniMap = false;
// Для масштабирования мини-карты
const float MIN_WIDTH = 640.0f;
const float MAX_WIDTH = 2000.0f;
// Для отображения времени
Text m_Text;
Font m_Font;
int m_TimeAtEndOfGame = 0;
float m_Time = 0;
public:
CameraGraphics(RenderWindow* window,
Texture* texture,
Vector2f viewSize,
FloatRect viewport);
434 Глава 17. Графика, камеры, действие
float* getTimeConnection();
// Из Component : Graphics
void assemble(VertexArray& canvas,
shared_ptr<Update> genericUpdate,
IntRect texCoords) override;
};
void draw(VertexArray& canvas) override;
Здесь довольно много кода, так что давайте разберем его. В блоке private мы
объявляем указатель на RenderWindow с именем m_Window и экземпляр View под
названием m_View (обычно они не присутствуют в наших классах, унаследованных от Graphics). Они нужны классу CameraGraphics потому, что он отвечает за
отрисовку массива VertexArray, который будет содержать обновленные позиции
вершин и координаты текстур в каждом кадре. Это имеет смысл, потому что камера может управлять перемещением, масштабированием, а затем и отрисовкой.
В целом мы не ограничены по количеству камер. Мы можем разработать игру
для четырех человек с разделением экрана на четыре части, для двух игроков
с экраном пополам или, как мы собираемся сделать, полноэкранную камеру
и мини-карту.
Целочисленная переменная m_VertexStartIndex будет содержать начальный
индекс в массиве вершин четырехугольника камеры. Вы можете подумать, что
камера просто отрисовывает массив вершин, так зачем ей нужен собственный
четырехугольник? Это справедливый вопрос, поскольку часто камера не имеет
собственного четырехугольника и текстурных координат, но наша камера будет
иметь почти полностью прозрачный прямоугольник для создания границы между
мини-картой и основным экраном.
Указатель m_Texture типа Texture — это текстура, содержащая все наши изображения. Переменная m_Position — это тип FloatRect, хранящий размер и координаты вида камеры на мир.
Логическая переменная m_IsMiniMap поможет нам написать код, который будет
немного отличаться для общего вида и мини-карты. Вы могли бы легко создать
два отдельных класса, например MainCameraGraphics и RadarCameraGraphics,
и избежать нескольких операторов if в коде при желании.
Константы MIN_WIDTH и MAX_WIDTH фиксируют минимальный и максимальный размеры для отображения игрового мира, что необходимо, поскольку мы собираемся
писать код, позволяющий масштабировать мини-карту.
Члены Text, Font и float m_Time предназначены для отображения времени в левом верхнем углу экрана. В каждом кадре игры будет три вызова draw, по одному
для каждой камеры и один для текста, но мы будем вызывать draw для текста
только в основной камере. Позже, в главе 21, мы добавим четвертый вызов draw,
когда начнем работать над параллаксом.
Создание классов камеры 435
Целочисленная переменная m_TimeAtEndOfGame помогает нам показать время
после окончания игры.
В блоке public находится объявление функции конструктора. Конструктор принимает параметры для инициализации указателя на RenderWindow и указателя на
Texture, а также переменные для размера области отображения и области просмотра (viewport) камеры. Область просмотра — это концепция SFML, определяющая область экрана, на которую будет выводиться изображение. Это станет понятнее, когда мы напишем код для файла .cpp. А пока вернемся к текущему коду.
Функция getTimeConnection возвращает указатель на переменную m_Time, которая будет вызвана классом LevelUpdate. Это дает классу LevelUpdate возможность
изменять значение переменной m_Time, что, как мы увидим, приведет к изменению
текста в левом верхнем углу экрана.
assemble — это наша обычная переопределенная функция, которая принимает
VertexArray, общий указатель на экземпляр Update и текстурные координаты.
Функция draw также переопределена из класса Graphics, и для ее работы нужен
только массив VertexArray.
Теперь запрограммируем все эти функции, чтобы полностью понять их. Затем мы
углубимся в класс View библиотеки SFML. Добавьте в файл CameraGraphics.cpp
следующий код:
#include "CameraGraphics.h"
#include "CameraUpdate.h"
CameraGraphics::CameraGraphics(
RenderWindow* window, Texture* texture,
Vector2f viewSize, FloatRect viewport)
{
m_Window = window;
m_Texture = texture;
m_View.setSize(viewSize);
m_View.setViewport(viewport);
// Область просмотра мини-карты меньше 1
if (viewport.width < 1)
{
m_IsMiniMap = true;
}
else
{
// Только полноэкранная камера имеет текстовый объект,
// отображающий время
m_Font.loadFromFile("fonts/KOMIKAP_.ttf");
m_Text.setFont(m_Font);
m_Text.setFillColor(Color(255, 0, 0, 255));
m_Text.setScale(0.2f, 0.2f);
}
}
Мы только что добавили конструктор для класса CameraGraphics. В этом коде мы
начинаем с инициализации указателя RenderWindow и Texture. Размер области
436 Глава 17. Графика, камеры, действие
отображения устанавливается вызовом setSize с передачей параметра viewSize,
а область просмотра — вызовом setViewport с передачей viewport. Давайте оставим класс CameraGraphics ненадолго и более подробно рассмотрим класс View
библиотеки SFML.
Класс View
Чтобы лучше понять, что такое область просмотра, взгляните на рис. 17.1.
Рис. 17.1. Области просмотра
Здесь мы видим несколько примеров того, как можно настроить область просмот
ра экземпляра View, чтобы контролировать, где и какую часть экрана займет вызов отрисовки. Во всех наших предыдущих проектах мы использовали значения
по умолчанию, которые охватывают весь экран.
Область просмотра определяется с помощью FloatRect библиотеки SFML, где
значения left и top задают левый верхний угол области, а width и height — ширину и высоту. Важно понимать разницу между областью просмотра viewport
и размером viewsize (задаваемым функцией setSize).
Функция setSize определяет, сколько единиц игрового мира будет передаваться
в область отображения. Однако высота и ширина области просмотра отвечают
Создание классов камеры 437
за часть экрана, которая будет занята областью отображения. Например, можно
показать большую часть мира в очень маленькой области просмотра или маленькую часть мира в огромной области — все зависит от того, что подходит в игре.
Области просмотра задаются в нормализованных координатах. Это означает, что
минимальное возможное значение — 0, а максимальное — 1.
Таким образом, стандартная область просмотра, занимающая весь экран, имеет
значения top и left, равные 0, и height и width, равные 1. Дополнительные примеры помогут разобраться.
Обратитесь к примеру экрана 1 на рис. 17.1. Область просмотра, обозначенная буквой a, будет иметь следующие значения: left = 0, top = 0, width = 0.5
и height = 1. Получается, что область просмотра начинается в левом верхнем
углу (0) и занимает половину ширины экрана (0.5) и всю высоту (1).
Область просмотра под буквой b имеет следующие значения: left = 0.5, top = 0,
width = 0.5 и height = 1. Таким образом, по горизонтали она начинается с середины экрана (0.5), по вертикали — вверху (0), а по ширине занимает половину
экрана (0.5) и полную высоту (1).
Посмотрите на пример экрана 2 и потратьте немного времени, чтобы сопоставить
эти значения для областей просмотра a, b, c и d:
zzобласть просмотра a: left = 0, top = 0, width = 0.5, height = 0.5;
zzобласть просмотра b: left = 0.5, top = 0, width = 0.5, height = 0.5;
zzобласть просмотра c: left = 0, top = 0.5, width = 0.5, height = 0.5;
zzобласть просмотра d: left = 0.5, top = 0.5, width = 0.5, height = 0.5.
Все области просмотра здесь занимают половину ширины и высоты (0.5). Две
области слева имеют значения left, равные 0, а две, начинающиеся с середины
экрана, — равные 0.5 и т. д.
А вот значения для областей просмотра в примере экрана 3:
zzобласть просмотра a: left = 0, top = 0, width = 1, height = 0.33;
zzобласть просмотра b: left = 0, top = 0.33, width = 1, height = 0.33;
zzобласть просмотра c: left = 0, top = 0.66, width = 1, height = 0.33.
Пример экрана 4 представляет собой примерное отображение областей просмотра
в нашей игре. Область просмотра a занимает весь экран, начиная с левого верхнего угла, с теми же значениями, что и по умолчанию: left = 0, top = 0, width = 1
и height = 1. Область просмотра b — это мини-карта, которая имеет следующие
значения: left = 0.2, top = 0.8, width = 0.6 и height = 0.19. При добавлении кода
в класс Factory мы увидим, что при передаче значений для области просмотра
учитывается также разрешение экрана и соотношение сторон. Кроме того, поскольку область просмотра b перекрывает область просмотра a, мы должны
438 Глава 17. Графика, камеры, действие
убедиться, что камера области просмотра b отрисовывается во вторую очередь.
В противном случае она будет перекрыта камерой области просмотра a.
Вернемся к коду.
Класс CameraGraphics: часть 2
Исходя из всего того, что узнали, можно сделать вывод, что если область просмотра меньше 1 в любом направлении (в нашей игре), то это не полноэкранная
камера. Поэтому, если условие if(viewport.width<1) принимает значение true,
значит, это камера мини-карты. Конечно, мы можем случайно передать неправильные значения, но код предполагает, что мы делаем все правильно, и, следовательно, любая ширина меньше 1 — это мини-карта. Внутри оператора if
переменная m_IsMiniMap устанавливается в true.
Внутри оператора else, который выполняется, если это основная камера, мы
загружаем шрифт, устанавливаем его, задаем цвет и масштаб, как мы делали
во всех предыдущих проектах, хотя и в другой части кода. Как уже говорилось,
мини-карта не будет использовать или отображать время, поэтому ей не нужны
экземпляры Font или Text.
Теперь добавьте функцию assemble в CameraGraphics.cpp:
void CameraGraphics::assemble(
VertexArray& canvas,
shared_ptr<Update> genericUpdate,
IntRect texCoords)
{
shared_ptr<CameraUpdate> cameraUpdate =
static_pointer_cast<CameraUpdate>(genericUpdate);
m_Position = cameraUpdate->getPositionPointer();
m_VertexStartIndex = canvas.getVertexCount();
canvas.resize(canvas.getVertexCount() + 4);
const int uPos = texCoords.left;
const int vPos = texCoords.top;
const int texWidth = texCoords.width;
const int texHeight = texCoords.height;
canvas[m_VertexStartIndex].texCoords.x = uPos;
canvas[m_VertexStartIndex].texCoords.y = vPos;
canvas[m_VertexStartIndex + 1].texCoords.x =
uPos + texWidth;
canvas[m_VertexStartIndex + 1].texCoords.y =
vPos;
canvas[m_VertexStartIndex + 2].texCoords.x =
uPos + texWidth;
canvas[m_VertexStartIndex + 2].texCoords.y =
vPos + texHeight;
canvas[m_VertexStartIndex + 3].texCoords.x =
uPos;
canvas[m_VertexStartIndex + 3].texCoords.y =
vPos + texHeight;
}
Создание классов камеры 439
В первой строке данного кода мы используем static_pointer_cast для преобразования обобщенного указателя Update в общий умный указатель CameraUpdate.
Теперь класс CameraGraphics может вызывать все публичные функции класса
CameraUpdate. Вторая строка кода использует эту возможность для инициализации указателя m_Position путем вызова функции getPositionPointer класса
CameraUpdate. Таким образом, мы всегда можем отследить, где должна быть отрисована камера.
Остальной код в функции assemble сохраняет индекс четырехугольника для
камеры и инициализирует все текстурные координаты, связанные с этим четырехугольником, в массиве VertexArray. Текстурные координаты сводятся к очень
светлому прозрачному прямоугольнику, чтобы создать визуальное разделение
между основной камерой и камерой мини-карты.
Теперь добавьте в файл CameraGraphics.cpp код функции getTimeConnection:
float* CameraGraphics::getTimeConnection()
{
return &m_Time;
}
Эта функция короткая и простая: она всего-навсего возвращает адрес переменной m_Time. После того как класс LevelUpdate вызовет эту функцию и сохранит
результат, он сможет обновить переменную m_Time, которая затем будет обновляться каждый кадр в функции draw (о функции draw мы поговорим чуть позже).
Наконец, добавьте следующий код в файл CameraGraphics.cpp для класса
CameraGraphics:
void CameraGraphics::draw(VertexArray& canvas)
{
m_View.setCenter(m_Position->getPosition());
Vector2f startPosition;
startPosition.x = m_View.getCenter().x m_View.getSize().x / 2;
startPosition.y = m_View.getCenter().y m_View.getSize().y / 2;
Vector2f scale;
scale.x = m_View.getSize().x;
scale.y = m_View.getSize().y;
canvas[m_VertexStartIndex].position = startPosition;
canvas[m_VertexStartIndex + 1].position =
startPosition + Vector2f(scale.x, 0);
canvas[m_VertexStartIndex + 2].position =
startPosition + scale;
canvas[m_VertexStartIndex + 3].position =
startPosition + Vector2f(0, scale.y);
if (m_IsMiniMap)
{
if (m_View.getSize().x <
440 Глава 17. Графика, камеры, действие
{
MAX_WIDTH && m_Position->width > 1)
m_View.zoom(m_Position->width);
}
else if (m_View.getSize().x >
MIN_WIDTH && m_Position->width < 1)
{
m_View.zoom(m_Position->width);
}
}
}
m_Window->setView(m_View);
// Отображаем время, но только в основной камере
if (!m_IsMiniMap)
{
m_Text.setString(std::to_string(m_Time));
m_Text.setPosition(
m_Window->mapPixelToCoords(Vector2i(5, 5)));
m_Window->draw(m_Text);
}
// Отрисовка основного холста
m_Window->draw(canvas, m_Texture);
В приведенной выше функции draw есть код, который будут использовать
все камеры, код, который будет задействовать только камера мини-карты
(if (m_IsMiniMap)), и код, к которому будет обращаться только обычная камера
(if (!m_IsMiniMap)).
В начале функции находится часть кода для обеих камер:
m_View.setCenter(m_Position->getPosition());
Vector2f startPosition;
startPosition.x = m_View.getCenter().x m_View.getSize().x / 2;
startPosition.y = m_View.getCenter().y m_View.getSize().y / 2;
Vector2f scale;
scale.x = m_View.getSize().x;
scale.y = m_View.getSize().y;
canvas[m_VertexStartIndex].position = startPosition;
canvas[m_VertexStartIndex + 1].position =
startPosition + Vector2f(scale.x, 0);
canvas[m_VertexStartIndex + 2].position =
startPosition + scale;
canvas[m_VertexStartIndex + 3].position =
startPosition + Vector2f(0, scale.y
Здесь переменная startPosition инициализируется с использованием размера
и центра экземпляра View. Затем инициализируется переменная scale, используя
размер экземпляра View.
Наконец (в приведенном выше коде), соответствующие вершины в массиве
VertexArray позиционируются с помощью startPosition и scale.
Создание классов камеры 441
Далее для наглядности снова показан код для мини-карты:
if (m_IsMiniMap)
{
if (m_View.getSize().x <
MAX_WIDTH && m_Position->width > 1)
{
m_View.zoom(m_Position->width);
}
else if (m_View.getSize().x >
MIN_WIDTH && m_Position->width < 1)
{
m_View.zoom(m_Position->width);
}
}
В данном фрагменте кода есть блок if, который оборачивает структуру if-else
if. В блоке if, который выполняется, когда m_IsMiniMap равно true, вводится
внешний блок if. Если размер экземпляра View меньше максимально допустимого и m_Position.width меньше 1, происходит масштабирование области отображения. Помните из класса CameraUpdate, что величина требуемого масштаба
хранится в m_Position.width.
Внутренний блок else-if выполняется, когда минимально допустимый масштаб
превышен и значение m_Position.width меньше 1. Внутри структуры else-if
масштаб области отображения увеличивается.
Обратите внимание, что после этого кода область отображения устанавливается в соответствующий экземпляр для обеих камер. В начале функции update
в классе CameraUpdate по умолчанию значение всегда устанавливается на 1. Таким
образом, обычная камера никогда не будет масштабироваться, и если колесо прокрутки не используется, ни одна из камер не будет масштабироваться.
m_Window->setView(m_View);
Следующий код используется только для основной камеры и приведен для наглядности:
if (!m_IsMiniMap)
{
m_Text.setString(std::to_string(m_Time));
m_Text.setPosition(
m_Window->mapPixelToCoords(Vector2i(5, 5)));
m_Window->draw(m_Text);
}
Здесь текст в левом верхнем углу экрана настраивается с помощью setString
и setPosition. Затем мы вызываем draw, используя указатель RenderWindow.
Функция draw вызывается один раз для каждого экземпляра класса Camera
Graphics, в отличие от десятков вызовов в нашей игре про зомби. Это связано
442 Глава 17. Графика, камеры, действие
с тем, что все игровые объекты находятся в массиве вершин. Это гораздо эффективнее, а значит, наша игра будет работать на менее мощных компьютерах, или
мы сможем добавить дополнительные игровые объекты, прежде чем возникнут
проблемы с производительностью.
Для полноты картины приведем еще раз вызов draw:
m_Window->draw(canvas, m_Texture);
Чтобы увидеть два новых класса, связанных с камерами, в действии, нам нужно
инстанцировать их в классе Factory, обернуть в экземпляры GameObject и добавить в вектор, который мы перебираем в игровом цикле.
Добавление экземпляров камеры в игру
У нас будут две камеры — одна для общего вида игры, другая — для мини-карты.
Добавьте следующие две выделенные директивы include в верхнюю часть файла
Factory.cpp:
#include "Factory.h"
#include
#include
#include
#include
"LevelUpdate.h"
"PlayerGraphics.h"
"PlayerUpdate.h"
"InputDispatcher.h"
#include "CameraUpdate.h"
#include "CameraGraphics.h"
Теперь добавьте следующий код для первой камеры после кода, который обрабатывает игрового персонажа, в самом конце (но внутри) функции loadLevel. Обратите внимание, что первые несколько строк предназначены для обеих камер (ко
второй камере мы перейдем позже). Обычная полноэкранная камера добавляется
первой, иначе она перекроет мини-карту:
// Для обеих камер
const float width = float(VideoMode::getDesktopMode().width);
const float height = float(VideoMode::getDesktopMode().height);
const float ratio = width / height;
// Основная камера
GameObject camera;
shared_ptr<CameraUpdate> cameraUpdate =
make_shared<CameraUpdate>();
cameraUpdate->assemble(nullptr, playerUpdate);
camera.addComponent(cameraUpdate);
Добавление экземпляров камеры в игру 443
shared_ptr<CameraGraphics> cameraGraphics =
make_shared<CameraGraphics>(
m_Window, m_Texture,
Vector2f(CAM_VIEW_WIDTH, CAM_VIEW_WIDTH / ratio),
FloatRect(CAM_SCREEN_RATIO_LEFT, CAM_SCREEN_RATIO_TOP,
CAM_SCREEN_RATIO_WIDTH, CAM_SCREEN_RATIO_HEIGHT));
cameraGraphics->assemble(
canvas,
cameraUpdate,
IntRect(CAM_TEX_LEFT, CAM_TEX_TOP,
CAM_TEX_WIDTH, CAM_TEX_HEIGHT));
camera.addComponent(cameraGraphics);
gameObjects.push_back(camera);
levelUpdate->connectToCameraTime(
cameraGraphics->getTimeConnection());
// Конец кода для основной камеры
В приведенном коде, который, вероятно, уже начинает казаться знакомым, сначала мы добавим немного полезного кода для обоих экземпляров. Переменные
width, height и ratio инициализируются в зависимости от разрешения экрана,
на котором запущена игра. Мы применим эти значения к обеим камерам. Теперь
перейдем к коду основной камеры.
Сперва мы создаем экземпляр GameObject с именем camera, а затем общий указатель на экземпляр CameraUpdate. Далее мы вызываем функцию assemble и передаем в нее общий указатель playerUpdate, а потом добавляем cameraUpdate в camera
с помощью функции addComponent.
После этого мы создаем общий указатель CameraGraphics и вызываем конструктор, передавая в него RenderWindow, Texture, наши константы для размера камеры
и области просмотра. Далее мы вызываем функцию assemble, передавая в нее
VertexArray , экземпляр cameraUpdate (в родительской форме Update ) и текстурные координаты. Затем добавляем экземпляр CameraGraphics к экземпляру
GameObject, а экземпляр GameObject — в вектор gameObjects.
Наконец, мы устанавливаем связь между экземпляром LevelUpdate и экземпляром CameraGraphics, вызывая connectToCameraTime на levelUpdate и передавая
результат вызова getTimeConnection на cameraGraphics.
Далее добавьте код для камеры мини-карты.
// Камера мини-карты
GameObject mapCamera;
shared_ptr<CameraUpdate> mapCameraUpdate =
make_shared<CameraUpdate>();
444 Глава 17. Графика, камеры, действие
mapCameraUpdate->assemble(nullptr, playerUpdate);
mapCamera.addComponent(mapCameraUpdate);
inputDispatcher.registerNewInputReceiver(
mapCameraUpdate->getInputReceiver());
shared_ptr<CameraGraphics> mapCameraGraphics =
make_shared<CameraGraphics>(
m_Window, m_Texture,
Vector2f(MAP_CAM_VIEW_WIDTH,
MAP_CAM_VIEW_HEIGHT / ratio),
FloatRect(MAP_CAM_SCREEN_RATIO_LEFT,
MAP_CAM_SCREEN_RATIO_TOP,
MAP_CAM_SCREEN_RATIO_WIDTH,
MAP_CAM_SCREEN_RATIO_HEIGHT));
mapCameraGraphics->assemble(canvas,
mapCameraUpdate,
IntRect(MAP_CAM_TEX_LEFT, MAP_CAM_TEX_TOP,
MAP_CAM_TEX_WIDTH, MAP_CAM_TEX_HEIGHT));
mapCamera.addComponent(mapCameraGraphics);
gameObjects.push_back(mapCamera);
// Конец кода для камеры мини-карты
В приведенном коде применяются те же методы и код, что и для первой камеры,
за исключением того, что размер и область просмотра отличаются. Размер миникарты больше, потому что она показывает более широкую плоскость, но область
просмотра меньше, поскольку она сжимается. Это станет более понятно, когда
в игре появится больше графики.
Запуск игры
Теперь наши камеры отрисовывают VertexArray на экране, и мы можем удалить
строку кода, которую временно добавили в функцию main. Удалите следующий
код из Run.cpp:
...
// Временный код до следующей главы
window.draw(canvas, factory.m_Texture);
...
Если вы запустите игру, то сможете увидеть камеры в действии. На рис. 17.2 показано, что персонаж правильно позиционирован и масштабирован. Кроме того,
если прокрутить колесико мыши, вы заметите, как мини-карта увеличивается
и уменьшается, хотя пока на ней мало что видно.
В следующей главе мы добавим платформы, а затем анимацию и элементы управления клавиатурой. Помните, что экземпляр InputReceiver в классе PlayerUpdate
уже получает все события, нам нужно только реагировать на них.
Резюме 445
Рис. 17.2. Камеры в действии
Резюме
В этой главе вы узнали, что гораздо эффективнее минимизировать количество
вызовов draw и что мы можем достичь этого, используя один VertexArray для всех
сущностей в нашей игре, хотя у нас также был отдельный экземпляр Text библио
теки SFML, на котором мы также вызывали draw. Кроме того, в этой главе мы запрограммировали две камеры, используя наш паттерн «Сущность — компонент»,
состоящий из класса, унаследованного от Update, и класса, унаследованного от
Graphics. Более того, мы узнали, что эти классы обмениваются данными друг
с другом для эффективной работы, а также с классами, связанными с персонажами.
Вы увидели, как можно добавлять камеры в фабрику, и, передавая необходимые параметры, такие как соответствующие экземпляры Update и Graphics ,
унаследованные от других классов, мы можем настроить камеры на нужное нам
поведение.
Теперь, когда работают наши камеры, а также игровая логика, которую мы добавили с помощью класса LevelUpdate в главе 16, все, что мы будем делать с игрой
сейчас, принесет мгновенное удовлетворение, поскольку мы сможем сразу
увидеть результат. В следующей главе мы добавим в игру платформы, а также
анимацию и реакцию персонажа на ввод с клавиатуры.
18
Платформы, анимация
игрового персонажа
и элементы управления
В данной главе мы займемся программированием платформ, анимацией игрового персонажа и элементов управления. На мой взгляд, всю самую сложеую
работу мы уже проделали, но надеюсь, эта глава будет интересной, так как прямо на наших глаза игра начнет обретать форму и мы сможем в нее полноценно
поиграть.
Готовый код для этой главы находится в папке Run4.
Создание платформ
Для начала создадим два класса, которые нам понадобятся в этой главе: Platform
Update и PlatformGraphics, наследуемые от Update и Graphics соответственно.
У нас уже есть классы, связанные с игровыми персонажами, и мы добавим в них
больше кода, когда закончим с платформами. Однако нам понадобится класс
Animator, который будет управлять анимацией персонажа, а позже в проекте —
анимацией огненных шаров. Вы можете создать пустой класс Animator сейчас или
подождать, пока мы дойдем до этого момента в книге.
Класс PlatformUpdate
Бо́льшая часть функциональности платформы будет заключаться в обработке
коллизий (столкновений) с персонажем. Если его ноги касаются верхней части
платформы, то они не должны проходить сквозь нее. Если персонаж касается
препятствия, то он должен упереться в него, и т. д. На рис. 18.1 показано, чего мы
добьемся с помощью класса PlatformUpdate.
На рисунке вертикальные и горизонтальные линии указывают на места, где
класс PlatformUpdate будет проверять коллизии. При обнаружении коллизии
игровой персонаж будет перемещен в ближайшую точку, где нет пересечения
(линии), — так мы создадим эффект твердого объекта для платформ.
Создание платформ 447
Рис. 18.1. Примеры коллизий между персонажем и платформой
Добавьте следующее в файл PlatformUpdate.h:
#pragma once
#include "Update.h"
#include "SFML/Graphics.hpp"
using namespace sf;
class PlatformUpdate :
public Update
{
private:
FloatRect m_Position;
FloatRect* m_PlayerPosition = nullptr;
bool* m_PlayerIsGrounded = nullptr;
public:
FloatRect* getPositionPointer();
};
// Из Update : Component
void update(float fps) override;
void assemble(shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate)
override;
Здесь мы объявляем переменные для позиции, указатель для позиции игрока
и логическую переменную, определяющую, стоит ли игрок на земле. Если нет,
он не сможет бежать. Кроме того, где лучше всего вычислять, находится ли игрок
на земле, как не в классе платформы?
448 Глава 18. Платформы, анимация игрового персонажа и элементы управления
В разделе public у нас есть функция getPositionPointer, которая возвращает
адрес экземпляра FloatRect, в котором будет храниться положение платформы.
Так мы передадим требуемое изменяемое положение в экземпляр LevelUpdate,
который написали в главе 15.
Далее следуют обязательные функции update и assemble. Мы уже не раз встречали такие определения. То, как мы их запрограммируем, будет наиболее интересным.
Добавьте следующий код в PlatformUpdate.cpp:
#include "PlatformUpdate.h"
#include "PlayerUpdate.h"
FloatRect* PlatformUpdate::getPositionPointer()
{
return &m_Position;
}
void PlatformUpdate::assemble(
shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate)
{
//mPosition = position;
m_PlayerPosition = playerUpdate->getPositionPointer();
m_PlayerIsGrounded = playerUpdate->getGroundedPointer();
}
В функции getPositionPointer мы возвращаем адрес переменной m_Position.
Далее мы программируем функцию assemble. Она инициализирует m_Player
Position адресом позиции игрового персонажа, который мы получаем с помощью
общего указателя playerUpdate и вызова функции getPositionPointer.
Затем мы инициализируем указатель m_PlayerIsGrounded адресом переменной,
полученной из функции getGroundedPointer класса PlayerUpdate. Теперь любые изменения m_PlayerPosition или m_PLayerIsGrounded мгновенно отразятся
в классах, связанных с персонажем.
Далее мы создадим функцию update, которая будет выполняться один раз на
каждой итерации игрового цикла.
Создание функции update для класса PlatformUpdate
Чтобы завершить работу над классом PlatformUpdate, добавьте следующий код
в PlatformUpdate.cpp:
void PlatformUpdate::update(float fps)
{
if (m_Position.intersects(*m_PlayerPosition))
{
Vector2f playerFeet(m_PlayerPosition->left +
Создание платформ 449
m_PlayerPosition->width / 2,
m_PlayerPosition->top +
m_PlayerPosition->height);
Vector2f playerRight(m_PlayerPosition->left +
m_PlayerPosition->width,
m_PlayerPosition->top +
m_PlayerPosition->height / 2);
Vector2f playerLeft(m_PlayerPosition->left,
m_PlayerPosition->top +
m_PlayerPosition->height / 2);
Vector2f playerHead(m_PlayerPosition->left +
m_PlayerPosition->width / 2,
m_PlayerPosition->top);
}
}
if (m_Position.contains(playerFeet))
{
if (playerFeet.y > m_Position.top)
{
m_PlayerPosition->top =
m_Position.top m_PlayerPosition->height;
*m_PlayerIsGrounded = true;
}
}
else if (m_Position.contains(playerRight))
{
m_PlayerPosition->left =
m_Position.left - m_PlayerPosition->width;
}
else if (m_Position.contains(playerLeft))
{
m_PlayerPosition->left =
m_Position.left + m_Position.width;
}
else if (m_Position.contains(playerHead))
{
m_PlayerPosition->top =
m_Position.top + m_Position.height;
}
Функция update выполняет бо́льшую часть работы, так что давайте разберем ее.
Весь код определяет, сталкивается ли персонаж с платформой, какая его часть
сталкивается с платформой и какая часть платформы сталкивается с персонажем.
Первый оператор if определяет, есть ли какое-либо пересечение между персонажем и платформой:
if (m_Position.intersects(*m_PlayerPosition))
450 Глава 18. Платформы, анимация игрового персонажа и элементы управления
Если между персонажем и платформой есть пересечение, то необходимо провести
дополнительные проверки, чтобы точно определить, какая именно коллизия
произошла. Если же пересечения нет, то остальную часть кода в функции update
можно пропустить.
Если коллизия обнаружена, то следующий код определяет части тела игрового
персонажа, которые мы будем проверять, чтобы получить данные для обработки
события:
Vector2f playerFeet(m_PlayerPosition->left +
m_PlayerPosition->width / 2,
m_PlayerPosition->top +
m_PlayerPosition->height);
Vector2f playerRight(m_PlayerPosition->left +
m_PlayerPosition->width,
m_PlayerPosition->top +
m_PlayerPosition->height / 2);
Vector2f playerLeft(m_PlayerPosition->left,
m_PlayerPosition->top +
m_PlayerPosition->height / 2);
Vector2f playerHead(m_PlayerPosition->left +
m_PlayerPosition->width / 2,
m_PlayerPosition->top);
В приведенном выше коде создаются четыре экземпляра Vector2f: playerFeet,
playerRight , playerLeft и playerHead . Экземпляры инициализируются вызовом конструктора Vector2f и передачей соответствующих значений из указателя m_PlayerPosition, который ведет на переменную m_Position в классе
PlayerUpdate.
Следующие операторы if и else-if обрабатывают действия для каждого случая
коллизии:
if (m_Position.contains(playerFeet))
{
if (playerFeet.y > m_Position.top)
{
m_PlayerPosition->top =
m_Position.top m_PlayerPosition->height;
*m_PlayerIsGrounded = true;
}
}
else if (m_Position.contains(playerRight))
{
m_PlayerPosition->left =
m_Position.left - m_PlayerPosition->width;
}
Создание платформ 451
else if (m_Position.contains(playerLeft))
{
m_PlayerPosition->left =
m_Position.left + m_Position.width;
}
else if (m_Position.contains(playerHead))
{
m_PlayerPosition->top =
m_Position.top + m_Position.height;
}
Когда голова или ноги касаются платформы, они выравниваются по нижней или
верхней части платформы соответственно. Когда персонаж пересекается с платформой справа, происходит выравнивание по левой части платформы, а когда
слева — по правой части. По рис. 18.1 можно понять, как это работает.
Другие игровые сущности, такие как огненные шары, будут проходить сквозь
платформы, потому что это подходит для нашей игры. Однако было бы несложно
получить указатели на все огненные шары и обработать коллизии для них или
любой другой сущности в игре, если бы мы захотели.
Осталось улучшить внешний вид наших платформ.
Класс PlatformGraphics
Теперь мы можем написать класс PlatformGraphics, который будет визуально
представлять данные в классе PlatformUpdate. Добавьте следующий код в файл
PlatformGraphics.h:
#pragma once
#include "Graphics.h"
#include "SFML/Graphics.hpp"
using namespace sf;
class PlatformGraphics : public Graphics
{
private:
FloatRect* m_Position = nullptr;
int m_VertexStartIndex = -1;
public:
// Из Graphics : Component
void draw(VertexArray& canvas) override;
void assemble(VertexArray& canvas,
shared_ptr<Update> genericUpdate,
IntRect texCoords) override;
};
В приведенном коде в блоке private есть указатель типа FloatRect с именем
m_Position, который будет указывать на класс PlatformUpdate и хранить текущую
452 Глава 18. Платформы, анимация игрового персонажа и элементы управления
позицию платформы. Помните, что платформы будут регулярно перемещаться
классом LevelUpdate, в котором хранится вектор всех позиций платформ.
Целочисленная переменная m_VertexStartIndex выполняет свою обычную роль —
запоминает позицию в VertexArray, с которой начинается четырехугольник для
этого объекта.
В блоке public у нас только две стандартные функции для классов, наследу
ющихся от класса Graphics. Это draw, которая принимает ссылку на VertexArray,
и assemble, которая используется для подготовки каждой платформы и вызывается из фабрики для всех экземпляров платформы. Мы напишем этот код,
связанный с фабрикой, как только завершим работу над классом.
Теперь мы можем написать определения наших двух переопределенных функций.
Добавьте следующий код в файл PlatformGraphics.cpp:
#include "PlatformGraphics.h"
#include "PlatformUpdate.h"
void PlatformGraphics::draw(VertexArray& canvas)
{
const Vector2f& position = m_Position->getPosition();
const Vector2f& scale = m_Position->getSize();
canvas[m_VertexStartIndex].position = position;
canvas[m_VertexStartIndex + 1].position =
position + Vector2f(scale.x, 0);
canvas[m_VertexStartIndex + 2].position =
position + scale;
canvas[m_VertexStartIndex + 3].position =
position + Vector2f(0, scale.y);
}
В функции draw, которую мы только что написали, нам нужно лишь инициализировать соответствующие индексы массива VertexArray значениями, на которые
указывает m_Position. Хотя в большинстве кадров игры платформа не будет
двигаться, мы инициализируем VertexArray, потому что в конечном счете она
должна перемещаться.
ПРИМЕЧАНИЕ
Если бы у нас были тысячи платформ, мы могли бы оптимизировать это, добавив
в класс PlatformUpdate логическую переменную, которая указывала бы, перемещалась ли платформа в текущем кадре, и выполнять предыдущий код только в случае
перемещения. В нашей игре такая оптимизация не понадобится. Я просто подумал,
что вам полезно знать о такой возможности.
Чтобы завершить работу над классом PlatformGraphics , добавьте функцию
assemble в файл PlatformGraphics.cpp:
Создание платформ 453
void PlatformGraphics::assemble(VertexArray& canvas,
shared_ptr<Update> genericUpdate,
IntRect texCoords)
{
shared_ptr<PlatformUpdate> platformUpdate =
static_pointer_cast<PlatformUpdate>(genericUpdate);
m_Position = platformUpdate->getPositionPointer();
m_VertexStartIndex = canvas.getVertexCount();
canvas.resize(canvas.getVertexCount() + 4);
const
const
const
const
}
int
int
int
int
uPos = texCoords.left;
vPos = texCoords.top;
texWidth = texCoords.width;
texHeight = texCoords.height;
canvas[m_VertexStartIndex].texCoords.x = uPos;
canvas[m_VertexStartIndex].texCoords.y = vPos;
canvas[m_VertexStartIndex + 1].texCoords.x =
uPos + texWidth;
canvas[m_VertexStartIndex + 1].texCoords.y =
vPos;
canvas[m_VertexStartIndex + 2].texCoords.x =
uPos + texWidth;
canvas[m_VertexStartIndex + 2].texCoords.y =
vPos + texHeight;
canvas[m_VertexStartIndex + 3].texCoords.x =
uPos;
canvas[m_VertexStartIndex + 3].texCoords.y =
vPos + texHeight;
В приведенном коде функции assemble общий экземпляр Update , переданный в качестве параметра, приводится к экземпляру PlatformUpdate. Теперь
указатель m_Position можно инициализировать вызовом platformUpdate->
getPositionPointer().
Далее определяется и сохраняется начальный индекс четырехугольника, а массив
VertexArray расширяется путем добавления четырех новых вершин.
Наконец, текстурные координаты инициализируются в соответствующих позициях массива VertexArray. Теперь наш класс готов к использованию.
Создание платформ в фабрике
Добавьте следующие директивы include в Factory.cpp, чтобы создать несколько
платформ:
#include "PlatformUpdate.h"
#include "PlatformGraphics.h"
454 Глава 18. Платформы, анимация игрового персонажа и элементы управления
Теперь добавьте следующий код перед кодом, который мы написали для камер,
но после кода для игрового персонажа:
// Сообщаем LevelUpdate об игровом персонаже
levelUpdate->assemble(nullptr, playerUpdate);
// Для платформ
for (int i = 0; i < 8; ++i)
{
GameObject platform;
shared_ptr<PlatformUpdate> platformUpdate =
make_shared<PlatformUpdate>();
platformUpdate->assemble(nullptr, playerUpdate);
platform.addComponent(platformUpdate);
shared_ptr<PlatformGraphics> platformGraphics =
make_shared<PlatformGraphics>();
platformGraphics->assemble(
canvas, platformUpdate,
IntRect(PLATFORM_TEX_LEFT, PLATFORM_TEX_TOP,
PLATFORM_TEX_WIDTH, PLATFORM_TEX_HEIGHT));
platform.addComponent(platformGraphics);
gameObjects.push_back(platform);
levelUpdate->addPlatformPosition(
platformUpdate->getPositionPointer());
}
// Конец кода для платформ
В приведенном коде цикл for выполняется восемь раз. Вы можете поэкспериментировать с бо́льшим или меньшим числом платформ, но восемь, кажется,
работают вполне неплохо. Следующие действия происходят на каждой итерации
цикла for.
Сначала мы создаем новый экземпляр GameObject с именем platform и общий
указатель экземпляра PlatformUpdate под названием platformUpdate. Затем мы
вызываем assemble на platformUpdate и передаем экземпляр playerUpdate. Далее
мы вызываем addComponent, чтобы добавить platformUpdate к platform.
После этого мы создаем экземпляр PlatformGraphics с общим указателем. Затем,
как и во всех наших классах, унаследованных от Graphics, мы вызываем assemble
и передаем массив VertexArray, экземпляр platformUpdate (как общий экземпляр
Update) и текстурные координаты.
Теперь мы добавляем экземпляр platformGraphics к объекту GameObject
(platform) и вызываем push_back на gameObjects, чтобы добавить platform в вектор экземпляров GameObject.
Наконец, мы используем levelUpdate, вызываем addPlatformPosition и передаем
результат вызова platformUpdate->getPositionPointer(), что позволяет классу
Добавление функциональности персонажу 455
LevelUpdate управлять положением только что созданной платформы. Цикл for
гарантирует, что это повторится еще семь раз.
Давайте посмотрим на промежуточный результат.
Первый запуск игры
Временно измените одну строку кода в файле LevelUpdate.h:
bool m_IsPaused = false;
Изменение m_isPaused на false позволит платформам генерироваться. Теперь
запустите игру. Вы увидите, что таймер в левом верхнем углу ведет отсчет, а платформы исчезают позади персонажа и вновь появляются перед ним (рис. 18.2).
Рис. 18.2. Обзор платформ
Измените m_IsPaused обратно на true. Давайте оживим игрового персонажа.
Добавление функциональности персонажу
Конечно, герой пока ничего не может сделать. Мы изменим это двумя способами.
Мы будем обрабатывать ввод с клавиатуры в функции handleInput с помощью
нашего InputReceiver. На этом этапе игры герой сможет двигаться. Затем мы
перейдем к анимации движения.
456 Глава 18. Платформы, анимация игрового персонажа и элементы управления
Создание элементов управления персонажем
В PlayerUpdate.h уже есть все необходимые нам переменные, нам просто нужно использовать их в файле PlayerUpdate.cpp. Добавьте полный код функции
handleInput в PlayerUpdate.cpp:
void PlayerUpdate::handleInput()
{
if (event.type == Event::KeyPressed)
{
if (event.key.code == Keyboard::D)
{
m_RightIsHeldDown = true;
}
if (event.key.code == Keyboard::A)
{
m_LeftIsHeldDown = true;
}
if (event.key.code == Keyboard::W)
{
m_BoostIsHeldDown = true;
}
if (event.key.code == Keyboard::Space)
{
m_SpaceHeldDown = true;
}
}
if (event.type == Event::KeyReleased)
{
if (event.key.code == Keyboard::D)
{
m_RightIsHeldDown = false;
}
if (event.key.code == Keyboard::A)
{
m_LeftIsHeldDown = false;
}
if (event.key.code == Keyboard::W)
{
m_BoostIsHeldDown = false;
}
if (event.key.code == Keyboard::Space)
{
m_SpaceHeldDown = false;
}
}
}
m_InputReceiver.clearEvents();
}
В приведенном коде есть два оператора if. Первый выполняется, когда клавиша нажата, а второй — когда отпущена. Мы будем использовать клавиши W,
Добавление функциональности персонажу 457
A, D и пробел. Для каждой комбинации клавиши и движения (вверх и вниз) мы
устанавливаем логическую переменную. Теперь можно будет реагировать на
состояние всех клавиш из функции update. Помните, что именно из функции
update была вызвана функция handleInput . Поэтому мы будем реагировать
на логические переменные, которые только что установили, после вызова
handleInput.
Далее мы добавим код функции update в PlayerUpdate.cpp, но сделаем это поэтапно. Функция update довольно объемная, поэтому разобьем ее на части. Проще всего начать эту функцию с нуля, как показано далее. Это длинная функция,
но я разбил ее на блоки, чтобы упростить понимание. Если есть сомнения относительно порядка или расположения кода в функции update, обратитесь к коду
в файле PlayerUpdate.cpp в папке Run4.
Сначала добавьте следующий код. Весь код функции будет находиться там, где
сейчас стоит комментарий:
void PlayerUpdate::update(float timeTakenThisFrame)
{
if (!*m_IsPaused)
{
// Весь остальной код
}
}
В приведенном выше коде мы сначала проверяем, поставлена ли игра на паузу.
Если игра приостановлена, то никакой код функции update не будет выполнен.
Весь остальной код находится под соответствующим комментарием. Далее добавьте этот код:
m_Position.top += m_Gravity *
timeTakenThisFrame;
handleInput();
if (m_IsGrounded)
{
if (m_RightIsHeldDown)
{
m_Position.left += timeTakenThisFrame * m_RunSpeed;
}
if (m_LeftIsHeldDown)
{
m_Position.left -= timeTakenThisFrame * m_RunSpeed;
}
}
Обратите внимание, что в приведенном коде мы проверяем значения логических
переменных, которые ранее установили в функции handleInput, и реагируем на
изменение значений, хранящихся в m_Position.
458 Глава 18. Платформы, анимация игрового персонажа и элементы управления
Первая строка кода выполняется всегда (кроме случаев, когда игра приостановлена) и тянет персонажа вниз с помощью силы тяжести (m_Gravity), умноженной
на время выполнения основного игрового цикла (m_TimeTakenThisFrame).
Затем вызывается функция handleInput , чтобы установить все логические
переменные. Следующий оператор if проверяет, находится ли персонаж на
земле. Дело в том, что мы хотим реагировать на движение персонажа влево или
вправо, только если он касается ногами земли, потому что бежать по воздуху
нельзя. Если игровой персонаж стоит на твердой поверхности, а мы удерживаем
нажатой клавишу A или D, то m_Position перемещается влево или вправо соответственно, основываясь на времени выполнения игрового цикла и скорости
главного героя (m_RunSpeed).
Чтобы обработать еще несколько движений, добавьте следующий код:
if (m_BoostIsHeldDown)
{
m_Position.top -= timeTakenThisFrame * m_BoostSpeed;
if (m_RightIsHeldDown)
{
m_Position.left += timeTakenThisFrame * m_RunSpeed / 4;
}
if (m_LeftIsHeldDown)
{
m_Position.left -=
timeTakenThisFrame * m_RunSpeed / 4;
}
}
Приведенный выше код выполняется только при нажатой клавише, отвечающей
за рывок (W). Если она нажата, игрок перемещается вверх на основе рывка
(m_BoostSpeed) и времени выполнения кадра. В дополнение к движению вверх,
если удерживать клавиши A или D, персонаж перемещается влево и вправо соответственно, как если бы он был на земле и бежал. Однако обратите внимание,
что скорость движения делится на 4. Это сделано для того, чтобы перемещение
влево и вправо при рывке было медленным и неудобным, поскольку рывок нужен
только в экстренных случаях, например, когда персонаж падает с платформы или
уклоняется от летящего огненного шара.
Теперь добавьте этот код:
// Обработка прыжка
if (m_SpaceHeldDown && !m_InJump && m_IsGrounded)
{
SoundEngine::playJump();
m_InJump = true;
m_JumpClock.restart();
}
Добавление функциональности персонажу 459
if (!m_SpaceHeldDown)
{
//mInJump = false;
}
Предыдущий код обрабатывает попытку игрока выполнить прыжок, проверяя,
нажал ли он пробел, находясь на земле. Если да, то воспроизводится звук прыжка,
m_InJump устанавливается в true, а таймер (m_JumpClock) перезапускается, чтобы
начать измерять время нахождения игрового персонажа в воздухе.
Переходя к заключительной части функции update, добавьте этот код:
if (m_InJump)
{
if (m_JumpClock.getElapsedTime().asSeconds() <
m_JumpDuration / 2)
{
// Движение вверх
m_Position.top -= m_JumpSpeed * timeTakenThisFrame;
}
else
{
// Движение вниз
m_Position.top += m_JumpSpeed * timeTakenThisFrame;
}
if (m_JumpClock.getElapsedTime().asSeconds() >
m_JumpDuration)
{
m_InJump = false;
}
if (m_RightIsHeldDown)
{
m_Position.left += timeTakenThisFrame * m_RunSpeed;
}
if (m_LeftIsHeldDown)
{
m_Position.left -= timeTakenThisFrame * m_RunSpeed;
}
}// Конец блока if(m_InJump)
m_IsGrounded = false;
Весь приведенный код управляет тем, что происходит, когда игровой персонаж
находится в состоянии прыжка, определяемом логической переменной m_InJump.
Первый оператор if после определения того, что персонаж прыгает, проверяет,
преодолел ли он середину пути с помощью данного кода:
if (m_JumpClock.getElapsedTime().asSeconds() <
m_JumpDuration / 2)
460 Глава 18. Платформы, анимация игрового персонажа и элементы управления
Если середина пути не пройдена, персонаж перемещается вверх (в блоке if); если
пройдена, код в блоке else перемещает его вниз.
Далее следующий код решает, пора ли завершить прыжок:
if (m_JumpClock.getElapsedTime().asSeconds() >
m_JumpDuration)
{
m_InJump = false;
}
Наконец, в части кода, посвященной прыжку, проверяется нажатие левой и правой
клавиш, и игровой персонаж перемещается влево или вправо, если соответствующая клавиша нажата. Обратите внимание, что во время прыжка он движется
с той же скоростью, что и во время бега. Это близко к реальности, но суть в том,
что всегда предпочтительнее бежать и прыгать, а не двигаться рывками.
Второй запуск игры
На данном этапе вы можете изменить одну строку кода в файле LevelUpdate.h
(если она еще не изменена), как показано ниже:
bool m_IsPaused = false;
Это позволит вам увидеть, как персонаж «скользит» по уровню, прыгает и ускоряется, но без анимации (рис. 18.3).
Рис. 18.3. Движение игрового персонажа без анимации
Создание класса Animator 461
Далее мы добавим анимацию персонажа, потому что это не игра про катание на
коньках. Сделаем это в два этапа. Сначала создадим класс, который будет выбирать кадр анимации (набор текстурных координат) из текстурного атласа, а затем добавим код в класс PlayerGraphics, который будет использовать экземпляр
нашего класса Animator, а также некоторый другой код.
Создание класса Animator
Класс Animator будет использоваться классами FireballGraphics и RainGraphics.
Он может быть использован любым классом, который хочет зацикливаться на
заданных кадрах анимации. Его можно настроить на любой набор анимаций,
если у них одинаковый размер, они равномерно распределены и имеют одни
и те же вертикальные координаты. Класс Animator может быть настроен кодом,
который его задействует, чтобы обратить порядок анимации, что полезно, когда
наш персонаж бежит в противоположном направлении, как мы скоро увидим.
Реверсирование анимации также может привести к эффекту лунной походки,
но я оставлю это вам для самостоятельного изучения. Частота кадров в секунду
и количество кадров также могут быть определены во время выполнения.
Создайте класс Animator (если вы его еще не создали), добавив следующий код
в Animator.h:
#pragma once
#include<SFML/Graphics.hpp>
using namespace sf;
class Animator
{
private:
IntRect m_SourceRect;
int m_LeftOffset;
int m_FrameCount;
int m_CurrentFrame;
int m_FramePeriod;
int m_FrameWidth;
int m_FPS = 12;
Clock m_Clock;
public:
Animator(
int leftOffset, int topOffset,
int frameCount,
int textureWidth,
int textureHeight,
int fps);
};
IntRect* getCurrentFrame(bool reversed);
462 Глава 18. Платформы, анимация игрового персонажа и элементы управления
Экземпляр IntRect под названием m_SourceRect будет хранить целочисленные
координаты текущего кадра анимации в текстурном атласе.
Переменная m_LeftOffset используется для отслеживания горизонтального
значения, определяющего левую сторону текущего кадра анимации. Вскоре мы
увидим в нашем коде, что для перехода к следующему набору текстурных координат мы прибавляем к этому значению m_FrameWidth.
Целочисленная переменная m_FrameCount хранит количество кадров в последовательности анимации. Переменная m_CurrentFrame — это номер текущего
рисуемого кадра.
m_FramePeriod — это длительность каждого кадра анимации. Она рассчитывается
как единица, деленная на количество кадров.
Целочисленная переменная m_FrameWidth хранит ширину кадра анимации. Это
значение никогда не меняется для данного набора анимации.
Переменная m_FPS будет содержать количество кадров, которые будут анимированы в каждую секунду. Экземпляр Clock m_Clock отслеживает время для частоты
кадров анимации.
Конструктор Animator имеет следующие параметры: int leftOffset , int
topOffset, int frameCount, int textureWidth, int textureHeight и int fps. Все
они соответствуют одной из переменных-членов и будут присвоены должным
образом.
Функция getCurrentFrame выполняет работу по вычислению текущего кадра для
отрисовки и возвращает координаты текстуры в текстурном атласе в указателе
IntRect. Логический параметр reversed указывает функции вычислять кадры, двигаясь справа налево в текстурном атласе, если параметр reversed установлен в true.
Добавьте в Animator.cpp следующий код реализации конструктора:
#include "Animator.h"
Animator::Animator(
int leftOffset, int topOffset,
int frameCount,
int textureWidth,
int textureHeight,
int fps)
{
m_LeftOffset = leftOffset;
m_CurrentFrame = 0;
m_FrameCount = frameCount;
m_FrameWidth = (float)textureWidth / m_FrameCount;
m_SourceRect.left = leftOffset;
m_SourceRect.top = topOffset;
Создание класса Animator 463
m_SourceRect.width = m_FrameWidth;
m_SourceRect.height = textureHeight;
m_FPS = fps;
}
m_FramePeriod = 1000 / m_FPS;
m_Clock.restart();
В предыдущем коде конструктора Animator инициализируются значения leftOffset,
currentFrame и frameCount. Ширина кадра вычисляется путем деления ширины
текстуры на количество кадров. Начальное значение для IntRect (m_SourceRect),
в котором хранятся текущие текстурные координаты, инициализируется с помощью левого смещения, верхнего смещения, ширины кадра и высоты текстуры.
Это имеет смысл, если учесть, что все кадры анимации расположены в ряд на
равном расстоянии друг от друга.
Затем добавьте функцию getCurrentFrame в Animator.cpp:
IntRect* Animator::getCurrentFrame(bool reversed)
{
// Reversed прибавляет 1 к номеру кадра
// при отрисовке текстуры в перевернутом виде.
// Это работает, потому что перевернутые
// (отраженные по горизонтали) текстуры
// рисуются пикселями справа налево
if (m_Clock.getElapsedTime().asMilliseconds()
> m_FramePeriod)
{
m_CurrentFrame++;
if (m_CurrentFrame >= m_FrameCount + reversed)
{
m_CurrentFrame = 0 + reversed;
}
}
m_Clock.restart();
m_SourceRect.left = m_LeftOffset + m_CurrentFrame
* m_FrameWidth;
}
return &m_SourceRect;
В функции getCurrentFrame первый оператор if проверяет, пришло ли время
перейти к следующему кадру. Если да, то m_CurrentFrame увеличивается. Следующий оператор if проверяет, не вышли ли мы за пределы последнего кадра.
Если кадр есть, он устанавливается в ноль внутри блока if . Предпоследняя
строка кода инициализирует текстурные координаты внутри m_SourceRect, после
чего m_SourceRect возвращается в вызывающий код. Теперь перейдем к созданию
класса PlayerGraphics, вызывающего функции, которые мы только что написали.
464 Глава 18. Платформы, анимация игрового персонажа и элементы управления
Анимация игрового персонажа
В этом разделе мы будем использовать только что созданный класс Animator.
Очевидно, что мы задействуем и функцию getCurrentFrame, но, кроме того, будем
обращаться и к отдельным кадрам в текстурном атласе, как, например, при рывке,
как показано на рис. 18.4.
Помимо прочего, мы видели, что наш класс Animator может изменять порядок
анимации кадров, но нам также нужно отражать текстуры по горизонтали, когда
персонаж повернут лицом влево. Мы рассмотрим, как определить, что текстуры
следует перевернуть, а также как это сделать. Например, предыдущее изображение иногда нужно будет нарисовать так, как показано на рис. 18.5.
Рис. 18.4. Рывок
Рис. 18.5. Рывок, но в другую сторону
Добиться этого очень просто, и скоро мы в этом убедимся. В файле PlayerGraphics.h
есть все, что нам нужно. Просто уберите символы комментариев в следующем
выделенном коде:
// Мы вернемся к этому позже
// class Animator;
И в этом:
// Мы вернемся к этому позже
// Animator* m_Animator;
Мы только что добавили экземпляр Animator и прямое объявление для класса
Animator.
Теперь нам нужно добавить немного кода в PlayerGraphics.cpp. Начнем с директивы include в файле PlayerGraphics.cpp:
#include "Animator.h"
Бо́льшая часть исходного кода, который мы поместили в функцию assemble, уже
не нужна, теперь у нас есть наш Animator, поэтому вы можете заменить функцию
assemble в PlayerGraphics следующим образом:
Анимация игрового персонажа 465
void PlayerGraphics::assemble(VertexArray& canvas,
shared_ptr<Update> genericUpdate,
IntRect texCoords)
{
m_PlayerUpdate = static_pointer_cast<PlayerUpdate>(genericUpdate);
m_Position = m_PlayerUpdate->getPositionPointer();
m_Animator = new Animator(
texCoords.left,
texCoords.top,
6, // 6 кадров
texCoords.width * 6,
texCoords.height,
12); // FPS
// Получаем первый кадр анимации
m_SectionToDraw = m_Animator->getCurrentFrame(false);
m_StandingStillSectionToDraw = m_Animator->getCurrentFrame(false);
}
m_VertexStartIndex = canvas.getVertexCount();
canvas.resize(canvas.getVertexCount() + 4);
В обновленной функции assemble мы получаем указатель на позицию игрового
персонажа в классе PlayerUpdate путем приведения экземпляра Update к экземпляру PlayerUpdate и вызова функции getPositionPointer.
Затем мы вызываем new для инициализации нашего экземпляра Animator и передаем необходимые параметры, которые включают в себя указание текстурных
координат слева и сверху, 6 кадров в сумме, общую ширину и высоту, а также
частоту 12 кадров в секунду. Класс Animator, код для которого мы написали ранее,
будет задействовать эти данные для предоставления нужного кадра анимации при
каждом вызове функции getCurrentFrame. Мы могли бы сделать getCurrentFrame
функцией класса PlayerGraphics, но тогда не смогли бы так легко использовать
ее для наших огненных шаров и дождя. Поскольку у нас есть класс Animator, мы
можем применять его столько раз, сколько захотим, и мы сделаем это для наших
огненных шаров и дождя.
Следующая строка кода инициализирует IntRect m_SectionToDraw, вызывая функцию getCurrentFrame. Затем мы инициализируем m_StandingStillSectionToDraw,
снова вызывая ту же функцию. Этот первый кадр мы будем использовать, когда
игровой персонаж стоит на месте.
Наконец, в функции assemble начальная вершина четырехугольника сохраняется
путем вызова canvas.getVertexCount и вычитания единицы из возвращаемого
значения. Затем мы можем расширить массив VertexArray, вызвав canvas.resize.
Функция draw полностью трансформируется, поэтому мы целиком заменим ее
следующим кодом в PlayerGraphics.cpp.
466 Глава 18. Платформы, анимация игрового персонажа и элементы управления
Функция draw длинная, но делить ее на несколько функций нет смысла, поэтому я просто разобью ее на части для объяснения. Я рекомендую скопировать
и вставить всю функцию draw из файла PlayerGraphics.cpp в папке Run4, если
у вас возникнут трудности с интерпретацией положения или структуры любого
из последующих кодов. Код не особенно сложный, но есть нюансы, которые нам
нужно учитывать для отрисовки игрового персонажа. Например, движется ли
он, прыгает, совершает рывок, стоит на месте, повернут влево или вправо? Все
эти варианты и различные их комбинации меняют то, как мы хотим отобразить
персонажа.
Первая часть функции draw выглядит следующим образом:
void PlayerGraphics::draw(VertexArray& canvas)
{
const Vector2f& position =
m_Position->getPosition();
const Vector2f& scale =
m_Position->getSize();
canvas[m_VertexStartIndex].position =
position;
canvas[m_VertexStartIndex + 1].position =
position + Vector2f(scale.x, 0);
canvas[m_VertexStartIndex + 2].position =
position + scale;
canvas[m_VertexStartIndex + 3].position =
position + Vector2f(0, scale.y);
if (m_PlayerUpdate->m_RightIsHeldDown &&
!m_PlayerUpdate->m_InJump &&
!m_PlayerUpdate->m_BoostIsHeldDown &&
m_PlayerUpdate->m_IsGrounded)
{
m_SectionToDraw = m_Animator->getCurrentFrame(false);
}
if (m_PlayerUpdate->m_LeftIsHeldDown &&
!m_PlayerUpdate->m_InJump &&
!m_PlayerUpdate->m_BoostIsHeldDown &&
m_PlayerUpdate->m_IsGrounded)
{
m_SectionToDraw = m_Animator->getCurrentFrame(true);
// Реверс
}
else
{
// Проверяем, в какую сторону повернут игровой персонаж
// на случай, если положение изменилось во время прыжка или ускорения
// Это значение используется в последнем варианте анимации
if (m_PlayerUpdate->m_LeftIsHeldDown)
{
m_LastFacingRight = false;
}
Анимация игрового персонажа 467
else
{
}
}
m_LastFacingRight = true;
В данном коде мы расположили вершины, определили, какой кадр взять — слева
или справа от предыдущего, и установили переменную m_LastFacingRight. В следующих разделах мы будем использовать соответствующий кадр и размещать его
на экземпляре VertexArray.
Добавьте в функцию draw следующий код:
const
const
const
const
int
int
int
int
uPos = m_SectionToDraw->left;
vPos = m_SectionToDraw->top;
texWidth = m_SectionToDraw->width;
texHeight = m_SectionToDraw->height;
if (m_PlayerUpdate->m_RightIsHeldDown &&
!m_PlayerUpdate->m_InJump &&
!m_PlayerUpdate->m_BoostIsHeldDown)
{
canvas[m_VertexStartIndex].texCoords.x
= uPos;
canvas[m_VertexStartIndex].texCoords.y
= vPos;
canvas[m_VertexStartIndex + 1].texCoords.x
= uPos + texWidth;
canvas[m_VertexStartIndex + 1].texCoords.y
= vPos;
canvas[m_VertexStartIndex + 2].texCoords.x
= uPos + texWidth;
canvas[m_VertexStartIndex + 2].texCoords.y
= vPos + texHeight;
canvas[m_VertexStartIndex + 3].texCoords.x
= uPos;
canvas[m_VertexStartIndex + 3].texCoords.y
= vPos + texHeight;
}
Код проверяет, что персонаж просто бежит вправо по платформам, а не стоит на
месте или находится в воздухе. Если это так, то мы хотим, чтобы воспроизводилась обычная анимация бега. Код в операторе if устанавливает текстурные координаты в массиве VertexArray, используя координаты, возвращенные из функции
getCurrentFrame, помещенные в m_SectionToDraw, а затем скопированные в uPos,
vPos, texWidth и texHeight.
Добавьте следующий код в функцию draw:
else if (m_PlayerUpdate->m_LeftIsHeldDown &&
!m_PlayerUpdate->m_InJump &&
!m_PlayerUpdate->m_BoostIsHeldDown)
468 Глава 18. Платформы, анимация игрового персонажа и элементы управления
{
}
canvas[m_VertexStartIndex].texCoords.x
= uPos;
canvas[m_VertexStartIndex].texCoords.y
= vPos;
canvas[m_VertexStartIndex + 1].texCoords.x
= uPos - texWidth;
canvas[m_VertexStartIndex + 1].texCoords.y
= vPos;
canvas[m_VertexStartIndex + 2].texCoords.x
= uPos - texWidth;
canvas[m_VertexStartIndex + 2].texCoords.y
= vPos + texHeight;
canvas[m_VertexStartIndex + 3].texCoords.x
= uPos;
canvas[m_VertexStartIndex + 3].texCoords.y
= vPos + texHeight;
В данном фрагменте кода функции draw оператор if выполняется, когда игровой
персонаж просто бежит влево. На первый взгляд, код выглядит так же, как и предыдущий, но есть одно небольшое изменение в том, как обрабатывается ширина
горизонтальных текстурных координат. Координаты x второй и третьей вершин
вычисляются следующим образом:
= uPos - texWidth;
Первая и третья координаты рассчитываются так:
= uPos;
Это приводит к тому, что пиксели правой части изображения используются
в текстуре на левой стороне четырехугольника и перемещаются справа налево по
пикселям текстуры и слева направо на четырехугольнике. По сути, это отзеркаливает изображение по горизонтали — как раз то, что нам нужно, когда персонаж
стоит лицом влево.
Добавьте в функцию draw следующий код:
else if (m_PlayerUpdate->m_RightIsHeldDown &&
m_PlayerUpdate->m_BoostIsHeldDown)
{
canvas[m_VertexStartIndex].texCoords.x =
BOOST_TEX_LEFT;
canvas[m_VertexStartIndex].texCoords.y =
BOOST_TEX_TOP;
canvas[m_VertexStartIndex + 1].texCoords.x
BOOST_TEX_LEFT + BOOST_TEX_WIDTH;
canvas[m_VertexStartIndex + 1].texCoords.y
BOOST_TEX_TOP;
canvas[m_VertexStartIndex + 2].texCoords.x
BOOST_TEX_LEFT + BOOST_TEX_WIDTH;
canvas[m_VertexStartIndex + 2].texCoords.y
BOOST_TEX_TOP + BOOST_TEX_HEIGHT;
=
=
=
=
Анимация игрового персонажа 469
}
canvas[m_VertexStartIndex + 3].texCoords.x =
BOOST_TEX_LEFT;
canvas[m_VertexStartIndex + 3].texCoords.y =
BOOST_TEX_TOP + BOOST_TEX_HEIGHT;
В приведенном коде оператор if выполняется, когда персонаж бежит вправо и делает рывок. Текстурные координаты в операторе if задаются с помощью целочисленных констант, которые представляют изображение рывка из текстурного
атласа. Это BOOST_TEX_LEFT, BOOST_TEX_TOP, BOOST_TEX_WIDTH и BOOST_TEX_HEIGHT.
Добавьте в функцию draw следующий код:
else if (m_PlayerUpdate->m_LeftIsHeldDown &&
m_PlayerUpdate->m_BoostIsHeldDown)
{
canvas[m_VertexStartIndex].texCoords.x =
BOOST_TEX_LEFT + BOOST_TEX_WIDTH;
canvas[m_VertexStartIndex].texCoords.y = 0;
canvas[m_VertexStartIndex + 1].texCoords.x =
BOOST_TEX_LEFT;
canvas[m_VertexStartIndex + 1].texCoords.y = 0;
canvas[m_VertexStartIndex + 2].texCoords.x =
BOOST_TEX_LEFT;
canvas[m_VertexStartIndex + 2].texCoords.y = 100;
canvas[m_VertexStartIndex + 3].texCoords.x =
BOOST_TEX_LEFT + BOOST_TEX_WIDTH;
canvas[m_VertexStartIndex + 3].texCoords.y = 100;
}
Данный код выполняется, когда персонаж совершает рывок влево. Опять же, мы
используем константы, которые представляют изображение рывка, и, как и в случае, когда персонаж бежит влево, мы отзеркаливаем координаты по горизонтали
и считываем пиксели справа налево, чтобы при выводе на экран изображение
персонажа корректно отображалось.
Добавьте в функцию draw следующее:
else if (m_PlayerUpdate->m_BoostIsHeldDown)
{
canvas[m_VertexStartIndex].texCoords.x
= BOOST_TEX_LEFT;
canvas[m_VertexStartIndex].texCoords.y
= BOOST_TEX_TOP;
canvas[m_VertexStartIndex + 1].texCoords.x
= BOOST_TEX_LEFT + BOOST_TEX_WIDTH;
canvas[m_VertexStartIndex + 1].texCoords.y
= BOOST_TEX_TOP;
canvas[m_VertexStartIndex + 2].texCoords.x
= BOOST_TEX_LEFT + BOOST_TEX_WIDTH;
canvas[m_VertexStartIndex + 2].texCoords.y
= BOOST_TEX_TOP + BOOST_TEX_HEIGHT;
canvas[m_VertexStartIndex + 3].texCoords.x
= BOOST_TEX_LEFT;
470 Глава 18. Платформы, анимация игрового персонажа и элементы управления
}
canvas[m_VertexStartIndex + 3].texCoords.y
= BOOST_TEX_TOP + BOOST_TEX_HEIGHT
Здесь оператор else if выполняется, только когда удерживается кнопка, отвечающая за рывок. При рывке и удержании правой кнопки используются одни
и те же константы.
Добавим в функцию draw очередной фрагмент кода:
else
{
}
}
if (m_LastFacingRight)
{
canvas[m_VertexStartIndex].texCoords.x =
m_StandingStillSectionToDraw->left;
canvas[m_VertexStartIndex].texCoords.y =
m_StandingStillSectionToDraw->top;
canvas[m_VertexStartIndex + 1].texCoords.x =
m_StandingStillSectionToDraw->left + texWidth;
canvas[m_VertexStartIndex + 1].texCoords.y =
m_StandingStillSectionToDraw->top;
canvas[m_VertexStartIndex + 2].texCoords.x =
m_StandingStillSectionToDraw->left + texWidth;
canvas[m_VertexStartIndex + 2].texCoords.y =
m_StandingStillSectionToDraw->top + texHeight;
canvas[m_VertexStartIndex + 3].texCoords.x =
m_StandingStillSectionToDraw->left;
canvas[m_VertexStartIndex + 3].texCoords.y =
m_StandingStillSectionToDraw->top + texHeight;
}
else
{
canvas[m_VertexStartIndex].texCoords.x =
m_StandingStillSectionToDraw->left + texWidth;
canvas[m_VertexStartIndex].texCoords.y =
m_StandingStillSectionToDraw->top;
canvas[m_VertexStartIndex + 1].texCoords.x =
m_StandingStillSectionToDraw->left;
canvas[m_VertexStartIndex + 1].texCoords.y =
m_StandingStillSectionToDraw->top;
canvas[m_VertexStartIndex + 2].texCoords.x =
m_StandingStillSectionToDraw->left;
canvas[m_VertexStartIndex + 2].texCoords.y =
m_StandingStillSectionToDraw->top + texHeight;
canvas[m_VertexStartIndex + 3].texCoords.x =
m_StandingStillSectionToDraw->left + texWidth;
canvas[m_VertexStartIndex + 3].texCoords.y =
m_StandingStillSectionToDraw->top + texHeight;
}
Третий запуск игры 471
В данной и заключительной части функции draw есть финальный блок else,
который дополняет все остальные операторы if и else-if. Он обрабатывает
случай, когда персонаж стоит на месте. Внутри блока else находится оператор
if, который выполняется, если m_LastFacingRight равна true, и оператор else,
который выполняется, если m_LastFacingRight равна false. В обоих случаях
координаты, сохраненные в m_StandingStillSectionToDraw, используются для
установки текстурных координат. Однако в операторе else координаты по горизонтали отзеркаливаются, чтобы персонаж был повернут влево.
Теперь мы можем насладиться плодами нашей работы и запустить игру.
Третий запуск игры
Временно измените одну строку кода в файле LevelUpdate.h, как показано здесь:
bool m_IsPaused = false.
Изменение m_isPaused на false позволит платформам появляться. Запустите
код. Теперь вы можете бегать, совершать рывки и прыгать сколько угодно.
Обязательно протестируйте бег влево, чтобы увидеть, что анимация корректно
работает (рис. 18.6).
Измените m_IsPaused обратно на true.
Рис. 18.6. Просмотр анимации
472 Глава 18. Платформы, анимация игрового персонажа и элементы управления
Резюме
В этой главе мы запрограммировали платформы. Как и ожидалось, потребовались
два класса: один — унаследованный от Update, другой — от Graphics. Мы добавили элементы управления игроком, создали класс Animator и задействовали его
в классе PlayerGraphics, чтобы игрок плавно двигался вправо и влево. В следующей главе мы сначала создадим меню для управления паузой, возобновлением
и выходом из игры, а затем добавим эффект дождя.
19
Экран меню и дождь
Здесь мы реализуем две важные функции. Одна из них — экран меню, который
будет информировать игрока о возможностях запуска, паузы, перезапуска
и выхода из игры. Другой задачей будет создание простого эффекта дождя.
Вы можете возразить, что он не нужен или даже что он не подходит к игре,
но это просто, весело и в целом полезный навык. Мы напишем классы, унаследованные от Graphics и Update, объединим их в экземпляры GameObject, и они
будут прекрасно работать вместе со всеми остальными нашими игровыми
сущностями.
Готовый код для этой главы находится в папке Run5.
Создание интерактивного меню
На рис. 19.1 показано, как будет выглядеть меню в двух возможных состояниях.
Рис. 19.1. Два состояния меню
Здесь представлены два варианта: слева игроку сообщается, что он может
нажать Esc, чтобы начать игру, или F1, чтобы выйти из нее, а справа — что Esc
позволяет продолжить игру, а F1 по-прежнему используется для выхода. Это
небольшое различие обусловлено тем, что во время игры игрок также сможет
поставить игру на паузу, нажав Esc. Когда вы находитесь в меню, нажатие клавиши F1 всегда приводит к выходу, однако во время игры это не работает.
474 Глава 19. Экран меню и дождь
Класс MenuUpdate
Теперь мы создадим новый класс, который будет управлять нашим внутри
игровым меню. Создайте новый класс под названием MenuUpdate, унаследованный от Update, и новый класс MenuGraphics, унаследованный от Graphics.
Теперь мы можем приступить к написанию кода. Добавьте следующий код
в MenuUpdate.h:
#pragma once
#include "Update.h"
#include "InputReceiver.h"
#include <SFML/Graphics.hpp>
using namespace sf;
using namespace std;
class MenuUpdate :
public Update
{
private:
FloatRect m_Position;
InputReceiver m_InputReceiver;
FloatRect* m_PlayerPosition = nullptr;
bool m_IsVisible = false;
bool* m_IsPaused;
bool m_GameOver;
RenderWindow* m_Window;
public:
MenuUpdate(RenderWindow* window);
void handleInput();
FloatRect* getPositionPointer();
bool* getGameOverPointer();
InputReceiver* getInputReceiver();
};
//Из Update : Component
void update(float fps) override;
void assemble(
shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate)
override;
Далее приведено описание всех переменных-членов и функций, представленных
в коде выше:
zzпеременная типа FloatRect с именем m_Position будет хранить горизонталь-
ное и вертикальное положение и размер;
zzэкземпляр типа InputReceiver под названием m_InputReceiver играет ту же
роль, что и одноименная переменная в PlayerUpdate и CameraUpdate, за исключением того, что он будет реагировать на нажатие Esc и F1;
Создание интерактивного меню 475
zzуказатель m_PlayerPosition типа FloatRect будет отслеживать положение пер-
сонажа, чтобы меню при отображении позиционировалось относительно него;
zzлогическая переменная m_IsVisible позволяет меню «знать», когда отображаться, а когда — нет;
zzлогический указатель m_IsPaused будет хранить адрес переменной в классе
LevelUpdate, которая определяет, приостановлена ли игра. Затем, в сочетании
с m_IsVisible и m_GameOver, это поможет меню «понять», когда отображаться
и какое изображение использовать. Логическое значение m_GameOver работает
в сочетании с двумя предыдущими переменными;
zzуказатель RenderWindow m_Window дает меню возможность закрыть окно приложения и завершить его выполнение;
zzконструктор MenuUpdate используется для подготовки класса MenuUpdate
к выполнению своих задач. Он будет обрабатывать детали, которые не может
выполнить функция assemble;
zzфункция handleInput вызывается один раз за кадр из функции update для обработки событий операционной системы, отправленных из InputDispatcher
в основном игровом цикле;
zzфункция getPositionPointer возвращает указатель типа FloatRect на позицию и масштаб меню;
zzфункция getGameOverPointer возвращает адрес логической переменной, которая указывает, закончилась ли игра из-за проигрыша;
zzфункция getInputReceiver возвращает адрес экземпляра InputReceiver для
InputDispatcher;
zzпереопределенная функция update выполняется на каждой итерации игрового цикла;
zzпереопределенная функция assemble возвращает void и имеет следующие
параметры: shared_ptr<LevelUpdate> levelUpdate и shared_ptr<PlayerUpdate>
playerUpdate. В ближайшее время мы приведем код этой функции, чтобы показать специфичный, но похожий способ ее использования в данном классе.
Теперь перейдем к реализации класса MenuUpdate. Добавим код для MenuUpdate.cpp
в четыре основных блока. Чтобы они работали, нам нужно будет добавить следующие директивы include:
#include
#include
#include
#include
"MenuUpdate.h"
"LevelUpdate.h"
"PlayerUpdate.h"
"SoundEngine.h"
Добавьте первый блок кода для MenuUpdate.cpp:
MenuUpdate::MenuUpdate(RenderWindow* window)
{
m_Window = window;
}
476 Глава 19. Экран меню и дождь
FloatRect* MenuUpdate::getPositionPointer()
{
return &m_Position;
}
bool* MenuUpdate::getGameOverPointer()
{
return &m_GameOver;
}
InputReceiver* MenuUpdate::getInputReceiver()
{
return &m_InputReceiver;
}
Здесь находится конструктор, в котором инициализируется указатель на Render
Window. Мы воспользуемся указателем на RenderWindow, когда игрок нажмет F1,
чтобы завершить игру. Функция getPositionPointer возвращает указатель на
m_Position. Другой класс, которому важна позиция меню, — это, конечно же,
класс MenuGraphics, который отвечает за отрисовку меню.
Функция getGameOverPointer возвращает адрес логической переменной
m_GameOver . Функция getInputReceiver используется (как и в PlayerUpdate
и CameraUpdate) для получения указателя на экземпляр InputReceiver. Это нужно
для InputDispatcher, чтобы знать, куда отправлять все события операционной
системы в каждом кадре.
Затем добавьте функцию assemble в файл MenuUpdate.cpp:
void MenuUpdate::assemble(
shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate)
{
m_PlayerPosition =
playerUpdate->getPositionPointer();
m_IsPaused =
levelUpdate->getIsPausedPointer();
m_Position.height = 75;
m_Position.width = 75;
}
SoundEngine::startMusic();
SoundEngine::pauseMusic();
Функция assemble подготавливает класс к использованию. Сперва адрес позиции
персонажа инициализируется в m_PlayerPosition, а адрес логической переменной, указывающей на состояние паузы, из экземпляра LevelUpdate копируется
в m_IsPaused. Затем ширина и высота меню определяются «магическим числом»,
Создание интерактивного меню 477
которое в данном случае равно 75. Наконец, музыка запускается и тут же приостанавливается. Теперь музыка готова к воспроизведению в любой момент,
когда игрок возобновляет игру или ставит ее на паузу.
Далее добавьте функцию handleInput в MenuUpdate.cpp:
void MenuUpdate::handleInput()
{
for (const Event& event :
m_InputReceiver.getEvents())
{
if (event.type ==
Event::KeyPressed)
{
if (event.key.code ==
Keyboard::F1 && m_IsVisible)
{
if (SoundEngine::mMusicIsPlaying)
{
SoundEngine::stopMusic();
}
m_Window->close();
}
}
if (event.type == Event::KeyReleased)
{
if (event.key.code ==
Keyboard::Escape)
{
m_IsVisible = !m_IsVisible;
*m_IsPaused = !*m_IsPaused;
if (m_GameOver)
{
m_GameOver = false;
}
if (!*m_IsPaused)
{
SoundEngine::resumeMusic();
SoundEngine::playClick();
}
if (*m_IsPaused)
{
SoundEngine::pauseMusic();
SoundEngine::playClick();
}
}
}
}
}
m_InputReceiver.clearEvents();
478 Глава 19. Экран меню и дождь
Функция handleInputFunction не должна вызвать сложностей, так как схожа с циклами обработки событий, которые мы создавали ранее. Цикл for, который перебирает все события ввода для текущего кадра игры, включает в себя два оператора if.
Первый оператор if проверяет комбинацию нажатия F1 и видимости меню. Если
музыка играет, она останавливается, а затем указатель RenderWindow используется
для закрытия окна и завершения игры.
Второй оператор if и последующее вложенное условие if проверяют нажатие
клавиши Esc. Сначала m_IsVisible переключается на противоположное значение.
Если оно было true, то становится false, и наоборот. Это как раз то, что нам
нужно. Каждый раз, когда игрок нажимает Esc, состояние игры переключается
между паузой и игрой. Точно такое же переключение выполняется с помощью
m_IsVisible, чтобы показать и скрыть меню.
На этом этапе логические состояния установлены правильно, и код выполняет
необходимое действие в зависимости от текущего состояния. Если игра закончена (m_GameOver равно true), то m_GameOver устанавливается в false. Если игра
не приостановлена, то музыка возобновляется и воспроизводится звук щелчка,
и, наконец, если игра на паузе, то музыка приостанавливается и также воспроизводится звук щелчка.
За пределами событий цикла for все события очищаются из экземпляра m_Input
Receiver , готового к следующей итерации основного цикла игры, вызовом
clearEvents.
Наконец, добавьте код для функции update в файл MenuUpdate.cpp:
void MenuUpdate::update(float fps)
{
handleInput();
if (*m_IsPaused && !m_IsVisible) // Игра завершена 1
{
m_IsVisible = true;
m_GameOver = true;
}
if (m_IsVisible)
{
// Следуем за игроком
m_Position.left =
m_PlayerPosition->getPosition()–x m_Position.width / 2;
m_Position.top =
m_PlayerPosition->getPosition()–y m_Position.height / 2;
}
else
{
m_Position.left = -999;
Создание интерактивного меню 479
}
}
m_Position.top = -999;
Функция update класса MenuUpdate начинается с вызова функции handleInput,
которую мы написали ранее. Первый оператор if выполняется, когда игра приостановлена, а меню не отображается. Код в операторе if устанавливает paused
и m_GameOver в true.
Второй оператор if выполняется, когда меню становится видимым. Когда меню
видно, конечно, мы должны убедиться, что меню действительно отображается.
Для этого m_Position.left и m_Position.top инициализируются левой и верхней
позициями игрового персонажа соответственно, за вычетом его ширины и высоты. Это позволит расположить меню в центре экрана, над персонажем.
В последнем блоке else, который выполняется, когда игра не приостановлена,
мы инициализируем m_Position.left и m_Position.top значением -999, что
скрывает меню.
Теперь мы можем перейти к классу MenuGraphics и посмотреть, как MenuUpdate
и MenuGraphics дополняют друг друга.
Класс MenuGraphics
Для начала добавьте следующий код в файл MenuGraphics.h:
#pragma once
#include "Graphics.h"
#include "SFML/Graphics.hpp"
class MenuGraphics :
public Graphics
{
private:
FloatRect* m_MenuPosition = nullptr;
int m_VertexStartIndex;
bool* m_GameOver;
bool m_CurrentStatus = false;
int
int
int
int
uPos;
vPos;
texWidth;
texHeight;
public:
// Из Graphics : Component
void draw(VertexArray& canvas) override;
void assemble(VertexArray& canvas,
shared_ptr<Update> genericUpdate,
IntRect texCoords) override;
};
480 Глава 19. Экран меню и дождь
Здесь указатель m_MenuPosition типа FloatRect инициализирован nullptr. Вскоре эта переменная будет инициализирована для отслеживания экземпляра
m_Position типа FloatRect из класса MenuUpdate.
Целочисленная переменная m_VertexStartIndex будет инициализирована для
запоминания начального индекса четырехугольника вершин, представляющих
меню в VertexArray, который отрисовывается для каждого кадра игры.
Логический указатель m_GameOver будет инициализирован для отслеживания переменной m_GameOver из класса MenuUpdate , а логическая переменная
m_CurrentStatus — для принятия решений и их запоминания путем инициализации в m_GameOver и последующей проверки изменений. Это станет понятнее,
когда мы увидим код в MenuGraphics.cpp.
Целочисленная переменная uPos будет хранить горизонтальную текстурную
координату, vPos — вертикальную, texWidth — ширину текстуры, а texHeight —
ее высоту.
Первая публичная функция — это переопределенная функция draw, сигнатура
которой нам уже знакома.
Функция assemble имеет обычные параметры, которые мы неоднократно видели. Совсем скоро мы ее запрограммируем, чтобы понять, как собирается класс
MenuGraphics.
В файле MenuGraphics.cpp мы напишем код функции assemble и draw.
Добавьте функцию assemble в MenuGraphics.cpp:
#include "MenuGraphics.h"
#include "MenuUpdate.h"
void MenuGraphics::assemble(
VertexArray& canvas,
shared_ptr<Update> genericUpdate,
IntRect texCoords)
{
m_MenuPosition = static_pointer_cast<MenuUpdate>(
genericUpdate)->getPositionPointer();
m_GameOver = static_pointer_cast<MenuUpdate>(
genericUpdate)->getGameOverPointer();
m_CurrentStatus = *m_GameOver;
m_VertexStartIndex = canvas.getVertexCount();
canvas.resize(canvas.getVertexCount() + 4);
// Запоминаем UV-координаты,
// потому что позже мы будем изменять их
uPos = texCoords.left;
Создание интерактивного меню 481
vPos = texCoords.top;
texWidth = texCoords.width;
texHeight = texCoords.height;
}
canvas[m_VertexStartIndex].texCoords.x = uPos;
canvas[m_VertexStartIndex].texCoords.y =
vPos + texHeight;
canvas[m_VertexStartIndex + 1].texCoords.x =
uPos + texWidth;
canvas[m_VertexStartIndex + 1].texCoords.y =
vPos + texHeight;
canvas[m_VertexStartIndex + 2].texCoords.x =
uPos + texWidth;
canvas[m_VertexStartIndex + 2].texCoords.y =
vPos + texHeight + texHeight;
canvas[m_VertexStartIndex + 3].texCoords.x =
uPos;
canvas[m_VertexStartIndex + 3].texCoords.y =
vPos + texHeight + texHeight;
В приведенном выше коде функция assemble начинается с этой строки:
m_MenuPosition = static_pointer_cast<MenuUpdate>(
genericUpdate)->getPositionPointer();
Функция static_pointer_cast преобразует экземпляр genericUpdate , который в данный момент имеет тип Update, в конкретный экземпляр MenuUpdate
shared_ptr. В той же строке кода после преобразования вызывается функция
getPositionPointer для экземпляра MenuUpdate. Результат этого вызова сохраняется в m_MenuPosition.
В следующей строке кода применяется та же техника приведения, только вызывается getGameOverPointer, а результат сохраняется в m_GameOver.
Затем происходит разыменование m_GameOver, и целочисленное значение сохраняется в m_CurrentStatus.
Далее переменная m_StartIndex инициализируется, получая текущий размер
VertexArray, а затем размер VertexArray увеличивается, чтобы вместить еще один
четырехугольник с помощью вызова canvas.resize.
Наконец мы запоминаем текстурные координаты, инициализируя uPos, vPos,
texWidth и texHeight из переданных текстурных координат. Помните, что они
хранятся в памяти и вскоре будут переданы из класса Factory.
Следующие восемь строк кода инициализируют текстурные координаты непосредственно в VertexArray. Причина, по которой нам нужно хранить исходные
текстурные координаты отдельно от VertexArray, заключается в том, что вскоре
482 Глава 19. Экран меню и дождь
мы добавим код в функцию update, который будет управлять текстурными координатами для отображения различных версий нашего меню для случаев, когда
игра поставлена на паузу (в отличие от завершения игры).
Наконец, добавьте функцию draw в файл MenuGraphics.cpp:
void MenuGraphics::draw(VertexArray& canvas)
{
if (*m_GameOver && !m_CurrentStatus)
// Текущее состояние только что переключилось на "игра завершена"
{
// Каждая координата v удваивается
// для ссылки на текстуру ниже
m_CurrentStatus = *m_GameOver;
canvas[m_VertexStartIndex].texCoords.x =
uPos;
canvas[m_VertexStartIndex].texCoords.y =
vPos + texHeight;
canvas[m_VertexStartIndex + 1].texCoords.x =
uPos + texWidth;
canvas[m_VertexStartIndex + 1].texCoords.y =
vPos + texHeight;
canvas[m_VertexStartIndex + 2].texCoords.x =
uPos + texWidth;
canvas[m_VertexStartIndex + 2].texCoords.y =
vPos + texHeight + texHeight;
canvas[m_VertexStartIndex + 3].texCoords.x =
uPos;
canvas[m_VertexStartIndex + 3].texCoords.y =
vPos + texHeight + texHeight;
}
else if (!*m_GameOver && m_CurrentStatus)
{
m_CurrentStatus = *m_GameOver;
canvas[m_VertexStartIndex].texCoords.x =
uPos;
canvas[m_VertexStartIndex].texCoords.y =
vPos;
canvas[m_VertexStartIndex + 1].texCoords.x =
uPos + texWidth;
canvas[m_VertexStartIndex + 1].texCoords.y =
vPos;
canvas[m_VertexStartIndex + 2].texCoords.x =
uPos + texWidth;
canvas[m_VertexStartIndex + 2].texCoords.y =
vPos + texHeight;
canvas[m_VertexStartIndex + 3].texCoords.x =
uPos;
Создание интерактивного меню 483
}
canvas[m_VertexStartIndex + 3].texCoords.y =
vPos + texHeight;
const Vector2f& position =
m_MenuPosition->getPosition();
}
canvas[m_VertexStartIndex].position =
position;
canvas[m_VertexStartIndex + 1].position =
position + Vector2f(m_MenuPosition->getSize().x, 0);
canvas[m_VertexStartIndex + 2].position =
position + m_MenuPosition->getSize();
canvas[m_VertexStartIndex + 3].position =
position + Vector2f(0, m_MenuPosition->getSize().y);
В этом коде есть два оператора: if и else if. Первое условие if выполняется,
когда m_GameOver равно true, а m_CurrentStatus — false. Блок else if выполняется, когда m_GameOver равно false и m_CurrentStatus — false.
Сначала рассмотрим, что происходит в блоке if . Значение m_CurrentStatus
устанавливается в разыменованное значение m_GameOver, а затем задаются все
текстурные координаты в массиве VertexArray. Они устанавливаются с помощью
тех же значений, которые мы использовали в функции assemble. Эти значения
соответствуют нижней версии меню в текстурном атласе.
Далее рассмотрим, что происходит в блоке else if. Здесь m_CurrentStatus снова синхронизируется с m_GameOver , и все текстурные координаты в массиве
VertexArray устанавливаются. Однако обратите внимание на код для всех вертикальных координат: в них отсутствует +texHeight. Это означает, что координаты теперь привязаны к верхней версии меню в текстурном атласе. Текстурные
координаты будут меняться местами каждый раз, когда игрок проигрывает,
и каждый раз, когда игрок ставит игру на паузу после перезапуска. Таким образом, текстурные координаты привязываются либо к меню паузы, либо к меню
завершения игры.
Конечно, мы еще не расположили вершины. Мы должны это сделать, потому
что они будут регулярно меняться в классе MenuUpdate по мере отображения
и скрытия меню. После выполнения структуры if-else-if, которую мы только
что рассмотрели, позиции вершин в массиве VertexArray позиционируются
с помощью значений в m_MenuPosition, указывающих на FloatRect в MenuUpdate.
Чтобы сделать код более лаконичным, мы сначала инициализируем константу
Vector2f вызовом m_MenuPosition->getPosition.
484 Глава 19. Экран меню и дождь
Создание меню в фабрике
Теперь мы можем создать рабочее меню, объединив экземпляр GameObject с нашими двумя новыми классами. Добавьте директивы include для двух наших
классов, связанных с меню:
#include "MenuUpdate.h"
#include "MenuGraphics.h"
Затем добавьте следующий код непосредственно перед закрывающей фигурной
скобкой функции loadLevel в файле Factory.cpp:
// Меню
GameObject menu;
shared_ptr<MenuUpdate> menuUpdate = make_shared<MenuUpdate>(m_Window);
menuUpdate->assemble(levelUpdate, playerUpdate);
inputDispatcher.registerNewInputReceiver(
menuUpdate->getInputReceiver());
menu.addComponent(menuUpdate);
shared_ptr<MenuGraphics>menuGraphics = make_shared<MenuGraphics>();
menuGraphics->assemble(canvas, menuUpdate,
IntRect(TOP_MENU_TEX_LEFT, TOP_MENU_TEX_TOP,
TOP_MENU_TEX_WIDTH, TOP_MENU_TEX_HEIGHT));
menu.addComponent(menuGraphics);
gameObjects.push_back(menu);
// Конец кода меню
Этот код должен показаться вам знакомым. Здесь мы выполняем следующие
действия.
1. Создаем новый экземпляр GameObject с именем menu.
2. Создаем общий указатель на экземпляр MenuUpdate под названием menuUpdate.
3. Вызываем функцию assemble на menuUpdate, передавая в levelUpdate и player
Update общие указатели.
4. Подготавливаем меню к получению обновлений, вызывая registerNewInput
Receiver на inputDispatcher и передавая в него результат, возвращенный из
menuUpdate->getInputReceiver.
5. Добавляем menuUpdate к экземпляру GameObject меню menu.
6. Создаем общий указатель типа MenuGraphics.
7. Вызываем функцию assemble для menuGraphics и передаем ей массив VertexArray,
экземпляр LevelUpdate и все необходимые текстурные координаты.
Первый запуск игры 485
8. Добавляем экземпляр MenuGraphics к экземпляру GameObject.
9. Наконец, добавляем экземпляр GameObject, который представляет наше меню,
в вектор gameObjects.
Вот и все. Наше меню готово.
Первый запуск игры
Теперь вы можете запустить игру. При нажатии Esc вы увидите меню, как показано на рис. 19.2.
Рис. 19.2. Меню
Вы сможете нажать F1, чтобы выйти из игры, как это предлагается в меню. Однако
если вы попытаетесь нажать Esc, чтобы начать игру, это также приведет к выходу.
Это не то поведение, которое нам нужно. Нам необходимо внести два небольших
изменения.
Найдите следующие строки кода в файле InputDispatcher.cpp и удалите их:
if (event.type == Event::KeyPressed &&
event.key.code == Keyboard::Escape)
{
m_Window->close();
}
Теперь вы можете играть в игру и ставить ее на паузу нажатием клавиши Esc,
а также закрывать ее с помощью F1, когда видно меню. Класс InputDispatcher
486 Глава 19. Экран меню и дождь
больше не обрабатывает никаких событий, он просто отправляет их экземплярам
InputReceiver в меню, игровому персонажу и камере на мини-карте.
Нам также нужно запретить игре запускаться автоматически. Мы сделаем это
в классе LevelUpdate.h. Найдите следующую строку кода:
bool m_IsPaused = false;
Затем измените ее следующим образом:
bool m_IsPaused = true;
Теперь пауза, запуск и выход из игры работают как положено.
Дождь
Нам нужно только изображение. Это нормально, как и то, что для логики уровня
достаточно иметь только компонент update. Хотя состояние RainGraphics будет
меняться, оно никак не зависит от времени выполнения основного игрового цикла или ввода игрока. Все изменения состояния управляются экземпляром класса
Animator внутри класса RainGraphics, который имеет собственные внутренние
часы. Мы создадим несколько экземпляров класса RainGraphics, поскольку они
будут занимать небольшой участок экрана. Каждый экземпляр RainGraphics
будет позиционироваться относительно игрового персонажа и следовать за ним,
создавая впечатление дождливой погоды.
Создание класса RainGraphics
Изображение в текстурном атласе выглядит так, как показано на рис. 19.3.
Рис. 19.3. Дождь из атласа
Я нарисовал рамки вокруг каждого кадра анимации и изменил фон с прозрачного на белый. Каждый кадр анимации имеет размер 100 × 100 пикселей. Таким
образом, общий размер спрайт-листа дождя составляет 400 × 100 пикселей. Все
кадры аккуратно выстроены в ряд и готовы к циклическому просмотру нашим
классом Animator, который мы также использовали для анимации персонажа.
Дождь 487
Создайте новый класс RainGraphics и добавьте следующий код в файл Rain
Graphics.h:
#pragma once
#include "Graphics.h"
class Animator;
class RainGraphics :
public Graphics
{
private:
FloatRect* m_PlayerPosition;
int m_VertexStartIndex;
Vector2f m_Scale;
float m_HorizontalOffset;
float m_VerticalOffset;
Animator* m_Animator;
IntRect* m_SectionToDraw;
public:
RainGraphics(FloatRect* playerPosition,
float horizontalOffset,
float verticalOffset,
int rainCoveragePerObject);
};
// Из Graphics : Component
void draw(VertexArray& canvas) override;
void assemble(VertexArray& canvas,
shared_ptr<Update> genericUpdate,
IntRect texCoords) override;
В предыдущем коде RainGraphics.h указатель m_PlayerPosition типа FloatRect
будет отслеживать положение нашего героя, чтобы дождь мог следовать за ним,
как в мультфильмах, когда неудачливого персонажа преследует серая туча.
Переменная m_VertexStartIndex запоминает начальный индекс вершин в массиве
VertexArray.
Переменная m_Scale запоминает размер кадра анимации. Переменная m_Hori
zontalOffset типа float — это начальное горизонтальное значение для изображения в атласе текстур, а переменная m_VerticalOffset типа float — эквивалент
вертикального значения.
Экземпляр m_Animator — это наш Animator, а указатель IntRect m_SectionToDraw
будет хранить текстурные координаты текущего кадра анимации.
Конструктор RainGraphics принимает и инициализирует переменные, о которых
мы только что говорили, а также целое число rainCoveragePerObject, помогающее
нам масштабировать каждый экземпляр изображения дождя.
488 Глава 19. Экран меню и дождь
Функции draw и assemble — это обычные переопределенные функции, которые
имеют идентичные объявления, но их реализация будет интересной, и мы обсудим ее в ближайшее время.
Далее для файла RainGraphics.cpp напишем код в двух частях: сначала конструктор, а затем функцию assemble. Добавьте следующий код в RainGraphics.cpp:
#include "RainGraphics.h"
#include "RainGraphics.h"
#include "Animator.h"
RainGraphics::RainGraphics(
FloatRect* playerPosition,
float horizontalOffset,
float verticalOffset,
int rainCoveragePerObject)
{
m_PlayerPosition = playerPosition;
m_HorizontalOffset = horizontalOffset;
m_VerticalOffset = verticalOffset;
m_Scale.x = rainCoveragePerObject;
m_Scale.y = rainCoveragePerObject;
}
В конструкторе мы инициализируем указатель на позицию игрового персонажа,
горизонтальное и вертикальное смещение для Animator, а также переменную
m_Scale, которая является IntRect и использует одно и то же значение для x и y.
Мы скоро увидим это в действии.
Затем добавьте функцию assemble, как показано ниже:
void RainGraphics::assemble(VertexArray& canvas,
shared_ptr<Update> genericUpdate,
IntRect texCoords)
{
m_Animator = new Animator(
texCoords.left,
texCoords.top,
4, // Кадры
texCoords.width * 4,
texCoords.height,
8); // Кадров в секунду
m_VertexStartIndex = canvas.getVertexCount();
canvas.resize(canvas.getVertexCount() + 4);
}
В функции assemble мы инициализируем наш экземпляр Animator, вызывая new
и передавая необходимые параметры. Обратите внимание, что здесь четыре кадра,
как и ожидалось, и мы указали восемь кадров в секунду.
Начальный индекс запоминается, и в массив VertexArray добавляется место для
четырех вершин квадрата, представляющего текстуру дождя, как мы уже делали
много раз. Однако помните, что будет несколько экземпляров класса RainGraphics.
Дождь 489
Наконец, добавьте функцию draw в RainGraphics.cpp, как показано ниже:
void RainGraphics::draw(VertexArray& canvas)
{
const Vector2f& position =
m_PlayerPosition->getPosition()
- Vector2f(m_Scale.x / 2 + m_HorizontalOffset,
m_Scale.y / 2 + m_VerticalOffset);
// Перемещаем дождь, чтобы он следовал за игроком
canvas[m_VertexStartIndex].position = position;
canvas[m_VertexStartIndex + 1].position =
position + Vector2f(m_Scale.x, 0);
canvas[m_VertexStartIndex + 2].position =
position + m_Scale;
canvas[m_VertexStartIndex + 3].position =
position + Vector2f(0, m_Scale.y);
// Перебираем кадры
m_SectionToDraw =
m_Animator->getCurrentFrame(false);
// Запоминаем текстуру для отрисовки
const int uPos = m_SectionToDraw->left;
const int vPos = m_SectionToDraw->top;
const int texWidth = m_SectionToDraw->width;
const int texHeight = m_SectionToDraw->height;
}
canvas[m_VertexStartIndex].texCoords.x =
uPos;
canvas[m_VertexStartIndex].texCoords.y =
vPos;
canvas[m_VertexStartIndex + 1].texCoords.x
uPos + texWidth;
canvas[m_VertexStartIndex + 1].texCoords.y
vPos;
canvas[m_VertexStartIndex + 2].texCoords.x
uPos + texWidth;
canvas[m_VertexStartIndex + 2].texCoords.y
vPos + texHeight;
canvas[m_VertexStartIndex + 3].texCoords.x
uPos;
canvas[m_VertexStartIndex + 3].texCoords.y
vPos + texHeight;
=
=
=
=
=
=
В функции draw первая часть кода использует позицию игрового персонажа для
перемещения дождя, чтобы он успевал за ним, куда бы он ни двигался. Обратите
внимание, что в первой строке кода применяются значения m_HorizontalOffset
и m_VerticalOffset, чтобы убедиться, что экземпляр расположен в правильном
месте относительно всех других экземпляров RainGraphics. Эти значения смещения, как вы помните, были переданы в конструктор, который мы написали
ранее. Как вы, наверное, уже догадались, несколько экземпляров RainGraphics
и их смещения будут скоординированы при создании в классе Factory.
490 Глава 19. Экран меню и дождь
Затем вызывается функция getCurrentFrame класса Animator, чтобы получить
текущие текстурные координаты, и обычные восемь строк кода присваивают соответствующие координаты X и Y четырем вершинам четырехугольника.
Создание дождя в фабрике
Для начала добавьте следующую директиву include в Factory.cpp:
#include "RainGraphics.h"
Добавьте следующий код для создания нескольких экземпляров RainGraphics
сразу после кода platform и перед camera:
// Дождь
int rainCoveragePerObject = 25;
int areaToCover = 350;
for (int h = -areaToCover / 2;
h < areaToCover / 2;
h += rainCoveragePerObject)
{
for (int v = -areaToCover / 2;
v < areaToCover / 2;
v += rainCoveragePerObject)
{
GameObject rain;
shared_ptr<RainGraphics> rainGraphics =
make_shared<RainGraphics>(
playerUpdate->getPositionPointer(),
h, v, rainCoveragePerObject);
rainGraphics->assemble(
canvas, nullptr,
IntRect(RAIN_TEX_LEFT, RAIN_TEX_TOP,
RAIN_TEX_WIDTH, RAIN_TEX_HEIGHT));
rain.addComponent(rainGraphics);
gameObjects.push_back(rain);
}
}
// Конец кода для дождя
В приведенном коде мы делаем уже привычные вещи: создаем экземпляры
GameObject и RainGraphics, добавляем экземпляр RainGraphics к GameObject,
а GameObject в вектор GameObjects.
Новым здесь является объявление дополнительных переменных для управления
положением и размером нескольких экземпляров RainGraphics:
int rainCoveragePerObject = 25;
int areaToCover = 350;
Второй запуск игры 491
Обратите внимание на структуру цикла for, который выполняет итерацию и создает несколько экземпляров. Вот он снова:
for (int h = -areaToCover / 2;
h < areaToCover / 2;
h += rainCoveragePerObject)
Условие цикла for означает, что h будет изменяться от -175 до 175 с шагом 25.
Внутри экземпляров RainGraphics все эти значения — мировые единицы (world
units), а не пиксели.
Внутренний цикл for, инициализирующий параметр v, использует ту же формулу, что и внешний цикл for. Наконец, обратите внимание на вызов конструктора
класса RainGraphics:
shared_ptr<RainGraphics> rainGraphics = make_shared<RainGraphics>(
playerUpdate->getPositionPointer(),
h, v, rainCoveragePerObject);
В целом это приводит к тому, что вокруг игрового персонажа зацикливается блок
RainGraphics размером 14 × 14 (196) экземпляров.
Второй запуск игры
Теперь мы можем запустить игру и увидеть плоды нашей работы (рис. 19.4).
Рис. 19.4. Дождь
492 Глава 19. Экран меню и дождь
Резюме
В этой главе мы создали интерактивное меню с двумя возможными вариантами
отображения, запрограммировав классы MenuUpdate и MenuGraphics. После этого
мы создали меню в фабрике точно так же, как добавляли функции в нашу игру
в предыдущих главах.
В завершение мы написали новый класс, унаследованный от Graphics, под названием RainGraphics, который создает простой и зрелищный эффект дождя. Как
обычно, мы обернули этот класс в экземпляр GameObject в фабрике, поместили
его в вектор gameObjects — и все заработало.
В следующей главе добавим в игру огненные шары, которые будут прилетать
слева или справа и мешать герою игры продвигаться вперед.
20
Огненные шары
и пространственный звук
В данной главе мы добавим звуковые эффекты и HUD. Мы делали это и в двух
предыдущих проектах, но в этот раз поступим немного иначе. Мы изучим сложную концепцию пространственного звука и то, как SFML делает ее простой
и понятной. Кроме того, мы создадим класс для HUD, чтобы инкапсулировать
код, который выводит информацию на экран.
Готовый код этой главы находится в папке Run6.
Что такое пространственный звук
Пространственный звук — это эффект, при котором звук становится частью
пространства, в котором он находится. В нашей повседневной жизни все в мире
по умолчанию имеет пространственный характер. Если мотоцикл проносится
мимо, мы слышим, как звук нарастает от едва слышного к громкому по мере его
приближения к нам. Когда мотоцикл будет проезжать мимо, звук станет более
отчетливым, а затем снова растворится вдали. Если бы мы проснулись однажды
утром, а мир больше не был бы пространственным, это было бы очень странно.
Если мы сможем сделать наши видеоигры более реалистичными, игроки будут
чувствовать себя более вовлеченными и погруженными в игровой процесс. Наша
игра про зомби была бы намного интереснее, если бы игроки могли слышать
зомби по мере их приближения.
Очевидно, что математика пространственного звука сложна. Как рассчитать,
насколько громким будет тот или иной звук в конкретном динамике, исходя из
расстояния и направления от игрового персонажа (слушателя звука) до объекта,
издающего звук (излучателя)?
К счастью, SFML выполняет все эти сложные процессы за нас. Все, что нам нужно
сделать, — это ознакомиться с несколькими техническими терминами, а затем
мы сможем воспользоваться SFML для пространственной обработки наших
звуковых эффектов.
494 Глава 20. Огненные шары и пространственный звук
Излучатели, затухание и слушатели
Чтобы дать SFML все необходимое для работы, нам для начала нужно знать, откуда исходит звук в нашем игровом мире. Этот источник называется излучателем
(или эмиттером). В игре излучателем могут быть зомби, транспортное средство
или, в случае нашего текущего проекта, огненный шар. Мы уже отслеживали
положение объектов в нашей игре, поэтому передать SFML местоположение излучателя будет несложно.
Следующий фактор, который стоит учесть, — затухание. В нашем случае это
уменьшение громкости звука со временем. Это не совсем точное с технической
точки зрения, но достаточно хорошее описание для этой главы и нашей игры.
И наконец, нам нужно понять, что такое слушатель. Простыми словами — это
«уши» игры. В большинстве игр логичнее всего использовать персонажа в качестве «ушей».
Давайте посмотрим на гипотетический код, прежде чем приступать к написанию
реального.
Обработка пространственного звука
с помощью SFML
В SFML есть несколько функций, которые позволяют нам работать с излучателями, затуханием и слушателями. Давайте рассмотрим их на гипотетических
примерах, а затем напишем настоящий код, отвечающий за пространственный
звук в нашем проекте.
Мы можем установить готовый к воспроизведению звуковой эффект, как мы
уже часто делали:
// Объявляем SoundBuffer, как обычно
SoundBuffer zombieBuffer;
// Объявляем объект Sound, как обычно
Sound zombieSound;
// Загружаем звук из файла, как мы делали много раз
zombieBuffer.loadFromFile("sound/zombie_growl.wav");
// Связываем объект Sound с Buffer
zombieSound.setBuffer(zombieBuffer);
Мы можем задать положение излучателя с помощью функции setPosition, как
показано в следующем коде:
//
//
//
//
//
Устанавливаем горизонтальную и вертикальную позиции излучателя
В данном случае излучателем будет зомби
В игре Zombie Arena мы могли бы использовать
getPosition().x и getPosition().y
Эти значения произвольны
Обработка пространственного звука с помощью SFML 495
float x = 500;
float y = 500;
zombieSound.setPosition(x, y, 0.0f);
Как указано в комментариях к предыдущему коду, то, как именно мы можем
получить координаты излучателя, вероятно, будет зависеть от типа игры. В игре
Zombie Arena это было бы довольно просто. В данном же проекте мы столкнемся
с некоторыми трудностями. Однако не стоит переживать: неразрешимых проблем не будет.
Мы можем установить уровень затухания следующим образом:
zombieSound.setAttenuation(15);
Эффект, который мы хотим получить, может не соответствовать точной научной формуле, используемой для уменьшения громкости звука с расстоянием
на основе затухания. Корректный уровень затухания обычно достигается путем
экспериментов. Чем выше уровень затухания, тем быстрее звук затихает.
Кроме того, мы можем задать зону вокруг излучателя, в которой громкость не будет понижаться вообще. Это полезно, если функция не подходит для работы за
пределами определенного диапазона или если у нас много источников звука,
и желательно не переусердствовать. Для этого можем воспользоваться функцией
setMinimumDistance:
zombieSound.setMinDistance(150);
При использовании предыдущей строки кода затухание не будет вычисляться
до тех пор, пока слушатель не окажется на расстоянии 150 пикселей (условных
единиц) от излучателя.
Среди других полезных функций библиотеки SFML есть функция setLoop .
Она указывает SFML продолжать воспроизводить звук снова и снова, если в качестве параметра передается true:
zombieSound.setLoop(true);
Звук будет воспроизводиться до тех пор, пока мы не остановим это с помощью
следующего кода:
zombieSound.stop();
Иногда нам нужно знать статус звука (играет или остановлен). Этого можно добиться с помощью функции getStatus:
if (zombieSound.getStatus() == Sound::Status::Stopped)
{
// Звук НЕ воспроизводится
// Выполняем необходимые действия
}
496 Глава 20. Огненные шары и пространственный звук
if (zombieSound.getStatus() == Sound::Status::Playing)
{
// Звук воспроизводится
// Выполняем необходимые действия
}
Еще один компонент пространственного звука в SFML, который нам нужно
рассмотреть, — слушатель. Где находится слушатель? Мы можем задать его положение следующим образом:
// Где находится слушатель?
// Как мы получаем значения x и y, зависит от игры.
// В игре Zombie Arena
// мы можем использовать getPosition().
Listener::setPosition(m_Thomas.getPosition().x,
m_Thomas.getPosition().y, 0.0f);
Данный код заставит все звуки воспроизводиться относительно конкретного места. Это как раз то, что нам нужно для отдаленного рева огненного шара или приближающегося зомби, но для обычных звуковых эффектов, таких как прыжки,
это проблема. Мы могли бы начать работать с излучателем для местоположения
игрока, но SFML упрощает нам задачу. Когда мы хотим воспроизвести «обычный» звук, то просто вызываем setRelativeToListener, а затем воспроизводим
звук точно таким же образом, как мы делали это до сих пор.
Вот как мы можем воспроизвести «обычный» непространственный звуковой
эффект прыжка:
jumpSound.setRelativeToListener(true);
jumpSound.play();
Все, что нам нужно сделать, — снова вызвать Listener::setPosition перед воспроизведением любых пространственных звуков, и это установит «уши» для
текущего звука.
Теперь у нас есть широкий набор функций SFML для работы со звуком, и мы
готовы приступить к делу.
Обновление класса SoundEngine
Добавим новую функциональность в класс SoundEngine и начнем внедрять функции пространственной обработки звука по-настоящему.
Первое дополнение к классу SoundEngine — это несколько новых переменныхчленов. В файле SoundEngine.h добавьте следующие два члена в раздел private:
static SoundBuffer mFireballLaunchBuffer;
static Sound mFireballLaunchSound;
Обновление класса SoundEngine 497
Теперь у нас есть новый SoundBuffer для загрузки в него звука и новый экземпляр
Sound для связывания с экземпляром SoundBuffer и воспроизведения звука.
Помните, что аудиофайл, который мы загружаем в mFireballLaunchBuffer, должен быть монофоническим, чтобы пространственный звук работал.
Затем добавьте следующее объявление функции в раздел public файла Sound
Engine.h:
static void playFireballLaunch(
Vector2f playerPosition,
Vector2f soundLocation);
Объявление функции playFireballLaunch принимает Vector2f для местоположения персонажа и Vector2f для позиции, из которой мы хотим имитировать звук.
В файл SoundEngine.cpp добавьте следующий выделенный код перед конструктором SoundEngine:
SoundBuffer SoundEngine::m_ClickBuffer;
Sound SoundEngine::m_ClickSound;
SoundBuffer SoundEngine::m_JumpBuffer;
Sound SoundEngine::m_JumpSound;
SoundBuffer SoundEngine::mFireballLaunchBuffer;
Sound SoundEngine::mFireballLaunchSound;
Данный код делает статические переменные из SoundEngine.h доступными
в SoundEngine.cpp. Статические переменные принадлежат классу и не являются
уникальными для каждого экземпляра. Это именно то, что нам нужно, так как мы
не хотим, чтобы в разных частях нашего кода использовались разные экземпляры
Sound или Music.
Теперь добавьте следующие инициализации перед закрывающей фигурной скобкой конструктора в файл SoundEngine.cpp:
Listener::setDirection(1.f, 0.f, 0.f);
Listener::setUpVector(1.f, 1.f, 0.f);
Listener::setGlobalVolume(100.f);
mFireballLaunchBuffer.loadFromFile(
"sound/fireballLaunch.wav");
mFireballLaunchSound.setBuffer(
mFireballLaunchBuffer);
В приведенном коде мы установили значения экземпляра слушателя Listener
для направления, задали вектор и глобальную громкость. Эти значения являются
глобальными и влияют на все звуки.
498 Глава 20. Огненные шары и пространственный звук
Теперь добавьте функцию playFireballLaunch в файл SoundEngine.cpp:
void SoundEngine::playFireballLaunch(
Vector2f playerPosition,
Vector2f soundLocation)
{
mFireballLaunchSound.setRelativeToListener(true);
if (playerPosition.x > soundLocation.x)
// Звук слева
{
Listener::setPosition(0, 0, 0.f);
mFireballLaunchSound.setPosition(-100, 0, 0.f);
mFireballLaunchSound.setMinDistance(100);
mFireballLaunchSound.setAttenuation(0);
}
else// Звук справа
{
Listener::setPosition(0, 0, 0.f);
mFireballLaunchSound.setPosition(100, 0, 0.f);
mFireballLaunchSound.setMinDistance(100);
mFireballLaunchSound.setAttenuation(0);
}
}
mFireballLaunchSound.play();
В приведенном выше коде мы вызываем setRelativeToListner и передаем true.
Это необходимо для работы пространственного звука. Далее идет конструкция
if-else. В условии if определяется, должен ли звук идти слева, и проверяется,
больше ли горизонтальная координата игрового персонажа, чем горизонтальная
координата огненного шара, вызвавшего функцию.
В обоих блоках if и else положение персонажа по горизонтали устанавливается
на 0, минимальное расстояние — на 100, а затухание — на 0. Разница между блоками заключается в том, что функция setPosition вызывается со значением горизонтального положения 100, если звук должен исходить слева, и -100, если справа.
После структуры if-else мы наконец запускаем функцию play на mFireballLaunch
Sound. Мы скоро вызовем рассмотренную только что функцию playFireballLaunch.
Теперь, когда мы добавили пространственный звук, можем написать пару классов
для представления огненных шаров в нашей игре, которые будут использовать
новый звук.
Огненные шары
Итак, воспользуемся нашей новой функцией, написав код для огненных шаров.
Чтобы приступить к созданию огненных шаров, нам понадобятся новые классы.
Создайте два новых класса, FireballUpdate и FireballGraphics, унаследованные
от Update и Graphics соответственно.
Огненные шары 499
Класс FireballUpdate
Для начала работы с классом FireballUpdate добавьте в FireballUpdate.h следующий код:
#pragma once
#include "Update.h"
#include <SFML/Graphics.hpp>
using namespace sf;
class FireballUpdate :
public Update
{
private:
FloatRect m_Position;
FloatRect* m_PlayerPosition;
bool* m_GameIsPaused = nullptr;
float m_Speed = 250;
float m_Range = 900;
int m_MaxSpawnDistanceFromPlayer = 250;
bool m_MovementPaused = true;
Clock m_PauseClock;
float m_PauseDurationTarget = 0;
float m_MaxPause = 6;
float m_MinPause = 1;
//float mTimePaused = 0;
bool m_LeftToRight = true;
public:
FireballUpdate(bool* pausedPointer);
bool* getFacingRightPointer();
FloatRect* getPositionPointer();
int getRandomNumber(int minHeight,
int maxHeight);
// Из Update : Component
};
void update(float fps) override;
void assemble(shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate)
override;
В блоке private заголовочного файла FireBallUpdate мы объявляем переменные
m_Position, m_PlayerPosition и m_GameIsPaused, которые служат для определения
положения огненного шара, указателя на позицию персонажа и указателя на логическое значение LevelUpdate, которое определяет, поставлена ли игра на паузу.
Переменные с плавающей точкой m_Speed и m_Range будут инициализированы
случайными значениями, чтобы определить, с какой скоростью будет двигаться
огненный шар и с какой позиции он начнет свой путь.
500 Глава 20. Огненные шары и пространственный звук
Целочисленная переменная m_MaxSpawnDistanceFromPlayer устанавливается
равной 250 в качестве верхнего предела расстояния, на котором огненный шар
может появиться от игрового персонажа.
Логическая переменная m_MovementPaused будет работать в сочетании с m_Game
IsPaused, чтобы останавливать и возобновлять движение огненного шара в соответствии с паузой, возобновлением, началом и завершением игры.
Экземпляр Clock, m_PauseClock, отсчитывает время до запуска огненного шара,
основываясь на случайном значении, присвоенном float m_PauseDurationTarget.
Это добавляет дополнительную случайность и вариативность между всеми экземплярами огненных шаров.
Переменные m_MaxPause и m_MinPause — это фиксированные значения, между
которыми будет генерироваться случайное время паузы в секундах.
Логическая переменная m_LeftToRight будет переключаться между true и false,
определяя, откуда летит огненный шар: слева от игрока или справа.
В разделе public у нас есть следующие переменные и функции:
zzв конструктор передается логический указатель, который позволяет отслеживать, когда игра поставлена на паузу;
zzфункция getFacingRightPointer возвращает указатель, чтобы отслеживать направление движения огненного шара. Данный указатель будет передан классу
FireballGraphics, чтобы он мог корректно отобразить пламя;
zzфункция getPositionPointer возвращает указатель на положение огненного
шара. Он будет передан классу FireballGraphics, чтобы корректно отобразить пламя;
zzфункция getRandomNumber принимает два целых значения и возвращает случайное число, находящееся в диапазоне между ними;
zzи, как обычно, у нас есть две переопределенные функции из класса Update:
update и assemble.
Мы разобьем FireballUpdate.cpp на несколько частей, потому что он довольно
длинный. Для первой части добавьте в FireballUpdate.cpp следующий код:
#include
#include
#include
#include
#include
"FireballUpdate.h"
<random>
"SoundEngine.h"
"FireballUpdate.h"
"PlayerUpdate.h"
FireballUpdate::FireballUpdate(bool* pausedPointer)
{
m_GameIsPaused = pausedPointer;
}
m_PauseDurationTarget = getRandomNumber(m_MinPause, m_MaxPause);
Огненные шары 501
bool* FireballUpdate::getFacingRightPointer()
{
return &m_LeftToRight;
}
FloatRect* FireballUpdate::getPositionPointer()
{
return &m_Position;
}
void FireballUpdate::assemble(
shared_ptr<LevelUpdate> levelUpdate,
shared_ptr<PlayerUpdate> playerUpdate)
{
m_PlayerPosition = playerUpdate->getPositionPointer();
m_Position.top = getRandomNumber(
m_PlayerPosition->top - m_MaxSpawnDistanceFromPlayer,
m_PlayerPosition->top + m_MaxSpawnDistanceFromPlayer);
}
m_Position.left = m_PlayerPosition->left - getRandomNumber(200, 400);
m_Position.width = 10;
m_Position.height = 10;
В приведенном выше коде переменная m_GameIsPaused синхронизируется с переменной, определяющей, поставлена ли игра на паузу, в классе LevelUpdate ,
а переменная m_PauseDurationTarget инициализируется случайным образом.
Поскольку каждый экземпляр инициализируется произвольно, огненные шары
будут появляться в разное время и не будут лететь на нашего героя игры сплошной смертоносной стеной.
В функции getFacingRightPointer возвращается адрес переменной m_LeftToRight.
Класс FireBallGraphics будет использовать эту функцию, чтобы отслеживать,
в какую сторону должна быть направлена текстура огненного шара.
В функции getPositionPointer возвращается адрес m_Position типа FloatRect.
Класс FireballGraphics будет использовать эту функцию, чтобы отслеживать
местоположение огненного шара и правильно размещать вершины массива VertexArray в игровом мире.
В функции assemble инициализируется адрес позиции игрового персонажа,
поскольку код огненного шара будет определять, когда тот попал в персонажа,
и соответствующим образом перемещать его. Верхняя и левая позиции огненного
шара инициализируются с помощью текущей позиции персонажа (для справедливости) и функции getRandomNumber (для разнообразия в пределах диапазона).
Ширина и высота огненного шара (10 × 10 единиц) инициализируются в последних двух строках кода функции assemble.
502 Глава 20. Огненные шары и пространственный звук
Для второй части реализации класса FireBallUpdate добавьте следующий код
в FireballUpdate.cpp:
int FireballUpdate::getRandomNumber(int minHeight, int maxHeight)
{
// Инициализация генератора случайных чисел текущим временем
std::random_device rd;
std::mt19937 gen(rd());
// Определение равномерного распределения для заданного диапазона
std::uniform_int_distribution<int>
distribution(minHeight, maxHeight);
}
// Генерация случайного числа в указанном диапазоне
int randomHeight = distribution(gen);
return randomHeight;
Здесь для функции getRandomNumber используется тот же код, который мы реа
лизовали для функции random класса LevelUpdate. Она возвращает случайное
число в диапазоне между двумя переданными значениями.
Наконец, для класса FireballUpdate мы напишем функцию update. Добавьте
следующий код в FireballUpdate.cpp:
void FireballUpdate::update(float fps)
{
if (!*m_GameIsPaused)
{
if (!m_MovementPaused)
{
if (m_LeftToRight)
{
m_Position.left += m_Speed * fps;
if (m_Position.left – m_PlayerPosition->left > m_Range)
{
m_MovementPaused = true;
m_PauseClock.restart();
m_LeftToRight = !m_LeftToRight;
m_Position.top = getRandomNumber(
m_PlayerPosition->top –
m_MaxSpawnDistanceFromPlayer,
m_PlayerPosition->top +
m_MaxSpawnDistanceFromPlayer);
}
}
else
m_PauseDurationTarget =
getRandomNumber(m_MinPause, m_MaxPause);
Огненные шары 503
{
m_Position.left -= m_Speed * fps;
if (m_PlayerPosition->left –
m_Position.left > m_Range)
{
m_MovementPaused = true;
m_PauseClock.restart();
m_LeftToRight = !m_LeftToRight;
m_Position.top = getRandomNumber(
m_PlayerPosition->top –
m_MaxSpawnDistanceFromPlayer,
m_PlayerPosition->top +
m_MaxSpawnDistanceFromPlayer);
}
}
m_PauseDurationTarget =
getRandomNumber(m_MinPause, m_MaxPause);
// Проверка, попал ли огненный шар в персонажа
if (m_PlayerPosition->intersects(m_Position))
{
// Сбиваем игрока с ног
m_PlayerPosition->top =
m_PlayerPosition->top +
m_PlayerPosition->height * 2;
}
}
else
{
}
}
}
if (m_PauseClock.getElapsedTime().asSeconds() >
m_PauseDurationTarget)
{
m_MovementPaused = false;
SoundEngine::playFireballLaunch(
m_PlayerPosition->getPosition(),
m_Position.getPosition());
}
Приведенный выше код длинный, поэтому разобьем его на пять частей и разберем их по отдельности.
В первой части мы видим следующие строки:
if (!*m_GameIsPaused)
{
if (!m_MovementPaused)
{
504 Глава 20. Огненные шары и пространственный звук
Здесь мы проверяем, что игра не поставлена на паузу, а таймер огненного шара
не приостановлен перед началом нового полета.
Следующий фрагмент:
if (m_LeftToRight)
{
m_Position.left += m_Speed * fps;
if (m_Position.left m_PlayerPosition->left > m_Range)
{
m_MovementPaused = true;
m_PauseClock.restart();
m_LeftToRight = !m_LeftToRight;
m_Position.top = getRandomNumber(
m_PlayerPosition->top m_MaxSpawnDistanceFromPlayer,
m_PlayerPosition->top +
m_MaxSpawnDistanceFromPlayer);
}
}
m_PauseDurationTarget =
getRandomNumber(m_MinPause, m_MaxPause);
В данном фрагменте код заключен в оператор if , который проверяет, движется ли огненный шар слева направо. Если он летит вправо, его положение
обновляется в соответствии со скоростью и временем, прошедшим с момента
последнего обновления. Следующий оператор if (внутри того, который отслеживал направление движения) проверяет, на каком расстоянии от персонажа
находится огненный шар. Если расстояние больше m_Range , то самое время
поставить огненный шар на паузу, перезапустить часы, изменить направление
движения и выбрать новую случайную высоту и новую случайную длительность паузы. Теперь огненный шар готов лететь в обратном направлении после
истечения m_PauseDuration.
В третьей части мы видим следующий код:
else
{
m_Position.left -= m_Speed * fps;
if (m_PlayerPosition->left m_Position.left > m_Range)
{
m_MovementPaused = true;
m_PauseClock.restart();
m_LeftToRight = !m_LeftToRight;
m_Position.top = getRandomNumber(
Огненные шары 505
m_PlayerPosition->top m_MaxSpawnDistanceFromPlayer,
m_PlayerPosition->top +
m_MaxSpawnDistanceFromPlayer);
}
}
m_PauseDurationTarget =
getRandomNumber(m_MinPause, m_MaxPause);
Здесь выполняется почти то же самое, что и в предыдущем выражении if, только
теперь огненный шар движется слева направо, а когда он окажется достаточно
далеко от игрового персонажа, он снова будет готов лететь справа налево.
Четвертая часть:
// Проверка, попал ли огненный шар в героя игры
if (m_PlayerPosition->intersects(m_Position))
{
// Сбиваем героя с ног
m_PlayerPosition->top =
m_PlayerPosition->top +
m_PlayerPosition->height * 2;
}
Здесь проверяется, попал ли огненный шар в персонажа. Если да, персонажа отбрасывает вниз на высоту, вдвое превышающую его рост.
И наконец, заключительная часть кода:
else
{
}
if (m_PauseClock.getElapsedTime().asSeconds() >
m_PauseDurationTarget)
{
m_MovementPaused = false;
SoundEngine::playFireballLaunch(
m_PlayerPosition->getPosition(),
m_Position.getPosition());
}
Блок else выполняется только в том случае, если предыдущий блок if не выполняется. Внутри блока else еще один оператор if проверяет, превышает ли прошедшее время m_PausedClock случайно сгенерированное значение
m_PauseDuration. Если да, то m_MovementPaused устанавливается в false, и огненный шар будет готов снова поразить героя игры. Функция playFireBallLaunch
передает классу SoundEngine необходимые параметры для воспроизведения пространственного звука летящего огненного шара.
506 Глава 20. Огненные шары и пространственный звук
Класс FireballGraphics
В этом разделе мы напишем код класса FireballGraphics. Чтобы понять следу
ющий за ним код, взгляните на рис. 20.1, на котором изображен текстурный атлас
для огненного шара.
Рис. 20.1. Три кадра огненного шара
Мы видим здесь три кадра анимации. Это идеально подходит для использования
с уже написанным классом Animator. Кроме того, как и в классе PlayerGraphics,
нам нужно будет зеркально отразить пиксели в текстурах, чтобы они были направлены в другую сторону, когда огненный шар летит справа налево. Технически
говоря, мы также должны «перевернуть» анимацию, но это не имеет большого
значения для огня. Однако для анимации персонажей это важно, поскольку
предотвращает эффект «лунной походки».
Файл FireballGraphics.h
В файле FireballGraphics.h добавьте следующий код:
#pragma once
#include "Graphics.h"
class Animator;
class PlayerUpdate;
class FireballGraphics :
public Graphics
{
private:
FloatRect* m_Position;
int m_VertexStartIndex;
bool* m_FacingRight = nullptr;
Animator* m_Animator;
IntRect* m_SectionToDraw;
std::shared_ptr<PlayerUpdate> m_PlayerUpdate;
public:
// Из Graphics : Component
void draw(VertexArray& canvas) override;
void assemble(VertexArray& canvas,
shared_ptr<Update> genericUpdate,
IntRect texCoords) override;
};
Огненные шары 507
Для начала мы добавили необходимые директивы include и прямые объявления
для классов Animator и PlayerUpdate, чтобы ссылаться на них в этом файле. Далее
идут все объявления private.
Указатель типа FloatRect с именем m_Position хранит позицию. Целочисленная
переменная m_VertexStartIndex, как и во всех наших классах, унаследованных
от Graphics, будет содержать позицию первой вершины в массиве VertexArray.
Указатель на логическую переменную m_FacingRight будет хранить адрес из
класса FireballUpdate, определяющий направление движения огненного шара.
Экземпляр Animator будет обрабатывать цикл из трех кадров анимации, связанных с огненным шаром, а IntRect.m_SectionToDraw — хранить текстурные
координаты текущего кадра анимации.
Общий указатель shared_ptr<PlayerUpdate> с именем m_PlayerUpdate позволит классу FireballGraphics вызывать все публичные функции класса
FireballUpdate.
Далее идут все публичные объявления, но нам нужны только две переопределенные функции — assemble и draw. Я не буду тратить время на повторное рассмотрение параметров, гораздо интереснее разобраться, что происходит внутри
этих функций.
Создание файла FireballGraphics.cpp
Мы напишем FireballGraphics.cpp в несколько этапов. Сначала добавим код
для директив include и функции assemble:
#include "FireballGraphics.h"
#include "Animator.h"
#include "FireballUpdate.h"
void FireballGraphics::assemble(
VertexArray& canvas,
{
shared_ptr<Update> genericUpdate,
IntRect texCoords)
shared_ptr<FireballUpdate> fu =
static_pointer_cast
<FireballUpdate>(genericUpdate);
m_Position = fu->getPositionPointer();
m_FacingRight = fu->getFacingRightPointer();
m_Animator = new Animator(
texCoords.left,
texCoords.top,
3, // 6 кадров
texCoords.width * 3,
texCoords.height,
6); // кадры в секунду
508 Глава 20. Огненные шары и пространственный звук
// Получаем первый кадр анимации
m_SectionToDraw =
m_Animator->getCurrentFrame(false);
m_VertexStartIndex = canvas.getVertexCount();
canvas.resize(canvas.getVertexCount() + 4);
const int uPos = texCoords.left;
const int vPos = texCoords.top;
const int texWidth = texCoords.width;
const int texHeight = texCoords.height;
}
canvas[m_VertexStartIndex].texCoords.x =
uPos;
canvas[m_VertexStartIndex].texCoords.y =
vPos;
canvas[m_VertexStartIndex + 1].texCoords.x
uPos + texWidth;
canvas[m_VertexStartIndex + 1].texCoords.y
vPos;
canvas[m_VertexStartIndex + 2].texCoords.x
uPos + texWidth;
canvas[m_VertexStartIndex + 2].texCoords.y
vPos + texHeight;
canvas[m_VertexStartIndex + 3].texCoords.x
uPos;
canvas[m_VertexStartIndex + 3].texCoords.y
vPos + texHeight;
=
=
=
=
=
=
В данном коде мы начинаем с приведения экземпляра Update к экземпляру
FireballUpdate и вызова функций getPositionPointer и getFacingRightPointer,
чтобы отслеживать позицию огненного шара и направление его движения.
Далее мы инициализируем экземпляр Animator и текстурные координаты начального кадра вызовом getCurrentFrame. Остальная часть кода аналогична тому, что
мы видели в других классах, унаследованных от Graphics. Мы сохраняем начальный индекс четырехугольника, расширяем массив VertexArray, чтобы вместить
четырехугольник, и инициализируем начальные текстурные координаты всех
вершин в массиве VertexArray.
Наконец, добавим функцию draw:
void FireballGraphics::draw(VertexArray& canvas)
{
const Vector2f& position =
m_Position->getPosition();
const Vector2f& scale =
m_Position->getSize();
canvas[m_VertexStartIndex].position =
position;
canvas[m_VertexStartIndex + 1].position =
position + Vector2f(scale.x, 0);
Огненные шары 509
canvas[m_VertexStartIndex + 2].position =
position + scale;
canvas[m_VertexStartIndex + 3].position =
position + Vector2f(0, scale.y);
if (*m_FacingRight)
{
m_SectionToDraw =
m_Animator->getCurrentFrame(false);
const int uPos = m_SectionToDraw->left;
const int vPos = m_SectionToDraw->top;
const int texWidth = m_SectionToDraw->width;
const int texHeight = m_SectionToDraw->height;
canvas[m_VertexStartIndex].texCoords.x =
uPos;
canvas[m_VertexStartIndex].texCoords.y =
vPos;
canvas[m_VertexStartIndex + 1].texCoords.x
uPos + texWidth;
canvas[m_VertexStartIndex + 1].texCoords.y
vPos;
canvas[m_VertexStartIndex + 2].texCoords.x
uPos + texWidth;
canvas[m_VertexStartIndex + 2].texCoords.y
vPos + texHeight;
canvas[m_VertexStartIndex + 3].texCoords.x
uPos;
canvas[m_VertexStartIndex + 3].texCoords.y
vPos + texHeight;
=
=
=
=
=
=
}
else
{
// Для огня порядок кадров не имеет большого значения,
// но лицевая часть должна быть спереди!
m_SectionToDraw = m_Animator->getCurrentFrame(true);
// Реверс
const int uPos = m_SectionToDraw->left;
const int vPos = m_SectionToDraw->top;
const int texWidth = m_SectionToDraw->width;
const int texHeight = m_SectionToDraw->height;
canvas[m_VertexStartIndex].texCoords.x =
uPos;
canvas[m_VertexStartIndex].texCoords.y =
vPos;
canvas[m_VertexStartIndex + 1].texCoords.x
uPos - texWidth;
canvas[m_VertexStartIndex + 1].texCoords.y
vPos;
canvas[m_VertexStartIndex + 2].texCoords.x
uPos - texWidth;
canvas[m_VertexStartIndex + 2].texCoords.y
vPos + texHeight;
=
=
=
=
510 Глава 20. Огненные шары и пространственный звук
}
}
canvas[m_VertexStartIndex + 3].texCoords.x =
uPos;
canvas[m_VertexStartIndex + 3].texCoords.y =
vPos + texHeight;
Мы можем разбить приведенный код на три простых блока: начальный, блок if
и блок else.
В начальном блоке обновляются позиции вершин. Они будут перемещаться
в большинстве кадров игры, за исключением случаев, когда игра приостановлена
или когда огненный шар ждет случайное время перед новым полетом.
В блоке if проверяется, направлен ли огненный шар вправо. Если да, то текстурные координаты присваиваются соответствующим вершинам. Если выполняется
блок else, то текстурные координаты присваиваются вершинам и зеркально отображаются по горизонтали, чтобы огненный шар был обращен своей передней
частью влево.
Далее мы будем использовать два наших новых класса.
Создание огненных шаров в фабрике
В этом разделе мы добавим код в класс Factory, чтобы инстанцировать несколько
огненных шаров в игре. Пропишите две новые директивы include в Factory.cpp:
#include "FireballGraphics.h"
#include "FireballUpdate.h"
Добавьте в фабрику дополнительный код после платформ, но до кода, связанного
с дождем, как показано ниже:
// Огненные шары
for (int i = 0; i < 12; i++)
{
GameObject fireball;
shared_ptr<FireballUpdate> fireballUpdate =
make_shared<FireballUpdate>(
levelUpdate->getIsPausedPointer());
fireballUpdate->assemble(levelUpdate, playerUpdate);
fireball.addComponent(fireballUpdate);
shared_ptr<FireballGraphics> fireballGraphics =
make_shared<FireballGraphics>();
fireballGraphics->assemble(canvas,
fireballUpdate,
IntRect(870, 0, 32, 32));
Запуск кода 511
fireball.addComponent(fireballGraphics);
gameObjects.push_back(fireball);
}
// конец кода огненных шаров
В приведенном выше коде цикл for выполняется 12 раз. При каждом прохождении цикла создается огненный шар, а в вектор gameObjects добавляется GameObject.
Для создания каждого экземпляра используется обычная процедура.
1. Создается экземпляр GameObject.
2. Создается производный от Update общий указатель и добавляются все необходимые параметры конструктора в вызов new.
3. Вызывается функция assemble.
4. Добавляется экземпляр, унаследованный от Update, к объекту GameObject
с помощью функции addComponent.
5. Повторяется для общего указателя, унаследованного от Graphics.
Теперь мы готовы увидеть наши огненные шары в действии!
Запуск кода
Наши огненные шары готовы! Запускайте игру и восхищайтесь огненными шарами, как на рис. 20.2.
Рис. 20.2. Огненные шары
512 Глава 20. Огненные шары и пространственный звук
Вы также можете услышать пространственный звук. Он подскажет вам, откуда
летит огненный шар, а мини-карта заранее предупредит вас об опасности.
Резюме
В этой главе вы узнали, что такое пространственный звук и как добавить его
в игру с помощью SFML. Затем мы усовершенствовали класс SoundEngine для
создания пространственных звуков и, наконец, написали классы, связанные с огненными шарами (унаследованные Graphics и Update). В следующей главе мы
добавим эффект под названием параллакс и несколько потрясающих шейдерных
эффектов.
21
Параллакс и шейдеры
К концу данной главы мы завершим работу над игрой, и в нее можно будет полноценно играть.
Готовый код для этой главы находится в папке Run7. Начнем с изучения OpenGL,
шейдеров и GLSL.
Знакомство с OpenGL, шейдерами и GLSL
Open Graphics Library (OpenGL) — это библиотека программирования, которая
позволяет работать с 2D- и 3D-графикой. OpenGL поддерживается всеми основными операционными системами для настольных ПК. Существует также версия
для мобильных устройств, известная как OpenGL ES.
OpenGL была выпущена в 1992 году. Она дорабатывалась и улучшалась более
чем двадцать лет. Более того, производители видеокарт разрабатывают свое
оборудование так, чтобы оно было совместимо с OpenGL. Я упомянул об этом
не для того, чтобы преподать вам урок истории, а чтобы объяснить, что пытаться
улучшить OpenGL и использовать ее в 2D (и 3D) играх на рабочем столе — бессмысленное занятие, особенно если мы хотим, чтобы наша игра работала не только
на Windows, что является очевидным выбором. Мы уже используем OpenGL,
потому что SFML задействует OpenGL.
Шейдеры — это программы, которые выполняются непосредственно на графическом процессоре. Мы узнаем о них больше в следующем разделе.
Программируемый конвейер и шейдеры
Благодаря OpenGL мы получаем доступ к так называемому программируемому
конвейеру. Он позволяет нам отправлять наши шейдерные программы на отрисовку в каждом кадре с помощью функции draw экземпляра RenderWindow. Мы также
можем написать код, который выполняется на графическом процессоре и может
управлять каждым пикселем независимо, после вызова функции draw. Это очень
мощная функциональность.
514 Глава 21. Параллакс и шейдеры
Этот дополнительный код, выполняемый на графическом процессоре, называется
программой-шейдером. Мы можем написать код для изменения геометрии (положения) нашей графики в вершинном шейдере. Кроме того, можно написать код
для управления внешним видом каждого пикселя по отдельности. Это называется
фрагментным шейдером. Существуют и другие виды шейдеров, такие как вычислительные и геометрические, но мы не будем рассматривать их в рамках книги.
Рассмотрим относительно простой код шейдеров с использованием языка шейдеров GL Shader Language (GLSL). В нашем проекте Run мы применим сторонний довольно сложный код шейдера на GLSL, чтобы получить впечатляющие эффекты.
В OpenGL все является точкой, линией или треугольником. Мы можем добавлять
цвета и текстуры к этой базовой геометрии и комбинировать данные элементы
для создания сложной графики, которую видим в современных играх. Все эти
элементы известны как примитивы. Мы имеем доступ к примитивам OpenGL
через примитивы SFML и VertexArray, а также классы Sprite и Shape.
Помимо примитивов, в OpenGL используются матрицы. Матрицы — это структура для выполнения арифметических операций, которые могут варьироваться от
очень простых вычислений школьного уровня, таких как перемещение (перевод)
координат, до довольно сложных вроде преобразования координат игрового мира
в координаты экрана OpenGL для графического процессора. К счастью, именно
эту работу SFML выполняет за нас. SFML также позволяет нам взаимодействовать с OpenGL напрямую.
СОВЕТ
Если вы хотите узнать больше об OpenGL, посетите сайт http://learnopengl.com/
#!Introduction. Если вы хотите использовать OpenGL напрямую вместе с SFML, прочтите эту статью: https://www.sfml-dev.org/tutorials/2.5/window-opengl.php.
Мы можем прикреплять разные шейдеры к разным игровым объектам, чтобы создавать желаемые эффекты. В этой игре у нас будет только один вершинный шейдер,
и мы применим его к фону с помощью отдельного вызова draw. В SFML вы прикрепляете шейдер к вызову draw, и он влияет на все, что находится в этом вызове.
Однако, когда вы увидите, как прикрепить шейдер к вызову draw, станет ясно,
что получить больше шейдеров — это просто.
Мы выполним следующие действия.
1. Нам понадобится код шейдера, который будет выполняться на GPU. Мы получим его в разделе «Программирование шейдера».
2. Затем нам нужно скомпилировать этот код с помощью кода SFML на C++.
Visual Studio не компилирует шейдеры за нас.
Знакомство с OpenGL, шейдерами и GLSL 515
3. Наконец, нам потребуется прикрепить шейдер к соответствующему вызову
функции draw в функции draw нашей игры.
GLSL — это язык. У него тоже есть свои типы и переменные этих типов, которые можно объявлять и использовать. Более того, мы можем взаимодействовать
с переменными шейдерной программы из нашего кода на C++.
Как мы увидим, GLSL имеет некоторые синтаксические сходства с C++.
Создание гипотетического фрагментного шейдера
В данном разделе мы рассмотрим простой гипотетический код. Не нужно добавлять его в наш проект. Итак, вот код несложного шейдера под названием
fragShader.frag:
// атрибуты из vertShader.vert
varying vec4 vColor;
varying vec2 vTexCoord;
// переменные типа uniform
uniform sampler2D uTexture;
uniform float uTime;
void main() {
float coef = sin(gl_FragCoord.y * 0.1 + 1 * uTime);
vTexCoord.y += coef * 0.03;
gl_FragColor = vColor * texture2D(uTexture, vTexCoord);
}
Первые четыре строки (не считая комментариев) — это переменные, которые
будет использовать фрагментный шейдер, но это не обычные переменные. Первый тип, который мы можем видеть, — это переменные типа varying. Данный
тип предназначен для переменных, которые находятся в области видимости
обоих шейдеров. Далее идут переменные uniform. Ими можно управлять прямо
из нашего кода на C++. Скоро мы увидим, как это делается, но на примере более
сложного шейдера.
Помимо типов varying и uniform у каждой переменной есть более традиционный
тип, определяющий фактический тип данных:
zzvec4 — вектор с четырьмя значениями;
zzvec2 — вектор с двумя значениями;
zzsampler2d будет хранить текстуру;
zzfloat — то же, что и float в C++.
Выполняется код внутри функции main. Если мы внимательно посмотрим на код
в main, то увидим каждую из используемых переменных. Объяснение принципа
работы данного кода выходит за рамки книги. Однако вкратце можно сказать, что
текстурные координаты (vTexCoord) и цвет пикселей/фрагментов (glFragColor)
516 Глава 21. Параллакс и шейдеры
обрабатываются с помощью нескольких математических функций и операций.
Помните, что все это выполняется для каждого пикселя, задействованного
в функции draw, которая вызывается в каждом кадре нашей игры. Кроме того,
учтите, что uTime передается с разным значением для каждого кадра. В результате
на изображениях будет эффект ряби.
Создание гипотетического вершинного шейдера
В данном разделе мы рассмотрим простой гипотетический код для вершинного
шейдера. Не нужно добавлять его в наш проект. Вот код из гипотетического
файла vertShader.vert:
// Переменные varying, которые будут использоваться во фрагментном шейдере
varying vec4 vColor;
varying vec2 vTexCoord;
void main() {
vColor = gl_Color;
vTexCoord = (gl_TextureMatrix[0] * gl_MultiTexCoord0).xy;
gl_Position = gl_ModelViewProjectionMatrix * gl_Vertex;
}
Прежде всего обратите внимание на две переменные типа varying, имеющие
префикс v. Это те самые переменные, которыми мы манипулировали во фрагментном шейдере. В функции main код управляет положением каждой вершины.
Объяснение принципа работы данного кода выходит за рамки книги, но поверьте,
внутри происходят довольно сложные вычисления.
В следующем разделе вы узнаете, как подготовить и загрузить реальную программу-шейдер, а также передать значения каждому кадру. Мы воспользуемся
программой-шейдером, которая намного превосходит рассмотренные нами гипотетические шейдеры.
Завершение работы над классом
CameraGraphics
В этом разделе мы рассмотрим, изменим и дополним наш класс CameraGraphics.
Сначала добавим в файл CameraGraphics.h некоторые переменные, связанные
с фоном и шейдерами. В конце блока private файла CameraGraphics.h добавьте
следующие переменные:
// Для шейдеров и параллакс-фона
Shader m_Shader;
bool m_ShowShader = false;
bool m_BackgroundsAreFlipped = false;
Clock m_ShaderClock;
Завершение работы над классом CameraGraphics 517
Vector2f m_PlayersPreviousPosition;
Texture m_BackgroundTexture;
Sprite m_BackgroundSprite;
Sprite m_BackgroundSprite2;
В приведенном выше коде есть SFML-шейдер с именем m_Shader и логическая переменная m_ShowShader, которую мы можем использовать, чтобы отслеживать, когда
показывать шейдер, а когда нет. Для этой игры мы будем переключаться между
десятью секундами показа шейдера и десятью секундами показа параллакс-фона.
Логическая переменная m_BackgroundsAreFlipped будет использоваться для
определения того, отображена ли зеркально по горизонтали текстура, представляющая фон. Это нужно для плавного соединения одного изображения фона
с несколькими его экземплярами, чтобы создать эффект плавной прокрутки.
Переменная m_ShaderClock типа Clock интересна тем, что она будет входным
значением для одной из varying-переменных шейдера.
Vector2f m_PlayersPreviousPosition позволит нам узнать, где находился игровой
персонаж до последнего обновления. Мы увидим пользу от этого, когда добавим
больше кода в файл CameraGraphics.cpp.
Экземпляр Texture с именем m_BackgroundTexture — это отдельная текстура для
изображения фона. Она полностью отделена от текстурного атласа, в котором
хранится все остальное.
Спрайт с именем m_BackgroundSprite предназначен для изображения фона,
а m_BackgroundSprite2 — для отображения зеркально перевернутой копии фона.
Теперь в конструкторе CameraGraphics в CameraGraphics.cpp, прямо перед закрывающей фигурной скобкой, добавьте следующий код:
// Инициализация спрайтов фона
m_BackgroundTexture.loadFromFile("graphics/backgroundTexture.png");
m_BackgroundSprite.setTexture(m_BackgroundTexture);
m_BackgroundSprite2.setTexture(m_BackgroundTexture);
m_BackgroundSprite.setPosition(0, -200);
// Инициализация шейдера
m_Shader.loadFromFile(
"shaders/glslsandbox109644", sf::Shader::Fragment);
if (!m_Shader.isAvailable())
{
std::cout << "The shader is not available\n";
}
m_Shader.setUniform(
"resolution", sf::Vector2f(2500, 2500));
m_ShaderClock.restart();
Данный код загружает текстуру фона и прикрепляет ее к обоим новым спрайтам.
Первый спрайт фона позиционируется так, чтобы заполнить экран за персонажем.
518 Глава 21. Параллакс и шейдеры
Далее мы загружаем шейдер с помощью функции loadFromFile, проверяем, что
шейдер доступен, вызывая функцию isAvailable, и устанавливаем значение
uniform переменной в коде шейдера с помощью функции setUniform. Значение
resolution соответствует uniform переменной, объявленной в самом коде шейдера: ей присваивается Vector2f, который используется в коде шейдера.
Наконец, мы вызываем restart для переменной m_ShaderClock, чтобы запустить ее.
Теперь, в функции draw, сразу после вызова функции m_Window->setView(m_View);
и непосредственно перед if (!m_IsMiniMap), добавьте код, который выполняет
отрисовку:
m_Window->setView(m_View);
// Фон
Vector2f movement;
movement.x = m_Position->left m_PlayersPreviousPosition.x;
movement.y = m_Position->top m_PlayersPreviousPosition.y;
if (m_BackgroundsAreFlipped)
{
m_BackgroundSprite2.setPosition(
m_BackgroundSprite2.getPosition().x
+ movement.x / 6,
m_BackgroundSprite2.getPosition().y
+ movement.y / 6);
m_BackgroundSprite.setPosition(
m_BackgroundSprite2.getPosition().x
+ m_BackgroundSprite2.getTextureRect().getSize().x,
m_BackgroundSprite2.getPosition().y);
if (m_Position->left >
m_BackgroundSprite.getPosition().x +
(m_BackgroundSprite.getTextureRect().getSize().x / 2))
{
m_BackgroundsAreFlipped = !m_BackgroundsAreFlipped;
m_BackgroundSprite2.setPosition(
m_BackgroundSprite.getPosition());
}
}
else
{
//cout << mBackgroundsAreFlipped << endl;
m_BackgroundSprite.setPosition(
m_BackgroundSprite.getPosition().x - movement.x /
6, m_BackgroundSprite.getPosition().y + movement.y / 6);
m_BackgroundSprite2.setPosition(
m_BackgroundSprite.getPosition().x +
m_BackgroundSprite.getTextureRect().getSize().x,
m_BackgroundSprite.getPosition().y);
Завершение работы над классом CameraGraphics 519
}
if (m_Position->left >
m_BackgroundSprite2.getPosition().x +
(m_BackgroundSprite2.getTextureRect().getSize().x / 2))
{
m_BackgroundsAreFlipped = !m_BackgroundsAreFlipped;
m_BackgroundSprite.setPosition(
m_BackgroundSprite2.getPosition());
}
m_PlayersPreviousPosition.x = m_Position->left;
m_PlayersPreviousPosition.y = m_Position->top;
// Устанавливаем параметры,
// которые нужно обновлять каждый кадр
m_Shader.setUniform("time",
m_ShaderClock.getElapsedTime().asSeconds());
sf::Vector2i mousePos =
m_Window->mapCoordsToPixel(m_Position->getPosition());
m_Shader.setUniform("mouse",
sf::Vector2f(mousePos.x, mousePos.y + 1000));
if (m_ShaderClock.getElapsedTime().asSeconds() > 10)
{
m_ShaderClock.restart();
m_ShowShader = !m_ShowShader;
}
if (!m_ShowShader)
{
m_Window->draw(m_BackgroundSprite, &m_Shader);
m_Window->draw(m_BackgroundSprite2, &m_Shader);
}
else // Показываем параллакс-фон
{
m_Window->draw(m_BackgroundSprite);
m_Window->draw(m_BackgroundSprite2);
}
// Отображаем интерфейс времени, но только в основной камере
if (!m_IsMiniMap)
Теперь давайте разберем этот большой блок кода.
Разбор кода в функции draw
Первое, что мы видим, — это объявление Vector2f с именем movement и установку
значений x и y с помощью последней позиции персонажа в предыдущем кадре:
/// Фон
Vector2f movement;
movement.x = m_Position->left -
520 Глава 21. Параллакс и шейдеры
m_PlayersPreviousPosition.x;
movement.y = m_Position->top m_PlayersPreviousPosition.y;
Затем идет следующий код:
if (m_BackgroundsAreFlipped)
{
m_BackgroundSprite2.setPosition(
m_BackgroundSprite2.getPosition().x
+ movement.x / 6,
m_BackgroundSprite2.getPosition().y
+ movement.y / 6);
m_BackgroundSprite.setPosition(
m_BackgroundSprite2.getPosition().x
+ m_BackgroundSprite2.getTextureRect().getSize().x,
m_BackgroundSprite2.getPosition().y);
}
if (m_Position->left >
m_BackgroundSprite.getPosition().x +
(m_BackgroundSprite.getTextureRect().getSize().x / 2))
{
m_BackgroundsAreFlipped = !m_BackgroundsAreFlipped;
m_BackgroundSprite2.setPosition(
m_BackgroundSprite.getPosition());
}
Данный код выполняется, когда m_BackgroundsAreFlipped равна true. Мы увидим,
что следующий блок выполняется, когда m_BackgroundsAreFlipped равна false.
В блоке выше m_BackgroundSprite2 располагается перед m_BackgroundSprite.
Здесь позиция m_BackgroundSprite2 устанавливается на основе изменения позиции персонажа с последнего кадра, но делится на 6. Число 6 — это «магическое»
число. Если увеличить его, фон будет прокручиваться медленнее, а если уменьшить — быстрее. Спрайт m_BackgroundSprite имеет свою позицию относительно
правого края m_BackgroundSprite2. Наконец, в данном коде есть оператор if,
который выполняется, если положение камеры выходит за левый край спрайта
m_BackgroundSprite, прибавленный к половине размера текстуры.
Это означает, что камера фокусируется на центре m_BackgroundSprite, а m_Back
groundSprite2 вообще не попадает в кадр. Это идеальное время для переключения порядка отрисовки фонов. Затем, по мере продвижения камеры вправо,
будет создаваться впечатление, что город бесконечен. Внутри оператора if
логическая переменная m_BackgroundsAreFlipped переключается, как и позиции
фонов.
Завершение работы над классом CameraGraphics 521
Далее идет часть кода, которую мы только что обсуждали:
else
{
//cout << mBackgroundsAreFlipped << endl;
m_BackgroundSprite.setPosition(
m_BackgroundSprite.getPosition().x - movement.x / 6,
m_BackgroundSprite.getPosition().y + movement.y / 6);
m_BackgroundSprite2.setPosition(
m_BackgroundSprite.getPosition().x +
m_BackgroundSprite.getTextureRect().getSize().x,
m_BackgroundSprite.getPosition().y);
}
if (m_Position->left >
m_BackgroundSprite2.getPosition().x +
(m_BackgroundSprite2.getTextureRect().getSize().x / 2))
{
m_BackgroundsAreFlipped = !m_BackgroundsAreFlipped;
m_BackgroundSprite.setPosition(
m_BackgroundSprite2.getPosition());
}
Данный код завершает логику переключения фона, отрисовывая первый фон
перед вторым, пока камера не сфокусируется на втором фоне и он снова не переключится.
Далее у нас есть такой код:
m_PlayersPreviousPosition.x = m_Position->left;
m_PlayersPreviousPosition.y = m_Position->top;
// Устанавливаем параметры,
// которые нужно обновлять каждый кадр
m_Shader.setUniform ("time", m_ShaderClock.getElapsedTime().asSeconds());
sf::Vector2i mousePos =
m_Window->mapCoordsToPixel(m_Position->getPosition());
m_Shader.setUniform("mouse",
sf::Vector2f(mousePos.x, mousePos.y + 1000));
Здесь мы сохраняем положение игрового персонажа в m_PlayersPreviousPosition.
Помните, что таким образом мы определяем движение фона в начале функции
draw. Потом вызываем функцию setUniform на нашем экземпляре Shader и передаем имя переменной типа uniform, которую нужно изменить, и текущее время
в секундах от нашего экземпляра Clock. Затем мы получаем пиксельные координаты мыши и передаем их для установки в шейдере.
522 Глава 21. Параллакс и шейдеры
Далее следует этот код:
if (m_ShaderClock.getElapsedTime().asSeconds() > 10)
{
m_ShaderClock.restart();
m_ShowShader = !m_ShowShader;
}
В данном коде мы проверяем, прошло ли десять секунд с момента предыдущего
сброса часов, и если да, то обнуляем их и меняем значение m_ShowShader, чтобы
чередовать показ шейдера и параллакс-фона.
И наконец, заключительный блок кода в игре:
if (!m_ShowShader)
{
m_Window->draw(m_BackgroundSprite, &m_Shader);
m_Window->draw(m_BackgroundSprite2, &m_Shader);
}
else // Показываем параллакс-фон
{
m_Window->draw(m_BackgroundSprite);
m_Window->draw(m_BackgroundSprite2);
}
В этом коде, если мы не показываем шейдер, мы будем отрисовывать фон с шейдером и без него.
Создание шейдера для игры
Единственное, что нам осталось сделать, — это наполнить файл, который пытается
загрузить код шейдера, поскольку он пуст. Код находится в открытом доступе,
но я не являюсь его автором и, скорее всего, не имею права его распространять.
Зайдите на сайт https://glslsandbox.com/e#109644.0 и нажмите кнопку show code (показать код). Скопируйте и вставьте весь код (около 400 строк) в файл shaders/
glsldandbox109644 и сохраните его. Не забудьте поблагодарить талантливого
программиста шейдеров, опубликовавшего код. Обсуждение кода шейдера выходит за рамки книги.
В следующем разделе мы увидим шейдер во всей его красе.
Запуск готовой игры
Запустите игру и насладитесь новым фоном и шейдером с эффектом огненного
пейзажа, который сменяется каждые десять секунд (рис. 21.1).
Вы, наверное, согласитесь, что возможности шейдеров и шейдерных программ
весьма впечатляют.
Создание шейдера для игры 523
Рис. 21.1. Шейдер
Поиграйте десять секунд, и изображение переключится на наш прокручива
ющийся фон, как показано на рис. 21.2.
Вот и все. Мы закончили работу над нашей игрой.
Рис. 21.2. Фон
524 Глава 21. Параллакс и шейдеры
Резюме
Когда вы впервые открыли эту объемную книгу, наверняка казалось, что до
последней страницы очень далеко. Но, надеюсь, путь к ней был не слишком
сложным.
Важно то, что вы дошли до этого момента и теперь у вас, надеюсь, есть представление о том, как создавать игры на C++.
В этом разделе я хочу не только поздравить вас с новым достижением, но и указать на то, что эта страница не должна стать финальной точкой. Если вы, как
и я, получаете удовольствие при создании каждой новой функции игры, значит,
впереди вас ждет еще много увлекательных открытий.
Дальнейшее чтение
Возможно, вы удивитесь, узнав, что даже после прочтения сотен страниц книги
мы лишь слегка коснулись C++. Многие темы, которые мы рассмотрели, можно
было бы осветить более глубоко, а есть и такие, о которых мы даже не упомянули.
Учитывая это, давайте обсудим, какие у вас могут быть дальнейшие шаги.
Если вам нужно формальное образование, то единственный способ — окончить
профильный вуз или хотя бы курсы. Это, конечно, требует времени и финансовых
вложений, и я ничем не могу помочь.
Однако если вы хотите учиться на практике, например начав разработку собственной игры, то далее мы обсудим возможные пути развития.
Одним из самых сложных решений в каждом проекте является структурирование
кода. На мой взгляд, лучший источник информации по организации игрового
кода на C++ — http://gameprogrammingpatterns.com/. Некоторые из тем касаются
концепций, которые не рассматриваются в книге, но если вы понимаете, что такое
классы, инкапсуляция, чистые виртуальные функции и синглтоны, то сможете
их освоить.
Шейдеры играют огромную роль в разработке игр, и в этой вводной книге мы
лишь немного их затронули. Если вы хотите углубиться в эту тему, я рекомендую
прочитать книгу Anton’s OpenGL 4 Tutorials Антона Герделана. Важно помнить,
что подход SFML к шейдерам отличается от работы с ними в OpenGL. Было бы
полезно немного изучить OpenGL (см. ниже) или, если вы придерживаетесь
SFML (вполне жизнеспособная стратегия), разобраться, как SFML абстрагирует
шейдеры (https://www.sfml-dev.org/tutorials/2.6/graphics-shader.php).
Что касается OpenGL, то существует множество учебных материалов. Если
вам нравятся видеокурсы, обратите внимание на Computer Graphics with Modern
OpenGL and C++ на Udemy. Если же вам удобнее текстовый формат, попробуйте
Дальнейшее чтение 525
OpenGL Programming Guide или Learn OpenGL: Learn modern OpenGL graphics
programming in a step-by-step fashion.
Если вы хотите выйти за пределы SFML и заняться разработкой более современных игр, например 3D, стоит обратить внимание на движок Unreal Engine,
который также использует C++. Для 2D-игр (возможно, с элементами 3D) попробуйте движок Godot.
В книге я уже упоминал официальный веб-сайт SFML. Если вы еще не посетили
его, обязательно сделайте это (http://www.sfml-dev.org/).
Если вы столкнулись с концепциями C++, которые вам непонятны, рекомендую
один из лучших структурированных источников по C++: http://www.cplusplus.com/
doc/tutorial/. Еще один полезный инструмент — ChatGPT. Он отлично подходит
для вопросов вроде «Объясни этот код» или «Как сделать этот код лучше и быстрее?».
Если вы хотите добавить в свою игру реалистичную 2D-физику, знайте, что
SFML отлично работает с физическим движком Box2d (http://box2d.org/). Вот один
из лучших ресурсов по его использованию с C++: http://www.iforce2d.net/.
Если вам кажется, что вы опоздали на «вечеринку» программирования игр на C++,
не волнуйтесь. Я думал, что опоздал 25 лет назад, но сегодня на C++ создается
больше игр, чем когда-либо прежде. Если вы хотите быть на передовой технологий, подумайте об изучении Web3 и блокчейна. Блокчейн позволяет создавать
Web3-игры — новый тип игр, в которых игроки действительно владеют своими
внутриигровыми активами и могут обмениваться ими. Это открывает возможности для создания более открытой и привлекательной игровой экосистемы.
Представьте, что кто-то играет в игру Pokemon и выигрывает редкую или даже
уникальную цифровую карточку Pokemon в «цифровом кошельке». Затем он
может обменять ее в Интернете или в офлайне. В этом и заключается перспектива
игр на блокчейне. К сожалению, многие существующие проекты в этой области
пока не оправдали ожиданий, а некоторые и вовсе оказались финансовыми аферами. Но тот, кто сделает Web3-игру правильно, может изменить всю индустрию.
Я хочу сказать, что вы не опоздали с программированием игр на C++. Все только
начинается.
Самое главное — большое спасибо за то, что выбрали эту книгу! Продолжайте
создавать игры!
Тайнан Сильвестр
ГЕЙМДИЗАЙН
Рецепты успеха лучших
компьютерных игр
от Super Mario и Doom
до Assassin's Creed и дальше
Что такое ГЕЙМДИЗАЙН? Это не код, графика или звук. Это не создание персонажей или раскрашивание игрового поля. Геймдизайн — это симулятор
мечты, набор правил, благодаря которым игра оживает.
Как создать игру, которую полюбят, от которой не смогут оторваться? Знаменитый геймдизайнер Тайнан Сильвестр на примере кейсов из самых
популярных игр рассказывает, как объединить эмоции и впечатления,
игровую механику и мотивацию игроков. Познакомьтесь с принципами
дизайна, которыми пользуются ведущие студии мира!
Тайнан Сильвестр занимается геймдизайном больше 15 лет. За это время
он успел поработать как над инди-проектами, так и над студийным блокбастером BioShock Infinite, но больше всего он известен благодаря RimWorld.
Николас Алехандро Борромео,
Хуан Габриэль Гомила Салас
РАЗРАБОТКА ИГР НА UNITY
4-е издание
Поднимите свои навыки разработки игр на следующий уровень. Исчерпывающее практическое руководство поможет раскрыть весь потенциал Unity. Каждая глава написана так, чтобы вы могли разработать собственную игру, а не
просто скопировать код из книги. Издание включает описание захватывающих
возможностей дополненной реальности и оптимизации производительности
с помощью стека технологий, ориентированных на данные (DOTS).
Используя пошаговые инструкции, вы пройдете путь от создания сцен до
бесшовной интеграции ресурсов и погрузитесь в программирование на C#
и визуальную разработку скриптов. Узнаете, как реализовать различные
динамические элементы геймплея, включая движения и системы здоровья.
Погрузитесь в магию игрового ИИ, принимающего решения с помощью
конечных автоматов. Научитесь создавать идеальные визуальные эффекты
с помощью материалов, шейдеров, текстур и систем частиц. Освоите приемы
оптимизации производительности с помощью профилировщика и методы
отладки игры до состояния отточенного конечного продукта.
Эта книга даст навыки, необходимые для воплощения игровых идей в жизнь
и новичкам, и опытным профессионалам.
Хуссин Хан
UNREAL ENGINE 5
Пошаговый курс по созданию
коротких фильмов и синематиков
Unreal Engine 5 — не просто инструмент для разработки игр. Это революционная платформа для кинопроизводства, анимации и виртуального
продакшена, которую используют Disney, Industrial Light & Magic и другие
студии такого уровня.
Технологии, на которых сделан сериал «Мандалорец», теперь доступны
и вам!
Эта книга — проводник в мир создания короткометражных фильмов, синематиков и гибридных проектов, где живые съемки сливаются с цифровыми
мирами.
Хуссин Хан, опираясь на многолетний опыт создания визуальных эффектов,
анимации и виртуальной реальности, предлагает полноценный курс — от
основ UE5 до продвинутых техник.