Обложка

Далее >
обложка
2003 Wordware Publishing Inc.


Аннотация

Книга «Программирование стратегических игр с DirectX 9.0» охватывает все темы, которые следует знать, чтобы создать собственную стратегическую игру на С++ с использованием DirectX. Книга выделяет процесс разработки игры и исследует игровые механизмы, управление ресурсами, разработку интерфейса, добавление звукового оформления и многое другое. Более сложные темы включают поиск пути, визуализацию стистем частиц и реализацию сетевого многопользовательского режима.

Прочитав эту книгу вы

  • узнаете об утилитах для управления проектами, которые могут облегчить процесс разработки;
  • изучите основные концепции использования блочной графики для реализации ландшафтов;
  • сможете реализовать высококачественное звуковое сопровождение с помощью интерфейсов DirectMusic и DirectSound;
  • создадите собственный редактор карт и узнаете как генерируются случайные карты;
  • узнаете как использовать ключевые кадры и анимационные наборы для анимации трехмерных объектов;
  • поймете как алгоритм А* находит лучший путь на вашей карте;
  • создадите использующие системы частиц спецэффекты, такие как взрывы и дымовые следы.

Исходный код рассматриваемых в книге примеров может быть загружен с сайтов www.wordware.com/files/games и www.lostlogic.com.


Оглавление

Благодарности
Введение
Для кого предназначена эта книга
Что ожидать от книги
Сопроводительные файлы и поддержка
Часть I. Основы
Глава 1. Экскурс в прошлое
Краткий обзор
Первые стратегии реального времени
Utopia от Intellivision
Игровое поле
Земля
Здания
Океан
Корабли
Рыба, погода и пираты
Резюме
Populous от Bullfrog
Приманка
Строительство ландшафта
Первые популярные стратегии реального времени
Command & Conquer от Westwood
История
Интерфейс
Экран радара
Индикаторы ресурсов
Объекты для постройки
Уровень энергии
Игровое поле
Технология
Баланс игры
Warcraft: Orcs & Humans
StarCraft от Blizzard
Будущее стратегий реального времени
Широкомасштабные многопользовательские игры
Умные компьютеры
Первые стратегические игры
Warlords
Empire
Command H.Q.
The Seven Cities of Gold
X-COM: UFO Defense
Итоги
Глава 2. Основы программирования в Windows
Краткий обзор
Архитектура программ для Windows
Работа, управляемая событиями
События
Структура программы
Очередь событий
Функция WinMain()
Обработчик сообщений
Пишем первую программу для Windows
Начало работы с Visual C++ 6.0
Как создать проект
Рабочее пространство
Файлы ресурсов
Добавление исходных файлов в проект
Погружаемся и сталкиваемся с кодом
Включение файлов и прототипы функций
Внутреннее устройство функции WinMain()
Структура данных WNDCLASSEX
Функция RegisterClassEx()
Функция CreateWindowEx()
Функция ShowWindow()
Получение сообщений функцией GetMessage()
Трансляция сообщений функцией TranslateMessage()
Помещение сообщений в очередь функцией DispatchMessage()
Функция обработки сообщений
Компиляция и выполнение кода
Итоги
Глава 3. Механика игры
Краткий обзор
Сюжет
Тема сюжета
Элементы сюжета
Сюжетная основа
Конечная цель сюжета
Расстановка целей
Пример для изучения — Empire Earth
Описание событий
Начальные цели
Главные вехи
Заключительная цель
Боевые единицы
Стоимость боевых единиц
Скорость боевых единиц
Вычисление скорости боевых единиц
Броня боевых единиц
Вычисление обороноспособности
Очки повреждений
Огневая мощь боевых единиц
Скорострельность
Вычисление скорострельности
Тип повреждений
Специальные повреждения
Скорость оружия
Вычисление скорости снаряда
Управление ресурсами
Выберите, что вы будете пить
Определение ресурсов
Добыча ресурсов
Баланс ресурсов
Пример несбалансированности
Пример сбалансированности
Скорость добычи
Дерево технологий
Различные типы технологий
Технологии для инфраструктуры
Технологии для вооружений
Технологии для модернизации
Стоимость технологий
Игровая кампания
Редактор миссий
Цели миссии
Многопользовательская игра
Итоги
Глава 4. Планирование игрового проекта
Краткий обзор
Фаза идей
Создание наброска
Фаза определения требований
Фаза технической документации
Фаза разработки
Контроль исходного кода
Управление метками
Отслеживание ошибок
Отслеживание
Привязка к исходному коду
Метрики качества
Тестирование отдельных частей
Фаза тестирования
Фаза производства
Распространение
Условно-бесплатные программы
Сайты аукционов
Издатели
Итоги
Часть II. Строительные блоки
Глава 5. Блочная графика
Краткий обзор
Основы блочной графики
Что такое блок?
Зачем использовать блоки?
Использование блоков для экономии памяти
Применение блоков для повторного использования графики
Использование блоков для динамического содержимого
Как создавать блоки?
Выбор размера блоков
Определение необходимых блоков
Как отображать блоки?
Отображение двухмерной сетки
Как вычислить местоположение в массиве
Как вычислить видимую позицию
Отображение изометрических блоков
Отображение трехмерных блоков
Многослойные блоки
Добавление деталей к блокам
Переходные блоки
Блоки с изображениями дорог
Как добавить трехмерные деревья
Анимированные блоки
Редактирование и хранение блоков
Хранение блоков в двухмерном массиве
Хранение многослойных блоков
Создание класса для хранения блоков
Заголовок класса
Реализация класса
Пример использования класса
Свойства блоков
Проходимость
Возвышенность
Яркость
Смещение
Отображение блоков
Отображение двухмерных блоков
Архитектура проекта D3DFrame_2DTiles
Заголовочный файл Main.h
Файл программы Main.cpp
Отображение двухмерных изометрических блоков
Архитектура проекта D3DFrame_Isometric2DTiles
Заголовочный файл Main.h
Файл программы Main.cpp
Отображение двухмерных изометрических блоков со спрайтами
Архитектура проекта D3DFrame_Isometric2DSpriteTiles
Заголовочный файл Main.h
Файл программы Main.cpp
Зачем использовать спрайты?
Отображение трехмерных блоков
Архитектура проекта D3DFrame_3DTiles
Заголовочный файл Main.h
Файл программы Main.cpp
Загрузка трехмерных моделей
Точечный источник света
Визуализация трехмерных моделей
Итоги
Глава 6. Проектирование и разработка интерфейса
Краткий обзор
Определение требований к интерфейсу
Активные зоны и графика
Детализация схемы интерфейса
Взаимосвязь шаблонов интерфейса
Звуковое оформление интерфейса
Состояния интерфейса
Раскадровка интерфейса
Удобство и простота интерфейса
Щелчки мышью и взаимодействие с интерфейсом
Сложность навигации по меню
Согласованность интерфейса
Двухмерная графика в Direct3D
Архитектура проекта D3D_TitleScreen
Заголовочный файл Main.h
Ключевые типы данных Direct3D
Структура данных настраиваемого формата вершин (FVF)
Данные местоположения
Данные нормали
Данные текстуры
Определение формата вершины
Прототипы функций
Файл программы Main.cpp
Функция WinMain()
Вычисление смещения клиентской области
Функция InitD3D()
Создание объекта Direct3D
Настройка параметров отображения
Создание устройства трехмерной визуализации
Настройка среды визуализации
Функция vInitInterfaceObjects()
Создание буфера вершин
Блокировка буфера вершин
Заполнение буфера вершин данными
Загрузка текстур
Функция vRender()
Функция vDrawInterfaceObject()
Горячие точки, или как я научился любить щелчки мыши
Обнаружение активных зон
Архитектура проекта D3D_MouseZones
Заголовочный файл Main.h
Глобальные данные активных зон
Файл программы Main.cpp
Функция WinMain()
Функция vSetupMouseZones()
Функция MouseZoneClass::vFreeZones()
Функция MouseZoneClass::vInitialize()
Функция MouseZoneClass::iAddZone()
Функция vCheckInput()
Управление щелчками мыши
Вычисление смещения клиентской области окна на рабочем столе
Вычисление местоположения указателя мыши
Функция MouseZoneClass::bCheckZones()
Обнаружение сообщений кнопок мыши
Выход из программы
Динамическое отображение меню
Заголовочный файл MouseZoneClass.h
Структура данных stHotSpot
Закрытые члены данных класса MouseZoneClass
Функции класса MouseZoneClass
Файл MouseZoneClass.cpp
Функция MouseZoneClass::MouseZoneClass()
Функция MouseZoneClass::~MouseZoneClass()
Функция MouseZoneClass::vInitialize() Function
Функция MouseZoneClass::vFreeZones() Function
Функция MouseZoneClass::iAddZone() Function
Функция MouseZoneClass::iRemoveZone() Function
Функция MouseZoneClass::bCheckZones() Function
Подсветка пунктов меню
Архитектура проекта D3D_MouseZoneHighlights
Заголовочный файл Main.h
Файл программы Main.cpp
Как определить подсвечиваемую активную зону
Как создать подсвечиваемую активную зону
Как отобразить подсветку активной зоны
Итоги
Глава 7. Добавляем к игре звук
Краткий обзор
Звуковые API
Архитектура DirectMusic
Загрузчик DirectMusic
Исполнитель DirectMusic
Сегменты DirectMusic
Как воспроизвести файл WAV
Проект DMusic_PlaySound
Заголовочный файл Main.h
Файл программы Main.cpp
Функция bInitializeSoundSystem()
Как инициализировать COM
Создание интерфейса загрузчика
Создание интерфейса исполнителя
Инициализация аудиосистемы
Как регулировать громкость
Загрузка звукового файла
Как загрузить сегмент
Функция vPlaySound()
Воспроизведение звука
Как воспроизвести файл MIDI
Как воспроизвести файл MP3
DirectShow
Проект DShow_PlayMP3
Заголовочный файл Main.h
Граф фильтров
Управление аудиовизуальным потоком
События аудиовизуального потока
Позиционирование аудиовизуального потока
Файл программы Main.cpp
Функция bPlayTitleMusic()
Инициализация DirectShow
Загрузка музыкального файла
Установка темпа воспроизведения
Воспроизведение музыки
Перехват фоновых событий
Функция vCheckMusicStatus()
Проверка кода события
Остановка музыки
Перемотка музыки
Запуск музыки
Реализация классов звуковой системы
Класс звуковой системы
Определение класса звуковой системы
Определение класса звукового фрагмента
Реализация класса звуковой системы
Деструктор SoundSystem::~SoundSystem()
Функция SoundSystem::hrInitSoundSystem()
Функция SoundSystem::hrLoadSound()
Функция SoundSystem::hrPlaySound()
Реализация класса звукового фрагмента
Пример использования класса звуковой системы
Инициализация класса звуковой системы
Воспроизведение звуковых фрагментов
Использование класса звуковой системы в меню
Итоги
Глава 8. Да будут войска!
Краткий обзор
Проектирование подразделений
Название подразделения
Способ передвижения
Передвижение по земле
Передвижение по воздуху
Передвижение по воде
Полеты в космосе
Скорость передвижения
Тип атаки
Тип защиты
Программирование шаблона
Класс CUnitDefense
Члены данных класса CUnitDefense
Коэффициенты защиты
Очки повреждений
Скорость восстановления
Тип защиты
Название защиты
Методы класса CUnitDefense
Класс CUnitOffense
Члены данных класса CUnitOffense
Коэффициенты поражения
Радиус взрыва
Скорострельность
Скорость снаряда
Дальнобойность
Тип атаки
Название типа атаки
Методы класса CUnitOffense
Класс CUnitMovement
Члены данных класса CUnitMovement
Скорость передвижения
Способ передвижения
Ускорение и торможение
Скорость поворота
Методы класса CUnitMovement
Класс CUnitAnimation
Члены данных класса CUnitAnimation
Анимация ожидания
Анимация передвижения
Анимация атаки
Анимация гибели
Начальный кадр анимации
Данные текстуры
Методы класса CUnitAnimation
Функция CUnitAnimation::vSetRenderDevice()
Функция CUnitAnimation::vLoadTextures()
Цвета владельца
Функция CUnitAnimation::vReset()
Класс CTexture
Члены данных класса CTexture
Методы класса CTexture
Функция CTexture::vLoad()
Функция CTexture::vRelease()
Функция CTexture::vSetRenderDevice()
Класс CUnit
Члены данных класса CUnit
Методы класса CUnit
Функция CUnit::vReset()
Функция CUnit::vSetBaseValues()
Функция CUnit::vSetPosition()
Класс CUnitManager
Загрузка базовых типов
Функция CUnitManager::iLoadBaseTypes()
Функция CUnitManager::ptrGetDefenseType()
Создание подразделений
Функция CUnitManager::iAddUnit()
Управление текстурами
Рисование подразделений
Функция vDrawUnit()
Функция vRender()
Использование альфа-канала
Отображение активных подразделений
Обновление кадра анимации
Обработка ожидающих подразделений
Обработка поворачивающих подразделений
Обработка атакующих подразделений
Обработка гибнущих подразделений
Обработка перемещающихся подразделений
Загрузка и создание подразделений
Итоги
Глава 9. За кулисами клавиатуры
Краткий обзор
Обзор DirectInput
Интерфейс IDirectInput8
Интерфейс IDirectInputDevice8
Ввод с клавиатуры
Проект DInput_Simple
Инициализация DirectInput
Функция iInitDirectInput()
Функция iInitKeyboard()
Буферизованный ввод с клавиатуры
Функция IDirectInputDevice8::SetProperty()
Установка формата данных клавиатуры
Установка уровня кооперации
Захват клавиатуры
Раскладка клавиатуры
Чтение данных клавиатуры
Функция iReadKeyboard()
Определение состояния DIK
Преобразование кода DIK в код ASCII
Ввод текста в игре
Навигация по меню
Активация ввода текста
Обработка текстового ввода
Отображение введенного текста
Интерфейс шрифта
Функция ID3DXFont::DrawText()
Итоги
Часть III. Инструменты разработчика
Глава 10. Редактируем карту мира
Краткий обзор
Основы редактирования карт
Компоненты редактора карт
Область редактирования
Область выбора блоков
Мини-карта
Область вывода информации
Просмотр карты
Глобальные переменные карты
Ход выполнения программы
Генерация случайной карты
Навигация по карте
Загрузка изображений блоков
Визуализация блоков
Редактирование карты
Глобальные переменные карты
Ход выполнения программы
Программирование панели инструментов
Отображение блоков на панели инструментов
Редактирование карты
Сохранение и загрузка карты
Функция SaveMap()
Функция LoadMap()
Отображение мини-карты
Функция vCreateMinimap()
Функция vRenderMinimap()
Алгоритмы генерации карт
Функция vGenerateMap()
Методы генерации карт
Многослойные карты
Изменения в заголовочном файле
Многомерный массив
Функция смены слоя
Переменные для новых кнопок
Изменения в функции vCreateToolbar()
Функция vChangeLayer()
Изменение процедур сохранения и загрузки
Изменения в функции vRender()
Изменения в функции vCheckMouse()
Итоги
Глава 11. Трехмерная анимация
Краткий обзор
Обзор трехмерной анимации
Сцена
Объекты
Фиксированные объекты
Ключевые кадры
Автоматическая вставка промежуточных кадров
Анимационные наборы
Импорт содержимого
Экспорт из 3ds max
Основы моделирования
Загрузка объекта
Программа Convert 3DS
Редактирование анимации
Готовые редакторы анимации
Программирование собственного редактора
Интерфейс редактора анимации
Команды редактора анимации
Команды работы с кадрами
Команды работы с объектами
Команды воспроизведения
Команды работы с файлами
Процесс редактирования
Сохранение и загрузка
Программирование редактора
Элементы управления программы D3D_AnimationEditor
Проект D3D_AnimationEditor
Класс C3DAnimation
Методы класса C3DAnimation
Члены данных класса C3DAnimation
Структура stKeyFrame
Реализация методов класса C3DAnimation
Конструктор класса C3DAnimation
Деструктор класса C3DAnimation
Функция C3DAnimation::vReset()
Функция C3DAnimation::vNewFrame()
Функция C3DAnimation::iNextFrame()
Функция C3DAnimation::iPrevFrame()
Функция C3DAnimation::iStartFrame()
Функция C3DAnimation::iNewObj()
Функция C3DAnimation::vUpdateTrans()
Функция C3DAnimation::vUpdateRot()
Функция C3DAnimation::vUpdateScale()
Функция C3DAnimation::vSave()
Функция C3DAnimation::vLoad()
Функция C3DAnimation::vSet3DDevice()
Программа D3D_AnimationEditor
Функция vInitAnimation()
Функция vLoadObject()
Создание кадров
Выбор необходимого объекта
Изменение местоположения объекта
Добавление ключевых кадров
Функция vRender()
Управление с клавиатуры
Что еще можно сделать
Реализация анимации
Итоги
Часть IV. Дополнительные темы
Глава 12. Поиск пути
Краткий обзор
Задача поиска пути
Простое решение
Поиск пути по алгоритму A*
Основы A*
Начало поиска
Вычисление стоимости узлов
Базовая стоимость узла
Стоимость относительно начального узла
Стоимость относительно цели
Общая стоимость
Поиск наилучшего узла
Продолжение поиска
Обратная трассировка для нахождения пути
Реализация в коде
Функция инициализации пути
Функция CPathFinder::bFindPath()
Итоги и оптимизация
Глава 13. Визуализация частиц
Краткий обзор
Основные сведения о частицах
Характеристики частиц
Изображение частиц
Движение частиц
Анимация частиц
Структура класса частиц
Класс CVector
Класс CParticle
Члены данных
Методы класса
Функция CParticle::vUpdate()
Реализация системы частиц
Структура проекта D3D_Particles
Инициализация частиц
Визуализация частиц
Итоги
Глава 14. Пример сетевого программирования
Краткий обзор
Сетевые соединения
Игра по локальной сети
BNC — шаг вперед!
Соединения витой парой
Соединение через кросс-кабель
Сеть с концентратором
Сеть с коммутатором
Маршрутизаторы — ворота в Интернет
Сетевые протоколы
Протокол TCP/IP
Протокол UDP
TCP/IP против UDP
Пакеты
Задержка
Одноранговые сети
Сети клиент-сервер
Сравнение одноранговых сетей и сетей клиент-сервер
Сокеты и DirectPlay
Пример программирования сокетов
Программа Sockets_Receive
Включение заголовочного файла WinSock
Установка версии WinSock
Создание сокета
Поиск сервера по URL
Установка номера порта
Подключение к серверу
Отправка данных серверу
Получение данных от сервера
Закрытие сокета
Отключение сокетов
Программирование сетевых походовых игр
Ход выполнения походовой игры
Программа Sockets_TurnGame
Глобальные переменные программы Sockets_TurnGame
Функции программы Sockets_TurnGame
Функция vHost()
Функция vConnect()
Функция vTurnDone()
Функция vSendTurnMessage()
Функция vReceiveTurnMessage()
Итоги
Приложение A. Photoshop — лучший редактор для текстур
Краткий обзор
Adobe Photoshop
Создание снимков экрана с помощью Photoshop
Диалоговое окно создания нового изображения
Установка атрибутов изображения
Вставка изображения из буфера
Компоновка изображения
Сохранение изображения
Слои
Эксперименты со слоями
Перемещение слоев
Изменение порядка слоев
Изменение прозрачности слоя
Эффекты
Свойства слоя
Параметры смешивания
Падающая тень
Фильтры
Альфа-карты
Делаем альфа-карту
Вкладка Channels
Инструмент Magic Wand
Выбор фрагмента
Создание маски
Инвертирование маски
Сохранение изображения
Итоги
Приложение B. Ресурсы для разработчика
Алфавитный указатель


Благодарности

Джим Хилл (Jim Hill) и Вес Бесквит (Wes Beckwith) из Wordware Publishing за издание этой книги. Бет Кохлер (Beth Kohler) и Хитер Хилл (Heather Hill) из Wordware за редактирование книги и допечатную подготовку. Гил Шиф (Gil Shif) из Blizzard Entertainment за помошь в получении разрешения на использование изображений из их игр. Эми Фэррис (Amy Farris) из Westwood Entertainment за помошь в получении разрешения на использование изображений из их игр. Джефф Ройл (Jeff Royle) из ATI за предоставление видеокарт для проверки совместимости. Сара Дэвис (Sarah Davis) из Discreet за помощь в работе над материалом о 3ds Мax. Сьюзан Доэринг (Susan Doering) из Adobe за помощь в работе над материалом о Photoshop.


Введение

Привет и добро пожаловать в восхитительный мир программирования стратегических игр! Стратегия всегда была одинм из моих любимых игровых жанров. Ничто в игровом мире не сравнится с длительной стратегической игрой с другом или противником.

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

  • Механика игры
  • Планирование проекта
  • Блочная графика
  • Дизайн и разработка интерфейса
  • Воспроизведение звука
  • Контроль и управление соединениями
  • Инструментальные средства разработчика
  • Трехмерная анимация
  • Мнногопользовательская игра

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

Для кого предназначена эта книга

Если вы новичок или начинающий разработчик игр, и желаете создать основу для производства стратегической игры, эта книга прекрасно подойдет вам. Хотя я и рассматриваю в книге некоторые достаточно сложные темы, я стараюсь сделать их доступными для как можно большей аудитории. Если вы удостокенный многих титулов ветеран разработки игр, вы, возможно, пройдете мимо. После всего, что вы сделали, прочтите книгу! Серьезно, большинство программистов, знакомых с теорией программирования игр, могут воспользоваться примерами чтобы сэкономить время.

Все примеры в этой книге написаны на языке С++ и компилировались Microsoft Visual C++ версии 6.0. Хотя я и пользуюсь С++, вы обнаружите, что в большинстве кода применяются только базовые средства языка и я избегаю пользоваться шаблонами и другими возможностями С++, чтобы код оставался как можно более простым для восприятия.

В качестве рекомендуемой операционной системы я использовал Windows 2000, кроме того, работа большей части кода была проверена и в Windows XP. Поскольку книга посвящена DirectX, Linux в ней не рассматривается.

ПРИМЕЧАНИЕ
Во всех темах, относящихся к DirectX в этой книге используется DirectX версии 9.

Что ожидать от книги

Вы можете обнаружить, что большинство примеров программ в этой книге очень просты и прямолинейны. Не следует ожидать увидеть оптимизированный или элегантный код. Я оставил программы простыми, чтобы их было легче понять. Хотя код не элегантен и не оптимизирован, он работает и выполняет поставленную задачу.

Сопроводительные файлы и поддержка

Сопроводительные файлы могут быть загружены с сайта www.wordware.com/files/games и с моего веб-сайта www.lostlogic.com.

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


Глава 1. Экскурс в прошлое

Краткий обзор

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

  • Первые стратегии реального времени.
  • Первые популярные стратегии реального времени.
  • Будущее стратегий реального времени.
  • Первые стратегические игры.


Глава 1 - Первые стратегии реального времени

Первые стратегии реального времени

Также, как история древних веков содержит много загадок, прошлое стратегических игр реального времени не является полностью ясным. Многие люди утверждают, что первая стратегия реального времени — это Dune от Westwood, но я вспоминаю намного более ранние примеры игр этого жанра.

Utopia от Intellivision

В далекой, далекой местности, в двух милях вниз по грунтовой дороге мимо старой рыжей собаки, слева от большого дуба домашняя игровая система Intellivision населяла несколько гостинных в Соединенных Штатах.

(Отсюда начинается ретроспектива.) Жаркий летний полдень, я и мой брат Эдди участвуем в решающем сражении. Этому моменту предшествовали несколько недель планирования, маневров и пропаганды. Мой боевой флот вторгся в его водное пространство и разрушил рыболовные суда. Его люди голодают. Единственная надежда Эдди — сохранить свои посевы до следующего ливня. Но увы, я я подрядил армию симпатизирующих моим целям мятежников, чтобы они уничтожали поля. Бим, бам, бом! Ход закончен. Черт, теперь придется ждать следующего хода, чтобы установить мое марионеточное правительство на его острове.

Я провел много дней играя в Utopia с любой жертвой, — я имею в виду противника, — которую я мог уговорить сыграть. Utopia была весьма оригинальной игрой, в которой игрок действовал в режиме реального времени, а ходы использовались чтобы сообщать о произошедшем и подсчитать счет. Вы могли играть в однопользовательском режиме, но настоящее удовольствие было в игре с другим человеком. Utopia пердставляла собой смесь SimCity и Command & Conquer. Вы должны увеличивать благосостояние своих людей и в то же время уменьшать благосостояние вашего противника.

Игровое поле

На рис. 1.1 показано представление игрового поля игры Utopia. Мир разделен на два отдельных острова. На игровом поле существуют несколько видов элементов: блоки, представляющие землю, здания, океан, корабли, рыбу и погоду.

Рис. 1.1. Игровое поле программы Utopia

Рис. 1.1. Игровое поле программы Utopia

Земля

Блоки, представляющие землю образуют два острова. На каждом квадрате земли может быть построено одно здание или ферма. Здания образуют инфраструктуру вашей колонии, а фермы обеспечивают продовольствием. Конечно, ничто не дается бесплатно, и вы можете строить только те объекты, на которые у вас хватает денег. Размещение объектов является ключевым фактором игры, поэтому к управлению землей следует отнестись тщательно.

Здания

Есть всего несколько типов зданий, которые можно выбрать, но каждый из них имеет важное значение для благосостояния цивилизации. Есть форты, фабрики, поля, школы, больницы, жилые дома и мятежники.

Вы можете спросить — как мятежники могут быть зданием? Для вас это может казаться странным, но в игре мятежные солдаты могут появляться и занимать квадраты земли. Земля, которую они занимают, не может быть использована для других целей, пока мятежники не будут уничтожены. Чтобы предотвратить захват вашей собственности мятежниками используются форты. Мятежные солдаты не могут находиться на смежных с фортом квадратах. Таким образом, если каждое из ваших зданий примыкает к форту, мятежники не смогут атаковать. Кроме того, форты защищают от пиратов. Представление форта показано на рис. 1.2.

Рис. 1.2. Форт в игре Utopia

Рис. 1.2. Форт в игре Utopia

Фабрики нужны чтобы приносить доход. Ваш итоговый счет зависит от количества накопленного золота, поэтому фабрики жизненно необходимы для победы. Каждая фабрика за один ход производит небольшое количество золота. Отрицательная черта фабрик — загрязнение окружающей среды, которое увеличивает смертность населения. Кроме того, на фабрики влияет благосостояние вашего населения. С его ростом увеличивается и количество производимого фабриками золота. Сделайте население счастливым — и вы будете богаты! Представление фабрики вы можете увидеть на рис. 1.3.

Рис. 1.3. Фабрика в игре Utopia

Рис. 1.3. Фабрика в игре Utopia

Поля очень просты — они обеспечивают людей пищей. Урожай с одного поля может прокормить 500 человек. Недостаток полей заключается в том, что они существуют ограниченное время. Вы должны создавать их заново каждые несколько ходов. Положительная черта заключается в том, что в случае ливня поля производят золото. Правильным образом размещенные поля могут превратиться в золотые рудники.

Школы увеличивают благосостояние населения. Побочным эффектом увеличения благосостояния является увеличение производительности фабрик. Образованное население — производительное население.

Больницы увеличивают численность населения. Кроме того, они значительно повышают благосостояние. Фактически, больница — одно из лучших зданий для постройки.

Жилые дома необходимы, чтобы вашему населению было где жить. Один квадрат жилых домов предоставляет кров для 500 человек.

Мятежные солдаты не являются зданием, но они могут занимать территорию вашего противника. Вы можете покупать мятежников и они автоматически появятся на собственности вашего оппонента. Следует иметь в виду, что мятежники не могут вторгаться в области, защищенные фортами.

Океан

Значительная часть игрового поля занятя океаном. Океан — свободная зона, и никто не может владеть отдельным квадратом океана. Вы не можете строить в океане здания, но вы можете строить рыболовные или боевые корабли, которые будут плавать в нем.

Корабли

В игре вы можете покупать корабли двух типов: рыболовецкие и боевые. Когда вы строите рыболовецкое судно, оно появляется на ближайшем свободном квадрате вашей базы.

Рыблолвецкими судами можно управлять, так что вы можете отправить их в плавание по океану. Когда вы помещаете рыболовецкий корабль на квадрат с косяком рыбы, он начинает производить золото. Кроме того, рыболовецкое судно автоматически кормит 500 человек. Эти корабли абсолютно необходимы игроку.

Боевые корабли помогают защищаться от пиратов и от боевого флота вашего противника. Кроме того, вы можете нападать на рыболовецкие суда противника и отправлять их на дно моря.

Рыба, погода и пираты

Игрок имеет значительный контроль над миром, но есть вещи неподвластные ему. Косяки рыбы и пираты беспорядочно бороздят океан. Рыба полезна для получения золота, а пираты могут потопить ваш рыболовный флот.

Рис. 1.4. Трусливые пираты бороздят океан игры Utopia

Рис. 1.4. Трусливые пираты бороздят океан игры Utopia

Есть несколько погодных явлений: ливни, тропические штормы и ураганы. Ливни полезны, поскольку когда они проходят над полями, то приносят золото. Тропические штормы тоже приносят золото аналогичным образом, но они могут уничтожить поля, потопить корабли и, иногда, даже разрушить здания. Ураганы хуже всего — они уничтожают все на своем пути. Опасайтесь ураганов — если вы не будете действовать быстро, они могут уничтожить весь ваш рыбацкий флот.

Резюме

Как вы теперь знаете, Utopia имеет весьма небольшой набор руководящих правил. Игровой процесс достаточно прямолинеен, поскольку ваша основная задача — произвести наибольшее количество золота. В конце игры побеждает тот игрок, у которого больше золота. Очень интересно, что в игре с таким небольшим количеством правил и параметров существует множество возможных стратегий.

Я описал эту игру так подробно, чтобы показать, что очень простая (по сегодняшним меркам) игра может быть и интересной и многогранной. Когда вы будете разрабатывать и программировать игры, помните, что простой набор базовых правил может сделать вашу игру более привлекательной для игрока. Нет необходимости в очень сложных игровых системах. Главное — чтобы игрок получал удовольствие.

Populous от Bullfrog

Через несколько лет после дебюта игры Utopia компания Bullfrog выпустила игру Populous. Это не была типичная стратегия реального времени, поскольку вы не могли непосредственно создавать военные соединения. Вместо этого ваши здания «выводили» большее количество жителей. Большее население увеличивало вашу власть.

Приманка

Первая игра Populous (сейчас игр с таким названием существует по меньшей мере три) была первой популярной игрой, позволяющей игроку побыть в роли «бога». Такая власть позволяет игроку испытать гораздо больше, чем простое строительство империи. В результате строительства большего числа городов вы получаете большую цивилизацию, но, кроме того, вы получаете и магические силы. По мере возрастания этих сил вы можете использовать все более мощные заклинания. Некоторые заклинания представляют собой простые атаки, такие как удар молнии. Другие заклинания гораздо мощнее, например, вулканы. Нет ничего приятнее, чем создать вулкан посреди города ващего противника.

Строительство ландшафта

Другой уникальной особенностью игры Populous является возможность изменять ландшафт территории, на которой проживают ваши люди. Инструменты в игре позволяют вам создавать равнины или возвышенности. Создавая большие пространства ровной земли вы обеспечиваете свое население пригодным для строительства ландшафтом. Если вы оставляете свою землю неровной, ваши люди вынуждены жить в небольших домах. Если же вы создадите широкие равнины, они смогут строить замки и большие здания. Проектируя свою игру помните об этой уникальной возможности. Иногда даже самые простые идеи делают игру гораздо более увлекательной.

Как я упоминал, было выпущено несколько вариантов игры Populous. Чтобы получить самые последние новости, относящиеся к этой игре, посетите сайт http://www.populous.net.


Глава 1 - Первые популярные стратегии реального времени

Первые популярные стратегии реального времени

Развитие большинства жанров видеоигр определялось появлением какой-либо популярной игры. Castle Wolfenstein и Doom сделали популярными трехмерные «стрелялки» с видом от первого лица. Игра SimCity принесла популярность экономическим симуляторам. Civilization определила развитие пошаговых стратегических игр. Путь стратегий реального времени не столь ясен, поскольку развитие жанра определили несколько игроков.

Command & Conquer от Westwood

Прошло более десяти лет с момента выхода игры Utopia, когда компания Westwood выпустила быстро ставшую популярной стратегию реального времени Command & Conquer. C&C, как известно не была первой современной стратегией реального времени, но она действительно определила черты современных игр этого жанра. Ниже вы можете видеть графическую заставку игры:

Рис. 1.5. Титульный экран игры Command & Conquer

Рис. 1.5. Титульный экран игры Command & Conquer. ©2002 Electronic Arts, All Rights Reserved.

Воспоминания возвращаются — моя армия готова, БМП загружены, воздушные силы заправлены и готовы к вылету, ядерное оружие готово к использованию. Я отдаю приказ «вперед» отвлекающему подразделению БМП. Оно стремительно атакует защитные системы врага. Пока враг занят отвлекающим маневром БМП, я отправляю флот геликоптеров атаковать строительный центр, сердце действий врага. Тем временем мой 11-й танковый дивизион медленно продвигается, разрушая внешнюю стену вражеской базы. В это время подразделение геликоптеров прорвало передовую линию ПВО и атаковало нервный центр. Я даю зеленый свет БМП с инженерами, они направляются к вражеской базе. Мои геликоптеры были разрушены, но нанесли серьезный урон строительному центру — он уже дымится. Пришло время для ядерной атаки. Код введен и ядерные заряды отправляются в атмосферу, нацеленные на строительный центр. После попадания нервный центр врага прекращает существование. Он не выдержал комбинацию из атаки геликоптеров и атомного оружия. К этому времени танки глубоко вклинились на территорию вражеской базы. Инженеры прибыли к кратеру, находящемуся на месте бывшегго строительного центра и выгрузились из БМП. Они размещают заряды на оставшихся оборонительных сооружениях. Заряды взрываются и разрушают цели. После этого танки идут вперед и разрушают то, что осталось от базы. Подождите, часы действительно показывают пять утра?

Я не могу сосчитать, сколько часов потратил на игру C&C в моем первом офисе. Мой деловой партнер и я приглашали в офис пару друзей и играли до раннего утра. Мой первый офис располагался в крошечном помещении размерами десять на десять футов. Компьютеры Pentium-133 (последняя модель в то время) создавали столько тепла, что температура в помещении достигала 89 градусов по Фаренгейту. Ничто из этого нас не устрашало; мы набивались в офис и играли. Обычно собиралось более четырех человек, и шла игра на выбывание.

История

Предпосылка для игры Command & Conquer заключается в том, что вы контролируете команду Глобальной Оборонной Инициативы Объединенных Наций, или GDI. GDI воюет со злым Братством NOD, лидера которого зовут Кейн. Вы можете видеть его фотографию на рис. 1.6. Главная идея состоит в том, что обе группировки находятся на планете, где сражаются за тибериум. Тибериум — это минерал, который делает возможным существование окружающего мира. Он используется в игре для постройки любого военного подразделения или здания. Без него вы проиграете. Раз тибериум это минерал, его необходимо собирать. Поэтому общая стратегия в игре выглядит так: собрать столько тибериума, сколько возможно, построить армию и уничтожить другого игрока (или игроков).

Рис. 1.6. Кейн, злой коммандир

Рис. 1.6. Кейн, злой коммандир (из Tiberian Sun). ©2002 Electronic Arts, All Rights Reserved.

Интерфейс

В C&C есть несколько элементов, перенесенных из более ранней игры Westwood, Dune. На рис. 1.7 можно заметить общие принципы организации интерфейса.

Рис. 1.7. Интерфейс Command & Conquer

Рис. 1.7. Интерфейс Command & Conquer. ©2002 Electronic Arts, All Rights Reserved.

Как видно на рисунке, интерфейс состоит из следующих основных элементов: экран радара, индикаторы ресурсов, объекты для постройки, уровень энергии и игровая область.

Экран радара

Экран радара показывает вам территорию, стоения и подразделения на игровой карте. Сразу после запуска игры экран радара неактивен. Чтобы он включился, игроку необходимо построить несколько зданий. Тот факт, что вы начинаете игру без этой технологии, добавляет интересный штрих к игровому процессу. Для рисования радарной карты игровой движок должен только уметь представлять каждый блок карты в виде одного пикселя. Сначала отображается территория, затем здания и потом подразделения. Каждому игроку назначается отдельный цвет, чтобы их можно было отличать друг от друга.

Большинство современных стратегий реального времени используют тот или иной вид радарного экрана. Игры, подобные Age of Empires от Ensemble Studios также используют экран радара для показа ресурсов, людей, территории и других, интересных игроку элементов. Игра Age of Empires идет в средневековом окружении, так что в ней не может быть настоящего радара, однако показывается та же самая информация. Возможно, какой-то фермер создал твердотопливный ракетный двигатель из свиного навоза, чтобы запустить спутник-шпион. Выбирайте объяснение сами.

Индикаторы ресурсов

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

Объекты для постройки

В середине правой части интерфейса расположены кнопки, представляющие здания и подразделения, которые может создать игрок. В начале игры игрок может создать всего несколько видов объектов. По ходу игры игрок открывает новые объекты для строительства и они появляются в рассматриваемом списке. Это хороший способ представить игроку все объекты, которые он может построить. Вместо того, чтобы блуждать по множеству меню, игрок прокручивает список доступных зданий и подразделений, после чего щелкает по требуемому объекту. На рис. 1.8 представлено изображение разведывательного байка.

Рис. 1.8. Разведывательный байк GDI

Рис. 1.8. Разведывательный байк GDI. ©2002 Electronic Arts, All Rights Reserved.

Уровень энергии

Хотя тибериум — это самый важный материал в Command & Conquer, без энергии ваши постройки не смогут функционировать. Индикатор энергии в нижнем правом углу интерфейса отображает объем используемой энергии в сравнении с доступной. Когда игрок строит электростанции цвет полосы становится более зеленым, а сама она растет. Когда строятся другие здания цвет полосы становится более красным. Такой стиль индикации дает игроку визуальное представление энергоснабжения в сравнении с потребностями. Это весьма изобретательно, поскольку чтобы представить текущую ситуацию игроку не надо счтиывать различные числа. Здесь мы пришли к еще одному ключевому пункту: постарайтесь, чтобы ваша игра не выглядела похожей на электронную таблицу. Для этого вам придется использовать графические представления числовых значений.

Игровое поле

Чем была бы игра без игрового поля? Тем же самым, чем будет приборная панель автомобиля без ветрового стекла! Игровое поле используется чтобы показать игру в действии. На нем отображается территория, здания и подразделения. Прочитав это вы можете подумать «Ха! Я так и знал!». Но прежде чем делать поспешные выводы, подумайте обо всех играх прошлых лет, которые обходились без игрового поля. Текстовые приключенческие игры, MUD и игры через BBS в основном обходились без игрового поля. Я думаю, должен вызывать интерес тот факт, что многие игры прошлого вообще обходились без графического вывода. Помните об этом разрабатывая свои игры. Роскошная графика и спецэффекты не являются необходимыми для того чтобы игра была интересной и захватывающей.

Технология

Как и в большинстве современных стратегий реального времени, в Command & Conquer присутствуют две воюющие стороны. Каждая из них обладает собственными сильными и слабыми сторонами. В данном случае сильные и слабые стороны выражаются в форме подразделений и оружия.

У зловещего Братства NOD есть тяжелые и медленные подразделения, обладающие большой огневой мощью. Также у него есть исключительно сильная оборона и специальные средства для атак. Одним из таких специальных средсв являются ядерные ракеты. Когда игрок, управляющий NOD, построит Храм NOD, он получает возможность достаточно часто запускать ядерные ракеты. Этот зловещий храм изображен на рис. 1.9.

Рис. 1.9. Храм NOD

Рис. 1.9. Храм NOD. ©2002 Electronic Arts, All Rights Reserved.

После запуска ядерных ракет храм должен быть перезаряжен. Это сохраняет баланс игры, поскольку игрок NOD не может просто сидеть на месте и запускать ракету за ракетой. Ядерная ракета особенно разрушительна в точке взрыва. Большинство строений в игре не могут противостоять такому нападению и разрушаются. На рис. 1.10 показаны опустошения, производимые подобным нападением.

Рис. 1.10. Атака ядерных ракет на игрока GDI

Рис. 1.10. Атака ядерных ракет на игрока GDI. ©2002 Electronic Arts, All Rights Reserved.

Как я упоминал ранее, NOD обладает также замечательной обороной. Одно из таких оборонительных сооружений называется Рука NOD. Это башня с лазером, который может разрушить большинство врагов за один выстрел. Обратная сторона такой защиты — большое потребление энергии и слабая защищенность против большого количества врагов. Вы можете увидеть как действует это оружие на рис. 1.11.

Рис. 1.11. Рука NOD в действии

Рис. 1.11. Рука NOD в действии. ©2002 Electronic Arts, All Rights Reserved.

Узнав об огневой мощи, доступной NOD, вы можете спросить, что может противопоставить этому GDI. Оказывается GDI обладает исключительно быстрыми подразделениями и мощными воздушными силами. Из специального вооружения у GDI есть ионная пушка, посылающая мощный пучок энергии в заданную цель. Обычно она уничтожает почти все, во что попадает.

Воздушные силы GDI действительно великолепны. Летяшие в строю атакующие геликоптеры практически неуничтожимы. Одна из используемых мною в игре стратегий состояла в создании флота геликоптеров Orca и отправке этого роя для уничтожения врага. Если вы построите достаточное количество геликоптеров, они сокрушат систему ПВО. Геликоптер Orca изображен на рис. 1.12.

Рис. 1.12. Геликоптер Orca ВВС GDI

Рис. 1.12. Геликоптер Orca ВВС GDI. ©2002 Electronic Arts, All Rights Reserved.

Баланс игры

Я полагаю, что одна из отличительных черт, выделяющих C&C, это возникающее чувство сбалансированности. По моему мнению Command & Conquer одна из наиболее сбалансированных игр. Вы не можете выиграть с помощью одного типа подразделений или единственного типа стратегии. Кажется, почти все в игре имеет свою Немезиду.

Вы должны бороться за сбалансированность, программируя любые типы стратегических игр. Если вы допустите несбалансированность, игроки быстро найдут ее и воспользуются ею. Это ясно видно в таких играх, как Total Annihilation от Cavedog Entertainment. В Total Annihilation выигрывает тот, кто первым построит большие воздушные силы. Самолеты передвигаются слишком быстро, и нет способа для их эффективного уничтожения.

Разрабатывая свою игру убедитесь, что вы выполнили моделирование сражений между разными подразделениями. Это поможет выявить их сильные и слабые стороны, а также обнаружить критическую несбалансированность.

Warcraft: Orcs & Humans

Почти в то же время, когда вышла игра C&C, другая компания выпустила не менее захватывающую стратегию реального времени. Этой игрой, конечно, была Warcraft от Blizzard Entertainment. Находясь на другом конце спектра, Warcraft является игрой в стиле фэнтези, проходящей в средневековом окружении, в то время как C&C — это футуристическая военная игра.

В Warcraft вы можете играть либо за орков, либо за людей. Игра разворачивается вокруг битвы за землю Азерот. Доступно несколько сценариев, я полагаю по 12 для каждой стороны. Игра в однопользовательском режиме очень интересна, но гораздо больше удовольствия я получил от выбивания соплей из моих друзей в многопользовательском режиме. Warcraft является еще одной игрой, которая отняла множество часов моего времени. Мы могли играть до рассвета и дальше.

Как и Command & Conquer, Warcraft — хорошо сбалансированная игра. Играете ли вы за орков или за людей, у вас есть равные шансы на победу в игре с грамотным противником. Позднее была выпущена игра Warcraft II: Tides of Darkness, но я не думаю, что она была также хорошо сбалансирована. Может быть это объясняется количеством доступных подразделений. Следует помнить, что больше не всегда значит лучше. Игра с сотнями несбалансированных подразделений гораздо хуже, чем игра с десятком идеально сбалансированных произведений.

StarCraft от Blizzard

Другой популярной игрой, выпущенной Blizzard Entertainment является StarCraft. StarCraft чем-то похожа на Warcraft: Orcs & Humans, но ее действие разворачивается в будущем. В игре имеются три различные расы, каждая со своими сильными и слабыми сторонами. Эта уникальная особенность выделяет StarCraft среди современников. До StarCraft, большинство стратегий реального времени предлагали противостояние примерно одинаковых рас или сторон. StarCraft впервые предложил настолько различных соперников. На рис. 1.13 показана атака расы, называемой зерги на людей.

Рис. 1.13. Атака зергов на людей в игре StarCraft

Рис. 1.13. Атака зергов на людей в игре StarCraft. ©2002 Blizzard Entertainment, All Rights Reserved.


Глава 1 - Будущее стратегий реального времени

Будущее стратегий реального времени

Теперь, когда вы увидели прошлое стратегий реального времени, я покажу вам будущее. Конечно, я не гадалка, но попытаюсь сделать это.

Широкомасштабные многопользовательские игры

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

Представьте, какое удовольствие доставит командование небольшим отрядом солдат в подобной игре. Каждое принимаемое вами решение может оказать влияние на исход сражения и войны в целом. Должно быть смоделировано все — от снабжения до командования. Варианты безграничны — хватало бы мощности процессора и полосы пропускания.

Умные компьютеры

Посмотрим правде в глаза: управляемые компьютером противники исключительно тупы. Сегодня не существует стратегии реального времени, которая могла бы бросить настоящий вызов игроку не прибегая к мошенничеству. Чтобы увидеть жульничающий искуственный интеллект в действии, достаточно сыграть в Empire Earth. Игра дает управляемым компьютером противникам больше ресурсов и, я подозреваю, что цикл производства у них тоже короче. Конечно, сначала некоторые игры кажутся трудными, но сколько длится это ощущение? Совсем недолго. В будущем появятся достаточно мощные процессоры, позволяющие создать феноменальные алгоритмы искусственного интеллекта. Проблема, стоящая перед программистами сегодня, заключается в том, что алгоритмы искусственного интеллекта требуют значительных объемов вычислений. После формирования каждую секунду десятков кадров с различными спецэффектами, на долю искуственного интеллекта остается не так много времени процессора. Возможно, когда-нибудь нашим кремниевым врагам будет предоставлено достаточно тактов.


Глава 1 - Первые стратегические игры

Первые стратегические игры

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

Warlords

Давным давно я играл в игру с названием Warlords на моем компьютере Amiga. Несколько друзей собирались у меня дома и приступали к игре. Эти встречи обычно продолжались по 12 часов и более, поскольку ходы делали все по очереди. В конечном итоге один из нас побеждал, и все получали массу удовольствия.

Warlords — это походовая стратегическая игра выпущенная Strategic Studies Group, в которой вы командуете фэнтезийными отрядами и ведете их в бой с противником. Усыпающие местность замки позволяют вам строить армии, необходимые для завоеваний. Уловка игры состоит в том, что замки создают различные армии. Некоторые замки производят драконов, другие — эльфов. Кроме того, вы можете получить героев, что дает значительное преимущество. Герои могут путешествовать по стране и находить специальные боевые артефакты в заброшенных храмах и руинах. Герои очень важны для игры, поскольку их преимущества влияют и на армии, путешествующие с ними.

За прошедшие годы SSG выпустила несколько новых версий Warlords, включая стратегическую игру в реальном времени с названием Warlords: Battlecry, у которой есть даже вторая версия. Привлекательность игры Warlords вызвана мощной системой искусственного интеллекта. Искусственный интеллект в серии игр всегда был превосходен иявляется достойным соперником в однопользовательском режиме игры.

Empire

Около 1987 года компания с названием Interstel выпустила игру Empire. Empire — это двухмерная походовая стратегическая игра в которой вам надо завоевывать города, чтобы установить господство над миром. Каждый раз, когда вы завоевываете город, у вас появляется возможность создавать в нем новые армии. Вы можете выбрать, какую армию создавать из списка в котором перечислены восемь различных воинских подразделений. Одни соединения создаются дольше, чем другие, кроме того некоторые подразделения доступны только в городах, расположенных рядом с океаном. Например, авианосцы доступны только в тех городах, где есть гавань.

Empire — удивительно захватывающая игра, которая удерживает вас на долгие часы. Я не уверен, почему она настолько захватывающая. Может быть потому что чем больше городов вы завоевываете, тем больше подразделений можете создать? Или возможно потому что карты очень большие и требуют много времени на исследования? Ключевой момент, который следует всегда помнить, заключается в том, что игра может быть простой и при этом доставлять массу удовольствия. К этой цели должны стремиться все программисты игр.

Рис. 1.14. Игровое поле программы Utopia

Рис. 1.14. Классическая игра Empire. ©2003 Killer Bee Software, All Rights Reserved.

Если вас заинтересовала игра Empire, проверьте сайт Killer Bee Software. Марк Кинкэд из Killer Bee Software приобрел права на серию Empire и планирует выпустить восстановленную игру Empire и обновленную версию.

Command H.Q.

Еще одна из моих любимых старых стратегических игр называется Command H.Q. Ее разработала Ozark Softscape, — небольшая домашняя команда разработчиков из Арканзаса. Наш мир настолько тесен,что один из разработчиков из Ozark, Марк Ботнер, теперь работает со мной (привет, Марк!).

В Command H.Q. вы сражаетесь один на один с другим игроком или против компьютера. Игра очень похожа на Empire, где вы завоевываете города, создаете воинские подразделения и совершаете набеги на вражескую территорию. Особенность игры заключается в том, что вы можете выбирать временной период, в который происходит игра. Если вы выберете прошедшие года, например 1918, то будете играть со старой технологией. Если вы выберете будущее, скажем 2023 год, в игре будут доступны войска будущего. Кроме того, игра поддерживает многопользовательский режим с соединением компьютеров через модем или через последовательный кабель.

В Command H.Q. есть несколько зацепок для привлечения внимания игрока. Во-первых — это управление ресурсами. Это одна из первых игр, в которые я играл, где в общую картину добавлена экономика. Контроль над месторождением нефти имеет такую же стратегическую ценность, как контроль над важным аванпостом. Второй приманкой является многопользовательский режим. Искуственный интеллект игры достаточно слаб, но многопользовательский режим компенсирует это недостаток, позволяя играть с живым соперником.

The Seven Cities of Gold

Если у вас был компьютер Commodore 64 или Atari 800, возможно вы играли в игру The Seven Cities of Gold. Это еще одна замечательная игра, созданная Ozark Softscape. Она была выпущена в 1987 году и имела оглушительный успех. В этой игре вы занимались исследованием Нового Света в роли Христофора Колумба или испанского конквистадора.

Игра очень интересная и захватывающая, поскольку вы исследуете Новый Свет постепенно. Вы начинаете с приобретения снаряжения и найма людей, которые будут помогать вам в путешествии. Как только вы подготовитесь, можно поднять паруса и плыть в Новый Cвет. Это путешествие интересно само по себе — если вы не найдете Новый Свет то умрете от голода! Если вы все-таки нашли Новый Свет, вам следует высадиться, основать миссию и наладить отношения с аборигенами. Здесь мы встречаемся еще с одной особенностью игры: вы можете встечать аборигенов мирно, либо можете обыскивать их деревни в поисках золота. Если вы пришли с миром, весть о вашей честности распространится, и другие деревни будут встречать вас тем же. Если вы убиваете аборигенов, весть о вашем предательстве распространится еще быстрее, и будущие встречи с аборигенами скорее всего закончатся кровопролитем.

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

X-COM: UFO Defense

Ни один список классических стратегических игр не может считаться полным бехз упоминания об X-COM. X-COM — это очень интересная игра в которой вы защищаете Землю от вторжения инопланетян. Главная часть игры представляет собой изометрический вид на действия боевой группы. Вы командуете группой бойцов, сражающихся против инопланетян в нескольких миссиях. Интерес игре придает развитие технологии. Вы начинаете игру с простейшим оборудованием, производимым земной промышленностью, а затем модернизируете его, изучая технологии инопланетян. С каждым сражением ваши солдаты становятся лучше и лучше, и в конце концов сравниваются с инопланетянами. Нет ничего более приятного, чем запустить управляемую ракету за угол, вниз по лестнице, а затем направо через дверной проем, чтобы поразить врага.

X-COM объединяет управление ресурсами и тактическую стратегию, предоставляя вам контроль над обороной всего мира. Вы должны перехватывать вражеские истребители, строить оборонные базы и уничтожать врагов на земле. Все это дает полезный опыт.

Единственным недостатком X-COM являются последовавшие продолжения. Первоначальная игра дает массу удовольствия, но продолжения не столь хороши.На этом язавершаю обзор первых стратегических игр. я мог бы продолжать еще и еще, но думаю, что у вас теперь есть достаточно примеров. Зачем я привел этот список игр? Чтобы дать вам идей для ваших собственных стратегических игр. Едва ли можно найти лучший способ начать разрабатывать игру, чем оглянуться назад и посмотреть, что люди создали до вас. История — великий учитель.


Глава 1 - Итоги

Итоги

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

  • Простой набор основных правил может сделать вашу игру интересной. Очень сложные системы игры не являются необходимыми. Главное — заинтересовать игрока.
  • Иногда простая идея может следать игру гораздо интереснее.
  • Планируя свою стратегию реального времени, подумайте о размещении выводимых данных. Не следует загромождать весь интерфейс текстом — это важный аспект разработки интерфейса.
  • Постарайтесь, чтобы игра не выглядела похожей на электронную таблицу. Для этого вы должны использовать графические представления числовых значений.
  • Чтобы игра была интересной и захватывающей, роскошная графика и спецэффекты не обязательны.
  • Разрабатывая свою игру убедитесь, что вы выполнили моделирование сражений между разными подразделениями. Это поможет выявить их сильные и слабые стороны, а также обнаружить критическую несбалансированность.
  • Игра с сотнями несбалансированных подразделений гораздо хуже, чем игра с десятком идеально сбалансированных подразделений.


Глава 2. Основы программирования в Windows

Краткий обзор

Все мы знаем, что многие программисты любят сразу переходить к написанию кода. Чтобы ваша заинтересованность не исчезла, я сейчас расскажу о том, как начать писать ваш первый каркас игры. Поскольку DirectX работает в Windows, сперва вы должны узнать как создать базовое приложение для Windows. В этой главе я раскрою следующие темы:

  • Архитектура программ Windows.
  • Как создать программу для Windows.

Если вы знакомы с программированием в Windows, можете пропустить эту главу. Если нет — усаживайтесь поудобнее, расслабьтесь и наслаждайтесь представлением.


Глава 2 - Архитектура программ Windows

Архитектура программ Windows

Если вы до Windows работали с другой операционной системой, такой как UNIX, Linux или DOS, новый стиль программирования может показаться несколько необычным. Сначала придется отказаться от функции main(), которая будет заменена функцией с именем WinMain(). Так же как в других операционных системах требовалось наличие функции main(), программам работающим в Windows необходима функция WinMain().

Работа, управляемая событиями

Следующее отличие программирования в Windows заключается в том, что программы для Windows управляются событиями. Это значит, что программа, вместо того, чтобы бежать за информацией, может бездельничать и ждать сообщений, поступающих ей через очередь сообщений. Все сообщения получает и обрабатывает обработчик сообщений (message handler). Элементы, обрабатываемые в обработчике сообщений обычно называются событиями (events).

События

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

Таблица 2.1. События Windows
Событие | Описание
WM_KEYDOWN | Это событие генерируется каждый раз, когда нажимается клавиша на клавиатуре. Ввод с клавиатуры жизненно необходим для большинства игр, поэтому данное сообщение очень важно.
WM_KEYUP | Это событие генерируется когда нажатая клавиша будет отпущена. Вам необходимо знать не только когда клавишу нажали, но и когда ее отпустили. Поэтому данное событие также важно.
WM_LBUTTONDOWN | Это событие генерируется когда пользователь нажимает левую кнопку мыши, если указатель мыши находится в пределах окна.
WM_LBUTTONUP | Это событие генерируется когда пользователь отпускает левую кнопку мыши, если указатель мыши находится в пределах окна.
WM_SETFOCUS | Это событие генерируется когда приложение получает фокус клавиатуры. Например, событие WM_SETFOCUS генерируется, когда вы щелкаете мышью по неактивному окну.
WM_SIZE | Это событие сообщает окну, что его размеры были изменены. Оно важно в ситуациях, когда для подгонки интерфейса к новому размеру окна требуется изменение расположения элементов.
СОВЕТ
Существует множество других событий. Я советую вам воспользоваться встроенной справкой Visual C++ и поискать описание всех сообщений, названия которых начинаются с WM_.

Структура программы

Если назначение обработчика событий все еще кажется вам немного странным, не волнуйтесь. Я пока не показал как он вписывается в общую картину. Рис. 2.1 поможет прояснить некоторые вещи.

Рис. 2.1. Структура обработки сообщений Windows

Рис. 2.1. Структура обработки сообщений Windows

На рис. 2.1 есть несколько областей, представляющих для нас интерес. Прежде всего обратите внимание на изображение окна программы. Под ним нарисована клавиатура, представляющая входные данные, которые поступают от пользователя.

Очередь событий

Вторая ключевая область называется очередь событий (event queue). С этого момента проявляются отличия программирования в Windows от программирования в других операционных системах. События, о которых я говорил ранее, помещаются в очередь событий. Почти всегда, когда программа Windows получает входные данные или уведомление от пользователя или операционной системы, происходит генерация события, которое сохраняется в очереди. Программы используют систему очередей, чтобы сообщения никогда не терялись. Кроме того, сообщение может храниться в очереди так долго, как требуется программе.

Если вы знакомы с программированием в операционных системах, отличающихся от Windows, то знаете что если программа занята, а пользователь в это время нажимает клавишу, данные о нажатии могут быть потеряны. Это происходит потому, что программа была занята чем-то другим и пропустила данное событие. В Windows сообщение клавиатуры посылается в очередь событий для хранения. Там оно находится в безопасности, пока не будет затребовано программой. Если вы подумаете об этом, то оцените все преимущества — больше не надо беспокоиться о потерянных данных.

События, помещенные в очередь обрабатываются по принципу «первым пришел — первым ушел (FIFO)». Это означает, что первое помещенное в очередь событие будет первым вытолкнуто из очереди, когда программа запросит следующее событие. В этом нет ничего революционного — всего лишь стандартная очередь.

На рис. 2.1 в очереди событий содержится три сообщения: WM_KEYDOWN, WM_KEYUP и WM_SIZE. Для разработчика они представляют три возможных действия пользователя. Сначала пользователь нажал клавишу и затем отпустил ее. После этого пользователь изменил размер окна.

Функция WinMain()

Следуя далее мы столкнемся с функцией WinMain(). Как я говорил ранее, она является неотъемлимой частью всех программ Windows, а также является первой функцией, вызываемой во время выполнения программы.

На рис. 2.1 под функцией WinMain() располагается блок условия с текстом «Событие для обработки?». Он изображает цикл обработки сообщений, являющийся стандартной частью большинства приложений для Windows. Обычно после того как вы инициализировали программу Windows, чтобы она отображала окно и необходимые кнопки, начинается ожидание событий. В большинстве случаев в коде программы присутствует тот или иной вид цикла, проверяющего наличие событий в очереди. На рисунке это изображено пунктирной линией, ведущей к очереди событий. Цикл обработки событий проверяет очередь, пока не обнаружит наличие в ней какой-либо информации.

Как только в очереди найдено событие, оно извлекается и передается для дальнейшей обработки обработчику сообщений.

Обработчик сообщений

Когда в своей программе вы создаете окно, то должны указать функцию, которая будет обрабатывать сообщения, поступающие от этого окна. Эта функция является действительным обработчиком сообщений. Итак мы пришли к следующему: обработчик сообщений — это обычная функция, получающая сообщения, направляемые вашей программе.

Обычно обработчик сообщений представляет собой одну гигантскую инструкцию switch, проверяющую каждый имеющий отношение к вашей программе тип сообщений, и затем выполняющую необходимые действия. Существует множество типов сообщений, которые никогда не будут докучать вам. Большинство моих игр проверяет только несколько ключевых типов сообщений.

СОВЕТ
В играх требуется проверка только нескольких типов событий, потому что большая часть входных данных обрабатывается через DirectX, а не через события Windows. При написании обычных приложений Windows вы будете использовать большее количество событий, чем при программировании игр.


Глава 2 - Пишем первую программу для Windows

Пишем первую программу для Windows

На этом я заканчиваю обсуждение теории, лежащей в основе программирования для Windows. Пришло время написать вашу первую программу для Windows. Во всех, рассматриваемых в этой книге примерах, я использую компилятор Microsoft Visual C++ 6.0. Если у вас еще нет этой программы, я настоятельно рекомендую пойти и приобрести ее.

СОВЕТ
Когда я проверял последний раз, версия Visual C++ 6.0 Standard стоила около 100 долларов США. Вероятно, вы сможете приобрести ее еще дешевле, присоединившись к программе скидок для студентов. Это хорошее вложение денег.

Начало работы с Visual C++ 6.0

Давайте продолжим. Запустите вашу копию Visual C++ 6.0. Вы должны увидеть экран, аналогичный представленному на рис. 2.2.

Рис. 2.2. Интерфейс Visual C++ 6.0

Рис. 2.2. Интерфейс Visual C++ 6.0

В интерфейсе, представленном на рис. 2.2, нет ничего особенного. Основные элементы управления сосредоточены в верхнем меню: File, Edit, View, Insert и т.д. Все, что вы делаете в Visual C++, должно быть связано с проектом. Проект — это верхний уровень иерархии в среде разработки.

Как создать проект

Лучший способ обучения — практика, так что давайте создадим новый проект, для чего щелкнем по меню File и выберем пункт New. Вам будет предоставлено множество вариантов, изображенных на рис. 2.3.

Рис. 2.3. Диалоговое окно создания нового проекта в Visual C++

Рис. 2.3. Диалоговое окно создания нового проекта в Visual C++

Диалоговое окно, которое вы видите, используется для указания типа создаваемого проекта. Как видно Visual C++ используется для создания всех типов программ, включая элементы управления ActiveX, COM-объекты, надстройки DevStudio и конечно же приложения Windows. Нас интересует тип приложений, который называется Win32 Application. Этот тип используется при создании программ для Windows. Выделите в списке пункт Win32 Application чтобы выбрать этот тип.

Перед тем, как двинуться дальше, вы должны сообщить Visual C++ имя вашего проекта и где он будет храниться. Эта информация вводится в текстовые поля Project name и Location. Для нашего примера в качестве имени проекта укажите CreateWindow.

СОВЕТ
Я храню весь исходный код в папке C:\Source\. Рекомендую вам создать аналогичную структуру каталогов, где будут храниться исходные тексты ваших программ. Лучше хранить весь код в единственном месте, чем разбрасывать его по всему жесткому диску.

После того, как вы введете требуемую информацию, щелкните по кнопке OK. Вам будет предложено диалоговое окно, показанное на рис. 2.4.

Рис. 2.4. Диалоговое окно выбора типа Windows-приложения

Рис. 2.4. Диалоговое окно выбора типа Windows-приложения

Диалоговое окно, представленное на рис. 2.4, предлагает три варианта:

  • An empty project — пустой проект;
  • A simple Win32 application — простое приложение Win32;
  • A typical "Hello World!" application — типичное приложение «Hello World».

Первый вариант, пустой проект, используется наиболее часто. Его выбор приводит к созданию пустого проекта в который не включены никакие объекты. Фактически создается чистый холст для последующей работы.

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

Третий вариант, типичное приложение «Hello World!», делает больше, чем два предыдущих. Он создает законченую программу для Windows, которая выводит текст «Hello World!». Я не использую этот вариант слишком часто, поскольку создаваемый код на мой вкус выглядит слишком загроможденным.

Выберите первый переключатель и щелкните по кнопке Finish, чтобы открыть диалоговое окно, изображенное на рис. 2.5.

Рис. 2.5. Диалоговое окно подтверждения выбранных параметров

Рис. 2.5. Диалоговое окно подтверждения выбранных параметров

Представленное диалоговое окно не содержит ничего необычного. Оно выводится только для того, чтобы вы могли удостовериться, что будет создан проект желаемого типа. Для продолжения работы щелкните по кнопке OK.

Рабочее пространство

Итак, вот ваш проект во всей славе! Теперь, после того как проект создан, настало время подробнее поговорить о нем. Взгляните на рис. 2.6.

Рис. 2.6. Пустое пространство проекта для вашей новой программы

Рис. 2.6. Пустое пространство проекта для вашей новой программы

Взгляните на рабочее пространство — область в левой части, содержащую имя вашего проекта со знаком «+» рядом с ним. Перейдите на вкладку FileView и разверните дерево, щелкнув по знаку «+». Вы увидите три подпапки в дереве файлов проекта:

  • Source Files — исходные файлы;
  • Header Files — заголовочные файлы;
  • Resource Files — файлы ресурсов.

Надеюсь, вы знаете как программировать на С или С++ и для чего предназначены исходные и заголовочные файлы. Если нет, я рекомендую вам приобрести книгу по программированию на С++ и изучить ее перед тем, как продолжать чтение. Без этих знаний вам придется очень трудно.

ПРИМЕЧАНИЕ
Рабочее пространство — это второй уровень иерархии в среде разработки. В начале цепочки расположен проект, затем следует рабочее пространство. Файлы вашего проекта расположены в конце цепочки. В одном проекте может быть несколько рабочих пространств, точно также как в одном рабочем пространстве может быть несколько исходных файлов.
Файлы ресурсов

Если вы умеете программировать, но являетесь новичком в программировании для Windows, вам может быть непонятно назначение папки Resource Files.

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

СОВЕТ
Ресурсы удобны, но не стоит включать в программу слишком много их. Если поступать так, полученные исполняемые файлы будут слишком большими. Это вызовет проблемы, когда для пользователей будет выпускаться обновление исполняемых файлов. Вместо того, чтобы загрузить компактный исполняемый файл, им придется загружать огромный файл, полный ресурсов.

Я не хочу потеряться среди связанных с ресурсами тем, так что давайте отправимся дальше. В вашем проекте пока нет файлов, поэтому в папках ничего не перечислено. Давайте исправим это, добавив в проект файл с исходным кодом на С++.

Добавление исходных файлов в проект

Раскройте меню Project и выберите пункт Project | Add To Project | New. В результате на экран будет выведено диалоговое окно, изображенное на рис. 2.7.

Рис. 2.7. Диалоговое окно для выбора типа добавляемого к проекту файла

Рис. 2.7. Диалоговое окно для выбора типа добавляемого к проекту файла

Фактически это то же самое диалоговое окно, что и изображенное на рис. 2.3, просто в нем выбрана другая вкладка. Вместо того, чтобы выбирать тип создаваемого проекта, сейчас мы будем выбирать тип добавляемого файла. Больше всего нас должны интересовать типы C/C++ Header File и C++ Source File. Перед тем, как продолжить, выберите тип C++ Source File.

Мы уже на финишной прямой, но еще не закончили. Перед тем, как щелкнуть по кнопке OK, вы должны задать имя создаваемого файла. Введите в качестве имени файла CreateWindow и щелкните по кнопке OK.

ПРИМЕЧАНИЕ
Вы не обязаны давать исходному файлу и проекту одинаковые имена. Я делаю это только ради организации информации. Не стесняйтесь называть файл с исходным кодом, как вы желаете. (Некоторые люди используют имя Main.)
СОВЕТ
Когда вы создаете новый файл для проекта к нему добавляется требуемое расширение. Вам не надо вводить его. Например, если вы создадите файл с исходным кодом C++ и укажете имя Main, на жестком диске он будет сохранен как файл Main.cpp.

Теперь, когда файл создан и добавлен к проекту, вы можете видеть его в окне рабочего пространства. Взгляните на рис. 2.8.

Рис. 2.8. Рабочее пространство с добавленным файлом

Рис. 2.8. Рабочее пространство с добавленным файлом

Я раскрыл папку Source Files, чтобы показать только что созданный файл CreateWindow.cpp. Когда вы дважды щелкаете по любому файлу, представленному в окне рабочего пространства, он загружается и выводится в области редактирования. На рис. 2.8 загружен файл CreateWindow.cpp. В данный момент он пуст и ждет, что вы добавите в него какой-нибудь текст.

Погружаемся и сталкиваемся с кодом

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

// Стандартный включаемый файл Windows
#include <windows.h>

// Прототип функции обратного вызова для обработки сообщений
LRESULT CALLBACK fnMessageProcessor (HWND, UINT, WPARAM, LPARAM);

// Функция вызывается автоматически, когда программа запускается
int WINAPI WinMain (HINSTANCE hInstance, HINSTANCE hPrevInstance, PSTR szCmdLine,
                    int iCmdShow)
{
   HWND         hWnd;
   MSG          msg;
   WNDCLASSEX   wndclass;

   // Настройка класса окна
   wndclass.cbSize        = sizeof(WNDCLASSEX);
   wndclass.style         = CS_HREDRAW | CS_VREDRAW;
   wndclass.lpfnWndProc   = fnMessageProcessor;
   wndclass.cbClsExtra    = 0;
   wndclass.cbWndExtra    = 0;
   wndclass.hInstance     = hInstance;
   wndclass.hIcon         = LoadIcon(NULL, IDI_APPLICATION);
   wndclass.hCursor       = LoadCursor(NULL, IDC_ARROW);
   wndclass.hbrBackground = (HBRUSH) GetStockObject (WHITE_BRUSH);
   wndclass.lpszMenuName  = NULL;
   wndclass.lpszClassName = "Window Class"; // Имя класса
   wndclass.hIconSm       = LoadIcon(NULL, IDI_APPLICATION);

   // Регистрация класса окна
   if(RegisterClassEx(&wndclass) == 0)
   {
       // Сбой программы, выход
       exit(1);
   }

   // Создание окна
   hWnd = CreateWindowEx(
      WS_EX_OVERLAPPEDWINDOW,
      "Window Class",            // Имя класса
      "Create Window Example",   // Текст заголовка
      WS_OVERLAPPEDWINDOW,
      0,
      0,
      320,
      200,
      NULL,
      NULL,
      hInstance,
      NULL);

   // Отображение окна
   ShowWindow(hWnd, iCmdShow);

   // Обработка сообщений, пока программа не будет прервана
   while(GetMessage (&msg, NULL, 0, 0))
   {
      TranslateMessage(&msg);
      DispatchMessage(&msg);
   }

   return (msg.wParam);
}

// Функция обратного вызова для обработки сообщений
// (НЕОБХОДИМА ВСЕМ ПРОГРАММАМ ДЛЯ WINDOWS)
LRESULT CALLBACK fnMessageProcessor (HWND hWnd, UINT iMsg, WPARAM wParam, LPARAM lParam)
{
   switch(iMsg)
   {
      // Вызывается, когда впервые создается окно
      case WM_CREATE:
         return(0);
      // Вызывается, когда окно обновляется
      case WM_PAINT:
         return(0);
      // Вызывается, когда пользователь закрывает окно
      case WM_DESTROY:
         PostQuitMessage(0);
         return(0);
      default:
         return DefWindowProc(hWnd, iMsg, wParam, lParam);
   }
}

Включение файлов и прототипы функций

В нескольких первых строках кода указываются включаемые заголовочные файлы и приводятся прототипы функций. В программировании для Windows необходим только один заголовочный файл с именем windows.h.

Для нашего примера требуется единственный прототип функции для обработчика сообщений. Любая программа для Windows должна содержать функцию, которая будет вызываться для обработки сообщений и должна соответствовать следующему прототипу:

LRESULT CALLBACK fnMessageProcessor (HWND, UINT, WPARAM, LPARAM);

Имя функции может отличаться, но параметры должны оставаться неприкосновенными. Это вызвано тем, что Windows автоматически вызываеет данную функцию и не сможет работать правильно, если вы измените параметры.

Внутреннее устройство функции WinMain()

Первый элемент, упоминаемый в функции WinMain() - это объект, используемый для создания окна. Объект является структурой типа WNDCLASSEX и очень важен для создания окна вашей программы.

Структура данных WNDCLASSEX

Если в прошлом вы уже занимались программированием для Windows, то могли использовать структуру WNDCLASS. Между ней и объектом WNDCLASSEX есть только пара отличий. Во-первых добавлен апраметр, задающий размер структуры в байтах. Во-вторых, добавлен параметр задающий дескриптор маленького значка для окна.

Ниже приведен прототип структуры WNDCLASSEX:

typedef struct _WNDCLASSEX {
   UINT    cbSize;
   UINT    style;
   WNDPROC lpfnWndProc;
   int     cbClsExtra;
   int     cbWndExtra;
   HANDLE  hInstance;
   HICON   hIcon;
   HCURSOR hCursor;
   HBRUSH  hbrBackground;
   LPCTSTR lpszMenuName;
   LPCTSTR lpszClassName;
   HICON   hIconSm;
} WNDCLASSEX;

Нравится ли вам встеча со сложной структурой, назначение членов которой надо запомнить? Нет! Хорошо хоть Microsoft позаботилась, чтобы имена этих переменных было просто читать. Первая переменная типа UINT называется cbSize. Она используется для указания размера структуры данных. Обычно для инициализации этого члена структуры используется выражение sizeof(WNDCLASSEX). Инициализацию этого члена данных вы можете увидеть взглянув на приведенный выше листинг.

ПРИМЕЧАНИЕ
Если вам непонятно ключевое слово UINT — оно обозначает целое число без знака. Это означает, что переменная может принимать только положительные значения.

Второй элемент структуры также имеет тип UINT и называется style. Как указывает имя, член style используется для задания стиля создаваемого окна. Удобство данного члена данных в том, что вы можете указывать комбинации одних стилей с другими, используя несколько флагов, объединеных поразрядной операцией ИЛИ (|). Доступные стили перечислены в таблице 2.2.

Таблица 2.2. Стили окон
Значение | Действие
CS_BYTEALIGNCLIENT | Выравнивает клиентскую область окна (область с содержимым) по границе байта в направлении оси X. Оказывает влияние на позицию окна по горизонтали и на его ширину. Лично я никогда не использовал этот стиль.
CS_BYTEALIGNWINDOW | Выравнивает окно по границе байта в направлении оси X. Оказывает влияние на границу окна по горизонтали и на его ширину. Этот стиль я также не использую.
CS_CLASSDC | Размещает в структуре класса единый контекст устройства для использования всеми окнами. Я пока не рассказывал о контекстах устройств, но сделаю это позже, так что нет причин для волнения. В Windows можно создать несколько классов окон одного и того же типа в разных потоках. Так как у вас может быть несколько копий класса, несколько потоков могут попытаться использовать контекст устройства одновременно. Это вызовет блокировку всех потоков, кроме одного, пока этот поток не завершит работу с контекстом.
CS_DBLCLKS | Очень простой стиль. Когда он указан, Windows будет посылать вашему окну сообщение о двойном щелчке каждый раз, когда пользователь выполняет двойной щелчок кнопкой мыши в пределах области окна. Это может показаться глупым, но многие приложения запоминают время каждого щелчка кнопки мыши, чтобы определить был ли выполнен двойной щелчок. Я, чтобы добиться того же, просто использую данный стиль.
CS_GLOBALCLASS | Этот стиль разрешает создание глобального класса окна. Чтобы получить дополнительную информацию, обратитесь к документации, поставляемой с Microsoft Visual C++. При разработке игр нет необходимости использовать данный стиль.
CS_HREDRAW | Этот стиль заставляет перерисовывать все окно в случае изменения его ширины.
CS_NOCLOSE | Запрещает выполнение закрытия окна через системное меню.
CS_OWNDC | Выделяет уникальный контекст устройства для каждого окна, созданного с использованием класса.
CS_PARENTDC | Устанавливается, когда дочернее окно использует ту же самую область отсечения, что и родительское. Это позволяет дочернему окну рисовать на родительском. Это не означает, что потомок использует контекст устройства родителя. В действительности потомок получает свой собственный контекст устройства из системного пула. Единственное, что делает этот флаг — увеличение производительности приложения.
CS_SAVEBITS | Сохраняет в памяти растровое изображение находящейся под окном области экрана. В дальнейшем при перемещении окна скрытая область копируется на экран. Это предотвращает отправку сообщения WM_PAINT всем окнам, которые были скрыты данным.
CS_VREDRAW | Заставляет перерисовывать все содержимое окна в случае изменения высоты окна.

Третий член структуры имеет тип WNDPROC, является указателем на функцию и называется lpfnWndProc. Помещаемый сюда указатель должен указывать на функцию обработки сообщений Windows, которую окно использует чтобы принимать сообщения. Это очень важно, и функция, на которую ссылаются здесь должна полностью соответствовать прототипу, приведенному в моем коде. Взгляните на рис. 2.9, чтобы увидеть взаимоотношения между классом и обработчиком сообщений.

Рис. 2.9. Взаимоотношения между классом окна и обработчиком сообщений

Рис. 2.9. Взаимоотношения между классом окна и обработчиком сообщений

Четвертый член структуры относится к типу int и называется cbClsExtra. Это целое число задает количество байт, которые будут выделены сразу за структурой данных класса окна. Я не имею ни малейшего представления, для чего это может быть нужно, поскольку во всех примерах, которые я видел, это значение устанавливалось равным 0. Я рекомендую вам поступать точно так же. Если желаете, можете просто игнорировать этот член данных, поскольку система сама по умолчанию присваивает ему нулевое значение.

Пятый элемент также относится к типу int и называется cbWndExtra. Это целое число задает количество байтов, которые будут выделены сразу за экземпляром окна. Работа с ним аналогична обращению с предыдущим членом структуры, и система также по умолчанию присваивает ему нулевое значение.

Шестой член структуры имеет тип HANDLE и называется hInstance. Дескриптор, который вы задаете здесь, является дескриптором экземпляра к которому относится окнонная процедура класса. В большинстве случаев можно задать значение дескриптора hInstance, получаемого функцией WinMain() в одном из параметров.

Седьмой параметр называется hIcon и имеет тип HICON. Тип HICON это ни что иное, как замаскированный тип HANDLE, поэтому он не должен смущать вас. Данный дескриптор указывает на класс значка, используемого окном. Класс значка в действительности является ресурсом значка. Не присваивайте этой переменной значение NULL, поскольку если сделать так, то программа будет перерисовывать изображение значка, при каждом свертывании окна.

Если вы еще раз взглянете на код, то увидите, что для инициализации седьмого параметра я использую вызов функции LoadIcon().

Функция LoadIcon() загружает ресурс значка из исполняемой программы. Хотя ресурсы компилируются внутрь вашей программы для Windows, вам все равно необходимо загружать их, поэтому и требуется вызов данной функции. Вот как выглядит ее прототип:

HICON LoadIcon(
   HINSTANCE hInstance,
   LPCTSTR lpIconName
);

К счастью, у этой функции всего два прарметра — HINSTANCE и LPCTSTR. Первый параметр с именем hInstance, содержит дескриптор экземпляра модуля чей исполняемый файл содержит значок, который вы желаете использовать. В моих примерах программ я присваиваю этому параметру значение NULL. Делая так вы разрешите использование встроенных значов Windows, которые часто также называют стандартными значками.

Второй параметр является указателем на строку, содержащую имя загружаемого значка. Взглянув на разбираемый пример, вы увидите, что для инициализации данного параметра я использую константу IDI_APPLICATION. Ее значение соответствует значку, используемому по умолчанию для приложений, который вы могли видеть во многих программах для Windows. Остальные стандартные значения перечислены в таблице 2.3.

Таблица 2.3. Константы для стандартных значков
Значение | Описание
IDI_APPLICATION | Значок, используемый по умолчанию для приложений. Он применяется и в рассматриваемом примере. В большинстве случаев вы можете использовать это значение, если только не хотите, чтобы у вашего приложения был нестандартный значок.
IDI_ASTERISK | Данное значение создает для вашей программы значок в виде небольшого овала с буквой «i» внутри.
IDI_ERROR | Красный круг с крестом внутри.
IDI_EXCLAMATION | Желтый треугольник с восклицательным знаком внутри.
IDI_HAND | Я понятия не имею, для чего нужны эти дубли, но этот значок выглядит точно так же, как IDI_ERROR.
IDI_INFORMATION | Еще один дубль. Это значение задает небольшой овальный значок, аналогичный задаваемому значением IDI_ASTERISK.
IDI_QUESTION | Использование этого значения даст вашему приложению значок с вопросительным знаком.
IDI_WARNING | О, опять дублирование. На этот раз значок выглядит так же как для значения IDI_EXCLAMATION.
IDI_WINLOGO | Если использовать эту константу, значок вашего приложения будет выглядеть как небольшой аккуратный логотип Windows.

Вот и все, что можно сказать о функции LoadIcon(). Теперь вернемся к разбираемой программе. Ох, подождите, прежде чем продолжить, взгляните на рис. 2.10, где изображены некоторые стандартные значки.

Рис. 2.10. Некоторые стандартные значки Windows

Рис. 2.10. Некоторые стандартные значки Windows. ©2002 Microsoft, All Rights Reserved.

Восьмой член структуры данных WNDCLASSEX очень похож на седьмой, за исключением того, что он задает используемый окном курсор. Его тип HCURSOR, а имя — hCursor. Тип HCURSOR это еще один замаскированный дескриптор. Обычно здесь указывается значение дескриптора класса курсора, который будет использован в вашей программе для ее собственного курсора. Но вместо того, чтобы создавать нестандартный курсор, я воспользуюсь функцией LoadCursor().

Функция LoadCursor() похожа на функцию LoadIcon() за исключением того, что она загружает ресурсы курсора, а не ресурсы значка. Вот ее прототип:

HCURSOR LoadCursor(
   HINSTANCE hInstance,
   LPCTSTR lpCursorName
);

Первый параметр называется hInstance, и содержит дескриптор экземпляра модуля, чей исполняемый файл содержит курсор, который вы собираетесь использовать. В своем примере я присваиваю данному параметру значение NULL. Это позволяет использовать встроенные курсоры Windows. Их также часто называют стандартными курсорами. Не чувствуете ли вы, что уже читали что-то похожее?

Второй параметр — это указатель на строку, содержащую имя загружаемого курсора. Как можно видеть, в рассматриваемом примере я присваиваю этому параметру значение IDC_ARROW. Оно соответствует стандартному курсору Windows, который вы могли видеть во многих программах. Оставшиеся стандартные значения перечислены в таблице 2.4.

Таблица 2.4. Константы для стандартных курсоров
Значение | Описание
IDC_APPSTRING | Это курсор в форме стандартной стрелки с присоединенными к ней песочными часами. Обычно, данный курсор устанавливается, когда ваша программа занята.
IDC_ARROW | Стандартный курсор Windows.
IDC_CROSS | Создает курсов, выглядящий как перекрестье прицела.
IDC_HELP | Этот курсор выглядит как стандартная стрелка с присоединенным к ней вопросительным знаком. Его хорошо использовать, когда пользователю предоставляется возможность задать вопрос.
IDC_IBEAM | Курсор в форме буквы «I». Обычно используется в режиме ввода и редактирования текста.
IDC_NO | Курсор в виде перечеркнутого круга. Его можно использовать, когда пользователь наводит курсор на область, которая не реагирует на щелчки кнопок мыши.
IDC_SIZEALL | Курсор с перекрещенными стрелками. Применяется, когда пользователь изменяет размер окна или графического элемента.
IDC_SIZENESW | Еще один курсор для изменения размера. В отличие от предыдущего курсора, у которого стрелки направлены во все четыре стороны, здесь стрелки направлены только на северо-восток и юго-запад.
IDC_SIZENS | То же, что и предыдущий курсор, но стрелки направлены на север и на юг.
IDC_SIZENWSE | То же, что и предыдущие два курсора, но стрелки направлены на северо-запад и юго-восток.
IDC_SIZEWE | Еще один курсор со стрелками. В данном случае они направлены на запад и на восток.
IDC_UPARROW | Курсор в виде стрелки, направленной вверх.
IDC_WAIT | Курсор в виде песочных часов. Я рекомендую использовать этот курсор в тех случаях, когда ваша программа занята и пользователь может только ждать пока она не закончит работу.

После того, как вы освоились с относящейся к курсорам частью структуры данных окна, пришло время поговорить о цвете фона. Чтобы задать цвет фона вашего окна, вы просто указываете желаемый цвет в члене данных hbrBackground. Это девятый элемент структуры, и он имеет тип HBRUSH. Возможно, вы подумали, что тип HBRUSH это всего лишь дескриптор класса кисти. Но этот член данных более гибкий и позволяет задать как дескриптор кисти, которая будет использоваться, так и значение используемого цвета. Существует одно требование — задавая цвет, вы должны использовать одно из следующих значений:

  • COLOR_ACTIVECAPTION
  • COLOR_APPWORKSPACE
  • COLOR_BACKGROUND
  • COLOR_BTNFACE
  • COLOR_BTNSHADOW
  • COLOR_BTNTEXT
  • COLOR_CAPTIONTEXT
  • COLOR_GRAYTEXT
  • COLOR_HIGHLIGHT
  • COLOR_HIGHLIGHTTEXT
  • COLOR_INACTIVEBORDER
  • COLOR_INACTIVECAPTION
  • COLOR_MENU
  • COLOR_MENUTEXT
  • COLOR_SCROLLBAR
  • COLOR_WINDOW
  • COLOR_WINDOWFRAME
  • COLOR_WINDOWTEXT

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

wndclass.hbrBackground = (HBRUSH)COLOR_GRAYTEXT;

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

Функция GetStockObject() часто используется для того, чтобы получить дескриптор одной из встроенных кистей, шрифтов, палитр или перьев. Дело в том, что в Windows есть несколько предопределенных типов, которые могут быть использованы в ваших приложениях. Давайте взглянем на прототип функции:

HGDIOBJ GetStockObject(
    int fnObject
);

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

Моей программе посчастливилось использовать встроенный объект WHITE_BRUSH. Он окрашивает фон окна в белый цвет. В таблице 2.5 приведены еще несколько значений, которые можно использовать в качестве аргумента функции GetStockObject().

Таблица 2.5. Предопределенные объекты Windows
Значение | Описание
BLACK_BRUSH | Соответствует названию. Объект предоставляет кисть, рисующую черным цветом.
DKGRAY_BRUSH | Темно-серая кисть.
GRAY_BRUSH | Серая кисть.
HOLLOW_BRUSH | Возможно вы смотрели фильм «Человек-невидимка»? (Если не смотрели, не волнуйтесь — все равно фильм плохой.) Эта кисть невидима для пользователя, так же как и человек-невидимка. Это означает, что при использовании данной кисти не появляется никаких цветов. Аналогичным образом действует кисть NULL_BRUSH.
LTGRAY_BRUSH | Светло-серая кисть.
NULL_BRUSH | То же самое, что и HOLLOW_BRUSH.
WHITE_BRUSH | Белая кисть. Именно она используется в моем примере.
BLACK_PEN | Черное перо. Перья не влияют на цвет фона. Вы должны использовать их для задания цвета текста.
WHITE_PEN | Белое перо.
ANSI_FIXED_FONT | Объект устанавливает моноширинный системный шрифт.
ANSI_VAR_FONT | Объект устанавливает системный шрифт с переменной шириной символов.
DEFAULT_GUI_FONT | Устанавливается заданный по умолчанию системный шрифт.
DEFAULT_PALETTE | Объект установленной по умолчанию палитры.

Десятый член структуры данных WNDCLASSEX — это завершающаяся нулевым символом строка с именем lpszMenuName. Она содержит имя ресурса меню, используемого окном. В моих окнах нет меню, поэтому я присваиваю данной переменной значение NULL.

Одиннадцатый член структуры данных также является строкой, которая должна завершаться нулевым символом. Его имя — lpszClassName. Как сказано в имени, эта строка используется для задания имени класса окна. Имя класса является уникальным идентификатором типа класса. Поэтому очень важно, чтобы вы не использовали заданное здесь имя для других классов вашей программы.

Двенадцатый, и последний, член структуры WNDCLASSEX — это переменная с именем hIconSm. Она аналогична члену данных hIcon, за исключением того, что здесь задается используемый программой маленький значок. В моем примере для этого применяется уже знакомая вам функция LoadIcon().

Ну что? Мы завершили изучение структуры данных WNDCLASSEX! Теперь пришло время зарегистрировать ее.

Функция RegisterClassEx()

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

if(RegisterClassEx(&wndclass) == 0)
{
    // Сбой программы, выход
    exit(1);
}

Вызов функции RegisterClassEx() необходим, чтобы потом мы смогли создать окно. Эта функция регистрирует класс в системе Windows. Если класс не зарегистрирован, то вы не сможете использовать его для создания окна.

Функция очень проста. Вот ее прототип:

ATOM RegisterClassEx(
    CONST WNDCLASSEX *lpwcx
);

Первый и единственный необходимый для нее параметр является указателем на структуру данных WNDCLASSEX. Здесь все просто, — в нашем примере достаточно только передать функции значение &wndclass.

Не следует слишком волноваться из-за того, что функция возвращает значение типа ATOM. Важно только проверить равно возвращаемое функцией значение NULL или нет. Если возвращаемое функцией RegisterClassEx() значение не равно нулю, ее выполнение завершилось успешно.

Итак, класс окна зарегистрирован, и программа переходит к действительному созданию окна.

Функция CreateWindowEx()

Для создания окна используется функция CreateWindowEx(). Ее можно применять для создания дочерних, всплывающих или перекрывающихся окон. При создании окна вы указываете используемый класс, имя приложения и некоторые другие параметры. Прототип функции выглядит следующим образом:

HWND CreateWindowEx(
    DWORD dwExStyle,
    LPCTSTR lpClassName,
    LPCTSTR lpWindowName,
    DWORD dwStyle,
    int x,
    int y,
    int nWidth,
    int nHeight,
    HWND hWndParent,
    HMENU hMenu,
    HINSTANCE hInstance,
    LPVOID lpParam
);

Первый параметр имеет тип DWORD и называется dwExStyle. Он похож на определяющий стиль член структуры WNDCLASSEX, но задает дополнительные стили окна. Список доступных дополнительных стилей приведен в таблице 2.6.

Таблица 2.6. Дополнительные стили окна
Стиль | Описание
WS_EX_ACCEPTFILES | Окно может получать файлы с использованием механизма перетаскивания.
WS_EX_APPWINDOW | Заставляет окно перемешаться на верхний уровень панели задач, когда оно видимо.
WS_EX_CLIENTEDGE | Окно обрамлено рамкой, чтобы клиентская область выглядела углубленной.
WS_EX_CONTROLPARENT | Позволяет переключаться между дочерними окнами с помощью клавиши TAB.
WS_EX_DLGMODALFRAME | Окно будет обрамлено двойной рамкой.
WS_EX_LEFT | Окно выровнено по левой границе. Это значение по умолчанию.
WS_EX_LEFTSCROLLBAR | Для некоторых языков полосу прокрутки следует располагать слева от текста. В таких случаях и применяется этот стиль.
WS_EX_LTRREADING | Отображаемый в окне текст читается слева направо. Это значение по умолчанию.
WS_EX_NOPARENTNOTIFY | Подавляет сообщение WM_PARENTNOTIFY отправляемое дочерним окном родительскому при создании или уничтожении. Применяется для дочерних окон.
WS_EX_OVERLAPPEDWINDOW | Комбинация флагов WS_EX_CLIENTEDGE и WS_EX_WINDOWEDGE.
WS_EX_PALETTEWINDOW | Комбинация флагов WS_EX_WINDOWEDGE, WS_EX_TOOLWINDOW и WS_EX_TOPMOST.
WS_EX_RIGHT | Содержимое окна выравнивается по правой границе. Это необходимо для некоторых языков.
WS_EX_RIGHTSCROLLBAR | Полоса прокрутки располагается справа от клиентской части окна. Это значение по умолчанию.
WS_EX_RTLREADING | В некоторых языках текст читают справа налево. Для таких языков использование данного стиля позволит системе отображать символы в окне справа налево.
WS_EX_STATICEDGE | Создает окно, предназначенное для элементов, которые не будут получать входные данные от пользователя.
WS_EX_TOOLWINDOW | Создает окно, выглядящее как панель инструментов.
WS_EX_TOPMOST | Окно будет оставаться самым верхним на рабочем столе, независимо от того, активно оно или нет.
WS_EX_WINDOWEDGE | У окна будет рамка с рельефной гранью.

В рассматриваемом примере я указываю в первом параметре дополнительный стиль WS_EX_OVERLAPPEDWINDOW. Благодаря использованию этого стиля, окно программы будет выглядеть подобно большинству приложений Windows.

Второй параметр представляет собой завершающуюся нулевым символом строку, содержащую имя класса для создаваемого окна. Все, что необходимо сделать — указать то же имя, которое вы использовали при инициализации члена lpszClassName структуры WNDCLASSEX. В моем примере я использую строку «Window Class».

Третий параметр — это еще одна завершающаяся нулевым символом строка. Вместо того, чтобы определять имя класса, она задает текст, который будет выведен в заголовке окна. Вы можете назвать свою программу как пожелаете, а я свою назвал «Create Window Example».

Четвертый параметр, dwStyle, позволяет задать различные комбинации стилей для вашего окна. Доступные стили перечислены в таблице 2.7.

Таблица 2.7. Cтили окна
Стиль | Описание
WS_BORDER | Окно обрамлено тонкой рамкой.
WS_CAPTION | У окна есть заголовок.
WS_CHILD | Окно является дочерним. Этот стиль нельзя использовать вместе со стилем WS_POPUP.
WS_CHILDWINDOW | То же самое, что и WS_CHILD.
WS_CLIPCHILDREN | Предотвращает рисование в тех областях окна, которые заняты дочерними окнами.
WS_CLIPSIBLINGS | Используется только для дочерних окон. Если дочернее окно, для которого был установлен этот стиль перекрывается другими дочерними окнами, то при обновлении содержимого окна будет перерисовываться только та часть, которая не закрыта другими окнами.
WS_CLIPSIBLINGS | Окно заблокировано и не может принимать данные от пользователя.
WS_DLGFRAME | Стиль для диалоговых окон.
WS_GROUP | Указывает, что данное окно является первым в группе окон. Используется для упорядочивания окон. Первое окно, у которого установлен стиль WS_GROUP начинает группу, после чего все последующие окна будут добавляться в ту же группу, пока снова не встретится окно с установленным стилем WS_GROUP (оно начнет следующую группу).
WS_HSCROLL | Окно с горизонтальной полосой прокрутки.
WS_ICONIC | После создания окно отображается в свернутом виде.
WS_MAXIMIZE | После создания окно отображается развернутым на весь экран.
WS_MAXIMIZEBOX | В заголовке окна есть кнопка для развертывания на весь экран.
WS_MINIMIZE | То же самое, что и стиль WS_ICONIC.
WS_MINIMIZEBOX | В заголовке окна есть кнопка для свертывания окна.
WS_OVERLAPPED | Перекрывающееся окно с заголовком и рамкой.
WS_OVERLAPPEDWINDOW | Окно с комбинацией стилей WS_OVERLAPPED, WS_CAPTION, WS_SYSMENU, WS_THICKFRAME, WS_MINIMIZEBOX и WS_MAXIMIZEBOX.
WS_POPUP | Стиль для всплывающих окон.
WS_POPUPWINDOW | Всплывающее окно с комбинацией стилей WS_BORDER, WS_POPUP и WS_SYSMENU.
WS_SIZEBOX | Окно может изменять размер.
WS_SYSMENU | У окна есть системное меню, вызываемое при щелчке правой кнопкой мыши по заголовку. Чтобы этот стиль работал правильно, он должен использоваться совместно со стилем WS_CAPTION.
WS_TABSTOP | Окно может стать активным при нажатии на клавишу TAB.
WS_THICKFRAME | То же самое, что и стиль WS_SIZEBOX.
WS_TILED | То же самое, что и стиль WS_OVERLAPPED.
WS_VISIBLE | После создания окно будет видимым.
WS_VSCROLL | Окно с вертикальной полосой прокрутки.

В программе CreateWindow в этом параметре я указываю стиль WS_OVERLAPPEDWINDOW. Это придает окну вид, похожий на большинство других приложений Windows.

Пятый параметр функции CreateWindowEx() — это целое число, задающее позицию окна на экране по горизонтали. Если вы создаете дочернее окно, координата отсчитывается относительно координаты по горизонтали родительского окна. Поскольку в примере нет дочерних окон, используемое значение координаты, 0, приведет к размещению окна вплотную к левой границе экрана.

Шестой параметро задает позицию окна на экране по вертикали. Подобно позиции по горизонтали, позиция по вертикали представляет координату на экране только в том случае, если окно не является дочерним. Для дочернего окна это значение задает смещение относительно позиции по вертикали родительского окна. Чтобы лучше понять эту концепцию взгляните на рис. 2.11.

Рис. 2.11. Координаты окна определяют его позицию в зависимости от того, является окно дочерним или нет

Рис. 2.11. Координаты окна определяют его позицию в зависимости от того, является окно дочерним или нет

Значения координат обоих окон на рис. 2.11 равны (10,10). Большее окно является родительским, поэтому его координаты (10,10) задают размещение окна почти вплотную к левой и верхней границам экрана. Меньшее окно является дочерним. Поэтому его координаты являются смещениями относительно родительского окна. Это означает, что координаты (10,10) в действительности представляют (parentx+10,parenty+10) или (20,20) в системе координат экрана.

Седьмой параметр создающей окно функции задает ширину окна в пикселах. В нашем примере ширина окна равна 320 пикселам.

Восьмой параметр задает высоту окна в пикселах. В примере высота окна равна 200 пикселам.

Девятый параметр функции указывает родителя данного окна. Он полезен, когда создается приложение с несколькими окнами. Поскольку в рассматриваемом примере есть только одно главное окно, данному параметру присваивается значение NULL чтобы указать, что у окна нет родителя.

Десятый параметр используется для задания дескриптора связанного с окном меню. В примере нет никаких меню, поэтому данному параметру присваивается значение NULL.

Одиннадцатый параметр используется для задания дескриптора экземпляра модуля. В этом параметре вы передаете дескриптор экземпляра, полученный функцией WinMain() в одном из ее параметров.

Последний параметр используется для указания дополнительных данных создаваемого окна. Мне очень редко приходилось использовать этот параметр, поэтому в большинстве приложений я присваиваю ему значение NULL.

Ничего себе! Вы проделали почти всю работу, необходимую для отображения окна на экране. На рис. 2.12 показан ход выполнения программы до настоящего момента.

Рис. 2.12. Ход выполнения кода приложения до вызова функции CreateWindowEx()

Рис. 2.12. Ход выполнения кода приложения до вызова функции CreateWindowEx()

Функция ShowWindow()

Верите или нет, но факт создания окна не приводит к его отображению. (Эти парни из Microsoft иногда такие странные!) Чтобы окно было действительно выведено на экран вы должны вызвать функцию ShowWindow(). К счастью, она достаточно прямолинейна. Вот ее прототип:

BOOL ShowWindow(
    HWND hWnd,
    int nCmdShow
);

Первый параметр задает дескриптор отображаемого окна. Это действительно просто, поскольку дескриптор уже подготовлен функцией, создавшей окно. Взгляните на пример, чтобы увидеть как я передаю дескриптор, возвращенный функцией CreateWindow().

Второй параметр — это целое число, определяющее как будет отображаться окно. Вот и пришло время для еще одной таблицы. Значения, которые можно использовать в этом параметре перечислены в таблице 2.8.

Таблица 2.8. Значения для функции ShowWindow()
Значение | Описание
SW_HIDE | Окно скрыто и затем окно активируется.
SW_MAXIMIZE | Окно развернуто на весь экран.
SW_MINIMIZE | Окно свернуто и затем окно активируется.
SW_RESTORE | Восстанавливает окно из свернутого или развернутого состояния. Это полезно, если требуется вернуть окну его первоначальные размеры и местоположение.
SW_SHOW | Активирует окно и отображает его.
SW_SHOWMAXIMIZED | Окно активируется и отображается развернутым на весь экран.
SW_SHOWMINIMIZED | Окно активируется и отображается свернутым.
SW_SHOWNA | Окно отображается в его текущем состоянии. Не оказывает никакого влияния если окно в данный момент активно.
SW_SHOWNORMAL | Отображает окно в его нормальном состоянии. Это значение используется, когда функция ShowWindow() вызывается в первый раз.

Хотя есть много значений, которые вы можете указать во втором параметре, простейший способ заключается в передаче значения целочисленной переменной iCmdShow, которая является одним из параметров функции WinMain(). Именно так я и поступаю в коде примера.

Итак, ваша программа полностью завершила свою основную задачу — отображение окна. Однако, она пока не может принимать никаких входных данных. Здесь в игру вступает код цикла обработки сообщений.

Получение сообщений функцией GetMessage()

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

Чтобы проверить наличие ожидающих обработки сообщений вызывается функция GetMessage(). Вот ее прототип:

BOOL GetMessage(
    LPMSG lpMsg,
    HWND hWnd,
    UINT wMsgFilterMin,
    UINT wMsgFilterMax
);

В первом параметре функция ожидает указатель на объект MSG. Структура данных MSG содержит всю информацию о любом обнаруженном сообщении.

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

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

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

Трансляция сообщений функцией TranslateMessage()

Перед тем, как отправить сообщение в вашу очередь сообщений, вы должны транслировать его в символьные данные. Это делает функция TranslateMessage(). Для нее требуется единственный параметр — указатель на транслируемое сообщение. В примере я передаю функции адрес переменной, содержащей сообщение.

После того, как сообщение транслировано в символьные данные, вы можете поместить его в очередь сообщений с помощью функции DispatchMessage().

Помещение сообщений в очередь функцией DispatchMessage()

Подобно функции TranslateMessage(), функция DispatchMessage() требует единственный параметр. Цель этой функции — отправить прошедшее трансляцию сообщение в очередь сообщений программы. После вызова этой функции сообщение попадает в функцию обработки сообщений.

Последняя строка кода функции WinMain() возвращает значение wParam последнего сообщения Windows извлеченного функцией получения сообщений. Как говорил поросенок Порки, «Вот и все, ребята!».

Функция обработки сообщений

Вы вероятно задаетесь вопросом, где обрабатываются сообщения. Когда функция DispatchMessage() отправляет сообщения, они направляются функции обработки сообщений, указанной в классе окна. В рассматриваемом примере все сообщения отправляются написанной мной функции fnMessageProcessor().

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

Мой пример проверяет сообщения WM_CREATE, WM_DESTROY и WM_PAINT. Дополнительная работа производится только для сообщения WM_DESTROY.

Когда приложение получает сообщение WM_DESTROY, оно вызывает функцию PostQuitMessage(), которая завершает работу программы Windows. Это происходит, когда вы завершаете работу, щелкнув по кнопке закрытия окна.

Если полученное сообщение не соответствует ни одному из проверяемых программой, оно возвращается функции WinMain() с помощью функции DefWindowProc(). Это стандартная практика, поэтому я не рекомендую пытаться изменить ее.

Рассматриваемый пример проверяет лишь несколько типов сообщений, но примеры, встечающиеся дальше в этой книге проверяют гораздо больше сообщений. Помните об этом, когда будете просматривать код последующих примеров — вы можете столкнуться с абсолютно незнакомыми типами сообщений.

Компиляция и выполнение кода

Итак, ваша программа целиком введена и вы готовы идти дальше. Чтобы скомпилировать программу в Visual C++ 6.0 вам необходимо сделать активным окно с компилируемой программой и нажать клавишу F7. Если все закончится успешнно, то в окне с выходными данными компилятора вы увидите следующий текст:

---------Configuration: CreateWindow - Win32 Debug------------
Compiling...
CreateWindow.cpp
Linking...
CreateWindow.exe - 0 error(s), 0 warning(s)

Если во время компиляции будут обнаружены какие-либо ошибки, заново проверьте код и убедитесь, что все набрано правильно. Когда будут исправлены все ошибки, вы сможете запустить программу, нажав комбинацию клавиш Ctrl+F5. В результате начнет выполняться программа активного проекта. На рис. 2.13 показано, как должна выглядеть работающая программа рассматриваемого примера.

Рис. 2.13. Результат выполнения программы CreateWindow

Рис. 2.13. Результат выполнения программы CreateWindow


Глава 2 - Итоги

Итоги

Разве вы не чуствуете себя лучше, узнав о том, как создать и отобразить окно? Я знаю, что это не работающая игра, но надо же с чего-то начинать. В данной главе не содержится много советов, поэтому приводимый список будет коротким и простым.

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

  • Следите, чтобы имя класса окна было уникальным. Это важно поскольку имя класса определяет тип создаваемого окна.
  • Убедитесь, что ваша программа никогда не попадает в длительный цикл без обработки сообщений Windows. Если вы не будете регулярно обрабатывать сообщения, ваше приложение может зависнуть.


Глава 3. Механика игры

Краткий обзор

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

  • Основы построения сюжета.
  • Расстановка целей.
  • Боевые единицы.
  • Управление ресурсами.
  • Дерево технологий.
  • Игровая кампания.
  • Многопользовательская игра.


Глава 3 - Сюжет

Сюжет

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

Тема сюжета

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

  • Научная фантастика.
  • Средневековье.
  • Вестерн.
  • Мир после катастрофы.

Есть множество других возмжных тем, которые можно выбрать или придумать; главное — выбрать одну и работать с ней. Возьмите, например, игру Star Wars: Galactic Battlegrounds, основанную на широко известной саге «Звездные войны», созданной Джорджем Лукасом. Она конечно попадает в категорию научной фантастики. Другим примером игры может служить Stronghold, где сюжет вращается вокруг захвата и строительства замков, и, следовательно, относится к средневековью. Я рекомендую выбирать тему, которая вам действительно нравится. К тому же, идеи для сюжета проще придумать, когда его тема интересна вам.

Элементы сюжета

Существует несколько ключевых моментов написания сюжета вашей игры. Каждый из них важен, и вы должны удостовериться, что каждый из них существует, перед тем как перейти к другим частям разработки игры. Эти элементы — сюжетная основа и конечная цель сюжета.

Сюжетная основа

Любой хороший сюжет имеет основу, и сюжет игры не является исключением. Сюжетная основа придает игровому сценарию глубину и смысл. Игрок сильнее стремится к победе, когда понимает причину этого. Например, взглянем на игру Command & Conquer от Westwood. В ней NOD сражается с GDI за контроль над полями тибериума. Если бы этой основы не существовало, игроки могли бы задаваться вопросом: почему NOD и GDI сражаются друг с другом.

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

Конечная цель сюжета

Игровой сюжет также дает игроку цель для победы в игре. Так же как сюжетная основа дает дополнительную информацию об игре, цель сюжета предоставляет сведения, необходимые, чтобы у игрока появилось желание победить. Если вы напишете сюжет, который даст игрокам цель, они получат больше удовольствия от игры. Я рекомендую, чтобы цель была больше, чем просто победить в максимальном количестве сражений — это слишком мелко. Попытайтесь придумать неотразимую и стоящую цель сюжета, она поможет установить цель игры.

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


Глава 3 - Расстановка целей

Расстановка целей

Как только у вас появится сюжет, можно начинать выписывать цели игры, основываясь на конечной цели сюжета. Зачем игроку нужны эти дополнительные цели, если он знает конечную цель сюжета? Наличие хорошо продуманных промежуточных целей очень важно для успеха вашей стратегической игры. Вы не хотите, чтобы игрок блуждал кругами не зная что делать, не так ли? Я считаю, что лучший способ расстановки промежуточных целей состоит в том, чтобы записать основную идею победы в вашей игре и затем разделить ее на отдельные шаги.

Для примера возьмем игру Warcraft разработанную Blizzard. В ней целью игрока является нанести поражение оркам или людям, в зависимости от того на стороне какой расы выступает сам игрок. Это звучит достаточно просто, так что давайте разобъем путь к победе на несколько промежуточных целей.

Первая цель игрока — построить город, способный содержать армию. Это, конечно, требует достижения многих второстепенных целей, таких как предоставление требуемого жилья, сбор необходимых ресурсов и оплата полезных модернизаций оборудования. Вот вы получили уже целый набор целей!

По мере дальнейшего погружения в игру вы начнете обнаруживать на пути к победе различные виды целей для игрока. Мой главный совет — делайте их простыми. Большинство игроков не желает наличия сотен промежуточных целей. Если вы поразмышляете об этом, то обнаружите, что большинство популярных стратегических игр ставят очень простые цели.

Пример для изучения — Empire Earth

Чтобы прояснить разбираемый вопрос, я взял на себя смелость разбить процесс игры Empire Earth на несколько целей. Цели, которые я вношу в список, используются в однопользовательской игре против компьютера. Помните об этом, если вы когда-либо играли в эту игру в другом режиме и удивляетесь, какого черта я придумал свой список!

ПРИМЕЧАНИЕ
Если вы никогда не играли в Empire Earth, я советую вам посетить сайт этой очень интересной игры, расположенный по адресу http://empireearth.sierra.com.

Описание событий

Для начала скажу, что Empire Earth очень похожа на игру Age of Empires где вы создаете цивилизацию на протяжении нескольких эпох, таких как каменный век и средневековье. Главное отличие Empire Earth состоит в том, что вы можете вести вашу цивилизацию гораздо дальше во времени, чем в игре Age of Empires. Фактически, вы можете открыть вашей цивилизации дорогу в будущее. Это не только делает игру более захватывающей, но и вносит в нее изрядную долю сложности. Конечно, это хорошо для вас, поскольку в результате в игре появляется несколько целей.

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

Начальные цели

Игра начинается очень просто с небольшой деревней и горсткой людей. На данном этапе вы очень уязвимы и лишь несколько человек помогают вам воздвигнуть пока несуществующий город. Как вы можете представить, первое, что вам необходимо — ресурсы для создания города. Для возведения большинства зданий необходима древесина, и хорошая первая цель состоит в том, чтобы отправить ваших граждан на вырубку леса. Взгляните на рис. 3.1, где изображено великое начало диаграммы целей.

Рис. 3.1. Первая цель в игре Empire Earth

Рис. 3.1. Первая цель в игре Empire Earth

Вот к чему мы пришли: первая цель игры — добыть древесину для постройки зданий. Из первой цели можно вывести другие. Что, например, поможет добывать древесину быстрее? Очевидный ответ — отправить на лесозаготовки больше граждан. Ведь вы не можете купить своим пещерным людям бензопилы!

Увеличению числа населения препятствует его потребность в пище. Учитывая, что пища тоже является жизненно важным для вашей цивилизации ресурсом, вы должны балансировать между потребностью в большем количестве работников и поставками продовольствия, производимого вашей неоперившейся империей. Здесь мы встречаемся со взаимозависимыми целями. Поскольку для рубки леса необходимы люди, а люди хотят есть, у вас появляется другая цель — сбор пищи. Пришло время обновить схему целей.

На рис. 3.2 я добавил две новых цели. Первая — увеличение населения, чтобы обеспечить более быструю добычу ресурсов. Вторая — добывание пищи, необходимой чтобы кормить людей, собирающих ресурсы. О, каким порочным кругом это становится!

Рис. 3.2. Вторая и третья цели игры Empire Earth

Рис. 3.2. Вторая и третья цели игры Empire Earth

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

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

Главные вехи

В Empire Earth вы начинаете игру в доисторическом веке. Чтобы выйти из доисторического века вы должны собрать заданное количество пищи. Это объясняет почему важно собирать пищу — она необходима вам для развития цивилизации. Фактически, в игре каждый временной период назван эпохой. Когда ваша цивилизация готова войти в следующую эпоху, вы тратите требуемые ресурсы и ждете некоторое время, пока не произойдут изменения. Самое интересное в эпохах то, что каждый раз когда вы переходите к новой эпохе у вас появляются новые объекты, доступные для строительства. Вы не только можете строить лучшие здания, но и можете тренировать лучшие войска. Это подводит нас к следующему ключевому пункту: главным вехам игры. Как следует из заголовка, такое глобальное событие в игре, как переход к новой эпохе, рассматривается как главная веха. На рис. 3.3 показаны эпохи, доступные в Empire Earth.

Рис. 3.3. Эпохи в игре Empire Earth

Рис. 3.3. Эпохи в игре Empire Earth

На рис. 3.3 видно, что игра поддерживает тринадцать эпох. Каждая из них дает дополнительные преимущества игроку и увеличивает игровые возможности. Хотя вехи делают игру увлекательнее, постарайтесь ограничить их количество. Сведя количество вех к минимуму, вы не будете рисковать запутать игрока. Предполагаю, что игроки — не сборище безмозглых дураков. (По крайней мере, я надеюсь на это — так как сам я тоже игрок!) Но вы все же должны быть бдительными при создании игры, чтобы в нее было просто начать играть.

Заключительная цель

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

По мере продолжения игры на горизонте появляются все новые и новые цели. Здесь содержится важный урок, который следует запомнить: вы не должны показывать игроку каждую цель заранее. Лучше постепенно подводить его к новым целям в процессе игры. Это не только не даст игроку запутаться, но и поддержит его интерес, поскольку по мере продвижения игры он сможет делать новые захватывающие вещи. Взгляните на рис. 3.4, где приведена полная диаграмма целей игры Empire Earth.

Рис. 3.4. Полное дерево целей игры Empire Earth

Рис. 3.4. Полное дерево целей игры Empire Earth


Глава 3 - Боевые единицы

Боевые единицы

Не во всех стратегических играх есть подразделения. Такие игры как SimGolf, Sim Theme Park и похожие на них вообще обходятся без сражений. Сперва вы можете решить, что эти игры не являются стратегическими, но если вы немного подумаете, то поймете, что это не так. Так или иначе, но это тема для отдельной дискуссии, а сейчас я собираюсь поговорить о боевых подразделениях.

Во всех связанных с военными действиями стратегических играх есть боевые подразделения. Некоторые игры, подобно Total Annihilation содержат сотни подразделений, а другие обходятся всего несколькими. Я не устану повторять, что ключ здесь — баланс. Поскольку вы пока находитесь на самых начальных стадиях разработки вашей собственной стратегической игры, надо с чего-то начинать. Я предлагаю сперва сосредоточиться на единственной, наиболее интересной вам боевой единицы. Кроме того, следует выбрать такое подразделение, существование которого не зависит от других. Самолет для авианосца был бы не самым лучшим выбором, гораздо лучшим вариантом является танк.

Как только вы выбрали подразделение, с которого будете начинать, сделайте его эскизы, чтобы получить общее представление о нем. Делая набросок вы можете получить хорошие идеи об используемом боевой единицей вооружении, понять насколько большой она является и какой способ передвижения использует. Это ключевые элементы проектирования боевой единицы, поскольку большинство из них имеет четыре главные характеристики: стоимость, скорость, броня и огневая мощь.

Стоимость боевых единиц

Все боевые единицы должны иметь какую-то стоимость для игрока, выражающуюся в затрачиваемых на их создание ресурсах. Если боевые единицы слишком дешевы, то игроки будут приобретать их сотнями и использовать стратегию, известную под названием «лавина». По моему мнению, это наиболее скучная форма сражения, но она срабатывает во многих случаях и поэтому широко используется. Вы должны удержать точный баланс между стоимостью боевой единицы и ее мощью. Обычно хорошей идеей является сделать так, чтобы для создания каждой боевой единицы требовалось несколько видов ресурсов. Это не даст игрокам возможности концентрироваться на одном виде ресурсов для содержания армии. Кроме того, это добавит игре тактической ценности.

СОВЕТ
Термин «лавина» происходит от тактики, когда игрок строит множество дешевых боевых единиц, формирует из них армию, и обрушивает ее на противника. Это настоящая форма блтцкрига, причем весьма опасная. Проблема в том, что данная тактика почти ничего не стоит в плане ресурсов, и большинство игр заканчиваются победой того игрока, который быстрее сформирует армию и обрушит ее на противника. Другая отрицательная сторона заключается в том, что большинство стратегий реального времени, страдающих от такого рода атак не имеют никакой защиты против подобной тактики. Помните, любая стратегия в игре нуждается в существовании противостратегии. Если это не так, игра превратится в постоянное скучное повторение одной и той же тактики.

Скорость боевых единиц

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

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

Вычисление скорости боевых единиц

Что касается математической стороны расчета скорости боевых единиц, я рекомендую вам создать базовую шкалу скоростей со значениями от 1 до 100, где 1 соответствует самой медленной скорости, а 100 — самой быстрой. Используя подобную шкалу вы можете просто умножить скорость боевой единицы на метод ее передвижения, чтобы получить перемещение.

Броня боевых единиц

Я использую термин «броня», чтобы ссылаться на значение, определяющее защищенность боевой единицы. Хотя не все боевые единицы обладают способностью нападать на противника, почти все должны уметь обороняться. Что за удоволдьствие обладать боевой единицей, которая будет разрушена как только кто-либо другой атакует ее! Тщательно продумывайте оборонительные способности ваших боевых еднинц. На данном этапе не следует слишком беспокоиться о них, поскольку разработка наступательных и оборонительных способностей боевых единиц будет завершена на стадии балансировки вашего игрового проекта.

Вычисление обороноспособности

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

Таблица 3.1. Броня боевых единиц

Тип брони Огонь Снаряд Химическое
оружие
Личная | 0,1 | 0,2 | 0,5
Тяжелый танк | 0,9 | 0,7 | 0,8
Легкий танк | 0,7 | 0,6 | 0,7

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

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

100 (наносимый ущерб) x 0.1 (оценка брони) = 10 единиц поглощено броней

Эта формула означает, что пехотинец получит следующее количество повреждений:

100 (наносимый ущерб) – 10 (поглощено броней) = 90 единиц повреждений получено

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

100 (наносимый ущерб) x 0.9 (оценка брони) = 90 единиц поглощено броней

100 (наносимый ущерб) – 90 (поглощено броней) = 10 единиц повреждений получено

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

Очки повреждений

Термин «очки повреждений» (hit points) пришел из ролевых игр старой школы, но нашел свое место и в других, связанных со сражениями компьютерных играх. Вы уже знаете, как вычислить защищенность или оценку брони, а теперь следует учесть какое количество повреждении может выдержать боевая единица до своего разрушения.

Для очков повреждений нет никаких сложных алгоритмов — это обычное число, показывающее сколько единиц повреждений может получить боевая единица до своего уничтожения. Давайте вернемся к примеру с броней. У пехотинца, получившего 90 единиц повреждений, было 50 очков повреждений, а значит в результате атаки он погиб. С другой стороны, танк получил только 10 единиц повреждений, обладая 1000 очков повреждений. Чтобы упростить жизнь я рекомендую создать для боевых единиц вашей игры шкалу от 50 до 5000 единиц. Слабейший из слабых может выдержать только 50 единиц повреждений, в то время, как самая сильная боевая единица может получить до 5000 единиц повреждений. Видите, как просто?

Огневая мощь боевых единиц

Немезида брони — огневая мощь. У каждого инь должен быть янь, и это именно такой случай. Все те же базовые правила, которые применялись для брони, применяются и для огневой мощи. Для начала вы должны быть аккуратны, когда задаете огневую мощь ваших боевых единиц. Если боевые единицы будут несбалансированными, каждый будет пользоваться только одним их типом, и игра будет скучной. Есть несколько новых понятий, связанных с огневой мощью, которые следует рассмотреть: скорострельность, тип повреждений, специальные повреждения и скорость.

Скорострельность

Скорострельность боевой единицы определяет как часто может стрелять установленное на ней оружие. Достаточно взглянуть на современное оружие, чтобы увидеть разницу в том как часто стреляет пистолет и как часто стреляет автомат. Различие между этими двумя типами вооружения выражается в скорострельности. Обычно более мощное оружие стреляет реже. Это, конечно, не справедливо для автомата, но истино для других видов вооружения, таких как корабельные орудия и ракетные системы земля-воздух.

Вычисление скорострельности

Хорошо зарекомендовавший себя на практике метод вычисления скорострельности — определить сколько раз в минуту может выстрелить конкретное оружие. Это дает хорошую систему для установки параметров скорострельности (rate of fire, ROF) или количества снарядов в минуту (round per minute, RPM) ваших систем вооружения. Несколько примеров приведено в таблице 3.2.

Таблица 3.2. Скорострельность
Оружие | Снарядов в минуту
9-мм пистолет | 120
M1917A1 — влагозащищенный автомат калибра .30 | 400 – 600
Автоматический пистолет PPS43 | 700
81-мм гранатомет M1 | 18

Как видно из таблицы 3.2, автоматическая винтовка имеет гораздо большую скорострельность чем миномет. Это имеет смысл, если принять во внимание перечисленные виды оружия.

Тип повреждений

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

Специальные повреждения

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

Скорость оружия

У самого оружия нет скорости, но она есть у вылетающих из оружия снарядов. Это ключевой момент проектирования оружия для вашей стратегической игры, поскольку он оказывает большое влияние на весь игровой процесс. Например, лазерные лучи воинов будущего имеют максимально возможную скорость — скорость света.

Вычисление скорости снаряда

Скорость оружия очень похожа на скорость боевой единицы. Вам необходима стандартная система скоростей для назначения всем, используемым в игре снарядам. Я еще раз предлагаю использовать шкалу от 1 до 100 для задания скорости снарядов. Тогда вы сможете умножать данное значение на используемую в игре стандартную меру длины, чтобы вычислить перемещение снаряда.

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


Глава 3 - Управление ресурсами

Управление ресурсами

Какая стратегия реального времени может считаться законченной без управления ресурсами? Держу пари, что вы не сможете привести более пары примеров. Управление ресурсами лежит в самом сердце большинства стратегий реального времени. Я говорю не о счетчике спрятанных в холодильнике консервированных бобов, а о материале, из которого сделаны легенды истории стратегий реального времени. У каждого лидера продаж прошлого было управление ресурсами. В Warcraft были древесина, золото и камни. В Command & Conquer был тибериум. В Age of Empires были древесина, золото, камень и пища. Этот список можно продолжать бесконечно.

Возможно, вы говорите себе «Большое дело! Управление ресурсами легко реализовать». Прежде чем сделать подобный вывод, минутку подумайте о том, насколько сложной может быть такая простая вещь, как управление ресурсами. Вернемся к моему предыдущему примеру с пищевыми ресурсами в игре Empire Earth. Описав только первые 15 минут игры, я уже отметил многие связанные с ним сложности и причины для беспокойства. В предыдущем разделе ресурсы рассматривались главным образом с точки зрения игрока. Теперь, когда мы подошли к вопросам реализации ресурсов, пришло время взглянуть на них с точки зрения разработчика.

Выберите, что вы будете пить

Первое необходимое действие — выбор ресурсов, которые должен собирать игрок. В зависимости от сложности вашей игры, потребуется наличие от трех до шести видов ресурсов, которые будет добывать игрок. Я всегда люблю обучать на примерах, поэтому давайте начнем с игры, над которой я работал некоторое время. В игре Battle Armor есть три основных вида ресурсов: топливо, руда и пища. Вы можете спростить — как я придумал их? Проще простого — я всего лишь напечатал их названия! Это была шутка. В действительности все следует начинать с записи некоторых ключевых целей для игрока. Для Battle Armor я придумал следующий список целей:

  • Кормить население.
  • Строить инфраструктуру.
  • Увеличивать армию.

Определение ресурсов

После того, как цели определены, вы вычисляете способ их достижения. Первая цель — накормить население — весьма прямолинейна. Чтобы кормить людей игрок должен собирать или выращивать продовольствие. Итак, первый ресурс это пища. Пока все очень просто, правда?

Следующая цель — строительство инфраструктуры — тоже достаточно прямолинейна. Поскольку большинство строений требуют для своего создания какого-либо вида минерального сырья, я объединил все под термином «руда». В действительности игрок не строит здания своего города из руды, но это отражает основную суть.

Последняя цель является наиболее сложной. Увеличение армии требует не только рассмотренных выше ресурсов, но и ряда новых. Сначала я думал об энергии как об основном ресурсе для армии, но потом остановился на топливе. Топливо позволяет существовать окружающему миру; поэтому на мой взгляд оно подходит для содержания армии.

Добыча ресурсов

Теперь, когда определены три ресурса для игры, необходимо указать, как игрок может их получить. Я начну с пищи. Поскольку Battle Armor — научно-фантастическая игра, я не хочу, чтобы игрок управлял толпами людей, вручную собирающих продовольствие. Вместо этого я выбрал более прогресивный метод — гидропонику.

Продовольствие производится гидропонным оборудованием. Следующий вопрос — как игроки могут строить гидропонные фабрики. Было бы очень неинтересно позволить игроку строить фабрики для добычи пищи в любом месте карты, поэтому я ограничиваю зоны возможного строительства гидропонных фабрик плантациями водорослей. Эй, знаешь что? От этого игра только получила большую глубину! Мало того, что имеется три основных вида ресурсов, кроме того, чтобы собрать один из них вам требуются фабрики, которые могут быть построены только на плантациях водорослей. Если бы я не поостерегся, то сказал бы что к неоперившейся игре добавилась еще одна цель: размещать плантации водорослей, чтобы строить производящие продовольствие фабрики.

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

Рис. 3.5. Гидропонная фабрика

Рис. 3.5. Гидропонная фабрика

Последний ресурс — топливо — теперь, когда другие два ресурса уже определены, тоже выглядит достаточно прростым. Следуя установленному шаблону, я хочу, чтобы для производства топлива игрок покупал нефтеперегонные заводы. Недостаточно просто приобрести нефтеперегонный завод, его надо построить на месторождении нефти. Изображение нефтеперегонного завода приведено на рис. 3.6.

Рис. 3.6. Нефтеперегонный завод

Рис. 3.6. Нефтеперегонный завод

Теперь, когда ресурсы игры Battle Armor остались позади, вы увидели процесс определения ресурсов для ваших стратегий реального времени. Он сводится к следующим этапам:

  • Определение необходимых ресурсов.
  • Определение способов получения ресурсов.
  • Определение ограничений на получение ресурсов.

Единственный предел создания ресурсов для вашей игры — ваше воображение. Для Battle Armor был использован очень простой метод. Я поощряю вас придумывать более занимательные типы ресурсов и способы их добычи.

Баланс ресурсов

Ах, опять это слово — баланс. Вы будете встречать его еще много-много раз. Баланс является ключевой особенностью любой хорошей стратегической игры, а ресурсы требуют тщательной балансировки.

Пример несбалансированности

Возьмем для примера старую игру Total Annihilation выпущенную Cavedog. В Total Annihilation, или TA как часто называют ее, игрок добывает металл и энергию. Вот это простота — всего два ресурса!

Главная проблема, которую я вижу в TA, заключается в том, что у игроков нет никаких ограничений на добычу ресурсов. Машины, производящие энергию и металл могут быть размещены в любой точке карты, и количество этих машин, которое можно построить, никак не ограничено. Это вызывает серъезные проблемы, приводящие к тому, что игрок может отгородить себя, собрать все требуемые ресурсы, а затем безаказанно обрушиться на врага.

Из моих объяснений вы, вероятно, поняли, что ресурсам в TA не хватает одного этапа определения ресурсов для стратегий реального времени. Этот этап ни что иное, как накладывание ограничений. Если у игроков нет никаких ограничений на то как или когда они могут добывать ресурсы, ваша игра получится несбалансированной.

Пример сбалансированности

Существует много примеров сбалансировааности. Command & Conquer выделяется тем, что игроки сильно зависят от тибериума. Замечательной особенностью является то, что запасы тибериума возобновляются очень медленно, и ни один игрок не может владеть всеми полями тибериума целиком. Здесь располагается главное ограничение, предотвращающее безнаказанный захват источников ресурсов игроком. Когда источник жизненно важных материалов оказывается в опасности, для игрока наступает время, требующее напряжения всех сил.

Скорость добычи

Помимо решения о том как игрок собирает ресурсы, вы должны определить как быстро они могут быть собраны. Если игрок получает ресурсы слишком быстро, игра превращается в яростную гонку или, что еще хуже, становится не слишком захватывающей. Если добыча ресурсов занимает много времени, игра получается раздражающей и монотонной.

СОВЕТ
Прежде всего убедитесь, что скорость добычи ресурсов можно настраивать в вашем коде. Вам придется провести достаточно времени, подбирая различные значения, чтобы получить правильное чувство игры.


Глава 3 - Дерево технологий

Дерево технологий

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

Рис. 3.7. Простое дерево технологий

Рис. 3.7. Простое дерево технологий

На рис. 3.7 первым узлом дерева является огонь. Поскольку огонь лежит в основе человеческих технологий, он в нашем случае является первым узлом, или стволом. От данного узла идут ветви к двум следующим технологиям: пару и металлургии. Поскольку и пар и обработка металов требуют огня, они естественным образом размещаются на ветвях, идущих от узла огня. Еще одна ветвь идет от пара к узлу с паровым двигателем. Это очевидно, поскольку вы можете изобрести паровой двигатель только если у вас уже есть пар.

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

ПРИМЕЧАНИЕ
Хотя я и называю рассматриваемую схему деревом технологий, она больше похожа на папоротник технологий. Я говорю так, потому что обычно она переворачивается вверх ногами с корнем сверху и ветвями под ним. Я уверен, что это вызвано скорее всего эстетикой и простотой восприятия.

Различные типы технологий

Чтобы проектировать дерево технологий для своей собственной игры, сперва следует рассмотреть различные типы технологий. Приведенный ниже список может служить хорошей отправной точкой:

  • Инфраструктура.
  • Вооружение.
  • Модернизация.

Технологии для инфраструктуры

Основные типы технологий имеют дело с инфраструктурой цивилизации игрока. Они связаны со строительными блоками цивилизации. Без технологий инфраструктуры игрок не может прогрессировать к большим и лучшим достижениям.

В качестве примера рассмотрим игру Age of Empires. В ней вы не можете создать кавалерию, пока не построите конюшни. Вы не можете построить конюшни, пока не достигнете второй эры. Таким образом технологии формируют основу инфраструктуры.

Технологии для вооружений

«...И будут спущены с цепей псы войны...»

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

Я помню игру Alpha Centauri. Это замечательная игра, которая фактически является Civilization в космосе. Единственная проблема, с которой я столкнулся, заключалась в том, что дерево технологий было очень запутанным. Поскольку игра является научно-фантастической, у всех технологий были фантастические названия, никак не связанные существующей действительностью. Это приводит к проблемам, поскольку разработчики знают, что они подразумевают, но у игроков нет никакого ключа, чтобы понять, о чем думал разработчик! Тот факт, что ваша игра основана на научной фантастике не означает, что вы должны создать для нее полностью новый язык.

СОВЕТ
Используйте простые для понимания деревья технологий. Даже если действие игры разворачивается в будущем, попытайтесь придумывать названия для технологий, основываясь на сегодняшней реальности.

Технологии для модернизации

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

Вы можете модифицировать не только оружие, но также и инфраструктуру. Возьмем для примера мою игру Battle Armor. В ней со временем появляется возможность модернизировать гидропонную фабрику, чтобы она за то же самое время производила больше пищи. Модернизированная гидропонная фабрика показана на рис. 3.8.

Рис. 3.8. Модернизированная гидропонная фабрика

Рис. 3.8. Модернизированная гидропонная фабрика

Стоимость технологий

Теперь, когда вы познакомились с тремя стартовыми типами технологий, надо выяснить, во что каждый из них обойдется игроку. Так как вы не хотите, чтобы игрок получал технологии бесплатно, следует назначить каждой из них цену. Стоимость должна выражаться в доступных в игре ресурсах, так что выбирайте мудро.

Выбирая стоимость технологий для вашей игры следует помнить о нескольких ключевых пунктах:

  • Убедитесь, что разработка технологии занимает какое-то время. Чеб более ценна технология, тем больше времени должно требоваться для ее открытия.
  • Убедитесь, что технология требует ресурсы, которые имеют отношение к ней. Другими словами, не делайте так, чтобы технология требовала ресурсы, которые не имеют с ней ничего общего.
  • Убедитесь, что стоимость технологии находится в разумных пределах. Если технология очень полезна, ее стоимость должна быть высокой. Однако не стоит выходить за разумные границы. Если технология стоит больше ресурсов, чем с ее помощью можно сэкономить, то она является бесполезной.

На рис. 3.9 показано обновленное дерево технологий, где указаны затраты ресурсов, связанные с каждым узлом технологий.

Рис. 3.9. Дерево технологий с указанием затрачиваемых ресурсов

Рис. 3.9. Дерево технологий с указанием затрачиваемых ресурсов


Глава 3 - Игровые миссии

Игровая кампания

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

Причина, по которой я говорю об этих играх, как о заранее подготовленных заключается в том, что все игры кампании созданы для вас разработчиками. В отличие от многопользовательских игр или схваток, в режиме кампании есть установленные опасности и награды. Например в игре Emperor: Battle for Dune, выпущенной Westwood, требуется, чтобы вы доказали свою ценность для клана, выполняя простые задачи, такие как управление в сражении небольшим подразделением боевых единиц. По мере развития кампании вам поручают выполнение все более трудных задач. Кроме того, по мере продвижения игры задачи также становятся более длительными и более сложными.

Редактор миссий

Каждая отдельная игра в режиме кампании называется миссией. Очевидная причина этого заключается в том, что вы посылаете игрока на выполнение задачи, которая связана и с опасностью и с наградой. Звучит подобно моей миссии! Поскольку миссии предопределены, вы, как разработчик, должны создать редактор миссий. Обычно редактор миссий встраивается в в общее инструментальное средство, используемое для редактирования игры. Позднее я покажу вам как создать редактор миссий, а пока поговорим только об основах.

Большинство стратегических игр содержат от 20 до 30 встроенных миссий для однопользовательской игры. Этого достаточно, чтобы занять игрока, пока не будет выпущен дополнительный пакет миссий. Да, я знаю, что это может казаться неудовлетворительным решением, но игровые компании не могут существовать не получая прибыли. Вы должны всегда планировать свои игры расширяемыми, чтобы если они обретут популярность можно было гарантировать быстрый выпуск дополнительных миссий или продолжения. Достаточно взглянуть на серию игр Age of Empires от Ensemble, чтобы увидеть что могут сделать для вас продолжения. Другой замечательный пример — RollerCoaster Tycoon. Держу пари, что Крис Сойер, автор игры, никогда не воображал тот огромный успех, которым пользуется его захватывающая игра, посвященная строительству парка аттракционов. Если вы следите за этой серией, то знаете что были выпущены десятки дополнений.

Цели миссий

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

Миссии не должны быть тривиальными заданиями, чтобы игрок просто сделал что-нибудь. Вместо этого они должны служить развитию сюжета и продвижению по нему игрока. Помните об этом, когда будете выписывать структуру миссий.

Каждая миссия нуждается в определенной цели. Независимо от того, будет ли это спасение заложника, уничтожение танковой колонны или поиск секретного вражеского укрытия, наличие конкретной цели жизненно важно для интересной игры.

Вот пока и все об игровой кампании. Я еще вернусь к этой теме и мы обсудим ее более подробно в главе, посвященной написанию редактора игры.


Глава 3 - Многопользовательская игра

Многопользовательская игра

Ах, моя любимая часть стратегических игр — многопользовательская игра! Лично я не слишком много играю в стратегии реального времени в режиме однопользовательской кампании, а предпочитаю сразиться с другими игроками в сети или играть против компьютера вместе с несколькими друзьями. Нет ничего более интересного, чем жаркая битва с другими людьми вокруг. Тем не менее, однопользовательский режим очень важен, поскольку многие люди наслаждаются им.

Раз вы собираетесь писать стратегическую игру, вам следует предусмотреть включение поддержки многопользовательского режима игры через Интернет или локальную сеть. Сейчас для этой цели обычно применяются два метода: использование сокетов или DirectPlay. По ряду причин я предпочитаю DirectPlay, но низкоуровневые сокеты работают ничуть не хуже.

В зависимости от сложности вашей игры следует предусмотреть поддержку одновременного участия от четырех до восьми игроков. Обычно восемь игроков — это слишком много для большинства систем, но в этм нет ничего невозможного. Главным ограничением является максимальное количество подразделений, которое может создать каждый игрок. Если ваша игра основана на наличии у каждого игрока нескольких уникальных подразделений, можно реализовать одновременную игру большого числа людей. Если же ожидается, что в вашей игре у каждого игрока будет примерно 200 боевых единиц, вы столкнетесь с проблемами, когда в вашей игре одновременно захотят участвовать достаточно много человек. Для простоты я предлагаю, чтобы вы начали с поддержки шести игроков — прекрасная, золотая середина.

С поддержкой многопользовательского режима связано несколько технических проблем. Одна из них — поддержка сохранения игры. Лишь несколько стратегических игр поддерживают такую возможность в многопользовательском режиме. Единственной, где я успешно использовал ее, была Age of Empires II. Я должен сказать, что это очень полезная возможность. Поскольку сеанс стратегической игры может длиться несколько часов, возможность сохранения неоценима.


Глава 3 - Итоги

Итоги

Теперь, когда вы познакомились с примерами основных строительных блоков стратегических игр, настало время перейти к более сложным темам. Перед тем, как отправляться дальше, вспомним ключевые моменты данной главы:

  • Постарайтесь разработать вашу игру так, чтобы отправка лавины дешевых боевых единиц не являлась успешной тактикой. В конце концов от этого ваша игра только выиграет.
  • Удостоверьтесь, что ваша система управления ресурсами легко настраивается, и вы можете сбалансировать вашу игру, внося минимальные изменения в код.
  • Используйте легкие для понимания деревья технологий. Даже если жанр игры предполагает, что действие разворачивается в будущем, постарайтесь основывать названия технологий на сегодняшней реальности.


Глава 4. Планирование игрового проекта

Краткий обзор

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

Многие программисты сразу мчатся к написанию кода, не размышляя об имеющемся в руках проекте. Такой подход приводит к пропуску ключевых моментов, хаотическому развитию, уходу членов команды и даже закрытию проекта. Даже если ваша группа состоит из одного или двух человек, не поддавайтесь искушению быстрого и беспорядочного развития проекта. Выделите время, чтобы должным образом спланировать ваш проект, и вы будете вознаграждены.

Достаточно проповедей. У каждого есть собственный метод управления проектом, и, конечно же, я не утверждаю, что здесь представлен самый лучший. Я предлагаю использовать написанное мной в качестве основы для создания вашего собственного метода. Руководство проектом очень похоже на разработку программного обеспечения, где у каждого есть собственный стиль. В этой главе я рассматриваю следующие темы:

  • Фаза идей.
  • Фаза определения требований.
  • Фаза технической документации.
  • Фаза разработки.
  • Фаза тестирования.
  • Фаза производства.
  • Распространение.


Глава 4 - Фаза идей

Фаза идей

Итак, вы стоите на краю бесконечности. Только вы и ваш разум устанавливают границы того, что лежит впереди. Вы находитесь в фазе идей, также известной как стадия творчества. Именно здесь вы должны придумать идею, которая победит все остальные. Идею, которая приведет к тому, что миллионы игроков стекутся к вашему порогу. Идею, которая изменит игровой мир! Пока вы не Сид Мейер и не Джон Кармак, ваша цель может быть меньше, чем изменение мира, но эй, все может случиться.

В данной фазе вы придумываете идею вашей программы. Так как вы имеете дело со стратегическими играми, стоит задать несколько вопросов, таких как:

  • К какому типу относится стратегическая игра — реального времени или походовая?
  • Игра основана на реальных или на вымышленных событиях?
  • В какой период времени разворачивается действие игры — средневековье, Гражданская война, Вторая Мировая война или будущее?
  • Игра будет однопользовательской, многопользовательской или поддерживать оба режима?
  • Графика будет двухмерной, трехмерной или и той и другой?
  • Как долго длится средний раунд игры до завершения?
  • Какова цель игры?
  • Игра в основном стратегическая или тактическая?
  • Игра простая или сложная?
  • Как выиграть в игре?
  • Как проиграть в игре?
  • Как долго продолжается игра до победы?

Вопросы, перечисленные выше ни в коем случае не охватывают все, что вы должны понять о стратегической игре. Здесь начинается творческая часть работы. Как я говорил в главе 3, в стратегической игре есть много различных элементов. Подумайте о них некоторое время, и увидите как появятся новые вопросы.

Создание наброска

Теперь у вас есть все эти идеи. Что дальше? Лучший из придуманных мной методов состоит в том, чтобы чтобы создать на основе идей набросок игры. Взгляните на следующий набросок:

    Исходные данные

      Вторая Мировая Война

        Сражения Паттона

        Тщательное моделирование реальных событий

      Сражения

        Уровень бригад

        Основное внимание механизированным подразделениям

        Команды передаются вниз до уровня подразделений

      Реализм

        Туман войны

        Проблемы с коммуникациями

        Отсутствует вид со спутника

        Возможные маршруты

        Необходимость маршрутов снабжения

      Игроки

        Один игрок

        Игровая кампания

        Многопользовательский режим

          Стычки между отрядами

          События однопользовательской кампании

Итак, перед вами очень простой набросок, сделанный в ходе фазы идей. Из него вы можете увидеть, что я собираюсь создать игру, основанную на знаменитых битвах генерала Паттона, происходивших во время Второй Мировой войны. Вы можете также увидеть, что бой будет происходить с участием большого количества подразделений и техники, а команды передаются вниз до уровня подразделений. Набросок показывает также, что игра собирается быть реалистичной. Реализм достигается использованием таких особенностей, как туман войны и реальные маршруты снабжения.

Читая мой набросок, вы представляете основную идею игры — реалистичная игра, посвященная танковым сражениям Второй Мировой войны. Несомненно, есть сотни деталей, которые еще следует определить, но это только первая стадия разработки программы.


Глава 4 - Фаза определения требований

Фаза определения требований

Хорошо, у вас есть ясно очерченная идея игры и вы готовы перейти к фазе определения требований. В этой фазе вы определяете, что должно быть в игре, чтобы она была признана готовой к производству. Будьте осторожны, чтобы не оказаться слишком честолюбивым, иначе вы закончите с проектом-монстром!

Отличительная черта этой фазы заключается в том, что требования могут меняться в ходе оставшихся фаз разработки. Спросите любого программиста об этой особенности, и, держу пари, вы получите весьма эмоциональный ответ! Требования изменяются часто и непредсказуемо. Такова природа развития. Главное, не волноваться, чтобы на этой стадии было перечислено каждое возможное требование. Позаботтесь только о главных, а остальные сами найдут свое место.

Используя в качестве примера мой предыдущий набросок, вот как бы я перечислил требования для раздела многопользовательской игры:

    Требования для многопользовательской игры.

    1. Поддержка от 2 до 16 игроков.
    2. Игра может быть сохранена и заново загружена.
    3. Необязательный выделенный сервер.
    4. Администратор игры может отключать и блокировать игроков.

Под каждым пронумерованным пунктом требований я бы написал еще абзац с их кратким описанием. Здесь хорошо следовать правилу гласящему, что больше информации — лучше. Ничего не может быть хуже, чем куцый документ с требованиями. Имейте в виду, что этот документ все разработчики будут должны использовать в качестве отправной точки. Это документ, на основании которого пишутся технические требования. Если вы что-то не перечислили здесь, не ожидайте, что это будет реализовано в игре.

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


Глава 4 - Фаза технической документации

Фаза технической документации

Руководители проекта и дизайнеры закончили свою работу, и теперь на сцену выходят разработчики, которые пишут техническое задание. Оно основывается исключительно на документе с формулировкой требований и ни на чем более. Если что-то отсутствует в документе с требованиями, оно не будет помещено и в техническое задание.

Пока это может звучать несколько запутано, так что взгляните на рис. 4.1, чтобы получить визуальное представление.

Рис. 4.1. Взаимоотношения между проектной документацией

Рис. 4.1. Взаимоотношения между проектной документацией

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

    Требования к многопользовательской игре

      Необязательный выделенный сервер

      1. Данная возможность требует наличия отдельной программы, которая содержит только сетевой код для игры. Программа должна тербовать как можно меньше ресурсов, чтобы игрок мог запустить на одном компьютере и выделенный сервер и саму игру.
      2. На одном компьютере можно запускать одновременно несколько выделенных серверов.
      3. Выделенный сервер настраивается с помощью конфигурациноого файла ,который должен находиться в том же каталоге, что и программа сервера.
      4. Выделенный сервер управляется с помощью параметров командной строки и не имеет пользовательского интерфейса.
      5. Сосотояние игры отображается в окне интерфейса командной строки сервера. Должны выводиться такие сведения как количество игроков, текущее время игры, среднее время игры и сообщения об ошибках.

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

Теперь возьмите ваш документ с требованиями и составьте техническое задание. Вы должны перечислить все, о чем можно подумать, рассматривая игру с точки зрения разработчика. В отличие от документа с требованиями, этот не столь гибок и может вызвать значительные разветвления проекта, если вы забыли что-либо включить в него. К счастью, поскольку вы сами являетесь разработчиком, — завершить эту фазу вам будет проще всего. Я завершил написание технического задания для моего последнего проекта приблизительно за три недели (в результате получился документ, объемом 137 страниц!).


Глава 4 - Фаза разработки

Фаза разработки

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

Очевидно, что в течение фазы разработки вы разрабатываете игру. Хотя вы и знакомы с этой фазой. возможно, вы упустили из виду следующие ключевые моменты:

  • Контроль исходного кода.
  • Управление метками.
  • Отслеживание ошибок.
  • Тестирование отдельных частей.

Контроль исходного кода

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

Делали ли вы когда нибудь изменение в стабильном коде только чтобы обнаружить, что оно привело к появлению ошибки или к нестабильности? Если да, то возврат к предыдущей версии и вычисление, какие изменения привели к возникновению проблем, может быть действительно болезненным. Используя контроль версий, вы можете получить старый код, проверить его и вычислить где появилась ошибка. Это дает как минимум отправную точку для последующих поисков. Кроме того утилиты, такие как WinDiff, позволяют вам сравнивать две версии кода и точно видеть различия между ними. Это неоценимо!

СОВЕТ
Утилита WinDiff включена в операционную систему Windows. Если вы используете Windows 2000, ME или XP, она должна у вас быть. Щелкните по кнопке Start (Пуск), выберите пункт Run (Выполнить), и введите WinDiff. После щелчка по кнопке OK, вы увидите окно программы WinDiff. Поиграйтесь с программой и попробуйте сравнить два похожих файла, чтобы увидеть различия. Держу пари, вы полюбите эту утилиту!

На рынке существует множество программ, которые помогут вам в организации контроля исходного кода. Наиболее попуярной из тех, которые я видел до настоящего времени, является CVS. Чтобы увидеть ее в действии, посетите сайт www.sourceforge.net. На SourceForge.net существуют тысячи проектов с открытым исходным кодом, которые вы можете посмотреть. Там есть даже свободно распространяемые стратегические игры и игровые библиотеки! Информацию о CVS можно получить на сайте www.cvshome.org.

Управление метками

Благодаря управлению метками вы можете получать преимущества от ясно помеченных ревизий исходного кода. Достигнув какой либо вехи в разработке, вы сохраняете весь ваш код и назначаете ему метку. Это позволяет вернуться назад и проверить все различные версии кода, требуемые для построения программы в какой-либо контрольной точке. Обратите внимание на следующие два файла:

    Main.cpp

      Версия 1.4

      Версия 1.3

      Версия 1.2

      Версия 1.1

      Версия 1.0

    Main.h

      Версия 1.2

      Версия 1.1

      Версия 1.0

Если теперь я попрошу вас выбрать код для бета-версии, какие файлы вы предложите? Вы можете взять самые последние версии файлов, но что, если они были написаны уже после выхода бета-версии? Без меток у вас нет никакого способа получить необходимую информацию, если только вы не решите создавать документ с номерами версий в каждой контрольной точке. Вы можете представить себе записывание версий нескольких тысяч файлов? Как можно предположить, это большая трудность. Теперь взгляните на те же самые два файла с метками:

    Main.cpp

      Версия 1.4

      Версия 1.3 BETA

      Версия 1.2

      Версия 1.1

      Версия 1.0

    Main.h

      Версия 1.2 BETA

      Версия 1.1

      Версия 1.0

Только взгляните на это! Возле тех файлов, которые использовались при создании бета-берсии стоит метка BETA. Все, что вам теперь остается сделать — выбрать исходный код с меткой BETA. К счастью программное обеспечение для контроля исходного кода позволяет снабжать код меткой или номером ревизии, так что для вас все автоматизировано. Вы должны также отметить насколько важны метки с точки зрения стабильности. Если бы для постройки бета-версии вы взяли бы самые последние файлы, у вас оказался бы выбран неправильный файл Main.cpp. Это могло бы вызвать огромные проблемы во всем проекте. Вышеупомянутый пример проиллюстрирован на рис. 4.2.

Рис. 4.2. Версии и метки исходного кода

Рис. 4.2. Версии и метки исходного кода

Отслеживание ошибок

Предположим, во время игры вы обнаружили ошибку в вашем коде. Что делать дальше? Вместо того, чтобы сразу исправлять ее, вам необходимо ввести данные о ней в систему отслеживания ошибок. Зачем? Использование программ отслеживания ошибок приносит много выгод. Основные из них — отслеживание, привязка к исходному коду и метрики качества.

Отслеживание

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

Отслеживание также полезно, чтобы не забыть устранить ошибку. Во время разработки вы можете сколкнуться с проблемами, которые сочтете несущественными. Конечно, они не приведут к краху игры, но они также должны быть устранены! Лучший способ помнить о необходимости их устранения заключается в том, чтобы ввести информацию в систему отслеживания ошибок. Большинство систем отслеживания ошибок позволяет вам указывать серьезность ошибки. Это позволяет располагать ошибки по их приоритетам, чтобы сначала работать над наиболее серьезными, а потом перейти к менее серьезным.

Еще одно преимущество отслеживания заключается в том, что другие пользователи или команды могут вводить в систему отслеживания данные об обнаруженных в вашем программном обеспечении ошибках. Это позволяет команде тестирования сообщать об обнаруженных в вашей игре ошибках без необходимости приезжать к вам и отчитываться лично. Даже если вы одинокий разработчик, я настоятельно рекомендую отслеживать собственные ошибки.

СОВЕТ
Один из пакетов, которые я использую для отслеживания ошибок называется TestTrack и выпущен Seapine Software. Посетите веб-сайт этой компании, расположенный по адресу www.seapine.com.

Привязка к исходному коду

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

Метрики качества

Одно из преимуществ наличия истории ошибок состоит в том, что на основании исторического архива вы можете создавать метрики качества. Вы можете вернуться во времени и увидеть, где было обнаружено больше всего ошибок. Была ли большая часть ошибок найдена во время тестирования? Или они были обнаружены в бета-версии? А может они были обнаружены во время производства? Ответив на эти вопросы вы сможете обнаружить дыры в ваших стандартах качества и попытаться устранить их. Если большая часть ошибок была обнаружена во время тестирования, попытайтесь выяснить, почему разработчики отправляют тестировщикам код, который содержит так много ошибок. Если большинство ошибок найдено в бета-версии, необходимо установить, почему тестировщики пропустили их. Если большинство ошибок обнаружено во время производства, вы должны выяснить, почему их пропустила команда, выпускающая бета-версию. Хуже всего, когда ошибка достигает фазы производства. Устранение ошибок на этом этапе может обойтись вам очень дорого.

Суть в следующем: метрики качества позволяют изолировать узкие места в вашем цикле тестирования и отслеживать вносимые усовершенствования.

Тестирование отдельных частей

Я не могу достаточно подчеркнуть, насколько важно тестирование отдельных частей. Тестирование частей — это испытание разработчиком его собственного изделия. Перед тем, как отправить игру команде тестировщиков, разработчики, как предполагается, проверяют ее самостоятельно. Мой лучший совет — проверьте ваш код полностью! Вы не должны передавать код тестировшикам, если в нем есть любыне известные серьезные ошибки. Если это все-таки приходится делать, удостоверьтесь, что все известные ошибки вам задокументированы.

Лично я руководствуюсь следующими принципами, прежде чем передаю свой код в тестирование:

  • Код выполняется без сбоев 5000000 раз в одном потоке.
  • Код выполняется без сбоев 5000000 раз одновременно в 32 потоках.
  • Код работает без сбоев в течение одной недели.
  • Код без сбоев проходит полное возвратное тестирование.

Многопоточная часть может не применяться в вашей разработке. В действительности все зависит от того, что вы пишете. Не следуйте перечисленным правилам буквально, а используйте их для идей, применимых в вашем собственном процессе.

Суть в следующем — код, передаваемый вами тестировщикам, должне быть надежен как скала. Если он не настолько надежен, зачем вы передаете его?


Глава 4 - Фаза тестирования

Фаза тестирования

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

В фазе тестирования команда тестировщиков подвергает вашу программу различным испытаниям. Обычно у команды тестировщиков есть документ, называемый план возвратного тестирования (regression plan), содержащий список испытаний, которым следует подвергнуть вашу программу. Если все пункты плана возвратного тестирования выполняются без ошибок, ваш код значительно продвигается к стадии бета-версии.

Системные тестировщики также выполняют для вашего кода то, что называется стрессовым тестированием (negative testing). Предположим, у вас есть поле ввода в котором игрок указывает количество отрядов, которые будут отправлены в боевую группу. Системный тестировщик может ввести буквы вместо цифр, чтобы увидеть что случится. Также тестировщик может ввести отрицательное или слишком большое положительное число, чтобы посмотреть не приведет ли это к аварийному завершению вашего кода. Такова природа стрессового тестирования — проверить реакцию на непредвиденные события. Поверьте мне, пользователи будут делать все возможные вещи, которых вы никогда не ожидали.

Несколько простых этапов процесса тестирования показаны на рис. 4.3.

Рис. 4.3. Процесс тестирования

Рис. 4.3. Процесс тестирования

На рис. 4.3 показано как разработчики работают над кодом и помещают его в систему контроля исходного кода. Когда в систему помещен весь необходимый код, библиотекарь извлекает его и строит программу. После того, как компиляция завершится успешно, библиотекарь помечает код как готовый к тестированию, и возвращает его в систему контроля версий. Системные тестировщики извлекают отмеченную программу и приступают к ее испытаниям. Любые обнаруженные ошибки вводятся в систему отслеживания ошибок и передаются разработчикам для просмотра и устранения. Когда разработчики завершают исправление ошибок, цикл запускается снова.


Глава 4 - Фаза производства

Фаза производства

Ко времени перехода в фазу производства в вашем программном обеспечении не должно быть ошибок с серьезностью выше низкой. Если осталась хоть одна ошибка с высокой серьезностью, коду не место в этой фазе!

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

Если в процессе производства обнаруживаются какие-либо ошибки, информация о них передается системным тестировщикам для воспроизведения ошибки. Если ошибка воспроизведена тестировщиками, она передается разработчикам, чтобы те позаботились о ней. После устранения ошибки команда разработчиков отправляет сведения об этом тестировщикам для подтверждения. Как только тестировщики подтверждают, что ошибка исправлена, они передают информацию в производственную группу для выпуска версии с исправленными ошибками. Смотрите — разве не просто?

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


Глава 4 - Распространение

Распространение

Теперь у вас в руках есть полностью законченная игра; как поступать дальше? В то время как предыдущие разделы этой главы были посвящены внутренней работе цеха профессионалов, данный раздел посвящен тому, как славный малый представляет выпущенную игру публике. Может быть этим «славным малым» будете вы, может быть, кто-то другой, но в любом случае этот раздел поможет вам.

Существует несколько общих путей, которые позволяют заниматься распространением собственных изделий. К ним относятся выпуск условно-бесплатных программ, сайты аукционов и договор с издателем.

Условно-бесплатные программы

Ах, условно-бесплатные программы, западня для стремлений. Это простейший метод, который вы можете выбрать для распространения вашей игры. Существует много открытых сайтов, на которых любой желающий может разместить свою игру для бесплатного скачивания. Все, что вам необходимо сделать — это разместить на сайте установочный пакет для скачивания и указать адрес для людей, которые пожелают сделать пожертвование.

Единственная проблема с условно-бесплатными программами заключается в том, что ваши шансы заработать реальные деньги практически равны нулю. Честно говоря, гораздо больше шансов быть пораженным молнией. Если ваш основной мотив — заставить людей запускать вашу игру, то условно-бесплатные программы можно рассматривать как полезный метод. Только не ожидайте, что таким образом вы заработаете на жизнь.

Одно из возможных мест для размещения ваших файлов называется FilePlanet. Оно располагается по адресу www.fileplanet.com. Вы можете разместить файл размером до 200 Мбайт, и он будет доступен другим посетителям для скачивания. (Убедитесь, что указали адрес для тех людей, которые захотят сделать пожертвования!)

Альтернативный метод выпуска условно-бесплатных программ состоит в создании сокращенной версии вашей игры. Предположим, ваша игра посвящена сражениям Второй Мировой войны. Включите в свободно распространяемую версию всего лишь несколько миссий, и сообщите игроку, что за загрузку полной версии следует заплатить. Это замечательный способ продемонстрировать вашу игру и продать несколько копий.

Сайты аукционов

Другой метод — упаковать вашу игру и руководство к ней в коробку и выставить на продажу на eBay или другом Интернет-аукционе. Вы не должны столкнуться с трудностями, устанавливая цену на игру в районе 10 долларов; однако помните, что цена должна окупать затраченные усилия. Единственная проблема, связанная с этим методом, заключается в том, что у публики будет очень ограниченное представление о вашей игре. Чтобы противодействовать этой проблеме, вы должны разместить на общедоступных сайтах демонстрационную версию вашей игры. В демонстрационную версию поместите ссылку на свой сайт, чтобы заинтересовавшиеся люди могли получить информацию о том, как купить полную версию игры.

Большинство записывающих приводов CD-ROM в наши дни очень дешевы, настолько же дешевы чистые диски. Общая стоимость упаковки вашей игры не должна превысить двух долларов. Как видите, прибыль весьма значительна; только убедитесь, что вы продемонстрировали игру широкой общественности до того, как будете пытаться продать ее. Я так же не беспокоился бы о том, чтобы заранее упаковывать много копий. Делайте это по мере поступления заказов.

Вы можете даже пробовать продавать разрешения на загрузку вашей игры. Некоторые клиенты не согласятся на такой вариант, но если у вас есть подобная возможность, это более простой способ, чем продажа компакт-дисков. Одно из мест, где вам предоставят такую возможность — www.digibuy.com.

Издатели

Если вы думаете, что ваша игра достаточно хороша для издателя, можете попробовать предложить ее некоторым сетевым издательствам. Одна из таких компаний — Garage Games, которая может издать вашу игру за определенный процент с продаж. Дополнительную информацию можно получить на сайте www.garagegames.com.

Если вы хотите, чтобы вашу игру опубликовал издатель с мировым именем, вроде Electronic Arts, вам лучше иметь первосортную игру. Я настоятельно рекомендую, чтобы вы подумали об этом перед созданием вашей игры. Вам необходимо озвучить свой бизнес-план, кроме того вам потребуется большой опыт. В действительности, это наиболее трудная вещь которую вы можете попытаться сделать, если ваше имя еще никому не известно. Я советую вам самостоятельно выпустить несколько популярных игр, прежде чем вступить на этот путь.


Глава 4 - Итоги

Итоги

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

  • Тестирование отдельных частей — это тестирование, выполняемое командой разработчиков.
  • Системное тестирование — это тестирование, выполняемое командой тестировщиков.
  • Метрики качества используются, чтобы сократить количество ошибок, обнаруживаемых во время тестирования и производства.
  • Никогда не переходите к производству программы, если в ней есть известные ошибки.


Глава 5. Блочная графика

Краткий обзор

Одним из ключевых моментов разработки стратегической игры является создание ландшафтной библиотеки. Учтиывая, что действие большинства стратегических игр происходит на поверхности земли, ландшафт приобретает очень важное значение. Существуют тысячи способов работы с ландшафтом, одним из которых является метод блочной графики. В данной главе я опишу следующие аспекты блочной графики для стратегических игр:

  • Основы блочной графики.
  • Многослойные блоки.
  • Редактирование и хранение блоков.
  • Свойства блоков.
  • Визуализация блоков.


Глава 5 - Основы блочной графики

Основы блочной графики

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

  • Что такое блок?
  • Зачем использовать блоки?
  • Как создавать блоки?
  • Как отображать блоки?

Что такое блок?

Те, кого мучает этот вопрос, должны спросить себя. Шутка. Если серьезно, блок ни что иное, как кирпичик для строительства ландшафта. Подобно тому, как обычные строительные кирпичи по отдельности не представляют ничего особенного, блоки также достаточно просты. Чтобы получить имеющий смымсл результат, вы должны разместить вместе несколько блоков.

Чтобы понять, как блоки работают в ландшафтной библиотеке, вам достаточно взглянуть на обычную мозаику. Каждый элемент мозаики сам по себе ничего не значит, но все вместе они формируют замечательную картинку. Взгляните на рис. 5.1, чтобы увидеть мозаику из блоков.

Рис. 5.1. Мозаика из блоков на экране

Рис. 5.1. Мозаика из блоков на экране

Как видно на рис. 5.1, в каждом отдельном блоке мозаики мало смысла, но когда они расположены вместе с другими блокам, они формируют завершенную картину. Блоки для ландшафтной библиотеки работают полностью аналогичным способом. Думайте о земле, как о холсте, а о блоках ландшафта, как о кистях.

Теперь, когда вы знаете о том, что такое блоки, как насчет нескольких примеров? Взгляните на рис. 5.2, где показан пример нескольких блоков для ландшафтной библиотеки.

Рис. 5.2. Пример ландшафтных блоков

Рис. 5.2. Пример ландшафтных блоков

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

Зачем использовать блоки?

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

Использование блоков для экономии памяти

Давайте для примера возьмем карту игры Warcraft III, ширина которой равна 100 блокам, и высота также равна 100 блокам. Подобная сетка карты изображена на рис. 5.3.

Рис. 5.3. Пример карты для размещения блоков

Рис. 5.3. Пример карты для размещения блоков

Пока ничего особенного, вы просто получили карту размером 100 x 100 блоков. Всего получается 10 000 блоков. Теперь представим, что в качестве карты вы решили использовать не блоки, а одно большое растровое изображение. Чтобы вычислить объем требуемой для карты памяти, вы должны умножить общее количество блоков на размер одного блока. Эту концепцию демонстрируют следующие вычисления:

    100 блоков в ширину * 100 блоков в высоту = 10 000 блоков

    64 точки в ширину * 64 точки в высоту = 4 096 точек в блоке

    10 000 блоков * 4 096 точек * 1 байт (8 бит) = 40 960 000 байтов (256 цветов)

    10 000 блоков * 4 096 точек * 4 байта (32 бита) = 163 840 000 байтов

Ничего себе! Посмотрите на результат. Простая карта, размером 100 x 100 блоков требует для своего хранения колоссального объема памяти — 163 Мбайт. Даже если вы решите ограничиться 8-разрядным цветом (256-ю цветами), все равно придется выделить 41 Мбайт только для хранения карты. Если вы не читаете эту книгу в 2008 году, 163 Мбайт только для хранения игровой карты — это слишком много.

Хорошо, теперь, когда вы видели темную сторону, настало время для небольшого просвещения. Возьмем предыдущий пример, и вычислим объем памяти, необходимый для хранения той же карты размером 100 x 100, но в этот раз с использованием блоков.

    100 блоков в ширину * 100 блоков в высоту = 10 000 блоков

    64 точки в ширину * 64 точки в высоту = 4 096 точек в блоке

    100 блоков * 4 096 точек в блоке * 4 байта на точку = 1 638 400 байт

    10 000 блоков * 1 байт на блок = 10 000 байт

    10 000 байт + 1 638 400 байт = 1 648 400 байт всего

Взгляните на результат. Используя набор из 100 блоков вы можете создать карту размером 100 x 100, заняв всего два мегабайта памяти. Черт, вы можете использовать набор из 1000 блоков, и вам понадобится менее 20 Мбайт памяти.

Итак, вот что мы имеем. Первая причина для использования блоков в ваших стратегических играх заключается в экономии памяти.

Применение блоков для повторного использования графики

Повторное использование графики очень важно в разработке игр, время художника также важно, как и время разработичика (если не более важно!). Взгляните на рис. 5.4.

Рис. 5.4. Простая игровая карта

Рис. 5.4. Простая игровая карта

На рис. 5.4 вы видите травяное поле, усыпанное различными камнями. При дальнейшем осмотре вы заметите, что все камни очень похожи. Теперь представьте, что художник должен вручную разместить каждую скалу. Через некоторое время это становится очень утомительно, поскольку художник тратит все время на перемещение одной и той же графики, вместо того, чтобы создавать новое содержание.

Рис. 5.5 проливает новый свет на рис. 5.4.

Рис. 5.5. Простая игровая карта, состоящая из блоков

Рис. 5.5. Простая игровая карта, состоящая из блоков

Смотрите, карта составлена из блоков. Вы, возможно, не заметили этого раньше, но в действительности карта составлена из блоков всего двух видов. Благодаря использованию блоков, один и тот же шаблон камней используется в одном изображении несколько раз. Это уменьшает нагрузку на художников, поскольку размещением готовых блоков может заниматься разработчик (или кто-нибудь еще).

Использование блоков для динамического содержимого

Динамическое содержимое требуется для многих стратегических игр. Возьмем, например, генератор случайных карт. Одна из игр, использующих генератор случайных карт — Civilization. В ней вы указываете тип карты, которую хотите получить, и игра генерирует ее для вас на лету. Когда у вас есть блоки, вы пишете алгоритм, размещающий их таким образом, чтобы полученная карта выглядела привлекательно. А если бы нам требовалось создать одно гигантское растровое изовражение — это был бы кошмар. Вы можете представить себе поточечное рисование каждого элемента программой?

Достаточно взглянуть на рис. 5.5, чтобы увидеть, как алгоритм может генерировать динамическое содержимое. Вам достаточно просто установить генератор случайных чисел для размещения на карте блоков с камнями. Это позволяет создавать динамическое содержимое легко и быстро.

Как создавать блоки?

Ах, замечательная часть! Сначала вы можете любить создание блоков. В конце концов вы можете возненавидеть это занятие. Почему так? Создание блоков — утомительная и отнимающая много времени работа. Конечно, некоторые люди действительно наслаждаются ею, только я не отношусь к их числу.

Выбор размера блоков

Вы должны начать с выбора графического пакета, такого как Adobe Photoshop 7. Затем следует выбрать размер основного блока. Для наборов блоков, которые не являются изометрическими, размер блока обычно выбирается из степеней двойки, например 32 x 32, 64 x 64 или 128 x 128. Размер блока, который вы реально выберете, зависит от конкретной ситуации. Нет никаких стандартов выбора размера блоков, но вы должны учесть следующее:

  • Сколько различных блоков требуется для игры?
  • Сколько памяти будет выделено для графики?
  • Сколько блоков будут отображаться одновременно?

Первые два вопроса взаимосвязаны, поскольку количество блоков непосредственно влияет на объем, необходимой для их хранения памяти. Если ваша игра требует тысячи блоков, вы, вероятно, будете избегать использовать блоки большого размера. Если же в вашей игре используется всего несколько сотен блоков, скорее всего, использование блоков большого размера не вызовет никаких трудностей.

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

Рис. 5.6. Интерфейс с видимым фрагментом 16 x 16 блоков

Рис. 5.6. Интерфейс с видимым фрагментом 16 x 16 блоков

На рис. 5.6 командные кнопки и интерфейс занимают пространство в правой и нижней частях экрана. Остальная часть интерфейса отведена под состоящую из блоков карту. Если установить размер видимой части карты равным 16 x 16 блоков, разрешение экрана должно быть достаточно для корректного отображения. Чтобы вычислить максимально возможный размер блока следует разделить минимальное разрешение экрана на выбранное количество отображаемых блоков. Воспользуемся для определения максимального размера блока следующими формулами:

    int(Ширина экрана в точках % Количество блоков в ширину)

    int(Высота экрана в точках % Количество блоков в высоту)

Предположив, что минимальное разрешение экрана равно 800 x 600, мы можем вычислить, что максимально возможный размер блоков на рис. 5.6 равен (800 / 16) = 50 точек в ширину и (600 / 16) = 37 точек в высоту. Если вы придерживаетесь метода, в котором размеры блоков равны степеням двойки, то максимальный размер блоков ограничен 32 точками в ширину и 32 точками в высоту.

Если вы чувствуете, что блоки размером 32 x 32 точки слишком малы, следует пересмотреть количество одновременно отображаемых блоков карты. В действительности все зависит от того, сколько подразделений вы планируете показывать одновременно. Если ваша игра требует, чтобы игрок управлял огромными армиями, предпочтительнее использовать блоки небольшого размера. Если в игре будет всего несколько подразделений, как, например в Warcraft III от Blizzard, использование блоков большого размера не вызовет никаких проблем.

Определение необходимых блоков

Теперь, когда вы знаете размер базового блока для вашей игры, можно перейти к фактическому созданию блоков. С чего же начать? Я начинаю строительство с создания фрагментов земной поверхности для игры. Например, в играх Age of Empires и Age of Wonders от Ensemble Studios, базовыми строительными блоками библиотеки работы с ландшафтом являются трава, почва или даже снег.

Вы знаете, что следует создать базовые блоки для изображения травы, почвы, снега и т.д. Теперь, когда вы уже вступили на этот путь, следует внести разнообразие в пейзаж. Кто захочет играть на огромных полях травы, верно? Если вы выбираете в качестве базового блока изображение травы, можно подумать о добавлении к списку блоков камней, деревьев, воды, кустарников или даже небольших участков почвы. Чтобы подхлестнуть творческие способности, посмотрите на рис. 5.7.

Рис. 5.7. Несколько блоков из набора

Рис. 5.7. Несколько блоков из набора

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

Как отображать блоки?

Теперь начинается самая интересная часть! Отображение блоков в теории выглядит очень просто, но выполнить его на практике достаточно сложно. Насколько трудным может быть отображение блоков в сетке? Вы заметили ключевое слово в предыдущем предложении? Если вы решили, что это слово «сетка», — наградите себя аплодисментами.

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

Отображение двухмерной сетки

Простейший способ отображения блоков — использование метода двухмерной сетки. В этом методе вы отображаете на экране горизонтальные и вертикальные столбцы из блоков. Чтобы увидеть данный метод в действии, взгляните на рис. 5.8.

Рис. 5.8. Двухмерная сетка для размещения блоков

Рис. 5.8. Двухмерная сетка для размещения блоков

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

int x,y;
// отображение сверху вниз
for(y = 0; y < 10; y++) {
   // Отображение слева направо
      for(x = 0; x < 10; x++) {
      // Ваша функция для отображения блока
      vDisplayTile(x, y);
   }
}

В коде есть два цикла. Первый цикл увеличивает позицию блока по вертикали. Второй цикл увеличивает позицию блока по горизонтали. Поместив цикл перебора позиций по горизонтали внутрь цикла перебора позиций по вертикали, вы получаете рисунок всей сетки. Рис. 5.9 показывает, в каком порядке рисуются блоки.

Рис. 5.9. Порядок двухмерных блоков

Рис. 5.9. Порядок двухмерных блоков

Обратите внимание, что нумерация блоков начинается с 0 в верхнем левом углу и заканчивается 99 в нижнем правом углу. Разве это не просто?

Возможно, вы задаетесь вопросом, как выглядит функция vDisplayTile(). Перед тем, как написать функцию отображения блока, вы должны сперва подумать о том, как будет храниться карта блоков. Общепринятый метод хранения карты блоков — представление карты в виде одного большого массива. Взгляните на следующий пример кода, чтобы увидеть использование этого метода:

// Глобальный массив для карты блоков
int g_iTileMap[100]; // 10*10 = 100 ячеек необходимо

// Прототип функции отображения блока
void vDisplayTile(int x, int y);

void main()
{
   int x, y;

   // Сверху вниз
   for(y = 0; y < 10; y++) {
      // Слева направо
      for (x = 0; x < 10; x++) {
          // Отображение блока
          vDisplayTile(x, y);
      }
   }
}

void vDisplayTile(int x, int y)
{
   int iTile;
   int tileWidth = 64;
   int tileHeight = 64;
   int mapWidth = 10;

   //
   // Вычисляем номер блока, расположенного
   // по полученным координатам x и y.
   //
   iTile = g_iTileMap[(x + (y * mapWidth))];

   // Отображение на экране растрового изображения
   // Следующая функция является фиктивной
   // и представляет лишь псевдокод. Чтобы код
   // работал вам необходимо заменить ее на
   // настоящую функцию рисования блока.
   //
   DrawBitmap(iTile, (x * tileWidth), (y * tileHeight));
}

В приведенной выше функции main() программа в цикле последовательно перебирает блоки и вызывает функцию vDisplayTile(). Код функции отображения блока начинается с вычисления, где в массиве блоков расположено значение, относящееся к данному блоку, и извлечения этого значения. При вычислении берется координата X и к ней прибавляется координата Y умноженная на ширину карты. Эту концепцию иллюстрирует рис. 5.10.

Рис. 5.10. Вычисление местоположения в массиве

Рис. 5.10. Вычисление местоположения в массиве

Как вычислить местоположение в массиве

Получение местоположение блока в массиве начинается с координаты X. Рассмотрим пример нахождения в массиве блока с координатами 5,5. На рис. 5.10 от блока в верхнем левом углу мы перемещаемся на пять блоков вправо. В результате мы окажемся в позиции, отмеченной на рисунке буквой A.

Теперь к текущей позиции вы должны прибавить координату Y блока, умноженную на ширину карты. Ширина карты равна десяти блокам, поэтому мы прибавляем 10 * 5 (координата Y) к текущей позиции. Следуйте по стрелке, размещенной справа от позиции, помеченной буквой A, и вы увидите, что она заканчивается в искомой позиции массива, отмеченной буквой B.

Итак, следуя рис. 5.10, мы получаем следующую формулу:

    X (A) + (Y * Ширина карты) = Позиция в массиве (B)

На рис. 5.10 массив для хранения карты представлен в виде сетки, но в действительности это один линейный участок памяти. Его просто легче представить себе, если изобразить в виде сетки. Преимущество использования массива состоит в том, что он непосредственно соответствует тому, что видит пользователь. Нет никаких сложных связанных списков, которые следует обходить, только один простой массив.

Как вычислить видимую позицию

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

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

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

Третий параметр, координата Y, действует аналогично второму параметру, за исключением того, что задает позицию выводимого изображения по вертикали.

Вернемся к примеру на рис. 5.10. Координаты X и Y в пикселах для блока с координатами на карте 5, 5 будут равны (5 * 64), (5 * 64), или 320, 320. Большинство графических функций позволяют указать координаты X и Y растрового изображения, так что пример не должен вызывать никаких затруднений.

Вот как вычисляются координаты X и Y для блока в пикселях:

    Координата X в пикселях = Координата X блока на карте * Ширина блока

    Координата Y в пикселях = Координата Y блока на карте * Высота блока

Отображение изометрических блоков

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

Множество игр используют изометрические блоки, например, Age of Empires, Civilization и Command & Conquer. Основное преимущество изометрических блоков в том, что они позволяют реализовать качественный трехмерный вид без использования настоящей трехмерной графики. Однако, это уже не является причиной для беспокойства, поскольку компьютеры большинства игроков замечательно справляются с трехмерной графикой. Поэтому большинство стратегий реального времени сегодня используют трехмерную графику. Использование изометрических блоков весьма ограничено.

Хотя большинство коммерческих игр прекратили использование наборов изометрических блоков, они еще жизнеспособны и вы можете применять их. Взгляните на рис. 5.11, чтобы увидеть карту из изометрических блоков.

Рис. 5.11. Базовая карта изометрических блоков

Рис. 5.11. Базовая карта изометрических блоков

Фактически, карта для изометрических блоков является картой для двухмерных блоков, повернутой на 45 градусов по двум осям. Это создает интересную сетку, поскольку с точки зрения зрителя блоки теперь кажутся повернутыми. Поскольку карта развернута, координаты X и Y теперь не соответствуют экранным координатам X и Y. На рис. 5.11 новые оси соответственно помечены. Обратите внимание, что ось X направлена от середины верхней части экрана к к его нижнему правому углу. Ось Y начинается также в середине верхней части экрана, но направлена к нижнему левому углу. В результате блок с координатами 0,0 расположен в центре верхней части экрана. Не слишком простой метод отображения!

Как только мы разместили начальный блок с координатами 0, 0, достаточно легко вывести алгоритм дальнейшего рисования. Он практически тот же, что использовался для отображения двухмерных блоков, за исключением того, что координаты блоков слегка изменились. Взгляните на рис. 5.12, чтобы увидеть, в каком порядке отображаются изометрические блоки.

Рис. 5.12. Порядок отображения изометрических блоков

Рис. 5.12. Порядок отображения изометрических блоков

На рис. 5.12 видно, что блоки по прежнему отображаются в сетке, вот только сама сетка повернута. В результате для каждого отображаемого блока необходимо указывать смещения координат X и Y.

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

Отображение трехмерных блоков

Да, святой грааль графики, — трехмерная графика! Большинство стратегических игр сегодня используют трехмерные блоки. У трехмерных блоков много преимуществ, в том числе:

  • Динамическое отображение.
  • Вращение.
  • Глубина.

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

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

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

Так как же отображать трехмерные блоки? Легко! Вы всего лишь преобразуете их в местоположение и применяете обычную функцию рисования. Действительно, трехмерные блоки отображаются точно так же, как и двухмерные. Вы просто помещаете их в сетку и визуализируете от более далеких к более близким. Составленная из трехмерных блоков карта изображена на рис. 5.13.

Рис. 5.13. Пример карты из трехмерных блоков

Рис. 5.13. Пример карты из трехмерных блоков

Хм-м-м, не так уж интересно? Рис. 5.13 не слишком отличается от карты из двухмерных блоков. Это важный момент. Трехмерная графика не означает драматического изменения вида вашей стратегической игры; она только лишь добавляет гибкости и открывает для вас новые возможности. Чтобы применять в стратегической игре трехмерную графику не требуется переход к трехмерному виду от первого лица. Вы можете придерживаться традиционного для стратегий реального времени вида и при этом использоваить трехмерную графику. В этом и заключается вся красота.


Глава 5 - Многослойные блоки

Многослойные блоки

Теперь, когда вы познакомились с основами, пришло время спуститься ниже и погрузиться в детали реализации блочной графики для вашей игры. Первой темой, которую мы затронем, будут многослойные блоки. Эта техника важна для отображения блоков, поскольку без нее ваша библиотека блочной графики будет очень громоздкой.

Многослойные блоки используются для добавления деталей и глубины составленной из блоков карте.

Добавление деталей к блокам

Первая и главная причина использования многослойных блоков заключается в необходимости добавления деталей вашей карте. Это выполняется путем простого рисования нескольких слоев блока поверх друг друга. Звучит достаточно просто, и действительно так же просто выполняется. Самое сложное — вычислить в каком слое какой блок рисовать. Взгляните на три состоящих из блоков карты, которые приведены на рис. 5.14.

Рис. 5.14. Три блочных карты с различным уровнем детализации

Рис. 5.14. Три блочных карты с различным уровнем детализации

Если смотреть на рис. 5.14 слева направо, количество деталей на блочных картах постепенно увеличивается. Первая карта состоит из блоков только одного типа — с изображением травы. Это не слишком захватывающий ландшафт, поскольку смотреть практически не на что.

На второй карте добавлены камни. Это уже двухслойная карта. Первый слой содержит фоновые блоки с изображением травы, а во второй слой включены блоки с дополнительными деталями (камнями).

На третей карте помимо камней добавлены еще и деревья. Первый слой содержит обычные фоновые блоки, во второй слой включены блоки с дополнительными деталями фоновых блоков, а новый, третий, слой содержит блоки с изображениями деревьев.

Возможно, вы задаетесь вопросом, почему все эти блоки не помещены в один и тот же слой. Дело в том, что при использовании только одного слоя количество блоков увеличивается, и в результате у вас окажется больше блоков, чем действительно необходимо. Взгляните на блочные карты, изображенные на рис. 5.14. Если бы на средней карте использовался только один слой блоков, блок с изображением камней мог бы выглядеть так, как изображено на рис. 5.15.

Рис. 5.15. Блок с изображением травы с камнями на ней

Рис. 5.15. Блок с изображением травы с камнями на ней

Блок на рис. 5.15 содержит и текстуру травы и текстуру камней. Это прекрасно и хорошо работает, но что случится, когда для разнообразия вы решите добавить другие фоновые блоки, например с изображением песка? В результате вам потребуется создавать новый блок и изображением камней на песке. Теперь у вас есть четыре блока: песок, трава, камни на песке и камни на траве.

Рис. 5.16 показывает, как использование слоев решает проблему и приводит к менее интенсивному расходованию ресурсов.

Рис. 5.16. Три блока: трава, песок и камни

Рис. 5.16. Три блока: трава, песок и камни

С блоком с изображением камней, изображенным на рис. 5.16, связан альфа-канал, чтобы его изображение смешивалось с базовой текстурой. Это позволяет добавлять блок с изображением камней отдельным слоем поверх травы или песка, вместо того чтобы хранить отдельный блок с изображением камней для каждого базового блока. Я знаю, что в моем примере мы сэкономили только одни блок, но в законченной игре будут сотни, или даже тысячи блоков, и возможности для расточительства поистине безграничны, особенно если вы неблагоразумно используете блоки.

Переходные блоки

Помимо замечательного мира камней и песка, на блочной карте есть и другие важные блоки. Первыми приходят на ум граничные блоки, так же называемые переходными блоками. Они используются для перехода от блоков с одной текстурой к блокам к другой. Помните, как в предыдущем разделе я использовал в качестве фоновых текстур блоков изображения травы и песка? Если мы возьмем эти два блока и сделаем из них небольшую карту, то в результате у нас получится что-то вроде рис. 5.17.

Рис. 5.17. Блочная карта из травы и песка

Рис. 5.17. Блочная карта из травы и песка

Первое, что бросается в глаза, — ужасный вид этой карты! Блоки с изображением песка и травы выделяются как 100-летний финалист на QuakeCon. Причина заключается в том, что нет никаких блоков, изображающих плавный переход от травы к песку. Переходные блоки — прошу на сцену.

Добавив к карте, изображенной на рис. 5.17 несколько блоков с дополнительными деталями, вы получите гораздо лучший результат смешивания текстур, изображенный на рис. 5.18.

Рис. 5.18. Блочная карта с плавными переходами от травы к песку

Рис. 5.18. Блочная карта с плавными переходами от травы к песку

Я знаю, что изображение на рис. 5.18 не объясняет использование переходных блоков, но разве оно не выглядит лучше, чем рис. 5.17? Не поддавайтесь раздражжению, ведь для того, чтобы совершить этот подвиг потребовалось лишь несколько дополнительных блоков.

Сперва нам потребуются блоки для переходов от травы к песку в направлении с севера на юг. Они обеспечивают приятно выглядящие переходы горизонтальных линий блоков, что иллюстрирует рис. 5.19.

Рис. 5.19. Блоки для переходов в направлении с юга на север

Рис. 5.19. Блоки для переходов в направлении с юга на север

В левой части рис. 5.19 изображен блок в верхней части которого (на севере) изображена трава, а в нижней части (на юге) расположена прозрачная область. Накладывая изображение травы на тестуру с изображением песка, получаем замечательный результат. Этот же подход остается правильным и для блока в правой части рисунка. Просто в этом случае изображение травы расположено внизу (на юге), а прозрачная область — сверху (на севере). Рассмотренные блоки охватывают первые два сценария перехода.

Теперь у нас есть блоки, обеспечивающие плавную смену текстуры в направлении с севера на юг. Следующий шаг — обеспечить аналогичную смену текстур в направлении с востока на запад. Это делают блоки, изображенные на рис. 5.20.

Рис. 5.20. Блоки для переходов в направлении с востока на запад

Рис. 5.20. Блоки для переходов в направлении с востока на запад

На блоке, изображенном в левой части рисунка, трава расположена слева (на западе), а прозрачная часть — справа (на востоке). Затем изображение накладывается на текстуру с изображением песка. Как обычно, это замечательно работает и для блоков, на которых трава располагается с правой стороны. Здесь мы не узнали ничего нового, только рассмотрели следующие два сценария перехода.

Возможно, вы думаете, что на этом работа закончена. А как насчет углов? Правильно, вам еще необходимы угловые блоки для соединения блоков с переходом изображения с севера на юг и блоков с переходом изображения с востока на запад. Обратите внимание на четыре угла, изображенных на рис. 5.21.

Рис. 5.21. Переходные блоки для углов

Рис. 5.21. Переходные блоки для углов

Блоки, изображенные на рис. 5.21 используются для объединения ранее созданных переходных блоков. Эта необходимая группа блоков позволяет вам создавать на карте произвольные области с текстурой, отличающейся от базовой, а не ограничиваться только горизонтальными или вертикальными рядами текстур, пересекающими всю карту. Карта на рис. 5.21 хорошо иллюстрирует вышесказанное, показывая как угловые блоки могут применяться для формирования на карте округлой области. Вы можете использовать и другие переходные блоки, чтобы формировать области большего размера, как показано на рис. 5.22.

Рис. 5.22. Совместное использование угловых блоков с другими переходными блоками

Рис. 5.22. Совместное использование угловых блоков с другими переходными блоками

Видите как угловые блоки работают вместе с другими переходными блоками при создании приятно выглядящих больших квадратных и прямоугольных областей на карте? Угловые блоки обеспечивают хорошо выглядящие скругленные углы. Теперь подумайте, что случится если область которую вы сформируете не будет квадратной или прямоугольной? Если вы предположили, что карте не хватает некоторых ключевых блоков — получите 100 очков. (Я не знаю что это за 100 очков, но полагаю, что это счет игры!) Рис. 5.23 иллюстрирует недостатки созданных к данному моменту блоков.

Рис. 5.23. Ошибки, возникающие при использовании четырех угловых блоков

Рис. 5.23. Ошибки, возникающие при использовании четырех угловых блоков

Обратите внимание, на неестественные бреши области ландшафта, изображенной на рис. 5.23 в том месте, где блоки поворачивают на 90 градусов. Это вызвано тем, что у нас отсутствуют перевернутые угловые блоки. Решение проблемы достаточно простое, поскольку кроме перевернутых угловых блоков нам ничего не потребуется. Применение перевернутых угловых блоков показано на рис. 5.24.

Рис. 5.24. Перевернутые угловые блоки в действии

Рис. 5.24. Перевернутые угловые блоки в действии

Блоки на рис. 5.24 позволяют корректно реализовать практически любую возмжную комбинацию переходов между блоками с изображением травы и блоками с изображением песка. У вас есть горизонтальные переходные блоки, вертикальные переходные блоки, угловые блоки и перевернутые угловые блоки. Как видите, перевернутые угловые блоки замечательно дополняют остальные блоки карты.

Блоки с изображениями дорог

С технической точки зрения блоки с изображениями дорог ничем не отличаются от блоков с деталями ландшафта и переходных блоков. Но при создании блоков для изображения дорог могут возникнуть проблемы не заметные с первого взгляда. Чтобы уберечь вас от тех ошибок, которые я совершил в прошлом, я кратко рассмотрю создание блоков для изображения дорог.

Предположим, игрок занят расширением своей империи в вашей игре, и он хочет построить новую дорогу. Что произойдет, если он разместит блок с изображением дороги рядом с еще одним (или не одним) таким же? Будете ли вы проверять все присутствующие на карте блоки с изображением дорог, чтобы посмотреть как они изменены? Будете ли вы проверять блоки, размещенные вокруг нового добавляемого блока, чтобы посмотреть не требуют ли они изменения? Решения, решения! Чтобы лучше проиллюстрировать рассматриваемую концепцию, я привожу рис. 5.25.

Рис. 5.25. Новый блок с изображением дороги помешается рядом с существующими дорогами

Рис. 5.25. Новый блок с изображением дороги помешается рядом с существующими дорогами

Итак, взглянем на рис. 5.25. На только что созданный блок с изображением дороги указывает большая стрелка. Представленная здесь проблема заключается в необходимости вычислить, каким образом должно быть повернуто изображение дороги на вновь добавляемом блоке. Мозг сообщает вам: «Элементарно — это должен быть угловой блок». (С робкой надеждой мозг спрашивает, не ошиблись ли вы в выборе профессии.) Независимо от способа, по ряду причин вы твердо уверены, что здесь необходим угловой блок.

Теперь давайте подумаем о процессе принятия решения. Первое, что вы скорее всего сделаете, — внимательно рассмотрите каждый блок, находящийся рядом с новым. Исследуя по одному соседние блоки вы получаете представление для чего необходим новый блок. Этот процесс показан на рис. 5.26.

Рис. 5.26. Исследуем блоки, расположенные вокруг нового

Рис. 5.26. Исследуем блоки, расположенные вокруг нового

На рис. 5.26 вы видите, что я перебрал все блоки, расположенные по соседству с вновь добавляемым, и пометил те, которые также содержат изображение дорог. В результате были помечены два соседних блока — один на севере и один на востоке.

Соседние блоки помечены, что дальше? Теперь воспользуемся справочной таблицей, чтобы определить, какой блок должен использоваться в данной ситуации. Как создается справочная таблица? В этом нет ничего сложного: двигаясь по часовой стрелке вы назначаете каждому блоку номер, являющийся последовательно увеличивающейся степенью двойки. В этом методе первому блоку назначается номер 1, второму — 2, третьему — 4, четвертому — 8. Звучит знакомо? Надеюсь, что да, учитывая что практически все, что вы делаете на компьютере основано на этом методе! Рис. 5.27 показывает, какие значения присвоены соседним блокам.

Рис. 5.27. Соседние блоки с присвоенными номерами

Рис. 5.27. Соседние блоки с присвоенными номерами

Помечены два блока, поэтому вы берете назначенные им числа и складываете их вместе. В результате получается 1 + 2 = 3. Проконсультировавшись со справочной таблицей, вы увидите что блок с номером 3 — это угловой блок с изображением дороги. Не волнуйтесь о разгадывании справочной таблицы — она представлена на рис. 5.28. Счастливого Рождества!

Рис. 5.28. Справочная таблица для блоков с изображением дорог

Рис. 5.28. Справочная таблица для блоков с изображением дорог

Как добавить трехмерные деревья

Что может вызвать трудности при добавлении деревьев к игре? Множество самых разных вещей. Во-первых, у деревьев есть ветви и листва, сквозь которую вы можете видеть. Во-вторых, деревья обычно колеблются под дуновениями ветра. В-третьих они должны по крайней мере казаться объемными. Графика Doom и Doom II больше не считается превосходной. Дни использования двухмерных спрайтов вместо трехмерных моделей остались далеко в прошлом. Или нет?

Упомянутые выше три фактора образуют уникальную проблему для разработчика стратегических игр. Проблема вызвана тем, что в большинстве игр используется изометрический вид или вид под углом. Если вы делаете игру, в которой камера направлена вертикально вниз, вы не столкнетесь с данной проблемой. Вы просто рисуете дерево, кустящееся во все стороны. В случаях, которые я рассматриваю, все выглядит по другому.

Взгляните на небольшую пальмовую рощу, изображенную на рис. 5.29.

Рис. 5.29. Пальмовая роща со зданием в центре

Рис. 5.29. Пальмовая роща со зданием в центре

Обратите внимание, как хорошо пальмы на рис. 5.29 передают глубину изображения. Деревья расположены и перед зданием, и позади него. В целом эффект создаваемый деревьями довольно убедителен. Поверите ли вы мне, если я скажу, что для создания этой сцены потребовалось всего лишь 300 треугольников? Сцена выполнена путем использования двухмерных изображений деревьев вместо настоящих трехмерных моделей. Чтобы увидеть фокусника позади занавеса, взгляните на рис. 5.30.

Рис. 5.30. Разоблачение поддельных трехмерных деревьев

Рис. 5.30. Разоблачение поддельных трехмерных деревьев

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

Анимированные блоки

Следующим важным типом блоков являются различные виды анимации. Почти в каждой стратегической игре на карте присутствуют какие-нибудь анимированные блоки. Наиболее распространенный вид анимированных блоков — вода. Неподвижная вода выглядит не слишком убедительно,поэтому для ее оживления в играх часто применяется анимация.

Так как же выполнить анимацию блоков? Один из простейших методов — выделить для выполнения анимации какой-нибудь диапазон блоков. Предположим, для выполнения анимаций мы выделили блоки с 1 по 100, и каждая анимационная последовательность будет состоять из 10 кадров. Это дает вам 10 анимированных блоков с 10 кадрами в каждой анимации. Если в цикле визуализации встречается блок с номером 0, 10, 20, 30, 40, 50, 60, 70, 80 или 90, программа прибавляет к номеру блока номер текущего кадра анимации, чтобы получить номер того блока, который должен быть выведен. Когда счетчик кадров анимации достигает значения 10, он сбрасывается в 0 и цикл анимации снова повторяется с самого начала. Взгляните, например, на следующий псевдокод:

Anim_Frame = 0;
Loop Start = 0; Loop < #TilesToDisplay; Loop++
   // Визуализация анимированных блоков
   If(Current_Tile.type == TYPE_ANIMATION)
      RenderTile(Current_Tile.value + Anim_Frame);
   // Визуализация обычных блоков
   Else
      RenderTile(Current_Tile.value);

   // Увеличение счетчика кадров
   Anim_Frame++;
   If(Anim_Frame == 10)
      Anim_Frame = 0;
Loop Repeat

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


Глава 5 - Редактирование и хранение блоков

Редактирование и хранение блоков

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

  • Хранение блоков в двухмерном массиве.
  • Хранение многослойных блоков.
  • Реализация класса для блочной графики.

Хранение блоков в двухмерном массиве

Простейший метод хранения блоков — использование двухмерного массива. Первое измерение образует горизонтальные строки, а второе — вертикальные столбцы. Взгляните на следующий код:

// Установка размеров карты
#define TilesWide   10
#define TilesHigh   10
// Объявление массива с картой
int iTileMap[TilesWide][TilesHigh];
// Заполнение всей карты блоком с номером 0
memset(&iTileMap, 0, (TilesWide * TilesHigh) * sizeof(int));

Как видите, в приведенном фрагменте кода объявлен двухмерный массив целых чисел. Поскольку и ширина и высота карты составляют 10 блоков, вся карта содержит 100 блоков. Координаты верхнего левого блока равны 0, 0, а координаты нижнего правого блока — 99, 99. Такая карта изображена на рис. 5.31.

Рис. 5.31. Блочная карта из 100 элементов

Рис. 5.31. Блочная карта из 100 элементов

В приведенном выше фрагменте кода я очищаю карту, заполняя ее блоками с номером 0. Это общепринятая практика, поскольку блок с номером 0 является базовым блоком для визуализации. Обычно этот блок содержит изображение земли или даже является специальным блоком-пустышкой. Блок-пустышка — это обычный блок, на котором расположена предупредительная надпись, например «НЕ ИСПОЛЬЗУЕТСЯ». Наличие такого блока позволяет сразу увидеть те области карты, блоки которых не были инициализированы.

Итак, теперь у вас есть двухмерный массив блоков. Как изменить блок? К счастью, очень просто. Скажем, вы хотите изменить блок, расположенный на два блока левее и на три блока ниже начала карты, чтобы его значение было равно 15. Для этого достаточно написать следующий код:

iTileMap[2][3] = 15;

Все, что надо сделать — присвоить желаемое значение расположенному в требуемой позиции элементу массива.

Взгляните на следующий код и попробуйте представить, как будет выглядеть полученная в результате карта. Подсказка: блок с номером 15 изображает заштрихованный квадрат, а блок с номером 0 — пустой квадрат.

// Сверху вниз
iTileMap[0] [0] = 15;
iTileMap[0] [1] = 15;
iTileMap[0] [2] = 15;
iTileMap[0] [3] = 15;
iTileMap[0] [4] = 15;
// Слева направо
iTileMap[1] [4] = 15;
iTileMap[2] [4] = 15;
iTileMap[3] [4] = 15;
// Снизу вверх
iTileMap[3] [3] = 15;
iTileMap[3] [2] = 15;
iTileMap[3] [1] = 15;
iTileMap[3] [0] = 15;
// Справа налево
iTileMap[2] [0] = 15;
iTileMap[1] [0] = 15;

Ответ показан на рис. 5.32.

Рис. 5.32. Карта с составленной из блоков буквой О

Рис. 5.32. Карта с составленной из блоков буквой О

Если вы решили, что теперь на карте находится составленное из блоков изображение буквы «О» или цифры 0, похвалите себя. Код начинается с рисования линии, образующей левую грань буквы О, начинающуюся сверху и заканчивающуюся внизу. Следующий блок кода рисует нижнюю чась буквы О слева направо. Затем код рисует правую часть буквы О снизу вверх. И, наконец, код завершает рисование буквы О, проводя линию справа налево.

Не кажется ли вам, что вы уже где-то это видели? Вполне возможно, если вы раньше рисовали какое-либо избражение по точкам. Если смотреть в корень, рисование изображения по точкам весьма похоже на создание блочной карты.

Хранение многослойных блоков

Однослойный двухмерный массив прекрасно подходит для унылых карт, но ведь вы хотите, чтобы ваши карты были интересными и захватывающими, верно? Раз так, вам нужны несколько слоев и, соответственно, несколько измерений в массиве, где хранятся блоки. Простейший способ сохранить несколько слоев — добавить еще одно измерение к массиву. Как это сделать показывает приведенный ниже код:

// Установка размеров карты
#define TilesWide   10
#define TilesHigh   10
#define TileLayers  3

// Объявление массива для хранения карты
int iTileMap[TilesWide][TilesHigh][TileLayers];
// Заполнение всей карты блоком с номером 0
memset(&iTileMap, 0, (TilesWide * TilesHigh * TileLayers) * sizeof(int));

Курсивом выделены те части кода, которые отличаются от рассмотренного ранее примера. Во-первых я добавил новое определение, задающее количество слоев на карте. Я произвольно установил количество слоев равным трем. Вы можете установить это значение соогласно вашим желаниям; число три я использую только для примера.

Следущим фрагментом кода, который претерпел изменения. является добавление измерения к массиву хранения блоков. Чтобы хранить несколько слоев, вам достаточно добавить тоько одно измерение.

Последнее изменение кода относится к инициализации массива. Раз у вас несколько слоев, вам требуется очистить больше элементов массива.

Теперь у вас есть многомерный и многослойный массив, который вы можете заполнять различными значениями блоков. Взгляните на карту, представленную на рис. 5.33.

Рис. 5.33. Блочная карта с двумя слоями

Рис. 5.33. Блочная карта с двумя слоями

Первый слой карты содержит два типа блоков с изображанеием травы. Второй слой карты содержит блоки с изображением камней. Взгляните на код, предназначенный для генерации такой карты:

//
// Установка базовых блоков
//

// Вертикаль
for(int i = 0; i < 10; i++) {
   // Горизонталь
   for(int j = 0; j < 10; j++) {
      // Случайный выбор базового блока
      iTileMap[i][j][0] = rand() % 2;
   }
}

//
// Добавляем блоки с деталями
//

iTileMap[5][5][1] = 3;
iTileMap[3][9][1] = 3;
iTileMap[1][7][1] = 3;
iTileMap[8][8][1] = 3;
iTileMap[6][3][1] = 3;
iTileMap[4][1][1] = 3;

Первая часть кода случайным образом присваивает значение каждому блоку основного слоя. Благодаря этому получается более естественный вид земли, чем при использовании фиксированных шаблонов.

В следующем слое вручную размещаются блоки с изображением камней. Положение блока в массиве определяется заданными координатами [x][y][z], где координата z отвечает за слой, в котором будет находиться блок. Задавая значение z равным 1, мы указываем игре, что изображения камней помещаются во втором слое.

Создание класса для представления блоков

Простейший метод хранения блочной карты уже определен, так что настало время погрузиться в объектно-ориентированный мир и создать класс для представления блоков.

Заголовок класса

Существует несколько моментов, которые следует рассмотреть перед созданием классов для блочной графики. Во-первых, вам необходим класс для блоков и класс для блочных карт. Класс для блоков используется для описания отдельных блоков, в то время как класс для блочных карт используется для определения групп блоков. Приведенный ниже фрагмент кода показывает пример заголовка класса для описания блоков:

class TileClass
{
   private:
      int*m_iValue;
      intm_iNumLayers;
      float *m_fRotX;
      float *m_fSize;

   public:
      TileClass();
      ~TileClass();
      int iGetValue(int layer);
      void vSetValue(int value, int layer);
      float fGetRot(int layer);
      void vSetRotation(float fRot, int layer);
      float fGetSize(int layer);
      void vSetSize(float fSize, int layer);
      void vSetNumLayers(int layers);
};

В классе есть четыре закрытых члена. Первый из них, m_iValue, хранит значение блока. Предположим, у вас есть 1000 загруженных в память растровых изображений, предназначенных для рисования блоков. Значение равное 1, указывает, что для блока используется первое загруженное в память растровое изображение. Таким образом, значение блока — это ни что иное, как индекс в массиве растровых изображений.

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

Следующим в поле нашего внимания попадает член класса с именем m_fRotX. Эта переменная определяет угол поворота рассматриваемого блока. Она действительно полезна, чтобы добавить вашим картам разнообразия не добавляя нового содержимого. Чтобы создать полностью новую графику вам достаточно просто повернуть блок на 90 или больше градусов. Я использую здесь значение с плавающей точкой только потому, что в последнее время работаю исключительно с трехмерной графикой. Если вы создаете библиотеку для двухмерной блочной графики, то можете использовать здесь целое число.

Теперь мы переходим к члену данных m_fSize. Эта переменная содержит размер блока. Для трехмерного мира размер измеряется в единицах трехмерной системы координат. Для двухмерного мира размер задается в пикселах. Если вы используете блоки 64 x 64 точки, размер будет равен 64. Обратите внимание — я предполагаю, что блоки будут квадратными. Если вы решите использовать прямоугольные блоки, вам придется использовать для хранения их размера две переменные, например m_fSizeX и m_fSizeY.

Переменные класса уже продефилировали перед вами, так что теперь настало время для функций класса. Первые две функции, которые вы видите — это конструктор и деструктор класса. В них нет ничего специального — обычная чепуха С++.

Первая заслуживающая отдельного упоминания функция класса называется iGetValue(). Она применяется для получения значения указанного слоя блока. Это наиболее часто вызываемая функция класса, поскольку обращение к ней происходит каждый раз, когда рисуется данный блок карты. Приведенная в примере функция возвращает при вызове значение целочисленной переменной m_iValue. Я предпочитаю этот метод, поскольку использую возвращаемое число в качестве индекса в моем массиве данных блоков. Вы можете захотеть, чтобы функция возвращала дескриптор растрового изображения, структуру данных или какой-либо другой тип данных, используемых в вашей библиотеке.

Следующая функция класса, vSetValue(), получает два параметра. Первый параметр является целым числом, определяющим значение блока, которое вы хотите установить. Второй параметр задает слой в котором будет установлено это значение. Значения хранятся в переменной класса m_iValue.

Функция fGetRot() возвращает значение переменной класса m_fRotX. Если ваша библиотека блочной графики поддерживает вращение относительно нескольких осей, вам потребуется добавить новые переменные класса для каждой оси вращения и параметр функции fGetRot(), определяющий ваш угол зрения.

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

Метод fGetSize() возвращает значение переменной класса m_fSize. Если ваша библиотека блочной графики поддерживает работу с прямоугольными блоками, вам следует добавить еще одну переменную класса для хранения второго измерения, а также добавить параметр функции fGetSize(), указывающий какой именно размер необходимо получить.

Следующая функция, vSetSize(), получает два параметра. Первый параметр устанавливает размер блока в единицах трехмерной системы координат или в пикселах (для двухмерной графики). Второй параметр указывает слой, к которому эта информация относится. Полученные значения сохраняются в переменной класса m_fSize.

Последняя, но не менее важная функция класса — это vSetNumLayers(). Ей передается единственный параметр с именем layers. Главное назначение этой функции — установка количества слоев блока для хранения номеров растровых изображений, углов поворота и размеров.

Вот и все, что можно сказать о заголовке класса. Структура класса показана на рис. 5.34.

Рис. 5.34. Структура класса для блочной графики

Рис. 5.34. Структура класса для блочной графики

Реализация класса

Приведенный ниже фрагмент кода содержит реализацию класса для блочной графики.

#include "TileClass.h"

// Конструктор
TileClass::TileClass()
{
   // Инициализация внутренних переменных
   m_iNumLayers = 0;
   m_iValue = NULL;
   m_fRotX = NULL;
   m_fSize = NULL;
}
// Деструктор
TileClass::~TileClass()
{
   // Освобождаем буфер слоев, если он был выделен
   if(m_iValue)
      delete [] m_iValue;
   if(m_fRotX)
      delete [] m_fRotX;
   if(m_fSize)
      delete [] m_fSize;
}
// Установка количества слоев
void TileClass::vSetNumLayers(int layers)
{
   // Освобождаем ранее выделенные буферы слоев
   if(m_iValue)
      delete [] m_iValue;
   if(m_fRotX)
      delete [] m_fRotX;
   if(m_fSize)
      delete [] m_fSize;
   // Выделяем память для буфера слоев
   m_iValue = new int[layers];
   memset(m_iValue, 0, layers * sizeof(int));

   m_fRotX = new float[layers];
   memset(m_fRotX, 0,layers * sizeof(int));

   m_fSize = new float[layers];
   memset(m_fSize, 0,layers * sizeof(int));

   // Устанавливаем количество слоев
   m_iNumLayers = layers;
}
// Получение значения блока
int TileClass::iGetValue(int layer)
{
   // Проверяем правильность указанного номера слоя
   if(layer >= m_iNumLayers) {
      return(-1);
   }
   // Возвращаем значение
   return(m_iValue[layer]);
}
// Установка значения блока
void TileClass::vSetValue(int value, int layer)
{
   // Проверяем правильность указанного номера слоя
   if(layer >= m_iNumLayers) {
      return;
   }
   // Устанавливаем значение
   m_iValue[layer] = value;
}
// Установка угла поворота
void TileClass::vSetRotation(float fRot, int layer)
{
   // Проверяем правильность указанного номера слоя
   if(layer >= m_iNumLayers) {
      return;
   }
   m_fRotX[layer] = fRot;
}
// Установка размера блока
void TileClass::vSetSize(float fSize, int layer)
{
   // Проверяем правильность указанного номера слоя
   if(layer >= m_iNumLayers) {
      return;
   }
   m_fSize[layer] = fSize;
}
// Получение угла поворота
float TileClass::fGetRot(int layer)
{
   // Проверяем правильность указанного номера слоя
   if(layer >= m_iNumLayers) {
      return(-1.0f);
   }
   return(m_fRotX[layer]);
}
// Получение размера блока
float TileClass::fGetSize(int layer)
{
   // Проверяем правильность указанного номера слоя
   if(layer >= m_iNumLayers) {
      return(-1.0f);
   }
   return(m_fSize[layer]);
}

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

Следующая функция — это деструктор класса. Она просто проверяет была ли выделена какая-нибудь область памяти, и, если да, то освобождает ее.

Затем следует код функции TileClass::vSetNumLayers(), устанавливающей количество слоев блока. Поскольку вы можете указать в качестве количества слоев любое осмысленное число, главная задача этой функции заключается в выделении необходимой для каждого слоя памяти. Сначала функция освобождает выделенную ранее память. Затем она выделяет память для переменных класса m_iValue, m_fRotX и m_fSize. Как только эта задача выполнена, выделенная память заполняется нулями с помощью вызова функции memset(). Помните, что эта функция должна быть вызвана перед первым использованием объекта блока. Если вы попытаетесь получить данные несуществующего слоя, класс вернет ошибку.

Далее следует наиболее часто вызываемый метод класса, TileClass::iGetValue(). Первая часть кода функции проверяет, не пытается ли программист получить значение, которого не существует. Если из кода убрать эту проверку, программа может аварийно завершиться из-за попытки обращения к несуществующей памяти. Затем функция возвращает значение переменной класса m_iValue, относящееся к указанному слою.

Следующая функция, TileClass::vSetValue(), применяется для изменения значения указанного слоя блока. Вероятно, вы не будете использовать эту функцию слишком часто в игре, но она будет очень часто использоваться в вашей программе редактирования карт. Внутри функции производится не слишком много работы; она проверяет, что указан допустимый номер слоя, и затем устанавливает сооответствующее слою значение в члене данных класса m_iValue.

Следующие две функции, TileClass::vSetRotation() и TileClass::vSetSize, работают точно так же, как функция установки значения слоя, за исключением того, что они изменяют переменные класса m_fRotX и m_fSize.

Последние две функции, TileClass::fGetRot() и TileClass::fGetSize(), работают аналогично функции получения значения блока, за исключением того, что они возвращают значения членов данных класса m_fRotX и m_fSize.

Вот м все, что пока можно сказать о классе для представления блоков. Чтобы удостовериться, что я охватил основные возможности, посмотрите на приведенный далее пример, чтобы увидеть как разработанный класс для представления блоков используется без класса диспетчера карты.

Пример использования класса

Ниже приводится пример использования разработанного класса блочной графики для создания карты игрового мира:

void main()
{
   int       iMapWidth  = 10;
   int       iMapHeight = 10;
   TileClass *Tiles;
   int       iBMPToRender;

   // Выделяем память для блоков
   Tiles = new TileClass[(iMapWidth * iMapHeight)];

   //
   // Цикл выполняет перебор всех блоков
   // и нинциализирует каждый из них
   //
   for(int i = 0; i < (iMapWidth * iMapHeight); i++) {
      // Выделяем для каждого блока один слой
      Tiles[i].vSetNumLayers(1);
      // Присваиваем каждому блоку значение 0
      Tiles[i].vSetValue(0, 0);
      // Устанавливаем размер блока равным 64 пикселам
      Tiles[i].vSetSize(64, 0);
   }

   //
   // Отображение блоков с использованием
   // фиктивной функции визуализации
   //
   // Отображение горизонтальных рядов
   for(int y = 0; y < iMapHeight; y++) {
      // Отображение блоков в каждом ряду
      for(int x = 0; x < iMapWidth; x++) {
         // Отображение конкретного блока
         iBMPToRender = Tiles[x + (y * iMapWidth)].iGetValue(0);
         vRenderTile(x, y, iBMPToRender);
      }
   }
}

Первый пункт, на который следует обратить внимание — это выделение памяти для хранения карты мира. Вместо выделения массива целых чисел, я выделяю массив объектов класса TileClass. Вам необходимо достаточное количество блоков, чтобы хранить одно значение для каждой позиции на карте, поэтому для того, чтобы выяснить, сколько блоков выделить, я умножаю ширину карты на ее высоту.

В следующем фрагменте кода в цикле осуществляется перебор и инициализация всех только что созданных объектов TileClass. Сперва в коде количество слоев устанавливается равным 1. Это позволяет задавать для каждого блока одно значение. Затем, значение каждого блока устанавливается равным 0. Последняя часть кода устанавливает размер каждого блока, равным 64 единицам.

Как только инициализация блоков завершена, начинается их визуализация справа налево и сверху вниз. В примере кода вызывается воображаемая функция vRenderTile(), которая отмечает место, на которое вы можете вставить любой вызов графической функции.

Если хотите, перед продолжением чтения поиграйтесь немного с приведенным кодом. Лично я, перед тем как перейти к следующей теме собираюсь поиграть в America's Army. Если хотите, зарегистрируйтесь и попробуйте найти меня; имя моего игрока — LostLogic.


Глава 5 - Свойства блоков

Свойства блоков

Ох, парни — ну и классная игра America's Army. Я набрал 62 очка в миссии Pipeline и великолепно провел время. Ну, за работу!

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

  • Проходимость
  • Возвышенность
  • Яркость
  • Смещение

Проходимость

Свойство проходимости используется для того, чтобы определить, возможно ли продвижение через данный блок. Если блок помечен как непроходимый, подразделения не могут пересекать его. Главным образом это свойство используется в алгоритмах поиска пути, поскольку процедуры поиска пути должны знать, какие части карты являются проходимыми, а какие — нет. Взгляните на пример, изображенный на рис. 5.35.

Рис. 5.35. Использование свойства проходимости при поиске пути

Рис. 5.35. Использование свойства проходимости при поиске пути

На рис. 5.35 видно, что для блоков с изображением воды значение проходимости равно 1, а для блоков с изображением земли значение этого же свойства равно 0. Для алгоритма поиска пути это означает, что по воде нельзя перемещаться. Если танк, расположенный в правой части карты, требуется переместить в левую часть, надо маневрировать таким образом, чтобы переехать преграду по земляному перешейку. Программа узнает как это сделать, основываясь на карте преград.

Возвышенность

Возвышенность — полезное свойство для тех стратегических игр, на картах которых есть настоящие возвышения. Например, в игре Total Annihilation есть возвышенности, влияющие на стратегию. Пулемет, расположенный на вершине холма может стрелять поверх стен вражеской базы. Хороший способ хранить карту возвышенностей — задавать значение высоты каждого блока. Программа может читать значение высоты блока, чтобы определить линию прицеливания.

Яркость

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

Рис. 5.36. Использование яркости блока для эффекта тумана войны

Рис. 5.36. Использование яркости блока для эффекта тумана войны

Вы видите, что блоки в центре рис. 5.36 яркие, а по мере приближения к краям они становятся все темнее и темнее. Это вызвано тем, что в центре рисунка располагается подразделение игрока, которое может видеть территорию вокруг себя. Присваивая блокам вокруг подразделения различные значения яркости, графическая библиотека реализует эффект области ухудшающейся видимости. Значение яркости, раное 1.0, означает, что блок полностью освещен, а значение, равное 0.0, означает что блок скрыт во тьме. Темные блоки указывают те области, которые данное подразделение не может видеть.

Смещение

Сперва свойство смещения может показаться очень странным, но оно весьма полезно при визуализации. Если вы помните, раньше я упоминал о выборе размера блока для вашей игры. Достаточно часто используются размеры 32 x 32, 64 x 64 и даже 128 x 128. Но что, если вам потребуется несколько блоков, которые не помещаются целиком в заданный размер? Примером могут служить деревья. Они обычно высоки и не очень широки. Если вы попытаетесь втиснуть дерево в изометрический блок, размером 32 x 32, то столкнетесь с проблемами. Лучший способ обхода проблемы состоит в использовании блоков неодинаковой формы.

Предположим, вы создали дерево высотой 64 пиксела и шириной 32 пиксела. Проблема с таким делевом заключается в том, что когда вы передадите этот блок вашей процедуре визуализации, на изображении будет казаться, что дерево погрузилось в землю, поскольку оно является слишком высоким. Это иллюстрирует рис. 5.37.

Рис. 5.37. Блоки разных размеров, визуализированные без смещения

Рис. 5.37. Блоки разных размеров, визуализированные без смещения

Обратите внимание, что все блоки, кроме деревьев на рис. 5.37 выглядят замечательно. Деревья же букавльно протыкают ландшафт. Это объясняется тем, что визуализация верхней границы дерева начинается в обычной позиции, но само изображение дерева слишком высокое. Чтобы решить эту проблему вы должны при визуализации деревьев учитывать смещение. Чтобы вычислить смещение, возьмите высоту обычных блоков и вычтите из нее высоту блока с изображением дерева. В приведенном примере высота дерева равна 64 пикселам, а высота обычного блока — 32 пикселам. Таким образом, смещение будет равно 32 – 64 = –32 пиксела. Теперь, при отображении вашей карты, все деревья будут выводиться в позиции Y + –32. Это перемещает изображение деревьев при визуализации на 32 точки вверх. Если желаете, можете выполнять подобные изменения, добавляя смещения по осям X и Z. В основном данный метод используется для выравнивания блоков, которые не вписываются в стандартный размер блоков, принятый в вашей игре.


Глава 5 - Отображение блоков

Отображение блоков

Мы уже там? Мы уже там? Мы уже там? ДА!!!! Мы уже там. Извините, мне просто вспомнилась последняя поездка на автомобиле, в которую я взял своих детей.

Правильно, вы наконец добрались до самой сути визуализации блоков. Здесь я рассмотрю реальный код, предназначенный для реализации следующих сценариев:

  • Отображение двухмерных блоков.
  • Отображение двухмерных изометрических блоков.
  • Отображение двухмерных изометрических блоков со спрайтами.
  • Отображение трехмерных блоков.

Отображение двухмерных блоков

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

Рис. 5.38. Окно программы, демонстрирующей визуализацию двухмерных блоков

Рис. 5.38. Окно программы, демонстрирующей визуализацию двухмерных блоков

Выглядит не слишком захватывающе? Хорошо, я понимаю, что вы уже видели десятки блочных карт, но для этой в сопроводительных файлах к книге (www.wordware.com/files/games) доступен полный исходный код, создающий ее! Загрузите проект D3DFrame_2DTiles, чтобы двигаться дальше.

Архитектура проекта D3DFrame_2DTiles

Проект содержит два уникальных файла: main.cpp и main.h. Остальные включенные в проект файлы, — d3dapp.cpp, d3denumeration.cpp, d3dfont.cpp, d3dsettings.cpp, d3dutil.cpp и dxutil.cpp, — являются частью Microsoft DirectX 9.0 SDK.

Уникальные файлы я создал специально для этого примера. Вы можете обратить внимание на префикс D3DFrame у названия проекта. Он означает, что при создании программы я пользовался предоставляемым Microsoft каркасом приложения Direct3D. Каждый раз, когда вы увидите этот префикс, знайте, что я использовал каркас приложения. Если вам не нравятся подобные каркасы, не волнуйтесь, — я покажу как выполнить ту же самую работу не применяя их.

Структура проекта показана на рис. 5.39.

Рис. 5.39. Структура файлов программы, демонстрирующей двухмерную блочную графику

Рис. 5.39. Структура файлов программы, демонстрирующей двухмерную блочную графику

Как видно на рисунке, проект включает собственные уникальные файлы, файлы каркаса приложения DirectX и библиотеки d3d9.lib, dxguid.lib, d3dx9dt.lib, d3dxof.lib, comctl32.lib и winmm.lib.

Заголовочный файл Main.h

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

#define STRICT
#include <windows.h>
#include <commctrl.h>
#include <commdlg.h>
#include <math.h>
#include <tchar.h>
#include <stdio.h>
#include <D3DX9.h>
#include "DXUtil.h"
#include "D3DEnumeration.h"
#include "D3DSettings.h"
#include "D3DApp.h"
#include "D3DFont.h"
#include "D3DUtil.h"

// Структура для данных вершин блока
struct TILEVERTEX
{
   D3DXVECTOR3 position;  // Позиция
   D3DXVECTOR3 vecNorm;   // Нормаль
   FLOAT       tu, tv;    // Координаты текстуры (U,V)
};

// Наш собственный FVF, описывающий созданную структуру данных вершин
// D3DFVF_XYZ= Информация о координатах
// D3DFVF_NORMAL = Информация о нормалях
// D3DFVF_TEX1   = Информация о текстуре
#define D3DFVF_TILEVERTEX (D3DFVF_XYZ | D3DFVF_NORMAL | D3DFVF_TEX1)

class CD3DFramework : public CD3DApplication
{
    // Шрифт для отображения FPS и данных видеорежима
   CD3DFont*              m_pStatsFont;
   // Массив целых чисел для хранения блочной карты
   int                    m_iTileMap[100];
   short                  m_shTileMapWidth;
   short                  m_shTileMapHeight;
   // Буфер для хранения текстур
   LPDIRECT3DTEXTURE9     m_pTexture[32];
   // Размеры окна
   short                  m_shWindowWidth;
   short                  m_shWindowHeight;
   // Буфер для хранения вершин
   LPDIRECT3DVERTEXBUFFER9 m_pVBTile;

protected:
   HRESULT OneTimeSceneInit();
   HRESULT InitDeviceObjects();
   HRESULT RestoreDeviceObjects();
   HRESULT InvalidateDeviceObjects();
   HRESULT DeleteDeviceObjects();
   HRESULT Render();
   HRESULT FinalCleanup();
   HRESULT CreateD3DXTextMesh(LPD3DXMESH* ppMesh, TCHAR* pstrFont, DWORD dwSize);
   // Создание буфера вершин блока
   void vInitTileVB(void);
   // Рисование блока на экране
   void vDrawTile(float fXPos, float fYPos, float fXSize, float fYSize, int iTexture);

public:
   LRESULT MsgProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam);
   CD3DFramework();
};

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

struct TILEVERTEX
{
   D3DXVECTOR3 position;  // Позиция
   D3DXVECTOR3 vecNorm;   // Нормаль
   FLOAT       tu, tv;    // Координаты текстуры (U,V)
};

ПРИМЕЧАНИЕ
Действительно, в названии программы присутствует слово 2D, но не позволяйте ему одурачить вас. DirectX больше не работает с чисто двухмерной графикой, так что теперь программы используют трехмерную графику, которая выглядит как двухмерная. По этой причине я создал структуру для описания вершин, содержащую информацию, необходимую для создания трехмерного блока, который будет выглядеть как имеющий только два измерения. Если вы хотите получить больше информации об этом аспекте программирования, я рекомендую вам обратиться к главе 6, которая сосредотачивается на программировании двухмерной графики в трехмерном окружении.

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

К заслуживающим упоминания переменным класса относятся m_iTileMap, m_shTileMapWidth, m_shTileMapHeight, m_pTexture и m_pVBTile. Массив m_iTileMap хранит информацию блочной карты. Я объявляю массив из 100 целых чисел, поскольку ширина и высота карты будут равны 10 блокам.

Переменные m_shTileMapWidth и m_shTileMapHeight хранят размеры блочной карты. В конструкторе класса я присваиваю этим переменным значение 10.

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

Указатель m_pVBTile указывает на буфер вершин, необходимый программе для визуализации блоков.

Следующий блок кода, который мы рассмотрим более подробно, выглядит так:

// Создание буфера вершин блока
void vInitTileVB(void);
// Рисование блока на экране
void vDrawTile(float fXPos, float fYPos, float fXSize, float fYSize, int iTexture);

Эти два прототипа функций являются сердцем рассмартиваемого примера, поскольку именно они относятся к отображению блоков. Первая функция, vInitTileVB(), инициализирует буфер вершин блока, используемый для визуализации. Следующая функция, vDrawTile(), применяется для отображения блока на экране.

ПРИМЕЧАНИЕ
Код, который вы видите здесь, не является точной копией кода из файла с сопроводительного компакт-диска. Чтобы сэкономить место в книге, я удалил из кода некоторые комментарии.

Файл программы Main.cpp

Файл main.cpp не слишком сложен, поскольку он по большей части следует каркасу приложения DirectX. Первая представляющая для нас интерес функция — это конструктор класса. Вот его код:

CD3DFramework::CD3DFramework()
{
   m_strWindowTitle    = _T("2D Tile Example");
   m_pStatsFont        = NULL;
   m_shWindowWidth     = 480;
   m_shWindowHeight    = 480;
   m_shTileMapWidth    = 10;
   m_shTileMapHeight   = 10;
}

Как видно из приведенного фрагмента кода, в конструкторе класса задается размер блочной карты. Я установил высоту и ширину карты равной 10 блокам, чтобы при выводе карта занимала все окно целиком. Ширина и высота блока равны 48 пикселам, поэтому размер окна устанавливается равным 480 на 480 точек. Поскольку 10 * 48 = 480, данный размер как раз обеспечивает точное совпадение окна и выводимой карты.

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

HRESULT CD3DFramework::OneTimeSceneInit()
{
   m_pStatsFont = new CD3DFont(_T("Arial"), 8, NULL);
   if(m_pStatsFont == NULL)
      return E_FAIL;
   // Заполнение карты блоками с изображением травы
   memset(m_iTileMap, 0, (m_shTileMapWidth * m_shTileMapHeight) * sizeof(int));
   // заполнение второй половины блоками с изображением песка
   for(int i = 0; i < 50; i++) {
      m_iTileMap[i+50] = 3;
   }
   // Случайное размещение камней на траве
   // Инициализация генератора случайных чисел
   srand(timeGetTime());
   for(i = 0; i < 50; i++) {
      // Размещение камней на траве, если случайное число = 5
      if(rand()%10 == 5)
         m_iTileMap[i] = 1;
   }
   // Размещение переходных блоков между травой и песком
   for(i = 50; i < 60; i++) {
      m_iTileMap[i] = 2;
   }

   return S_OK;
}

Изучать этот код мы начнем со строки в которой находится вызов функции memset(). Данный фрагмент очищает блочную карту, присваивая всем ее элементам значение 0. Текстура с номером 0 содержит изображение травы, так что рассматриваемый код заполняет травой всю карту.

Следующая часть кода представляет собой цикл, в котором блокам в нижней половине карты присваивается значение 3. Блок с номером 3 содержит текстуру с изображением песка; таким образом этот код формирует песчанный пляж в нижней части карты.

Идущий далее кусок кода случайным образом размещает блоки с изображением камней в верхней половине карты. Блоки с изображением камней служат только для украшения, поэтому их местоположение не является важным. Чтобы задать плотность этих блоков на карте я использую функцию rand().

И, наконец, код размещает в середине карты переходные блоки, объединяющие области травы и песка. Это обеспецивает плавный и приятно выглядящий переход от блоков с изображением травы к песчанному пляжу.

Поэкспериментируйте с расстановкой различных блоков в коде инициализации. Вы можете попробовать реализовать шаблоны или какие-то другие технологии. Это ваш шанс установить значение блока и увидеть результат.

Теперь я перескочу вниз к фрагменту функции RestoreDeviceObjects(). Это очень важная часть программы, поскольку она содержит код инициализации текстур. Вот как выглядит отвечающий за это фрагмент:

sprintf(szFileName, "grass00.bmp");
if(FAILED(D3DXCreateTextureFromFile(m_pd3dDevice, szFileName, &m_pTexture[0]))) {
   return S_OK;
}
sprintf(szFileName, "grass_rocks.bmp");
if(FAILED(D3DXCreateTextureFromFile(m_pd3dDevice, szFileName, &m_pTexture[1]))) {
   return S_OK;
}
sprintf(szFileName, "grass_edging_bottom.bmp");
if(FAILED(D3DXCreateTextureFromFile(m_pd3dDevice, szFileName, &m_pTexture[2]))) {
   return S_OK;
}
sprintf(szFileName, "beach.bmp");
if(FAILED(D3DXCreateTextureFromFile(m_pd3dDevice, szFileName, &m_pTexture[3]))) {
   return S_OK;
}

Код инициализации текстур использует вспомогательную функцию DirectX с именем D3DXCreateTextureFromFile(). Это действительно крутая функция, ведь она содержит весь код, необходимый для загрузки изображений таких форматов, как BMP, TGA и JPEG. Чтобы использовать ее необходимо включить в проект библиотеку d3dx9.lib и заголовочный файл d3dx9tex.h. Прототип функции выглядит следующим образом:

HRESULT D3DXCreateTextureFromFile(
   LPDIRECT3DDEVICE9       pDevice,
   LPCSTR                  pSrcFile,
   LPDIRECT3DTEXTURE9*     ppTexture
);

Первый параметр, pDevice, должен быть указателем на устройство Direct3D, которое вы используете для визуализации. В коде рассматриваемого примера указателем на устройство является переменная m_pd3dDevice. Ее мы и указываем в первом параметре.

Во втором параметре, pSrcFile, ожидается имя загружаемого файла с текстурой. Данный параметр не должен вызывать какие-либо затруднения, поскольку вас достаточно указать заключенное в кавычки имя требуемого файла. Явно указывать путь к файлу не требуется, поскольку при его отсутствии функция пытается найти файл с текстурой в рабочем каталоге приложения. Если необходимо указать другой каталог, можно для получения пути прочитать параметр из реестра. Лично я просто использую подкаталоги в папке с основной программой. Данный метод позволяет использовать несколько каталогов не тревожа параметры в реестре.

Последний параметр, ppTexture, является указателем на текстуру. Если вы вернетесь назад, к моему описанию заголовочного файла, то вспомните, что для хранения указателей на текстуры я использую массив m_pTexture. В этом параметре я также указываю индекс в массиве текстур. Например, для текстуры с номером 1 я указываю в параметре m_pTexture[0], для текстуры с номером 2 использую m_pTexture[1], и так далее.

В завершающей части процедуры инициализации текстур выполняется вызов функции vInitTileVB(). Эта функция всего лишь инициализирует виртуальный буфер для хранения информации о трехмерных блоках.

Вернемся назад и перейдем к коду функции Render(). Вот где происходит волшебство. Пожалуйста, не обращайте внимание на человека за занавесом! Вот как выглядит код, отвечающий за логику визуализации:

HRESULT CD3DFramework::Render()
{
   D3DXMATRIX    matTranslation;
   D3DXMATRIX    matRotation;
   D3DXMATRIX    matRotation2;
   D3DXMATRIX    matScale;
   int           iX;
   int           iY;
   int           iCurTile;
   float         fTileX, fTileY;

   // Очистка порта просмотра
   m_pd3dDevice->Clear(0L, NULL, D3DCLEAR_TARGET,
                       D3DCOLOR_XRGB(120,120,120),
                       1.0f, 0L);

   // Начало создания сцены
   if(SUCCEEDED(m_pd3dDevice->BeginScene())) {
      // Вертикаль
      for(iY = 0; iY < m_shTileMapHeight; iY++) {
         // Горизонталь
         for(iX = 0; iX < m_shTileMapWidth; iX++) {
            // Вычисление номера отображаемого блока
            iCurTile = m_iTileMap[iX + (iY * m_shTileMapWidth)];
            // Вычисление экранных координат
            fTileX = -240.0f + (iX * 48.0f);
            fTileY = 192.0f - (iY * 48.0f);
            // Отображение блока
            vDrawTile(fTileX, fTileY, 48.0f, 48.0f, iCurTile);
         }
      }
      // Отображение частоты кадров
      m_pStatsFont->DrawText(2, 0, D3DCOLOR_ARGB(255,255,255,0), m_strFrameStats);
      // Отображение сведений о видеокарте
      m_pStatsFont->DrawText(2, 20, D3DCOLOR_ARGB(255,255,255,0), m_strDeviceStats);

      // Завершение создания сцены
      m_pd3dDevice->EndScene();
   }
   return S_OK;
}

В первой строке кода вызывается функция Clear(). Она относится к устройству Direct3D и используется для очнстки поверхности визуализации трехмерной графики. Я применяю заливку буфера серым цветом средней интенсивности. Вы можете выбрать тот цвет, который вам больше нравится; он не имеет значения, поскольку видимая область будет полностью заполнена блоками.

Далее расположен вызов функции BeginScene(). Она запускает механизм трехмерной визуализации. Вы должны вызвать эту функцию перед выполнением операций трехмерной графики.

ВНИМАНИЕ
Вы должны начинать визуализацию с вызова функции BeginScene() и завершать ее вызовом функции EndScene(). Если вы не сделаете это, ваша программа аварийно завершится.

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

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

Следующие инструкции вычисляют, в каком месте экрана должен размещаться отображаемый блок. Поскольку в программе используется трехмерная графика, для задания смещения блока применяются значения с плавающей точкой. Размеры создаваемого программой окна составляют 480 точек в ширину и в высоту. Учитывая эту особенность, для того, чтобы блоки отображались вплотную к границе окна, они должны смещаться на 240 единиц влево. Аналогичным образом для того чтобы блоки отображались вплотную к верхней границе окна, они должны смещаться на 240.0 – 48.0, или 192.0 единицы вверх от начала координат.

В следующей строке кода вызывается написанная мной функция vDrawTile(). Прототип функции выглядит следующим образом:

vDrawTile(
   float fXPos,
   float fYPos,
   float fXSize,
   float fYSize,
   int iTexture)

Первый параметр, fXPos, содержит координату по оси Х того места на экране, где будет нарисован блок. Это значение с плавающей точкой, определяющее местоположение вдоль оси X в трехмерной системе координат. Не путайте его с координатой точки на экране.

Следующий параметр, fYPos, аналогичен первому, за исключением того, что задает местоположение в трехмерной системе координат вдоль оси Y.

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

Последний параметр является индексом в массиве текстур m_pTexture. Он задает текстуру для визуализации.

В результате вызова функции vDrawTile() блок появляется в экранном буфере. Все что осталось сделать — вывести частоту кадров и сведения о видеокарте, а затем отобразить готовую сцену. Этим и занимается оставшаяся часть кода функции визуализации.

Смотрите, это было не так уж и плохо, правда? Поток выполнения программы показан на рис. 5.40.

Рис. 5.40. Поток выполнения программы, отображающей двухмерные блоки

Рис. 5.40. Поток выполнения программы, отображающей двухмерные блоки

На рисунке вы видите поток выполнения программы от инициализации до визуализации. На этом закончим разговор про двухмерные блоки. Пришло время перейти к отображению изометрических блоков

Отображение двухмерных изометрических блоков

Отображение изометрических блоков является очень сложной темой. Была опубликована целая книга, посвященная созданию изометрических игр. Код, который я буду рассматривать, не охватывает все возможные ситуации, могущие возникнуть при визуализации изометрических блоков. Он скорее является основанием для вашей собственной разработки. Я расскажу о некоторых проблемах и способах их решения, но многие вопросы очень обширны и выходят за рамки книги. Я предлагаю вам познакомиться с приведенным здесь кодом, а затем использовать его в собственных исследованиях и разработках. На рис. 5.41 показан результат работы программы, отображающей двухмерные изометрические блоки.

Рис. 5.41. Окно программы, демонстрирующей визуализацию двухмерных изометрических блоков

Рис. 5.41. Окно программы, демонстрирующей визуализацию двухмерных изометрических блоков

Проект, содержащий код программы называется D3DFrame_Isometric2DTiles. Вы найдете его в сопроводительных файлах (www.wordware.com/files/games). Загрузите его и следуйте за мной дальше.

Архитектура проекта D3DFrame_Isometric2DTiles

Проект содержит два уникальных файла: main.cpp и main.h. Остальные включенные в проект файлы, — d3dapp.cpp, d3denumeration.cpp, d3dfont.cpp, d3dsettings.cpp, d3dutil.cpp и dxutil.cpp, — являются частью Microsoft DirectX 9.0 SDK. Звучит знакомо, а? Вы должны заметить повторяющуюся тему в программах, написанных с использованием каркаса приложения Direct3D. Я пытаюсь оставить вещи настолько простыми, насколько это возможно, и буду так же поступать в дальнейшем.

Заголовочный файл Main.h

И снова наше внимание будет сосредоточено только на одном заголовочном файле — main.h. Раньше вы уже видели большую часть содержимого этого файла, поэтому я сконцентрируюсь на наиболее важных фрагментах. Первый из таких фрагментов — объявление переменных класса:

// Массив целых чисел для хранения карты
int                  m_iTileMap[100][2];
short                m_shTileMapWidth;
short                m_shTileMapHeight;
// Буфер для хранения текстур
LPDIRECT3DTEXTURE9   m_pTexture[32];

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

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

Файл программы Main.cpp

Загрузите файл main.cpp. Первая вещь, которая представляет для нас интерес — глобальная переменная с именем g_iNumTextures. Она содержит количество загружаемых в память текстур блоков. Я создал ее, чтобы упростить добавление блоков в программе. В настоящее время значение этой переменной равно 9. Если вы будете экспериментировать с программой и добавите свои собственные блоки, убедитесь, что соответствующим образом изменено и значение переменной.

Переместимся далее, к коду конструктора класса. Вы видите, что в нем присваиваются значения нескольким переменным класса:

m_shWindowWidth   = 640;
m_shWindowHeight  = 320;
m_shTileMapWidth  = 10;
m_shTileMapHeight = 10;

На этот раз я создаю окно, ширина которого равна 640 точкам, а высота — 320 точкам. Я поступаю так по той причине, что визуализация изометрических блоков слегка отличается от визуализации двухмерных квадратных блоков, и для нее требуется большая экранная область.

Следующий блок переменных задает размеры визуализируемой блочной карты. Если вы решите увеличить размер блочной карты, не забудьте добавить элементы к массиву m_iTileMap.

Теперь взглянем на функцию класса OneTimeSceneInit(). В этой функции я заполняю два слоя блочной карты данными, после того, как очищу массив функцией memset().

// Инициализация генератора случайных чисел
srand(timeGetTime());
for(int i = 0; i < 100; i++) {
   // Заполнение базового слоя блоками с изображением травы песка и брусчатки
   if(rand()%10 == 3)
      m_iTileMap[i][0] = 2;
   else if(rand()%10 == 4)
      m_iTileMap[i][0] = 3;
   else
      m_iTileMap[i][0] = 4;
   // Заполнение слоя деталей деревьями и колоннами
   if(rand()%10 == 5)
      m_iTileMap[i][1] = 6;
   else if(rand()%10 == 4)
      m_iTileMap[i][1] = 5;
   else if(rand()%10 == 3)
      m_iTileMap[i][1] = 8;
}

Сперва вызов функции srand() инициализирует генератор случайных чисел, используя в качестве начального значения текущее системное время. Чтобы получить текущее значение времени, я вызываю функцию timeGetTime(). Она находится в библиотеке winmm.lib и требует, чтобы в программу был включен заголовочный файл mmsystem.h. Инициализация генератора случайных чисел позволяет получать различные результаты при каждом запуске программы.

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

Обратите внимание, что устанавливая блоки основного слоя я обращаюсь к первому слою массива карты, используя ссылки вида m_iTileMap[xxTileToChangexx] [0]. Располагая блоки с деталями, я обращаюсь ко второму слою, и ссылки выглядят так: m_iTileMap[xxTileToChangexx] [1]. Если вы желаете, то можете добавить еще измерения; только не забудьте проверить, что вы заполняете эти слои осмысленными значениями.

На этом разговор об инициализации карты завершен. Далее, также как и в предыдущей программе, я загружаю текстуры. Эту работу выполняет функция RestoreDeviceObjects(). Я не буду вам надоедать обсуждением подробностей, поскольку здесь нет никаких важных отличий от предыдущего примера.

Прииигооотооовииитьсяяя к вииизуууааалииизааацииииии!!!!! (Мои извинения Майклу Бафферу). Сейчас действительно настало время перейти к отображению блоков, так что не будем задерживаться...

// Вертикаль
for(iY = 0; iY < m_shTileMapHeight; iY++) {
   // Горизонталь
   for(iX = 0; iX < m_shTileMapWidth; iX++) {
      //---------------------------------------------
      //        ВИЗУАЛИЗАЦИЯ БАЗОВОГО СЛОЯ
      //---------------------------------------------
      // Вычисление номера отображаемого блока
   iCurTile = m_iTileMap[iX + (iY * m_shTileMapWidth)][0];

      // Вычисление экранных координат
      fTileX = -32.0f + (iX*32.0f) - (iY*32.0f);
      fTileY = 128.0f - ((iY*16.0f) + (iX*16.0f));
      // Отображение блока
      vDrawTile(fTileX, fTileY, 64.0f, 32.0f, iCurTile);

      //---------------------------------------------
      //        ВИЗУАЛИЗАЦИЯ СЛОЯ С ДЕТАЛЯМИ
      //---------------------------------------------
      // Вычисление номера отображаемого блока
      iCurTile = m_iTileMap[iX + (iY * m_shTileMapWidth)][1];
      if(iCurTile != 0) {
         // Вычисление экранных координат
         fTileX = -32.0f + (iX*32.0f) - (iY*32.0f);
         fTileY = 128.0f - ((iY*16.0f) + (iX*16.0f));
         if(iCurTile == 5)
            vDrawTile(fTileX, fTileY, 64.0f, 125.0f, iCurTile);
         else if(iCurTile == 6)
            vDrawTile(fTileX, fTileY, 67.0f, 109.0f, iCurTile);
         else if(iCurTile == 8)
            vDrawTile(fTileX, fTileY, 64.0f, 64.0f, iCurTile);
      }
   }
}

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

Начальный фрагмент кода визуализации занимается отображением базового слоя. Он выводит на экран блоки, изображающие траву, брусчатку и песок. Возможно вы заметитли переменную iCurTile. Я использую ее, чтобы вычислить, какой блок должен быть отображен. Работает этот код точно также, как и в предыдущей программе, за исключением добавленной ссылки на дополнительное измерение массива карты. В данном случае я ссылаюсь на первое измерение буфера блоков, или в терминах кода — [0].

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

    X-Pos = ЭкранноеСмещение + (X * ШиринаБлока) - (Y * (ВысотаБлока / 2))

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

    Y-Pos = ЭкранноеСмещение - ((Y * (ШиринаБлока / 2)) + (X * (ШиринаБлока / 2)))

Как только местоположение блока на экране стало известно, я отображаю блок воспользовавшись хорошо зарекомендовавшей себя на практике функцией vDrawTile(), которую мы обсуждали ранее.

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

Вот мы и обсудили отображение изометрических блоков. А вы думали, что на это потребуется целая книга! Ох, я совсем забыл еще об одном примере программы. Уберите шампанское — нам надо посмотреть еше один вариант кода для отображения изометрических блоков.

Отображение двухмерных изометрических блоков со спрайтами

В этой программе используется предусмотренный в DirectX 9.0 SDK интерфейс ID3DXSprite. Он упрощает процесс рисования на экране двухмерных изображений. Я заменил большую часть используемых в предыдущей программе вызовов функкций, относящихся к трехмерной графике, вызовами функций работы со спрайтами. Первое, что бросается в глаза — код стал проще и яснее. Хорошо это или плохо — решать вам. На рис. 5.42 показан результат работы программы, отображающей двухмерные изометрические блоки с использованием спрайтов.

Рис. 5.42. Окно программы, демонстрирующей визуализацию двухмерных изометрических блоков с использованием спрайтов

Рис. 5.42. Окно программы, демонстрирующей визуализацию двухмерных изометрических блоков с использованием спрайтов

Проект, содержащий код программы называется D3DFrame_Isometric2DSpriteTiles. Вы найдете его в сопроводительных файлах. Загрузите его и следуйте дальше.

Архитектура проекта D3DFrame_Isometric2DSpriteTiles

Проект содержит два уникальных файла: main.cpp и main.h. Остальные включенные в проект файлы, — d3dapp.cpp, d3denumeration.cpp, d3dfont.cpp, d3dsettings.cpp, d3dutil.cpp и dxutil.cpp, — являются частью Microsoft DirectX 9.0 SDK. Снова используется каркас приложения DirectX.

Заголовочный файл Main.h

В заголовочный файл было внесено несколько изменений. Так я удалил код, относящийся к буферу вершин блока, поскольку в данном примере этот буфер нам не потребуется. Вместо функции vInitTileVB() и переменных класса мы будем использовать единственный указатель LPD3DXSPRITE. Вместо функции vDrawTile() мы воспользуемся функцией BltSprite(). Ее прототип выглядит так:

HRESULT BltSprite(
   RECT               *pDestRect,
   LPDIRECT3DTEXTURE9 pSrcTexture,
   RECT               *pSrcRect)

Первый параметр, pDestRect, является указателем на переменную типа RECT. Этот прямоугольник указывает функции визуализации в каком месте экрана следует отображать текстуру.

Следующий параметр называется pSrcTexture; это указатель на текстуру, которая будет выведена на экран.

Третий параметр, pSrcRect, является указателем на еще одну переменную типа RECT. Он сообщает функции визуализации, какой фрагмент текстуры должен быть отображен. Это позволяет отображать как отдельные части текстуры, так и всю ее целиком.

Файл программы Main.cpp

Первое отличие, которое вы можете заметить — отсутствие инициализации буфера вершин для визуализации блоков. Эти действия нам больше не требуются, поскольку для всех задач визуализации программа использует интерфейс спрайтов. Чтобы отображать спрайтовую графику необходимо создать спрайтовое устройство. Это делается в функции RestoreDeviceObjects() с помощью следующей строки кода:

D3DXCreateSprite(m_pd3dDevice, &pd3dxSprite);

Просто, да? Функция D3DXCreateSprite() выполнит за вас всю работу, необходимую чтобы создать спрайтовое устройство. Ей передаются два параметра. Первый параметр — это указатель на устройство трехмерной визуализации. Второй параметр является адресом указателя на спрайтовое устройство, которое будет создано функцией.

Переместимся к функции Render(), чтобы посмотреть следующий набор изменений кода. Цикл визуализации выглядит также как и раньше — один внешний цикл и один внутренний. Самые значительные отличия расположены в коде внутренннего цикла. Вместо вызова функции vDrawTile() теперь используется вызов функции BltSprite(). Еще одно отличие заключается в том, что для указания местоположения блока теперь используются прямоугольники, а не значения с плавающей точкой, определяющие координаты в трехмерном пространстве. Отметим важный момент — прямоугольники определяют местоположение на экране, а не в трехмерном пространстве.

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

Перед тем, как начать визуализацию, вы должны вызвать функцию спрайтового устройства Begin(). Завершать визуализацию следует вызовом функции спрайтового устройства End(). Эти вызовы полностью отличаются от того, что мы делали при работе с трехмерным устройством. Они нужны только в том случае, если вы используете спрайты, и не применяются, когда используется трехмерная визуализация.

Зачем использовать спрайты?

Теперь, посмотрев на интерфейс спрайтов в действии, вы можете задаться вопросом — зачем вам когда-либо может понадобиться применять спрайты. Одна из причин заключается в том, что использовать спрайты проще, чем выполнять визуализацию буфера вершин. Интерфейс спрайтов скрывает всю работу, необходимую для правильного отображения ортогональной проекции. За исключением вышесказанного, я не могу найти дополнительных причин для использования спрайтов. Поэтому я предлагаю, чтобы вы сами поэкспериментировали с визуализацией вершин и с работой со спрайтами, после чего выбрали бы то, что вам больше нравится.

Отображение трехмерных блоков

Отображение трехмерных блоков! Оооо, звучит угрожающе, не так ли? Хотя название темы звучит пугающе, в действительности она почти ничем не отличается от рассмотренного в этой главе ранее отображения двухмерных блоков. Действительно, в каждом, использующем двухмерную графику примере из этой главы, применялась трехмерная визуализация. Главное видимое отличие заключается в том, что «настоящие» трехмерные программы не используют ортогональную проекцию. На рис. 5.43 показан результат работы программы, демонстрирующей отображение трехмерных блоков.

Рис. 5.43. Окно программы, демонстрирующей визуализацию трехмерных блоков

Рис. 5.43. Окно программы, демонстрирующей визуализацию трехмерных блоков

Проект, содержащий код программы называется D3DFrame_3DTiles. Вы найдете его в сопроводительных файлах. Загрузите его и следуйте дальше.

Архитектура проекта D3DFrame_3DTiles

Проект содержит два уникальных файла: main.cpp и main.h. Остальные включенные в проект файлы, — d3dapp.cpp, d3denumeration.cpp, d3dfile.cpp, d3dfont.cpp, d3dsettings.cpp, d3dutil.cpp и dxutil.cpp, — являются частью Microsoft DirectX 9.0 SDK.

Обратите внимание, что название одного из файлов выделено полужирным шрифтом. Файл d3dfile.cpp отсутствовал ранее и был добавлен только к этому проекту. Его назначение — помогать загружать файлы формата .x, содержащие информацию о трехмерных объектах. Он необходим для загрузки трехмерных моделей, созданных в таких пакетах визуального моделирования, как Maya, 3D Studio MAX и MilkShape. Если хотите, вы можете написать собственный загрузчик моделей, но встроенные функции, предоставляемые файлом d3dfile.cpp сделают вашу жизнь намного проще.

Заголовочный файл Main.h

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

#define STRICT
#include <windows.h>
#include <commctrl.h>
#include <commdlg.h>
#include <math.h>
#include <tchar.h>
#include <stdio.h>
#include <D3DX9.h>
#include "DXUtil.h"
#include "D3DEnumeration.h"
#include "D3DSettings.h"
#include "D3DApp.h"
#include "D3DFont.h"
#include "D3DFile.h"
#include "D3DUtil.h"

int g_iNumTiles = 2;

// Формат вершин для трехмерных блоков
struct D3DVERTEX
{
    D3DXVECTOR3 p;
    D3DXVECTOR3 n;
    FLOAT       tu, tv;

    static const DWORD FVF;
};
const DWORD D3DVERTEX::FVF = D3DFVF_XYZ | D3DFVF_NORMAL | D3DFVF_TEX1;

class CD3DFramework : public CD3DApplication
     {
   CD3DFont*    m_pStatsFont;
   TCHAR        m_strFont[LF_FACESIZE];
   DWORD        m_dwFontSize;
   // Данные трехмерных объектов
   CD3DMesh*    m_pObject[32];
   // Массив целых чисел для хранения блочной карты
   int          m_iTileMap[100];
   short        m_shTileMapWidth;
   short        m_shTileMapHeight;
   // Размеры окна
   short        m_shWindowWidth;
   short        m_shWindowHeight;

protected:
   HRESULT OneTimeSceneInit();
   HRESULT InitDeviceObjects();
   HRESULT RestoreDeviceObjects();
   HRESULT InvalidateDeviceObjects();
   HRESULT DeleteDeviceObjects();
   HRESULT Render();
   HRESULT FrameMove();
   HRESULT FinalCleanup();
   HRESULT CreateD3DXTextMesh(LPD3DXMESH* ppMesh, TCHAR* pstrFont, DWORD dwSize);

public:
   LRESULT MsgProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam);
   CD3DFramework();
};

Первое изменение находится в разделе включаемых файлов. Файлу d3dfile.cpp требуется заголовочный файл d3dfile.h. Поскольку наша программа использует функции из файла d3dfile.cpp, заголовочный файл должен быть также включен в исходный код.

ПРИМЕЧАНИЕ
Вспомогательные файлы, такие как d3dfile.cpp, d3dapp.cpp, d3dsettings.cpp, и т.д., находятся в той папке, куда вы установили DirectX SDK. Если вы не меняли предлагаемый путь по умолчанию, файлы находятся в папке C:\DXSDK\Samples\C++\Common.

Следующий фрагмент, который представляет интерес — объявление переменной g_iNumTiles расположенное сразу после перечисления включаемых файлов. Данная переменная содержит количество загружаемых программой трехмерных моделей. Очень важно отслеживать это число, поскольку программа должна знать, сколькими моделями ей управлять. Если вы решите добавить к рассматриваемому примеру еще модели, убедитесь, что значение переменной также соответствующим образом увеличено.

Следующее изменение находится в структуре данных D3DVERTEX. Ее формат слегка отличается от использованного ранее. Это сделано для поддержки формата вершин трехмерных моделей. Для такой поддержки необходима переменная FVF типа DWORD. Это единственное отличие от использованного ранее формата вершин.

Спустимся ниже к определению класса. Здесь добавлена новая переменная с именем m_pObject. Это массив значений типа CD3DMesh. Объект CD3DMesh предоставляется библиотекой d3dfile.cpp. Это ваше окно в мир загрузки и отображения трехмерных моделей. Я произвольным образом установил размер массива равным 32. Это указывает, что максимальное количество загруженных блоков будет равно 32. Не стоит волноваться, — если захотите, вы в дальнейшем всегда сможете изменить это число.

Файл программы Main.cpp

Теперь откройте файл main.cpp, чтобы увидеть код, используемый в данном примере. Ниже приведен первый фрагмент кода, который представляет для нас интерес:

HRESULT CD3DFramework::OneTimeSceneInit()
{
   int i;
   m_pStatsFont = new CD3DFont(_T("Arial"), 8, NULL);
   if(m_pStatsFont == NULL)
      return E_FAIL;
   // Выделение памяти для блоков
   for(i = 0; i < g_iNumTiles; i++) {
      m_pObject[i] = new CD3DMesh();
   }
   // Заполнение карты блоками с кодом 0
   memset(m_iTileMap, 0, (m_shTileMapWidth * m_shTileMapHeight) * sizeof(int));
   // Случайное размещение скал на траве
   // Инициализация генератора случайных чисел
   srand(timeGetTime());
   for(i = 0; i < 100; i++) {
      if(rand() % 5 == 3)
         m_iTileMap[i] = 1;
      else
         m_iTileMap[i] = 0;
   }
   return S_OK;
}

Новые действия начинаются в первом цикле for. В нем выделяется память для трехмерных объектов (блоков). Для этой цели используется оператор new. Смотрите, разве это не просто?

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

Загрузка трехмерных моделей

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

HRESULT CD3DFramework::InitDeviceObjects()
{
   HRESULT hr;
   char szFileName[512];
   // Инициализация шрифта
   if(FAILED(hr = m_pStatsFont->InitDeviceObjects(m_pd3dDevice)))
      return hr;
   //  Загрузка информации трехмерных блоков
   for(int i = 0; i < g_iNumTiles; i++) {
      // Создаем имя файла
      sprintf(szFileName, "ground_tile%d.x", i+1);
      // Загружаем сетку
      if(FAILED(m_pObject[i]->Create(m_pd3dDevice, _T(szFileName))))
         return D3DAPPERR_MEDIANOTFOUND;
      // Устанавливаем тип вершин
      m_pObject[i]->SetFVF(m_pd3dDevice, D3DVERTEX::FVF);
   }
   return S_OK;
}

Цикл в этой функции начинается с создания имени загружаемого файла с данными трехмерного объекта. Как только имя готово к использованию, код вызывает функцию Create(), принадлежащую объекту класса CD3DMesh. Вот прототип функции CD3DMesh::Create():

HRESULT Create(
   LPDIRECT3DDEVICE9 pd3dDevice,
   TCHAR* strFilename)

Первый параметр, pd3dDevice, является указателем на используемое в приложении трехмерное устройство. Я передаю в этом параметре указатель m_pd3dDevice, который инициализируется в коде каркаса приложения.

Следующий параметр, strFilename, является именем файла, содержащего загружаемый в память объект. Здесь я передаю созданное ранее имя файла.

Теперь, когда объект загружен в память, укажем формат вершин для объекта. Выполняйте эти действия когда хотите управлять форматом вершин, используемым при визуализации объекта. Если вам это не нужно, нет никаких причин использовать SetFVF(). Данный вызов добавлен только чтобы обеспечить дополнительный контроль.

Точечный источник света

Переместимся ниже к функции CD3DFramework::RestoreDeviceObjects(), чтобы увидеть сделанные мной незначительные изменения ее кода. Отличий совсем немного; главное из них то, что в этом примере я использую новый тип источника света. Вот код для нового точечного источника света:

ZeroMemory(&d3dLight, sizeof(D3DLIGHT9));
d3dLight.Type         = D3DLIGHT_POINT;
d3dLight.Diffuse.r    = 1.0f;
d3dLight.Diffuse.g    = 1.0f;
d3dLight.Diffuse.b    = 1.0f;
d3dLight.Position.x   = 0.0f;
d3dLight.Position.y   = -20.0f;
d3dLight.Position.z   = 20.0f;
d3dLight.Attenuation0 = 1.0f;
d3dLight.Attenuation1 = 0.0f;
d3dLight.Range        = 100.0f;

В предыдущих примерах программ использовался направленный источник света. DirectGraphics предлагает для использования и другие типы освещения, такие как зональное освещение (прожектор) и точечный источник света. В рассматриваемом примере я применяю точечный источник света.

Значение Type указывает системе визуализации тип источника света. В данном примере я задаю значение D3DLIGHT_POINT. Оно указывает, что система визуализации должна ожидать и использовать параметры для точечного источника света.

Структура Diffuse задает цвет источника света. Она содержит три компонента — красный, зеленый и синий. Значения каждого компонента должны находиться в диапазоне от 0.0 до 1.0. Значение 0.0 соответствует полному отсутствию данного цвета, а значение 1.0 — его максимальной интенсивности. Поскольку для каждого из цветов я указал значение 1.0, в результате будет получен белый свет максимальной интенсивности. Если вы не знакомы с освещением трехмерных сцен, я предлагаю вам поиграть с параметрами, чтобы увидеть, какое влияние они оказывают на сцену.

Структура Position содержит местоположение источника света в трехмерном пространстве. Я разместил его в точке с координатами (0.0, –20.0, 20.0).

Значение Attentuation0 определяет, как интенсивность света будет изменяться с увеличением расстояния. Это значение устанавливает константу, с которой начнется изменение интенсивности. Значение Attenuation1 устанавливает следующую константу, используемую для изменения интенсивности. Задав масштабирование в диапазоне от 1.0 до 0.0, я указываю, что с увеличением расстояния интенсивность света должна уменьшаться.

Значение Range указывает расстояние на котором источник света перестает оказывать влияние на объекты. В нашем примере источник света не освещает предметы, которые удалены от него более, чем на 100 единиц.

Визуализация трехмерных моделей

Готовы ли вы к визуализации трехмерных блоков? Я готов! Вот новый и улучшенный код визуализации трехмерных блоков:

HRESULT CD3DFramework::Render()
{
   D3DXMATRIX   matTranslation;
   int    iX, iY;
   int    iCurTile;
   float  fXPos;
   float  fYPos;

   // Очистка порта просмотра
   m_pd3dDevice->Clear(0L, NULL, D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER,
                       D3DCOLOR_XRGB(0, 0, 0), 1.0f, 0L);

   // Начало создания сцены
   if(SUCCEEDED(m_pd3dDevice->BeginScene()))
      {
      for(iY = 0; iY < 10; iY++) {
         // Горизонтали
         for(iX = 0; iX < 10; iX++) {
            // Вычисляем, какой блок отображать
            iCurTile = m_iTileMap[iX + (iY * m_shTileMapWidth)];
            // Вычисляем местоположение блока
            fXPos = (-5.0f * iX) + 22.5f;
            fYPos = (-5.0f * iY) + 32.5f;
            // Устанавливаем позицию блока
            D3DXMatrixTranslation(&matTranslation, fXPos, fYPos, 0.0f);
            m_pd3dDevice->SetTransform(D3DTS_WORLD, &matTranslation);
            // Отображаем блок
            m_pObject[iCurTile]->Render(m_pd3dDevice);
         }
      }

      // Показываем частоту кадров
      m_pStatsFont->DrawText(2, 0, D3DCOLOR_ARGB(255,255,255,0), m_strFrameStats);
      // Показываем сведения о видеокарте
      m_pStatsFont->DrawText(2, 20, D3DCOLOR_ARGB(255,255,255,0), m_strDeviceStats);

      // Завершаем создание сцены
      m_pd3dDevice->EndScene();
   }

   return S_OK;
}

Обратите внимание, насколько этот код похож на предыдущие примеры. Визуализация трехмерных моделей на самом деле не так уж и сложна. В рассматриваемом примере присутствует обычный набор ключевых фрагментов. Есть внешний цикл для визуализации блоков вдоль оси Y и внутренний цикл для визуализации блоков вдоль оси X. Местоположение блока вычисляется обычным способом с небольшим изменением координат. Главное отличие — вызов функции D3DXMatrixTranslation().

Функция D3DXMatrixTranslation() создает матрицу перемещения, содержащую набор трехмерных координат. Перемещение — это всего лишь причудливое слово, обозначающее задание местоположения. Таким образом, матрица перемещения задает местоположение объекта в трехмерном пространстве. Как только местоположение объекта установлено, вызов функции SetTransform() вводит задающую местоположение матрицу в действие.

ВНИМАНИЕ
Убедитесь, что значение первого параметра функции SetTransform() равно D3DTS_WORLD. В ином случае вы измените что угодно, но только не местоположение объекта!

Теперь объект находится в требуемой позиции, и для его отображения следует вызвать функцию Render(). Функция Render() принадлежит объекту CD3DMesh и выполняет за вас всю необходимую работу. Вам надо только передать указатель на трехмерное устройство, и объект сделает все остальное. Разве не круто?

Теперь вы знаете как отображать трехмерные блоки. Получен ответ на древний вопрос! Я признаю, что еще многое требуется изучить, но вы уже на пути к созданию полностью трехмерной стратегической игры с блочной графикой.


Глава 5 - Итоги

Итоги

Вот мы и поговорили о блочной графике. Вспомните следующие ключевые моменты визуализации блоков:

  • Блоки позволяют создавать большие карты с минимальным расходом памяти.
  • Блоки позволяют создавать динамическое графическое содержание.
  • Тщательно выбирайте размер блоков для вашей игры.
  • Применение спрайтов упрощает визуализацию блоков.


Глава 6. Проектирование и разработка интерфейса

Краткий обзор

Некоторые разработчики игр начинают писать код до того, как разработают интерфейс для своей игры. Не попадитесь в эту ловушку: проектирование и программирование интерфейса это очень важные аспекты разработки игр. Даже если вы создаете клон Тетриса, интерфейс играет ключевую роль в степени привлекательности вашей игры. Без хорошего интерфейса игрок будет путаться и раздражаться, и может даже прекратить играть в вашу игру. Я не хочу, чтобы это случилось, и знаю, что вы тоже не хотите этого. Надеюсь, эта глава откроет вам огромный мир проектирования и программирования интерфейсов. Вот список рассматриваемых в ней тем:

  • Определение требований к интерфейсу.
  • Удобство интерфейса.
  • Двухмерная графика в Direct3D.
  • Горячие точки.


Глава 6 - Определение требований к интерфейсу

Определение требований к интерфейсу

Первый аспект разработки интерфейса относится к выяснению того, что требуется от интерфейса. Вы должны спросить себя «Что должен делать интерфейс?». Это ключевой вопрос, потому что не ответив на него вы не сможете начать разработку. Конечно, вы можете начать программирование, оставив размышления об интерфейсе на потом, но я настоятельно не рекомендую так поступать. Итак, вопрос в том, как вам определить требования к интерфейсу?

Моя первая рекомендация — начните с чистого блокнота. Я использую блокноты в которых листы скреплены проволочной спиралью, поскольку они легко складываются и в сложенном виде их удобно использовать в качестве справочника во время написания программы. Вооружившись новым блокнотом выпишите в виде схемы различные интерфейсы, необходимые вашей игре. Схема для простой игры «Крестики-нолики» может выглядеть так:

    Начальный экран

      Заставка игры (bmp)

      Кнопка пропуска заставки (mzone)

    Главное меню

      Кнопка начала новой игры (mzone)

      Кнопка загрузки игры (mzone)

      Кнопка записи игры (mzone)

      Кнопка выхода из игры (mzone)

    Интерфейс игры

      Игровое поле (bmp)

      Клетки игрового поля (mzones)

      Изображение хода игрока (bmp)

      Горячая точка для выхода из игры (mzone)

    Загрузка игры

      Список записанных игр (bmp)

      Поле для ввода имени записаной игры (mzone)

      Кнопка загрузки (mzone)

      Кнопка возврата к главному меню (mzone)

    Запись игры

      Список записанных игр (bmp)

      Поле для ввода имени записываемой игры (mzone)

      Кнопка записи (mzone)

      Кнопка возврата к главному меню (mzone)

      Кнопка возврата к игре (mzone)

    Завершение игры

      Графика для завершения игры (bmp)

Ничего себе, могли ли вы предположить, что у игры «Крестики-нолики» будет такая сложная схема интерфейса? Интересно и то, что в действительности я пропустил некоторые элементы. Конечно, в большинстве игр «Крестики-нолики» не предусмотрена возможность записи и загрузки игр, но послушайте, это особенности производства.

Активные зоны и графика

Взгляните на начало схемы. Обратите внимание, что она начинается со стартовой заставки игры. В большинстве игр отображение заставки можно прервать, щелкнув мышью по кнопке на экране или нажав клавишу на клавиатуре. В схеме я описал кнопку на экране, которая позволяет игроку перейти от заставки к главному меню. Рядом с описанием кнопки я поместил индикатор «mzone», сообщающий что это будет активная зона для мыши. Активной зоной называется область экрана, по которой пользователь может щелкнуть мышью, чтобы были выполнены какие-то действия. Графика стартового экрана игры присутствует в схеме как отдельный элемент. Рядом с ним я поместил индикатор «bmp», указывающий, что это графический элемент.

Далее следует блок для главного меню игры. Оно содержит четыре кнопки с активными зонами: одна для начала новой игры, одна для загрузки существующей игры, одна для записи текущей игры и одна для выхода из программы.

Щелчок мышью по активной области новой игры переводит пользователя к экрану с интерфейсом игры. В разделе схемы, посвященном игровому интерфейсу я описываю графическое изображение для игрового поля. Поскольку игрок может щелкнуть по ячейке игрового поля, чтобы поместить туда крестик или нолик, я описываю активные зоны для клеток игрового поля. Я также упоминаю графические изображения, представляющие сделанный игроком ход. Поскольку поле в котором был сделан ход уже не реагирует на щелчки мышью, рядом с графикой, изображающей ход игрока я поставил индикатор «bmp». И в конце раздела я описываю кнопку выхода, позволяющую игроку вернуться к главному меню.

Исследуя схему дальше, мы встречаем интерфейс загрузки игры. Здесь я описал графический элемент, содержащий список сохраненных ранее игр. Я также перечислил текстовое поле для ввода имени файла, кнопку загрузки и кнопку возврата к главному меню. Есть еще несколько активных зон, которые я не упомянул в схеме. Можете добавить их? Ответ будет дан чуть позже.

Меню сохранения игры выглядит аналогично меню загрузки игры, за исключением добавленной мной кнопки, которая позволяет игроку вернуться к игре. Это необходимо потому, что игрок попадает в данное меню из игрового интерфейса.

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

Вот что можно сказать о схеме интерфейса игры «Крестики-нолики». Она весьма обширна, несмотря на то, что «Крестики-нолики» — это очень простая игра. А теперь попробуйте вообразить как будет выглядеть схема интерфейса для игры из серии Command & Conquer. Ах, вы интересуетесь, что я пропустил в интерфейсе загрузки игры? Я пропустил графические элементы и активные зоны, которые позволят игроку прокручивать список записанных игр. Что делать, если список всех сохраненных игр не помещается на одном экране? Итак, вам потребуется полоса прокрутки и реагирующие на щелчки мыши области для кнопок. Вы могли подумать и о многих других элементах, которые я также не учел. Теперь напишите вашу собственную схему интерфейса для игры «Тетрис», и посмотрите, что получилось. Выможете быть удивлены сложностью этой схемы.

Детализация схемы интерфейса

Пока я лишь показал вам наипростейший проектный документ для интерфейса. Как насчет чего-нибудь более стимулирующиего? Есть еще несколько элементов, необходимых для хорошей схемы проекта интерфейса. Вот список некоторых из них:

  • Взаимосвязи.
  • Звук.
  • Состояние.

Взаимосвязь шаблонов интерфейса

Хотя «Крестики-нолики» — это интригующая и сложная игра, она не отвечает требованиям, предъявляемым к настоящему примеру; поэтому я начну создавать полностью новую схему интерфейса.

Рис. 6.1. Стартовый экран игры Battle Armor

Рис. 6.1. Стартовый экран игры Battle Armor

На рис. 6.1 показан стартовый экран моей игры Battle Armor. Он не слишком сложен, но здесь присутствуют несколько элементов, образующих интерфейс. На рис. 6.2 приведена схема кадра со стартовым экраном.

Рис. 6.2. Схема кадра стартового экрана игры Battle Armor

Рис. 6.2. Схема кадра стартового экрана игры Battle Armor

На рис. 6.2 каждому элементу стартового экрана присвоена метка. Всего отмечено шесть различных эелементов. Они перечислены в алфавитном порядке, но их порядок не имеет значения. Я назначил им метки произвльным образом. Теперь, глядя на кадр интерфейса, давайте разберем его схему:

    1. Стартовый экран

      A. Заставка

        Графика

        Активная зона

          (2. Основное меню)

      B. Индикатор загрузки

        Текст

      C. Нижняя полоса

        Графика

      D. Верхняя полоса

        Графика

      E. Кнопка выхода

        Графика

        Активная зона

          (0. Рабочий стол)

      F. Музыкальное сопровождение

        Звуковой файл

Приведенная выше схема отличается от рассмотренной ранее схемы интерфейса игры «Крестики-нолики», поскольку представляет собой дальнейшее развитие стандартной схемы интерфейса. Нововведениями являются дополнительная детализация и информация о взаимосвязях.

Самый первый элемент в схеме помечен номером 1 и представляет собой кадр интерфейса. Он называется «Стартовый экран», поскольку это название точно отражает его назначение. Так как это элемент самого верхнего уровня, он расположен вплотную к левому краю страницы. Остальные кадры интерфейса должны быть перечислены таким же образом.

Далее располагается элемент с меткой «A» и названием «Заставка». Ниже перечислены различные свойства этого элемента. Первое из перечисленых свойств — «Графика». Оно сообщает вам, что данный элемент содержит графическое изображение. В данном случае изображением является картинка с названием игры. Следующее свойство — «Активная зона». Это значит, что данный элемент также является горячей точкой и выполняет какие-то действия, когда пользователь щелкает по нему мышью. Чтобы узнать, какие действия будут выполнены, взгляните на следующую строчку. Там расположена запись «(2. Основное меню)». Она означает, что при щелчке мышью по данному элементу пользователь переходит ко второму кадру интерфейса, который называется «Основное меню». Знаете, что? Вы только что создали взаимосвязь между стартовым экраном и основным меню! Полагаю это было для вас неожиданно, но здесь действительно нет ничего сложного.

Следующему элементу присвоена метка «B» и у него есть единственное свойство — «Текст». Это означает, что данный элемент создается с помощью системного шрифта, а не художником. Это существенное различие, так как вы, конечно, не захотите, чтобы художник впустую тратил свое время, рисуя текстовые сообщения, которые можно создать с помощью системного шрифта.

Следующие два элемента с метками «C» и «D» являются графическими элементами, образующими верхнюю и нижнюю полосы интерфейса. В них нет ничего особенного.

Элемент с меткой «E» более интересен, так как содержит и графику и активную зону. Возможно, вам интересно, где расположен элемент «(0. Рабочий стол)». Это специальная метка, означающая рабочий стол Windows. Другими словами, при щелчке пользователя по данному элементу программа завершает свою работу и игрок возвращается к рабочему столу системы. Итак, вы сделали это — определили еще одну важную взаимосвязь интерфейса.

Последний элемент с меткой «F» относится к музыкальному оформлению. Он сообщает, что во время показа стартового экрана будет воспроизводиться фоновая музыка. Музыка не связана с какими-либо другими элементами, поэтому никаких уточнений не приводится. Если хотите, можете указать имя файла WAV или MP3, который будет воспроизводиться.

Звуковое оформление интерфейса

Помните активную зону стартового экрана, щелчок по которой переводит игрока к основному меню? Схема кадра с этим меню показана на рис. 6.3.

Рис. 6.3. Схема кадра с основным меню

Рис. 6.3. Схема кадра с основным меню

На рис. 6.3 показан кадр с основным меню. В нем нет ничего сложного — всего лишь несколько графических изображений и активных зон. Но подождите! Взгляните на изображение динамика рядом с элементом «A». Да ведь это напоминает звук! Перед тем, как подробнее разобраться с этим вопросом, взгляните на схему меню:

    2. Основное меню

      A. Схватка

        Графика

        Активная зона

          (3. Меню схватки)

          Звук

            (I. Щелчок по кнопке)

      B. Кампания

        Графика

        Активная зона

          (4. Меню кампании)

          Звук

            (I. Щелчок по кнопке)

      C. Загрузка игры

        Графика

        Активная зона

          (5. Меню загрузки игры)

          Звук

            (I. Щелчок по кнопке)

      D. Настройка

        Графика

        Активная зона

          (6. Меню настройки)

          Звук

            (I. Щелчок по кнопке)

      E. Нижняя полоса

        Графика

      F. Верхняя полоса

        Графика

      G. Кнопка выхода

        Графика

        Активная зона

          (0. Рабочий стол)

      H. Музыкальное сопровождение

        Звуковой файл

      I. Щелчок по кнопке

        Звуковой файл

Ничего себе, какой сложной вещью оказалось так просто выглядящее меню. Первым в списке элементов стоит пункт меню «Схватка». Так как этот элемент является пунктом меню, с ним связаны графическиое изображение и активная зона. Главным отличием этого элемента является добавленный к нему элемент «Звук». Этот новый элемент сообщает вам, что при щелчке по активной зоне, связанной с пунктом меню «Схватка» должен воспроизводиться звук. Элемент, расположенный под пунктом «Звук», сообщает, какой именно звук должен быть воспроизведен. В данном случае будет воспроизведен звук, который перечислен в списке элементов под меткой «I». Элемент с меткой «I» называется «Щелчок по кнопке» и в нем указан тип воспроизводимого звука. Если желаете, вы можете указать здесь имя реального файла WAV или MP3.

Состояния интерфейса

Ради примера, представьте, что игрок выбрал в главном меню пункт «Схватка». Согласно схеме, в результате этого действия игрок переходит к меню с номером 3. Кадр с этим меню изображен на рис. 6.4.

Рис. 6.4. Схема кадра меню режима схватки

Рис. 6.4. Схема кадра меню режима "схватка"

Схема, соответствующая рис. 6.4, выглядит так:

    3. Меню схватки

      A. Одиночная игра

        Графика

        Активная зона

          (7. Меню однопользовательской схватки)

          Звук

            (H. Щелчок по кнопке)

      B. Многопользовательская игра

        Графика

        Активная зона

          (8. Меню многопользовательской схватки)

          Звук

            (H. Щелчок по кнопке)

      C. Возврат к основному меню

        Графика

        Активная зона

          (2. Основное меню)

          Звук

            (H. Щелчок по кнопке)

      D. Нижняя полоса

        Графика

      E. Верхняя полоса

        Графика

      F. Кнопка выхода

        Графика

        Активная зона

          (0. Рабочий стол)

      G. Музыкальное сопровождение

        Звуковой файл

      H. Щелчок по кнопке

        Звуковой файл

Как видно из приведенной схемы, в этом интерфейсе нет никаких новых элементов. Так как игрок просто продолжает навигацию по системе меню, никаких специальных элементов не требуется. Для примера предположим, что игрок выбрал многопользовательский режим игры и перешел к интерфейсу с номером 8. Следуйте за мной к описанию интерфейса, изображенного на рис. 6.5.

Рис. 6.5. Схема кадра меню многопользовательской схватки

Рис. 6.5. Схема кадра меню многопользовательской схватки

Схема кадра, изображенная на рис. 6.5 не слишком отличается от рассмотренных ранее интерфейсов. В нем присутствуют несколько кнопок, которые просто отправляют игрока к другим меню. Предположим, игрок выбрал в меню пункт «Игровой сервер». На рис. 6.6 показан интерфейс игрового сервера.

Рис. 6.6. Схема кадра с меню игрового сервера

Рис. 6.6. Схема кадра с меню игрового сервера

Интерфейс на рис. 6.6 более сложен, чем те, которые мы изучали до сих пор. Вот схема этого маленького шедевра:

    9. Экран сервера многопользовательской схватки

      A. Флажок готовности

        Графика

          (unchecked_box.bmp)

        Активная зона

          Графика

            (checked_box.bmp)

          Звук

            (O. Щелчок по кнопке)

      B. Имя игрока

        Текстовое поле

          Ширина: 16 символов

          Переменная состояния

            m_szPlayerName[]

      С. Цвета игрока

        Графика

          Переменная состояния

            m_iPlayerColor[]

          (color0.bmp – color9.bmp)

        Активная зона

          Звук

            (O. Щелчок по кнопке)

      D. Команда игрока

        Графика

          Переменная состояния

            m_iPlayerTeam[]

          (team0.bmp – team5.bmp)

        Активная зона

          Звук

            (O. Щелчок по кнопке)

      E. IP-адрес игрока

        Текстовое поле

          Ширина: 16 символов

          Переменная состояния

            m_szPlayerIP[]

      F. Верхняя полоса

        Графика

          (top_bar.bmp)

      G. Окно переговоров

        Текстовое поле

          Ширина: 24 символа

          Переменная состояния

            m_szChatBuffer[]

      Н. Поле ввода реплик

        Текстовое поле ввода

          Ширина: 24 символа

          Переменная состояния

            m_szChatSendBuffer[]

      I. Кнопка выхода

        Графика

          (exitbutton.bmp)

        Активная зона

          (0. Рабочий стол)

      J. Карта игры

        Графика

          Переменная состояния

            m_iGameMapID

          (gamemap_0.bmp – gamemap9.bmp)

      К. Кнопка выбора карты

        Графика

          (choosmapbutton.bmp)

        Активная зона

          (10. Меню выбора карты)

          Звук

            (O. Щелчок по кнопке)

      L. Кнопка начала игры

        Графика

          (startbutton.bmp)

        Активная зона

          (11. Игровой экран многопользовательской схватки)

          Звук

            (O. Щелчок по кнопке)

      M. Музыкальное сопровождение

        Звуковой файл

      N. Нижняя полоса

        Графика

          (bottom_bar.bmp)

      O. Щелчок по кнопке

        Звуковой файл

Уже первый элемент интерфейса отличается от предыдущих примеров. Первое отличие, которое вы должны заметить, — включение имен реальных графических файлов, связанных с элементом. Это хороший способ отслеживать то, как художник назвал файлы, относящиеся к схеме. Следующее изменение — добавление графического элемента в свойства активной зоны. Я делаю это, чтобы отметить, что при щелчке по активной зоне изображение элемента должно быть заменено на новое. В данном случае пустое поле заменяется на поле с флажком и наоборот.

Далее идет элемент «Имя игрока». В его своиствах указан новый тип элемента — текстовое поле. Это указывает, что элемент содержит динамический текст, создаваемый из системного шрифта. Атрибуты элемента сообщают, что текстовое поле может содержать не более 16 символов. Это важная отметка, сообщающая художнику, чтобы он оставил на экране достаточно места для вывода переменной с именем игрока. Кроме того, у элемента «Имя игрока» есть новое свойство, называемое «Переменная состояния». Оно указывает, что внешний вид элемента зависит от внутренней переменной. В нашем случае переменная — это содержащий имя игрока массив символов с именем m_szPlayerName[]. Эту информацию полезно ввести в схему для того, чтобы вы могли отслеживать какие переменные требуются для работы вашего интерфейса.

Следующий элемент называется «Цвет игрока» и указанные для него тип и свойства уже знакомы вам. Единственное отличие — имена графических файлов. Я указал диапазон имен, чтобы отметить, что для отображения данного элемента используются несколько графических изображений. С графическим элементом связана также переменная состояния. Это сообщает вам, что выводимое графическое изображение должно соответствовать значению переменной состояния.

Перейдем ниже, к элементу с меткой «H», чтобы познакомиться с новым типом элемента — текстовым полем ввода. Этот тип указывает, что игрок может вводить текст в данное поле. Переменная состояния, указанная в свойствах элемента, будет хранить введенный текст.

Типы остальных элементов мы уже рассмотрели ранее, поэтому здесь мы их пропустим.

Раскадровка интерфейса

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

Рис. 6.7. Раскадровка интерфейса игры Battle Armor

Рис. 6.7. Раскадровка интерфейса игры Battle Armor

Обратите внимание на пять прямоугольников на рисунке. Каждый прямоугольник представляет собой уменьшенную версию рассмотренных мной ранее кадров. Под каждым прямоугольником написано название кадра интерфейса. Это поможет связать написанную схему с визуальной раскадровкой.

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

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


Глава 6 - Удобство и простота интерфейса

Удобство и простота интерфейса

Удобство и простота использования интерфейса вашей игры безусловно одна из важнейших составляющих при выпуске высококачественного продукта. Если игроку не понравится интерфейс, он просто не будет играть в игру. Это и впрямь так просто. Я уверен, что в прошлом вам не раз встречались игры с плохим интерфейсом. Но я так же готов поспорить, что в такие игры вы играли не слишком долго. Проектируя свой интерфейс помните о следующих ключевых вопросах:

  • Сколько щелчков мышью должен сделать пользователь для выполнения обычных задач?
  • Сколько уровней в вашей иерархии меню?
  • Согласованы ли интерфейсы во всей игре?

Щелчки мышью и взаимодействие с интерфейсом

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

В большинстве игр есть обычные задачи, которые игрок выполняет по ходу игры много раз. Например, в Warcraft III часто приходится выбирать какое-нибудь подразделение. Это действие выполняется одиночным щелчком левой кнопкой мыши. Вы также можете выбрать сразу несколько подразделений, расширив квадрат выбора на карте таким образом, чтобы он охватывал все требуемые подразделения. Теперь вообразите, что произойдет, если убрать из игры возможность выбирать сразу несколько подразделений. Пользователю пришлось бы по одному выбирать требуемые подразделения, пока группа не была бы сформирована полностью. Это была бы огромная головная боль, и для очевидно простого действия потребовалось бы очень много щелчков мышью.

Одни из методов, которые я использую для ответа на первый вопрос, заключается в создании в моей программе счетчика щелчков мыши (или других действий, необходимых для ввода данных). Я подсчитываю сколько раз игрок нажал клавишу на клавиатуре или одну из кнопок мыши и сколько раз он перемещал джойстик. В конце игры я сохраняю значения этих счетчиков в текстовом файле. Затем я пытаюсь настроить интерфейс таким образом, чтобы значения счетчиков уменьшились. Пока количество сделанных пользователем щелчков уменьшается, я знаю, что нахожусь на правильном пути. Этот прием должен применяться все время, пока жив ваш проект. Всегда старайтесь насколько возможно уменьшить количество операций ввода.

Сложность навигации по меню

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

К счастью этой ловушки довольно просто избежать. Спроектируйте меню согласно своим представлениям, а затем подсчитайте сколько уровней располагается между игроком и каждым пунктом меню. Если для того, чтобы выбрать пункт меню следует пройти более трех промежуточных меню, программа, возможно, становится слишком сложной. Конечно, из каждого правила есть исключения, но в большинстве случаев следование «правилу трех» оправдывает себя.

Согласованность интерфейса

Вы когда нибудь играли в игру, где на каждом экране интерфейс полностью меняется? Ну и как, понравилось? Меня это бесконечно раздражает. Последний ключевой вопрос относится к согласованности интерфейса. Понятия не имею почему, но некоторые проектировщики считают, что очень круто заставить игрока изучать в каждом игровом меню новый интерфейс. Поверьте мне, большинству игроков это не доставляет никакого удовольствия.

Так как же добиться согласованности интерфейса? Один способ — минимизировать изменения графического оформления при переключении меню. Позже в этой главе вы увидите несколько программ, демонстрирующих навигацию по меню. Добравшись до них, попробуйте обратить внимание на то, как зафиксированы меню, чтобы не допустить значительных изменений графического оформления. Сведя изменения графики к минимуму, вы предоставите игроку графический и психологический якорь, привязывающий его к интерфейсу. Работа с соответствующими этому правилу интерфейсами большинству пользователей кажется более простой. И вашей игре следование этому правилу будет только на пользу.

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


Глава 6 - Двухмерная графика в Direct3D

Двухмерная графика в Direct3D

Теперь, когда вы знаете как сформулировать требования к интерфейсу и как сделать интерфейс простым и удобным, пришла пора узнать как отображать интерфейс на экране! Отбросьте опасения — в этом разделе я расскажу вам о том, как отображать графику интерфейса. Так как все новые графические карты построены на основе аппаратных ускорителей трехмерной графики, в рассматриваемых далее методах для отображения интерфейса будет использоваться трехмерная графика. Обратной стророной трехмерной визуализации является то, что вам не всегда требуется учитывать глубину при отображении объектов. Отдельные элементы интерфейса, такие как текст, выглядят лучше когда отображаются как двухмерные. Учитывая эти два требования, позвольте мне объяснить вам, как отображать трехмерную графику, которая будет выглядеть как двухмерная. Я покажу как можно использовать текстуры трехмерных объектов для имитации двухмерной графики.

Взгляните на рис. 6.8 и пристегните ремни — настало время писать код.

Рис. 6.8. Окно программы TitleScreen

Рис. 6.8. Окно программы TitleScreen

На рис. 6.8 показано окно, полученное в результате работы программы D3D_TitleScreen. Эта программа отображает стартовый экран воображаемой игры Battle Armor, который выглядит так, будто нарисован с использованием двухмерной графики. (Возможно однажды у меня появится время, чтобы действительно закончить работу над игрой Battle Armor, но до тех пор будем просто притворяться, что это реальная игра.) Итак, начнем! А сейчас загрузите проект в Visual C++, чтобы двигаться дальше.

Архитектура проекта D3D_TitleScreen

Проект содержит два уникальных файла: main.cpp и main.h. Остальные, включенные в проект файлы, — d3dfont.cpp, d3dutil.cpp и dxutil.cpp, — являются частью Microsoft DirectX 9.0 SDK. Вы заметили, что список короче чем раньше? Это из-за того, что данный пример не использует предоставляемый Microsoft каркас приложения DirectX. В данном проекте мы воспользуемся лишь несколькими вспомогательными файлами для выполнения действий не имеющих отношения к каркасу приложения.

Программе необходимы библиотеки d3d9.lib, dxguid.lib, d3dx9dt.lib, d3dxof.lib, comctl32.lib и winmm.lib.

Заголовочный файл Main.h

Есть только один заголовочный файл, который представляет для нас интерес — main.h. В этом заголовочном файле я выполняю следующие действия:

  • Объявляю глобальные переменные Direct3D.
  • Описываю пользовательскую структуру формата вершин.
  • Объявляю различные глобальные переменные проекта.
  • Определяю прототипы функций.

Ключевые типы данных Direct3D

В первых строках кода объявлены несколько ключевых переменных DirectX, необходимых для работы программы. Вот как выглядит этот фрагмент:

LPDIRECT3D9             g_pD3D           = NULL;
LPDIRECT3DDEVICE9       g_pd3dDevice     = NULL;
LPDIRECT3DVERTEXBUFFER9 g_pVBInterface   = NULL;
LPDIRECT3DTEXTURE9      g_pTexture[32];

Первая переменная относится к типу LPDIRECT3D9 и названа g_pD3D. Это указатель на интерфейс Direct3D. Без этого интерфейса программа не сможет выполнять никаких действий, относящихся к отображению трехмерной графики на экране. Это важный фрагмент головоломки, так что убедитесь, что помните о нем.

Следующая из объявленных переменных относится к типу LPDIRECT3DDEVICE9 и названа g_pd3dDevice. Она является указателем на устройство визуализации. Без устройства визуализации ваша программа будет выглядеть ужасно скучно. Эта переменная будет использоваться при настройке видеоадаптера, установке метода визуализации и отображении трехмерной графики. Учитывая, как много она делает, вам лучше хорошенько запомнить ее.

Следующая переменная, g_pVBInterface типа LPDIRECT3DVERTEXBUFFER9 используется для хранения необходимых примеру данных трехмерной геометрии. Буфер вершин хранит вершины, являющиеся основными кирпичиками из которых строятся трехмерные объекты. Чуть позже мы обсудим эту тему более подробно.

И, наконец, в коде объявлен массив текстур с именем g_pTexture[]. В нем хранится 32 элемента типа LPDIRECT3DTEXTURE9. Это всего лишь место хранения изображений, необходимых примеру. Благодаря вспомогательным библиотекам DirectX использовать текстуры очень удобно. Кроме того, они очень важны, так что уделите им достаточно внимания. Может быть вам интересно, почему я создал массив для 32 текстур. Для этого нет никаких причин. В программе используется только семь элементов массива, но всегда лучше предохраняться, чем извиняться!

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

int g_iXOffset = 0;
int g_iYOffset = 0;
int g_iWindowWidth = 640;
int g_iWindowHeight = 480;

Сначала перечислены две целочисленных переменных, g_iXOffset и g_iYOffset, которые применяются для хранения смещения клиентской области окна относительно координат окна. Я объясню их назначение позже, при обсуждении файла main.cpp. Сейчас просто примите их такими, как они есть, так же как смерть и налоги.

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

Структура данных настраиваемого формата вершин (FVF)

Перейдем дальше к структуре данных настраиваемого формата вершин. Она объявляет формат геометрических данных используемых в примере для трехмерной визуализации. Код выглядит следующим образом:

struct CUSTOMVERTEX
{
   D3DXVECTOR3 position;  // Местоположение
   D3DXVECTOR3 vecNorm;   // Нормаль
   FLOAT     tu, tv;      // Координаты текстуры
   FLOAT     tu2, tv2;    // Координаты текстуры
};
#define D3DFVF_CUSTOMVERTEX (D3DFVF_XYZ | D3DFVF_NORMAL | D3DFVF_TEX2)

DirectX использует структуру данных вершины при описании геометрии визуализируемых трехмерных объектов. Без такой структуры вы ничего не сможете отобразить на экране. Это очень важный аспект DirectX и краеугольный камень визуализации. Как видите, в описанном мной формате вершин, присутствует несколько ключевых элементов: сведения о местоположении, данные нормали и координаты текстуры.

Данные местоположения

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

    Точка 1 — (0,0,0)

    Точка 2 — (0,10,0)

    Точка 3 — (10,0,0)

Взгляните на рис. 6.9, чтобы увидеть образованный этими точками треугольник.

Рис. 6.9. Треугольник, образованный тремя точками в трехмерном пространстве

Рис. 6.9. Треугольник, образованный тремя точками в трехмерном пространстве

Как видно из рис. 6.9, местоположение каждой из вершин определяет геометрию образуемого в трехмерном пространстве объекта. Если изменить местоположение, то при визуализации изменится и геометрия объекта. Понятно, что для трехмерной визуализации это очень важно. Для хранения данных о местоположении я использую тип данных D3DXVECTOR3, поскольку он содержит элементы для хранения координат по осям X, Y и Z.

Данные нормали

Затем я объявляю данные, содержащие информацию о нормали грани. Нормали в трехмерной визуализации задают направление, в котором направлена грань. Эти данные необходимы для трехмерного освещения, поскольку аппаратура должна знать, как изображать освещение грани. Данные нормали хранятся в виде набора трех координат, так же, как и данные местоположения. Поэтому я снова использую значение типа D3DXVECTOR3. Чтобы увидеть нормаль, взгляните на рис. 6.10.

Рис. 6.10. Нормаль треугольной грани

Рис. 6.10. Нормаль треугольной грани

Обратите внимание, что на рис. 6.10 вектор нормали расположен под углом 90 градусов к грани. Это показывает, что нормали направлены от лицевой поверхности треугольника. Если вы хотите, чтобы лицевой считалась другая поверхность треугольника, просто измените вектор, чтобы он указывал в другом направлении. Большей частью эти данные относятся к случаю выполнения полутонового затенения, а не к плоскому затенению. Поскольку визуализация полутонового затенения базируется на данных нормалей, это весьма важная информация.

Данные текстуры

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

Direct3D представляет информацию о координатах текстуры в виде декартовых координат на плоскости. Другими словами координаты текстуры содержат два числа — одно для оси X и одно для оси Y. Главное отличие координат текстуры заключается в том, что диапазон допустимых значений для них ограничен. Они могут принимать значения в диапазоне от 0.0 до 1.0. Это объясняется тем, что указываются относительные координаты, а реальные будут зависеть от размера изображения текстуры. Возьмем текстуру размером 16 на 16 точек. Координаты текстуры (0.5, 0.5) будут соответствовать центру текстуры с реальными координатами точки (8, 8). В случае текстуры размером 32 на 32 точки те же самые координаты текстуры будут соответствовать центру текстуры с реальными координатами (16, 16). Эту концепцию иллюстрирует рис. 6.11.

Рис. 6.11. Координаты текстуры указываются относительно ее размера

Рис. 6.11. Координаты текстуры указываются относительно ее размера

На рис. 6.11 представлены две сетки. Сетка слева изображает текстуру размером 16 на 16 точек. Сетка справа изображает текстуру размером 32 на 32 точки. Под каждой сеткой изображена шкала координат текстуры с диапазоном значений от 0.0 до 1.0. Поскольку шкала связана с размером текстуры, размеры шкал совпадают с размерами сеток. Шкала справа в два раза больше, чем шкала слева, но диапазон значений на ней все тот же — от 0.0 до 1.0. Я понимаю, что говорю очевидные вещи, но они важны для понимания координат текстур.

Корме того, для каждой сетки я показываю диапазон координат точек. Меньшая сетка начинается с точки (0, 0) и заканчивается точкой (15, 15). Большая текстура начинается с точки (0, 0) и простирается до точки (31, 31). Здесь нет ничего особенного; числа просто обозначают координаты точек.

В центре каждой сетки я помещаю маркер для координат текстуры (0.5, 0.5). Он показывает вам как одни и те же координаты текстуры могут соответствовать двум совершенно различным точкам в зависимости от размера текстуры. Поскольку координаты текстуры масштабируются, указываемая ими точка перемещается в зависимости от целевой текстуры.

Определение формата вершины

Теперь, когда структура данных вершины описана, настало время создать определение вершины для последующего использования. В инструкции #define установлены флаги формата вершины. Этот набор флагов сообщает системе визуализации, какие данные следует ожидать в структуре данных вершины. Вам следует быть очень внимательными и перечислять флаги в том порядке, в котором данные располагаются в структуре.

В данной программе я использую три флага: D3DFVF_XYZ, D3DFVF_NORMAL и D3DFVF_TEX2. Флаг D3DFVF_XYZ указывает данные местоположения. Флаг D3DFVF_NORMAL указывает данные нормали. Флаг D3DFVF_TEX2 указывает наличие двух наборов координат текстур. Если вам нужна дополнительная информация о других доступных флагах, обратитесь к документации DirectX.

Прототипы функций

Теперь вы закончили знакомство со структурой данных для настраиваемого формата вершин. Следующий блок кода задает прототипы используемых в программе функций. Вот как он выглядит:

void vDrawInterfaceObject(int iXPos, int iYPos, float fXSize, float fYSize,
                          int iTexture);
void vInitInterfaceObjects(void);
LRESULT WINAPI fnMessageProcessor(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam);
HRESULT InitD3D(HWND hWnd);
void vRender(void);
void vCleanup(void);

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

Файл программы Main.cpp

Следующий уникальный файл, main.cpp, содержит основную функциональность программы. Он следует стандартной структуре программы, которую я описал в главе 2.

Функция WinMain()

Первой представляющей интерес функцией является WinMain(). Как и в любой программе для Windows, она является главной точкой входа в код. Вот как выглядит ее листинг:

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, PSTR szCmdLine,
                    int iCmdShow)
{
   HWND         hWnd;
   MSG          msg;
   WNDCLASSEX   wndclass;
   RECT         rcWindowClient;
   // Инициализация класса окна
   wndclass.cbSize        = sizeof(wndclass);
   wndclass.style         = CS_HREDRAW | CS_VREDRAW;
   wndclass.lpfnWndProc   = fnMessageProcessor;
   wndclass.cbClsExtra    = 0;
   wndclass.cbWndExtra    = 0;
   wndclass.hInstance     = hInstance;
   wndclass.hIcon         = LoadIcon(NULL, IDI_APPLICATION);
   wndclass.hCursor       = LoadCursor(NULL, IDC_ARROW);
   wndclass.hbrBackground = (HBRUSH) GetStockObject (WHITE_BRUSH);
   wndclass.lpszMenuName  = NULL;
   wndclass.lpszClassName = "Title Demo";
   wndclass.hIconSm       = LoadIcon(NULL, IDI_APPLICATION);
   // Регистрация класса окна
   if(RegisterClassEx(&wndclass) == NULL) {
      // Выход из программы при сбое
      exit(1);
   }
   // Создание окна
   hWnd = CreateWindowEx(WS_EX_OVERLAPPEDWINDOW,
                "Title Demo", "D3D_TitleScreen",
                WS_OVERLAPPEDWINDOW, 0, 0,
                g_iWindowWidth, g_iWindowHeight,
                NULL, NULL, hInstance, NULL);
   // Отображение окна
   ShowWindow(hWnd, iCmdShow);
   // Получение размеров клиентской области
   GetClientRect(hWnd, &rcWindowClient);
   // Вычисление смещения визуализации на основе размеров клиентской области
   g_iXOffset = (g_iWindowWidth - (rcWindowClient.right - rcWindowClient.left));
   g_iYOffset = (g_iWindowHeight - (rcWindowClient.bottom - rcWindowClient.top));
   // Изменение размеров окна, чтобы они соответствовали желаемому разрешению
   SetWindowPos(hWnd, NULL, 0, 0,
          g_iWindowWidth + g_iXOffset,  // Ширина
          g_iWindowHeight + g_iYOffset, // Высота
          NULL);
   // Очистка структуры сообщения
   ZeroMemory(&msg, sizeof(msg));
   // Инициализация Direct3D
      if(SUCCEEDED(InitD3D(hWnd))) {
         // Инициализация виртуального буфера для отображения четырехугольников
         vInitInterfaceObjects();
         // Вход в цикл сообщений
         while(msg.message != WM_QUIT) {
         if(PeekMessage(&msg, NULL, 0U, 0U, PM_REMOVE)) {
            TranslateMessage(&msg);
            DispatchMessage(&msg);
         }
         else {
            // Визуализация сцены
            vRender();
         }
      }
   }
   // Освобождение ресурсов и выход из приложения
   vCleanup();
   UnregisterClass("Title Demo", wndclass.hInstance);
   return 0;
}

Чтобы отделить код, относящийся к Direct3D от «стандартного» кода программы для Windows, взгляните на рис. 6.12.

Рис. 6.12. Структура вызовов других функций в функции WinMain()

Рис. 6.12. Структура вызовов других функций в функции WinMain()

На рис. 6.12 показан список функций, которые я использую в WinMain(). Они приведены в порядке их исполнения. Обратите внимание, что большинство вызовов не относятся к Direct3D. Это объясняется тем, что функциия WinMain() содержит в основном код инициализации, а не код выполнения или визуализации.

Первым интересным фрагментом кода является вызов функции GetClientRect(). Вы можете недоумевать, почему она внесена в список. Для лучшего понимания позвольте мне рассказать о некоторых особенностях написания программ для работы в оконном режиме.

Вычисление смещения клиентской области

Вы должны помнить, что у функции CreateWindowEx() есть параметры, задающие размер создаваемого окна. Эти параметры позволяют создать окно необходимого размера. Проблема заключается в том, что при этом не учитывается наличие у окна заголовка. В результате, если у вашего окна есть заголовок, вы получите меньше пространства для визуализации, чем рассчитывали. Взгляните на рис. 6.13.

Рис. 6.13. Окно размером 640 x 480

Рис. 6.13. Окно размером 640 x 480

На рис. 6.13 показано созданное мной окно шириной 640 точек и высотой 480 точек. Обратите внимание, что высота заголовка равна 24 точкам. В результате высота видимой области визуализации будет составлять только 456 точек, что представляет проблему как для художника, так и для программиста. Поскольку все ваши вычисления для визуализации графического интерфейса пользователя базируются на размерах экрана, проблема должна быть решена. Функция GetClientRect() — ваш выход.

Функция GetClientRect() сообщает вам действительный размер видимой области визуализации. Вооружившись этими размерами вы можете настроить размеры окна, чтобы устранить несоответствие. Для этого сперва надо взять желаемый размерокна и вычесть из него размер клиентской области, возвращенный функцией GetClientRect(). Получив два числа вы изменяете размер окна, делая его равным сумме желаемого размера и полученного на предыдущем этапе числа. Вот как выглядят формулы этого трюка:

    Новый размер окна по X =
    (Желаемый размер по X) + ((Желаемый размер по X) - (Размер клиентской области по X))

    Новый размер окна по Y =
    (Желаемый размер по Y) + ((Желаемый размер по Y) - (Размер клиентской области по Y))

Для примера, изображенного на рис. 6.13 формулы работают следующим образом:

    Новый размер окна по X = 640 + (640 - 640)

    Новый размер окна по Y = 480 + (480 - 456)

Ширина окна остается равной 640 точкам, а высота становится равной 504 точкам. Теперь размеры области визуализации в окне достаточны для корректного отображения изображений размером 640 на 480 точек. На рис. 6.14 показано новое окно и его размеры.

Рис. 6.14. Измененное окно размером 640 x 504

Рис. 6.14. Измененное окно размером 640 x 504

Размеры окна меняются функцией SetWindowPos(). Она позволяет изменить формат окна, его размеры и местоположение. Нам сейчас необходимо изменить только размеры.

Перемещайтесь по коду вниз, пока не увидите вызов функции InitD3D(). Найдя его, перейдите к коду, реализующему функцию.

Функция InitD3D()

Функция InitD3D() занимается трудной задачей создания среды визуализации Direct3D. Используемый в этом примере код выглядит очень простым, если его сравнивать с полноценной процедуой инициализации Direct3D. Например, я не потрудился перечислить доступные видеоадаптеры и устройства. Код просто настраивает среду выполнения и надеется, что все будет хорошо. Если у вас инициализация экрана не выполняется, можно попробовать изменить код. Это должно сработать, поскольку основная часть кода инициализации взята из DirectX SDK. Хватит предостережений, давайте взглянем на код функции:

HRESULT InitD3D(HWND hWnd)
{
   D3DPRESENT_PARAMETERS   d3dpp;
   D3DXMATRIX              matproj, matview;
   D3DDISPLAYMODE          d3ddm;

   // Создание объекта D3D
   if(NULL == (g_pD3D = Direct3DCreate9(D3D_SDK_VERSION)))
      return E_FAIL;

   // Получение видеорежима, используемого рабочим столом, чтобы мы могли
   // установить такой же формат для вторичного буфера
   if(FAILED(g_pD3D->GetAdapterDisplayMode(
                            D3DADAPTER_DEFAULT, &d3ddm)))
      return E_FAIL;

   // Создание вторичного буфера и установка формата
   ZeroMemory(&d3dpp, sizeof(d3dpp));
   d3dpp.Windowed                = TRUE;
   d3dpp.SwapEffect              = D3DSWAPEFFECT_DISCARD;
   d3dpp.BackBufferFormat        = d3ddm.Format;
   d3dpp.EnableAutoDepthStencil  = FALSE;

   // Создание D3DDevice
   if(FAILED(g_pD3D->CreateDevice(D3DADAPTER_DEFAULT,
                    DS3DDEVTYPE_HAL, hWnd,
                    3DCREATE_HARDWARE_VERTEXPROCESSING,
                    &d3dpp, &g_pd3dDevice)))
   {
      return E_FAIL;
   }

   // Установка двухмерного представления и состояния визуализации
   D3DXMatrixIdentity(&matview);
   g_pd3dDevice->SetTransform(D3DTS_VIEW, &matview);

   // Установка ортогональной проекции, т.е двухмерная графика в трехменом пространстве
   D3DXMatrixOrthoLH(&matproj, (float)g_iWindowWidth, (float)g_iWindowHeight, 0, 1);
   // Задание матрицы проецирования
   g_pd3dDevice->SetTransform(D3DTS_PROJECTION, &matproj);
   // Выключение отбраковки
   g_pd3dDevice->SetRenderState(D3DRS_CULLMODE, D3DCULL_NONE);
   // Выключение освещения
   g_pd3dDevice->SetRenderState(D3DRS_LIGHTING, FALSE);
   // Выключение Z-буфера
   g_pd3dDevice->SetRenderState(D3DRS_ZENABLE, FALSE);
   g_pd3dDevice->SetRenderState(D3DRS_ZWRITEENABLE, FALSE);

   return S_OK;
}

Взгляните на рис. 6.15, чтобы представить ход выполнения функции.

Рис. 6.15. Ход выполнения функции InitD3D()

Рис. 6.15. Ход выполнения функции InitD3D()

Первым интересным моментом, который можно заметить на рисунке, является вызов единственной функции верхнего уровня с именем Direct3DCreate9(). После этого вызова все оставшиеся функции относятся к указателю на объект Direct3D с именем g_pD3D. Он обрабатывает все остальные, относящиеся к трехмерной графике вызовы функций, которые будут сделаны во время жизненного цикла программы.

Создание объекта Direct3D

Функция Direct3DCreate9() является самой важной в Direct3D, поскольку без нее ничего не сможет произойти. Это самая первая функция, которую вы вызываете в процессе настройки среды визуализации. Главной задачей функции является создание объекта IDirect3D9. Вот как выглядит ее прототип:

IDirect3D9 *Direct3DCreate9(
   UINT SDKVersion
);

Вы должны полюбить его за простоту. Единственный параметр, являющийся беззнаковым целым числом, указывает используемую вами версию DirectX SDK. Для этого параметра всегда следует использовать значение D3D_SDK_VERSION.

Параметр предназначен для проверки кода программы на отсутствие тривиальных ошибок. Фактически, выполняется проверка переданного номера версии и его сравнение с номером версии, хранящимся в заголовочных файлах DirectX. Если номера версий не совпадают, код знает, что что-то в установленном DirectX вызывает подозрения. Не заморачивайте себе этим голову; лучше всего оставьте эту функцию в покое и используйте рекомендованное значение.

ПРИМЕЧАНИЕ
Если вы добавите к вашей системе новые видеокарты или мониторы после вызова функции Direct3DCreate9(), объект IDirect3D9 не будет знать об этих устройствах. Чтобы новые устройства были перечислены в списке, необходимо заново создать объект.
Настройка параметров отображения

Теперь объект интерфейса трехмерной графики готов к работе и пришла пора настроить параметры отображения. В нашем примере первый этап — это определение формата вторичного буфера для визуализации. Чтобы освежить знания об экранных буферах, взгляните на рис. 6.16.

Рис. 6.16. Двойная буферизация в действии

Рис. 6.16. Двойная буферизация в действии

На рис. 6.16 изображены два буфера — первичный (FB) и вторичный (BB). Оба буфера содержат графические данные. В первичном буфере находится изображение, которое в данный момент отображается на экране пользователя, а вторичный буфер хранит изображение, которое будет показано следующим. Когда наступает время показа следующего изображения, вторичный бувер меняется местами с первичным, либо содержимое вторичного буфера копируется в первичный буфер. В результате на экране появляется новое изображение.

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

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

Вернемся к настройке параметров вторичного буфера. Поскольку первичный буфер создается вместе с окном, параметры вторичного буфера должны быть настроены так, чтобы быть полностью идентичными. Для этого необходимо получить параметры текущего видеорежима и окна и сохранить этот формат. Задача решается функцией GetAdapterDisplayMode(). Прототип функции GetAdapterDisplayMode() выглядит следующим образом:

HRESULT GetAdapterDisplayMode(
   UINT Adapter,
   D3DDISPLAYMODE *pMode
);

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

Второй параметр, pMode, вызывает больше вопросов, поскольку является указателем на структуру данных D3DDISPLAYMODE. После завершения работы функции эта структура данных будет содержать информацию о текущем видеорежиме. На повестку дня выностися вопрос: «Как выглядит структура данных D3DDISPLAYMODE?» А вот и ответ на него:

typedef struct _D3DDISPLAYMODE {
   UINT Width;
   UINT Height;
   UINT RefreshRate;
   D3DFORMAT Format;
} D3DDISPLAYMODE;

Первый член структуры, Width, хранит ширину экрана в пикселях.

Второй член структуры, Height, хранит высоту экрана.

Третий член структуры, RefreshRate, содержит частоту кадров текущего видеорежима. Если его значение равно 0, значит установлено значение частоты по умолчанию.

Четвертое значение, Format, значительно сложнее, чем предыдущие. Эта переменная является перечислением типа D3DFORMAT и может содержать различные значения, описывающие формат поверхности для текущего видеорежима. Существует слишком много доступных значений, чтобы перечислять их здесь, так что за дополнительной информацией я рекомендую обращаться к документации DirectX SDK.

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

typedef struct _D3DPRESENT_PARAMETERS_ {
   UINT BackBufferWidth;
   UINT BackBufferHeight;
   D3DFORMAT BackBufferFormat;
   UINT BackBufferCount;
   D3DMULTISAMPLE_TYPE MultiSampleType;
   DWORD MultiSampleQuality;
   D3DSWAPEFFECT SwapEffect;
   HWND hDeviceWindow;
   BOOL Windowed;
   BOOL EnableAutoDepthStencil;
   D3DFORMAT AutoDepthStencilFormat;
   DWORD Flags;
   UINT FullScreen_RefreshRateInHz;
   UINT PresentationInterval;
} D3DPRESENT_PARAMETERS;

Первые два элемента структуры, BackBufferWidth и BackBufferHeight, достаточно просты; они просто хранят размеры буфера.

Следующий член данных, BackBufferFormat, содержит используемый для вторичного буфера формат поверхности. Здесь мы воспользуемся тем самым форматом, который получили ранее при вызове функции GetAdapterDisplayMode().

Затем мы задаем тип множественной выборки, хранящийся в переменной MultiSampleType. Эта переменная имеет тип D3DMULTISAMPLE_TYPE. Ух ты — еще одно перечисление! Давайте обратимся к таблице 6.1, чтобы разобраться в элементах этого нового перечисления и их назначении.

Таблица 6.1. Типы множественной выборки
Значение | Описание
D3DMULTISAMPLE_NONE | Множественная выборка отсутствует.
D3DMULTISAMPLE_NONMASKABLE | Разрешает использование значений уровня качества.
D3DMULTISAMPLE_2_SAMPLES | Доступно две выборки.
D3DMULTISAMPLE_3_SAMPLES | Доступно три выборки.
D3DMULTISAMPLE_4_SAMPLES | Доступно четыре выборки.
D3DMULTISAMPLE_5_SAMPLES | Доступно пять выборок.
D3DMULTISAMPLE_6_SAMPLES | Доступно шесть выборок.
D3DMULTISAMPLE_7_SAMPLES | Доступно семь выборок.
D3DMULTISAMPLE_8_SAMPLES | Доступно восемь выборок.
D3DMULTISAMPLE_9_SAMPLES | Доступно девять выборок.
D3DMULTISAMPLE_10_SAMPLES | Доступно десять выборок.
D3DMULTISAMPLE_11_SAMPLES | Доступно одиннадцать выборок.
D3DMULTISAMPLE_12_SAMPLES | Доступно двенадцать выборок.
D3DMULTISAMPLE_13_SAMPLES | Доступно тринадцать выборок.
D3DMULTISAMPLE_14_SAMPLES | Доступно четырнадцать выборок.
D3DMULTISAMPLE_15_SAMPLES | Доступно пятнадцать выборок.
D3DMULTISAMPLE_16_SAMPLES | Доступно шестнадцать выборок.
D3DMULTISAMPLE_FORCE_DWORD | Не используется.

Множественная выборка необходима для визуализации с включенным сглаживанием. Не знаете что такое сглаживание? Взгляните на рис. 6.17, чтобы увидеть его в действии.

Рис. 6.17. Сглаживание

Рис. 6.17. Сглаживание

На рис. 6.17 изображены две линии. Линия, изображенная слева, имеет ступенчатые края. У линии справа края сглажены. Обратите внимание, что линия справа выглядит более гладкой, а линия слева — более зазубренной. Это достигается благодаря использованию более светлых серых пикселей по краям линии. Если ваша видеокарта поддерживает сглаживание, вы можете включить его, чтобы графика в играх, поддерживающих эту технологию, выглядела более гладкой. Если видеокарта не поддерживает данную технологию, возможно пора подумать о ее замене.

ПРИМЕЧАНИЕ
Запомните, что множественная выборка корректно работает только в том случае, если значение параметра, определяющего режим работы цепочки переключения экранных буферов равно D3DSWAPEFFECT_DISCARD.

Вернемся к представленным параметрам. Следующий параметр в списке называется MultiSampleQuality и имеет тип DWORD. Как вы, возможно, догадались, этот параметр устанавливает качество множественной выборки. Его значение может находиться в диапазоне от нуля до максимально возможного уровня, возвращаемого функцией IDirect3D9::CheckDeviceMultiSampleType(), минус один.

Далее расположен параметр SwapEffect. Он сообщает системе визуализации о том, каким образом во время визуализации будет выполняться переключение экранных буферов. Параметр может принимать одно из значений перечисления D3DSWAPEFFECT. Значения этого перечисления и их описание приведены в таблице 6.2.

Таблица 6.2. Режимы переключения экранных буферов
Значение | Описание
D3DSWAPEFFECT_DISCARD | Разрешает драйверу видеокарты самому выбрать наиболее эффективный метод переключения. Этот метод обычно быстрее, чем другие. Единственная проблема этого метода заключается в том, что он негарантирует сохранность вторичного буфера и его содержимого. Так что при использовании этого метода вы не можете полагаться на неизменность содержимого вторичного буфера. Вы должны использовать этот метод, если значение параметра, определяющего тип множественной выборки, отличается от D3DMULTISAMPLE_NONE.
D3DSWAPEFFECT_FLIP | Использует циклическую схему экранных буферов. Буферы перемещаются по кругу в замкнутой кольцевой очереди. Метод позволяет добиться гладкой визуализации, если значение параметра, определяющего интервал переключения не равно D3DPRESENT_INTERVAL_IMMEDIATE.
D3DSWAPEFFECT_COPY | Этот метод может использоваться только в том случае, если у вас есть один вторичный буфер. Кроме того, этот метод гарантирует неизменность изображения во вторичном буфере. Недостатком метода является то, что в оконном режиме он может вызвать нарущения целостности графики. Это вызвано тем, что изображение выводится во время обратного хода кадровой развертки монитора. Не используйте этот метод, если ваша программа работает в оконном режиме и вам необходима гладкая графика.
D3DSWAPEFFECT_FORCE_DWORD | Не используется

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

Следующий член данных, Windowed, указывает будет ли использоваться оконный режим, или программа будет работать в полноэкранном режиме. Установите значение TRUE для оконного режима и FALSE — для полноэкранной визуализации.

Затем расположен флаг EnableAutoDepthStencil. Он сообщает системе визуализации будет ли она управлять буфером глубины или нет. Если вы присвоите данному параметру значение TRUE, Direct3D будет управлять буфером глубины за вашу программу.

Следующий член структуры, названный AutoDepthStencilFormat, устанавливает используемый формат буфера глубины. Он используется только в том случае, если значение параметра EnableAutoDepthStencil равно TRUE. Если вы используете автоматическую установку буфера глубины, убедитесь, что в этом параметре вы указали корректный формат.

Следующий член данных в списке носит неопределенное имя Flags. Вы не слишком любите такие прямолинейные имена как это? Значения для этого параметра перечислены в таблице 6.3.

Таблица 6.3. Флаги режима отображения
Значение | Описание
D3DPRESENTFLAG_LOCKABLE_BACKBUFFER | Разрешает приложению блокировать вторичный буфер.
D3DPRESENTFLAG_DISCARD_DEPTHSTENCIL | Разрешает системе освобождать z-буфер после каждого отображения данных буфера. Это может увеличить быстродействие, если драйвер видеокарты поддерживает данную возможность.
D3DPRESENTFLAG_DEVICECLIP | Область визуализации в оконном режиме будет обрезаться. Работает только в Windows 2000 и Windows XP.
D3DPRESENTFLAG_FORCEGDIBLT | Для копирования изображений будет использоваться GDI. Работает только в Windows 2000 и Windows XP.
D3DPRESENTFLAG_VIDEO | Сообщает драйверу видеокарты, что вторичный буфер содержит видеоданные.

Следующий член данных называется FullScreen_RefreshRateInHz. Его назначение достаточно очевидно: он задает частоту с которой видеокарта будет обновлять изображение на экране монитора. Для визуализации в оконном режиме здесь следует указать значение D3DPRESENT_RATE_DEFAULT; в этом случае будет установлена частота кадров по умолчанию данной видеокарты.

Ах, вот и последний элемент параметров отображения! Он называется PresentationInterval. Эта переменная устанавливает максимальную частоту, с которой будет отображаться вторичный буфер. Оконные приложения требуют, чтобы значение этого параметра было D3DPRESENT_INTERVAL_IMMEDIATE. Для полноэкранной визуализации можно использовать значение D3DPRESENT_INTERVAL_DEFAULT или одно из значений, перечисленных в таблице 6.4.

Таблица 6.4. Интервалы отображения
Значение | Описание
D3DPRESENT_INTERVAL_DEFAULT | Система сама выбирает частоту смены кадров. В оконном режиме используется частота по умолчанию.
D3DPRESENT_INTERVAL_ONE | Перед отображением графики система ждет один период кадровой развертки.
D3DPRESENT_INTERVAL_TWO | Перед отображением графики система ждет два периода кадровой развертки.
D3DPRESENT_INTERVAL_THREE | Перед отображением графики система ждет три периода кадровой развертки.
D3DPRESENT_INTERVAL_FOUR | Перед отображением графики система ждет четыре периода кадровой развертки.
D3DPRESENT_INTERVAL_IMMEDIATE | Система не ожидает завершения кадровой развертки перед выводом изображения. Этот метод может вызвать несогласованность графики.
Создание устройства трехмерной визуализации

Вы уже установили параметры отображения, так что настало время создать объект устройства IDirect3DDevice9. Он является основой для всех относящихся к визуализации вызовов и поэтому исключительно важен. Без этого маленького объекта вы ничего не сможете отобразить на экране. Для его создания применяется функция IDirect3D9::CreateDevice(). Вот как выглядит ее прототип:

HRESULT CreateDevice(
   UINT Adapter,
   D3DDEVTYPE DeviceType,
   HWND hFocusWindow,
   DWORD BehaviorFlags,
   D3DPRESENT_PARAMETERS *pPresentationParameters,
   IDirect3DDevice9 **ppReturnedDeviceInterface
);

Первый параметр, Adapter, содержит порядковый номер используемой видеокарты. Для большинства систем с одним монитором можно использовать значение D3DADAPTER_DEFAULT. Этот параметр работает точно так же как и первый параметр функции GetAdapterDisplayMode(), который я описал ранее.

Второй параметр, DeviceType, является перечислением, задающим тип устройства. Доступные типы перечислены в таблице 6.5. В данной программе я использую значение D3DDEVTYPE_HAL. Из-за этого программа может не работать с видеокартами, не поддерживающими аппаратное ускорение. В этом случае для того, чтобы программа запустилась попробуйте изменить значение этого параметра на D3DDEVTYPE_REF.

Таблица 6.5. Типы устройств
Значение | Константа | Описание
1 | D3DDEVTYPE_HAL | Визуализация выполняется аппаратурой видеокарты. Этот метод позволяет использовать преимущества доступных методов аппаратного ускорения.
2 | D3DDEVTYPE_REF | Direct3D всю визуализацию выполняет программно. Это плохой выбор, если видеокарта поддерживает аппаратное ускорение.
3 | D3DDEVTYPE_SW | Используется программное устройство визуализации, эмулирующее аппаратуру.

Третий параметр, hFocusWindow, задает окно, которому будет принадлежать фокус системы визуализации DirectX. Для полноэкранного режима это должно быть окно самого верхнего уровня. В рассматриваемом примере я использую дескриптор окна, созданного в функции WinMain(). Это общепринятая практика для приложений, работающих в оконном режиме.

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

Таблица 6.6. Флаги, задающие поведение устройства
Значение | Описание
D3DCREATE_FPU_PRESERVE | Приложение требует вычислений с плавающей точкой двойной точности.
D3DCREATE_MULTITHREADED | Direct3D будет работать в безопасном многопоточном режиме. Этот режим приводит к значительному падению производительности и должен применяться только в случае крайней необходимости.
D3DCREATE_PUREDEVICE | Указывает, что устройство не поддерживает вызовы Get для использования в блоках состояния. Кроме того, устройство не будет поддерживать обработку вершин. Лично я никогда не использую этот флаг.
D3DCREATE_HARDWARE_VERTEXPROCESSING | Обработка вершин производится аппаратурой видеокарты. Этот флаг используется в рассматриваемой программе. Если приложение на вашем компьютере не работает, попробуйте заменить его на следующее значение из спсика.
D3DCREATE_SOFTWARE_VERTEXPROCESSING | Обработка вершин производится программно. Это значительно медленнее, чем аппаратная обработка.
D3DCREATE_MIXED_VERTEXPROCESSING | Обработка вершин производится как аппаратурой, так и программно. Этот вариант может работать медленнее, чем чисто аппаратная обработка.
D3DCREATE_DISABLE_DRIVER_MANAGEMENT | Устраняет драйвер видеокарты от управления ресурсами. Если флаг установлен, всеми ресурсами управляет Direct3D.
D3DCREATE_ADAPTERGROUP_DEVICE | Используется, если одновременно работают несколько видеокарт.
D3DCREATE_MANAGED | Передает вопросы управления памятью в ведение устройства.

Пятый параметр, pPresentationParameters, задает параметры отображения для устройства. В рассматриваемом примере я использую указатель на инициализированную ранее структуру.

Последний параметр, ppReturnedDeviceInterface, содержит адрес указателя, который после завершения работы функции будет представлять созданное устройство. В этом параметре я передаю глобальный указатель на интерфейс IDirect3DDevice9 с именем g_pd3dDevice.

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

Настройка среды визуализации

Поскольку мы будем выполнять двухмерную визуализацию в трехмерной среде, сначала надо настроить область просмотра в которой будет отображаться графика. Главное отличие между обычной трехмерной визуализацией и визуализацией, которая выглядит как двухмерная, заключается в используемой при создании среды проекционной матрице. В данном конкретном случае для настройки проекционной матрицы я использую функцию D3DXMatrixOrthoLH(). Вот ее прототип:

D3DXMATRIX *D3DXMatrixOrthoLH(
   D3DXMATRIX *pOut,
   FLOAT w,
   FLOAT h,
   FLOAT zn,
   FLOAT zf
);

Первый параметр, pOut, хранит указатель на объект D3DXMATRIX, используемый для хранения полученного результата.

Второй параметр, w, задает ширину области просмотра.

Третий параметр, h, задает высоту области просмотра.

Четвертый параметр, zn, задает минимальное значение по оси Z. Все объекты, расположенные ближе к камере, чем указанное значение, не отображаются.

Пятый параметр, zf, задает максимальное значение по оси Z. Объекты, расположенные дальше от камеры не отображаются.

Взгляните на фрагмент моего кода, который выполняет эту операцию:

D3DXMatrixOrthoLH(&matproj, (float)g_iWindowWidth, (float)g_iWindowHeight, 0, 1);
g_pd3dDevice->SetTransform(D3DTS_PROJECTION, &matproj);

Обратите внимание на то, как я поместил размеры окна в вызов функции. Это там, где вы задаете размеры области визуализации (второй и третий параметры). Это действительно легко. Поместив размеры окна визуализации в проекционную матрицу мы получаем двухмерную проекционную матрицу для трехмерной сцены, идеально подходящую к нашему окну. Вызов функции настройки прямоугольной проекции выполняет всю необходимую работу.

Проекционная матрица является основой и готова к использованию. Последним шагом в настройке проекции для двухмерной визуализации является вызов функции SetTransform() с параметром D3DTS_PROJECTION. Он активирует проекционную матрицу и открывает путь к двухмерной визуализации.

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

Следующей настройкой состояния визуализации является отключение аппаратного освещения для сцены. Этот шаг не является необходимым; я добавил его только чтобы упростить данный пример. Для выключения освещения переменной состояния визуализации с именем D3DRS_LIGHTING присваивается значение FALSE.

Затем я отключаю Z-буфер, присваивая переменной состояния визуализации D3DRS_ZENABLE значение FALSE. Я выключаю z-буферизацию для того, чтобы никакие фрагменты моей двухмерной картинки не были удалены во время визуализации.

Последним шагом настройки состояний визуализации является отключение записи в z-буфер. Для этого переменной состояния визуализации D3DRS_ZWRITEENABLE присваивается значение FALSE. Я делаю это чтобы предотвратить изменение z-буфера операциями двухмерной визуализации. Это очень важно, когда в одной сцене совместно используются двухмерные и трехмерные элементы. Вы же не хотите, чтобы двухмерные элементы интерфейса изменяли буфер глубины, используемый вашими трехмерными объектами.

На этом настройка среды визуализации закончена. Пришло время функции vInitInterfaceObjects().

Функция vInitInterfaceObjects()

К данному моменту вы создали окно, проинициализировали Direct3D, создали проекционную матрицу для двухмерного отображения и установили различные переменные состояния визуализации. Функция vInitInterfaceObjects() содержит последний фрагмент головоломки, завершающий общую картину инициализации. Она создает геометрические объекты, необходимые для трехмерной визуализации и загружает двухмерные текстуры для отображения на экране. Взгляните на рис. 6.18, где показан геометрический объект, создаваемый в этой функции.

Рис. 6.18. Полоса треугольников, используемая для двухмерной визуализации

Рис. 6.18. Полоса треугольников, используемая для двухмерной визуализации

На рис. 6.18 вы видите четырехугольник, созданный из двух полигонов. У фигуры четыре вершины, соединенные линиями, образующими четырехугольник. Вы можете заметить, что две грани изображены пунктирной линией. Эти грани выделены по той причине, что мы используем для описания объекта полосу треугольников. Полоса треугольников требует меньшего объема памяти, чем список треугольников и зачастую быстрее отображается, поскольку центральному процессору и графическому процессору приходится выполнять меньше работы. За дополнительной информацией я рекомендую обратиться к справочной системе DirectX SDK.

Создание буфера вершин

Чтобы создать объект для визуализации необходимо сначала создать буфер вершин, который будет хранить данные, описывающие геометрию объекта. Эту задачу выполняет функция IDirect3DDevice9::CreateVertexBuffer(). Она достаточно прямолинейна, поскольку лишь создает буфер вершин для геометрических операций DirectX. Вот как выглядит прототип этой функции:

HRESULT CreateVertexBuffer(
   UINT Length,
   DWORD Usage,
   DWORD FVF,
   D3DPOOL Pool,
   IDirect3DVertexBuffer9 **ppVertexBuffer,
   HANDLE* pHandle
);

Первый параметр, Length, содержит размер создаваемого буфера вершин. Это очень важный параметр, поскольку вам придется дальше работать с буфером вершин запрошенного размера.

Второй параметр, Usage, содержит одну или несколько констант типа D3DUSAGE. Доступные значения перечислены в таблице 6.7.

Таблица 6.7. Значения D3DUSAGE
Значение | Описание
D3DUSAGE_DYNAMIC | Буфер вершин требует динамического использования памяти. Это позволяет драйверу видеокарты управлять буфером для оптимизации скорости визуализации. Если данный флаг отсутствует, буден создан статический буфер. Совместно с этим флагом нельзя использовать флаг параметров пула D3DPOOL_MANAGED
D3DUSAGE_AUTOGENMIPMAP | Для буфера автоматически генерируются mip-текстуры. Этот метод требует больше памяти, но благодаря его использованию достигается более высокое качество визуализации
D3DUSAGE_DEPTHSTENCIL | Буфер является буфером глубины или буфером трафарета. Совместно с этим флагом можно использовать только флаг параметров пула D3DPOOL_DEFAULT
D3DUSAGE_RENDERTARGET | Буфер является целевым буфером визуализации. Совместно с этим флагом должен использоваться флаг параметров пула D3DPOOL_DEFAULT

В рассматриваемой программе я присваиваю параметру значение NULL. В результате будет создан статический буфер вершин.

Третий параметр, FVF, содержит комбинацию флагов D3DFVF. Он сообщает системе, какая информация будет содержаться в данных каждой вершины. Вы можете предпочесть не использовать формат FVF для буфера вершин. В этом случае присвойте данному параметру значение NULL. За дополнительной информацией о флагах D3DFVF обращайтесь к документации DirectX SDK.

Четвертый параметр, Pool, сообщает системе, какой класс памяти следует использовать. Так как данные вершин занимают память, они должны ее где-то получить. Значения D3DPOOL указывают доступные возможности. Все значения перечислены в таблице 6.8.

Таблица 6.8. Значения D3DPOOL
Значение | Описание
D3DPOOL_DEFAULT | Разрешает системе выделять память в наиболее подходящем пуле данных. Данный метод требует освобождения выделенных ресурсов перед сбросом устройства Direct3D
D3DPOOL_MANAGED | Разрешает системе в случае необходимости копировать ресурсы из системной памяти в память устройства. Это позволяет выполнять сброс устройства без предварительного принудительного освобождения управляемой памяти
D3DPOOL_SYSTEMMEM | Требует, чтобы система хранила ресурсы вне памяти устройства. Это не самый эффективный способ для тех систем, где есть аппаратные ускорители трехмерной графики. Созданные ресурсы не требуется освобождать перед сбросом устройства
D3DPOOL_SCRATCH | Ресурсы данного типа недоступны для устройства Direct3D. Тем не менее их можно создавать, блокировать и копировать
D3DPOOL_FORCE_DWORD | Не используется

В программе для настройки пула памяти я использую значение D3DPOOL_DEFAULT. В данной ситуации это простейший способ действий.

Пятый параметр, ppVertexBuffer, должен содержать адрес указателя IDirect3DVertexBuffer9. После завершения функции он будет указывать на созданный буфер вершин. В коде я использую глобальный буфер вершин с именем g_pVBInterface.

Последний параметр, pHandle, в данное время не используется, так что не стоит о нем беспокоиться. Разве это не здорово?

Блокировка буфера вершин

Следующая вызываемая в программе функция называется IDirect3DVertexBuffer9::Lock(). Она блокирует только что созданный буфер вершин для редактирования. В трехмерной визуализации буфер вершин нельзя редактировать сразу как захочется. Прежде, чем начать редактироывать содержимое буфера, вы должны заблокировать его. Это действие гарантирует, что данные не будут использоваться, пока вы их редактируете. Это необходимо сделать потому, что у видеокарты есть собственный процессор. Вот как выглядит прототип функции блокировки:

HRESULT Lock(
   UINT OffsetToLock,
   UINT SizeToLock,
   VOID **ppbData,
   DWORD Flags
);

К счастью, параметры этой функции не слишком сложны. Первый из них, OffsetToLock, задает номер байта в буфере вершин с которого начинается блокировка. Он очень полезен, когда надо отредактировать часть буфера вершин не блокируя его весь. Если вы не хотите задавать смещение, укажите значение 0.

Второй параметр, SizeToLock, указывает количество байт, блокируемых в буфере вершин. Если вы хотите заблокировать весь буфер, укажите значение 0.

Третий параметр, ppbData, является указателем на блокируемый буфер вершин. Ничего себе имечко у него!

Четвертый параметр, Flags, определяет параметры блокировки. В рассматриваемом примере никакие флаги не используются, но я все равно приведу описание доступных флагов в таблице 6.9.

Таблица 6.9. Значения D3DLOCK
Значение | Описание
D3DLOCK_DISCARD | Приложение переписывает значения пользуясь только операциями записи. Этот метод применяется когда используются динамические данные, такие как динамические текстуры, и буферы вершин
D3DLOCK_NO_DIRTY_UPDATE | Оберегает систему от изменения грязных областей данных. Флаг используется достаточно редко
D3DLOCK_NOSYSLOCK | Предотвращает остановку действий системы, таких как перемещение курсора мыши, на время блокировки. Рекомендуется использовать, когда выполняется длительная по времени блокировка.
D3DLOCK_READONLY | Буфер доступен только для чтения
D3DLOCK_NOOVERWRITE | Система немедленно возвращается из функции блокировки, поскольку приложение обязуется не перезаписывать содержимое буфера. Этот вариант работает быстрее, чем блокировка только для чтения

Как видите, в коде программы я использовал принятые по умолчанию параметры блокировки.

Заполнение буфера вершин данными

Итак, у вас в руках есть свежесозданный буфер вершин, но в нем нет никаких осмысленных данных. Что же делать программисту? Я скажу что вам требуется сделать: создайте необходимые данные вершин и поместите их в буфер!

Взгляните на следующий фрагмент кода в котором содержатся используемые программой данные вершин:

pVertices[0].position  = D3DXVECTOR3(0.0f, 0.0f, 0.0f);
pVertices[0].tu        = 0.0f;
pVertices[0].tv        = 1.0f;
pVertices[0].tu2       = 0.0f;
pVertices[0].tv2       = 1.0f;
pVertices[0].vecNorm   = D3DXVECTOR3(0.0f,0.0f,1.0f);
pVertices[1].position  = D3DXVECTOR3(0.0f, 1.0f, 0.0f);
pVertices[1].tu        = 0.0f;
pVertices[1].tv        = 0.0f;
pVertices[1].tu2       = 0.0f;
pVertices[1].tv2       = 0.0f;
pVertices[1].vecNorm   = D3DXVECTOR3(0.0f,0.0f,1.0f);
pVertices[2].position  = D3DXVECTOR3(1.0f, 0.0f, 0.0f);
pVertices[2].tu        = 1.0f;
pVertices[2].tv        = 1.0f;
pVertices[2].tu2       = 1.0f;
pVertices[2].tv2       = 1.0f;
pVertices[2].vecNorm   = D3DXVECTOR3(0.0f,0.0f,1.0f);
pVertices[3].position  = D3DXVECTOR3(1.0f, 1.0f, 0.0f);
pVertices[3].tu        = 1.0f;
pVertices[3].tv        = 0.0f;
pVertices[3].tu2       = 1.0f;
pVertices[3].tv2       = 0.0f;
pVertices[3].vecNorm   = D3DXVECTOR3(0.0f,0.0f,1.0f);

Я знаю, что необученному взгляду эти данные почти ничего не говорят. Черт побери, даже для обученного взгляда они похожи на мигрень. Вы еще не забыли рис. 6.18? Его обновленная версия приведена на рис. 6.19.

Рис. 6.19. Геометрия, используемая для двухмерной визуализации в трехмерном пространстве

Рис. 6.19. Геометрия, используемая для двухмерной визуализации в трехмерном пространстве

На рис. 6.19 представлены четыре пронумерованных вершины. Первой вершине присвоен номер 0, а последней — номер 3. Номера на рис. 6.19 соответствуют позициям в массиве вершин из приведенного выше кода. Находящиеся рядом с каждой из вершин координаты показывают вам где в трехмерном пространстве расположена каждая вершина.

Проследуйте от вершины 0 к вершине 3 и обратите внимание, что форма пути напоминает букву Z, положенную на бок. Поскольку я использую полосу треугольников, система сама дополнит оставшиеся грани, чтобы образовался квадрат. Обратите внимание также на то, что на рисунке отмечена базовая точка изображения. Это очень важный момент, поскольку все последующие операции трансформации должны знать какая точка квадрата является базовой.

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

Вот так выглядят данные вершин в действии. Если данные не обрели смысл, попробуйте переместить вершины и посмотрите, что получится. Вы можете без особого труда создать деформированный квадрат — для этого достаточно чуть-чуть изменить данные местоположения вершин.

Данные вершин помещены на свои места, так что можно идти дальше и разблокировать буфер вершин. Это делает простой вызов функции IDirect3DVertexBuffer9::Unlock(). Вам надо только вызвать функцию разблокировки для буфера вершин, и вы завершите его редактирование.

Загрузка текстур

Буфер вершин полностью наряжен, но пока нет места куда он мог бы пойти. Как насчет нескольких текстур? Оставшаяся часть кода инициализации осуществляет загрузку текстур, необходимых программе TitleScreen. Для загрузки текстур я использую функцию D3DXCreateTextureFromFile() предоставляемую вспомогательной библиотекой DirectX. Вот как выглядит ее прототип:

HRESULT D3DXCreateTextureFromFile(
   LPDIRECT3DDEVICE9 pDevice,
   LPCSTR pSrcFile,
   LPDIRECT3DTEXTURE9 *ppTexture
);

Первый параметр, pDevice, является указателем на устройство Direct3D, которое вы используете для визуализации. Я передаю в этом параметре созданный ранее глобальный указатель на устройство.

Второй параметр, pSrcFile, содержит имя загружаемого файла. Функция может загружать файлы различных типов, включая JPEG, TGA, BMP и PCX. В коде программы я передаю в этом параметре имена различных файлов с текстурами. Если у вас есть желание поэкспериментировать с различными типами графических файлов, вы можете изменить приведенные имена.

Третий параметр, ppTexture, является адресом указателя на объект IDirect3DTexture9, который будет хранить загруженную текстуру. Как видите в рассматриваемом примере я передаю в этом параметре глобальный массив текстур с именем g_pTexture.

Вот и все, что я хотел сказать о загрузке текстур. Как видите, стоит только привыкнуть и эта работа оказывается очень легкой.

Функция vRender()

Вы готовы к визуализации? Думаю, да. Следуйте за мной к функции vRender(). Вот как выглядит ее код:

void vRender(void)
{
   // Очистка вторичного буфера
   g_pd3dDevice->Clear(0, NULL,
             D3DCLEAR_TARGET,
             D3DCOLOR_XRGB(0,0,0),
             1.0f, 0);
   // Начало создания сцены
   g_pd3dDevice->BeginScene();

   // Рисование титульного экрана
   vDrawInterfaceObject(0, 0, 256.0f, 256.0f, 0);
   vDrawInterfaceObject(256, 0, 256.0f, 256.0f, 1);
   vDrawInterfaceObject(512, 0, 256.0f, 256.0f, 2);
   vDrawInterfaceObject(0, 256, 256.0f, 256.0f, 3);
   vDrawInterfaceObject(256, 256, 256.0f, 256.0f, 4);
   vDrawInterfaceObject(512, 256, 256.0f, 256.0f, 5);

   // Логотип
   vDrawInterfaceObject(192, 64, 256.0f, 256.0f, 6);

   // Конец создания сцены
   g_pd3dDevice->EndScene();

   // Вывод содержимого вторичного буфера на экран
   g_pd3dDevice->Present(NULL, NULL, NULL, NULL);
}

Эй, этот код выглядит не так уж и страшно! Фактически большинство сложных операций визуализации выполняются в функции vDrawInterfaceObject().

Функция vDrawInterfaceObject()

Функция vDrawInterfaceObject() не является частью DirectX. Я создал ее специально для этой программы чтобы упростить трехмерную визуализацию двухмерных объектов. Функция выполняет ту же работу, что и стандартная функция копирования двухмерных изображений. Она берет изображение и помещает его на экран в указанное с помощью координат место. Вот как выглядит прототип функции:

void vDrawInterfaceObject(
   int iXPos,
   int iYPos,
   float fXSize,
   float fYSize,
   int iTexture
);

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

Следующие два параметра, fXSize и fYSize, задают размер отображаемой на экране текстуры. Они необходимы для того, чтобы система знала как выполнять масштабирование.

Последний параметр, iTexture, является индексом в глобальном массиве текстур. Он определяет какая исменно текстура будет отображена.

Теперь взгляните на код этого бриллианта из мира функций:

void vDrawInterfaceObject(int iXPos, int iYPos, float fXSize, float fYSize,
                           int iTexture)
{
   D3DXMATRIX   matWorld, matRotation;
   D3DXMATRIX   matTranslation, matScale;
   float        fXPos, fYPos;

   // Установка начальных значений местоположения,
   // масштабирования и вращения
   D3DXMatrixIdentity(&matTranslation);
   // Масштабирование спрайта
   D3DXMatrixScaling(&matScale, fXSize, fYSize, 1.0f);
   D3DXMatrixMultiply(&matTranslation, &matTranslation, &matScale);
   // Поворот спрайта
   D3DXMatrixRotationZ(&matRotation, 0.0f);
   D3DXMatrixMultiply(&matWorld, &matTranslation, &matRotation);
   // Вычисление местоположения на экране
   fXPos = (float)(-(g_iWindowWidth / 2) + iXPos);
   fYPos = (float)(-(g_iWindowHeight / 2) - iYPos + fYSize - g_iYOffset);
   // Перемещение спрайта
   matWorld._41 = fXPos;  // X
   matWorld._42 = fYPos;  // Y
   // Установка матрицы
   g_pd3dDevice->SetTransform(D3DTS_WORLD, &matWorld);
   g_pd3dDevice->SetTexture(0, g_pTexture[iTexture]);
   g_pd3dDevice->SetStreamSource(0, g_pVBInterface, 0, sizeof(CUSTOMVERTEX));
   g_pd3dDevice->SetFVF(D3DFVF_CUSTOMVERTEX);
   g_pd3dDevice->DrawPrimitive(D3DPT_TRIANGLESTRIP, 0, 2);
   // Разыменовывание текстуры
   g_pd3dDevice->SetTexture(0, NULL);
}

В начале кода создается матрица для трехмерного объекта, содержащая значения по умолчанию. Это достигается путем вызова функции D3DXMatrixIdentity(). Эта вспомогательная функция предоставляется DirectX SDK и удобна для создания матрицы по умолчанию. Она обнуляет за вас все значения в матрице, за исключением тех, которые расположены на главной диагонали (им она присваивает значение 1). Это эквивалент стирания старых записей со школьной доски.

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

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

По умолчанию матрица описывает геомертию трехмерного пространства. Поскольку мы выполняем операции с двухмерной поверхностью дисплея, необходимо вычислить координаты объекта в экранных пикселах. Делая это я учитываю высоту и ширину экрана. Как можно увидеть в коде примера, я делю размеры экрана на два и использую их в формуле вместе с желаемыми координатами объекта, чтобы вычислить его местположение на экране. Концепцию трехмерных координат в пространстве экрана поясняет рис. 6.20.

Рис. 6.20. Отображение текстуры в трехмерном пространстве на экране

Рис. 6.20. Отображение текстуры в трехмерном пространстве на экране

На рис. 6.20, точка с координатами (0,0) соответствует центру экрана. Это коренным образом отличается от традиционной двухмерной визуализации. В традиционной двухмерной среде изображенной на рис 6.20 точке в центре экрана соответствуют координаты (400,300). Так как вы имеете дело с трехмерным миром, вам необходимо компенсировать это различие. Для этого и пригодился код о котором я только что говорил. Он преобразует трехмерную геометрию для отображения в двухмерном пространстве экрана.

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

Теперь матрица содержит данные о масштабировании, вращении и перемещении, необходимые для визуализации. Матрицу следует активировать, для чего необходимо вызвать функцию IDirect3DDevice9::SetTransform(). Я вызываю эту функцию, которая меняет матрицу D3DTS_WORLD на матрицу, созданную ранее для отображения растра. В результате активируется новая матрица.

Затем я активирую требуемую текстуру. Для этой цели применяется вызов функции IDirect3DDevice9::SetTexture(), которой в качестве параметра передается указатель на ту текстуру, которую мы хотим отобразить.

Чтобы при визуализации использовался созданный ранее в программе буфер вершин, я должен активировать его вызвав функцию IDirect3DDevice9::SetStreamSource(). Эта функция активирует указанный буфер вершин, после чего он будет использован в качестве потокового источника для последующих вызовов функций визуализации.

Следующей вызывается функция с именем IDirect3DDevice9::SetFVF(), которая сообщает системе визуализации формат буфера вершин. В нем содержится важная информация, такая как нормаль, цвет и координаты текстуры. В параметре этой функции я передаю описание настраиваемого формата вершин D3DFVF_CUSTOMVERTEX определенное в заголовочном файле программы.

И последней — но от этого она не становится менее важной — я вызываю функцию IDirect3DDevice9::DrawPrimitive(). Она является самым сердцем визуализации и выполняет всю работу, необходимую для отображения трехмерных объектов. Поскольку для изображения квадрата использовалась полоса треугольников, в первом параметре функции я указываю тип данных D3DPT_TRIANGLESTRIP.

И, наконец, я разыменовываю текстуру, присваивая активной текстуре значение NULL.

Закончив разбирать функцию рисования элементов интерфейса, вернемся назад к функции vRender() и посмотрим, каким образом в ней реализовано рисование интерфейса. Ниже я еще раз привел отвечающий за это фрагмент кода:

vDrawInterfaceObject(0, 0, 256.0f, 256.0f, 0);
vDrawInterfaceObject(256, 0, 256.0f, 256.0f, 1);
vDrawInterfaceObject(512, 0, 256.0f, 256.0f, 2);
vDrawInterfaceObject(0, 256, 256.0f, 256.0f, 3);
vDrawInterfaceObject(256, 256, 256.0f, 256.0f, 4);
vDrawInterfaceObject(512, 256, 256.0f, 256.0f, 5);
vDrawInterfaceObject(192, 64, 256.0f, 256.0f, 6);

Чтобы лучше представить себе результат этих вызовов функций, взгляните на рис. 6.21.

Рис. 6.21. Расположение текстур на титульном экране

Рис. 6.21. Расположение текстур на титульном экране

На рис. 6.21 изображен экран обрамленный тонкой линией. Кроме того там изображены шесть текстур, расположенных в виде сетки. Эти шесть текстур полностью заполняют экран. Поскольку ширина и высота каждой текстуры равна 256 точкам, их общий размер превышает размеры экрана ширина которого равна 640 точкам, а высота — 480 точкам. Такие размеры текстур необходимы для того, чтобы они соответствовали требованиям оборудования, которое поддерживает только текстуры с размером, равным степени двойки. Большинство современных видеокарт могут работать только с текстурами размером 2 x 2, 4 x 4, 8 x 8, 16 x 16, 32 x 32, 64 x 64, 128 x 128, 256 x 256 и т.д. точек, поэтому и требуется данное действие. Лучший способ учесть эти требования — создать экран в требуемом разрешении, а затем разбить его на блоки размером 256 x 256 точек. Конечно, в результате увеличится объем использукмой памяти, но это можно проигнорировать.

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


Глава 6 - Горячие точки, или как я научился любить щелчки мыши

Горячие точки, или как я научился любить щелчки мыши

Теперь у вас есть чудесно выглядящий титульный экран, но идти дальше никуда нельзя. Лучшим выбором будет добавить несколько функциональных возможностей для работы с мышью и двигаться дальше! Предыдущий пример программы выглядит действительно предлестно, но не предоставляет пользователю никаких возможностей для взаимодействия. Чтобы исправить это упущение я покажу вам как добавлять к приложениям активные зоны, реагирующие на щелчки мышью. Поскольку в большинстве игр для PC управление выполняется мышью, это очень важный шаг. В данном разделе мы обсудим следующие темы:

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

Обнаружение активных зон

Если вы забыли, что такое активная зона, пожалуйста вернитесь к началу этой главы и еще раз прочитайте раздел «Активные зоны и графика». Первая из программ, которые мы сейчас рассмотрим, называется D3D_MouseZones, и демонстрирует простую навигацию по меню. Она содержит несколько работающих меню, по которым может перемещаться пользователь. Экран программы с главным меню изображен на рис. 6.22.

Рис. 6.22. Окно программы D3D_MouseZones

Рис. 6.22. Окно программы D3D_MouseZones

На рис. 6.22 показан экран с главным окном программы. В данном примере работают не все кнопки меню, а только Options и Exit. Программа устанавливает активные зоны и реагирует на щелчки пользователя по кнопкам меню. Теперь загрузите проект, чтобы перейти к изучению кода. Имя проекта — D3D_MouseZones.

Архитектура проекта D3D_MouseZones

Проект содержит четыре уникальных файла: main.cpp, main.h, MouseZoneClass.cpp и MouseZoneClass.h. Файлы с кодом класса для реализации активных зон являются основным отличием этого проекта от предыдущего.

Программе необходимы следующие библиотеки: d3d9.lib, dxguid.lib, d3dx9dt.lib, d3dxof.lib, comctl32.lib и winmm.lib.

Заголовочный файл Main.h

Главный заголовочный файл проекта называется main.h. В нем я выполняю следующие действия:

  • Устанавливаю глобальные переменные Direct3D.
  • Устанавливаю структуру настраиваемого формата вершин.
  • Устанавливаю различные глобальные переменные.
  • Определяю прототипы функций.
  • Устанавливаю глобальные данные активных зон.
  • Устанавливаю глобальные данные кнопок мыши.

Глобальные данные активных зон

Большая часть кода в файле main.h аналогична содержимому одноименного файла из предыдущего примера. Главные отличия заключаются в добавлении глобальных переменных и прототипов функций для работы с активными зонами. Поскольку для активных зон в данном примере используется отдельный класс, в файл main.h добавлено совсем немного нового кода. Взгляните на приведенный ниже листинг, обратив особое внимание на строки выделенные полужирным курсивом:

#define STRICT
#include <windows.h>
#include <commctrl.h>
#include <commdlg.h>
#include <math.h>
#include <tchar.h>
#include <stdio.h>
#include <D3DX9.h>
#include "DXUtil.h"
#include "D3DUtil.h"
#include "MouseZoneClass.h"

// Глобальные переменные
LPDIRECT3D9 g_pD3D = NULL;
LPDIRECT3DDEVICE9 g_pd3dDevice = NULL;
LPDIRECT3DVERTEXBUFFER9 g_pVBInterface = NULL;
// Глобальный массив для хранения графики интерфейса
LPDIRECT3DTEXTURE9  g_pTexture[32];
// Смещения видимой области окна
int    g_iXOffset = 0;
int    g_iYOffset = 0;
// Размеры окна
int    g_iWindowWidth = 640;
int    g_iWindowHeight = 480;
// Структура данных нашего настраиваемого формата вершин
struct CUSTOMVERTEX
{
   D3DXVECTOR3 position;   // Местоположение
   D3DXVECTOR3 vecNorm;    // Нормаль
   FLOAT       tu, tv;     // Координаты текстуры
   FLOAT       tu2, tv2;   // Координаты текстуры
};
// Описание структуры настраиваемого формата вершинwh
#define D3DFVF_CUSTOMVERTEX (D3DFVF_XYZ|D3DFVF_NORMAL|D3DFVF_TEX2)

void vDrawInterfaceObject(int iXPos, int iYPos, float fXSize, float fYSize,
                          int iTexture);
void vInitInterfaceObjects(void);
LRESULT WINAPI fnMessageProcessor(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam);
HRESULT InitD3D(HWND hWnd);
void vRender(void);
void vCleanup(void);
void vCheckInput(void);
void vSetupMouseZones(int iMenu);

// Глобальный класс активных зон
MouseZoneClass   MZones;
// Идентификатор текущего меню
int    g_iCurrentScreen = 0;
// Переменные состояния кнопок мыши
bool   g_bLeftButton = 0;
bool   g_bRightButton = 0;
// Глобальный дескриптор окна игры
HWND   g_hWnd;

Первым отличием приведенного кода является наличие директивы включения заголовочного файла MouseZoneClass.h. Этот файл содержит заголовочные данные класса MouseZoneClass. Я подробно опишу его чуть позже. До этого момента просто помните о необходимости включать этот файл каждый раз, когда вы хотите использовать класс MouseZoneClass.

Теперь обратите внимание на два новых прототипа функций. Первая из функций, называемая vCheckInput(), проверяет состояние мыши и обрабатывает полученные результаты. Вторая функция называется vSetupMouseZones() и устанавливает необходимые горячие точки в зависимости от текущего меню.

Следующий фрагмент нового кода создает глобальный объект MouseZoneClass. Он называется MZones и используется для всех активных зон, присутствующих в игре.

Затем в коде расположилась целочисленная переменная с именем g_iCurrentScreen. Она отслеживает в каком меню пользователь находится в данный момент. Это необходимо для того, чтобы программа знала какие команды меню ей обрабатывать.

Следующие две новых строки содержат переменные состояния кнопок мыши. Кнопка мыши может быть либо нажата, либо отпущена, так что для хранения ее состояния можно воспользоваться логической переменной. Когда значение переменной равно 0, — кнопка отпущена. Когда значение переменной равно 1, — кнопка нажата.

В последней строке кода создается глобальный дескриптор окна. Он необходим для доступа к созданному программой окну из функций, отличных от WinMain().

Файл программы Main.cpp

Следующий уникальный файл проекта называется main.cpp. Он содержит обычный код Windows и некоторый объем нового кода для обработки ввода от мыши и обнаружения активных зон.

Функция WinMain()

Функция WinMain() в рассматриваемой программе изменилась весьма незначительно. Взгляните на приведенный ниже фрагмент кода, в котором выделены главные отличия:

// Инициализация Direct3D
if(SUCCEEDED(InitD3D(hWnd))) {
   // Инициализация виртуального буфера для отображения четырехугольников
   vInitInterfaceObjects();
   // Инициализация активных зон
   vSetupMouseZones(0);
   // Начало цикла обработки сообщений
   while(msg.message != WM_QUIT) {
      if(PeekMessage(&msg, NULL, 0U, 0U, PM_REMOVE)) {
         TranslateMessage(&msg);
         DispatchMessage(&msg);
      }
      else {
         // Если все нормально, обработать щелчок мыши
         if(timeGetTime() > dwInputTimer) {
            // Проверка входных данных
            vCheckInput();
            dwInputTimer = timeGetTime() + 50;
         }

         // Проверка необходимости выхода из программы
         if(g_iCurrentScreen == 3) {
            break;
         }

         // Визуализация сцены
         vRender();
      }
   }
}

Первым изменением является добавленный вызов функции vSetupMouseZones(). Программа только что была запущена и вызов этой функции необходим для того, чтобы установить активные зоны для первого экрана. На рис. 6.23 показан первый экран, выводимый программой.

Рис. 6.23. Первый экран программы D3D_MouseZones

Рис. 6.23. Первый экран программы D3D_MouseZones

Сейчас изображенный на рис. 6.23 экран должен выглядеть для вас очень знакомо. Это титульный экран игры Battle Armor. Поскольку делать на титульном экране почти нечего, я устанавливаю лишь пару активных зон. Давайте перейдем к коду функции vSetupMouseZones(), чтобы увидеть какие зоны используются на титульном экране.

Функция vSetupMouseZones()

Функция vSetupMouseZones() содержит следующий код:

void vSetupMouseZones(int iMenu)
{
   // Титульный экран
   if(iMenu == 0) {
      MZones.vFreeZones();
      MZones.vInitialize(2);
      MZones.iAddZone("TITLE_SCREEN", 0, 0, 640, 480, 2);
      MZones.iAddZone("EXIT_BUTTON", 587, 0, 53, 24, 0);
   }
   // Главное меню
   else if(iMenu == 1) {
      MZones.vFreeZones();
      MZones.vInitialize(5);
      MZones.iAddZone("EXIT_BUTTON", 587, 0, 53, 24, 0);
      MZones.iAddZone("MAINMENU_NEWGAME", 192, 64, 256, 64, 0);
      MZones.iAddZone("MAINMENU_LOADGAME", 192, 128, 256, 64, 0);
      MZones.iAddZone("MAINMENU_SAVEGAME", 192, 192, 256, 64, 0);
      MZones.iAddZone("MAINMENU_OPTIONS", 192, 256, 256, 64, 0);
   }
   // Экран выхода из игры
   else if(iMenu == 2) {
      MZones.vFreeZones();
      MZones.vInitialize(1);
      MZones.iAddZone("TITLE_SCREEN", 0, 0, 640, 480, 2);
   }
   // Меню параметров
   else if(iMenu == 7) {
      MZones.vFreeZones();
      MZones.vInitialize(5);
      MZones.iAddZone("EXIT_BUTTON", 587, 0, 53, 24, 0);
      MZones.iAddZone("OPTIONS_AUDIO", 192, 64, 256, 64, 0);
      MZones.iAddZone("OPTIONS_VIDEO", 192, 128, 256, 64, 0);
      MZones.iAddZone("OPTIONS_DIFF", 192, 192, 256, 64, 0);
      MZones.iAddZone("OPTIONS_BACK", 192, 256, 256, 64, 0);
   }
}

Большинство вызовов функций кажутся абсолютно незнакомыми, потому что я пока еще не рассказал о классе MouseZoneClass. Сейчас я покажу только пример его использования, так что взгляните на первый блок кода в функции.

Функция MouseZoneClass::vFreeZones()

Как видите, функция проверяет переданный ей номер меню и действует соответствующим образом. WinMain() при вызове функции передает ей номер меню 0. В коде, относящемся к меню с номером 0, первым расположен вызов функции MouseZoneClass::vFreeZones(). Эта функция удаляет все существующие на данный момент активные зоны и выполняет инициализацию класса активных зон. Это необходимо, так как к титульному экрану можно перейти из других меню.

Функция MouseZoneClass::vInitialize()

Затем вызывается функция MouseZoneClass::vInitialize(). Она выделяет память для активных зон, количество которых передано ей в параметре. Вы можете указать число, превышающее максимальное количество активных зон, которые вы реально будете использовать. Соответствие параметра количеству активных зон необходимо только для повышения эффективности программы.

ВНИМАНИЕ
Убедитесь, что вы инициализируете класс для достаточного количества активных зон, чтобы он мог обрабатывать все зоны, которые вы намереваетесь использовать. Если вы превысите указанное при инициализацмм максимальное значение, код не будет работать.

Затем начинается код действительного создания активных зон. Активные зоны, используемые в титульном экране, создаются путем вызова функции MouseZoneClass::iAddZone(). Титульный экран содержит две активных зоны: одну для кнопки выхода из игры и одну для всего остального экрана. Кнопка выхода из игры направляет пользователя к финальному экрану программы, а вторая активная зона переносит игрока к главному меню.

Функция MouseZoneClass::iAddZone()

Прототип добавляющей активную зону функции выглядит следующим образом:

int MouseZoneClass::iAddZone(
   char *szZoneName,
   short shX,
   short shY,
   short shWidth,
   short shHeight,
   short shClickType)

Первый параметр называется szZoneName и в нем передается имя создаваемой активной зоны. Класс активной зоны использует это имя, чтобы сообщить вам какая из зон была активирована, поэтому оно очень важно. Для титульного экрана используются два имени. Кнопке выхода из игры я присваиваю имя EXIT_BUTTON, а остальной части экрана — имя TITLE_SCREEN.

Следующие два параметра, shX и shY, задают местоположение левого верхнего угла активной зоны. Активные зоны являются прямоугольными, так что данные о местоположении одного из углов необходимы. Все координаты указываются в пространстве рабочей области окна, так что вам не надо беспокоиться об их вычислении. Если вы взглянете на код, то увидите, что зона с именем TITLE_SCREEN начинается в левом верхнем углу экрана с координатами (0, 0).

Следующий параметр называется shWidth. Он задает ширину активной зоны. Ширина окна игры в рассматриваемом примере равна 640 точкам, поэтому я задаю ширину охватывающей весь экран зоны равной 640.

Следующий параметр, shHeight, задает высоту зоны.

Последний параметр называется shClickType и определяет тип щелчка мыши, на который будет реагировать зона. Доступные типы и их описания приведены в таблице 6.10.

Таблица 6.10. Типы щелчков мышью в классе MouseZoneClass
Значение | Описание
0 | Нажатие левой кнопки мыши.
1 | Нажатие правой кнопки мыши.
2 | Нажатие любой кнопки мыши.
3 | Нажатие кнопок мыши не требуется.

Тип щелчка позволяет управлять поведением активной зоны в зависимости от состояния кнопок мыши. Помимо прочих применений, это очень полезно для зон, реагирующих на наведение указателя мыши. В рассматриваемом примере я указываю, что зона TITLE_SCREEN будет срабатывать при щелчке любой кнопкой мыши, а зона EXIT_BUTTON реагирует только на щелчок левой кнопкой.

Функция vCheckInput()

Мы покинули функцию WinMain() и перешли к функции vSetupMouseZones(), а затем к членам класса активных зон. Настало время вернуться обратно к функции WinMain() и посмотреть где обрабатываются события мыши. В главной функции находится следующий код:

if(timeGetTime() > dwInputTimer) {
   // Проверка входных данных
   vCheckInput();
   dwInputTimer = timeGetTime()+50;
}
Управление щелчками мыши

Внутри этого небольшого фрагмента кода находится вызов функции vCheckInput(). Возможно, вы недоумеваете для чего здесь нужен вызов функции timeGetTime(). Дело в том, что современные компьютеры настолько быстрые, что единственное нажатие на кнопку мыши длится десятки итераций цикла обработки сообщений. В результате одно нажатие на кнопку мыши активирует пункт меню десятки раз. Это приведет к проблемам, поскольку ваш интерфейс окажется слишком чувствительным к сделанным пользователем щелчкам мышью. Чтобы побороть проблему, я установил таймер, который разрешает обработку сообытия мыши не чаще чем раз в 50 милисекунд. Код проверяет текущее время и смотрит прошло ли 50 милисекунд с момента последнего вызова функции vCheckInput(). Если прошло достаточно времени, функция вызывается снова и таймер сбрасывается. Если время еще не истекло, выполнение кода продолжается, но никакой проверки введенных данных не происходит. Величина 50 милисекунд выбрана мной произвольным образом и вы можете изменить ее в соответствии с вашими вкусами. Если вам непонятно, какой эффект она оказывает, установите значение 0 и запустите пример (попробуйте щелкнуть в меню по кнопке Options).

Функция vCheckInput() проверяет текущую позицию указателя мыши и выполняет действия, зависящие от того, какое меню в данный момент показывается пользователю. Вот как выглядит код этой функции:

void vCheckInput(void)
{
   bool   bRet;
   char   szZoneHit[64];
   POINT  Point;
   RECT   rcWindowRect;
   int    iMouseX;
   int    iMouseY;

   // Проверка смещения окна
   GetWindowRect(g_hWnd, &rcWindowRect);
   // Обновление позиции указателя мыши
   GetCursorPos(&Point);
   // Вычисление реальных координат указателя мыши
   iMouseX = Point.x - g_iXOffset - rcWindowRect.left;
   iMouseY = Point.y - g_iYOffset - rcWindowRect.top;
   // Проверка попадания в активную зону
   bRet = MZones.bCheckZones(
          (short)iMouseX, (short)iMouseY,
          szZoneHit, g_bLeftButton,
          g_bRightButton);
   if(bRet) {
      // ЛОГИКА ДЛЯ ТИТУЛЬНОГО ЭКРАНА
      if(g_iCurrentScreen == 0) {
         // Переход к главному меню
         if(!stricmp(szZoneHit, "TITLE_SCREEN")) {
            // Делаем главное меню текущим
            g_iCurrentScreen = 1;
            // Устанавливаем активные зоны
            vSetupMouseZones(1);
         }
         // Переход к экрану завершения игры
         else if(!stricmp(szZoneHit, "EXIT_BUTTON")) {
            // Делаем завершающий экран текущим
            g_iCurrentScreen = 2;
            // Устанавливаем активные зоны
            vSetupMouseZones(2);
         }
      }
      // ЛОГИКА ГЛАВНОГО МЕНЮ
      else if(g_iCurrentScreen == 1) {
         // Переход к экрану завершения игры
         if(!stricmp(szZoneHit, "EXIT_BUTTON")) {
            // Делаем завершающий экран текущим
            g_iCurrentScreen = 2;
            // Устанавливаем активные зоны
            vSetupMouseZones(2);
         }
         else if(!stricmp(szZoneHit, "MAINMENU_NEWGAME"))
         {
            // Добавьте сюда код для начала новой игры
         }
         else if(!stricmp(szZoneHit, "MAINMENU_LOADGAME"))
         {
            // Добавьте сюда код для загрузки игры
         }
         else if(!stricmp(szZoneHit, "MAINMENU_SAVEGAME"))
         {
            // Добавьте сюда код для сохранения игры
         }
         else if(!stricmp(szZoneHit, "MAINMENU_OPTIONS"))
         {
            // Делаем меню параметров текущим
            g_iCurrentScreen = 7;
            // Устанавливаем активные зоны
            vSetupMouseZones(7);
         }
      }
      // ЛОГИКА ЭКРАНА ЗАВЕРШЕНИЯ ИГРЫ
      else if(g_iCurrentScreen == 2) {
         // Выходим из программы, если пользователь
         // нажмет любую кнопку мыши
         if(!stricmp(szZoneHit, "TITLE_SCREEN")) {
            // Сообщаем WinMain() о завершении программы
            g_iCurrentScreen = 3;
         }
      }
      // ЛОГИКА МЕНЮ ПАРАМЕТРОВ
      else if(g_iCurrentScreen == 7) {
         // Переход к экрану завершения игры
         if(!stricmp(szZoneHit, "EXIT_BUTTON")) {
            // Делаем экран завершения игры текущим
            g_iCurrentScreen = 2;
            // Устанавливаем активные зоны
            vSetupMouseZones(2);
         }
         // Возврат к главному меню
         else if(!stricmp(szZoneHit, "OPTIONS_BACK")) {
            // Делаем главное меню текущим
            g_iCurrentScreen = 1;
            // Устанавливаем активные зоны
            vSetupMouseZones(1);
         }
      }
   }
}
Вычисление смещения клиентской области окна на рабочем столе

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

Рис. 6.24. Различия между клиентской областью и пространством рабочего стола

Рис. 6.24. Различия между клиентской областью и пространством рабочего стола

На рис. 6.24 показано окно игры на рабочем столе системы. Ширина клиентской области окна равна 640 точкам, а высота — 480 точкам. Ширина рабочего стола сотавляет 1024 точки, а его высота — 768 точек. В клиентской области окна есть активная зона, и ее координаты в клиентском пространстве — (340, 10). Очень важно понимать, что класс активных зон хранит координаты в клиентском простанстве, а не в пространстве рабочего стола. Теперь представьте себе, что произойдет, если вы будете искать активную зону с координатами (340, 10), а окно будет передвинуто. В этом вам поможет рис. 6.25.

Рис. 6.25. Перемещение окна на рабочем столе

Рис. 6.25. Перемещение окна на рабочем столе

На рис. 6.25 окно было перемещено. Поэтому его клиентская область сместилась на 10 точек вправо и на 10 точек вниз. Щелчки мышью по активной зоне теперь регистрируются со смещением на 10 точек по обеим осям. Это вызвано тем фактом, что система передает программе данные о положении указателя мыши в пространстве рабочего стола. Проверка активной зоны использует координаты (340, 10) и не беспокоится о смещении клиентской области окна. Это вызывает проблему, поскольку активаня зона на рис. 6.25 в действительности расположена по координатам (350, 20). Для решения этой проблемы мы вычисляем в каком месте рабочего стола расположено окно и вычитаем его координаты из координат указателя мыши в момент щелчка. В результате мы получаем координаты в клиентской области не зависящие от местоположения окна. На рис. 6.25 корректировка значений снова вернет нас в точку (340, 10), поскольку вычисления будут выглядеть следующим образом: (350–10, 20–10).

Вычисление местоположения указателя мыши

Следующим этапом работы кода является вычисление текущего местоположения указателя мыши. Это делается с помощью вызова предоставляемой Windows функции GetCursorPos(). Эта функция проверяет текущее местоположение указателя мыши и сохраняет полученный результат в структуре POINT. Структура POINT содержит координаты указателя мыши по осям X и Y.

ПРИМЕЧАНИЕ
Функция GetCursorPos() не является частью DirectX SDK. Это внутренняя функция Windows. Для работы с мышью я предпочитаю применять стандартные функцииI Windows, поскольку они достаточно быстрые и значительно проще в использовании, чем вызовы DirectX.

Следующие строки кода корректируют полученные координаты указателя мыши. Это достаточно простое вычисление — берутся координаты указателя мыши и из них вычитаются координаты окна и смещение клиентской области окна. В результате будут получены скорректированные координаты, которые можно использовать для проверки попадания по активной зоне.

Функция MouseZoneClass::bCheckZones()

В следующей части кода выполняется вызов функции bCheckZones(). Эта функция получает текущее состояние мыши и сравнивает его с хранящимися в памяти данными активной зоны. Если кнопки мыши находятся в требуемом состоянии, и указатель мыши расположен внутри активной зоны, функция возвращает название активной зоны. Вот как выглядит прототип функции:

bool MouseZoneClass::bCheckZones(
   short shX,
   short shY,
   char *szZoneHit,
   bool bLeftDown,
   bool bRightDown)

В первых двух параметрах передаются скорректированные координаты указателя мыши по осям X и Y соответственно. Мы передадим координаты, которые вычислили чуть раньше.

Следующим параметром является символьный буфер, куда будет помещено название активированной пользователем зоны. Название копируется в буфер если найдена зона с требуемыми координатами и выполнены условия ее активации.

В последних двух параметрах функции передается состояние кнопок мыши. Если кнопка нажата, передается 1, а если отпущена — 0. Я передаю здесь переменные g_bLeftButton и g_bRightButton.

Для примера предположим, что пользователь запустил программу и щелкнул по рисунку на титульном экране. В результате была активирована зона TITLE_SCREEN. Символьный массив szZoneHit теперь содержит название активированной зоны. Что делать дальше? Вы устанавливаете переменную g_iCurrentScreen, чтобы указать, что теперь пользователь находится в главном меню, а затем устанавливаете активные зоны для главного меню.

Переменная g_iCurrentScreen хранит состояние меню. Это необходимо, чтобы отслеживать местоположение пользователя в мире интерфейсов игры. Число 0 означает, что пользователь находится на титульном экране. Число 1 означает, что пользователь находится в главном меню. В приведенном ниже списке перечислены все используемые в рассматриваемом примере значения.

0 | Титульный экран
1 | Главное меню
2 | Экран завершения игры
3 | Выход из программы
7 | Меню Options

Чтобы переместить пользователя от одного меню к другому вы должны изменить значение переменной, содержащей номер текущего экрана, а затем установить активные зоны для нового меню. Теперь вы можете завершить перемещение пользователя, отобразив новый экран. Вот и все, что относится к навигации по меню! Взгляните на оставшуюся часть функции vCheckInput() и посмотрите, сможете ли вы следовать за ее логикой. Завершив это дело, взгляните на рис. 6.26, где изображена вся рассмотренная к данному моменту структура меню.

Рис. 6.26. Титульный экран, экран завершения, главное меню и меню параметров

Рис. 6.26. Титульный экран, экран завершения, главное меню и меню параметров

Обнаружение сообщений кнопок мыши

Работа функции vCheckInput() зависит от состояния кнопок мыши. К счастью, определение состояния кнопок мыши — очень простой процесс. Взгляните на функцию fnMessageProcessor() из программы. Вот как выглядит ее фрагмент:

switch(msg)
   {
      case WM_LBUTTONDOWN:
         g_bLeftButton = 1;
         break;
      case WM_LBUTTONUP:
         g_bLeftButton = 0;
         break;
      case WM_RBUTTONDOWN:
         g_bRightButton = 1;
         break;
      case WM_RBUTTONUP:
         g_bRightButton = 0;
         break;
      case WM_DESTROY:
      PostQuitMessage(0);
      return 0;
      default:
         break;
   }

Первые четыре инструкции case проверяют системные сообщения мыши. Первое из них, WM_LBUTTONDOWN, позволяет узнать что нажата левая кнопка мыши. Следующее, WM_LBUTTONUP, сообщает вам, что левая кнопка мыши была отпущена. То же самое справедливо и для правой кнопки мыши, только используются сообщения WM_RBUTTONDOWN и WM_RBUTTONUP.

Простейший способ хранить состояние мыши — воспользоваться глобальными переменными. Для хранения состояния двух кнопок мыши я использую переменные g_bRightButton и g_bLeftButton. Что может быть проще?

Выход из программы

Давайте на минуту сосредоточим наше внимание на функции WinMain(). Я знаю, что заставляю вас прыгать с места на место, но пожалуйста потерпите. Вернемся к функции WinMain() в которой содержится следующий код:

if(g_iCurrentScreen == 3) {
   break;
}

Этот код я использую для выхода пользователя из программы. Нельзя выкинуть пользователя из программы, как только он щелкнул по завершающему экрану. Сперва надо завершить главный цикл обработки сообщений. Лучший способ обнаружения завершения программы состоит в проверке значения специальной переменной. Я проверяю не равно ли значение переменной g_iCurrentScreen числу 3. Это сообщает мне, что пользователь закончил играть и необходимо завершить работу программы. В реальной игре может использоваться более трудоемкий метод, но для простого примера подходит и рассмотренный простой способ.

Динамическое отображение меню

Мы почти закончили исследование кода программы. К данному моменту вы уже знаете как установить активные зоны, определить местоположение указателя мыши, обрабатывать сообщения кнопок мыши и завершить работу пользователя с программой. Осталось узнать только одну вещь — как динамически отображать экраны меню. Это необходимо, поскольку графика меню меняется на каждом экране. Сосредоточьтесь на функции vRender() чтобы увидеть как это делается.

Код этой функции выглядит очень похоже на код предыдущей программы. Главное отличие заключается в появившейся проверке значения переменной g_iCurrentScreen. Цикл визуализации проверяет текущее значение переменной, чтобы определить, какое изображение выводить на экран. Как видите, фоновое оформление каждого меню одно и то же; меняется только изображение в центре экрана. Если вы взглянете на каждый логический блок, то увидите, что отличается только последний вызов функции рисования. Вы, если пожелаете, можете полностью изменить графику. Я же хотел оставить пример максимально простым.

Вот и все, что требуется для простого динамического отображения меню. Главная хитрость — проверять состояние программы и отображать соответствующую графику. Правда, просто?

Заголовочный файл MouseZoneClass.h

Итак, вы увидели класс активных зон в действии, и теперь настало время разобраться как он работает. Класс активных зон состоит из двух файлов: MouseZoneClass.h и MouseZoneClass.cpp. Откройте заголовочный файл MouseZoneClass.h и следуйте дальше.

Структура данных stHotSpot

Первый заслуживающий внимания элемент заголовочного файла класса — структура данных stHotSpot. Вот как выглядит ее код:

struct stHotSpot
{
   short m_shZoneXPos;
   short m_shZoneYPos;
   short m_shZoneWidth;
   short m_shZoneHeight;
   bool m_bActive;
   short m_shClickType;
   char *m_szZoneName;
};

Структура данных горячей точки представляет активную зону в экранных координатах. Поскольку зоны прямоугольные, указываются координаты угла, высота и ширина. Взаимосвязь между членами структуры данных и параметрами активной зоны показана на рис. 6.27.

Рис. 6.27. Взаимосвязь между структурой данных и активной зоной

Рис. 6.27. Взаимосвязь между структурой данных и активной зоной

Из рис. 6.27 видно, что переменные m_shZoneXPos и m_shZoneYPos задают координаты верхнего левого угла зоны. Член данных m_shZoneWidth определяет ширину зоны, а m_shZoneHeight — ее высоту.

Член данных m_bActive предназначен для внутреннего использования классом активных зон и определяет используется ли зона в данный момент. Он используется, когда при создании новой зоны происходит поиск свободного места для ее данных.

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

Переменная m_szZoneName используется для хранения имени активной зоны.

Закрытые члены данных класса MouseZoneClass

Далее в заголовочном файле расположено объявление класса MouseZoneClass. Ниже приведен его код:

class MouseZoneClass
{
   private:
      int       m_iMaxZones;
      stHotSpot *m_HotSpots;

   public:
      MouseZoneClass(void);
      ~MouseZoneClass(void);
      void vInitialize(int iMaxZones);
      void vFreeZones(void);
      int iAddZone(char *szZoneName, short shX, short shY,
                   short shWidth, short shHeight, short shClickType);
      int iRemoveZone(char *szZoneName);
      bool bCheckZones(short shX, short shY, char *szZoneHit,
                       bool bLeftDown, bool bRightDown);
};

Есть только две закрытые переменные класса — m_iMaxZones и m_HotSpots. Переменная m_iMaxZones хранит количество активных зон, для которых выделена память. Это очень важные сведения, поскольку количество используемых зон может изменяться. Переменная m_HotSpots является указателем на массив структур данных stHotSpot, представляющих реально существующие активные зоны.

Функции класса MouseZoneClass

Первые две объявленные функции — это конструктор и деструктор класса. В их прототипах нет ничего необычного; это просто обычная рутина программирования.

Следом за ними идет функция vInitialize(). Она получает единственный параметр, определяющий для скольких активных зон будет выделена память.

Функция vFreeZones() очищает всю память, выделенную для хранения данных активных зон и сбрасывает внутренние переменные.

Функция iAddZone() активирует новую зону в массиве m_HotSpots. Если свободная зона доступна, функция возвратит ее номер. Если же свободных зон больше нет, функция возвращает –1.

Функция iRemoveZone() делает указанную зону неактивной.

Функция bCheckZones() получает набор координат и состояние кнопок мыши, после чего определяет активирована ли какая-нибудь зона. Функция возвращает имя активной зоны. Если никакая зона не активирована, функция возвращает NULL.

Файл MouseZoneClass.cpp

Файл MouseZoneClass.cpp содержит код функций, объявленных в заголовочном файле. Откройте файл MouseZoneClass.cpp и пойдемте дальше.

Функция MouseZoneClass::MouseZoneClass()

Первой из функций является конструктор класса. Его код выглядит следующим образом:

MouseZoneClass::MouseZoneClass(void)
{
   // Количество зон равно нулю
   m_iMaxZones = 0;
}

Эта функция исключительно проста. Она только лишь устанавливает количество активных зон, для которых выделена память равным нулю. Это необходимо для того, чтобы деструктор не пытался освободить память, которая не была выделена.

Функция MouseZoneClass::~MouseZoneClass()

Следом идет деструктор класса, код которого выглядит так:

MouseZoneClass::~MouseZoneClass(void)
{
   // Очистка выделенных зон
   vFreeZones();
}

Единственное действие деструктора — вызов функции vFreeZones(). Поскольку деструктор вызывается когда класс покидает область видимости, необходимо гарантировать освобождение выделенной памяти до выхода из него. Именно по этой причине я и включил в код вызов функции освобождающей активные зоны.

Функция MouseZoneClass::vInitialize()

Ах, наконец-то мы добрались до функции чей код занимает больше двух строк. Она получает максимальное количество активных зон, которые вы намереваетесь использовать, выделяет память для хранения их данных и присваивает начальные значения. Вот ее код:

void MouseZoneClass::vInitialize(int iMaxZones)
{
   int i;
   // Очистка существующих зон
   vFreeZones();
   // Сохранение максимального количества зон
   m_iMaxZones = iMaxZones;
   // Выделение памяти для указанного количества зон
   m_HotSpots = new stHotSpot[m_iMaxZones];
   // Очистка данных зоны
   for(i = 0; i < m_iMaxZones; i++) {
      m_HotSpots[i].m_shZoneXPos = 0;
      m_HotSpots[i].m_shZoneYPos = 0;
      m_HotSpots[i].m_shZoneWidth = 0;
      m_HotSpots[i].m_shZoneHeight = 0;
      m_HotSpots[i].m_shClickType = 0;
      m_HotSpots[i].m_bActive = 0;
      m_HotSpots[i].m_szZoneName = new char[64];
      memset(m_HotSpots[i].m_szZoneName, 0x00, 64);
   }
}

Сначала выполняется вызов функции vFreeZones(), которая освобождает любую выделенную ранее память.

Затем я присваиваю значение единственного параметра функции внутренней переменной класса m_iMaxZones. Эта переменная используется внутри класса и очень важна, поскольку предохраняет внутренние циклы функций от выхода за пределы доступной памяти.

Теперь выделяется память для хранения массива структур данных горячих точек. Для каждой активной зоны создается одна структура данных.

В цикле перебираются все созданные активные зоны и всем их параметрам присваиваются нулевые значения. Кроме того, в цикле выделяется 64 байта памяти для хранения имени зоны. Это максимальная длина имени зоны. (Я выбрал это значение абсолютно произвольно; если вам нужны более длинные имена зон, вы вольны изменить его.) После выделения памяти для имени зоны, я заполняю ее нулевыми значениями.

Функция MouseZoneClass::vFreeZones()

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

void MouseZoneClass::vFreeZones(void)
{
   int i;

   if(m_iMaxZones) {
      // Освобождение имен
      for(i = 0; i < m_iMaxZones; i++) {
         delete [] m_HotSpots[i].m_szZoneName;
      }
      // Освобождение горячих точек
      delete [] m_HotSpots;
      m_iMaxZones = 0;
   }
}

Сначала функция проверяет значение члена данных m_iMaxZones, чтобы убедиться, что для горячих точек была выделена какая-то память. Если нет, функция немедленно завершается не выполняя никаких действий. Если же активные зоны были созданы, функция перебирает их по одной, освобождая выделенную для хранения их имен память. Сразу после этого функция удаляет данные всех активных зон. И, перед завершением работы функция устанавливает количество доступных зон равным 0.

Функция MouseZoneClass::iAddZone()

Эта функция активирует зону и записывает ее данные в доступный элемент массива структур данных горячих точек. Перед вызовом этой функции необходимо вызвать функцию vInitialize(). Если свободных элементов в массиве нет, функция возвращает –1. Код функции выглядит следующим образом:

int MouseZoneClass::iAddZone(char *szZoneName,
                 short shX, short shY,
                 short shWidth, short shHeight,
                 short shClickType)
{
   int i;

   for(i = 0; i < m_iMaxZones; i++) {
      // Ищем неиспользуемую зону
      if(m_HotSpots[i].m_bActive == 0) {
         m_HotSpots[i].m_shZoneXPos = shX;
         m_HotSpots[i].m_shZoneYPos = shY;
         m_HotSpots[i].m_shZoneWidth = shWidth;
         m_HotSpots[i].m_shZoneHeight = shHeight;
         m_HotSpots[i].m_shClickType = shClickType;
         // Активируем горячую точку
         m_HotSpots[i].m_bActive = 1;
         // Сохраняем имя
         strcpy(m_HotSpots[i].m_szZoneName, szZoneName);
         return(i);
      }
   }
   // Нет свободных зон, возвращаем -1 (ошибка)
   return(-1);
}

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

Функция MouseZoneClass::iRemoveZone()

Данная функция отключает активную зону. Вот ее код:

int MouseZoneClass::iRemoveZone(char *szZoneName)
{
   int i;

   for(i = 0; i < m_iMaxZones; i++) {
      // Проверим, активна ли зона
      if(m_HotSpots[i].m_bActive == 1) {
         // Проверка соответствия имени зоны
         if(!stricmp(m_HotSpots[i].m_szZoneName, szZoneName)) {
            // Деактивация
            m_HotSpots[i].m_bActive = 0;
            return(1);
         }
      }
   }
   return(0);
}

Функция перебирает в цикле все зоны, для которых выделена память и сравнивает имя каждой из них с переданным параметром. Если имя найдено, зона деактивируется путем установки переменной m_bActive в 0. Чтобы сообщить об успещном завершении функция возвращает 1.

Если зона с указанным именем не найдена, функция сообщает об ошибке, возвращая 0.

Функция MouseZoneClass::bCheckZones()

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

bool MouseZoneClass::bCheckZones(short shX, short shY, char *szZoneHit, bool
                                 bLeftDown, bool bRightDown)
{
   int i;
   for(i = (m_iMaxZones-1); i >= 0; i--) {
      // Проверим активна ли зона
      if(m_HotSpots[i].m_bActive == 1) {
         // Соответствует ли состояние кнопок требуемому?
         if((bLeftDown && m_HotSpots[i].m_shClickType == 0) ||
            (bRightDown && m_HotSpots[i].m_shClickType == 1) ||
            ((bRightDown || bLeftDown) && m_HotSpots[i].m_shClickType == 2) ||
            ((!bRightDown && !bLeftDown) && m_HotSpots[i].m_shClickType == 3)) {
            // Проверка координат по горизонтали
            if(m_HotSpots[i].m_shZoneXPos <= shX {
               // Проверка координат по вертикали
               if(m_HotSpots[i].m_shZoneYPos <= shY) {
                  // Попали ли в зону заданной ширины?
                  if((m_HotSpots[i].m_shZoneXPos + m_HotSpots[i].m_shZoneWidth)
                                    >= shX) {
                     // Попали ли в зону указанной высоты?
                     if((m_HotSpots[i].m_shZoneYPos + m_HotSpots[i]
                                  .m_shZoneHeight) >= shY) {
                        // Устанавливаем указатель на имя зоны
                        strcpy(szZoneHit, m_HotSpots[i].m_szZoneName);
                        // Возвращаем 1 (попадание)
                        return(1);
                     }
                  }
               }
            }
         }
      }
   }
   // Возвращаем 0 (нет попадания)
   return(0);
}

Функция начинается с цикла, перебирающего активные зоны для которых выделена память в обратном порядке. Я делаю это для того, чтобы зона, добавленная последней была выбрана первой. Благодаря этому вы можете создавать слои из зон. Зона добавленная поверх других сработает раньше, чем зоны добавленные ранее нее.

Сперва в цикле выполняется проверка включена ли зона. Если зона включена, мы переходим к проверке соответствия текущего состояния кнопок мыши типу щелчка, активирующего зону.

Если зона активируется щелчком левой кнопкой мыши, код проверяет равен ли тип щелчка 0 и равно ли 1 значение переменной bLeftDown.

Если зона активируется щелчком правой кнопкой мыши, код проверяет равен ли тип щелчка 1 и равно ли 1 значение переменной bRightDown.

Если зона может быть активирована щелчком любой кнопки мыши, код проверяет равен ли тип щелчка 2 и равно ли 1 значение переменной bLeftDown или bRightDown.

Если зона активируется, когда на нее наведен указатель мыши, а ни одна из кнопок не нажата, код проверяет равен ли тип щелчка 3 и равно ли 0 значение переменных bLeftDown и bRightDown.

Если какое-либо из перечисленных выше правил выполнено, код копирует имя активной зоны в буфер и возвращает 1, сигнализируя об успехе. Если же для всех зон ни одно из правил не выполнено, функция возвращает 0, сообщая что ни одна из активных зон не сработала.

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

Подсветка пунктов меню

Предыдущий пример программы достаточно интересен, но ему не хватает визуальных эффектов. Часть кнопок меню работает, но они выглядят не слишком живыми. Что может помочь? Я знаю — как насчет подсветки того пункта меню над которым в данный момент находится указатель мыши?

Возможно, играя в такие игры как Populous или Sacrifice вы удивлялись как их создатели заставляют элементы меню изменяться, когда на них наведен указатель мыши. Теперь вам не придется удивляться. В следующем примере программы я покажу простой и эффективный способ добиться такого эффекта. Загрузите программу D3D_MouseZoneHighlights и следуйте вместе со мной дальше.

Архитектура проекта D3D_MouseZoneHighlights

Проект содержит четыре уникальных файла: main.cpp, main.h, MouseZoneClass.cpp и MouseZoneClass.h. Данный пример программы является усовершенствованной версией проекта D3D_MouseZones в которой сделано не так уж и много изменений.

Для программы необходимы следующие библиотеки: d3d9.lib, dxguid.lib, d3dx9dt.lib, d3dxof.lib, comctl32.lib и winmm.lib.

Заголовочный файл Main.h

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

Рис. 6.28. Главное меню с подсветкой кнопки Options

Рис. 6.28. Главное меню с подсветкой кнопки Options

На рис. 6.28 показано главное меню. Главное отличие между этим рисунком и предыдущим примером программы заключается в том, что кнопка Options выделена с помощью подсветки. Кнопки меню изменяют свой цвет, когда указатель мыши проходит над ними. В этом и заключается суть подсветки зон.

Давайте вернемся к коду заголовочного файла. В приведенном ниже фрагменте кода отражены те изменения, которые были сделаны для поддержки подсветки зон:

// Переменные состояния подсветки
bool   g_bMainMenu_NewGame_Highlight = 0;
bool   g_bMainMenu_LoadGame_Highlight = 0;
bool   g_bMainMenu_SaveGame_Highlight = 0;
bool   g_bMainMenu_Options_Highlight = 0;

В рассматриваемом примере программы реализована подсветка четырех пунктов главного меню. Для этих четырех пунктов программа должна отслеживать какой именно из них в данный момент активирован. Я делаю это используя логические значения. Каждое логическое значение представляет состояние отдельного пункта меню. Если значение равно 0, соответствующая кнопка не подсвечена. Если же значение равно 1, механизм визуализации знает, что для кнопки следует включить подсветку. Весьма просто, не так ли?

Файл программы Main.cpp

Я не хочу утомлять вас, описывая код с которым вы уже знакомы, так почему бы сразу не перейти к сделанным изменениям? Итак, отправимся к функции vCheckInput(), чтобы увидеть первый набор изменений.

Как определить подсвечиваемую активную зону

Вот как выглядит код функции vCheckInput():

void vCheckInput(void)
{
   bool      bRet;
   char      szZoneHit[64];
   POINT     Point;
   RECT      rcWindowRect;
   int       iMouseX;
   int       iMouseY;

   // Проверка смещения окна
   GetWindowRect(g_hWnd, &rcWindowRect);

   // Обновить местоположение мыши
   GetCursorPos(&Point);

   // Вычисление реальных координат мыши
   iMouseX = Point.x - g_iXOffset - rcWindowRect.left;
   iMouseY = Point.y - g_iYOffset - rcWindowRect.top;

   // Проверка попадания
   bRet = MZones.bCheckZones((short)iMouseX, (short)iMouseY, szZoneHit,
                             g_bLeftButton, g_bRightButton);
   if(bRet) {
      // ЛОГИКА ТИТУЛЬНОГО ЭКРАНА
      if(g_iCurrentScreen == 0) {
         // Переход к главному меню
         if(!stricmp(szZoneHit, "TITLE_SCREEN")) {
            // Делаем главное меню текущим
            g_iCurrentScreen = 1;
            // Устанавливаем активные зоны
            vSetupMouseZones(1);
      }
      // Переход к экрану завершения игры
      else if(!stricmp(szZoneHit, "EXIT_BUTTON")) {
         // Делаем экран завершения текущим
         g_iCurrentScreen = 2;
         // Устанавливаем активные зоны
         vSetupMouseZones(2);
      }
   }
   // ЛОГИКА ГЛАВНОГО МЕНЮ
   else if(g_iCurrentScreen == 1) {
      // Выключаем подсветку всех зон
      g_bMainMenu_NewGame_Highlight = 0;
      g_bMainMenu_LoadGame_Highlight = 0;
      g_bMainMenu_SaveGame_Highlight = 0;
      g_bMainMenu_Options_Highlight = 0;
      // Переход к экрану завершения игры
      if(!stricmp(szZoneHit, "EXIT_BUTTON")) {
         // Делаем экран завершения текущим
         g_iCurrentScreen = 2;
         // Устанавливаем активные зоны
         vSetupMouseZones(2);
      }
      else if(!stricmp(szZoneHit, "MAINMENU_NEWGAME"))
      {
         // Добавьте сюда логику начала новой игры
      }
      else if(!stricmp(szZoneHit, "MAINMENU_LOADGAME"))
      {
         // Добавьте сюда логику для загрузки игры
      }
      else if(!stricmp(szZoneHit, "MAINMENU_SAVEGAME"))
      {
         // Добавьте сюда логику для записи игры
      }
      else if(!stricmp(szZoneHit, "MAINMENU_OPTIONS"))
      {
         // Деламем меню параметров текущим
         g_iCurrentScreen = 7;
         // Устанавливаем активные зоны
         vSetupMouseZones(7);
      }
      // Проверим находится ли указатель над активной зоной
      else if(!stricmp(szZoneHit, "MAINMENU_NEWGAME_H"))
      {
         // Активация подсветки
         g_bMainMenu_NewGame_Highlight = 1;
      }
      else if(!stricmp(szZoneHit, "MAINMENU_LOADGAME_H"))
      {
         // Активация подсветки
         g_bMainMenu_LoadGame_Highlight = 1;
      }
      else if(!stricmp(szZoneHit, "MAINMENU_SAVEGAME_H"))
      {
         // Активация подсветки
         g_bMainMenu_SaveGame_Highlight = 1;
      }
      else if(!stricmp(szZoneHit, "MAINMENU_OPTIONS_H"))
      {
         // Активация подсветки
         g_bMainMenu_Options_Highlight = 1;
      }
   }
   // ЛОГИКА ЭКРАНА ЗАВЕРШЕНИЯ
   else if(g_iCurrentScreen == 2) {
      // Выходим из программы, если пользователь нажал кнопку мыши
      if(!stricmp(szZoneHit, "EXIT_SCREEN")) {
         // Флаг, сообщающий WinMain() о завершении программы
         g_iCurrentScreen = 3;
      }
   }
   // ЛОГИКА МЕНЮ ПАРАМЕТРОВ
   else if(g_iCurrentScreen == 7) {
      // Переходим к завершающему экрану
      if(!stricmp(szZoneHit, "EXIT_BUTTON")) {
         // Делаем завершающий экран текущим
         g_iCurrentScreen = 2;
         // Устанавливаем активные зоны
         vSetupMouseZones(2);
      }
      // Возврат к главному меню
         else if(!stricmp(szZoneHit, "OPTIONS_BACK")) {
            // Делаем главное меню текущим
            g_iCurrentScreen = 1;
            // Устанавливаем активные зоны
            vSetupMouseZones(1);
         }
      }
   }
}

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

Затем код проверяет не активна ли какая-либо из подсвечиваемых зон. Подсвечиваемые зоны активируются когда ни одна из кнопок мыши не нажата; поэтому достаточно только навести на зону указатель мыши. Если подсвечиваемая зона активна, код присваивает соответствующей логической переменной значение true. Это сообщает функции визуализации, что надо вывести подсвеченное изображение данной кнопки вместо обычного.

Как создать подсвечиваемую активную зону

В функции vCheckInput() вы проверяете активацию подсвечиваемых зон, но как они перед этим были созданы? Очень просто. Вы создаете их в функции vSetupMouseZones(). Данная функция также слегка отличается от кода предыдущего примера. Вот ее измененный фрагмент:

MZones.iAddZone("MAINMENU_NEWGAME_H", 192, 64, 256, 64, 3);
MZones.iAddZone("MAINMENU_LOADGAME_H", 192, 128, 256, 64, 3);
MZones.iAddZone("MAINMENU_SAVEGAME_H", 192, 192, 256, 64, 3);
MZones.iAddZone("MAINMENU_OPTIONS_H", 192, 256, 256, 64, 3);

Эти вызовы функции iAddZone() выглядят также как и остальные за исключением параметра, определяющего тип щелчка. Я устанавливаю значение этого параметра равным 3. Это сообщает системе, что зона становится активной когда над ней расположен указатель мыши и ни одна из кнопок мыши не нажата. Вот как выполняется создание подсвечиваемых зон — достаточно только указать соответствующий тип щелчка. Кроме того, я добавил к именам зон символы _H в конце, чтобы их было легче идентифицировать в коде.

Как отобразить подсветку активной зоны

Итак, вы создали переменные состояния подсвечиваемых активных зон и сами зоны. Теперь пришло время отобразить эти зоны в функции vRender(). К счастью для вашей психики и рассудка по сравнению с предыдущим примером функция изменилась не слишком сильно. Вот самые главные изменения:

else if(g_iCurrentScreen == 1) {
   // Отображение главного меню
   vDrawInterfaceObject(0, 0, 256.0f, 256.0f, 0);
   vDrawInterfaceObject(256, 0, 256.0f, 256.0f, 1);
   vDrawInterfaceObject(512, 0, 256.0f, 256.0f, 2);
   vDrawInterfaceObject(0, 256, 256.0f, 256.0f, 3);
   vDrawInterfaceObject(256, 256, 256.0f, 256.0f, 4);
   vDrawInterfaceObject(512, 256, 256.0f, 256.0f, 5);
   // Отображение подсвеченных зон, если они есть
   // либо обычного меню без подсветки
   if(g_bMainMenu_NewGame_Highlight) {
      vDrawInterfaceObject(192, 64, 256.0f, 256.0f, 10);
   }
   else if(g_bMainMenu_LoadGame_Highlight) {
      vDrawInterfaceObject(192, 64, 256.0f, 256.0f, 11);
   }
   else if(g_bMainMenu_SaveGame_Highlight) {
      vDrawInterfaceObject(192, 64, 256.0f, 256.0f, 12);
   }
   else if(g_bMainMenu_Options_Highlight) {
      vDrawInterfaceObject(192, 64, 256.0f, 256.0f, 13);
   }
   else {
      // Меню без подсветки
      vDrawInterfaceObject(192, 64, 256.0f, 256.0f, 7);
   }
}

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

Рис. 6.29. Изображения меню для реализации подсветки

Рис. 6.29. Изображения меню для реализации подсветки

На рис. 6.29 представлены пять изображений меню. На первом представлено меню в котором не подсвечен ни один пункт. Следующие четыре изображения представляют меню с различными состояниями подсветки. Каждое состояние характеризуется собственным подсвеченным пунктом меню. Пусть вас не смущает термин «подсветка». При желании вы можете полностью изменить изображение пункта меню.

Я отображаю на экране первое изображение с рис. 6.29, если ни одна из подсвечиваемых зон не активна. Я отображаю второе изображение, если подсвечивается пункт меню New Game, третье изображение если подсвечивается пункт меню Load Game и четвертое изображение если подсвечивается пункт меню Save Game. Пятое изображение показывается когда должен быть подсвечен пункт меню Options.

Это совсем не трудно, не так ли?


Глава 6 - Итоги

Итоги

Можете представить себе это? Глава действительно закончилась! Краткий обзор поможет вспомнить вам некоторые вещи, относящиеся к разработке и программированию интерфейса.

  • Определите требования к интерфейсу прежде чем начниете программировать.
  • Детализируйте проект интерфейса, включив в него максимум информации.
  • Разрабатывая интерфейс, спрашивайте себя, как в нем выполняются три ключевых правила удобства и простоты использования.
  • Прямоугольная проекция в трехмерном пространстве позволяет вам отображать двухмерные изображения, используя оборудование, предназначенное для трехмерной графики.
  • Разбивайте необходимые для интерфейса большие изображения на несколько текстур, соответствующих заложенным в оборудовании для трехмерной графики ограничениям.
  • Активные зоны полезны для обнаружения поступающих от мыши данных.
  • Подсветка активных зон улучшает внешний вид меню.
  • В качестве домашнего задания сыграйте 100 000 раз в Warcraft III. (Шутка.)


Глава 7. Добавляем к игре звук

Краткий обзор

Качественное звуковое сопровождение абсолютно необходимо для полного погружения в игру. Многие согласятся, что качество звукового сопровождения игры очень сильно влияет на впечатление от игры в целом. Достаточно взглянуть на современные игровые консоли. Корпорация Microsoft оснастила свою первую игровую приставку Xbox настоящей поддержкой звукового сопровождения формата 5.1 Dolby Digital. Другие компании выпускают звуковые системы формата 6.1 для PC и целые домашние кинотеатры, которые можно использовать совместно с компьютером. Мы уже далеко ушли от компьютеров с единственным внутренним динамиком, издававшим простое бибиканье. Задача с которой сталкиваетесь вы, разработчик игр, заключается в необходимости включить в свою игру высококачественное звуковое сопровождение. Конечно, вы можете пропустить этот материал и использовать низкокачественный звук, но люди заметят это и не оценят.

Играли ли вы когда нибудь в интересную игру с ужасным звуковым оформлением? Такую, где выстрелы из автомата похожи на выхлопы, а взрывы на фейерверк на ветру? И уж конечно вы не хотите, чтобы люди ответили на эти вопросы «да», подразумевая при этом вашу игру. В этой главе я поговорю о следующих темах, которые помогут вам добавить звук в ваши игры:

  • Звуковые API
  • Архитектура DirectMusic
  • Как воспроизвести файл WAV
  • Как воспроизвести файл MIDI
  • Как воспроизвести файл MP3
  • Реализация класса


Глава 7 - Звуковые API

Звуковые API

Существует несколько способов воспроизведения звука в компьютерной игре, включая использование различных свободно распространяемых API, таких как OpenAL и DirectX. Кроме того, доступны различные коммерческие библиотеки, но их стоимость может вас неприятно удивить.

На данный момент я предпочитаю пользоваться DirectX API. Вы можете спросить, почему? Девятая версия DirectX предоставляет сотни различных возможностей. Она также поддерживается практически каждым производителем оборудования. А больше всего мне нравится, что за ее использование не надо платить.

OpenAL тоже распространяется свободно, но на данный момент не может похвалиться такой широкой поддержкой производителей аппаратуры. Это вызывает проблемы с драйверами. Если ваша игра поддерживает только OpenAL, пользователи не имеющие драйверов OpenAL для своих звуковых карт скорее всего возвратят вашу игру даже не попытавшись сыграть в нее. Пока OpenAL не стал таким же распространенным, как OpenGL, я рекомендую воздержаться от его использования. Не поймите меня неправильно. Если вы действительно хотите использовать OpenAL, просто сделайте так, чтобы ваша игра могла работать как с DirectX так и с OpenAL. Тогда пользователь сможет выбирать то, что он хочет, и все останутся в выигрыше.


Глава 7 - Архитектура DirectMusic

Архитектура DirectMusic

DirectX содержит два интерфейса, которые можно применять для воспроизведения звуковых файлов: DirectSound и DirectMusic. Главное различие между ними заключается в том, что DirectSound предоставляет низкоуровневый доступ к аппаратуре звуковой карты. Лично я предпочитаю использовать DirectMusic, поскольку он предоставляет более широкие возможности и достаточно быстро работает на современных процессорах. DirectMusic содержит следующие основные части:

  • Загрузчик
  • Исполнитель
  • Сегменты

Загрузчик DirectMusic

Загрузчик в DirectMusic одвечает за загрузку аудио-содержимого. С его помощью вы можете загрузить файлы MIDI, файлы WAV, коллекции DLS и файлы сегментов DirectMusic. Как только вы сообщите, какой именно аудио-ресурс вам требуется загрузить, загрузчик выполнит всю необходимую работу и начнет потоковое чтение ресурса. Вам остается только выполнить воспроизведение аудиоданных!

Загрузчик использует единственный интерфейс с именем IDirectMusicLoader8. Возможно, вы удивляетесь почему в имени интерфейса DirectX 9.0 не стоит цифра 9? Дело в том, что по сравнению с 8 версией в DirectMusic не было сделано никаких существенных изменений. Большинство изменений были сделаны для увеличения быстродействия кода.

Интерфейс загрузчика содержит несколько методов, перечисленных в таблице 7.1.

Таблица 7.1. Методы интерфейса IDirectMusicLoader8
Метод | Описание
CacheObject | Увеличивает счетчик ссылок объекта. Полезно использовать для предотвращения многократной загрузки объекта.
ClearCache | Очищает счетчик ссылок для объекта указанного типа.
CollectGarbage | Очищает неиспользуемые ссылки.
EnableCache | Включает автоматическое кэширование. Может также применяться для выключения автоматического кэширования.
EnumObject | Перечисляет объекты заданного типа.
GetObject | Возвращает объект.
LoadObjectFromFile | Загружает объект из файла. Это наиболее часто используемый метод, поскольку он отвечает за загрузку файлов WAV.
ReleaseObjectByUnknown | Освобождает ссылку на объект.
ReleaseObject | Освобождает ссылку на объект.
ScanDirectory | Выполняет поиск в каталоге файлов указанного типа. Кэширует результаты для перечисления.
SetObject | Позволяет установить атрибуты некорректного объекта.
SetSearchDirectory | Устанавливает путь к каталогу, в котором будет выполняться поиск аудиофайлов.

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

Исполнитель DirectMusic

Подобно тому, как музыкант исполняет произведение, интерфейс исполнителя в DirectMusic отвечает за исполнение музыки. Он управляет воспроизведением, работой с сообщениями, назначением каналов и контролем времени. Если говорить в двух словах, вы только создаете в вашей игре один из этих интерфейсов.

Вся работа исполнителя поддерживается единственным интерфейсом с именем IDirectMusicPerformance8. Он является рабочей лошадкой DirectMusic; поэтому в нем присутствуют десятки функций. Перечисление функций приведено в таблице 7.2.

Таблица 7.2. Методы интерфейса IDirectMusicPerformance8
Метод | Описание
AddNotificationType | Добавляет тип уведомления.
AddPort | Назначает исполнителю порт.
AdjustTime | Смещает время исполнителя вперед или назад.
AllocPMsg | Выделяет память для сообщения.
AssignPChannel | Назначает канал исполнителя.
AssignPChannelBlock | Назначает блок из 16 каналов.
ClonePMsg | Копирует сообщение исполнителя.
CloseDown | Закрывает объект исполнителя.
CreateAudioPath | Создает аудио-путь (audio path).
CreateStandardAudioPath | Создает аудио-путь со стандартными параметрами.
DownloadInstrument | Загружает инструмент DLS.
FreePMsg | Освобождает занятую сообщением память.
GetBumperLength | Возвращает время между помещением сообщения в буфер и началом его обработки.
GetDefaultAudioPath | Возвращает аудио-путь по умолчанию.
GetGlobalParam | Возвращает глобальные значения исполнителя.
GetGraph | Возвращает инструментальный граф (toolgraph).
GetLatencyTime | Возвращает время, необходимое исполнителю на обработку звука и вывод его на динамики.
GetNotificationPMsg | Возвращает сообщение уведомления.
GetParam | Возвращает параметры дорожки.
GetParamEx | Возвращает параметры дорожки. Поддерживает саморегулируемые сегменты.
GetPrepareTime | Возвращает латентность дорожки.
GetQueueTime | Возвращает время, когда сообщения могут быть вытеснены.
GetResolvedTime | Преобразует время в предел.
GetSegmentState | Возвращает состояние текущего сегмента.
GetTime | Возвращает время исполнителя.
InitAudio | Инициализирует исполнителя.
Invalidate | Вытесняет все сообщения.
IsPlaying | Проверяет, воспроизводится ли текущий сегмент.
MIDIToMusic | Преобразует значение ноты MIDI в значение DirectMusic.
MusicToMIDI | Преобразует значение DirectMusic в значение MIDI.
MusicToReferenceTime | Преобразует MUSIC_TIME в REFERENCE_TIME.
PChannelInfo | Возвращает информацию о канале.
PlaySegment | Воспроизводит сегмент.
PlaySegmentEx | Воспроизводит сегмент с дополнительными параметрами.
ReferenceToMusicTime | Преобразует REFERENCE_TIME в MUSIC_TIME.
RemoveNotificationType | Удаляет тип уведомления.
RemovePort | Удаляет порт.
RhythmToTime | Преобразует время ритма во время музыки.
SendPMsg | Отправляет сообщение.
SetBumperLength | Устанавливает интервал между помещением сообщения в буфер и его обработкой.
SetDefaultAudioPath | Устанавливает аудио-путь по умолчанию. Установленный путь становится активным.
SetGlobalParam | Устанавливает глобальные значения.
SetGraph | Устанавливает инструментальный граф.
SetNotificationHandle | Устанавливает обработчик события.
SetParam | Устанавливает данные дорожки.
SetPrepareTime | Устанавливает время между отправкой сообщения и воспроизведением звука.
Stop | Останавливает воспроизведение сегмента.
StopEx | Останавливает сегмент или аудио-путь.
TimeToRhythm | Преобразует время музыки во время ритма.

Ничего себе. Какой огромный список функций. Он не должен подавлять вас; я использую в своих программах лишь несколько из перечисленных функций. Представьте себе, вся эта функциональность доступна вам абсолютно бесплатно!

Сегменты DirectMusic

Сегменты в DirectMusic представляют собой реально воспроизводимые звуковые данные. Любой файл WAV или последовательность MIDI, которые вы воспроизводите должны быть сперва загружены в сегмент. В DirectMusic существует два типа сегментов: первичные и вторичные. Первичный сегмент является главной звуковой дорожкой. Вторичные сегменты обычно используются для спецэффектов.

Вся функциональность сегментов сосредоточена в интерфейсе IDirectMusicSegment8. Так как он может содержать данные объектов различных типов, в нем достаточно много функций. Все они перечислены в таблице 7.3.

Таблица 7.3. Методы интерфейса IDirectMusicSegment8
Метод | Описание
AddNotificationType | Добавляет тип события.
Clone | Копирует сегмент.
Compose | Составляет дорожку.
Download | Копирует данные в объект исполнителя.
GetAudioPathConfig | Возвращает конфигурацию аудио-пути.
GetDefaultResolution | Возвращает разрешение времени для сегмента.
GetGraph | Возвращает инструментальный граф.
GetLength | Возвращает длину сегмента.
GetLoopPoints | Возвращает точки начала и конца цикла.
GetParam | Возвращает параметры дорожки.
GetRepeats | Возвращает количество цикличских повторений сегмента.
GetStartPoint | Возвращает начальную точку.
GetTrack | Возвращает дорожку, соответствующую заданным условиям поиска.
GetTrackGroup | Возвращает группу битов дорожки.
InitPlay | Инициализирует состояние воспроизведения.
InsertTrack | Вставляет дорожку.
RemoveNotificationType | Удаляет тип события.
RemoveTrack | Удаляет дорожку.
SetDefaultResolution | Устанавливает разрешение по умолчанию.
SetGraph | Устанавливает инструментальный граф.
SetLength | Устанавливает длину.
SetLoopPoints | Устанавливает начальную и конечную точки цикла.
SetParam | Устанавливает параметры дорожки.
SetPChannelsUsed | Устанавливает используемый канал исполнителя.
SetRepeats | Устанавливает количество циклических повторов сегмента.
SetStartPoint | Устанавливает начальную точку.
SetTrackConfig | Конфигурирует дорожку.
Unload | Удаляет данные из объекта исполнителя.

Как видно из таблицы 7.3, интерфейс сегмента предоставляет много полезных функций. Функциональные возможности DirectMusic позволяют вам управлять практически каждым параметром сегмента. Вся красота в том, что вы можете использовать столько функциональных возможностей, сколько пожелаете. Перейдем к демонстрации!


Глава 7 - Как воспроизвести файл WAV

Как воспроизвести файл WAV

Я не идиот — по крайней мере, моя жена не называет так меня в открытую, — так что держу пари, что вы главным образом хотите узнать о том, как воспроизводить файлы WAV. Я не могу придумать лучшего способа обучения, чем замечательный мир исходных кодов. Так что загружайте проект DMusic_PlaySound из сопроводительных файлов к книге.

Программа DMusic_PlaySound демонстрирует как инициализировать интерфейсы исполнителя и загрузчика, загрузить сегмент и воспроизвести его.

Проект DMusic_PlaySound

Программа содержит несколько исходных файлов: main.cpp, main.h и DXUtil.cpp. Все они являются уникальными для данного проекта, за исключением файла DXUtil.cpp, который является частью набора вспомогательных файлов DirectX SDK.

Проект использует следующие библиотеки: dxguid.lib, winmm.lib и dsound.lib. Вы можете спросить, почему нет библиотеки с именем dmusic.lib. Я не знаю, что вам ответить. Microsoft решила поместить функции DirectMusic в библиотеку DirectSound. Я предполагаю, что это вызвано тем, что они большей частью совместно используют одну и ту же логику.

Что-то я давно не показывал вам новых иллюстраций. Взгляните на рис. 7.1, где показан основной поток выполнения примера программы.

Рис. 7.1. Поток выполнения программы DMusic_PlaySound

Рис. 7.1. Поток выполнения программы DMusic_PlaySound

На рис. 7.1 видно, что WinMain() вызывает функцию bInitializeSoundSystem(). Эта функция инициализации выполняет несколько вызовов функций DirectX для инициализации звуковой системы. Затем программа ожидает события мыши и воспроизводит файл WAV с помощью функции vPlaySound(). Если вы еще этого не сделали, запустите программу и щелкните по ее окну левой кнопкой мыши чтобы воспроизвести файл WAV. Разве вам не понравился этот замечательный тестовый файл WAV? Эй, я знаю, что он не производит особого впечатления. Если вы хотите поэкспериментировать, замените файл testsound.wav одним из ваших собственных звуковых файлов. Программа должна работать с любым WAV-файлом.

Заголовочный файл Main.h

Откройте заголовочный файл main.h и следуйте за мной. Заголовочный файл в этом примере очень простой и короткий. Вот он целиком:

#ifndef MAIN_H
#define MAIN_H
#define STRICT
#include <windows.h>
#include <stdio.h>
#include <D3DX9.h>
#include <dmusici.h>
#include <dsound.h>
#include <dshow.h>
#include <dxutil.h>

// Прототипы функций
LRESULT WINAPI fnMessageProcessor(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam);
void vCleanup(void);
bool bInitializeSoundSystem(HWND hWnd);
void vPlaySound(void);

// Глобальные звуковые данные
IDirectMusicLoader8       *g_pLoader;
IDirectMusicPerformance8  *g_pPerformance;
IDirectMusicSegment8      *g_pSound;
#endif

Список включаемых файлов не должен преподнести вам много сюрпризов. В основном это обычные включаемые файлы для Windows-программы. Единственный файл, добавляемый специально для DirectMusic — это dmusici.h.

В разделе прототипов функций появились две новые функции: bInitializeSoundSystem() и vPlaySound(). Назначение обоих очевидно из их названий. (Разве вы не любите самодокументируемый код?)

Следующий блок кода заголовочного файла содержит глобальные переменные, которые я использую в программе. Если вы прочитали предыдущий раздел этой главы, они должны выглядеть для вас очень знакомо. Интерфейс g_pLoader используется для загрузки звукового файла, интерфейс g_pPerformance предназначен для воспроизведения звука, а интерфейс g_pSound содержит загружаемые из звукового файла данные.

ПРИМЕЧАНИЕ
Я вовсе не поощряю вас использовать глобальные переменные в коде. Я применяю их в примерах только для того, чтобы сделать код как можно проще.

Файл программы Main.cpp

Главная часть кода расположена в файле программы main.cpp. Пришло время открыть его. Как обычно, нашего внимания требует функция WinMain(). Она содержит стандартный код инициализации приложения Windows и несколько новых вызовов функций. Вот фрагмент кода, который должен заинтересовать вас:

// Инициализация Direct Sound
bRet = bInitializeSoundSystem(hWnd);
if(bRet == 0) {
   MessageBox(hWnd, "Initialization Failure",
             "Failed to initialize Direct Sound",
             MB_ICONEXCLAMATION | MB_OK);
   // Сбой в программе, выход
   exit(1);
}

Я вызываю функцию bInitializeSoundSystem() сразу после того, как создано окно программы. Функция получает один параметр — дескриптор окна. Если функция возвращает 0, значит ее выполнение закончилось неудачно. В этом случае я вывожу на экран окно с сообщением об ошибке и завершаю работу программы после того, как пользователь щелкнет по кнопке OK. В противном случае все работает как предполагалось и выполнение кода продолжается.

Функция bInitializeSoundSystem()

Ох парни, сейчас начнется настоящее веселье. Данная функция содержит весь код, необходимый для инициализации DirectMusic. Кроме того, она выполняет загрузку звукового файла, воспроизводимого программой.

Как инициализировать COM

Первая вещь, которую должен сделать код, — инициализация COM. DirectX использует COM-интерфейсы, так что это неизбежное зло (или добро). Если вы не знакомы с COM, я рекомендую вам пойти в любимый книжный магазин и посмотреть несколько книг, посвященных этой технологии. Вызов для инициализации выглядит следующим образом:

CoInitialize(NULL);

Исключительно просто, правда? К счастью, это все, что требуется сделать для инициализации COM. После этого вы можете создавать интерфейсы DirectX.

Создание интерфейса загрузчика

Следующий фрагмент кода создает интерфейс загрузчика. Как вы, возможно, помните, интерфейс загрузчика отвечает за загрузку звуковых данных, таких как файлы WAV и файлы MIDI. Вот как выглядит соответствующий код:

if(FAILED(hResult = CoCreateInstance(CLSID_DirectMusicLoader, NULL,
   CLSCTX_INPROC, IID_IDirectMusicLoader8,
   (void**) &g_pLoader))) {
   return(0);
}

Для получения интерфейса загрузчика я вызываю функцию CoCreateInstance(). Интерфейс IDirectMusicLoader8 использует CLSID CLSID_DirectMusicLoader и идентификатор интерфейса IID_IDirectMusicLoader8. Указатель на интерфейс сохраняется в глобальной переменной g_pLoader.

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

Создание интерфейса исполнителя

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

if(FAILED(hResult = CoCreateInstance(CLSID_DirectMusicPerformance, NULL,
   CLSCTX_INPROC, IID_IDirectMusicPerformance8,
   (void**) &g_pPerformance))) {
   return(0);
}

И снова для создания интерфейса я применяю функцию CoCreateInstance(). Интерфейс IDirectPerformance8 использует CLSID CLSID_DirectMusicPerformance и идентификатор интерфейса IID_DirectMusicPerformance8. Указатель на интерфейс я сохраняю в глобальной переменной g_pPerformance.

Инициализация аудиосистемы

В отличие от загрузчика, исполнитель требует, чтобы после успешного создания интерфейса была выполнена его инициализация. Эта задача осуществляется функцией IDirectMusicPerformance8::InitAudio(), которая инициализирует исполнителя и устанавливает для него аудио-путь. Вот как выглядит прототип функции:

HRESULT InitAudio(
   IDirectMusic** ppDirectMusic,
   IDirectSound** ppDirectSound,
   HWND hWnd,
   DWORD dwDefaultPathType,
   DWORD dwPChannelCount,
   DWORD dwFlags,
   DMUS_AUDIOPARAMS *pParams
);

Первый параметр, ppDirectMusic, позволяет функции возвратить созданный интерфейс DirectMusic. Если значение этого параметра равно NULL, то интерфейс DirectMusic создается и используется внутри объекта исполнителя. Я предпочитаю использовать NULL, поскольку это упрощает код.

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

Третий параметр, hWnd, предназначен для передачи дескриптора того окна для которого создается интерфейс DirectSound. Если значение параметра равно NULL, используется фоновое окно. Я предпочитаю использовать здесь NULL.

Четвертый параметр, dwDefaultPathType, задает тип аудио-пути по умолчанию. Возможные значения перечислены в таблице 7.4.

Таблица 7.4. Типы аудио-пути
Значение | Описание
DMUS_APATH_DYNAMIC_3D | Трехмерный звук
DMUS_APATH_DYNAMIC_MONO | Монофонический звук
DMUS_APATH_DYNAMIC_STEREO | Стереофонический звук
DMUS_APATH_SHARED_STEREOPLUSREVERB | Стереофонический звук с эхом

Из доступных типов я обычно использую DMUS_APATH_DYNAMIC_STEREO поскольку он предоставляет возможности, необходимые для стереофонического звукового сопровождения.

Пятый параметр, dwPChannelCount, задает количество используемых в аудио пути каналов исполнителя. В рассматриваемом примере я использую четыре канала.

Шестой параметр, dwFlags, позволяет вам задать набор функциональных возможностей, которые вы хотите видеть в объекте исполнителя. Доступные флаги и их назначение описаны в таблице 7.5.

Таблица 7.5. Флаги функциональных возможностей исполнителя
Значение | Описание
DMUS_AUDIOF_3D | Трехмерные буферы
DMUS_AUDIOF_ALL | Все возможности
DMUS_AUDIOF_BUFFERS | Множественные буферы
DMUS_AUDIOF_DMOS | Дополнительные DMO (DirectX Media Object)
DMUS_AUDIOF_EAX | Эффекты EAX
DMUS_AUDIOF_ENVIRON | Моделирование среды
DMUS_AUDIOF_STREAMING | Изменяющиеся формы волн

В рассматриваемом примере я использую флаг DMUS_AUDIOF_ALL. Он указывает объекту исполнителя использовать любые доступные возможности. Это удобный вариант, позволяющий пользователю отключить отдельные возможности, которые могут привести к снижению быстродействия.

Седьмой параметр, pParams, позволяет задать желаемые аудио параметры в виде структуры данных DMUS_AUDIOPARAMS. Я обычно использую значения параметров по умолчанию и указываю здесь NULL.

Функция инициализации аудиосистемы выполнила свою работу. Последний шаг, необходимый для инициализации интерфейса исполнителя — вызов функции IDirectMusicPerformance8::GetDefaultAudioPath(), возвращающей аудио-путь по умолчанию, созданный функцией инициализации аудиосистемы. Аудио-путь требуется для установки уровня громкости. Если вы не желаете связываться с регулировкой громкости, можно пропустить этот этап.

Вот как выглядит код, описываемый в приведенном выше тексте:

// Инициализация аудиосистемы
if(FAILED(hResult = g_pPerformance->InitAudio(
   NULL,
   NULL,
   hWnd,
   DMUS_APATH_DYNAMIC_STEREO,
   4,
   DMUS_AUDIOF_ALL,
   NULL
   ))) {
   return(0);
}
// Получение пути по умолчанию
if(FAILED(g_pPerformance->GetDefaultAudioPath(&dmAudioPath)))
   return(0);
Как регулировать громкость

Как я намекал раньше, интерфейс IDirectMusicAudioPath8 позволяет вам регулировать уровень громкости. Для этого предназначена функция SetVolume(), прототип которой выглядит следующим образом:

HRESULT SetVolume(
   long lVolume,
   DWORD dwDuration
);

Первый параметр, lVolume, устанавливает желаемый уровень громкости в сотнях децибел. Допустимы значения от –9600 до 0. Значение 0 соответствует максимальной громкости.

Второй параметр, dwDuration, задает период времени за который осуществляется изменение громкости. Если его значение равно 0, система изменит громкость как только это будет возможно.

Вот как выглядит используемый в примере код:

// Установка громкости
if(FAILED(dmAudioPath->SetVolume(0,0)))
   return(0);
Загрузка звукового файла

Вы помните интерфейс загрузчика, который создали минуту назад? Пора снова воспользоваться им для загрузки тестового файла WAV, включенного в сопроводительные файлы. Чтобы сделать это, обратимся к функции LoadObjectFromFile(). Перед тем, как я покажу вам ее прототип, взгляните на код из программы:

// Загрузка звукового файла
if (FAILED(g_pLoader->LoadObjectFromFile(
   CLSID_DirectMusicSegment,
   IID_IDirectMusicSegment8,
   L"testsound.wav",
   (LPVOID*) &g_pSound
   )))
{
   return(0);
}

// Загрузка данных
if (FAILED (g_pSound->Download(g_pPerformance))) {
   return(0);
}

В коде я использую функцию загрузки файла, чтобы загрузить WAV-файл с жесткого диска. Затем я загружаю сегмент в объект исполнителя для воспроизведения. Вот что видно с высоты птичьего полета.

Прототип функции LoadObjectFromFile() выглядит следующим образом:

HRESULT LoadObjectFromFile(
   REFGUID rguidClassID,
   REFIID iidInterfaceID,
   WCHAR *pwzFilePath,
   void ** ppObject
);

В первом параметре, rguidClassID, должен быть передан уникальный идентификатор класса. Для загрузки в сегмент используйте идентификатор класса CLSID_DirectMusicSegment.

Второй параметр, iidInterfaceID, должен быть уникальным идентификатором интерфейса. Для загрузки в сегмент используйте IID_IDirectMusicSegment8.

Третий параметр, pwzFilePath, является именем загружаемого файла. В рассматриваемом примере я использую файл testsound.wav. Вы можете заметить букву L перед строкой с именем файла. Я поместил ее потому, что в данном параметре должно передаваться имя в кодировке Unicode (где для представления одного символа используются два байта).

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

Как загрузить сегмент

Теперь вы должны загрузить сегмент в объект исполнителя. Эту задачу выполняет функция IDirectMusicSegment8::Download(), прототип которой выглядит так:

HRESULT Download(
   IUnknown* pAudioPath
);

Как приятно и просто. Всего один параметр, который является указателем на интерфейс, в который загружается сегмент. Я в данном параметре передаю указатель на объект исполнителя g_pPerformance.

Взгляните на рис. 7.2, чтобы увидеть описанные к данному моменту этапы.

Рис. 7.2. Этапы инициализации DirectMusic

Рис. 7.2. Этапы инициализации DirectMusic

На рис. 7.2 видно, как вы инициализировали COM, создали загрузчик, создали исполнителя, инициализировали аудиосистему, получили аудио-путь по умолчанию, установили уровень громкости, загрузили файл и, наконец, загрузили сегмент WAV. Этот набор шагов будет повторяться каждый раз, когда вы будете использовать в своих программах DirectMusic, так что запомните его (или по крайней мере, заложите эту страницу).

Функция vPlaySound()

Пример программы обрабатывает события левой кнопки мыши и вызывает при каждом событии функцию vPlaySound(). Обработкой событий занимается функция fnMessageProcessor(), но представляющий интерес код выполняется в функции воспроизведения звука. Давайте взглянем на код этой функции:

void vPlaySound(void)
{
   // Воспроизведение звукового сегмента
   g_pPerformance->PlaySegmentEx(
      g_pSound,
      NULL,
      NULL,
      DMUS_SEGF_DEFAULT | DMUS_SEGF_SECONDARY,
      0,
      NULL,
      NULL,
      NULL
   );
}

Функция vPlaySound() очень проста. Фактически она состоит из единственного вызова функции IDirectMusicPerformance8::PlaySegmentEx().

Воспроизведение звука

Функция PlaySegmentEx() начинает воспроизведение загруженного сегмента. У нее есть несколько параметров, но как видите, в коде моего примера значения многих из них равны NULL. Вот как выглядит прототип функции:

HRESULT PlaySegmentEx(
   IUnknown* pSource,
   WCHAR *pwzSegmentName,
   IUnknown* pTransition,
   DWORD dwFlags,
   __int64 i64StartTime,
   IDirectMusicSegmentState** ppSegmentState,
   IUnknown* pFrom,
   IUnknown* pAudioPath
);

В первом параметре, pSource, передается указатель на интерфейс воспроизводимого объекта. В рассматриваемой программе я использую глобальный указатель на сегмент, загрузка которого была выполнена раньше.

Второй параметр, pwzSegmentName, в DirectX 9.0 не используется.

Третий параметр, pTransition, позволяет задать модуляцию для сегмента. Я передаю в этом параметре NULL.

Четвертый параметр, dwFlags, позволяет вам указать набор флагов, определяющих различные параметры воспроизведения. В рассматриваемом примере для этого параметра я использую флаги DMUS_SEGF_DEFAULT и DMUS_SEGF_SECONDARY. Эти флаги указывают, что сегмент воспроизводится в его границах по умолчанию и что сегмент воспроизводится как вторичный звук. Доступно еще много других флагов, и я рекомендую вам посмотреть их описание в документации DirectX SDK.

Пятый параметр, i64StartTime, задает начальное время для сегмента. Я передаю в этом параметре NULL чтобы воспроизведение звука началось немедленно.

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

Седьмой параметр, pFrom, позволяет указать интерфейс для остановки воспроизведения когда стартует новый сегмент. Здесь я также передаю NULL.

Восьмой параметр, pAudioPath, сообщает системе какой аудио-путь используется для воспроизведения сегмента. Я присваиваю этому параметру значение NULL чтобы для воспроизведения использовался аудио-путь по умолчанию.

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


Глава 7 - Как воспроизвести файл MIDI

Как воспроизвести файл MIDI

Вы когда-нибудь хотели воспроизводить в вашей игре MIDI-файлы? Если да, то вы попали по правильному адресу. К счастью, воспроизводить MIDI-файлы очень просто. Фактически, если вы читали предыдущий раздел, то уже знаете все, что необходимо! Абсолютно верно — воспроизведение MIDI-файлов осуществляется точно также, как воспроизведение WAV-файлов. Я пошел дальше и создал включенный в сопроводительные файлы проект с именем DMusic_PlayMIDI. Загрузите его, скомпилируйте и выполните. Вы увидите окно, изображенное на рис. 7.3.

Рис. 7.3. Окно программы DMusic_PlayMIDI

Рис. 7.3. Окно программы DMusic_PlayMIDI

Спорим, что программа выглядит знакомо? Я сделал лишь несколько косметических изменений в программе воспроизведения WAV-файлов. Например, я изменил имя воспроизводимого файла на c:\dxsdk\samples\media\canyon.mid. Если у вас DirectX SDK располагается в другой папке, вам необходимо изменить путь к файлу и заново откомпилировать программу. Вы также можете указать имя файла и путь для любого вашего MIDI-файла. (Я не включил никаких MIDI-файлов в сопроводительные файлы к книге потому что у меня нет лицензионного программного обеспечения для их создания. Может быть в следующий раз.)


Глава 7 - Как воспроизвести файл MP3

Как воспроизвести файл MP3

Хотите ли вы, чтобы в вашей игре было реализовано воспроизведение файлов MP3? Если да, этот раздел для вас. К сожалению, воспроизведение файлов MP3 требует полностью нового набора интерфейсов и функций. Разве не удивляет такой быстрый переход от радости к сожалениям? Такова жизнь, мой друг.

На тот случай, если последние несколько лет вы провели на необитаемом острове, сообщу, что MP3 — это формат для хранения звуковых данных с высокой степенью сжатия. Большинство людей использует его для песен, но он также подходит и для речи и звуковых эффектов. Единственный недостаток формата MP3 — большая, по сравнению с другими форматами, нагрузка на процессор при воспроизведении. Учитывая производительность современных процессоров, эта особенность не представляет большой проблемы, но ее все же следует иметь в виду.

В сопроводительные файлы к этой книге включен проект с названием DShow_PlayMP3. На рис. 7.4 показано окно, выводимое этой программой.

Рис. 7.4. Окно программы DShow_PlayMP3

Рис. 7.4. Окно программы DShow_PlayMP3

Согласен, в изображенном на рис. 7.4 окне нет ничего особенного. Но для этого есть причина: программа создана для воспроизведения файлов MP3, а не для показа вращающихся трехмерных кубов!

Теперь загрузите проект DShow_PlayMP3, чтобы иметь возможность идти дальше. Я рекомендую вам скомпилировать и запустить программу, чтобы услышать результат ее работы. Если вы ничего не услышали, проверьте строку, в которой указано имя файла c:\dxsdk\samples\media\track.mp3. Если в указанном каталоге у вас нет MP3-файла, скорректируйте путь, чтобы он указывал на любой существующий в вашей системе файл MP3. Несколько пригодных для воспроизведения файлов входят в DirectX SDK.

DirectShow

Первая вещь на которой следует остановиться — имя проекта. В отличие от первых двух проектов из этой главы, имя данного проекта начинается с префикса DShow. Я сделал это потому, что данная программа использует DirectShow а не DirectMusic. DirectShow представляет собой отдельный интерфейс DirectX предназначенный для работы с потоковой аудиовизуальной информацией в Windows. Он может применяться для воспроизведения различных форматов, в том числе AVI, MPEG, MP3 и даже WAV. Как видите, вы можете воспроизводить не только звук, но и видео, а также комбинировать оба этих способа. Это действительно замечательная возможность, открывающая дорогу к воспроизведению видеофрагментов в начале вашей игры и между уровнями.

Проект DShow_PlayMP3

Программа содержит несколько файлов с исходным кодом: main.cpp, main.h и DXUtil.cpp. Все исходные файлы, за исключением DXUtil.cpp, являются уникальными для данного проекта. Кроме того, в проекте используются следующие библиотеки: dxguid.lib, winmm.lib и Strmiids.lib. Файл Strmiids.lib необходим для работы с DirectShow.

Заголовочный файл Main.h

Заголовочный файл main.h содержит обычный набор объявлений глобальных переменных и директив включения файлов, необходимых для примера. Вот как выглядит код секции с директивами включения файлов:

#include <windows.h>
#include <stdio.h>
#include <D3DX9.h>
#include <dxutil.h>
#include <dshow.h>

Новым в этом блоке является включение файла dshow.h. Он необходим для вызова интерфейсов и функций DirectShow. Если вы планируете использовать функциональность DirectShow, убедитесь, что этот файл есть в списке включаемых.

В следующем блоке кода находятся прототипы функций. Вот как он выглядит:

LRESULT WINAPI fnMessageProcessor(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam);
void vCleanup(void);
bool bPlayTitleMusic(void);
void vStopTitleMusic(void);
void vCheckMusicStatus(void);

Функция fnMessageProcessor() — это обычный обработчик сообщений Windows. Здесь нет ничего нового — все та же старая чепуха.

Функция vCleanup() вызывается перед выходом из программы. Она выполняет освобождение интерфейсов.

Функция bPlayTitleMusic() вызывается один раз в начале программы. Она инициализирует DirectShow, загружает файл MP3 и начинает его воспроизведение.

Функция vStopTitleMusic() останавливает воспроизведение музыки перед завершением работы программы.

Функция vCheckMusicStatus() проверяет не завершилось ли воспроизведенеи музыкального сегмента. Если да, воспроизведение музыки повторяется с начала.

Далее в заголовочном файле расположены глобальные переменные. Я создаю несколько указателей на интерфейсы и одну логическую переменную, как показано ниже:

bool            g_bBackgroundMusicActive = 0;
IGraphBuilder   *g_pGraph;
IMediaControl   *g_pMediaControl;
IMediaEvent     *g_pEvent;
IMediaSeeking   *g_pSeeking;

Переменная g_bBackgroundMusicActive используется для отслеживания состояния музыки. Если музыка воспроизводится, ее значение равно 1. Если нет, значение равно 0.

Переменная g_pGraph является указателем на интерфейс IGraphBuilder. Эй, эй, что это за граф?

Граф фильтров

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

  • Чтение данных из файла.
  • Декодирование потоковых данных.
  • Захват видео.
  • Передача данных системной аппаратуре.

Например, граф фильтров может читать файл MP3 и вормировать звук для его вывода аудиооборудованием. Эти действия показаны на рис. 7.5.

Рис. 7.5. Граф фильтров MP3

Рис. 7.5. Граф фильтров MP3

Как видно на рис. 7.5, граф фильтров читает данные из файла MP3, декодирует их, а затем отправляет аудиоаппаратуре для воспроизведения. Рабочей лошадкой индустрии фильтров в DirectShow является интерфейс IGraphBuilder. В таблице 7.6 перечислены входящие в этот интерфейс функции.

Таблица 7.6. Методы интерфейса IGraphBuilder
Метод | Описание
Abort | Сообщает графу о необходимости прекратить текущую операцию.
AddSourceFilter | Добавляет фильтр источника.
Connect | Соединяет два контакта.
Render | Добавляет фильтр к выходному контакту.
RenderFile | Загружает файл для воспроизведения. Я использую этот метод в своем примере для загрузки MP3-файла.
SetLogFile | Устанавливает обработчик для файла журналирования выходной информации.
ShouldOperationContinue | Сообщает должна ли продолжаться операция. Это очень странная функция, которую вам никогда не придется вызывать.

Управление аудиовизуальным потоком

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

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

Таблица 7.7. Методы интерфейса IMediaControl
Метод | Описание
GetState | Возвращает состояние графа.
Pause | Приостанавливает воспроизводимый в данный момент аудиовизуальный поток.
Run | Запускает аудиовизуальный поток. Это аналог кнопки Play на пульте дистанционного управления видеомагнитофона.
Stop | Завершает воспроизведение аудиовизуального потока.
StopWhenReady | Более мягкая остановка.

События аудиовизуального потока

Следующим в заголовочном файле расположен указатель на интерфейс IMediaEvent. Этот тип интерфейса используется для коммуникации с графом фильтров. Он будет информировать вас о текущем состоянии воспроизводимого аудиовизуального потока. В рассматриваемом примере программы я использую данный интерфейс, чтобы получить сообщение о завершении воспроизведения музыки. Методы интерфейса перечислены в таблице 7.8.

Таблица 7.8. Методы интерфейса IMediaEvent
Метод | Описание
CancelDefaultHandling | Отменяет установленную по умолчанию обработку события фильтром.
FreeEventParams | Освобождает связанные с параметром ресурсы.
GetEvent | Возвращает следующее событие из очереди.
GetEventHandle | Возвращает дескриптор следующего сообщения в очереди.
RestoreDefaultHandling | Восстанавливает обработчик по умолчанию.
WaitForCompletion | Ожидает пока граф фильтров не завершит воспроизведение аудиовизуального потока. Я использую эту функцию в примере программы чтобы проверить завершено ли воспроизведение музыки.

Позиционирование аудиовизуального потока

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

Таблица 7.8. Методы интерфейса IMediaSeeking
Метод | Описание
CheckCapabilities | Проверяет, обладает ли поток указанными возможностями.
ConvertTimeFormat | Преобразует из одного формата в другой.
GetAvailable | Возвращает доступный диапазон значений времени для позиционирования.
GetCapabilities | Возвращает возможности аудиовизуального потока.
GetCurrentPosition | Возвращает текущую позицию в потоке.
GetDuration | Возвращает длину потока.
GetPositions | Возвращает текущую и конечную позиции.
GetPreroll | Возвращает размер аудиовизуального потока, расположенного перед начальной позицией.
GetRate | Возвращает темп воспроизведения.
GetStopPosition | Возвращает конечную позицию. Она сообщает вам, когда воспроизведение потока будет завершено.
GetTimeFormat | Возвращает используемый в данный момент формат времени.
IsFormatSupported | Проверяет поддерживается ли указанный формат времени.
IsUsingTimeFormat | Проверяет используется ли в данный момент указанный формат времени.
QueryPreferredFormat | Возвращает предпочтительный формат времени.
SetPositions | Устанавливает текущую и завершающую позиции.
SetRate | Устанавливает темп воспроизведения.
SetTimeFormat | Устанавливает формат времени.

В описываемой программе я использую функции SetRate() и SetPositions().

Файл программы Main.cpp

Основной код программы располагается в файле main.cpp. Загрузите его сейчас и следуйте дальше. Найдите в коде функцию WinMain() и обратите внимание на следующий фрагмент:

// Воспроизведение музыки
bRet = bPlayTitleMusic();
if(bRet == 0) {
   MessageBox(hWnd, "Initialization Failure",
      "Failed to initialize DirectShow",
      MB_ICONEXCLAMATION | MB_OK);
   // Сбой в программе, выход
   exit(1);
}

В этом блоке кода вызывается функция bPlayTitleMusic(), являющаяся локальной для моей программы. Она отвечает за инициализацию DirectShow и воспроизведение файла MP3 из каталога с звуковыми файлами DirectX SDK. Давайте перейдем к этой функции.

Функция bPlayTitleMusic()

Эта функция в рассматриваемой программе выполняет большую часть работы. Она инициализирует COM, создает интерфейсы, загружает музыку, устанавливает темп воспроизведения и начинает проигрывание музыки. Вот как выглядит ее код:

bool bPlayTitleMusic(void)
{
   HRESULT hr;

   // Инициализация COM
   CoInitialize(NULL);
   // Создание графа
   CoCreateInstance(CLSID_FilterGraph,
      NULL,
      CLSCTX_INPROC_SERVER,
      IID_IGraphBuilder,
      (void **)&g_pGraph);
   // Запрос интерфейсов объекта
   g_pGraph->QueryInterface(
      IID_IMediaControl, (void **)&g_pMediaControl);
   g_pGraph->QueryInterface(
      IID_IMediaEvent, (void **)&g_pEvent);
   g_pGraph->QueryInterface(
      IID_IMediaSeeking, (void **)&g_pSeeking);

   // Загрузка песни (вставьте имя своего файла)
   hr = g_pGraph->RenderFile(
      L"c:\\dxsdk\\samples\\media\\track3.mp3", NULL);
   if(hr != S_OK) {
      return(0);
   }
   // Установка темпа воспроизведения
   g_pSeeking->SetRate(1);
   // Воспроизведение музыки
   g_pMediaControl->Run();
   // Установка флага воспроизведения
   g_bBackgroundMusicActive = 1;
   return(1);
}
Инициализация DirectShow

Первая вещь, которую делает функция, — инициализация COM. Это необходимый этап, поскольку DirectShow использует COM-интерфейсы.

Следующий шаг к небесам DirectShow — создание объекта графа. Я выполняю это с помощью вызова функции CoCreateInstance(). Интерфейс IGraphBuilder использует CLSID CLSID_FilterGraph и идентификатор интерфейса IID_IGraphBuilder. Указатель на интерфейс сохраняется в глобальной переменной с именем g_pGraph.

Теперь, когда у вас есть интерфейс построителя графов, вы можете создать другие интерфейсы, отправив запрос построителю графов. Это делается с помощью функции IGraphBuilder::QueryInterface(). Мы создаем три интерфейса: IID_IMediaControl, IID_IMediaEvent и IID_IMediaSeeking. После создания указатель на каждый из интерфейсов сохраняется в глобальных переменных, о которых я упоминал ранее.

К данному моменту вы инициализировали COM, создали объект графа и необходимые для программы вспомогательные интерфейсы. Это основные действия инициализации, необходимые для воспроизведения файлов MP3. Осталось только загрузить файл с музыкой, установить темп воспроизведения и начать воспроизведение. Перед тем, как идти дальше, взгляните на рис. 7.6.

Рис. 7.6. Этапы инициализации DirectShow

Рис. 7.6. Этапы инициализации DirectShow

На рис. 7.6 показана взаимосвязь между интерфейсом управления аудиовизуальным потоком, интерфейсом событий аудиовизуального потока, интерфейсом позиционирования аудиовизуального потока и интерфейсом графа фильтров. Также видна связь между функциями воспроизведения и интерфейсом управления аудиовизуальным потоком и между функцией установки темпа и интерфейсом позиционирования аудиовизуального потока. Позднее вы увидите как в эту большую картину вписывается интерфейс событий аудиовизуального потока.

Загрузка музыкального файла

Функция IGraphBuilder::RenderFile() выполняет всю работу, необходимую для загрузки файла. Она получает два параметра: первый содержит строку с именем файла, а второй не используется. Как видно из кода в рассматриваемой программе я загружаю файл c:\dxsdk\samples\media\track3.mp3. Если у вас DirectX SDK установлен в другом каталоге, убедитесь что путь соответствующим образом скорректирован. Вы можете скорректировать имя файла, чтобы воспроизводить любую из имеющихся на вашем компьютере песен в формате MP3. Лично я указал песню Amish Paradise, которую исполняет Weird Al Yankovic. Поскольку у меня нет прав на распространение этой песни, пришлось указать файл, который, скорее всего, будет на диске вашего компьютера!

Если загрузка файла прошла успешно, функция возвращает значение S_OK. Если же при загрузке произошел сбой, вам может понадобиться консультация с документацией DirectX SDK для получения информации о возвращаемом коде ошибки.

Установка темпа воспроизведения

Давайте двигаться дальше. Следующий фрагмент кода задает темп песни. Темп музыки определяет насколько быстро (или медленно) она воспроизводится. Данный параметр может использоваться для того, чтобы голос актера звучал похоже на белку или на Дарта Вейдера. Все это делает функция IMediaSeeking::SetRate(). Она получает единственный параметр — темп воспроизведения. Если вы хотите, чтобы для воспроизведения песни использовался ее темп по умолчанию, укажите здесь значение 1. Чтобы услышать, как артист поет с удвоенной скоростью, укажите значение 2. Кроме того, вы можете использовать различные промежуточные значения. Например, я люблю воспроизводить музыку группы Metallica с темпом 1.25, чтобы получить чистый скоростной металл. Вы почти можете видеть, как руки Ларса дымятся от этого!

Воспроизведение музыки

После всей выполненной работы воспроизведение MP3 осуществляется исключительно просто. Достаточно лишь вызвать функцию IMediaControl::Run(). Если функция возвращает S_OK, вы знаете, что воспроизведение успешно началось.

Перехват фоновых событий

Теперь, когда все необходимые части проинициализированы и запущены, код присваивает флагу g_bBackgroundMusicActive значение 1. Благодаря этому цикл сообщений функции WinMain() узнает, что надо проверять состояние музыки. Вернитесь назад к функции WinMain() и взгляните на следующий фрагмент кода:

if(g_bBackgroundMusicActive) {
   vCheckMusicStatus();
}

Эй, я не говорил, что будет много кода! Так или иначе, главный цикл проверяет состояние музыки вызывая мою функцию vCheckMusicStatus().

Функция vCheckMusicStatus()

Функция vCheckMusicStatus() проверяет завершено ли фоновое воспроизведение музыки. Если да, музыка перематывается к началу и воспроизведение запускается по новой. Вот как выглядит код функции:

void vCheckMusicStatus(void)
{
   long      evCode;

   // Проверка кода события
   g_pEvent->WaitForCompletion(0, &evCode);
   // Если музыка закончилась, запустить ее заново
   if(evCode == EC_COMPLETE) {
      // Устанавливаем начальную позицию в 0
      LONGLONG lStartPos = 0;
      // Останавливаем музыку
      g_pMediaControl->Stop();
      // Устанавливаем позиции
      g_pSeeking->SetPositions(
         &lStartPos,
         AM_SEEKING_AbsolutePositioning,
         NULL, AM_SEEKING_NoPositioning);
      // Запускаем музыку
      g_pMediaControl->Run();
   }
}
Проверка кода события

Пришло время использовать старый верный интерфейс IMediaEvent. Этот интерфейс позволяет увидеть состояние музыки. Я вызываю функцию IMediaEvent::WaitForCompletion() чтобы получить самый верхний код события.

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

Если код события равен EC_COMPLETE, я знаю, что воспроизведение песни закончилось. Это явная подсказка, что пора перемотать песню к началу и снова запустить ее воспроизведение. Если возвращен код EC_ERRORABORT или EC_USERABORT, я знаю, что что-то пошло наперекосяк и воспроизведение музыки прекращено.

Остановка музыки

Следующий фрагмент кода подразумевая, что песня подошла к концу, останавливает музыку, перематывает ее к началу и заново начинает воспроизведение. Остановка музыки осуществляется вызовом функции IMediaControl::Stop(). Параметров у функции нет.

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

Перемотка музыки

Теперь, когда музыка остановлена, вам необходимо перемотать ее к началу. Конечно же никакой реальной перемотки не выполняется; вы просто снова устанавливаете указатель позиции на начало песни. Это выполняет функция IMediaSeeking::SetPositions(). Вот как выглядит ее прототип:

HRESULT SetPositions(
   LONGLONG *pCurrent,
   DWORD dwCurrentFlags,
   LONGLONG *pStop,
   DWORD dwStopFlags
);

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

Следующий параметр, dwCurrentFlags, является комбинацией битовых флагов, относящихся к устанавливаемой позиции. Существует два типа флагов: флаги позиционирования и модификаторы. Здесь я использую флаг AM_SEEKING_AbsolutePositioning, чтобы сообщить системе, что позиция 0 является абсолютной, а не относительной. Названия трех других флагов описывают их назначение: AM_SEEKING_NoPositioning, AM_SEEKING_RelativePositioning и AM_SEEKING_IncrementalPositioning.

Следующий параметр, pStop, является адресом переменной, содержащей позицию остановки музыки. Передавая в этом параметре значение NULL я сообщаю системе, что следует использовать время завершения по умолчанию, которое хранится в файле с песней.

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

Запуск музыки

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

Взгляните на рис. 7.7, чтобы увидеть все, что выполняет наша программа.

Рис. 7.7. Поток исполнения программы воспроизведения MP3

Рис. 7.7. Поток исполнения программы воспроизведения MP3

На рисунке видно как система инициализирует DirectShow, загружает песню и начинает ее воспроизведение, проверяет не закончилась ли песня, перематывает ее и запускает воспроизведение снова. На этом мы завершаем обсуждение воспроизведения файлов MP3.


Глава 7 - Реализация классов звуковой системы

Реализация классов звуковой системы

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

  • Инициализация DirectSound
  • Загрузка звуковых файлов
  • Воспроизведение звуковых файлов

Класс звуковой системы

Сперва я опишу структуру класса звуковой системы, которая показана на рис. 7.8.

Рис. 7.8. Структура класса звуковой системы

Рис. 7.8. Структура класса звуковой системы

Обратите внимание, что на рисунку показаны три главных метода класса и два главных члена данных. В классе также есть обычные конструктор и деструктор, но на рисунке они не показаны, поскольку присутствуют в любом классе.

Три метода называются hrInitSoundSystem(), hrLoadSound() и hrPlaySound(). Достаточно прямолинейно, правда? Функция инициализации вызывается один раз для каждого экземпляра игры. Поскольку у вас должен быть только один экземпляр, это означает, что вы один раз вызываете функцию и она делает всю необходимую работу. Функция загрузки звука должна вызываться один раз для каждого звукового файла. Нет никакой необходимости загружать один и тот же звук несколько раз, если только вы действительно не хотите этого по каким-то причинам. Функция воспроизведения звука может и, возможно, будет, вызываться несколько раз для одного и того же звука. Нет никаких ограничений того, сколько раз можно воспроизводить звук.

Два основных члена данных, m_pLoader (IDirectMusicLoader8) и m_pPerformance (IDirectMusicPerformance8), предоставляют классу необходимые интерфейсы объекта. Как вы, возможно, помните, загрузчик отвечает за загрузку звуковых файлов, а объект исполнителя осуществляет воспроизведение звука.

Определение класса звуковой системы

Итак, вы познакомились со структурой класса; теперь пришло время посмотреть на реальный код. А вот и он во всей славе:

class SoundSystem
{
   private:

   public:
      HWND                      m_hWnd;
      // Звуковая система
      IDirectMusicLoader8       *m_pLoader;
      IDirectMusicPerformance8  *m_pPerformance;

      // Функции
      SoundSystem();
      ~SoundSystem();
      HRESULT hrInitSoundSystem(void);
      HRESULT hrLoadSound(char *szname,GameSound *gs);
      HRESULT hrPlaySound(GameSound *gs);
};

Код определения класса достаточно короток, но не слишком, если его сравнивать с самим классом. Ваше внимание может привлечь одна вещь — тип данных GameSound. Что же это такое?

Определение класса звукового фрагмента

Я создал класс GameSound потому, что вам требуется только один объект исполнителя и один объект загрузчика, но несколько сегментов. Данный класс содержит реальные данные звукового сегмента для отдельного звукового фрагмента. Этот класс ни что иное, как простое хранилище звуковой информации. Вот как выглядит заголовок класса:

class GameSound
{
   public:
   IDirectMusicSegment8      *m_pSound;
   IDirectMusicPerformance8  *m_pPerformance;
   ~GameSound();
   GameSound();
};

Не беспокойтесь по поводу указателя m_pPerformance. Он всего лишь указывает на интерфейс исполнителя в классе звуковой системы. Реально используется только один член данных, m_pSound, в котором сразу после загрузки сохраняются звуковые данные.

Взаимосвязь двух классов показана на рис. 7.9.

Рис. 7.9. Взаимодействие класса звуковой системы и класса звукового фрагмента

Рис. 7.9. Взаимодействие класса звуковой системы и класса звукового фрагмента

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

Реализация класса звуковой системы

Обзор закончен, так как насчет нескольких фрагментов кода реализации класса, чтобы поддержать разговор? Ниже представлен код конструктора класса:

SoundSystem::SoundSystem()
{
   m_pLoader = NULL;
   m_pPerformance = NULL;
}

Как видите, в конструкторе я присваиваю значение NULL указателям на интерфейсы загрузчика и исполнителя. Это делается для того чтобы иметь возможность проверить существуют ли объекты загрузчика и исполнителя. Если значение указателя равно NULL, объект не готов. Если же значение отличается от NULL, я знаю, что могу использовать данный объект. Если хотите, можете назвать это санитарной проверкой.

Деструктор SoundSystem::~SoundSystem()

В следующем блоке кода представлен деструктор класса:

SoundSystem::~SoundSystem()
{
   SAFE_RELEASE(m_pLoader);
   SAFE_RELEASE(m_pPerformance);
}

Деструктор освобождает объекты загрузчика и исполнителя. Я использую вспомогательный макрос DirectX с именем SAFE_RELEASE. Он проверяет равно ли значение указателя NULL и, если нет, освобождает объект. Поэтому данная операция и называется безопасным освобождением. Раз в классе создаются объекты загрузчика и исполнителя, в деструкторе их следует освободить.

Функция SoundSystem::hrInitSoundSystem()

Первая действительно важная функция — это функция инициализации. Вот ее код:

HRESULT SoundSystem::hrInitSoundSystem(void)
{
   HRESULT                hResult;
   IDirectMusicAudioPath8 *path;

   // Инициализация COM
   CoInitialize(NULL);

   // Создание загрузчика
   if(FAILED(hResult = CoCreateInstance(
               CLSID_DirectMusicLoader, NULL,
               CLSCTX_INPROC, IID_IDirectMusicLoader8,
               (void**)&m_pLoader))) {
      return(SOUNDERROR_MUSICLOADER);
   }

   // Создание исполнителя
   if(FAILED(hResult = CoCreateInstance(
               CLSID_DirectMusicPerformance, NULL,
               CLSCTX_INPROC, IID_IDirectMusicPerformance8,
               (void**)&m_pPerformance))) {
      return(SOUNDERROR_MUSICPERFORMANCE);
   }

   // Инициализация аудиосистемы
   if(FAILED(hResult = m_pPerformance->InitAudio(
               NULL, NULL,
               m_hWnd, DMUS_APATH_DYNAMIC_STEREO,
               4, DMUS_AUDIOF_ALL, NULL ))) {
      return(SOUNDERROR_INITAUDIO);
   }

   // Получение пути по умолчанию
   if(FAILED(m_pPerformance->GetDefaultAudioPath(&path)))
      return(SOUNDERROR_PATH);

   // Установка уровня громкости
   if(FAILED(path->SetVolume(0, 0)))
      return(SOUNDERROR_VOLUME);

   return(S_OK);
}

При виде кода функции у вас в голове должен прозвонить звонок. Ранее в этой главе я уже описывал аналогичную функцию. Если говорить кратко, функция инициализирует COM, создает интерфейс загрузчика и интерфейс исполнителя, инициализирует аудиосистему, получает аудиопуть по умолчанию и устанавливает уровень громкости.

ПРИМЕЧАНИЕ
Вы не обязаны вызывать здесь функцию установки уровня громкости, но я предпочитаю при инициализации аудиосистемы задать и уровень громкости по умолчанию. Кроме того, вы можете удалить код инициализации COM, если планируете инициализировать COM где-то в другом месте вашей программы.
Функция SoundSystem::hrLoadSound()

Следующая функция в списке загружает звуковые данные. Вот как выглядит ее код:

HRESULT SoundSystem::hrLoadSound(char *szname,GameSound *gs)
{
   WCHAR szWideFileName[512];

   // Проверяем инициализирована ли аудиосистема
   if(!m_pLoader)
      return(SOUNDERROR_MUSICLOADER);

   if(!m_pPerformance)
      return(SOUNDERROR_MUSICPERFORMANCE);

   // Очищаем звуковые данные, если они существуют
   if(gs->m_pSound) {
      gs->m_pSound->Unload(m_pPerformance);
      gs->m_pSound->Release();
      gs->m_pSound = NULL;
   }

   // Копируем имя файла
   DXUtil_ConvertGenericStringToWideCch(
      szWideFileName, szname, 512);

   // Загружаем звуковые данные из файла
   if (FAILED(m_pLoader->LoadObjectFromFile (
      CLSID_DirectMusicSegment,
      IID_IDirectMusicSegment8,
      szWideFileName,
      (LPVOID*) &gs->m_pSound
      )))
   {
      return(SOUNDERROR_LOAD);
   }

   // Устанавливаем указатель на исполнителя в объекте звукового фрагмента
   gs->m_pPerformance = m_pPerformance;

   // Загружаем данные
   if (FAILED (gs->m_pSound->Download(m_pPerformance))) {
      return(SOUNDERROR_DOWNLOAD);
   }

   return(S_OK);
}

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

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

Санитарные проверки завершены и пора заняться чем-нибудь более существенным. Далее в коде вызывается вспомогательная функция DirectX с именем DXUtil_ConvertGenericStringToWideCch(). Поскольку выполняющие загрузку звуковых данных функции DirectX требуют, чтобы имя файлов было представлено строкой 16-разрядных символов, необходимо преобразовать переданную в параметре строку с именем файла в строку 16-разрядных символов. Вызов этой функции прямолинеен, так что я полагаю, что здесь код говорит сам за себя.

После того, как имя файла преобразовано, можно спокойно переходить к функции загрузки. Чтобы загрузить реальные звуковые данные я вызываю функцию LoadObjectFromFile(). Как помните, ранее в этой главе я говорил, что данная функция загружает звуковые данные из файла и сохраняет их в объекте звукового сегмента. В данном случае я сохраняю данные сегмента в сегменте на который указывает член данных объекта звукового фрагмента m_pSound.

Следующая строка кода устанавливает внутренний указатель исполнителя в объекте звукового фрагмента, чтобы он указывал на интерфейс исполнителя класса звуковой системы. Это необходимо потому, что объекту звукового фрагмента требуется указатель на исполнителя для загрузки и выгрузки данных.

И, наконец, я загружаю данные в только что установленный объект исполнителя. Теперь звуковые данные готовы для воспроизведения.

Функция SoundSystem::hrPlaySound()

Раз уж мы заговорили о воспроизведении, пришла пора показать вам код функции воспроизведения звука.

HRESULT SoundSystem::hrPlaySound(GameSound *gs)
{
   // Проверяем наличие объекта исполнителя
   if(!m_pPerformance)
      return(SOUNDERROR_MUSICPERFORMANCE);

   // Проверяем наличие звукового сегмента
   if(!gs->m_pSound)
      return(SOUNDERROR_NOSEGMENT);

   // Воспроизводим звуковой сегмент
   if(FAILED (m_pPerformance->PlaySegmentEx(
          gs->m_pSound,
          NULL, NULL,
          DMUS_SEGF_DEFAULT | DMUS_SEGF_SECONDARY,
          0, NULL, NULL, NULL       )))
      return(SOUNDERROR_PLAYFAIL);

   return(S_OK);
}

И снова я выполняю санитарные проверки, чтобы убедиться, что используемые интерфейсы доступны. Если они не существуют, вызывающей функции возвращается код ошибки.

Затем я вызываю функцию PlaySegmentEx(), которая выполняет всю черновую работу по воспроизведению звуковых данных. Поскольку функция требует, чтобы ей указали воспроизводимый звуковой сегмент, я передаю ей указатель на сегмент из объекта звукового фрагмента.

Реализация класса звукового фрагмента

Класс звукового фрагмента является очень простым и содержит только конструктор и деструктор. Вот как выглядит реализация данного класса:

// Конструктор
GameSound::GameSound()
{
   m_pSound        = NULL;
   m_pPerformance  = NULL;
}
// Деструктор
GameSound::~GameSound()
{
   if(m_pSound) {
      if(m_pPerformance)
         m_pSound->Unload(m_pPerformance);
   }
   SAFE_RELEASE(m_pSound);
}

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

Взгляните на рис. 7.10, чтобы увидеть схему описанного к данному моменту кода.

Рис. 7.10. Взаимодействие объектов классов звуковой системы и звукового фрагмента

Рис. 7.10. Взаимодействие объектов классов звуковой системы и звукового фрагмента

На рис. 7.10 показано взаимодействие объектов и взаимоотношения между классом звуковой системы и классом звукового фрагмента. В верхней части рисунка показано как интерфейсы зхагрузчика и исполнителя используются в функции инициализации. В центре рисунка видно как интерфейс загрузчика применяется в функции загрузки звука. В функцию загрузки передается объект звукового фрагмента и там вызывается его метод загрузки данных. В нижней части рисунка показано как объект звукового фрагмента передается в функцию возпроизведения, чтобы началось воспроизведение звука. Чтобы воспроизвести реальный звук объект исполнителя из класса звуковой системы использует данные сегмента из объекта звукового фрагмента. У-ух. Понятно?

Пример использования класса звуковой системы

Как насчет примера программы, которая использует рассмотренный только что класс? Загрузите с компакт-диска проект с именем DSound_SoundSystem и следуйте за мной дальше.

Рассматриваемый пример состоит из пяти основных файлов: main.cpp, main.h, SoundSystem.cpp, SoundSystem.h и dxutil.cpp. Файлы main.cpp и main.h содержат основной код программы, а в файлах SoundSystem.cpp и SoundSystem.h находится код класса звуковой системы. Файл dxutil.cpp содержит код полезных вспомогательных функций DirectX.

Для компиляции приложения потребуются несколько библиотек: dxguid.lib, comctl32.lib, winmm.lib и dsound.lib. Список должен выглядеть знакомо, поскольку те же самые библиотеки использовались в первом примере программы из этой главы.

На рис. 7.11 показано окно, выводимое данной программой.

Рис. 7.11. Окно программы DSound_SoundSystem

Рис. 7.11. Окно программы DSound_SoundSystem

Полагаю, что окно на рис. 7.11 нельзя сравнивать с экранами из игры Warcraft II, но тем не менее это настоящая программа! Фактически в программе нет никаких изображений для разглядывания. Но тем не менее, тут есть что послушать! Запустите программу и нажмите на левую кнопку мыши, а затем на правую. Вы услышите воспроизведение двух разных звуков. Более того, вы можете нажимать на кнопки мыши снова и снова и звук будет воспроизодиться несколько раз.

Инициализация класса звуковой системы

Не будем тратить бумагу и рубить больше деревьев, чем требуется, так что без лишних проволочек перейдем к коду функции WinMain() в файле main.cpp. Там вы увидите вызов функции с именем bInitializeSoundSystem(), которая написана специально для данного примера. Вот ее код:

bool bInitializeSoundSystem(void)
{
   HRESULT   hr;

   // Выделение памяти для звуковых фрагментов
   g_sndButton      = new GameSound;
   g_sndButtonOver  = new GameSound;

   // Инициализация звуковой системы
   g_SoundSys.hrInitSoundSystem();

   // Загрузка звуковых фрагментов
   hr = g_SoundSys.hrLoadSound("button.wav", g_sndButton);
   if(hr == SOUNDERROR_LOAD) {
      return(0);
   }

   hr = g_SoundSys.hrLoadSound(
      "button_over.wav",
      g_sndButtonOver);
   if(hr == SOUNDERROR_LOAD) {
      return(0);
   }

   // Успешное завершение
   return(1);
}

В программе выполняется загрузка двух звуковых файлов: button_over.wav и button.wav. Поскольку у нас два звуковых файла, нам необходимо два объекта GameSound. Они присутствуют в виде переменных g_sndButton и g_sndButtonOver. Каждая из них является объектом GameSound и объявлена в заголовочном файле main.h. Первым действием является выделение памяти для двух объектов звуковых фрагментов. Я делаю это с помощью оператора new.

Объекты звуковых фрагментов не слишком полезны без звуковой системы, поэтому я объявляю в заголовочном файле main.h указатель на объект звуковой системы с именем g_SoundSys. Чтобы этим объектом можно было воспользоваться, его следует инициализировать, для чего я вызываю метод hrInitSoundSystem(). Данный вызов инициализирует DirectSound и подготавливает систему к воспроизведению звука.

Затем я дважды вызываю метод класса звуковой системы hrLoadSound(). В параметрах этих вызовов я передаю два объекта звуковых фрагментов, чтобы они были заполнены данными из указанных файлов WAV. Вы можете спокойно заменить загружаемые здесь WAV-файлы на свои собственные.

Как только работа функций загрузки звуковых данных успешно завершена, программе возвращается значение 1, свидетельствующее об успешном завершении инициализации. Если в ходе инициализации произошел сбой, возвращается 0, что говорит о наличии ошибки.

Итак, мы разобрали этапы успешной инициализации звуковой системы.

Воспроизведение звуковых фрагментов

Вернемся к циклу обработки сообщений Windows. В нем находится код, ожидающий пока не будет нажата левая или правая кнопки мыши. Как только произойдет одно из указанных событий, будет воспроизведен соответствующий звук. Вот как выглядит код:

switch(msg)
{
   case WM_LBUTTONDOWN:
      // Воспроизведение звука
      g_SoundSys.hrPlaySound(g_sndButtonOver);
      break;
   case WM_RBUTTONDOWN:
      // Воспроизведение другого звука
      g_SoundSys.hrPlaySound(g_sndButton);
      break;
   case WM_DESTROY:
          PostQuitMessage(0);
          return 0;
   default:
      break;
}
return DefWindowProc(hWnd, msg, wParam, lParam);

В блоке логики оператора switch видно, как система реагирует на события кнопок мыши вызывая функцию класса звуковой системы hrPlaySound(). Если нажата левая кнопка мыши, воспроизводится звук из объекта g_sndButtonOver. Если нажата правая кнопка мыши, воспроизводится звук из объекта g_sndButton.

ПРИМЕЧАНИЕ
Сообщения WM_LBUTTONDOWN и WM_RBUTTONDOWN являются частью системы событий Microsoft Windows. Существуют сотни различных событий и для получения более подробной информации о них я рекомендую обратиться к документации Visual C++.

На рис. 7.12 показан ход выполнения программы с момента запуска до завершения.

Рис. 7.12. Работа программы, использующей класс звуковой системы

Рис. 7.12. Работа программы, использующей класс звуковой системы

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

Использование класса звуковой системы в меню

Вы помните программу с меню игры Battle Armor, которую я описывал в 6 главе? Пришло время добавить к ней звуки и музыку. Откройте находящийся на компакт-диске проект с именем D3D_MenuSounds и следуйте за мной. Вместо того, чтобы вываливать на вас новые курганы кода, я просто проведу обзор внесенных изменений с высоты птичьего полета.

В файл Main.h я добавил директиву включения заголовочного файла класса звуковой системы. Кроме того, я создал глобальный объект класса звуковой системы и несколько объектов звуковых фрагментов. Все это показано на рис. 7.13.

Рис. 7.13. Структура реализации звуковой системы в заголовочном файле проекта D3D_MenuSounds

Рис. 7.13. Структура реализации звуковой системы в заголовочном файле проекта D3D_MenuSounds

В файл Main.cpp были добавлены вызовы функций для инициализации звуковой системы, загрузки звуковых файлов и их воспроизведения. Данные изменения иллюстрирует рис. 7.14.

Рис. 7.14. Структура реализации звуковой системы в главном файле проекта D3D_MenuSoundsSound

Рис. 7.14. Структура реализации звуковой системы в главном файле проекта D3D_MenuSoundsSound

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

Чтобы добавить воспроизведение файлов MP3 я просто скопировал в программу работы с меню функции bPlayTitleMusic(), vStopTitleMusic() и vCheckMusicStatus(). Эти действия и добавление вызова, начинающего воспроизведение музыки в код инициализации и составляют весь секрет трюка.

Если вы еще не сделали это, запустите программу D3D_MenuSounds и пощелкайте по разным кнопкам меню. Музыка MP3 воспроизводится в фоновом режиме, а при щелчке по некоторым кнопкам меню воспроизводится WAV-файл. Обратите внимание, что звук в WAV-файле достаточно тихий и вам, возможно, придется прислушаться, чтобы расслышать его на фоне музыки. Я советую вам попробовать поместить в программу свои собственные музыку и звуки (ха, вы можете добавить даже несколько звуков, чтобы закрепить полученные навыки).

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


Глава 7 - Итоги

Итоги

Вспомните следующие моменты, относящиеся к звуковому сопровождению:

  • DirectSound предоставляет низкоуровневый доступ к звуковой аппаратуре.
  • DirectMusic предоставляет больше возможностей, чем DirectSound.
  • Интерфейс DirectMusicLoader используется для загрузки звуковых файлов.
  • Интерфейс DirectMusicPerformance используется для воспроизведения звуковых файлов.
  • Интерфейс DirectMusicSegment используется для хранения загруженных звуковых данных.
  • DirectShow может использоваться для воспроизведения файлов MP3 и потокового видео.
  • Интерфейс графа DirectShow используется для загрузки аудио и видео потоков.
  • Интерфейс управления аудиовизуальным потоком DirectShow используется для воспроизведения аудио и видео потоков.
  • Интерфейс позиционирования аудиовизуального потока DirectShow используется для перемещения по аудио и видео потокам.
  • Интерфейс событий аудиовизуального потока DirectShow используется для получения уведомлений о событиях аудио и видео потоков.
  • Metallica самая талантливая из всех металлических групп. Точка.


Глава 8. Да будут войска!

Краткий обзор

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

В этой главе я рассмотрю следующие темы:

  • Проектирование подразделений.
  • Программирование шаблона.
  • Отображение подразделений.
  • Загрузка и создание подразделений.


Глава 8 - Определение подразделений

Проектирование подразделений

Если вам придется выбрать общие признаки для описания подразделений, какими они будут? Я думаю, что приведенный ниже список может служить хорошей отправной точкой:

  • Название.
  • Способ передвижения.
  • Скорость передвижения.
  • Тип атаки.
  • Тип защиты.

Название подразделения

Очевидно, сперва следует выбрать название подразделения. Если ваша игра относится ко Второй Мировой войне, вы можете использовать названия «Танк Т-34» или «Танк "Тигр"». Возможно, сюжет вашей игры разворачивается в будущем и подразделения будут называться «Плазменный танк» или «Лазерный танк». Выбор названий может показаться легкой задачей, но я советую отнестись к нему ответственно. Если вы не создаете реалистичную игру с использованием названий реально существующих боевых единиц, я рекомендую выбирать такие названия, которые будут объяснять, что данное подразделение делает. Если вы назовете ваш фантастический танк «Змей», это почти ничего не скажет игроку и не будет служить подсказкой во время игры. Если вы чувствуете, что такие имена необходимы, пользуйтесь ими в качестве префиксов (например, «Змей: Тяжелый лазерный танк»).

Хорошим примером из реального мира является лучник из игры Warcraft III: Reign of Chaos. Лучник — это эльфийский стрелок, выпускающий во врагов стрелы из своего лука. Название интуитивно понятно и соответствует подразделению. Изображение данного подразделения приведено на рис. 8.1.

Рис. 8.1. Лучник из игры Warcraft III: Reign of Chaos

Рис. 8.1. Лучник из игры Warcraft III: Reign of Chaos

Способ передвижения

Как перемещается подразделение? Летает ли оно, плавает, перемещается в космосе или может использовать все эти три способа? Это очень важный вопрос ответ на который влияет на стратегию использования данного подразделения в ходе игры. Взгляните на список наиболее распространенных способов передвижения:

  • По земле
  • По воздуху
  • По воде
  • В космосе

Передвижение по земле

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

Если вернуться к теме Warcraft, хорошим примером наземного подразделения является катапульта. Катапульта изготавливается орками и хорошо подходит для разрушения вражеских построек. Изображение катапульты приведено на рис. 8.2.

Рис. 8.2. Катапульта в игре Warcraft III: Reign of Chaos

Рис. 8.2. Катапульта в игре Warcraft III: Reign of Chaos

Передвижение по воздуху

Если вы играете в стратегическую игру на стороне Соединеных Штатов, скорее всего у вас будут мощные военно-воздушные силы. Передвижение по воздуху применимо ко всему, что может летать. Будь это вертолет, биплан или дракон — все они воздушные подразделения. У таких подразделений есть ряд уникальных параметров. Подумайте, например, о максимальной высоте полета. У вас может быть несколько подразделений неспособных достичь определенных высот. Это сделает игру более сложной и может привести к более увлекательному игровому процессу.

Одним из моих любимых воздушных подразделений в игре Warcraft III является химера. Это двухголовые летучие бестии, которых разводят ночные эльфы. Больше всего мне нравится способность химеры быстро разрушать вражеские постройки. Ее изображение приведено на рис. 8.3.

Рис. 8.3. Химера в игре Warcraft III: Reign of Chaos

Рис. 8.3. Химера в игре Warcraft III: Reign of Chaos

Передвижение по воде

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

В игре Warcraft III нет морских подразделений, так что данный тип перемещения не будет проиллюстрирован примером.

Полеты в космосе

Космические подразделения могут перемещаться во внешнем пространстве. Я не видел игры в которой бы космические войска использовались вместе с подразделениями других перечисленных типов, но не говорю, что она не может существовать. Фактически, я думаю было бы очень интересно объединить подразделения всех четырех типов в одной игре.

Warcraft III это игра в жанре фэнтези, так что в ней нет никаких космических подразделений.

Итак, как можно реализовать способы передвижения в коде? Проще всего воспользоваться перечислением. Приведенный ниже фрагмент кода демонстрирует данный подход:

enum UNIT_ATTR_MOVETYPE { MOVETYPE_LAND,
                          MOVETYPE_SEA,
                          MOVETYPE_AIR,
                          MOVETYPE_SPACE };

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

Если вы хотите использовать более детализированный набор способов передвижения, используйте код, похожий на приведенный ниже:

enum UNIT_ATTR_MOVETYPE_ADV { MOVETYPE_LAND_WHEELED,
                              MOVETYPE_LAND_TRACKED,
                              MOVETYPE_LAND_HOVER,
                              MOVETYPE_LAND_FOOT,
                              MOVETYPE_SEA_SURFACE,
                              MOVETYPE_SEA_SUBMERGED,
                              MOVETYPE_AIR_LOW,
                              MOVETYPE_AIR_HIGH,
                              MOVETYPE_SPACE_INNER,
                              MOVETYPE_SPACE_OUTER };

Скорость передвижения

Скорость передвижения подразделений очень важна для выбора игроком стратегии использования войск в игре. Обычно подразделения, оборудованные тяжелыми вооружениями, передвигаются медленнее, чем легковооруженные. Это имеет смысл, поскольку платой за более мощное вооружение является увеличение веса. Хотя бывают и исключения. Возьмите для примера 105-мм пушку и винтовку M-16. Пушка весит значительно больше, чем винтовка, но танк, оборудованный 105-мм пушкой передвигается быстрее, чем пехотинец с винтовкой M-16.

Поскольку в наши дни для визуализации подразделений все чаще используется трехмерная графика, для указания скорости перемещения лучше всего использовать единицы используемой трехмерной системы координат. Для примера установим, что размер блока игрового поля равен единице. В этом случае медленно движущиеся подразделения будут перемещаться за такт игры на 1/10 размера блока, а быстро перемещающиеся подразделения могут за то же время преодолевать 8/10 блока. Эта концепция проиллюстрирована на рис. 8.4.

Рис. 8.4. Скороость передвижения

Рис. 8.4. Скороость передвижения

На рис. 8.4 изображены танк и космический корабль. Игровое поле представлено четырьмя блоками, длина каждого из которых равна единице. Космический корабль за каждый такт игры перемещается на 0,8 длины блока, а танк за то же время перемещается лишь на 0,25 длины блока. По мере увеличения количества прошедших тактов, космический корабль будет все больше обгонять танк. На рисунке показано, что космический корабль переместился на четыре блока игрового поля за то время, которое потребовалось танку, чтобы преодолеть два блока. При использовании данного метода определения скорости перемещения очень легко вычислить в каком месте будет находиться подразделение в любой указанный момент времени. Кроме того, вам дополнительно облегчит жизнь использование блоков одинакового размера.

СОВЕТ
Не следует использовать в вашей игре блоки странных размеров. Блоками с размерами 1,0 или 10,0 единиц гораздо легче управлять, чем блоками с размером 3,5 единиц. Это становится особенно ясно при вычислении скорости передвижения и пройденного пути.

Тип атаки

Вы должны не только учесть объем наносимого боевой единицей ущерба, но также и подумать о том, каким именно способом этот ущерб будет наноситься. Будет ли подразделение выпускать во врага ракеты, или оно будет стрелять пулями, воспользуется лазером, или каким-либо другим оружием?

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

    Название подразделения | Легкий танк
Наносимый ущерб | 100
Скорострельность | 5
Косвенный ущерб от оружия | 0
Изображение атаки | laser.bmp
Мощность брони | 100
Скорость передвижения | 50

В данном примере легкий танк может причинить вражескому подразделению до 100 единиц ущерба, стреляет пять раз в минуту, для изображения оружия используется файл laser.bmp, оборудован броней мощностью 100 единиц и за один ход может переместиться на 50 единиц. Что, если теперь вы захотите создать средний танк, оборудованный тем же вооружением, но имеющий броню мощьностью 150 единиц и способный передвинуться за один ход только на 40 единиц? Конечно, вы можете заново задать все значения параметров атаки. В результате ряд параметров будет дублироваться и вы получите настоящую головную боль, если решите внести глобальные изменения в параметры вооружений.

Гораздо лучше сгруппировать параметры нападения в типы атаки. Взгляните на следующий пример:

    Тип атаки | лазер
Наносимый ущерб | 100
Скорострельность | 5
Косвенный ущерб от оружия | 0
Изображение атаки | laser.bmp
Название подразделения | легкий танк
Тип атаки | лазер
Мощность брони | 100
Скорость передвижения | 50
Название подразделения | средний танк
Тип атаки | лазер
Мощность брони | 150
Скорость передвижения | 40

В данном примере я создаю тип атаки с названием «лазер». Затем я создаю два подразделения, которые используют один и тот же тип атаки, но отличаются параметрами защиты и скоростью передвижения. Эта ситуация показана на рис. 8.5.

Рис. 8.5. Два типа подразделений используют один тип атаки

Рис. 8.5. Два типа подразделений используют один тип атаки

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

Тип защиты

Каждому преступлению соответствует наказание. Так что для каждого типа атаки должен быть тип защиты, верно? Ладно, не всегда, но это утверждение можно принять в качестве отправной точки. Так или иначе, типы защиты работают точно так же как и типы атаки. Возьмем для примера рассматривавшиеся выше легкий и средний танки. Вместо того, чтобы в параметрах каждого из них задавать параметры брони, можно создать два типа защиты. Чтобы увидеть, как это действует, взгляните на рис. 8.6.

Рис. 8.6. Два подразделения с различными типами защиты

Рис. 8.6. Два подразделения с различными типами защиты

На рис. 8.6 видно, что привычные персонажи совместно используют один и тот же тип атаки. Отличие в том, что они используют два различных типа защиты. Вы можете подумать, что это приведет к лишенму расходу памяти, но давайте посмотрим что произойдет при добавлении третьего типа подразделений. Как насчет бронетранспортера? Он будет использовать ту же самую легкую броню, что и легкий танк. Он не будет вооружен лазером, и для него надо будет создать новый тип атаки, но по крайней мере, новое подразделение предоставит пример многократного использования одного типа защиты.

СОВЕТ
Вы можете использовать данный прием и в других областях, например, создавая различные способы передвижения. Для танков может использоваться способ передвижения с названием «Гусеницы». Для грузовиков способ передвижения может быть назван «Колеса». Различные способы передвижения определяют значения таких атрибутов, как максимальная скорость, время ускорения, радиус поворота и т.д.


Глава 8 - Программирование шаблона

Программирование шаблона

Приготовьтесь к беспощадной драке, поскольку пришло время спуститься с небес на землю и заняться шаблонами подразделений. Данный раздел книги более сложен, чем остальные, так что будьте внимательны, чтобы извлечь максимум пользы из предоставленной информации. Не прерывайтесь, чтобы поиграть в Combat Mission!

Прежде чем погрузиться в глубины кода, взгляните на рис. 8.7, где изображен результат работы программы, о которой я собираюсь рассказать.

Рис. 8.7. Окно программы D3DFrame_UnitTemplate

Рис. 8.7. Окно программы D3DFrame_UnitTemplate

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

Загрузите с компакт-диска проект с именем D3DFrame_UnitTemplate и следуйте за мной дальше. Чтобы создать полнофункциональный шаблон подразделения, вам потребуются следующие классы:

  • Класс типа защиты
  • Класс типа атаки
  • Класс типа передвижения
  • Класс анимации подразделения
  • Класс текстур подразделения
  • Класс подразделения
  • Класс диспетчера подразделений

Класс CUnitDefense

Помните, как типы защиты помогают структурировать данные подразделений? Теперь вы добрались до практического примера, показывающего как реализовать эту концепцию в виде класса. Откройте заголовочный файл UnitTemplateClasses.h, входящий в проект D3DFrame_UnitTemplate. В начале этого файла вы увидите следующий код:

class CUnitDefense
{
   public:
      int          m_iType;
      unsigned int m_iMissileArmorRating;
      unsigned int m_iBulletArmorRating;
      unsigned int m_iLaserArmorRating;
      unsigned int m_iMeleeArmorRating;
      unsigned int m_iHitPoints;
      unsigned int m_iRegenRate;
      char         m_szName[64];

   public:
      CUnitDefense();
      ~CUnitDefense();
      virtual void vReset(void);
};

Члены данных класса CUnitDefense

Класс очень простой и содержит только открытые члены данных, конструктор, деструктор и единственный метод. Главным образом я использую классы как структуры данных, так что не ожидайте наличия сотен методов. Так же помните, что данный пример значительно упрощен, чтобы сделать его более легким для понимания. В реальном приложении вы можете сделать члены данных закрытыми и добавить методы для доступа к ним. Так или иначе, продолжаем разговор. Взгляните на рис 8.8, где показана структура переменных класса способа защиты.

Рис. 8.8. Структура переменных класса CUnitDefense

Рис. 8.8. Структура переменных класса CUnitDefense

Коэффициенты защиты

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

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

Каждому коэффициенту защиты я присваиваю целочисленное значение. При этом я выбрал допустимый диапазон значений от 0 до 1000. Если коэффициент защиты равен 0, подразделение совершенно беззащитно перед данным типом атаки. Значение 1000 означает, что для данного способа атаки подразделение практически неуязвимо.

Очки повреждений

Далее на рис. 8.8 изображена переменная m_iHitPoints. Я использую ее для хранения общего количества очков повреждений, которое данное подразделение может получить во время битвы. Когда количество очков повреждений становится равным нулю, подразделение погибает. Для очков повреждений, как и для коэффициентов защиты, я использую целочисленные значения из диапазона от 0 до 1000.

Скорость восстановления

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

Ключевым моментом является настройка соответствия между диапазоном значений очков повреждений и скоростью восстановления. Поскольку количество очков повреждений у подразделения будет увеличиваться один раз за раунд игры на значение, равное скорости восстановления, последнюю величину надо выбрать сравнительно небольшой. Я рекомендую принять диапазон допустимых значений от 0 до 100. Если скорость восстановления равна 100, подразделение восстановит свои параметры от предсмертного состояния до полного здоровья за десять раундов. Если же значение равно 1, то и через 100 раундов подразделение будет в двух шагах от гибели.

Тип защиты

В переменной m_iType хранится число, определяющее тип защиты. Например, ноль может соответствовать броне легкого танка, а единица — броне среднего танка. Диапазон значений зависит от того, как много различных типов защиты будет в вашей игре. Общее их количество редко превышает несколько десятков, но никогда не знаешь точно. Чтобы увидеть пример двух подразделений, использующих два различных типа защиты, вернитесь назад к рис. 8.6.

Название защиты

Переменная m_szName хранит название защиты в виде строки символов. Я использую ее чтобы было проще узнать тип защиты подразделения без необходимости запоминать соответствующие числовые значения. Это поле добавлено лишь для удобства.

Методы класса CUnitDefense

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

// Конструктор
CUnitDefense::CUnitDefense()
{
   // Установка внутренних переменных
   vReset();
}
// Деструктор
CUnitDefense::~CUnitDefense()
{
}
// Сброс внутренних переменных
void CUnitDefense::vReset(void)
{
   m_iType                 = 0;
   m_iMissileArmorRating   = 0;
   m_iBulletArmorRating    = 0;
   m_iLaserArmorRating     = 0;
   m_iMeleeArmorRating     = 0;
   m_iHitPoints            = 0;
   m_iRegenRate            = 0;
   strcpy(m_szName, "N/A");
}

В приведенном выше коде видно, что для установки начальных значений внутренних переменных класса конструктор вызывает функцию vReset(). Деструктор не делает ничего полезного, а просто занимает место. Однажды он может понадобиться для каких-либо целей, но не сегодня.

Что может быть проще? Если же вам нравится сложный код, просто немного потерпите.

Класс CUnitOffense

Подобно классу типов защиты, класс типов атаки помогает вам организовать данные о подразделениях. Я пользуюсь классом с именем CUnitOffense, который выполняет за меня всю необходимую работу. Посмотрите на заголовок этого класса:

class CUnitOffense
{
   public:
      int          m_iType;
      unsigned int m_iMissileDamageRating;
      unsigned int m_iBulletDamageRating;
      unsigned int m_iLaserDamageRating;
      unsigned int m_iMeleeDamageRating;
      unsigned int m_iSplashRadius;
      unsigned int m_iRateOfFire;
      float        m_fProjectileSpeed;
      unsigned int m_iRange;
      char         m_szName[64];

   public:
      CUnitOffense();
      ~CUnitOffense();
      virtual void vReset(void);
};

Члены данных класса CUnitOffense

Члены данных класса выглядят очень знакомо, за исключением того, что эти значения относятся к атаке, а не к обороне. Члены данных класса показаны на рис. 8.9.

Рис. 8.9. Структура переменных класса CUnitOffense

Рис. 8.9. Структура переменных класса CUnitOffense

Коэффициенты поражения

На рис 8.9 показаны четыре коэффициента поражения: для ракет, для пуль, для лазера и для рукопашной схватки. Точно также как и в классе обороны, эти значения относятся к тем типам атаки, которые упоминаются в их названии. Например, коэффициент поражения от пуль показывает, сколько повреждений наносит выпущенная из оружия пуля. Он может применяться для автоматической винтовки M-16, или для любого другого оружия, которое стреляет пулями. Я предпочитаю использовать для данного коэффициента тот же диапазон значений, что и для коэффициеттов защиты (в данном примере — от 0 до 1000). Это значительно упрощает вычисления, так как в этом случае для того, чтобы определеить полученные подразделением повреждения достаточно сравнить коэффициент защиты и коэффициент поражения. Взгляните на следующий пример:

  1. У бронежилета коэффициент защиты от пуль равен 50.
  2. У автоматической винтовки M-16 коэффициент поражения пулями равен 60.
  3. 60 - 50 = 10 единиц проникает сквозь защиту.

Из этого примера видно, что бронежилет поглощает 50 единиц наносимого пулей ущерба, а пуля, выпущенная из M-16 наносит 60 единиц повреждений. В результате 10 единиц повреждений проходят сквозь защиту и портят здоровье тому, на ком одет бронежилет. В результате у данного подразделения вычитается 10 очков повреждений, после чего оно, будем надеяться, остается в живых. Вот другой пример:

  1. У бронежилета коэффициент защиты от пуль равен 50.
  2. У 105-мм гаубицы коэффициент поражения пулями равен 650.
  3. 650 - 50 = 600 единиц проникает сквозь защиту.

Здесь видно, что против 105-мм гаубицы у бронежилета нет практически ни одного шанса. Подразделение получает 600 единиц повреждений и, скорее всего, будет уничтожено. И еще один, последний, пример:

  1. У бронежилета коэффициент защиты от пуль равен 50.
  2. У кольта 45 калибра коэффициент поражения пулями равен 30.
  3. 30 - 50 = -20 повреждений нет.

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

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

Радиус взрыва

Переменная m_iSplashRadius сообщает, какое количество повреждений может нанести разрыв снаряда, выпущенного из данного типа оружия. Это полезно для таких типов вооружения, как гранаты, катапульты и т.п. Радиус указывает количество блоков игрового поля, на которые распространяется действие взрыва. Взгляните на рис. 8.10.

Рис. 8.10. Радиус взрыва

Рис. 8.10. Радиус взрыва

На рис. 8.10 изображены три танка. Нижний танк стреляет из своего главного орудия по одному из двух верхних вражеских танков. Радиус взрыва танкового снаряда равен 2, а значит его сфера повреждений распространяется от точки взрыва на два блока игрового поля в каждом из направлений. Поскольку радиус взрыва достаточно велик, второй единице вражеской техники также наносятся повреждения. На иллюстрации в области взрыва есть темная область, где количество наносимых повреждений максимально, и более светлые области, где количество наносимых повреждений уменьшается. Это полезно, если вы хотите сделать модель взрыва более реалистичной, чтобы количество наносимых повреждений уменьшалось с удалением от центра взрыва.

Скорострельность

Переменная m_iRateOfFire сообщает вам, сколько раундов игры должно пройти, прежде чем оружие сможет снова выстрелить. Быстродействующее оружие, такое как автомат, может стрелять залпами в каждом раунде игры. Более медленное оружие, например, катапульты, будут стрелять один раз в пять раундов, или что-то подобное. Конечно, автомат может выпускать сразу несколько пуль, и именно поэтому я использовал термин «стрелять залпами».

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

Скорость снаряда

Переменная m_fProjectileSpeed задает с какой скоростью снаряд покидает ствол оружия. Это значение применимо только для снарядов и ракет, поскольку в рукопашной схватке снаряды отсутствуют, а лазерный луч распространяется со скоростью света.

Скорость снаряда указывает сколько блоков игрового поля может преодолеть снаряд за раунд игры. По этой причине диапазон допустимых значений будет от 0.0 до 0.99. Если вы не хотите, чтобы снаряд пересекал несколько блоков игрового поля за один раунд, максимальным значением должно быть именно 0.99.

Дальнобойность

Переменная m_iRange сообщает вам количество блоков игрового поля, на которое может выстрелить данный тип оружия. Это применимо только к вооружению, действующему на расстоянии, поскольку для ручного холодного оружия дальнобойность равна нулю.

Тип атаки

Переменная m_iType хранит число, соответствующее данному типу атаки. Это работает точно так же, как и для типов защиты.

Название типа атаки

Переменная m_szName хранит название типа атаки в виде последовательности символов. Это поле действует аналогично полю с названием типа защиты.

Методы класса CUnitOffense

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

// Конструктор
CUnitOffense::CUnitOffense()
{
   // Установка внутренних переменных
   vReset();
}
// Деструктор
CUnitOffense::~CUnitOffense()
{
}
// Сброс внутренних переменных
void CUnitOffense::vReset(void)
{
   m_iType                = 0;
   m_iMissileDamageRating = 0;
   m_iBulletDamageRating  = 0;
   m_iLaserDamageRating   = 0;
   m_iMeleeDamageRating   = 0;
   m_iSplashRadius        = 0;
   m_iRateOfFire          = 0;
   m_fProjectileSpeed     = 0.0f;
   m_iRange               = 0;
   strcpy(m_szName, "N/A");
}

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

Класс CUnitMovement

Класс способов передвижения также помогает организовать ваши боевые единицы. Для выполнения этой работы я использую класс CUnitMovement. Вот как выглядит его заголовок:

class CUnitMovement
{
   public:
      int          m_iType;
      float        m_fMovementSpeed;
      unsigned int m_iMovementType;
      float        m_fAcceleration;
      float        m_fDeacceleration;
      float        m_fTurnSpeed;
      char         m_szName[64];

   public:
      CUnitMovement();
      ~CUnitMovement();
      virtual void vReset(void);
};

Члены данных класса CUnitMovement

Класс содержит переменные, аналогичные тем, которые находятся в классе атаки, за исключением того, что их значения относятся к перемещению, а не атаке. Члены данных класса показаны на рис. 8.11.

Рис. 8.11. Переменные класса CUnitMovement

Рис. 8.11. Переменные класса CUnitMovement

Скорость передвижения

На рис. 8.11 присутствуют несколько переменных, контроллирующих передвижение боевых единиц. Первая из них, скорость передвижения, указывает на сколько блоков игрового поля может переместиться данное подразделение за один раунд игры. Я здесь использую значение с плавающей запятой, поскольку, вероятно, вы не захотите, чтобы за каждый раунд подразделение перемещалось на целое число блоков.

Способ передвижения

Данное поле сообщает вам какой именно способ использует подразделение для своего передвижения. Летает оно, плавает или ползает? Может быть оно ходит? Может быть оно катится? Переменная, задающая способ перемещения отвечает на этот вопрос.

Ускорение и торможение

Чтобы добавить сложности, я включил параметры, задающие ускорение и торможение. Ускорение определяет на сколько возрастает скорость подразделения за раунд игры, когда оно разгоняется. Торможение сообщает вам на сколько уменьшается подразделения за раунд игры, когда оно тормозит. Этот параметр позволяет увеличить реализм сражений. Одни подразделения могут и должны быть медленнее (или быстрее), чем другие. Рассмотрим следующий пример:

    Легкая конница: Ускорение = 0.3, Торможение = 0.5, Скорость = 0.5

    Катапульта: Ускорение = 0.1, Торможение = 0.2, Скорость = 0.3

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

Скорость поворота

Последний уникальный элемент данных класса передвижения сообщает вам насколько быстро подразделение поворачивает. Это число с плавающей точкой, указывающее на сколько градусов может развернуться подразделение за один раунд. Подразделению, скорость поворота которого равна 10.0 потребуется 36 раундов, чтобы сделать полный круг. Если скорость поворота равна 30, подразделению на полный круг потребуется лишь двенадцать раундов. Преимушества более быстрого разворота показаны на рис. 8.12.

Рис. 8.12. Два подразделения с разной скоростью поворота

Рис. 8.12. Два подразделения с разной скоростью поворота

На рис. 8.12 скорость поворота левого танка равна 45. Скорость поворота правого подразделения равна 22.5. За два раунда левый танк повернется вправо. И у него останется еще два раунда, прежде чем правый танк сможет повернуться к нему. Если эти два танка сражаются, левый танк сможет несколько раз выстрелить, прежде чем правый развернет свою пушку в его направлении! Вот почему скорость поворота так важна в сражениях.

Некоторые игры не беспокоятся о скорости поворота. Они просто считают, что подразделение может сразу передвигаться в заданном направлении не тратя времени на поворот. Это помогает сохранить быстрый темп игры, но отнимает у нее значительную долю реализма.

Методы класса CUnitMovement

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

// Конструктор
CUnitMovement::CUnitMovement()
{
   // Установка внутренних значений
   vReset();
}
// Деструктор
CUnitMovement::~CUnitMovement()
{
}
// Установка внутренних переменных
void CUnitMovement::vReset(void)
{
   m_iType             = 0;
   m_fMovementSpeed    = 0.0f;
   m_iMovementType     = 0;
   m_fAcceleration     = 0.0f;
   m_fDeacceleration   = 0.0f;
   m_fTurnSpeed        = 0.0f;
   strcpy(m_szName, "N/A");
}

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

Класс CUnitAnimation

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

const int UNITMANAGER_MAXOWNERS = 4;
class CUnitAnimation
{
   public:
      char  m_szName[64];
      char  m_szBitmapPrefix[64];
      int   m_iNumStillFrames;
      int   m_iNumMoveFrames;
      int   m_iNumAttackFrames;
      int   m_iNumDieFrames;
      int   m_iType;
      int   m_iStartStillFrames;
      int   m_iStartMoveFrames;
      int   m_iStartAttackFrames;
      int   m_iStartDieFrames;
      // Данные текстуры
      CTexture  *m_Textures;
      int   m_iTotalTextures;
      // Указатель на устройство Direct3D для загрузки текстур
      LPDIRECT3DDEVICE9   m_pd3dDevice;

      CUnitAnimation();
      ~CUnitAnimation();
      virtual void vReset(void);
      virtual void vSetRenderDevice(LPDIRECT3DDEVICE9 pd3d);
      virtual void vLoadTextures(void);
};

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

Изображения подразделения хранятся в массиве объектов класса CTexture. Класс CTexture — это отдельный класс, который я создал в данном приложении для хранения графической инфоримации. Мы обсудим его в этой главе чуть позже.

Члены данных класса CUnitAnimation

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

Анимация ожидания

Переменная m_iNumStillFrames сообщает сколько кадров используется в анимационной последовательности, изображающей боевую единицу в состоянии ожидания. Множество подразделений в состоянии ожидания ничего не делают, но поведение некоторых боевых единиц может быть очень сложным. Например, у радиолокационной станции скорее всего будет вращаться антена, что потребует нескольких кадров анимации. Танк, с другой стороны, в состоянии ожидания не выполняет никаких видимых действий. Это показано на рис. 8.13.

Рис. 8.13. Кадры анимации ожидания для танка

Рис. 8.13. Кадры анимации ожидания для танка

Обратите внимание, что для танка, находящегося в состоянии ожидания, достаточно одного кадра. Это вызвано тем, что в состоянии ожидания танк ничего не делает!

Анимация передвижения

Следующая переменная, m_iNumMoveFrames, сообщает сколько кадров в анимационной последовательности, показываемой при передвижении боевой единицы. Пример показан на рис. 8.14.

Рис. 8.14. Кадры анимации передвижения танка

Рис. 8.14. Кадры анимации передвижения танка

Как видите, при передвижении танка используются три кадра анимации. Положение колес на каждом из кадров слегка отличается. В результате, показываемые один за другим, эти кадры создают иллюзию движения.

Анимация атаки

Переменная, m_iNumAttackFrames, указывает, сколько кадров присутствует в анимационной последовательности, показываемой когда подразделение кого-нибудь атакует. Этот момент иллюстрирует рис. 8.15.

Рис. 8.15. Кадры анимации танковой атаки

Рис. 8.15. Кадры анимации танковой атаки

На рис. 8.15 видно, что для анимации атакующего танка используется два кадра. На первом кадре изображен обычный танк, а на втором кадре к его изображению добавляется вспышка выстрела. Красота этой системы в том, что при желании вы можете ее детализировать или упрощать произвольным образом. Для этого вам достаточно создать собственные кадры анимации и указать их количество.

Анимация гибели

Переменная m_iNumDieFrames сообщает вам сколько кадров содержится в анимационной последовательности, показываемой при гибели боевой единицы. Пример показан на рис. 8.16.

Рис. 8.16. Кадры гибели танка

Рис. 8.16. Кадры гибели танка

Обратите внимание, что на рис. 8.16 для анимации гибели танка используются три кадра. В первом кадре изображен обычный танк, во втором кадре нарисован красивый взрыв, а в третьем кадре мы видим искореженный и обгоревший танк. Эта анимационная последовательность будет воспроизводиться всякий раз, когда гибнет подразделение, так что убедитесь, что она выглядит впечатляюще!

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

Начальный кадр анимации

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

Рис. 8.17. Полная анимационная последовательность для танка

Рис. 8.17. Полная анимационная последовательность для танка

На рис. 8.17 показаны сразу все кадры анимации танка. Первый кадр — это изображение ожидающего танка. Следующие три кадра содержат анимационную последовательность для движения. Следующие два кадра содержат анимационную последовательность атаки. Последние три кадра содержат анимационную последовательность гибели. Вместо того, чтобы хранить кадры анимации в различных массивах, класс анимации сохраняет их все в одном непрерывном массиве. Это означает, что все кадры будут расположены один за другим. В результате, анимация ожидания начинается с нулевого кадра, а анимационная последовательность перемещения — нет. Стартовый кадр каждой последовательности зависит от того, сколько кадров находится перед ним. Рассмотрим для примера анимационную последовательность атаки. Она начинается с четвертого кадра в цепочке, поскольку перед ней расположены кадр для состояния ожидания и анимационная последовательность передвижения. Помните, что номер первого кадра в цепочке — 0, а не 1. Взглянув еще раз на рисунок, вы заметите, что под каждым кадром приведен связанный с ним порядковый номер. В данном примере анимационная последовательность ожидания начинается с кадра 0, анимационная последовательность передвижения — с кадра 1, анимационная последовательность атаки — с кадра 4 и анимационная последовательность гибели — с кадра 6. Если вы добавите кадры в середину набора, номера начальных кадров расположенных правее анимационных последовательностей должны быть увеличены.

Данные текстуры

Указатель m_Textures применяется для хранения кадров анимации подразделения. Он указывает на массив объектов CTexture и замечательно справляется с задачей хранения информации.

Переменная m_iTotalTextures сообщает вам, сколько всего кадров анимации требуется для данного подразделения. Она, помимо всего прочего, полезна для контроля за расходованием памяти.

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

Методы класса CUnitAnimation

В классе анимации есть уже ставшие привычными конструктор, деструктор и функция установки начальных значений, но к ним добавились две новые функции: vSetRenderDevice() и vLoadTextures().

Функция CUnitAnimation::vSetRenderDevice()

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

Вот как выглядит код этой функции:

void CUnitAnimation::vSetRenderDevice(LPDIRECT3DDEVICE9 pd3d)
{
   m_pd3dDevice = pd3d;
}
Функция CUnitAnimation::vLoadTextures()

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

void CUnitAnimation::vLoadTextures(void)
{
   // Загрузка анимаций
   int    i, j;
   int    iLocalCount = 0;
   char   szBitmapFileName[128];

   // Выделение памяти для текстур
   m_Textures = new CTexture[
      (m_iNumStillFrames * (UNITMANAGER_MAXOWNERS + 1)) +
      (m_iNumMoveFrames * (UNITMANAGER_MAXOWNERS + 1)) +
      (m_iNumAttackFrames * (UNITMANAGER_MAXOWNERS + 1))+
      (m_iNumDieFrames * (UNITMANAGER_MAXOWNERS + 1))];

      // Графика для ожидания (покоя)
      m_iStartStillFrames = 0;
      for(i = 0; i < m_iNumStillFrames; i++) {
         for(j = 0; j < UNITMANAGER_MAXOWNERS+1; j++) {
            sprintf(szBitmapFileName, "UnitData\\%s%d_%d.tga",
                     m_szBitmapPrefix, iLocalCount, j);

            // Задаем устройство визуализации
            m_Textures[m_iTotalTextures].vSetRenderDevice(m_pd3dDevice);
            // Загружаем текстуру
            m_Textures[m_iTotalTextures].vLoad(szBitmapFileName);
            // Увеличиваем общее количество текстур
            m_iTotalTextures++;
         }
         iLocalCount++;
      }

      // Графика для перемещения
      m_iStartMoveFrames = m_iTotalTextures;
      for(i = 0; i < m_iNumMoveFrames; i++) {
         for(j = 0; j < UNITMANAGER_MAXOWNERS+1; j++) {
            sprintf(szBitmapFileName, "UnitData\\%s%d_%d.tga",
                     m_szBitmapPrefix, iLocalCount, j);

            // Задаем устройство визуализации
            m_Textures[m_iTotalTextures].vSetRenderDevice(m_pd3dDevice);
            // Загружаем текстуру
            m_Textures[m_iTotalTextures].vLoad(szBitmapFileName);
            // Увеличиваем общее количество текстур
            m_iTotalTextures++;
         }
         iLocalCount++;
      }

      // Графика для атаки
      m_iStartAttackFrames = m_iTotalTextures;
      for(i = 0; i < m_iNumAttackFrames; i++) {
         for(j = 0; j < UNITMANAGER_MAXOWNERS+1; j++) {
            sprintf(szBitmapFileName, "UnitData\\%s%d_%d.tga",
                     m_szBitmapPrefix, iLocalCount, j);

            // Задаем устройство визуализации
            m_Textures[m_iTotalTextures].vSetRenderDevice(m_pd3dDevice);
            // Загружаем текстуру
            m_Textures[m_iTotalTextures].vLoad(szBitmapFileName);
            // Увеличиваем общее количество текстур
            m_iTotalTextures++;
         }
         iLocalCount++;
      }

      // Графика для гибели
      m_iStartDieFrames = m_iTotalTextures;
      for(i = 0; i < m_iNumDieFrames; i++) {
      for(j = 0; j < UNITMANAGER_MAXOWNERS+1; j++) {
         sprintf(szBitmapFileName, "UnitData\\%s%d_%d.tga",
                  m_szBitmapPrefix, iLocalCount, j);

         // Задаем устройство визуализации
         m_Textures[m_iTotalTextures].vSetRenderDevice(m_pd3dDevice);
         // Загружаем текстуру
         m_Textures[m_iTotalTextures].vLoad(szBitmapFileName);
         // Увеличиваем общее количество текстур
         m_iTotalTextures++;
      }
      iLocalCount++;
   }
}

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

Цвета владельца

Здесь начинаются хитрости анимационной графики. Функция загрузки текстур выделяет необходимую для текстур память, а затем в цикле перебирает кадры каждой анимационной последовательности, загружая данные текстур. Но для чего нужна константа UNITMANAGER_MAXOWNERS? Очень хороший вопрос!

Давайте еще раз взглянем на код, вычисляющий общее количество кадров:

m_Textures = new CTexture[
              (m_iNumStillFrames * (UNITMANAGER_MAXOWNERS + 1)) +
              (m_iNumMoveFrames * (UNITMANAGER_MAXOWNERS + 1)) +
              (m_iNumAttackFrames * (UNITMANAGER_MAXOWNERS + 1)) +
              (m_iNumDieFrames * (UNITMANAGER_MAXOWNERS + 1))];

Все выглядит нормально, за исключением операций умножения. Константа UNITMANAGER_MAXOWNERS содержит общее количество доступных в игре цветов игроков. Я использую это значение, чтобы узнать, сколько различных цветов для игроков поддерживает игра. Если вы раньше уже играли в стратегические игры, то знаете, что подразделения разных игроков отмечаются разными цветами. У одного игрока на боевых единицах могут быть красные полоски, в то время как у другого игрока эти же полоски будут пурпурными. Для этого необходимы дополнительные кадры анимации: для каждого кадра анимации столько, сколько доступно цветов.

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

    Количество_кадров_анимации * (Количество_цветов + 1)

Я прибавляю к количеству цветов 1, чтобы учесть исходный кадр. Кадры с цветами содержат только информацию о раскраске боевой единицы, в то время как исходный кадр содержит изображение самого подразделения. Если вам трудно это понять, взгляните на рис. 8.18.

Рис. 8.18. Кадры с цветами владельца для вертолета Apache

Рис. 8.18. Кадры с цветами владельца для вертолета Apache

На рис. 8.18 показаны кадры состояния ожидания для вертолета Apache. Первый кадр содержит изображение самой боевой единицы. На нем вы видите корпус вертолета, оружие, механизмы и лопасти пропеллера. На следующих кадрах изображена только накладываемая на исходное изображение раскраска. В примере поддерживается только четыре варианта раскраски, так что вы видите четыре кадра, каждый со своим цветом. Черно-белые изображения вам не слишком помогут, так что лучше загрузить графику из сопроводительных файлов. Она находится в каталоге D3DFrame_UnitTemplate\UnitData. Загрузите файлы apache0_0.tga, apache0_1.tga, apache0_2.tga, apache0_3.tga и apache0_4.tga. Файл apache0_0.tga содержит базовое изображение, а остальные файлы содержат только данные о цветах владельца.

Спрашивается, как это влияет на анимационную последовательность? Весьма сильно! И снова одна картинка гораздо лучше тысячи слов, так что смотрите на рис. 8.19.

Рис. 8.19. Анимационная последовательность для танка с учетом цветов владельца

Рис. 8.19. Анимационная последовательность для танка с учетом цветов владельца

На рис. 8.19 показаны анимационная последовательность ожидания и анимационная последовательность передвижения для танка, которые я уже демонстрировал ранее. Однако здесь в них внесено несколько изменений. Во-первых увеличилось количество кадров анимации. Это вызвано тем, что помимо основного кадра теперь в последовательности присутствуют и кадры с цветами владельца. Результат ясно виден на кадре анимации ожидания. Анимация ожидания состоит из одного кадра, но вместе с ним хранятся данные кадров с четырьмя цветами владельцев. Теперь только для анимации ожидания требуется целых пять кадров.

Взгляните таже на приведенную на рис. 8.19 анимационную последовательность передвижения. В предыдущем примере я показал вам, что кадры анимации передвижения размещаются один за другим. На самом деле между ними располагаются кадры с цветами владельца. Первый кадр анимации передвижения находится в кадре с номером 5, и за ним следуют четыре кадра с цветами владельца. Следующий кадр анимационной последовательности передвижения расположен в десятом кадре, и за ним так же следуют четыре кадра с цветами. Последний кадр анимации передвижения находится в кадре с номером 15 и за ним следуют последние четыре кадра с цветами владельца, необходимые для анимации.

Давайте еще раз взглянем на цикл, загружающий кадры анимации ожидания:

m_iStartStillFrames = 0;
for(i = 0; i < m_iNumStillFrames; i++) {
   for(j = 0; j < UNITMANAGER_MAXOWNERS+1; j++) {
      sprintf(szBitmapFileName, "UnitData\\%s%d_%d.tga",
         m_szBitmapPrefix, iLocalCount, j);

      // Задаем устройство визуализации
      m_Textures[m_iTotalTextures].vSetRenderDevice(m_pd3dDevice);
      // Загружаем текстуру
      m_Textures[m_iTotalTextures].vLoad(szBitmapFileName);
      // Увеличиваем общее количество текстур
      m_iTotalTextures++;
   }
   iLocalCount++;
}

Сначала я указываю, что анимация ожидания начинается с кадра с номером 0. Поскольку анимационная последовательность ожидания является самой первой в массиве, она начинается с кадра с индексом 0.

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

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

    UnitData\\ПрефиксТекстуры_НомерКадра_НомерЦвета.tga

Вместо поля ПрефиксТекстуры подставляется префикс имени файла с текстурой. Для танка вы можете выбрать префикс «TankGraphic». Для вертолета Apache я использую префикс «Apache».

Поле НомерКадра заменяется на номер кадра в анимационной последовательности. Поскольку анимационная последовательность для ожидания состоит из одного кадра, в это поле помещается 0.

Поле НомерЦвета содержит номер загружаемого кадра с цветами владельца. Базовому кадру с изображением боевой единицы соответствует номер 0.

После того, как название файла создано, я задаю устройство визуализации для объекта текстуры, после чего вызываю функцию загрузки объекта текстуры. Завершив эти действия я увеличиваю счетчик общего количества загруженных текстур и заканчиваю цикл.

Функция CUnitAnimation::vReset()

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

void CUnitAnimation::vReset(void)
{
   memset(m_szName, 0x00, 64);
   memset(m_szBitmapPrefix, 0x00, 64);
   // Освобождаем память текстур
   if(m_iTotalTextures) {
      delete [] m_Textures;
      m_Textures          = NULL;
      m_iTotalTextures    = 0;
   }
   m_iNumStillFrames      = 0;
   m_iNumMoveFrames       = 0;
   m_iNumAttackFrames     = 0;
   m_iNumDieFrames        = 0;
   m_iType                = 0;
   m_iStartStillFrames    = 0;
   m_iStartMoveFrames     = 0;
   m_iStartAttackFrames   = 0;
   m_iStartDieFrames      = 0;
}

Как видно в коде, чтобы определить наличие текстур я проверяю значение переменной m_iTotalTextures. Если какие-либо текстуры загружены, я удаляю массив m_Textures и устанавливаю количество загруженных текстур равным 0. Просто, не так ли?

Класс CTexture

Как я упоминал ранее, класс текстур используется мной для хранения данных Почему я использую отдельный класс текстур? Я думаю, что такой подход упрощает переход к новым версиям DirectX. Вместо того, чтобы изменять во многих местах тип указателя на текстуру, я просто внесу изменения в класс текстуры. Кроме того, это позволяет мне абстрагироваться от используемых методов загрузки. Взгляните как выглядит заголовок класса:

class CTexture
{
   public:
      // Название текстуры
      char               m_szName[64];
      // Указатель на текстуру
      LPDIRECT3DTEXTURE9 m_pTexture;
      // Указатель на устройство Direct3D для загрузки текстуры
      LPDIRECT3DDEVICE9  m_pd3dDevice;

      CTexture();
      ~CTexture();
      virtual void vLoad(char *szName);
      virtual void vRelease(void);
      virtual void vSetRenderDevice(LPDIRECT3DDEVICE9 pd3d);
};

Класс не очень сложен, поскольку он всего лишь загружает и хранит данные текстуры.

Члены данных класса CTexture

Переменная m_szName хранит имя файла с текстурой, а переменная m_pTexture хранит загруженные данные. Еще раз упомяну переменную m_pd3dDevice. Она необходима для загрузки данных текстуры.

Методы класса CTexture

Помимо конструктора и деструктора в классе текстуры присутствуют три функции: vLoad(), vRelease() и vSetRenderDevice().

Функция CTexture::vLoad()

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

void CTexture::vLoad(char *szName)
{
   // Сохраняем имя файла
   strcpy(m_szName, szName);
   // загружаем текстуру
   D3DXCreateTextureFromFile(m_pd3dDevice, m_szName, &m_pTexture);
}

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

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

Функция CTexture::vRelease()

Функция освобождения ресурсов очень проста, поскольку ей необходимо только освободить выделенную для хранения текстуры память. Вместо оператора delete используется метод Release, поскольку это требование DirectX. Код функции приведен ниже:

void CTexture::vRelease(void)
{
   // Удаление текстуры, если она есть в памяти
   if(m_pTexture) {
      m_pTexture->Release();
      m_pTexture = NULL;
   }
}

Сначала я проверяю, была ли выделена память для объекта текстуры; если да, то я вызываю метод для освобождения памяти, занятой данными текстуры. В результате данные удаляются из памяти.

Функция CTexture::vSetRenderDevice()

Чтобы обеспечить возможность установки внутреннего указателя на устройство визуализации, я предоставляю функцию задания устройства визуализации. Она получает указатель на основное устройство трехмерной визуализации и сохраняет его в локальной переменной объекта текстуры. Если вы хотите взглянуть на код, откройте файл UnitTemplateClasses.cpp.

Вот и все, ребята! Я стремительно пролетел сквозь класс текстуры, но он действительно очень прост и не требует особого внимания. Надеюсь, вы согласны. Если нет, загрузите Age of Mythology и пришлите мне ICQ с предложением поиграть!

Класс CUnit

Вот мы и рассмотрели все необходимые для подразделения базовые классы. У нас есть готовые к использованию данные защиты, атаки, передвижения и анимации. Отсутствует только клей, который соединит эти разрозненные компоненты вместе. По отдельности эти детали не слишком полезны, но собранные вместе они образуют подразделение. Здесь и вступает в игру класс CUnit. Он содержит указатели на различные базовые типы, а также ряд переменных состояния. Базовые типы хранят те данные подразделения, которые никогда не меняются, а данные состояния могут изменяться в зависимости от того, что происходит с подразделением. Все это иллюстрирует рис. 8.20.

Рис. 8.20. Структура объекта подразделения

Рис. 8.20. Структура объекта подразделения

На рис. 8.20 видно, что класс подразделения состоит из базовых классов и данных состояния. В блоке данных состояния находятся переменные для различных параметров, таких как текущее количество очков повреждений, направление поворота, местоположение и текущая скорость. Обратите внимание на пунктирную линию, соединяющую максимальное количество очков повреждений в базовом объекте защиты и текущее количество очков повреждений в данных состояния. Текущее количество очков повреждений показывает сколько еще повреждений может получить подразделение до его уничтожения. Это значение изменяется когда подразделение получает повреждения или восстанавливается. Поскольку подразделения не могут совместно использовать одно общее значение здоровья, текущее значение здоровья хранится каждым подразделением локально в его данных состояния. Базовый тип защиты вступает в игру, когда вычисляется максимально возможный для подразделения показатель здоровья. Это значение никогда не изменяется, и поэтому базовый класс — наилучшее место для него.

Держа в уме информацию с рис. 8.20, взглянем на исходный код:

class CUnit
{
   public:
      CUnitDefense   *m_Defense;
      CUnitOffense   *m_Offense1;
      CUnitOffense   *m_Offense2;
      CUnitOffense   *m_Offense3;
      CUnitMovement  *m_Movement;
      CUnitAnimation *m_Animation;
      int            m_iType;
      int            m_iCurHitPoints;
      float          m_fCurSpeed;
      float          m_fXPos;
      float          m_fYPos;
      float          m_fRot;
      float          m_fScale;
      int            m_iUnitID;
      int            m_iParentID;
      char           m_szName[64];
      bool           m_bActive;
      int            m_iOwner;
      int            m_iCurAnimFrame;
      int            m_iCurAttackFrame;
      int            m_iCurStillFrame;
      int            m_iCurMoveFrame;
      int            m_iCurDieFrame;

   public:
      CUnit();
      ~CUnit();
      virtual void vReset(void);
      virtual void vSetBaseValues(
                CUnitDefense* ptrDef,
                CUnitOffense* ptrOff1,
                CUnitOffense* ptrOff2,
                CUnitOffense* ptrOff3,
                CUnitMovement* ptrMove,
                CUnitAnimation* ptrAnim);
   virtual void vSetPosition(float fX, float fY);
};

С точки зрения количества функций класс не выглядит слишком сложным. Большая часть кода состоит из объявлений необходимых для игры переменных состояния. Это ни в коем случае нельзя считать достаточным для завершенного класса подразделения. Перечисленных переменных состояния достаточно только для рассматриваемого примера. В реальной игре их будет гораздо больше!

Члены данных класса CUnit

Давайте начнем с указателей на базовые типы. В рассматриваемом примере есть тип защиты, три типа атаки, тип перемещения и тип анимации. Я использую три типа атаки потому что на одной боевой единице может быть установлено несколько типов оружия. Например, на танке обычно установлена пушка и пулемет. Имея три типа атаки подразделение в игре может стрелять из трех различных типов оружия. Если вам не нужна такая сложность, просто удалите лишние типы атаки. Если же вы хотите усложнить игровой процесс, добавьте их!

Переменная m_iCurHitPoints хранит текущий показатель здоровья подразделения. Когда ее значение достигает 0, подразделение погибает. Максимально возможное значение этого поля хранится в переменной m_iHitPoints класса защиты.

Переменная m_fCurSpeed показывает текущую скорость подразделения. Чтобы вычислить куда переместилось подразделение следует умножить текущую скорость на вектор направления. Когда движение подразделения замедляется, выполняется вычитание из этого значения, а чтобы подразделение двигалось быстрее, увеличьте значение данного поля. Максимальное значение данного поля хранится в переменной базового класса типа перемещения с именем m_fMovementSpeed.

Переменные m_fXPos и m_fYPos хранят местоположение подразделения на карте. В рассматриваемом примере используется двухмерная графика и поэтому координат требуется тоже две — X и Y.

Переменная m_fRot указывает угол поворота боевой единицы в градусах. Это значение используется, когда необходимо развернуть подразделение по направлению к противнику или определить направление перемещения. Поскольку значение изменяется в градусах, допустимый диапазон значений — от 0.0 до 359.0.

Поле m_fScale задает текущий размер подразделения. Оно применяется для того, чтобы в двухмерной графике создать эффект приближения подразделения к камере. Обычно значение данной переменной равно 1.0, чтобы подразделение выглядело таким же, как и при разработке.

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

Поле m_iParentID указывает какое подразделение является владельцем данного. Переменная используется для транспортных средств, таких как десантные самолеты и авианосцы. Если значение переменной отличается от –1, значит данное подразделение перевозится другой боевой единицей. Если же значение равно –1, — подразделение не имеет назначенного родителя.

Массив символов m_szName хранит название подразделения. Оно используется для отображения в интерфейсе пользователя и других информационных целей.

Поле m_bActive сообщает вам, является ли подразделение активным в данный момент. Поскольку в игре для каждого игрока выделяется лишь ограниченное количество боевых единиц, погибшие подразделения должны отмечаться как неактивные, чтобы освободившиеся записи можно было использовать для других подразделений. Когда подразделение отмечено как активное, оно используется в игре и его запись не может использоваться для других целей.

Поле m_iOwner сообщает кто является владельцем данного подразделения. Одно из его применений — назначение цветов владельца при отображении графики.

Поле m_iCurAnimFrame указывает, какой именно кадр анимационной последовательности отображается в данное время.

Поле m_iCurAttackFrame следит за анимацией подразделения во время воспроизведения анимационной последовательности атаки. Это необходимо потому что у вас может быть несколько кадров для каждого типа анимации.

Поле m_iCurStillFrame работает так же как и предыдущее, но следит за анимацией ожидания, а не атаки. Оно используется в те моменты, когда подразделение ничем не занято.

Поле m_iCurMoveFrame похоже на остальные счетчики кадров анимации, но используется когда подразделение перемещается.

Переменная m_iCurDieFrame работает также как и предыдущие счетчики кадров анимации и используется только при гибели подразделения. О-ох, взгляните на эти взрывы!

Чтобы увидеть как переменные состояния связаны с базовыми типами, взгляните на рис. 8.21.

Рис. 8.21. Взаимосвязь между переменными состояния и базовыми типами

Рис. 8.21. Взаимосвязь между переменными состояния и базовыми типами

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

Методы класса CUnit

В классе CUnit я реализовал сравнительно мало методов. Идея проста — вы сами добавите необходимые вам методы, базируясь на потребностях собственного проекта. Итак, вот та часть работы, которую я проделал за вас.

Функция CUnit::vReset()

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

Функция CUnit::vSetBaseValues()

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

void CUnit::vSetBaseValues(CUnitDefense* ptrDef,
                          CUnitOffense* ptrOff1,
                          CUnitOffense* ptrOff2,
                          CUnitOffense* ptrOff3,
                          CUnitMovement* ptrMove,
                          CUnitAnimation* ptrAnimation)
{
   // Указатели на переданные классу объекты
   m_Defense    = ptrDef;
   m_Offense1   = ptrOff1;
   m_Offense2   = ptrOff2;
   m_Offense3   = ptrOff3;
   m_Movement   = ptrMove;
   m_Animation  = ptrAnimation;
}

В коде я присваиваю внутренним указателям на базовые типы переданные функции параметры. Параметров всего шесть: один для защиты, один для передвижения, один для анимации и три для атаки.

Функция CUnit::vSetPosition()

Функция задания местоположения позволяет установить координаты X и Y подразделения с помощью одного вызова. Она получает данные о новом местоположении подразделения и сохраняет их во внутренних переменных. Вот как выглядит код:

void CUnit::vSetPosition(float fX, float fY)
{
   m_fXPos = fX;
   m_fYPos = fY;
}

Вот так, красиво и просто!

Класс CUnitManager

Теперь у вас есть класс атаки, класс защиты, класс передвижения, класс анимации и даже класс подразделения, чтобы объединить все предыдущие классы в единое целое. Чего же не хватает? Класса для управления всей этой информацией! Все эти классы великолепны, но ручное управление ими подобно камешку в ботинке. Класс диспетчера выполняет эту работу за вас, объединяя различные строительные блоки из которых состоят подразделения в одном месте. Класс диспетчера решает за вас следующие задачи:

  • Загрузка базовых типов
  • Создание подразделений
  • Управление текстурами

Загрузка базовых типов

У вас есть базовые классы для хранения данных подразделения, но как загрузить в них информацию? Один из способов — жестко задать все значения параметров подразделений в коде программы. Подобное сляпанное наспех решение не позволит создать гибкую систему. Я предпочитаю использовать конфигурационные файлы, которые загружаются во время работы программы. Вы можете редактировать конфигурационные файлы и снова запускать игру без повторной компиляции. Это неоценимое преимущество, поскольку вы наверняка будете менять параметры вашей игры во время разработки. Это также позволяет легко создавать расширения для игры, поскольку для создания новых типов подразделений достаточно изменить значения нескольких параметров в конфигурационных файлах.

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

const int UNITMANAGER_MAXBASEOBJS= 256;
const int UNITMANAGER_MAXUNITS   = 1024;
class CUnitManager
{
   public:
      CUnitDefense     *m_DefenseObjs;
      CUnitOffense     *m_OffenseObjs;
      CUnitMovement    *m_MovementObjs;
      CUnitAnimation   *m_AnimationObjs;
      CUnit            *m_UnitBaseObjs;
      CUnit            *m_UnitObjs;
      int              m_iTotalDefObjs;
      int              m_iTotalOffObjs;
      int              m_iTotalMovObjs;
      int              m_iTotalAnimationObjs;
      int              m_iTotalUnitBaseObjs;
      int              m_iTotalUnitObjs;
      int              m_iOwnerTotal[UNITMANAGER_MAXOWNERS];
      // Указатель Direct 3D для загрузки текстур
      LPDIRECT3DDEVICE9   m_pd3dDevice;

      CUnitManager();
      ~CUnitManager();
      virtual void vSetRenderDevice(LPDIRECT3DDEVICE9 pd3d);
      virtual void vReset(void);
      virtual void vClearMem(void);
      virtual int   iLoadBaseTypes(
         char *szDefFileName,
         char *szOffFileName,
         char *szMovFileName,
         char *szUnitFileName,
         char *szAnimFileName);
      virtual CUnitDefense* ptrGetDefenseType(char *szName);
      virtual CUnitOffense* ptrGetOffenseType(char *szName);
      virtual CUnitMovement* ptrGetMoveType(char *szName);
      virtual CUnitAnimation* ptrGetAnimType(char *szName);
      virtual int iAddUnit(char *szName, int iOwner);
      virtual void vRemoveUnit(int iUnitID);
      virtual int iCountTotalTextures(void);
};

Большинство членов данных класса имеет отношение к объектам базовых типов. Поля m_DefenseObjs, m_OffenseObjs, m_MovementObjs, m_AnimationObjs и m_UnitBaseObjs используются как массивы для хранения загружаемых впоследствии базовых типов. Переменные m_iTotalDefObjs, m_iTotalOffObjs, m_iTotalMovObjs, m_iTotalAnimationObjs и m_iTotalUnitBaseObjs отслеживают кличество загруженных в память объектов каждого типа. Это показано на рис. 8.22.

Рис. 8.22. Базовые типы в классе диспетчера подразделений

Рис. 8.22. Базовые типы в классе диспетчера подразделений

На рис. 8.22 показаны базовые типы, содержащиеся в классе диспетчера подразделений. Слева указаны типы, а в центре — названия реальных полей. Изображения хранилищ данных справа на рисунке представляют выделенную для хранения базовых типов память.

Функция CUnitManager::iLoadBaseTypes()

Вы узнали, качие элементы класса хранят информацию базовых типов, но как загружаются данные? Здесь вступает в игру функция iLoadBaseTypes(). Она получает пять параметров, каждый из которых является именем файла, содержащего импортируемые данные. Отдельные файлы требуются для данных защиты, данных атаки, данных передвижения, данных анимации и данных подразделений. Функция загрузки базовых типов получает имена пяти файлов и импортирует данные из них в диспетчер подразделений. На рис. 8.23 показана взаимосвязь между классом диспетчера подразделений и импортируемыми файлами.

Рис. 8.23. Импорт данных из пяти различных файлов в базовые типы CUnitManager

Рис. 8.23. Импорт данных из пяти различных файлов в базовые типы CUnitManager

На рис. 8.23 показано как диспетчер подразделений загружает информацию в базовые типы из пяти различных файлов данных. Имена этих файлов BaseType_Defense.csv, BaseType_Offense.csv, BaseType_Movement.csv, BaseType_Unit.csv и BaseType_Animation.csv. Расширение имени файла .csv обозначает, что это файлы в формате с разделенными запятыми значениями. Такие файлы содержат значения, разделенные запятыми. Это общепринятый формат, поддерживаемый электронными таблицами, поскольку он позволяет сохранять данные в простом для импортирования формате. Лично я для ввода и редактирования информации о подразделениях использую программу работы с электронными таблицами Excel. Вот пример данных для базовых типов защиты:

    Medium Heli Armor, 20, 2, 2, 30, 30, 0

    Heavy Heli Armor, 30, 2, 2, 50, 100, 0

    Light Heli Armor, 10, 2, 2, 20, 70, 0

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

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

Рис. 8.24. Данные защиты в электронной таблице Excel

Рис. 8.24. Данные защиты в электронной таблице Excel

На рис. 8.24 показаны уже представленные ранее данные, но в виде гораздо лучше выглядящей электронной таблицы с названиями столбцов. Если у вас есть программа для работы с электронными таблицами или базами данных, экспорт в формат CSV осуществляется очень легко. Загляните в папку проекта D3DFrame_UnitTemplate, находящуюся среди сопроводительных файлов на CD-ROM и вы найдете там папку UnitData, содержащую csv-файлы с информацией о подразделениях, необходимой для данного примера.

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

// Открываем файл с данными базового типа
fp = fopen(szDefFileName, "r");
if(fp == NULL) {
   return(-1);
}
// Читаем строку с заголовками столбцов и игнорируем ее
fgets(szTempBuffer, 512, fp);
szTempBuffer[strlen(szTempBuffer) - 1] = '\0';
// Устанавливаем общее количество объектов равным 0
m_iTotalDefObjs = 0;

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

// Последовательный перебор строк файла
while(!feof(fp)) {
   // Получаем следующую строку
   fgets(szTempBuffer, 512, fp);
      if(feof(fp)) {
      break;
   }
   // Добавляем разделитель
   szTempBuffer[strlen(szTempBuffer)-1] = '\0';
   iStart    = 0;
   iEnd      = 0;
   iCurPos   = 0;
   iCurValue = 0;
   // Извлекаем значение
   while(szTempBuffer[iCurPos] != '\0' && iCurPos < 512) {
      // Проверяем достигли ли конца значения
      if(szTempBuffer[iCurPos] == ',') {
         iEnd = iCurPos;
         memset(&szValue[iCurValue][0], 0x00, 32);
         memcpy(&szValue[iCurValue], &szTempBuffer[iStart], iEnd - iStart);
         iStart = iEnd + 1;
         iCurValue++;
      }
      iCurPos++;
   };
   // Импорт последнего столбца
   iEnd = iCurPos;
   memset(&szValue[iCurValue][0], 0x00, 32);
   memcpy(&szValue[iCurValue], &szTempBuffer[iStart], iEnd - iStart);
   iStart = iEnd + 1;
   iCurValue++;
   ...

Как видите, я извлекаю значения, находящиеся между запятыми и сохраняю их во временном символьном массиве с именем szValue. Как только все значения из строки помещены во временный массив, я копирую их в объект типа защиты. Это происходит в следующем фрагменте кода:

// Идентификатор типа
m_DefenseObjs[m_iTotalDefObjs].m_iType = m_iTotalDefObjs;
// Название
strcpy(m_DefenseObjs[m_iTotalDefObjs].m_szName, &szValue[0][0]);
// Коэффициент защиты от пуль
m_DefenseObjs[m_iTotalDefObjs].m_iBulletArmorRating = atoi(&szValue[1][0]);
// Коэффициент защиты от ракет
m_DefenseObjs[m_iTotalDefObjs].m_iMissileArmorRating = atoi(&szValue[2][0]);
// Коэффициент защиты от лазера
m_DefenseObjs[m_iTotalDefObjs].m_iLaserArmorRating = atoi(&szValue[3][0]);
// Коэффициент защиты в рукопашной
m_DefenseObjs[m_iTotalDefObjs].m_iMeleeArmorRating = atoi(&szValue[4][0]);
// Очки повреждений
m_DefenseObjs[m_iTotalDefObjs].m_iMeleeArmorRating = atoi(&szValue[5][0]);
// Скорость восстановления
m_DefenseObjs[m_iTotalDefObjs].m_iMeleeArmorRating = atoi(&szValue[6][0]);
// Увеличиваем количество объектов
m_iTotalDefObjs++;
}
fclose(fp);

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

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

// Тип идентификатора
m_AnimationObjs[m_iTotalAnimationObjs].m_iType = m_iTotalAnimationObjs;
// Имя
memset(m_AnimationObjs[m_iTotalAnimationObjs].m_szName, 0x00, 64);
strcpy(m_AnimationObjs[m_iTotalAnimationObjs].m_szName, &szValue[0][0]);
// Префикс
memset(m_AnimationObjs[m_iTotalAnimationObjs].m_szBitmapPrefix, 0x00, 64);
strcpy(m_AnimationObjs[m_iTotalAnimationObjs].m_szBitmapPrefix, &szValue[1][0]);
// Количество кадров ожидания
m_AnimationObjs[m_iTotalAnimationObjs].m_iNumStillFrames = atoi(&szValue[2][0]);
// Количество кадров перемещения
m_AnimationObjs[m_iTotalAnimationObjs].m_iNumMoveFrames = atoi(&szValue[3][0]);
// Количество кадров атаки
m_AnimationObjs[m_iTotalAnimationObjs].m_iNumAttackFrames = atoi(&szValue[4][0]);
// Количество кадров гибели
m_AnimationObjs[m_iTotalAnimationObjs].m_iNumDieFrames = atoi(&szValue[5][0]);
// Установка устройства визуализации
m_AnimationObjs[m_iTotalAnimationObjs].vSetRenderDevice(m_pd3dDevice);
// Загрузка текстур
m_AnimationObjs[m_iTotalAnimationObjs].vLoadTextures();
// Увеличение количества объектов
m_iTotalAnimationObjs++;

Приведенный выше код похож на остальные фрагменты кода за исключением вызовов двух методов объекта анимации. Первый из них, vSetRenderDevice(), устанавливает внутренний указатель объекта анимации на устройство визуализации Direct3D. Это позволяет объекту загружать текстуры. Второй метод, vLoadTextures(), использует информацию, хранящуюся в csv-файле данных анимации для загрузки необходимых для анимации текстур. Он формирует имена файлов, комбинируя заданный в данных анимации префикс растровой графики со значением счетчика кадров. На рис. 8.25 показаны данные для типов атаки.

Рис. 8.25. Данные атаки хранящиеся в электронной таблице Excel

Рис. 8.25. Данные атаки хранящиеся в электронной таблице Excel

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

// Тип защиты
ptrDefense = ptrGetDefenseType(&szValue[1][0]);
// Первый тип атаки
ptrOffense1 = ptrGetOffenseType(&szValue[2][0]);
// Второй тип атаки
ptrOffense2 = ptrGetOffenseType(&szValue[3][0]);
// Третий тип атаки
ptrOffense3 = ptrGetOffenseType(&szValue[4][0]);
// Тип передвижения
ptrMovement = ptrGetMoveType(&szValue[5][0]);
// Тип анимации
ptrAnimation = ptrGetAnimType(&szValue[6][0]);
// Установка базовых типов
m_UnitBaseObjs[m_iTotalUnitBaseObjs].vSetBaseValues(
       ptrDefense,
       ptrOffense1,
       ptrOffense2,
       ptrOffense3,
       ptrMovement,
       ptrAnimation);

В приведенном выше коде я устанавливаю для подразделения типы защиты, атаки, передвижения и анимации. Это делается с помощью вызова различных методов диспетчера подразделений, задачей которых является получение экземпляра базового типа по его имени. Первым вызывается метод с именем ptrGetDefenseType(). Данные, о которых я только что рассказывал представлены на рис. 8.26.

Рис. 8.26. Данные подразделений хранящиеся в электронной таблице Excel

Рис. 8.26. Данные подразделений хранящиеся в электронной таблице Excel

Функция CUnitManager::ptrGetDefenseType()

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

CUnitDefense* CUnitManager::ptrGetDefenseType(char *szName)
{
   int          i;
   CUnitDefense *ptrUnitDefense = NULL;

   for(i = 0; i < m_iTotalDefObjs; i++) {
      if(!stricmp(szName, m_DefenseObjs[i].m_szName)) {
         ptrUnitDefense = &m_DefenseObjs[i];
         return(ptrUnitDefense);
      }
   }

   return(ptrUnitDefense);
}

Код представляет собой простой цикл, перебирающий все загруженные типы защиты. Название каждого типа защиты сравнивается с переданной функции строкой. Если строки совпадают, возвращается указатель на тип защиты. Это позволяет вызывающему коду использовать данные типа защиты без создания копии данных, благодаря чему уменьшается объем занимаемой памяти.

Функции, подобные рассмотренной выше, используются для получения указателей на типы атаки, передвижения и анимации. Я не буду приводить их здесь, поскольку они практически идентичны уже рассмотренному коду и вы можете увидеть их в файле UnitTemplateClasses.cpp.

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

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

Создание подразделений

Теперь, после того как базовая информация о подразделених загружена, вы можете создавать подразделения, которые будут использоваться в игре. Вы не можете модифицировать базовые типы, так что следует создавать новые объекты подразделений. Здесь в игру вступает член данных диспетчера подразделений с именем m_UnitObjs. Данный массив хранит модифицируемые объекты подразделений, использующиеся в игре. Для управления этими объектами применяются две функции: iAddUnit() и vRemoveUnit().

Функция CUnitManager::iAddUnit()

Когда вы хотите ввести в игру новое подразделение, следует вызвать функцию добавления подразделения. Она находит неактивное подразделение и инициализирует его данные для использования в игре. Вот как выглядит код этой функции:

int CUnitManager::iAddUnit(char *szName, int iOwner)
{
   int i;
   int iFoundID = -1;

   // Ищем соответствующий тип
   for(i = 0; i < m_iTotalUnitBaseObjs; i++) {
      if(!stricmp(szName, m_UnitBaseObjs[i].m_szName)) {
         iFoundID = i;
         break;
      }
   }
   // Возвращаемся, если базовый тип не найден
   if(iFoundID == -1) {
      return(-1);
   }
   // Ищем свободный блок данных подразделения
   for(i = 0; i < m_iTotalUnitObjs; i++) {
      // Проверяем, является ли блок неактивным
      if(!m_UnitObjs[i].m_bActive) {
         // Активируем подразделение
         m_UnitObjs[i].m_bActive= 1;
         // Устанавливаем его внутренние типы
         m_UnitObjs[i].vSetBaseValues(
            m_UnitBaseObjs[iFoundID].m_Defense,
            m_UnitBaseObjs[iFoundID].m_Offense1,
            m_UnitBaseObjs[iFoundID].m_Offense2,
            m_UnitBaseObjs[iFoundID].m_Offense3,
            m_UnitBaseObjs[iFoundID].m_Movement,
            m_UnitBaseObjs[iFoundID].m_Animation);
         // Устанавливаем тип подразделения
         m_UnitObjs[i].m_iType     = iFoundID;
         // Устанавливаем владельца подразделения
         m_UnitObjs[i].m_iOwner = iOwner;
         // Увеличиваем количество подразделений у владельца
         m_iOwnerTotal[iOwner]++;

         return(i);
      }
   }
   return(-1);
}

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

ПРИМЕЧАНИЕ
Всегда выполняйте поиск имени базового типа подразделения. Если вы не будете искать имя, соответствующее переданному из вызывающего кода, у вас может использоваться несуществующий тип подразделения!

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

Еще раз проясню ситуацию: массив m_UnitObjs хранит данные подразделений, которые изменяются во время игры, а массив m_UnitBaseObjs хранит шаблоны подразделений, которые никогда не меняются. Объекты m_UnitObjs меняют свои данные состояния, а объекты m_UnitBaseObjs — нет. Взаимосвязь между базовыми типами и динамическими объектами показана на рис. 8.27.

Рис. 8.27. Взаимосвязь между статическими базовыми данными и динамическими объектами подразделений

Рис. 8.27. Взаимосвязь между статическими базовыми данными и динамическими объектами подразделений

На рис. 8.27 видно, как динамические данные подразделений из массива m_UnitObjs используют в качестве основы данные, хранящиеся в базовых типах.

Управление текстурами

Я уже показывал вам управление текстурами ранее, в разделе посвященном импорту данных подразделений. Поскольку функция iLoadBaseTypes() загружает все тербуемые текстуры, можно считать, что управление текстурами уже реализовано. Тем не менее, я добавил еще одну функцию управления, которая подсчитывает количество загруженных текстур и возвращает полученное значение. Она полезна при вычислении объема используемой для хранения текстур памяти. Функция называется iCountTotalTextures(), и вот как выглядит ее код:

int CUnitManager::iCountTotalTextures(void)
{
   int iCount = 0;
   // Цикл перебора объектов анимации и подсчета текстур
   for(int i = 0; i < m_iTotalAnimationObjs; i++) {
      iCount += m_AnimationObjs[i].m_iTotalTextures;
   }
   return(iCount);
}

В функции я перебираю все загруженные базовые типы анимации и суммирую количество текстур, содержащееся в каждом из них. После того, как цикл завершен я возвращаю итоговое значение вызывающей программе. Поскольку каждая текстура в данной игре имеет размер 128x128 точек и глубину цвета 32 бит, для вычисления объема занимаемой текстурами памяти вам достаточно умножить возвращаемое функцией общее количество текстур на 65536 (128 x 128 x 4).


Глава 8 - Рисование подразделений

Рисование подразделений

Все эти классы великолепны, но как насчет визуализации? Если вы откроете файл main.cpp из проекта D3DFrame_UnitTemplate, я покажу вам! Спускайтесь вниз до функции vInitTileVB().

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

Рис. 8.28. Два квадрата с различными базовыми точками

Рис. 8.28. Два квадрата с различными базовыми точками

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

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

Рис. 8.29. Два текстурированных квадрата с различными базовыми точками

Рис. 8.29. Два текстурированных квадрата с различными базовыми точками

На рис. 8.29 показаны те же два квадрата, что и ранее, но на них нанесена текстура с изображением танка. Танк слева поворачивается очень странно, поскольку его базовая точка расположена неверно. Танк справа поворачивается правильно, потому что его базовая точка расположена в центре квадрата.

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

// Создание вершин
pVertices[0].position  = D3DXVECTOR3(-0.5f, -0.5f, 0.0f);
pVertices[0].tu        = 0.0f;
pVertices[0].tv        = 1.0f;
pVertices[0].vecNorm   = D3DXVECTOR3(0.0f,0.0f,1.0f);
pVertices[1].position  = D3DXVECTOR3(-0.5f, 0.5f, 0.0f);
pVertices[1].tu        = 0.0f;
pVertices[1].tv        = 0.0f;
pVertices[1].vecNorm   = D3DXVECTOR3(0.0f,0.0f,1.0f);
pVertices[2].position  = D3DXVECTOR3(0.5f, -0.5f, 0.0f);
pVertices[2].tu        = 1.0f;
pVertices[2].tv        = 1.0f;
pVertices[2].vecNorm   = D3DXVECTOR3(0.0f,0.0f,1.0f);
pVertices[3].position  = D3DXVECTOR3(0.5f, 0.5f, 0.0f);
pVertices[3].tu        = 1.0f;
pVertices[3].tv        = 0.0f;
pVertices[3].vecNorm   = D3DXVECTOR3(0.0f,0.0f,1.0f);

Код создает четыре вершины — по одной для каждого из углов квадрата. Их расположение показано на рис. 8.30.

Рис. 8.30. Координаты вершин квадрата с базовой точкой в центре

Рис. 8.30. Координаты вершин квадрата с базовой точкой в центре

На рис. 8.30 вы видите квадрат с базовой точкой, находящейся в центре. Так же там показано расположение осей X, Y и Z относительно вершин квадрата. Точки снизу и слева находятся в отрицательном пространстве, а точки сверху и справа — в положительном.

Функция vDrawUnit()

Теперь, когда у вас есть буфер вершин для подразделения, необходимо место для выполнения визуализации. Здесь выходит на сцену функция рисования подразделения. Она работает точно так же, как функция рисования блока игрового поля, которую я показывал в главе 5, за исключением некоторых добавленных функций. Вот ее код:

void CD3DFramework::vDrawUnit(
   float fXPos,
   float fYPos,
   float fXSize,
   float fYSize,
   float fRot,
   CUnitAnimation *animObj,
   int iTexture,
   int iOwner)
{
   D3DXMATRIX   matWorld;
   D3DXMATRIX   matRotation;
   D3DXMATRIX   matTranslation;
   D3DXMATRIX   matScale;

   // Установка значений по умолчанию для
   // местоположения, масштабирования и вращения
   D3DXMatrixIdentity(&matTranslation);
   // Масштабирование блока
   D3DXMatrixScaling(&matScale, fXSize, fYSize, 1.0f);
   D3DXMatrixMultiply(&matTranslation,&matTranslation,&matScale);
   // Вращение блока
   D3DXMatrixRotationZ(&matRotation, (float)DegToRad(-fRot));
   D3DXMatrixMultiply(&matWorld, &matTranslation, &matRotation);
   // Перемещение блока
   matWorld._41 = fXPos - 0.5f; // X-Pos
   matWorld._42 = fYPos + 0.5f; // Y-Pos
   // Установка матрицы
   m_pd3dDevice->SetTransform(D3DTS_WORLD, &matWorld);
   // Используем буфер вершин блока
   m_pd3dDevice->SetStreamSource(
          0, m_pVBUnit,
          0, sizeof(TILEVERTEX));
   // Используем фрмат вершин блока
   m_pd3dDevice->SetFVF(D3DFVF_TILEVERTEX);
   // Задаем используемую текстуру
   m_pd3dDevice->SetTexture(
          0, animObj->m_Textures[iTexture].m_pTexture);
   // Отображаем квадрат
   m_pd3dDevice->DrawPrimitive(D3DPT_TRIANGLESTRIP, 0, 2);
   // Задаем используемую текстуру
   m_pd3dDevice->SetTexture(
          0, animObj->m_Textures[iTexture + iOwner + 1].m_pTexture);
   // Отображаем квадрат
   m_pd3dDevice->DrawPrimitive(D3DPT_TRIANGLESTRIP, 0, 2);
   // Разыменовываем текстуру
   m_pd3dDevice->SetTexture(0, NULL);
}

Первое отличие данной функции от ранее рассмотренной vDrawTile() — добавление параметра вращения. Он позволяет вам развернуть двухмерное изображение на любой необходимый угол. Само вращение реализуется перемножением матриц поворота и преобразования. Матрица поворота создается вспомогательной функцией DirectX D3DXMatrixRotationZ().

СОВЕТ
В DirectX угол поворота всегда вычисляется в радианах. Для преобразования угловых величин в радианы я использую макрос DegToRad(). Вам в ваших программах также следует использовать аналогичную функцию или вращение графики и трехмерных объектов будет выполняться неправильно.

Следующее отличие рассматриваемой функции заключается в том, что я использую буфер вершин m_pVBUnit вместо буфера m_pVBTile. Это сделано для того, чтобы базовая точка располагалась в центре, о чем я говорил ранее.

Самое большое отличие данной функции — добавление параметра CUnitAnimation. Он сообщает функции откуда она должна брать текстуры. Указатель на класс анимации необходим потому, что именно в нем хранятся используемые для визуализации текстуры.

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

Функция vRender()

Сейчас у вас есть буфер вершин для подразделения и функция, помогающая при визуализации. Белым пятном остается место, где создается изображение каждого кадра. Встречайте старого знакомого — функцию vRender().

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

Использование альфа-канала

Первые отличия кода функции проявляются в том месте, где я включаю альфа-смешивание. Это действие позволяет видеть блоки карты сквозь текстуры подразделений. Сам процесс достаточно прямолинеен и осуществляется путем изменения нескольких состояний визуализации. Вот как выглядит код:

// Включение прозрачности
m_pd3dDevice->SetRenderState(
       D3DRS_ALPHABLENDENABLE,
       TRUE);
m_pd3dDevice->SetRenderState(
       D3DRS_SRCBLEND,
       D3DBLEND_SRCALPHA);
m_pd3dDevice->SetRenderState(
       D3DRS_DESTBLEND,
       D3DBLEND_INVSRCALPHA);

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

Отображение активных подразделений

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

// Цикл перебирающий подразделения
for(int i = 0; i < m_UnitManager.m_iTotalUnitObjs; i++) {
   // Устанавливаем указатель на подразделение
   ptrUnit = &m_UnitManager.m_UnitObjs[i];
   // Проверяем, активно ли подразделение
   if(ptrUnit->m_bActive) {
      // Рисуем подразделение
      vDrawUnit(
         ptrUnit->m_fXPos,
         ptrUnit->m_fYPos,
         ptrUnit->m_fScale * 128.0f,
         ptrUnit->m_fScale * 128.0f,
         ptrUnit->m_fRot,
         ptrUnit->m_Animation,
         ptrUnit->m_iCurAnimFrame,
         ptrUnit->m_iOwner
      );
   }
}

В приведенном выше коде я в цикле перебираю все подразделения, созданные в диспетчере подразделений. Если подразделение активно я вызываю функцию рисования и передаю ей параметры подразделения. Местоположение подразделения определяет в каком месте экрана оно будет находиться. Параметр, задающий поворот определяет ориентацию подразделения. Указатель анимации сообщает функции визуализации подразделения откуда ей брать данные текстуры. Номер текущего кадра анимации сообщает функции визуализации подразделения, какую именно текстуру ей следует выводить. Код владельца определяет, какая именно текстура с цветами владельца будет наложена поверх изображения подразделения. Хм-м-м... Мне кажется, я что-то забыл. Ах, да! Как вычисляется текущий кадр анимации? С помощью функции vUpdateUnits(). Взгляните на рис. 8.31, чтобы увидеть ход выполнения функции визуализации до данного момента.

Рис. 8.31. Ход выполнения функции визуализации

Рис. 8.31. Ход выполнения функции визуализации

На рис. 8.31 видно, что функция визуализации вызывает перед началом визуализации функцию обновления данных подразделений. Это важный шаг, так как кадры анимации подразделений должны быть обновлены до того, как начнется их визуализация. Технически вы можете выполнять обновление и позже, но главное, чтобы оно обязательно где-нибудь выполнялось!

Обновление кадра анимации

Перед тем как я погружусь в работу функции обновления подразделений, давайте взглянем на следующий код:

// Обновление подразделений
if(timeGetTime() > dwLastUpdateTime) {
   vUpdateUnits();
   dwLastUpdateTime = timeGetTime() + 33;
}

Данный код перед тем как вызвать функцию обновления данных подразделений снова, проверяет прошло ли 33 миллисекунды с момента ее последнего вызова. Это позволяет ограничить частоту обновления графики. Если вы не поместите в код подобный ограничитель, анимация будет некорректно воспроизводиться на системах, которые работают быстрее чем ваша. Конечно, у вас может быть наилучшая на сегодняшний день система, но что будет через пару лет? Это напоминает мне о родственнике, который в давние времена написал игру для IBM PC. В программе была собственная встроенная операционная система. Он поступил так чтобы уменьшить занимаемый объем памяти, поскольку программа содержала около двух миллионов строк ассемблерного кода! В графические вызовы он не поместил никаких задержек, из за того, что подошел к самому пределу возможностей оборудования того времени. Недавно я посетил его, он стряхнул пыль со старой пятидюймовой дискеты, вставил ее в дисковод и загрузил ту самую программу. Верите или нет, но программа, которой исполнилось более десяти лет, без проблем загрузилась и запустилась в демонстрационном режиме. Мы попытались сыграть и игру, но графические и синхронизирующие функции выполнялись настолько быстро, что на экране мы увидели мешанину из различных изображений. Это выглядело забавно, но в то же время нам стало грустно из-за того, что мы не смогли насладиться игрой. Мораль этой длинной истории такова: всегда помещайте в ваши игры таймеры. (Если вам интересно, игра называлась Chain Reaction.)

Смыслом жизни функции vUpdateUnits() является определение для каждого активного подразделения того, какой кадр анимации должен выводиться следующим. Для этого требуется, чтобы функция в цикле перебирала все активные подразделения, определяла какая анимационная последовательность обновляется, и затем обновляла ее. Есть пять основных действий, которые следует учесть при обновлении данных подразделения:

  • Ожидание
  • Поворот
  • Атака
  • Гибель
  • Перемещение
Обработка ожидающих подразделений

Первое действие представляет собой состояние «ничегонеделанья» или ожидания. Код для его обработки выглядит следующим образом:

ptrUnit->m_iCurStillFrame++;
if(ptrUnit->m_iCurStillFrame >=
   ptrUnit->m_Animation->m_iNumStillFrames)
{
   ptrUnit->m_iCurStillFrame = 0;
}
ptrUnit->m_iCurAnimFrame =
   ptrUnit->m_Animation->m_iStartStillFrames +
   (ptrUnit->m_iCurStillFrame * (UNITMANAGER_MAXOWNERS + 1));

Сперва в коде увеличивается номер текущего кадра ожидания. Это продвигает анимационную последовательность ожидания.

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

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

Обработка поворачивающих подразделений

Второе действие представляет собой разворот подразделения. Вот предназначенный для этого код:

// Поворот
ptrUnit->m_fRot += ptrUnit->m_Movement->m_fTurnSpeed;
// Сброс, если завершен полный разворот
if(ptrUnit->m_fRot > 360.0f)
   ptrUnit->m_fRot -= 360.0f;

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

Следующая строка кода проверяет не превысил ли угол поворота величину 360 градусов. Если да, то из величины угла поворота вычитается 360 градусов. Благодаря этому значение угла никогда не станет слишком большим.

Поскольку вращение происходит без изменения текстуры, текущий кадр анимации в этом коде не устанавливается.

Обработка атакующих подразделений

Третий тип анимации относится к атакующим подразделениям. Код работает точно так же, как и код для обработки ожидающих подразделений — в нем кадр с изображением атакующего подразделения последовательно меняется, пока не будет достигнут конец анимационной последовательности, после чего воспроизведение начинается сначала. А вот и сам код:

ptrUnit->m_iCurAttackFrame++;
if(ptrUnit->m_iCurAttackFrame >=
   ptrUnit->m_Animation->m_iNumAttackFrames)
{
   ptrUnit->m_iCurAttackFrame = 0;
}
ptrUnit->m_iCurAnimFrame =
   ptrUnit->m_Animation->m_iStartAttackFrames +
   (ptrUnit->m_iCurAttackFrame * (UNITMANAGER_MAXOWNERS + 1));
Обработка гибнущих подразделений

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

ptrUnit->m_iCurDieFrame++;
if(ptrUnit->m_iCurDieFrame >= ptrUnit->m_Animation->m_iNumDieFrames) {
   ptrUnit->m_iCurDieFrame = 0;
}
ptrUnit->m_iCurAnimFrame =
   ptrUnit->m_Animation->m_iStartDieFrames +
   (ptrUnit->m_iCurDieFrame * (UNITMANAGER_MAXOWNERS + 1));

Обычно эта анимационная последовательность воспроизводится когда подразделение взрывается в блеске славы.

Обработка перемещающихся подразделений

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

ptrUnit->m_iCurMoveFrame++;
if(ptrUnit->m_iCurMoveFrame >=
   ptrUnit->m_Animation->m_iNumMoveFrames) {
   ptrUnit->m_iCurMoveFrame = 0;
}
ptrUnit->m_iCurAnimFrame =
   ptrUnit->m_Animation->m_iStartMoveFrames +
   (ptrUnit->m_iCurMoveFrame * (UNITMANAGER_MAXOWNERS + 1));

Если вы хотите добавить код для передвижения, попытайтесь увеличивать координату Y подразделения, пока изображение не выйдет за границу экрана, а затем повторить цикл с низу экрана. Мне кажется, вы разочарованы? Вот код с добавленной логикой для передвижения:

// Передвижение подразделения
ptrUnit->m_fYPos += ptrUnit->m_Movement->m_fMovementSpeed;
// Если вышли за верхнюю границу экрана, начинаем заново снизу
if(ptrUnit->m_fYPos > 360.0f)
   ptrUnit->m_fYPos = -360.0f;

Приведенный выше код увеличивает координату Y подразделения, пока оно не скроется из виду, а затем устанавливает координаты подразделения, чтобы оно появилось в нижней части экрана. Это очень простой вариант перемещения снизу вверх, но он открывает перед вами много возможностей.


Глава 8 - Загрузка и создание подразделений

Загрузка и создание подразделений

Я показал вам как написать классы подразделений, управлять подразделениями и анимировать их. Последняя тема, которую я затрону — загрузка и создание новых подразделений. В рассматриваемом примере программы есть функция с именем vInitializeUnits(). Она отвечает за загрузку информации о базовых типах и добавляет в игру несколько активных подразделений. Вот ее код:

void CD3DFramework::vInitializeUnits(void)
{
   int iUnit;

   // Инициализация диспетчера подразделений
   m_UnitManager.vReset();

   // Установка устройства Drect3D
   m_UnitManager.vSetRenderDevice (m_pd3dDevice);

   // Импорт базовых данных подразделений
   m_UnitManager.iLoadBaseTypes(
          "UnitData\\BaseType_Defense.csv",
          "UnitData\\BaseType_Offense.csv",
          "UnitData\\BaseType_Movement.csv",
          "UnitData\\BaseType_Unit.csv",
          "UnitData\\BaseType_Animation.csv");

   // Добавление нескольких подразделений к "игре"
   iUnit = m_UnitManager.iAddUnit("Apache Attack Helicopter", 0);
   m_UnitManager.m_UnitObjs[iUnit].vSetPosition(-180.0f, -80.0f);
   iUnit = m_UnitManager.iAddUnit("Apache Attack Helicopter", 1);
   m_UnitManager.m_UnitObjs[iUnit].vSetPosition(-70.0f, -80.0f);

   iUnit = m_UnitManager.iAddUnit("Spirit Scout Helicopter", 2);
   m_UnitManager.m_UnitObjs[iUnit].vSetPosition(50.0f, -80.0f);

   iUnit = m_UnitManager.iAddUnit("Spirit Scout Helicopter", 3);
   m_UnitManager.m_UnitObjs[iUnit].vSetPosition(180.0f, -80.0f);
}

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

Затем для диспетчера подразделений я устанавливаю указатель на устройство визуализации DirectX. Если вы помните, ранее говорилось, что это необходимо для загрузки текстур.

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

Пришло время для веселья! Следующая часть кода создает подразделения с помощью функции добавления подразделений. Диспетчер создает и активирует запрошенные подразделения, чтобы они появились в игровом цикле. Сразу после создания каждого подразделения я инициализирую данные о его местоположении, чтобы подразделение появилось в требуемом месте экрана. Обратите внимание, создавая подразделения я назначаю каждому из них собственный цвет владельца. Это позволит вам увидеть как различные цвета владельца отображаются во время визуализации.

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


Глава 8 - Итоги

Итоги

Мы уже достигли конца очередной главы? Это произошло так быстро! Вот несколько секретов, которые мы узнали в этой главе:

  • Вот четыре блока, которые являются фунндаментом разработки подразделений: атака, защита, передвижение и анимация.
  • Базовые типы подразделений помогают сократить объем используемой памяти.
  • Базовые типы подразделений помогают организовывать подразделения в группы.
  • Диспетчер подразделений упрощает контроль за подразделениями.
  • Использование текстовых файлов для хранения данных подразделений поможет сделать вашу игру более гибкой.


Глава 9. За кулисами клавиатуры

Краткий обзор

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

  • Обзор DirectInput.
  • Ввод с клавиатуры.
  • Ввод текста в игре.


Глава 9 - Обзор DirectInput

Обзор DirectInput

DirectInput — это часть DirectX, которая обрабатывает все формы ввода от игрока. Вы можете управлять мышью, клавиатурой, джойстиками, устройствами с обратной связью и многими другими типами устройств ввода. Для каждого типа контроллера имеется связанный с ним объект устройства. Для каждого объекта устройства вы создаете его экземпляр. Все это показано на рис. 9.1.

Рис. 9.1. Объекты DirectInput

Рис. 9.1. Объекты DirectInput

На рис. 9.1 изображен главный объект DirectInput с двумя объектами устройств. Левый объект является мышью, а правый — клавиатурой. Под мышью изображен экземпляр объекта устройства, представляющий кнопки мыши. Под клавиатурой находится экземпляр объекта устройства, представляющий клавиши клавиатуры.

Интерфейс IDirectInput8

Рабочей лошадкой DirectInput является интерфейс IDirectInput8. Это COM-объект, отвечающий за настройку среды ввода. После того, как вы создали объект DirectInput можно создавать устройства для объекта. Как же можно создать этот объект? С помощью следующего кода:

if(FAILED(hReturn = DirectInput8Create(
   g_hInstance, DIRECTINPUT_VERSION,
   IID_IDirectInput8, (VOID**)&pDI, NULL))) {
   return(INPUTERROR_NODI);
}

Из приведенного кода видно, что объект DirectInput создает функция DirectInput8Create(). Вот как выглядит прототип этой функции:

HRESULT WINAPI DirectInput8Create(
   HINSTANCE hinst,
   DWORD dwVersion,
   REFIID riidltf,
   LPVOID *ppvOut,
   LPUNKNOWN punkOuter
);

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

Следующий параметр, dwVersion, содержит номер версии DirectInput, которую вы намереваетесь использовать. В приведенном выше примере я использую глобальную константу с именем DIRECTINPUT_VERSION. Ее значение равно 0x0800, и это значит, что мы намереваемся использовать DirectInput версии 8.

ПРИМЕЧАНИЕ
Хотя эта книга посвящена использованию DirectX 9, компонент DirectInput не изменялся с версии 8.

Третий параметр, riidltf, передает уникальный идентификатор интерфейса. Для DirectX 8 и 9 вы должны использовать идентификатор IID_IDirectInput8.

Четвертый параметр, ppvOut, содержит адрес указателя в котором будет сохранена ссылка на объект DirectInput. Для этого параметра я использую глобальный указатель с именем pDI. Тип указателя pDI — LPDIRECTINPUT8.

Последний параметр, punkOuter, используется для указания на интерфейс IUnknown COM-объекта. Я всегда передаю в этом параметре значение NULL, и вы можете поступать так же.

Если работа функции завершена успешно, она возвращает значение DI_OK.

Интерфейс IDirectInputDevice8

Объект DirectInput создает устройства в виде объектов интерфейса IDirectInputDevice8. Интерфейс IDirectInputDevice8 выполняет большую часть работы по поддержке конкретного устройства. Чтобы создать интерфейс устройства вы должны вызвать метод CreateDevice() главного объекта DirectInput. Вот как выглядит его прототип:

HRESULT CreateDevice(
   REFGUID rguid,
   LPDIRECTINPUTDEVICE *lplpDirectInputDevice,
   LPUNKNOWN pUnkOuter
);

В первом параметре, rguid, передается GUID создаваемого устройства. Для этой цели каждый тип устройств в DirectX имеет свой собственный GUID. Если вы хотите создать интерфейс клавиатуры, передайте в этом параметре идентификатор GUID_SysKeyboard. Чтобы создать интерфейс мыши, передайте идентификатор GUID_SysMouse.

Второй параметр, lplpDirectInputDevice, представляет собой указатель на указатель на создаваемое новое устройство. В своих примерах я передаю указатель с именем pKeyboard типа LPDIRECTINPUTDEVICE8.

Последний параметр применяется для COM, и большинство людей просто передают здесь NULL.

В случае успешного завершения функция возвращает значение DI_OK.


Глава 9 - Ввод с клавиатуры

Ввод с клавиатуры

То, что вы прочитали можно назвать самым коротким обзором DirectInput. Причина подобной краткости в том, что стратегические игры не требуют сложных устройств ввода. Нет никакой необходимости использовать устройства с обратной связью, джойстики, игровые пульты и другие подобные устройства. В стратегических играх непревзойденными остаются старые добрые клавиатура и мышь.

Проект DInput_Simple

В сопроводительные файлы книги включен проект DInput_Simple. Он строит небольшое приложение, создающее объект клавиатуры и читающее поступающие от него данные. Окно программы показано на рис. 9.2.

Рис. 9.2. Окно программы DInput_Simple

Рис. 9.2. Окно программы DInput_Simple

На рис. 9.2 изображено обычное окно с текстом, сообщающим, что для выхода из программы следует нажать на клавишу Esc. Вместо того, чтобы использовать для перехвата нажатия на клавишу Esc сообщения Windows, мы воспользуемся DirectInput и устройством клавиатуры. Теперь загрузите проект и следуйте за мной далее.

Проект содержит два файла: main.cpp и main.h. В файле main.cpp находится код реализации функций, а в заголовочном файле main.h сосредоточена вся заголовочная информация. Проекту необходимы две библиотеки: dxguid.lib и dinput8.lib. Библиотека dxguid.lib содержит уникальные GUID для устройств DirectInput. В библиотеке dinput8.lib находятся сами функции DirectInput.

Инициализация DirectInput

Откройте файл main.cpp и найдите код функции WinMain(). В ней вы найдете обычный код создания объектов Windows, за которым следует код инициализации DirectInput и устройства клавиатуры, выглядящий так:

// Инициализация DirectInput
iResult = iInitDirectInput();
if(iResult != INPUTERROR_SUCCESS) {
   MessageBox(hWnd, "DirectInput Error",
   "Unable to initialize Direct Input.", MB_ICONERROR);
   vCleanup();
   exit(1);
}
// Инициализация клавиатуры DI
iResult = iInitKeyboard(hWnd);
if(iResult != INPUTERROR_SUCCESS) {
       MessageBox(hWnd, "DirectInput Error",
       "Unable to initialize Keyboard.", MB_ICONERROR);
   vCleanup();
   exit(1);
}

В приведенном выше коде вызываются две функции: iInitDirectInput() и iInitKeyboard(). Вызов первой из них инициализирует главный объект DirectInput, а вызов второй создает устройство клавиатуры. Увидеть ход выполнения программы можно на рис. 9.3.

Рис. 9.3. Ход выполнения программы DInput_Simple

Рис. 9.3. Ход выполнения программы DInput_Simple

Функция iInitDirectInput()

Функция iInitDirectInput() — это мое собственное творение и я использую ее для создания главного объекта DirectInput. Код, используемый мной для создания упомянутого объекта должен выглядеть для вас очень знакомым, поскольку я уже описывал его в предыдущем разделе главы. Здесь я привожу полный код функции:

int iInitDirectInput(void)
{
   HRESULT hReturn;

   // Не пытаться создать Direct Input, если он уже создан
   if(!pDI) {
      // Создаем объект DInput
      if(FAILED(hReturn = DirectInput8Create(
         g_hInstance, DIRECTINPUT_VERSION,
         IID_IDirectInput8, (VOID**)&pDI, NULL))) {
         return(INPUTERROR_NODI);
      }
   }
   else {
      return(INPUTERROR_DI_EXISTS);
   }
   return(INPUTERROR_SUCCESS);
}

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

В следующем блоке кода выполняется вызов функции DirectInput8Create() для создания объекта DirectInput. Как только он будет успешно выполнен, моя функция возвращает WinMain() код успешного завершения. В результате этих действий глобальный указатель pDI будет содержать ссылку на созданный при вызове функции объект DirectInput.

Функция iInitKeyboard()

Теперь, когда у нас есть действующий объект ввода в форме глобального указателя pDI, можно создать интерфейс объекта клавиатуры. Здесь выходит на сцену моя функция iInitKeyboard(). В ней я создаю устройство клавиатуры, устанавливаю буфер клавиатуры, задаю режим доступа, захватываю клавиатуру и получаю раскладку клавиатуры. Вот как выглядит код функции:

int iInitKeyboard(HWND hWnd)
{
   HRESULT         hReturn = 0;
   DIPROPDWORD     dipdw;

   // Не пытайтесь создать клавиатуру дважды
   if(pKeyboard) {
      return(INPUTERROR_KEYBOARDEXISTS);
   }
   // Выход, если не найден интерфейс DirectInput
   else if (!pDI) {
      return(INPUTERROR_NODI);
   }

   // Получаем интерфейс устройства системной клавиатуры
   if(FAILED(hReturn = pDI->CreateDevice(
      GUID_SysKeyboard,
      &pKeyboard,
      NULL))) {
      return(INPUTERROR_NOKEYBOARD);
   }

   // Создаем буфер для хранения данных клавиатуры
   ZeroMemory(&dipdw, sizeof(DIPROPDWORD));
   dipdw.diph.dwSize      = sizeof(DIPROPDWORD);
   dipdw.diph.dwHeaderSize = sizeof(DIPROPHEADER);
   dipdw.diph.dwObj       = 0;
   dipdw.diph.dwHow       = DIPH_DEVICE;
   dipdw.dwData           = KEYBOARD_BUFFERSIZE;

   // Устанавливаем размер буфера
   if(FAILED(hReturn = pKeyboard->SetProperty(
      DIPROP_BUFFERSIZE,
      &dipdw.diph))) {
      return(INPUTERROR_NOKEYBOARD);
   }
   // Устанавливаем формат данных клавиатуры
   if(FAILED(hReturn = pKeyboard->SetDataFormat(
      &c_dfDIKeyboard))) {
      return(INPUTERROR_NOKEYBOARD);
   }
   // Устанавливаем уровень кооперации для монопольного доступа
   if(FAILED(hReturn = pKeyboard->SetCooperativeLevel(
      hWnd,
      DISCL_NONEXCLUSIVE | DISCL_FOREGROUND
      ))) {
      return(INPUTERROR_NOKEYBOARD);
   }
   // Захватываем устройство клавиатуры
   pKeyboard->Acquire();
   // Получаем раскладку клавиатуры
   g_Layout = GetKeyboardLayout(0);
   return(INPUTERROR_SUCCESS);
}

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

Первая часть кода проверяет не проинициализирован ли уже указатель pKeyboard. Если да, объект клавиатуры уже создан ранее и функция возвращает код ошибки, извещающий нас об этом. В следующей проверке мы убеждаемся, что существует объект ввода pDI. Если инициализация DirectInput не выполнена, нет смысла пытаться создать объект клавиатуры!

Как только необходимые проверки успешно пройдены, я вызываю функцию CreateDevice() для создания устройства клавиатуры. Ранее я уже описывал эту функцию, так что код должен выглядеть для вас очень знакомо.

Буферизованный ввод с клавиатуры

Следующая часть функции может показаться вам странной, поскольку пока я еще не объяснил ее назначение. Дело в том, что для клавиатуры имеется два способа получения входных данных: непосредственный и буферизованный. Непосредственный ввод позволяет получить состояние клавиш на момент опроса. Если пользователь нажал клавишу хотя бы на 1/100 секунды раньше, это событие будет пропущено, поскольку оно не произошло именно в тот момент, когда выполнялась проверка. В игре это представляет серьезную проблему, поскольку циклы визуализации и обработки данных отнимают много времени, что может привести к частой потере вводимых данных. Данный момент проиллюстрирован на рис. 9.4.

Рис. 9.4. Непосредственное чтение данных клавиатуры

Рис. 9.4. Непосредственное чтение данных клавиатуры

На рис. 9.4 видно, что программа обработала только нажатие клавиши L, поскольку возвращаются только данные о непосредственно нажатых клавишах.

Вы когда-нибудь играли в игру, которая в половине случаев игнорирует нажатия на клавиши? Наиболее часто нажатия клавиш теряются когда процессор загружен выводом графики или какими-нибудь другими задачами. Причина пропуска изменений состояний клавиш заключается в том, что программа не использует буферизованный ввод, который позволяет системе обработать каждое изменение состояний клавиш, произошедшее с момента последнего опроса устройства. Буферизованный ввод показан на рис. 9.5.

Рис. 9.5. Буферизованный ввод с клавиатуры

Рис. 9.5. Буферизованный ввод с клавиатуры

На рис. 9.5 показан тот же процесс, что и на рис. 9.4, за исключением того, что функция чтения с клавиатуры получает каждое нажатие клавиш, произошедшее с начала игрового цикла. Это более мощный метод, чем непосредственный захват, и я предлагаю вам всегда использовать его.

Функция IDirectInputDevice8::SetProperty()

Реализация буферизованного ввода достаточно проста — достаточно установить свойство устройства клавиатуры. Это осуществляется с помощью функции установки свойств. Вот как выглядит ее прототип:

HRESULT SetProperty(
   REFGUID rguidProp,
   LPCDIPROPHEADER pdiph
);

Первый параметр, rguidProp, является GUID того свойства устройства, которое вы хотите установить. Чтобы установить размер буфера устройства используйте значение DIPROP_BUFFERSIZE.

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

dipdw.dwData = KEYBOARD_BUFFERSIZE;

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

Установка формата данных клавиатуры

Затем вы должны задать формат данных клавиатуры. Это простая формальность, для соблюдения которой достаточно вызвать функцию IDirectInputDevice8::SetDataFormat(). Функция получает один параметр, задающий формат данных устройства. Для клавиатуры используйте значение c_dfDIKeyboard. Если же вам необходимо задать формат данных для мыши, воспользуйтесь значением c_dfDIMouse.

Установка уровня кооперации

Поскольку DirectX предоставляет прямой доступ к аппаратуре, очень важен уровень кооперации устройства. Он определяет как программа может использовать данный ресурс совместно с другими приложениями. Если вы установите монопольный режим, больше никто не сможет воспользоваться данным ресурсом. Если вы установите совместный режим, то доступ к клавиатуре смогут получить все желающие. Уверен, вы можете вспомнить игры, которые не делят клавиатуру ни с кем. Мне на ум приходит EverQuest. Поскольку создатели игры не хотели, чтобы сторонние разработчики писали приложения для их игры, они заблокировали использование клавиатуры вне их программы. Это не слишком хорошо и может вызвать настоящие проблемы, если вы переключитесь из игры на другое приложение, чтобы проверить почту или сделать что-нибудь еще.

Для установки уровня кооперации применяется функция IDirectInputDevice8::SetCooperativeLevel(). Вот ее прототип:

HRESULT SetCooperativeLevel(
   HWND hwnd,
   DWORD dwFlags
);

В ее первом параметре, hwnd, передается дескриптор окна, которое будет связано с устройством. Я в этом параметре передаю дескриптор, который был возвращен мне при создании главного окна.

Второй параметр, dwFlags, задает уровень кооперации устройства. Доступные уровни перечислены в таблице 9.1.

Таблица 9.1. Уровни кооперации устройств
Значение | Описание
DISCL_BACKGROUND | Доступ к клавиатуре будет предоставлен даже если окно свернуто.
DISCL_EXCLUSIVE | Предоставляется монопольный доступ к клавиатуре, для всех остальных клавиатура недоступна.
DISCL_FOREGROUND | Доступ к данным клавиатуры предоставляется только когда окно активно.
DISCL_NONEXCLUSIVE | Устройство используется совместно с другими программами.
DISCL_NOWINKEY | Блокирует клавишу Windows.

Для рассматриваемого примера я устанавливаю флаги уровня кооперации DISCL_NONEXCLUSIVE и DISCL_FOREGROUND. Благодаря этому программа использует клавиатуру совместно с другими приложениями, а сама может читать данные клавиатуры только когда ее окно активно.

Захват клавиатуры

Последний, относящийся к DirectX этап — вызов функции IDirectInputDevice8::Acquire(). Эта функция необходима для привязки приложения к используемому им устройству ввода. Всякий раз когда окно теряет фокус клавиатура должна быть захвачена снова.

Раскладка клавиатуры

В рассматриваемом примере я покажу вам как считывать коды клавиш DirectInput и ASCII-коды клавиш. Чтобы получить возможность преобразования кодов DIK в коды ASCII вы должны вызвать функцию GetKeyboardLayout(). Она получает раскладку подключенной к системе клавиатуры для дальнейшего использования.

ПРИМЕЧАНИЕ
Функция GetKeyboardLayout() не является необходимой для работы кода DirectInput. Я применяю ее только для преобразования кодов DIK в коды ASCII.

Этапы, необходимые для инициализации клавиатуры, показаны на рис. 9.6.

Рис. 9.6. Этапы инициализации клавиатуры

Рис. 9.6. Этапы инициализации клавиатуры

Чтение данных клавиатуры

Вернемся к функции WinMain() и рассмотрим следующий фрагмент кода:

while(msg.message != WM_QUIT) {
   if(PeekMessage(&msg, NULL, 0U, 0U, PM_REMOVE)) {
      TranslateMessage(&msg);
      DispatchMessage(&msg);
      }
      else {
         // Чтение из буфера клавиатуры
         iResult = iReadKeyboard();
         // Проверяем, сколько нажатий на клавиши возвращено
         if(iResult) {
            // Цикл обработки полученных данных
            for(i = 0; i < iResult; i++) {
               // Выход из программы, если нажата клавиша ESC
               if(diks[DIK_ESCAPE][i]) {
                  PostQuitMessage(0);
               }
               else if (ascKeys[13][i]) {
                  PostQuitMessage(0);
               }
            }
         }
      }
   }

Представленный код является стандартным циклом обработки сообщений Windows. Его ключевой особенностью является вызов функции iReadKeyboard(). Обращение к ней происходит каждый раз, когда в очереди нет системных сообщений для обработки. Функция возвращает количество зафиксированных изменений состояний клавиш и сохраняет их в глобальных массивах diks и ascKeys. Если функция возвратила какие-нибудь данные, программа в цикле перебирает полученные изменения состояний клавиш и проверяет не была ли нажата клавиша Esc. Если клавиша была нажата, выполнение программы завершается.

Функция iReadKeyboard()

Вместо того, чтобы одним махом показать вам весь код функции, я разделил его на небольшие кусочки. Вот первый фрагмент функции iReadKeyboard():

if(!pKeyboard || !pDI) {
   return(INPUTERROR_NOKEYBOARD);
}

Этот маленький фрагмент кода проверяет существуют ли объекты клавиатуры и DirectInput. Если какого-нибудь из них нет, функция возвращает код ошибки. Пришло время следующего фрагмента:

hr = pKeyboard->GetDeviceData(
        sizeof(DIDEVICEOBJECTDATA),
        didKeyboardBuffer,
        &dwItems,
        0);

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

// Клавиатуа может быть потеряна, захватить устройство снова
if(FAILED(hr)) {
   pKeyboard->Acquire();
   return(INPUTERROR_SUCCESS);
}

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

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

// Если есть данные, обработаем их
if (dwItems) {
   // Обработка данных
   for(dwCurBuffer = 0; dwCurBuffer < dwItems; dwCurBuffer++) {
      // Преобразование скан-кода в код ASCII
      byteASCII = Scan2Ascii(
      didKeyboardBuffer[dwCurBuffer].dwOfs);

      // Указываем, что клавиша нажата
      if(didKeyboardBuffer[dwCurBuffer].dwData & 0x80) {
         ascKeys[byteASCII][dwCurBuffer] = 1;
         diks[didKeyboardBuffer[dwCurBuffer].dwOfs]
         [dwCurBuffer] = 1;
      }
      // Указываем, что клавиша отпущена
      else {
         ascKeys[byteASCII][dwCurBuffer] = 0;
         diks[didKeyboardBuffer[dwCurBuffer].dwOfs]
         [dwCurBuffer] = 0;
      }
   }
}

Код проверяет были ли возвращены какие-нибудь данные функцией получения данных от устройства. Если да, код в цикле перебирает возвращенные элементы в буфере и сохраняет результаты в глобальных массивах diks и ascKeys.

Определение состояния DIK

Массив didKeyboardBuffer хранит данные возвращенные DirectInput. Чтобы сделать их читаемыми, необходимо проверить значение каждого элемента массива. Если результат поразрядной логической операции И над возвращенным значением и константой 0x80 не равен нулю, значит клавиша была нажата; в ином случае клавиша была отпущена. Я знаю, это выглядит причудливо, но именно так работает DirectInput!

Преобразование кода DIK в код ASCII

Для преобразования кодов DIK в коды ASCII я написал следующую функцию:

BYTE Scan2Ascii(DWORD scancode)
{
   UINT vk;

   // Преобразование скан-кода в код ASCII
   vk = MapVirtualKeyEx(scancode, 1, g_Layout);
   // Возвращаем код ASCII
   return(vk);
}

Функция получает код клавиши DirectInput и вызывает функцию MapVirtualKeyEx() для преобразования его в ASCII. Для работы функции отображения кодов необходимы данные о раскладке клавиатуры, которые мы получили на этапе инициализации.


Глава 9 - Ввод текста в игре

Ввод текста в игре

Решение задачи ввода текста в игре может казаться лежащим на поверхности, но есть множество моментов, на которые следует обратить внимание. Как, например, вы будете обрабатывать ввод текста в разгаре игры? Стратегическая игра реального времени останавливающаяся каждый раз, когда пользователь хочет ввести текст, выглядит не слишком хорошо! Кроме того, стоит выбрать способ отображения текста. Что вы будете применять для отображения текста: двухмерные шрифты или трехмерные карты текстур? Читайте дальше и вы найдете ответы на эти и другие вопросы.

Для начала взгляните на рис. 9.7, где изображен пример ввода текста в игре.

Рис. 9.7. Пример ввода текста в игре

Рис. 9.7. Пример ввода текста в игре

На рис. 9.7 вы видите уже ставший знакомым интерфейс игры Battle Armor с полем ввода текста в центре экрана. В это поле вводится имя игрока. Обратите внимание, что я ввел в это поле строку «Lost Logic» и курсор находится в конце введенного текста. Все графические элементы должны быть вручную обработаны в вашей игре, так что читайте дальше, чтобы выяснить как это происходит.

Откройте проект с именем D3D_InputBox чтобы увидеть код, создающий окно, изображенное на рис. 9.7. Этот проект является вариантом рассмотренного ранее проекта игрового интерейса, так что большая часть кода должна выглядеть знакомо. После загрузки проекта взгляните на рис. 9.8, где изображен ход выполнения программы. На рис. 9.8 видно, что программа инициализирует DirectInput, клавиатуру, Direct3D, объекты интерфейса и активные зоны. После завершения цинициализации программа входит в цикл обработки сообщений где проверяет поступающие данные и отображает графику.

Рис. 9.8. Ход выполнения программы D3D_InputBox

Рис. 9.8. Ход выполнения программы D3D_InputBox

Навигация по меню

Вы можете вспомнить, как в главе 6 навигация по меню была реализована в функции проверки ввода. Внутри нее программа смотрела, активирована ли какая-нибудь из зон меню. Если да, код определял какой из пунктов меню выбран и выполнял соответствующие действия. Взгляните на приведенный ниже код:

if(g_iCurrentScreen == 0) {
   // Переход к главному меню
   if(!stricmp(szZoneHit, "TITLE_SCREEN")) {
      // Делаем главное меню активным
      g_iCurrentScreen = 1;
      // Устанавливаем активные зоны
      vSetupMouseZones(1);
   }
   // Переход к экрану завершения игры
   else if(!stricmp(szZoneHit, "EXIT_BUTTON")) {
      // Делаем экран завершения текущим
      g_iCurrentScreen = 2;
      // Устанавливаем активные зоны
      vSetupMouseZones(2);
   }
}

Приведенный код выполняется когда активен титульный экран. Если пользователь выбирает активную зону TITLE_SCREEN, программа делает активным экран главного меню и устанавливает активные зоны для него. Если выбрана кнопка Exit, код активирует экран выхода из игры и устанавливает активные зоны для него. Такие же действия выполняются в коде для каждого доступного пункта меню. Ход выполнения функции проверки ввода показан на рис. 9.9.

Рис. 9.9. Структура функции проверки ввода

Рис. 9.9. Структура функции проверки ввода

На рис. 9.9 видно как функция проверки ввода проверяет активные зоны, в случае необходимости обновляет меню, а также проверяет клавиатуру. Ключевой особенностью рассматриваемого примера является активная зона MAINMENU_NEWGAME. При активации данной зоны программа вызывает функцию установки активных зон для инициализации экрана начала новой игры.

Активация ввода текста

Функция установки активных зон отвечает за размещение в интерфейсе игры горячих точек, реагирующих на щелчки кнопок мыши. Помимо этого она применяется для активации ввода текста в экране начала новой игры. Приведенный ниже код покажет вам, как я выполняю эти действия:

// Экран начала новой игры
else if(iMenu == 4) {
   MZones.vFreeZones();
   MZones.vInitialize(1);
   MZones.iAddZone("EXIT_BUTTON", 587, 0, 53, 24, 0);
   //
   // Установка поля ввода текста
   //
   // Установка позиции курсора
   g_shTextInputXPos = 200;
   g_shTextInputYPos = 196;
   // Очистка текста
   memset(g_szTextInputBuffer, 0x00, 64);
   // Установка позиции ввода данных
   g_shTextInputPosition = 0;
   // Установка активного поля данных
   g_iTextInputFieldID = GAMEINPUT_NAME;
   // Установка флага активности поля ввода
   g_bTextInputActive = 1;
   // Установка таймера мерцания курсора
   g_dwTextInputTimer = 0;
   // Установка состояния мерцания курсора
   g_bTextInputCursorFlash = 0;
   // Установка максимальной длинны текста: 20 символов
   g_shTextMaxSize = 20;
}

Приведенный выше код — всего лишь фрагмент функции vSetupMouseZones(), но он показывает вам как я сообщаю программе о необходимости принять вводимое с клавиатуры имя игрока. Код не только устанавливает активные зоны для экрана начала новой игры, но и присваивает значения нескольким глобальным переменным, которые сообщают программе, как обрабатывать вводимый текст. Эти переменные и их назначение перечислены в таблице 9.2.

Таблица 9.2. Глобальные переменные, управляющие вводом текста
Переменная | Описание
g_shTextInputXPos | Координата X текстового поля ввода.
g_shTextInputYPos | Координата Y текстового поля ввода.
g_szTextInputBuffer | Хранит содержимое текстового поля.
g_shTextInputPosition | Активная позиция в текстовом поле.
g_iTextInputFieldID | Следит, какое текстовое поле активно.
g_bTextInputActive | Сообщает системе, что текстовый ввод включен.
g_dwTextInputTimer | Таймер для анимации курсора в активном текстовом поле.
g_bTextInputCursorFlash | Определяет включен курсор или выключен во время мерцания.
g_shTextMaxSize | Максимальное количество символов в буфере.

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

Затем устанавливается позиция ввода текста. Я присваиваю этой переменной 0, чтобы игрок вводил текст с начала буфера имени.

Далее идентификатору поля присваивается значение GAMEINPUT_NAME. В заголовочном файле main.h у меня есть ряд констант, соответствующих присутствующим в игре текстовым полям. Вы не обязаны использовать константы, но мне они помогают следить за тем, что происходит в программе.

Потом я присваиваю полю g_bTextInputActive значение 1. Оно сообщает программе, что текстовое поле активно и ожидает ввод. Это важно знать, так как программа должна добавлять текст в поле и отображать его.

После того, как текстовое поле активизировано, я присваиваю 0 переменной g_dwTextInputTimer. Данный таймер отвечает за анимацию курсора. Следующая переменная, g_bTextInputCursorFlash, определяет включен курсор или выключен. Когда таймер курсора заканчивает отсчет она меняет свое состояние.

Последнее, что требуется сделать для инициализации текстового ввода — задать максимальное количество символов в имени игрока. Я делаю это присваивая переменной g_shTextMaxSize значение 20.

Обработка текстового ввода

Теперь, когда система текстового ввода активизирована, вам необходимо обработать поступающие от клавиатуры данные и сохранить результат в буфере символов. Этим занимается функция vCheckInput(). Раньше я уже показывал вам часть функции обработки ввода, которая обрабатывает поступающие от мыши данные. Сейчас пришло время взглянуть на ту часть функции, которая обрабатывает данные клавиатуры. Вот ее код:

// ВВОД С КЛАВИАТУРЫ
// Чтение из буфера клавиатуры
int iResult = iReadKeyboard();
// Проверяем, сколько нажатий на клавиши возвращено
if(iResult) {
   // Цикл перебора полученных данных
   for(int i = 0; i < iResult; i++) {
      // Выход из программы, если нажата клавиша ESC
      if(diks[DIK_ESCAPE][i]) {
         PostQuitMessage(0);
      }
      // ЛОГИКА РАБОТЫ ТЕКСТОВОГО ВВОДА
      if(g_bTextInputActive) {
         // Не сохранять текст, если для него нет места в буфере
         if(g_shTextInputPosition < g_shTextMaxSize) {
            // Сохраняем отпущенные клавиши
            for(int j = 32; j < 123; j++) {
               // Проверяем, что введен допустимый символ
               if((j > 96)
                  || (j == 32)
                  || (j > 47 && j < 58)) {
                  // Проверяем, что клавиша отпущена
                  if(ascKeys[j][i]) {
                     if(g_bShift) {
                        g_szTextInputBuffer[
                        g_shTextInputPosition]
                        = toupper(j);
                     }
                     else {
                        g_szTextInputBuffer[g_shTextInputPosition] = j;
                     }
                     g_shTextInputPosition++;
                  }
               }
            }
         }
         // Проверяем не нажата ли клавиша удаления символа
         if(diks[DIK_BACK][i]) {
            // Проверяем введен ли какой-нибудь текст
            if(g_shTextInputPosition) {
               // Удаляем последний символ
               g_szTextInputBuffer[g_shTextInputPosition - 1] = '\0';
               // Сдвигаем курсор назад
               g_shTextInputPosition--;
            }
         }
         // Проверяем не нажата ли клавиша ENTER
         if(diks[DIK_RETURN][i]) {
            // Завершаем ввод имени
            g_bTextInputActive = 0;
            // АКТИВАЦИЯ НОВОЙ ИГРЫ
            if(g_iTextInputFieldID == GAMEINPUT_NAME) {
               // Делаем текущим основной игровой экран
               g_iCurrentScreen = 5;
               // Устанавливаем активные зоны
               vSetupMouseZones(5);
            }
            break;
         }
      }
   }
}

Это достаточно большой фрагмент кода, так что взгляните на рис. 9.10.

Рис. 9.10. Блок-схема ввода данных с клавиатуры

Рис. 9.10. Блок-схема ввода данных с клавиатуры

На рис. 9.10 показана логика, необходимая для получения данных от клавиатуры и их помещения в текстовое поле. Начнем сверху: программа вызывает функцию чтения с клавиатуры, чтобы проверить есть ли какие-либо ожидающие обработки данные. Если есть, система в цикле перебирает полученные данные и выполняет ряд проверок. Сперва проверяется не была ли нажата клавиша Esc. Если да, программа помещает в очередь сообщение о выходе и завершает работу. Если нет, работа продолжается и выполняется проверка активности текстового поля. Если текстовое поле активно, система проверяет осталось ли в текстовом поле свободное место для ввода очередного символа. Если свободное место обнаружено, программа в цикле перебирает все клавиши клавиатуры и проверяет состояние каждой из них. Если проверяемая на данной итерации цикла клавиша является алфавитно-цифровой или пробелом, программа проверяет, была ли данная клавиша отпущена. Если клавиша была отпущена, проверяется нажата ли клавиша Shift. Если да, программа помещает в буфер имени игрока символ данной клавиши в верхнем регистре. Если клавиша Shift не нажата, в буфер помещается полученный по умолчанию символ. Данный процесс повторяется, пока не будут обработаны все состояния клавиш, находящиеся в буфере DirectInput.

Кроме того, на рис. 9.10 изображены проверки нажатия клавиш Backspace и Enter. Если игрок нажимает клавишу Backspace, программа удаляет последний символ в буфере имени игрока и передвигает курсор на одну позицию назад. Если нажата клавиша Enter, программа переходик к экрану новой игры и деактивирует текстовый ввод.

Отображение введенного текста

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

// Отображение экрана новой игры
vDrawInterfaceObject(0, 0, 256.0f, 256.0f, 0);
vDrawInterfaceObject(256, 0, 256.0f, 256.0f, 1);
vDrawInterfaceObject(512, 0, 256.0f, 256.0f, 2);
vDrawInterfaceObject(0, 256, 256.0f, 256.0f, 3);
vDrawInterfaceObject(256, 256, 256.0f, 256.0f, 4);
vDrawInterfaceObject(512, 256, 256.0f, 256.0f, 5);
// Поле ввода
vDrawInterfaceObject(192, 64, 256.0f, 256.0f, 14);
// Отображаем курсор, если ввод активен
if(g_bTextInputActive) {
   // Обновление состояния мерцания курсора
   if(timeGetTime() > g_dwTextInputTimer) {
      if(g_bTextInputCursorFlash) {
         g_bTextInputCursorFlash = 0;
         g_dwTextInputTimer = timeGetTime() + 250;
      }
      else {
          g_bTextInputCursorFlash = 1;
          g_dwTextInputTimer = timeGetTime() + 250;
      }
   }
   // Рисуем курсор, если он не скрыт
   if(g_bTextInputCursorFlash) {
      vDrawInterfaceObject(g_shTextInputXPos + g_shTextInputPosition * 8,
                           g_shTextInputYPos, 4.0f, 16.0f, 15);
   }
}
// Отображение текста
// Создаем прямоугольник для текста
RECT rectText = { g_shTextInputXPos,
                  g_shTextInputYPos,
                  g_shTextInputXPos + (g_shTextMaxSize * 8),
                  g_shTextInputYPos + 20 };
// Выводим текст
pD3DXFont->DrawText(g_szTextInputBuffer, -1, &rectText,
                       DT_LEFT, D3DCOLOR_RGBA(255, 255, 255, 255));

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

Рис. 9.11. Ход выполнения процедуры отображения текста

Рис. 9.11. Ход выполнения процедуры отображения текста

На рис 9.11 видно как функция визуализации проверяет номер текущего экрана, чтобы выяснить что именно должно быть отображено. Если активен экран номер четыре, она переходит к визуализации текстового поля ввода. При этом сначала функция рисует основные элементы интерфейса. Затем она выводит изображение текстового поля ввода. Это простая текстура, которую я создал в Photoshop. После этого программа проверяет активен ли текстовый ввод. Если да, то проверяется значение таймера мерцания курсора. Если таймер завершил отсчет заданного временного промежутка, код проверяет скрыт курсор или нет и меняет его состояние на противоположное. Вернувшись к визуализации код отображает курсор, если он не скрыт. И последняя вешь, которую делает код, — отображение введенного имени игрока. Обратите внимание, что код отображает имя игрока независимо от активности текстового ввода. Даже если игрок не вводит текст, введенное имя игрока должно быть видно ему.

Интерфейс шрифта

Возможно, рассматривая код визуализации вы заметили, что для отображения текста на экране я использую объект с именем pD3DXFont. Это экземпляр предоставляемого DirectX интерфейса ID3DXFont. Данный интерфейс очень полезен, так как выполняет все необходимое для отображения шрифтов в Direct3D. Вам надо лишь указать дескриптор шрифта и выводимый текст. Это действительно просто! Если вы взглянете на функцию инициализации объектов интерфейсов, то увидите следующий код:

// Шрифт текста
hFont = CreateFont(16, 0, 0, 0, 0,
                    0, 0, 0, 0, 0, 0,
                    PROOF_QUALITY, 0, "fixedsys");
D3DXCreateFont(g_pd3dDevice, hFont, &pD3DXFont);

В первой строке вызывается системная функция CreateFont(). Она является частью системы GDI Windows и создаеет дескриптор шрифта, получая в качестве параметров имя шрифта, его размер и ряд других атрибутов. Подробные сведения об этой функции вы найдете в справочнике MSDN.

После того, как вы получили дескриптор шрифта, остается только вызвать функцию D3DXCreateFont(). Эта функция получает указатель на устройство Direct3D, дескриптор шрифта и адрес указателя на объект ID3DXFont. В результате выполнения функции в указатель на объект ID3DXFont помещается ссылка на созданный интерфейс шрифта, который будет использоваться в дальнейшем при визуализации.

В рассматриваемом примере я создаю моноширинный шрифт. Я люблю моноширинные шрифты, поскольку при их использовании очень просто вычислить длину создаваемой строки. В пропорциональных шрифтах ширина символов неодинакова. Это создает кучу проблем, особенно когда требуется отобразить курсор в конце текста!

Вернемся к коду визуализации. Для отображения только что созданного шрифта я обращаюсь к функции интерфейса шрифта DrawText().

Функция ID3DXFont::DrawText()

Прототип функции DrawText() выглядит следующим образом:

INT DrawText(
   LPCSTR pString,
   INT Count,
   LPRECT pRect,
   DWORD Format,
   D3DCOLOR Color
);

Первый параметр, pString, исключительно прост, ведь в нем передается отображаемый текст. В рассматриваемом примере я передаю в этом параметре имя игрока.

Второй параметр, Count, содержит количество отображаемых символов. Я передаю в этом параметре –1, чтобы DirectX мог сам вычислить, сколько символов отображать. Если вы будете поступать так же, убедитесь, что ваша строка завершается нулевым символом!

Третий параметр, pRect, представляет собой описание прямоугольной области, сообщающее DirectX где именно следует отображать текст. В рассматриваемом примере я создаю область визуализации внутри изображения текстового поля ввода и сохраняю ее параметры в переменной rectText.

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

Пятый параметр, Color, определяет цвет, используемый при визуализации. В этом параметре я использую макрос D3DCOLOR_RGBA(), позволяющий просто указать значения RGBA для шрифта.


Глава 9 - Итоги

Итоги

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

  • DirectInput предоставляет все необходимое для работы с клавиатурой в вашей игре. Он также предоставляет возможность работать с другими типами устройств ввода, необходимыми для игры.
  • Коды DIK — это внутренние коды клавиатуры, назначенные DirectInput.
  • С помощью DirectInput можно получить коды ASCII, но для этого вы должны сами преобразовывать коды DIK.
  • Буферизованный ввод необходим. Никогда не реализуйте методы ввода без буферизации.
  • Интерфейс ID3DXFont предоставляет вам мощные средства для отображения текста.


Глава 10. Редактируем карту мира

Краткий обзор

Добро пожаловать в третью часть книги! В этом разделе я расскажу вам все, что вы хотели узнать о программировании инструментальных средств работы с картами, но боялись спросить. Ладно, может и не все, но основные вещи точно! В данной главе я расскажу об основах редактирования карт. Поскольку большинство стратегических игр основано на блочной графике, имеет смысл рассмотреть высокое искусство создания карт. В конце концов, что хорошего в стратегической игре без карты? Чтобы пробудить в вас картографа, я рассмотрю следующие темы:

  • Основы редактирования карт.
  • Просмотр карты.
  • Редактирование карты.
  • Сохранение.
  • Загрузка.
  • Отображение мини-карты.
  • Алгоритмы генерации карт.
  • Слои карты.


Глава 10 - Основы редактирования карт

Основы редактирования карт

Первый вопрос, который вы должны задать: «Что такое редактор карт?». Редактор карт помогает вам собирать вместе графические блоки карты в формате, пригодном для использования в вашей игре. Он очень похож на программу для рисования, где в качестве холста выступает карта, а в качестве кистей — графические блоки.

Следующий вопрос: «Зачем создают редакторы карт?». Если вы не хотите вручную печатать тысячи номеров графических блоков, редактор карт — единственный способ создавать карты и уровни для вашей игры. Продолжая аналогию с программой для рисования можно сказать, что создание карт без редактора подобно рисованию картины путем ввода шестнадцатеричных кодов, задающих цвета точек. Это подходит для мечты идиота, но в реальном мире не слишком практично.

И, наконец, последний вопрос: «На что похож редактор карт?». Если вы когда-нибудь использовали редактор уровней в игре, то уже знаете ответ на этот вопрос. Если нет, взгляните на рис. 10.1.

Рис. 10.1. Редактор уровней игры Warcraft III

Рис. 10.1. Редактор уровней игры Warcraft III

На рис. 10.1 представлено окно редактора уровней игры Warcraft III, поставляемого вместе с игрой и являющегося очень мощным инструментом. Он позволяет вам редактировать карты, поставляемые с игрой, или создавать свои собственные с нуля. На рисунке вы можете видеть мини-карту, представляющую высокоуровневый взгляд на карту и крупный план редактируемой в данный момент области, занимающий большую часть окна редактора. Вокруг области редактирования есть различные панели инструментов, позволяющие выбирать текстуры и производимые с картой действия.

В этой главе я покажу вам как создать собственный редактор карт. Он будет не таким мощным и сложным, как редактор Blizzard для Warcraft III, но, по крайней мере, предоставит вам хорошую отправную точку.

ПРИМЕЧАНИЕ
Создание редактора карт может показаться простой задачей, но процесс его разработки обычно занимает столько же (если не больше) времени, что и цикл разработки самой игры!

Компоненты редактора карт

Давайте еще раз взглянем на рис. 10.1, чтобы выделить компоненты редакторов карт и уровней. На представленной иллюстрации можно отметить несколько ключевых компонентов:

  • Область редактирования.
  • Область выбора блоков.
  • Мини-карта.
  • Область вывода информации.

Область редактирования

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

Хотя область редактирования во многом похожа, на то что видит игрок, обычно существует ряд отличий. Первое из них — добавление сетки блоков. Вы должны включать возможность вывода сетки в области редактирования, чтобы облегчить создателю карт выравнивание блоков. Сетка показывает где начинается и где заканчивается каждый блок. Это также полезно, чтобы показать размер редактируемых блоков. Пример сетки редактирования показан на рис. 10.2.

Рис. 10.2. Редактор уровней Warcraft III с включенной сеткой

Рис. 10.2. Редактор уровней Warcraft III с включенной сеткой

На рисунке показана область редактирования редактора уровней игры Warcraft III с включенной сеткой редактирования. Как видите, сетка ускоряет выравнивание плиток.

Область выбора блоков

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

Мини-карта

Область мини-карты показывает вам, как редактируемый мир выглядит с большой высоты. На рис. 10.1 и рис. 10.2 мини-карта расположена в левом верхнем углу интерфейса. Хороший метод для применения в мини-картах — назначить различные цвета различным типам блоков. Например, вы можете выбрать зеленый цвет для блоков изображающих землю и синий цвет — для блоков изображающих воду.

Область вывода информации

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


Глава 10 - Просмотр карты

Просмотр карты

Хватит уже теории! Как насчет какого-нибудь кода, который покажет вам как создать свой собственный редактор карт? Загрузите проект D3D_MapViewer и следуйте вперед.

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

Запустите программу просмотра карт, и вы увидите окно, изображенное на рис. 10.3.

Рис. 10.3. Окно программы просмотра карты

Рис. 10.3. Окно программы просмотра карты

На рис. 10.3 вы видите выглядящий знакомым набор блоков, отображенный в окне несколько большего размера. Отличие этой блочной карты от примеров из главы 5 заключается в том, что вы можете передвигать карту с помощью клавиш управления курсором. Стрелки вверх и вниз вызывают перемещение вдоль оси Y, а стрелки влево и вправо — вдоль оси X. Запустите программу и проверьте это самостоятельно.

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

Глобальные переменные карты

В заголовочном файле main.h проекта находится несколько исключительно важных типов данных, используемых для просмотра карты. Вот их краткий список:

int g_iTileSize  = 32;
int g_iTilesWide = 20;
int g_iTilesHigh = 15;
int g_iMapWidth  = 100;
int g_iMapHeight = 100;
int g_iXPos = 0;
int g_iYPos = 0;
int g_iTileMap[10000];

Первая переменная, g_iTileSize, сообщает программе просмотра карты сколько точек в ширину и в высоту занимают используемые блоки. Я присваиваю ей значение 32, следовательно ширина и высота моих блоков будут равны 32 точкам.

Вторая переменная, g_iTilesWide, сообшает программе сколько блоков должно помещаться в окне просмотра по горизонтали. Поскольку ширина используемого мной окна равна 640 точкам, а ширина блока равна 32 точкам, я присваиваю этой переменной значение 20, чтобы карта занимала все окно.

Третья переменная, g_iTilesHigh, работает точно так же, как g_iTilesWide, за исключением того, что задает количество блоков в окне по вертикали. Высота области просмотра равна 480 точкам, так что 15 блоков замечательно заполнят ее.

Четвертая переменная, g_iMapWidth, сообщает программе сколько блоков в карте по оси X. Поскольку программа просмотра может прокручивать карту, последняя может быть больше, чем область просмотра. Я задаю здесь значение 100, чего должно быть вполне достаточно для демонстрации прокрутки.

Пятая переменная, g_iMapHeight, работает точно так же как и предыдущее поле, за исключением того, что задает количество блоков в карте по оси Y. Ей я также присваиваю значение 100, чтобы карта была квадратной.

Шестая переменная, g_iXPos, сообщает программе просмотра в каком месте по оси X расположена область просмотра. Поскольку карта по размерам больше чем окно просмотра, программа должна отслеживать положение окна просмотра на карте. Это число не может быть отрицательным, поскольку отрицательные координаты располагаются за пределами карты.

Седьмая переменная, g_iYPos, задает вторую координату местоположения окна просмотра на карте.

Восьмая переменная, g_iTileMap, представляет собой массив целых чисел, описывающий блочную карту. В нем хранятся номера всех блоков, отображаемых на карте. Поскольку ширина и высота нашей карты равны 100 блокам, я создаю массив размером 10 000 элементов.

Назначение этих переменных и их значения представлены на рис. 10.4.

Рис. 10.4. Глобальные переменные для просмотра карты

Рис. 10.4. Глобальные переменные для просмотра карты

На рис. 10.4 видно как ширина и высота карты задают ее общий размер. Также видно как размер окна просмотра задается в блоках. Кроме того, можно обратить внимание как координаты области просмотра задают ее положение на карте.

Ход выполнения программы

Ход выполнения программы очень похож на работу остальных примеров в этой книге. Сперва выполняется инициализация составляющих программу систем. После ее завершения программа начинает обработку сообщений и вводимых пользователем данных. Это продолжается до тех пор, пока пользователь не завершит работу приложения. Все это и кое-что еще показано на рис. 10.5.

Рис. 10.5. Ход выполнения программы просмотра карт

Рис. 10.5. Ход выполнения программы просмотра карт

На рис. 10.5 появилась только одна новая функция — vInitMap().

Генерация случайной карты

Функция vInitMap() отвечает за создание случайной карты. Взгляните как выглядит код, выполняющий эти действия:

void vInitMap(void)
{
   int i;

   // Заполнение карты случайными блоками
   for(i = 0; i < g_iMapWidth * g_iMapHeight; i++) {
      g_iTileMap[i] = rand()%3;
   }
}

Возможно вы думаете «Что мне может дать случайное заполнение карты?». Гораздо больше, чем вы могли предположить. Хотя вы будуте редактировать почти каждый блок на игровом поле, случайный набор блоков является хорошей отправной точкой, благодаря которой карта будет выглядеть естественно. Скорее всего, вы не захотите вручную размещать каждый камень, куст или ягоду на нескольких десятках карт. Сначала это может быть забавно, но очень быстро вы устанете.

В коде видно, как я просматриваю весь буфер карты и присваиваю каждому блоку случайное значение в диапазоне от 0 до 2. В результате карта будет похожа на мешанину. Но есть несколько вещей, которые вы можете реализовать здесь. Например, вы можете задать параметры распределения случайных блоков. Пусть 10 процентов карты беспорядочно заполняются камнями, а 60 процентов — водой. Тпкие игры, как SimCity 4 и Civilization используют подобный метод при создании собственных карт. Позднее я подробнее рассмотрю автоматическую генерацию карт, так что пока прекратим вешать лапшу на уши.

Навигация по карте

На блок-схеме программы, изображенной на рис. 10.5, присутствует вызов функции vCheckInput(). Навигация по карте осуществляется путем нажатия на клавиши, так что это очень важная функция. Следуйте далее и взгляните на приведенный ниже код:

void vCheckInput(void)
{
   // Чтение из буфера клавиатуры
   int iResult = iReadKeyboard();
   // Проверяем, сколько нажатий на клавиши возвращено
   if(iResult) {
      // Перебираем в цикле полученные данные
      for(int i = 0; i < iResult; i++) {
         // Выход из программы, если нажата клавиша ESC
         if(diks[DIK_ESCAPE][i]) {
            PostQuitMessage(0);
         }
         // Вверх
         if(diks[DIK_UP][i]) {
            g_iYPos--;
         }
         // Вниз
         if(diks[DIK_DOWN][i]) {
            g_iYPos++;
         }
         // Влево
         if(diks[DIK_LEFT][i]) {
            g_iXPos--;
         }
         // Вправо
         if(diks[DIK_RIGHT][i]) {
            g_iXPos++;
         }
            // Проверяем, не вышли ли за границы
         if(g_iYPos < 0)
            g_iYPos = 0;
         else if (g_iYPos >= (g_iMapHeight - g_iTilesHigh))
            g_iYPos = (g_iMapHeight - g_iTilesHigh);
         if(g_iXPos < 0)
            g_iXPos = 0;
         else if (g_iXPos >= (g_iMapWidth - g_iTilesWide))
            g_iXPos = (g_iMapWidth - g_iTilesWide);
      }
   }
}

Иллюстрации всегда хорошо дополняют слова, так что взгляните на рис. 10.6, показывающий работу кода.

Рис. 10.6. Ход выполнения функции проверки входных данных

Рис. 10.6. Ход выполнения функции проверки входных данных

На рис. 10.6 показан ход выполнения функции проверки входных данных. В первой части кода я проверяю буфер клавиатуры, чтобы убедиться, что пользователь нажимал на какие-нибудь клавиши. Если да, код проверяет какие именно клавиши нажаты. Если нажата клавиша Esc, программа завершает работу. Если нажата какая-нибудь клавиша управления курсором в коде соответствующим образом меняются значения координат области просмотра g_iXPos и g_iYPos. После того, как выполнена проверка нажатия клавиш управления курсором, код выполняет проверку, чтобы убедиться, что координаты находятся в допустимом диапазоне. Благодаря этому в окне просмотра не отображаются области, лежащие за границами карты.

Загрузка изображений блоков

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

for(int i = 0; i < 3; i++) {
   // Установка имени
   sprintf(szTileName, "tile%d.bmp", i);
   // Загрузка
   if(FAILED(D3DXCreateTextureFromFile(
      g_pd3dDevice,
      szTileName,
      &g_pTexture[i]))) {
      return;
   }
}

Загрузка блоков довольно проста. Я просто запускаю цикл, перебирающий доступные блоки и загружающий файлы с именами от tile0.bmp до tile2.bmp. Поскольку загружаются только три блока, процесс выполняется очень быстро. Для последующего использования загруженные блоки сохраняются в массиве g_pTexture.

Визуализация блоков

Функция vRender() занимается отображением блочной карты. В ней я в цикле перебираю блоки карты и отображаю текстуры, соответствующие номерам блоков. Вот код цикла визуализации:

// Сверху вниз
for(iY = 0; iY < g_iTilesHigh; iY++) {
   // Справа налево
   for(iX = 0; iX < g_iTilesWide; iX++) {
      // Вычисляем смещение в буфере
      iBufferPos = iX + g_iXPos + ((iY + g_iYPos) * g_iMapWidth);
      // Получаем требуемый блок
         iCurTile = g_iTileMap[iBufferPos];
         // отображаем блок
         vDrawInterfaceObject((iX * g_iTileSize),
                      (iY * g_iTileSize),
                      (float)g_iTileSize,
                      (float)g_iTileSize,
                      iCurTile);
   }
}

В функции визуализации присутствуют два цикла. Первый цикл осуществляет перебор блоков вдоль оси Y. Внутренний цикл перебирает блоки вдоль оси X. Таким образом я отображаю все необходимые блоки. Это тот же самый метод, который я описывал в главе 5.


Глава 10 - Редактирование карты

Редактирование карты

К данному моменту у вас появилась лишь возможность интерактивного просмотра карты. Как насчет того, чтобы действительно поредактировать ее? Заучит заманчиво, а? В этом разделе я покажу вам как написать редактор карт, который позволит вам самим размещать блоки на карте. Ушли те дни, когда вы задавали карты в виде набора значений в коде программы! Взгляните на рис. 10.7, где изображено окно редактора карт, о котором я буду рассказывать.

Рис. 10.7. Окно программы D3D_MapEditorLite

Рис. 10.7. Окно программы D3D_MapEditorLite

На рис. 10.7 изображено окно программы D3D_MapEditorLite. Оно выглядит очень похоже на окно программы просмотра карт, о которой вы уже читали. Главным отличием данной программы является добавление области выбора блоков и возможность редактировать карту в реальном времени.

Теперь загрузите проект D3D_MapEditorLite и следуйте дальше вместе со мной.

Глобальные переменные карты

В заголовочном файле проекта main.h появилось несколько новых членов данных, необходмых для редактирования. Вот они в порядке их появления:

int   g_iCurTile = 0;
int   g_iCurTileSet = 0;
int   g_iMaxTileSet = 3;
int   g_iTotalTiles = 18;

Первая переменная, g_iCurTile, сообщает редактору какой именно блок выбран пользователем для рисования в данный момент. Когда пользователь редактирует карту, на нее будет помещаться именно этот блок.

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

Далее идет переменная g_iMaxTileSet. Она сообщает системе сколько страниц может быть в наборе блоков. Фактически вы можете указать здесь сколь угодно большое число. Я использую его лишь для того, чтобы уберечь пользователя от погони за горизонтом.

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

Ход выполнения программы

Ход выполнения программы редактирования карты практически не отличается от работы программы просмотра карты. Также как и в программе просмотра карт, здесь сперва инициализируются клавиатура, система визуализации, текстуры и карта. Нововведением является инициализация панели инструментов. Панель инструментов содержит область выбора блоков, позволяющую указать тот блок, который будет использоваться для редактирования. После того, как все компоненты инициализированы, программа ожидает ввода данных пользователем и отображает карту. Ход выполнения программы показан на рис. 10.8.

Рис. 10.8. Ход выполнения программы редактирования карт

Рис. 10.8. Ход выполнения программы редактирования карт

Программирование панели инструментов

Панель инструментов с областью выбора блоков является очень важной частью редактора карт. Без нее динамическое редактирование блочной карты было бы очень трудным. Код использует стандартные приемы программирования для Windows, чтобы создать дочернее окно главного окна программы и добавить к нему несколько элементов управления. Отображение блоков на панели инструментов выполняется посредством обращения к DirectX. Вот как выглядит код, создающий окно панели инструментов и кнопки навигации по страницам набора блоков:

void vCreateToolbar(HWND hwnd, HINSTANCE hinst)
{
   WNDCLASSEX wcToolBar;
   // Инициализация и регистрация класса окна панели инструментов
   wcToolBar.cbSize       = sizeof(wcToolBar);
   wcToolBar.style        = CS_HREDRAW | CS_VREDRAW;
   wcToolBar.lpfnWndProc  = fnMessageProcessor;
   wcToolBar.cbClsExtra   = 0;
   wcToolBar.cbWndExtra   = 0;
   wcToolBar.hInstance    = hinst;
   wcToolBar.hIcon        = LoadIcon(NULL, IDI_APPLICATION);
   wcToolBar.hCursor      = LoadCursor(NULL, IDC_ARROW);
   wcToolBar.hbrBackground= (HBRUSH) GetStockObject (COLOR_BACKGROUND);
   wcToolBar.lpszMenuName = NULL;
   wcToolBar.lpszClassName= "ToolBar";
   wcToolBar.hIconSm      = LoadIcon(NULL, IDI_APPLICATION);
   RegisterClassEx(&wcToolBar);
   // Создание окна панели инструментов
   hWndToolBar = CreateWindowEx(
      WS_EX_LEFT|WS_EX_TOPMOST|WS_EX_TOOLWINDOW,
      "ToolBar", "ToolBar",
      WS_BORDER | WS_VISIBLE | WS_CAPTION | WS_MINIMIZEBOX,
      g_iWindowWidth - 100,
      g_iYOffset, 100, g_iWindowHeight - 20,
      hwnd, NULL, hinst, NULL);

   // Кнопка выбора предыдущего блока
   hBUTTON_PREVTILE = CreateWindow(
      "BUTTON", "<",
      WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
      10, 405, 20, 20,
      hWndToolBar, (HMENU)ID_BUTTON_PREVTILE, hinst, NULL);

   // Кнопка выбора следующего блока
   hBUTTON_NEXTTILE = CreateWindow(
      "BUTTON", ">",
      WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
      65, 405, 20, 20,
      hWndToolBar, (HMENU)ID_BUTTON_NEXTTILE, hinst, NULL);

   // Активация области редактирования
   SetActiveWindow(g_hWnd);

   // Отображение набора блоков на панели инструментов
   vRenderTileSet();
}

Переменная wcToolBar хранит информацию класса окна панели инструментов. Значения для класса не представляют собой ничего такого, что следовало бы описать, поскольку следуют стандартным правилам программирования для Windows.

Функция CreateWindowEx() выполняет фактическое создание панели инструментов. Она создает окно с именем ToolBar без кнопок закрытия и свертывания. Это гарантирует, что пользователь случайно не закроет область выбора блоков. Кроме того, в функции создания окна я задаю местоположение панели инструментов таким образом, чтобы она находилась внутри границ главного окна.

После того, как окно панели инструментов создано, я создаю пару кнопок для навигации по набору блоков. Эти кнопки называются hBUTTON_PREVTILE и hBUTTON_NEXTTILE. Когда их нажимают, программа переходит к предыдущей или следующей странице странице набора блоков.

Отображение блоков на панели инструментов

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

void vRenderTileSet(void)
{
   RECT   rectDest;
   RECT   rectSrc;
   int    iX;
   int    iY;
   int    iTile;

   // Включаем рассеянное освещение
   g_pd3dDevice->SetRenderState(D3DRS_AMBIENT, 0x00606060);

   // Очищаем вторичный буфер и z-буфер
   g_pd3dDevice->Clear(0, NULL, D3DCLEAR_TARGET,
                D3DCOLOR_XRGB(0,0,0), 1.0f, 0);
   // Начинаем визуализацию
   g_pd3dDevice->BeginScene();

   // Задаем состояние альфа-смешивания
   // Это необходимо для реализации прозрачности/полупрозрачности
   g_pd3dDevice->SetRenderState(D3DRS_ALPHABLENDENABLE, TRUE);
   g_pd3dDevice->SetRenderState(D3DRS_SRCBLEND, D3DBLEND_SRCALPHA);
   g_pd3dDevice->SetRenderState(D3DRS_DESTBLEND, D3DBLEND_INVSRCALPHA);

   // Отображение активных блоков
   for(iY = 0; iY < 7; iY++) {
      for(iX = 0; iX < 3; iX++) {
         // Вычисляем отображаемый блок
         iTile = (g_iCurTileSet * 21) + (iX + (iY * 3));
         // Отображаем, если это существующий блок
         if(iTile < g_iTotalTiles) {
            vDrawInterfaceObject(
               iX * g_iTileSize,
               iY * g_iTileSize,
               (float)g_iTileSize,
               (float)g_iTileSize,
               iTile);
         }
         // Рисуем рамку поверх текущего блока
      if(iTile == g_iCurTile) {
            vDrawInterfaceObject(
               iX * g_iTileSize,
               iY * g_iTileSize,
               (float)g_iTileSize,
               (float)g_iTileSize,
               18);
      }
   }
}

// Отображаем текущий блок
vDrawInterfaceObject(
      32,
      32 * 7,
      (float)g_iTileSize,
      (float)g_iTileSize,
      g_iCurTile);

   // Завершаем визуализацию
   g_pd3dDevice->EndScene();

   // Исходный прямоугольник
   rectSrc.top    = 0;
   rectSrc.bottom = g_iTileSize * 8;
   rectSrc.left   = 0;
   rectSrc.right  = g_iTileSize * 3;

   // Целевой прямоугольник
   rectDest.top    = 2;
   rectDest.bottom = (g_iTileSize * 8) + 2;
   rectDest.left   = 0;
   rectDest.right  = (g_iTileSize * 3);

   g_pd3dDevice->Present(&rectSrc, &rectDest, hWndToolBar, NULL);
}

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

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

После того, как визуализация набора блоков завершена, копия выбранного в данный момент блока отображается в нижней части экрана. Это еще один полезный индикатор текущего блока. Взгляните на рис. 10.9, чтобы увидеть структуру панели инструментов.

Рис. 10.9. Структура панели инструментов

Рис. 10.9. Структура панели инструментов

На рис. 10.9 видно как отображение блоков начинается в левом верхнем углу и продолжается сверху вниз. На иллюстрации текущим блоком является блок с номером 10 и поэтому именно вокруг него нарисован красный квадрат. Тот же самый блок скопирован и в нижней части области просмотра блоков, что так же указывает какой именно блок выбран. Еще ниже на панели инструментов выводятся кнопки для навигации по набору блоков.

Теперь, когда выполнена визуализация всех блоков, надо поместить их на панель инструментов. Это выполняется путем задания исходной и целевой областей и вызова функции Present(). Как видите, в качестве параметров этой функции передаются исходная прямоугольная область и прямоугольная область места назначения. Сама функция сообщает системе визуализации, что необходимо взять изображение из одной области и скопировать его в другую. В этом случае вы можете визуализировать блоки в буфере трехмерной графики, а затем скопировать их на панель инструментов для показа. Посмотрите на код, и вы увидите как я копирую исходную область и перемещаю ее на панель инструментов.

Редактирование карты

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

В программе D3D_MapEditorLite есть два режима работы мыши. В режиме редактирования вы шелкаете левой кнопкой мыши по карте в области просмотра и редактируете выбранный блок. В режиме выбора блока вы щелкаете по изображениям блоков на панели инструментов, чтобы выбрать новый блок для использования при редактировании. Чтобы выяснить в каком режиме работает пользователь, я проверяю координаты указателя мыши и смотрю, находятся ли они внутри области панели инструментов или внутри области просмотра карты. Если координаты находятся внутри области панели инструментов, я перехожу к логике для панели инструментов, чтобы определить, какой блок находится под указателем мыши. Если координаты находятся внутри области просмотра карты, в игру вступает логика редактирования карты, вычисляющая какой именно блок карты редактируется. Вот как выглядит код, выполняющий эти задачи:

void vCheckMouse(void)
{
   RECT   rcWindow;
   POINT  Point;
   int    iMouseX;
   int    iMouseY;
   int    iTileX;
   int    iTileY;

   // Получаем координаты указателя мыши
   GetCursorPos(&Point);
   iMouseX = Point.x;
   iMouseY = Point.y;

   // Определяем местонахождение рабочей области панели инструментов
   GetWindowRect(hWndToolBar, &rcWindow);

   // Проверяем, находится ли указатель мыши внутри окна панели инструментов
   if(iMouseX > rcWindow.left &&
      iMouseX < rcWindow.right &&
      iMouseY > rcWindow.top &&
      iMouseY < rcWindow.bottom)
   {
      // Преобразуем координаты указателя мыши
      // в локальные координаты панели инструментов
      iMouseX -= rcWindow.right;
      iMouseY -= rcWindow.top;

      // Вычисляем координаты блока
      iTileX = iMouseX / g_iTileSize;
      iTileY = iMouseY / g_iTileSize;
      // Вычисляем, какой блок выбран
      g_iCurTile = (g_iCurTileSet * 21) + (iTileX + (iTileY * 3)) - 1;
      // Проверяем, что выбран существующий блок
      if(g_iCurTile < 0|| g_iCurTile >= g_iTotalTiles) {
         g_iCurTile = 0;
      }
      vRenderTileSet();
   }
   // Проверяем, находится ли указатель мыши в окне редактирования
   else {
      GetWindowRect(g_hWnd, &rcWindow);

      if(iMouseX > rcWindow.left &&
         iMouseX < rcWindow.right &&
         iMouseY > rcWindow.top &&
         iMouseY < rcWindow.bottom)
      {
         // Преобразуем координаты указателя мыши
         // в локальные координаты окна редактирования
         iMouseX -= rcWindow.left + g_iXOffset;
         iMouseY -= rcWindow.top + g_iYOffset;

         // Вычисляем координаты блока
         iTileX = iMouseX / g_iTileSize;
         iTileY = iMouseY / g_iTileSize;

         g_iTileMap[iTileX + g_iXPos +
                   ((iTileY + g_iYPos)
                   * g_iMapWidth)] = g_iCurTile;
      }
   }
}

Чтобы определить местоположение панели инструментов и окна редактирования я воспользовался функцией GetWindowRect(). После того, как я узнал где они расположены, достаточно простой проверки, чтобы определить находится ли указатель мыши в заданной области.

Если указатель мыши находится внутри панели инструментов, я беру координаты указателя мыши и делю их на размеры блока, чтобы увидеть, какой именно блок выбирает пользователь. Как только я узнал, какой блок выбран, я заношу в переменную g_iCurTile новое значение. Затем я вызываю функцию vRenderTileSet(), чтобы переместить красный квадрат, отмечающий выбранный блок на новое место.

Если указатель мыши находится в окне редактирования, я корректирую координаты указателя мыши с учетом местоположения клиентской области окна. Затем я делю координаты указателя мыши на размеры блока, чтобы вычислить какой блок карты выбран для редактирования. Последний шаг к Валгалле — помещение значения из переменной g_iCurTile в соответствующий элемент массива g_iTileMap.


Глава 10 - Сохранение и загрузка карты

Сохранение и загрузка карты

Вау! Мы уже узнали как редактировать карту, так что не осталось никаких препятствий на пути к вершинам картографии. Редактирование карт — великолепная возможность, но она бессмысленна, если вы не можете сохранить результаты своей работы. Здесь на сцену выходит функция сохранения карты. В этом разделе я покажу вам как добавить к программе D3D_MapEditorLite полнофункциональные кнопки сохранения и загрузки. Загрузите новый улучшенный проект D3D_MapEditorPlus и пойдемте дальше. Сперва взгляните на рис. 10.10, где изображено окно этой программы.

Рис. 10.10. Окно программы D3D_MapEditorPlus

Рис. 10.10. Окно программы D3D_MapEditorPlus

На рис 10.10 видно, что я добавил на панель инструментов кнопки Load и Save. Их функции должны быть ясны, поскольку кнопка Load загружает данные карты из указанного файла, а кнопка Save сохраняет данные карты в указанном файле.

Функция SaveMap()

Чтобы сохранить карту вам необходимо сохранить все содержимое массива блоков g_iTileMap. Поскольку массив представляет собой одну непрерывную область памяти, операция достаточно проста. Фактически, вы должны создать файл для сохранения данных, записать в этот файл содержимое массива блочной карты, после чего закрыть файл. Для большего удобства я добавил функциональность, предоставляющую пользователю диалоговое окно выбора файла. Оно позволяет выбрать имя файла данных с помощью простого и интуитивно понятного интерфейса. Вот как выглядит код, который я использую для сохранения карты:

void vSaveMap(void)
{
   FILE         *fp;
   int          iRet;
   OPENFILENAME fileStruct;
   char         szFileName[512];
   char         szFilter[32];
   char         szExtension[32];

   // Очищаем буфер для получения имени файла
   memset(szFileName, 0x00, 512);

   // Создаем фильтр имен файлов
   memset(szFilter, 0x00, 32);
   strcpy(szFilter, "*.dat");
   // Указываем расширение имени файла
   memset(szExtension, 0x00, 32);
   strcpy(szExtension, "dat");

   // Создаем структуру диалога выбора файла
   memset(&fileStruct, 0x00, sizeof(OPENFILENAME));

   // Инициализируем структуру
   fileStruct.hInstance = g_hInstance;
   fileStruct.hwndOwner = g_hWnd;
   fileStruct.lpstrDefExt = szExtension;
   fileStruct.lpstrFileTitle = szFileName;
   fileStruct.lpstrFilter = szFilter;
   fileStruct.nMaxFileTitle = 512;
   fileStruct.lStructSize = sizeof(OPENFILENAME);

   // Получаем имя файла
   iRet = GetSaveFileName(&fileStruct);

   // Выходим в случае ошибки
   if(!iRet) {
      return;
   }
   // Открываем файл
   fp = fopen(szFileName, "wb");
   // Возвращаемся, если не можем открыть файл
   if(fp == NULL) {
      return;
   }
   // Сохраняем буфер блочной карты
   fwrite(g_iTileMap, 10000, sizeof(int), fp);
   // Закрываем файл
   fclose(fp);
   // Воспроизводим звук, сообщающий о завершении действия
   PlaySound("bleep.wav",NULL,SND_FILENAME|SND_ASYNC);
}

В первой части кода выполняется инициализация структуры данных OPENFILENAME, необходимой для функции GetSaveFileName(). Функция GetSaveFileName() является частью Microsoft Visual C++ SDK и предоставляет все необходимое для создания диалогового окна сохранения файла.

СОВЕТ
Предоставляемое Microsoft диалоговое окно делает получение имени файла очень простой задачей. Я рекомендую вам применять его в своих программах.

После того, как вызванная функция возвратила имя файла, откройте файл и сохраните в нем содержимое массива карты. Здесь можно воспользоваться функцией fwrite(), которая замечательно справится с этой работой.

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

ПРИМЕЧАНИЕ
Если вы введете имя файла в поле Save As и не выберете расширение, файл будет сохранен без расширения. Убедитесь, что вы сохраняете карты в файлы с расширением .dat, если не хотите столкнуться с проблемами во время их загрузки.

Загрузите редактор карт и поиграйте с ним, редактируя и сохраняя карты. Это предоставит вам материал для экспериментов с загрузкой карт.

Функция LoadMap()

Функция загрузки карты работает во многом так же, как и функция сохранения карты. Тем не менее, в ней есть ряд ключевых отличий. Во-первых, функция GetSaveFileName() заменена на функцию GetOpenFileName(). Я не знаю, почему есть две различных функции, выполняющих одинаковые действия, но кто я такой, чтобы подвергать сомнению установленный порядок вещей? Так или иначе, но функция получает имя открываемого файла и заносит его в указанную строку. Убедившись, что указанный файл существует, код открывает его, и загружает содержимое в глобальный массив карты. После завершения работы воспроизводится звуковой сигнал, оповещающий об успешной загрузке файла.

Если вы этого еще не сделали, запустите программу D3D_MapEditorPlus и щелкните по кнопке Load. Загрузите файл с именем TileMap.dat и вы увидите рисунок, выполненный моей рукой из песка.

Это все основные сведения о загрузке и сохранении блочных карт. С демонстрацией!


Глава 10 - Отображение мини-карты

Отображение мини-карты

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

Рис. 10.11. Окно программы D3D_MapEditorPlusGold

Рис. 10.11. Окно программы D3D_MapEditorPlusGold

Правильно, — это старый редактор карт, но теперь — золотое издание! (Я знаю, что название программы становится все более причуддливым, но по крайней мере я не называю ее MapEditor 2700+ или как-нибудь еще в этом роде!)

На рис. 10.11 вы видите редактор карт, но теперь в нижнем левом углу есть небольшое окно в котором отображается мини-карта. В действительности это большая карта мира, на которой каждый блок представляется одной точкой. Это позволяет мне отобразить полную карту мира, размером 100 x 100 блоков в окне размером 100 x 100 точек. Загрузите проект D3D_MapEditorPlusGold и я покажу вам изменения, необходимые для реализации отображения мини-карты.

Ход исполнения программы изменился не слишком сильно, но внесенные изменения надо проиллюстрировать. Пожалуйста, посмотрите на рис. 10.12, чтобы увидеть ход выполнения программы.

Рис. 10.12. Ход исполнения программы D3D_MapEditorPlusGold

Рис. 10.12. Ход исполнения программы D3D_MapEditorPlusGold

На рис. 10.12 видно, как программа инициализирует DirectInput, клавиатуру, DirectGraphics, объекты интерфейса, блочную карту, панель инструментов и, наконец, окно мини-карты. Поскольку очень удобно, когда мини-карта размещается в собственном перемещаемом окне, я создаю отдельное окно специально для этой цели. Это делает функция vCreateMinimap().

Функция vCreateMinimap()

Функция создания мини-карты вызывается приложением только один раз во время инициализации. Функция создает окно для мини-карты и размещает его в нижнем левом углу главного окна редактора. При вычислении координат этого окна я учитываю местоположение и размер главного окна редактора и размер мини-карты. Вот код, который создает окно:

// Создание окна мини-карты
hWndMinimap = CreateWindowEx(
   WS_EX_LEFT|WS_EX_TOPMOST|WS_EX_TOOLWINDOW,
   "Minimap", "Minimap",
   WS_BORDER | WS_VISIBLE | WS_MINIMIZEBOX,

   rcWindow.left + 10, rcWindow.bottom + g_iYOffset - 140,
   100, 100, hwnd, NULL, hinst, NULL);

Приведенный выше код не слишком гибок, поскольку я жестко запрограммировал его для мини-карты размером 100 х 100 точек. Впрочем, ничто не мешает вам изменить эти значения, чтобы приспособить окно для отображения мини-карты другого размера.

Теперь, когда у вас есть окно мини-карты, необходим код, который будет отображать саму мини-карту. Этим занимается функция vRenderMinimap().

Функция vRenderMinimap()

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

void vRenderMinimap(void)
{
   RECT   rectSrc;
   RECT   rectDest;
   int    iX;
   int    iY;
   int    iCurTile;
   int    iBufferPos;

   // Очистить вторичный буфер, заполнив его синим цветом
   g_pd3dDevice->Clear(0, NULL,
                D3DCLEAR_TARGET,
                D3DCOLOR_XRGB(0,0,0), 1.0f, 0);
   // Начинаем отображение сцены
   g_pd3dDevice->BeginScene();

   // Визуализация мини-карты
   // Сверху вниз
   for(iY = 0; iY < g_iMapHeight; iY++) {
      // Справа налево
      for(iX = 0; iX < g_iMapWidth; iX++) {
         // Вычисление смещения в буфере
         iBufferPos = iX + (iY * g_iMapWidth);
         // Получаем требуемый блок
         iCurTile = g_iTileMap[iBufferPos];
         // Отображаем блок
         vDrawInterfaceObject((iX),
                      (iY),
                      (float)1,
                      (float)1,
                      iCurTile);
      }
   }
   // Завершаем сцену
   g_pd3dDevice->EndScene();
   // Исходный прямоугольник
   rectSrc.top    = 0;
   rectSrc.bottom = g_iMapHeight;
   rectSrc.left   = 0;
   rectSrc.right  = g_iMapWidth;
   // Прямоугольник места назначения
   rectDest.top    = 0;
   rectDest.bottom = g_iMapHeight;
   rectDest.left   = 0;
   rectDest.right  = g_iMapWidth;
   // Представляем результат
   g_pd3dDevice->Present(&rectSrc, &rectDest, hWndMinimap, NULL);
}

Я отметил наиболее интересные части функции полужирным курсивом. Обратите внимание, что при выводе я устанавливаю размер каждого блока равным 1 х 1 пикселу. Благодаря этому при отображении мини-карты происходит масштабирование каждого блока до размеров единственной точки. Важно заметить, что при этом точка представляет общий цвет блока, поскольку это масштабированное представление, а не замещающая текстура. Что такое замещающая текстура? Один из методов рисования мини-карты заключается в том, что каждому типу блоков назначается представляющий его цвет. Например, вода может изображаться квадратами синего цвета, земля — квадратами зеленого цветаа постройки — черными квадратами. В этом случае не надо выполнять масштабирование блока, а достаточно заменить блок на точку с цветом, соттветствующим функциональному назначению данного блока в игре. Лично я предпочитаю метод масштабирования, поскольку он позволяет получить более точное представление карты и обходится без дополнительного кода для замещения текстур.

СОВЕТ
Если вы хотите получить мини-карту более крупного масштаба, надо только изменить размер окна мини-карты и размер блоков в цикле визуализации. Побробуйте сделать это!


Глава 10 - Алгоритмы генерации карт

Алгоритмы генерации карт

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

Рис. 10.13. Окно программы D3D_MapEditorGeneration

Рис. 10.13. Окно программы D3D_MapEditorGeneration

Обратите внимание на уже ставшие привычными элементы рисунка. Здесь есть панель инструментов, область редактирования и мини-карта. Новым элементом является появившаяся на панели инструментов кнопка Generate. Она очищает всю карту, заполняя ее водой, и создает случайно расположенные участки суши. Кроме того, вы можете заметить, что я увеличил размер мини-карты. Я сделал это для того, чтобы вы более ясно увидели эффект генерации случайной карты. Есть и еще одно изменение, которое нельзя заметить на рис. 10.13. Если вы запустите программу D3D_MapEditorGeneration, то увидите красный прямоугольник на мини-карте. Он показывает, какой именно фрагмент большой карты отображается в окне просмотра и помогает понять что именно вы сейчас редактируете.

Теперь загрузите проект D3D_MapEditorGeneration и следуйте за мной, а я буду рассказывать вам о коде программы.

Функция vGenerateMap()

Структура этой программы редактирования карт практически полностью повторяет структуру предыдущего примера. Главные отличия сконцентрированы в функции генерации карты. Ее цель — генерировать случайно расположенные участки суши, базируясь на передаваемом в качестве параметра номере используемого алгоритма. В рассматриваемом примере я реализовал только один тип алгоритма генерации карты, но ничто не мешает вам добавить и другие! Вот как выглядит код функции:

void vGenerateMap(int iType)
{
   int iRandDirection;
   int iSeedPos[32];
   int i, j;
   int iNumSeeds     = 32;
   int iNumUpdates   = 800;

   // -- ТИП 0 -- Случайные семена
   if(iType == 0) {
      // Очиска карты
      vInitMap();
      // Создание случайно расположенных начальных семян
      for(i = 0; i < iNumSeeds; i++) {
         // Установка начальной позиции семени
         iSeedPos[i] = rand() % (g_iMapHeight * g_iMapWidth);
         // Помещаем в позицию семени блок с изображением травы
         g_iTileMap[iSeedPos[i]] = 17;
      }
      // Перемещение семени
      for(i = 0; i < iNumUpdates; i++) {
         for(j = 0; j < iNumSeeds; j++) {
            iRandDirection = rand()%4;

            // перемещаем семя вверх
            if(iRandDirection == 0) {
               iSeedPos[j] -= g_iMapWidth;
            }
            // Перемещаем семя вправо
            else if(iRandDirection == 1) {
               iSeedPos[j]++;
            }
            // Перемещаем семя вниз
            else if(iRandDirection == 2) {
               iSeedPos[j] += g_iMapWidth;
            }
            // Перемещаем семя влево
            else if(iRandDirection == 3) {
               iSeedPos[j]--;
            }

            // Если семя вышло за пределы карты,
            // помещаем его в случайную позицию
            if(iSeedPos[j] < 0 ||
               iSeedPos[j] >= (g_iMapHeight * g_iMapWidth))
            {
               iSeedPos[j] =
                  rand() % (g_iMapHeight * g_iMapWidth);
            }
            // Помещаем в позицию семени блок с изображением травы
            g_iTileMap[iSeedPos[j]] = 17;
         }
      }
   }
   // Отображение мини-карты
   vRenderMinimap();
   // Воспроизведение звука, сообщающего о завершении операции
   PlaySound("bleep.wav",NULL,SND_FILENAME|SND_ASYNC);
}

Код может выглядеть устрашающе, так что сперва взгляните на рис. 10.14, где показан ход выполнения функции.

Рис. 10.14. Ход выполнения функции vGenerateMap()

Рис. 10.14. Ход выполнения функции vGenerateMap()

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

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

Есть еще пара вещей, отсутствующих на иллюстрации. Во-первых, перед началом создания ландшафта я вызываю функцию vInitMap() для очистки карты. Это требуется для того, чтобы перед созданием случайно расположенных блоков с изображением травы массив карты был приведен в исходное состояние. Во-вторых, на иллюстрации отсутствует проверка, гарантирующая, что семя не будет блуждать за пределами карты. Если семя выходит за границы карты, оно помещается в случайную позицию внутри карты и продолжает формировать изображение суши в новом месте. И последняя отсутствующая вещь — вызов функции отображения миникарты, показывающей обновленный ландшафт.

Методы генерации карт

Как я уже говорил ранее, в рассматриваемом примере реализован только один метод генерации случайной карты. Существует множество других, более трудоемких методов, которые вы, возможно, захотите поместить в ваши собственные процедуры. Например, вы можете использовать фракталы для генерации интересных ландшафтов. Или вы можете использовать метод шаблонов, в котором на карте случайным образом размещаются заранее определенные шаблоны фрагментов ландшафта. Работа метода шаблонов показана на рис. 10.15.

Рис. 10.15. Использование шаблонов для генерации случайного ландшафта

Рис. 10.15. Использование шаблонов для генерации случайного ландшафта

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


Глава 10 - Многослойные карты

Многослойные карты

Вы помните многослойные блоки, о которых рассказывалось в главе 5? Если нет, вам лучше сейчас вернуться назад и повторить изложенный там материал. Слои позволяют вам отображать несколько блоков один поверх другого. Например, вы можете вывести блок с изображением травы, а затем добавить поверх него блок с изображением деревьев. Вы можете даже добавить поверх блока с изображением деревьев блок с изображением огня, чтобы показать лесной пожар. Открывающиеся возможности безграничны. В связи с этим возникает вопрос: как реализовать редактирование нескольких слоев в редакторе карт? Подумайте об этом, поскольку я собираюсь показать вам подобную возможность! Взгляните на рис. 10.16, где изображен редактор карт с поддержкой слоев.

Рис. 10.16. Окно программы D3D_MapEditorLayers

Рис. 10.16. Окно программы D3D_MapEditorLayers

На рис. 10.16 показано окно программы D3D_MapEditorLayers. На панели инструментов появились четыре новые кнопки, отмеченные цифрами от 1 до 4. Они позволяют установить активный слой, который будет редактироваться. Например, чтобы редактировать базовый слой, щелкните по кнопке 1. После того, как вы выбрали первый слой, все щелчки по области редактирования будут менять текстуры, выводящиеся в первом слое блоков. Кнопки, отвечающие за другие слои работают аналогично. В изображенном на иллюстрации окне редактирования вы видите маленький песчанный остров с травой в центре. Изображение песка находится в слое 1, а изображение травы — в слое 2. Благодаря этому достигается плавный переход от травы к песку без необходимости вводить специальные переходные блоки с изображением травы и песка. Хватит обсуждать иллюстрацию. Загрузите программу D3D_MapEditorLayers и следуйте вперед.

Изменения в заголовочном файле

Программа D3D_MapEditorLayers базируется на предыдущих версиях редактора карт, так что большая его часть должна выглядеть для вас знакомо. Первое принципиальное отличие находится в заголовочном файле main.h. Оно показано в приведенном ниже коде:

int   g_iTileSize  = 32;
int   g_iTilesWide = 20;
int   g_iTilesHigh = 15;
int   g_iMapWidth  = 100;
int   g_iMapHeight = 100;
int   g_iXPos = 0;
int   g_iYPos = 0;
int   g_iTileMap[10000][4];
int   g_iCurTile    = 0;
int   g_iCurTileSet = 0;
int   g_iMaxTileSet = 3;
int   g_iTotalTiles = 18;
int   g_iCurLayer   = 0;

Многомерный массив

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

Следующее изменение — добавление переменной g_iCurLayer. Она отслеживает с каким именно слоем карты ведется работа. Это очень важно знать, когда вы размещаете новый блок в окне редактирования. Программа должна знать куда его поместить!

Функция смены слоя

Для поддержки переключения слоев я также добавил в редактор новую функцию. Вот ее прототип:

void vChangeLayer(int iLayer);

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

Переменные для новых кнопок

Чтобы кнопки переключения слоев правильно функционировали, каждой из них требуется дескриптор окна и уникальный идентификатор. Соответствующий код приведен ниже:

const int    ID_BUTTON_LAYER1 = 40006;
const int    ID_BUTTON_LAYER2 = 40007;
const int    ID_BUTTON_LAYER3 = 40008;
const int    ID_BUTTON_LAYER4 = 40009;
HWND         hBUTTON_LAYER1   = NULL;
HWND         hBUTTON_LAYER2   = NULL;
HWND         hBUTTON_LAYER3   = NULL;
HWND         hBUTTON_LAYER4   = NULL;

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

Изменения в функции vCreateToolbar()

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

hBUTTON_LAYER1 = CreateWindow(
   "BUTTON", "1",
   WS_CHILD | WS_VISIBLE | BS_DEFPUSHBUTTON,
   3, 275, 20, 20, hWndToolBar, (HMENU)ID_BUTTON_LAYER1,
   hinst, NULL);
hBUTTON_LAYER2 = CreateWindow(
   "BUTTON", "2",
   WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
   25, 275, 20, 20, hWndToolBar, (HMENU)ID_BUTTON_LAYER2,
   hinst, NULL);
hBUTTON_LAYER3 = CreateWindow(
   "BUTTON", "3",
   WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
   48, 275, 20, 20, hWndToolBar, (HMENU)ID_BUTTON_LAYER3,
   hinst, NULL);
hBUTTON_LAYER4 = CreateWindow(
   "BUTTON", "4",
   WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
   71, 275, 20, 20, hWndToolBar, (HMENU)ID_BUTTON_LAYER4,
   hinst, NULL);

В приведенном выше коде ясно видны четыре блока. Каждый из них отображает одну кнопку переключения слоя блоков. Одна из этих кнопок отличается от других. Внимательно посмотрите на код, и вы увидите, что тип первой кнопки — BS_DEFPUSHBUTTON. Это значение сообщает графическому интерфейсу о необходимости нарисовать вокруг кнопки черный прямоугольник. Я использую этот прямоугольник, чтобы показать, какой слой является активным. Поскольку по умолчанию программа работает с нулевым слоем, я делаю активной первую кнопку переключения слоев.

Функция vChangeLayer()

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

void vChangeLayer(int iLayer)
{
   // Уничтожение кнопок слоев
   DestroyWindow(hBUTTON_LAYER1);
   DestroyWindow(hBUTTON_LAYER2);
   DestroyWindow(hBUTTON_LAYER3);
   DestroyWindow(hBUTTON_LAYER4);

   // Установка кнопок в состояние по умолчанию
   hBUTTON_LAYER1 = CreateWindow(
      "BUTTON", "1",
      WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
      3, 275, 20, 20, hWndToolBar, (HMENU)ID_BUTTON_LAYER1,
      g_hInstance, NULL);
   hBUTTON_LAYER2 = CreateWindow(
      "BUTTON", "2",
      WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
      25, 275, 20, 20, hWndToolBar, (HMENU)ID_BUTTON_LAYER2,
      g_hInstance, NULL);
   hBUTTON_LAYER3 = CreateWindow(
      "BUTTON", "3",
      WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
      48, 275, 20, 20, hWndToolBar, (HMENU)ID_BUTTON_LAYER3,
      g_hInstance, NULL);
   hBUTTON_LAYER4 = CreateWindow(
      "BUTTON", "4",
      WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
      71, 275, 20, 20, hWndToolBar, (HMENU)ID_BUTTON_LAYER4,
      g_hInstance, NULL);

   // Активация требуемой кнопки
   if(iLayer == 1) {
      DestroyWindow(hBUTTON_LAYER1);
      hBUTTON_LAYER1 = CreateWindow(
         "BUTTON", "1",
         WS_CHILD | WS_VISIBLE | BS_DEFPUSHBUTTON,
         3, 275, 20, 20, hWndToolBar, (HMENU)ID_BUTTON_LAYER1,
         g_hInstance, NULL);
   }
   else if(iLayer == 2) {
      DestroyWindow(hBUTTON_LAYER2);
      hBUTTON_LAYER2 = CreateWindow(
         "BUTTON", "2",
         WS_CHILD | WS_VISIBLE | BS_DEFPUSHBUTTON,
         25, 275, 20, 20, hWndToolBar,
         (HMENU)ID_BUTTON_LAYER2,
         g_hInstance, NULL);
   }
   else if(iLayer == 3) {
      DestroyWindow(hBUTTON_LAYER3);
      hBUTTON_LAYER3 = CreateWindow(
         "BUTTON", "3",
         WS_CHILD | WS_VISIBLE | BS_DEFPUSHBUTTON,
         48, 275, 20, 20, hWndToolBar,
         (HMENU)ID_BUTTON_LAYER3,
         g_hInstance, NULL);
   }
   else if(iLayer == 4) {
      DestroyWindow(hBUTTON_LAYER4);
      hBUTTON_LAYER4 = CreateWindow(
         "BUTTON", "4",
         WS_CHILD | WS_VISIBLE | BS_DEFPUSHBUTTON,
         71, 275, 20, 20, hWndToolBar,
         (HMENU)ID_BUTTON_LAYER4,
         g_hInstance, NULL);
   }

   // Установка текущего слоя
   g_iCurLayer = (iLayer - 1);

   PlaySound("button.wav", NULL, SND_FILENAME|SND_ASYNC);
}

Возьмем к примеру кнопку слоя с номером 2. Когда вы щелкаете по ней, выполняется вызов функции и значение ее параметра iLayer равно 2. Функция уничтожает кнопки слоев, а затем создает снова без черной рамки вокруг. Затем функция проверяет, на какой слой указывает параметр iLayer. Она доходит до второй проверки и вновь уничтожает кнопку второго слоя. Затем кнопка создается вновь, но уже с черной рамкой вокруг, показывающей, что данный слой активен. В самом конце кода функции переменной g_iCurLayer также присваивается значение, соответствующее активному слою.

Изменение процедур сохранения и загрузки

Функции vSaveMap() и vLoadMap() модифицированы для включения в каждую карту информации о дополнительных слоях. Поскольку в примере поддерживается четыре слоя, будет сохраняться и записываться в четыре раза больше данных. Необходимые для этого изменения кода минимальны. Приведенная ниже строка показывает изменения, необходимые для функции vLoadMap():

fread(g_iTileMap, 40000, sizeof(int), fp);

Обратите внимание, что функция fread() считывает 40 000 целых чисел, а не 10 000, как раньше. Аналогичные изменения вносятся и в функцию vSaveMap():

fwrite(g_iTileMap, 40000, sizeof(int), fp);

В функции сохранения карты количество записываемых чисел также изменено с 10 000 на 40 000. Это единственное изменение, которое необходимо сделать в функции записи.

ВНИМАНИЕ!
Не пытайтесь загружать карты, созданные одной версией редактора в другую версию. Это может привести к краху программы.

Изменения в функции vRender()

Для поддержки многослойных блоков необходимо внести изменения и в функцию vRender(). К счастью, объем вносимых изменений незначителен и все они сконцентрированы в небольшом фрагменте кода. Вот как выглядит измененный код:

// Слои
for(iLayer = 0; iLayer < 4; iLayer++) {
   // Вычисляем смещение в буфере
   iBufferPos = iX+g_iXPos+((iY+g_iYPos)*g_iMapWidth);
   // Получаем требуемый блок
   iCurTile = g_iTileMap[iBufferPos][iLayer];
   // Отображаем блок
   if(iCurTile != 0 || iLayer == 0) {
      vDrawInterfaceObject((iX * g_iTileSize),
                   (iY * g_iTileSize),
                   (float)g_iTileSize,
                   (float)g_iTileSize,
                   iCurTile);
   }
}

Поскольку программа поддерживает четыре слоя, вы должны в цикле перебрать каждый из четырех слоев каждого блока карты. Если в данном блоке присутствует слой, он отображается. Но из этого правила есть исключение. Если текущий слой не первый, в нем не может быть блоков с номером 0. Блоки с номером 0, расположенные выше первого слоя просто не отображаются. Так реализуется прозрачность слоев. Вы можете считать, что второй третий и четвертый слои представляют собой растровые изображения у которых цвет с кодом 0 является прозрачным. Если в этих слоях встречается блок с номером 0, он просто не отображается.

На рис. 10.17 показано совмещение слоев в действии. Там изображены четыре слоя с блоками. Первый слой заполнен блоками с номером 1. Большая часть второго слоя заполнена блоками с номером 0, но кроме этого там есть несколько блоков с номером 2. Большая часть третьего слоя также заполнена блоками с номером 0, но на нем есть и несколько блоков с номером 3. Аналогичным образом устроен и четвертый слой. Когда слои совмещаются вместе, блок с номером 0 работает как цветовой ключ для размещения второго, третьего и четвертого слоев поверх первого. Результат виден в нижней части иллюстрации. Рассмотрев изображенные в левой части рисунка отдельные блоки вы поймете, как он получен.

Рис. 10.17. Объединение слоев карты

Рис. 10.17. Объединение слоев карты

Изменения в функции vCheckMouse()

Поскольку вам необходима возможность редактировать различные слои карты, требуется внести изменения в функцию vCheckMouse(). Вот как выглядит измененный код:

g_iTileMap[iTileX+g_iXPos+
   ((iTileY + g_iYPos)
   * g_iMapWidth)][g_iCurLayer]
   = g_iCurTile;

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

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


Глава 10 - Итоги

Итоги

Эта глава познакомила вас с основами редактирования, просмотра и генерации карт, а также с методами отображения мини-карты. Вспомните следующие ключевые моменты:

  • Редактор карт является одной из наиболее важных частей вашего проекта. Если с вашим редактором карт сложно работать, создание карт для игры будет занимать очень много времени и игроки не захотят создавать свои собственные карты.
  • Кнопки интерфейса редактора карт хорошо размещать на панели инструментов. Сделайте панель инструментов перемещаемой, чтобы она не занимала ценное пространство области редактирования.
  • Мини-карта выглядит более реалистично, если при ее отображении вы масштабируете исходные блоки, а не используете цветовое представление блоков. Учтите, что это влияет на скорость, поскольку для масштабирования требуются дополнительные ресурсы системы визуализации.
  • Существует множество методов алгоритмической генерации карт. Попробуйте сперва продемонстрированный мной простейший метод, а затем усовершенствуйте его, пока не получите мощный генератор случайных ландшафтов.
  • Слои предоставляют вам большую свободу творчества при создании карт. Благодаря им вы сможете использовать перекрытия, полупрозрачные блоки и многие другие эффекты.


Глава 11. Трехмерная анимация

Краткий обзор

Хотелось ли вам когда-нибудь узнать о том, как поместить анимированые трехмерные объекты в свои игры? Хочется прикоснуться к прекрасному искусству трехмерной анимации, ускользнувшему от вас в прошлом? К счастью, эта глава прольет свет и даст ответы на некоторые ваши вопросы. В ней я расскажу об основах редактирования трехмерной анимации и методах загрузки анимации в ваши собственные программы. Более того, я даже познакомлю вас с некоторыми техниками моделирования! Будут рассмотрены следующие темы:

  • Обзор трехмерной анимации.
  • Импорт содержимого.
  • Редактирование анимации.
  • Программирование анимации.
  • Встраивание анимации в программы.


Глава 11 - Обзор трехмерной анимации

Обзор трехмерной анимации

Сперва выясним, что же такое трехмерная анимация? В двух словах — это выполнение действий с одним или несколькими объектами в трехмерном пространстве, изменяющих их с течением времени каким-либо образом. Думаете, почему это нужно вам, разработчику стратегических игр? Хорошо, объясню специально для начинающих: трехмерный танк в вашей игре будет выглядеть не слишком правдоподобно, если его гусеницы не двигаются, а трехмерный механоид не выглядит впечатляющим, если антена его радара не вращается. Чтобы понять как трехмерная анимация вписывается в общую картину разработки игр, сперва надо усвоить следующие концепции:

  • Сцена
  • Объекты
  • Ключевые кадры
  • Анимационные наборы

Сцена

Первое, что потребуется вам для трехмерной анимации — сцена. Сцена это всего лишь набор трехмерных объектов. Звучит элементарно, правда? Взгляните на сцену, изображенную на рис. 11.1.

Рис. 11.1. Трехмерная сцена визуализированная в 3ds max

Рис. 11.1. Трехмерная сцена визуализированная в 3ds max

На иллюстрации показан результат визуализации созданной в 3ds max сцены. (Компания Discreet была настолько любезна, что предоставила старому доброму автору свое программное обеспечение для этой книги, так что пожалуйста рассмотрите их программы, когда будете выбирать пакет трехмерного моделирования для себя.) Обратите внимание на составляющие сцену трехмерные объекты. Самыый бросающийся в глаза объект — вода. Кроме того, здесь есть небо, несколько островов и одинокая рыбацкая лодка. Все эти объекты вместе и составляют сцену. Поняли? Хорошо, идем дальше.

Объекты

Объекты — это источник жизненной силы трехмерной анимации. Без объектов не было бы ни сцен ни анимации. Что такое объект? В этой главе объектом будет называться трехмерная модель, состоящая из полигонов, цветов и текстур. Возьмем к примеру механоида, упомянутого мной минутой раньше. Механоид, как вы хорошо знаете, представляет собой бронированного боевого робота. К тому же у рассматриваемого в примере механоида наверху есть вращающаяся антена радара. Поскольку антена вращается независимо от движения тела механоида, она является отдельным объектом. Итак, в этом примере у нас два трехмерных объекта: тело механоида и антена радара. Хотя эти два объекта всегда существуют вместе, они должны быть отдельными, чтобы вы могли анимировать их независимо друг от друга. Другой пример, танк, изображен на рис. 11.2.

Рис. 11.2. Танк

Рис. 11.2. Танк

Как видите, танк состоит из башни, корпуса, гусениц и колес. Поскольку башня может вращаться независимо от корпуса, она должна быть отдельным объектом. То же самое справедливо для гусениц и колес.

Фиксированные объекты

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

Ключевые кадры

Теперь мы переходим к будничной, но абсолютно необходимой части анимации. Чтобы анимировать объект вы задаете серию ключевых кадров. В каждом кадре вы вносите какое-нибудь изменение, чтобы объект отличался от своего изображения в предыдущем кадре. Для наших целей при переходе от одного кадра к другому вы можете поворачивать, перемещать или масштабировать объект. Предположим, вы хотите, чтобы башня танка повернулась на 45 градусов. Взгляните на рис. 11.3.

Рис. 11.3. Два кадра танка

Рис. 11.3. Два кадра танка

На иллюстрации изображены два ключевых кадра танка. На левом кадре объект башни находится в исходном состоянии. На кадре справа объект башни повернут на 45 градусов влево. Как видите, ключевой кадр это просто снимок объекта в определенный момент времени. На рис. 11.3 есть одна проблема — при наличии только двух кадров нельзя показать постепенный разворот башни. Это элементарно решается путем добавления дополнительных кадров. Взгляните на рис. 11.4 где представлена более детальная анимация танка.

Рис. 11.4. Более детальная анимация танка

Рис. 11.4. Более детальная анимация танка

Здесь в анимации башни больше ключевых кадров. Вместо двух их стало шесть. Это повышает качество анимации.

Автоматическая вставка промежуточных кадров

Помните, что вам не обязательно создавать ключевой кадр для каждого изменения объекта. Вы можете использовать метод, называемый автоматическая вставка промежуточных кадров (tweening). В этом случае вы задаете ключевые кадры и алгоритм, который будет генерировать промежуточные изображения для плавной анимации. Пример представлен на рис. 11.5.

Рис. 11.5. Автоматическая вставка промежуточных кадров при анимации танка

Рис. 11.5. Автоматическая вставка промежуточных кадров при анимации танка

На рис. 11.5 вы видите ту же самую анимацию танка, но на этот раз я отметил ключевые кадры дополнительно обведя контур танка. Остальные, промежуточные кадры создаются автоматически, чтобы обеспечить плавный переход от одного ключевого кадра к другому. Ясно? Хотя автоматическая генерация промежуточных кадров во многих случаях может оказаться полезной, в примерах программ из данной главы ради простоты я ее не применяю.

Анимационные наборы

К настоящему моменту у вас есть сцена, состоящая из объектов с набором ключевых кадров для выполнения анимации. Кроме этого вооружения аниматора вам необходимы анимационные наборы. Зачем? Чтобы ваша жизнь стала проще. Вы знаете что говорится: программисты по своей природе ленивы. Именно поэтому они всегда пытаются писать программы, для того чтобы упростить работу. Или чтобы не покупать изделия других программистов. Кто еще работает 26 часов в сутки? Шутка. Если серьезно, анимационные наборы — хороший способ сэкономить время при разработке игры. Взгляните на рис. 11.6, где изображены анимационные наборы в действии.

Рис. 11.6. Анимационный набор для танка

Рис. 11.6. Анимационный набор для танка

На рис. 11.6 изображены три анимационных набора для танка. Верхний называется «поворот башни влево», средний — «поворот башни вправо», а нижний — «откат орудия». Идентифицировав таким образом анимационные наборы, вы можете произвольным образом комбинировать их, чтобы оживить объекты в игре. Возьмем, к примеру, стратегическую игру в которой требуется, чтобы башня танка повернулась влево, затем возвратилась в исходное положение, после чего был бы произведен выстрел из орудия. Если попробовать сделать для этого отдельную анимацию, то в результате у вас будут тысячи, если не миллионы отдельных анимаций для изображения каждого возможного сценария. Благодаря анимационным наборам вы можете создавать небольшие простые анимации, а затем динамически объединять их. Ниже приведен список анимационных наборов, необходимых для футуристической игры с боями механоидов.

  • Выстрел оружия.
  • Взрыв.
  • Падение.
  • Прыжок.
  • Включение щитов.
  • Ходьба.
  • Бег.
  • Шаг в сторону.

В списке перечислены названия различных анимационных наборов, комбинация которых позволяет реализовать динамическую анимацию объекта. Правда, здорово?

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


Глава 11 - Импорт содержимого

Импорт содержимого

Поскольку для анимации абсолютно необходимы трехмерные объекты, следует рассмотреть несколько способов создания моделей. Обычно эта работа выполняется с помощью профессиональных пакетов трехмерного моделирования, таких как Softimage, Maya или 3ds max. Есть несколько свободно распространяемых или более дешевых программ моделирования, таких как trueSpace, MilkShape и Rhino.

СОВЕТ
На рынке есть множество продуктов, но мой любимый — 3ds max компании Discreet. Помимо того, что его цена сравнительно небольшая, если учесть предоставляемые возможности, в пакет встроена самая мощная система анимации из тех, которые я использовал. Для всех примеров из этой книги я использую 3ds max. Даже если у вас нет этой программы, вы сможете использовать описанные здесь приемы в своей программе моделирования.

DirectX поддерживает работу с трехмерными моделями, представленными в виде файлов .x. Большинство программ моделирования поддерживают преобразование собственных форматов в формат .x, так что вам не придется беспокоиться об экспорте моделей.

Экспорт из 3ds max

Парни в Microsoft были достаточно любезны и снабдили вас исходным кодом программы экспорта файлов из 3ds max в DirectX. Его можно загрузить со страницы DirectX SDK и он замечательно работает. Скомпилировав и установив его вы сможете экспортировать модели 3ds max несколькими щелчками мыши. Не хотите взглянуть на пример экспорта?

Основы моделирования

Экспорт — достаточно прямолинейный процесс. Для начала вам потребуется модель, которую будем экспортировать. Давайте создадим простейшую модель бокала для вина. Запустите программу 3ds max и следуйте за мной. Вы должны увидеть интерфейс, похожий на тот, что изображен на рис. 11.7.

Рис. 11.7. Интерфейс программы 3ds max

Рис. 11.7. Интерфейс программы 3ds max

Поскольку программа обладает огромными возможностями настройки внешнего вида, ваш интерфейс может значительно отличаться от моего, но в любом случае вы получите отправной пункт. Важно помнить, что DirectX и 3ds max используют различные системы координат. В 3ds max ось Z применяется для перемещения объекта вверх или вниз, а в DirectX для этого используется ось Y. Поэтому, создавая объекты в 3ds max следует быть очень аккуратным; иначе они могут оказаться повернутыми самым неожиданным образом.

Поскольку в DirectX высота объекта задается вдоль оси Y, выберите в 3ds max окно вида сверху (Top view) и нажмите клавишу W, чтобы развернуть его на весь экран. В результате интерфейс программы будет выглядеть так, как изображено на рис. 11.8.

Рис. 11.8. Вид сверху в 3ds max

Рис. 11.8. Вид сверху в 3ds max

После того, как выбран вид сверху, измените масштаб изображения таким образом, чтобы на экране по вертикали помещалось примерно шесть квадратов сетки. Теперь пора создать контур бокала. Это делается с помощью команды Line shape из выпадающего меню Splines. Выберите этот пункт и нарисуйте контур, похожий на изображенный на рис. 11.9.

Рис. 11.9. Контур бокала для вина

Рис. 11.9. Контур бокала для вина

На рис. 11.9 изображен фрагмент поперечного сечения бокала. Я знаю, что он выглядит достаточно угловато, но имея дело с трехмерной графикой в реальном времени надо стараться уменьшить количество полигонов в моделях. Если вы ошиблись, размещая вершины, отредактируйте модель, выбрав пункт Edit Mesh в списке Modifier List. Перемещайте вершины, пока не будете удовлетворены получившимся контуром, а затем выберите пункт Lathe в списке Modifier List. Если вы все сделали правильно, то увидите картинку, похожую на рис. 11.10.

Рис. 11.10. Результат вращения контура бокала

Рис. 11.10. Результат вращения контура бокала

На рис. 11.10 изображен результат вращения контура в 3ds max. Объект действительно вращается, но ось вращения задана неправильно. К счастью, это легко исправить, щелкнув по кнопке Max расположенной в рамке Align панели Parameters. Щелкните по кнопке Max и изображение станет выглядеть так, как изображено на рис. 11.11.

Рис. 11.11. Правильное вращение контура бокала

Рис. 11.11. Правильное вращение контура бокала

Трехмерные модели довольно скучны без текстур, так что убедитесь, что в команде Lathe вы задали координаты текстур объекта. Для этого установите флажок Generate Mapping Coordinates в панели Parameters. Затем в той же панели установите флажок Weld Core. Этот параметр объединяет вершины, образующие ось вращения модели.

Нажмите клавишу W, чтобы вернуться к четырем окнам просмотра. Выберите окно Perspective и повращайте камеру, чтобы получить изображение бокала, похожее на рис. 11.12.

Рис. 11.12. Изображение бокала в окне Perspective

Рис. 11.12. Изображение бокала в окне Perspective

На рис. 11.12 вы видите изображение бокала в окнах Top и Perspective. Перед тем, как экспортировать склянку, нужно преобразовать ее в редактируемую сетку. Это необходимо потому, что DirectX не знает как работать непосредственно с объектами 3ds max. Щелкните правой кнопкой мыши по изображению бокала, выберите в появившемся меню команду Convert To, а затем Convert To Editable Mesh. Эта операция не вызовет никаких изменений во внешнем виде бокала, но изменит то, как на него будет реагировать DirectX. После того, как конвертирование завершено, пришло время экспортировать модель. Раскройте меню File и выберите команду Export Selected. На экран будет выведено диалоговое окно, в котором следует указать имя объекта и выбрать тип файла. Введите имя файла и в выпадающем списке Save as выберите формат X-File (*.X). Сохраните объект и вам будет предложено окно, изображенное на рис. 11.13.

Рис. 11.13. Параметры экспорта X-файлов

Рис. 11.13. Параметры экспорта X-файлов

Снимите флажки Include Animation Data и Looping Animation Data, а затем щелкните по кнопке Go!.

ПРИМЕЧАНИЕ
Чтобы в выпадающем списке Save as присутствовал формат X-файлов, вы должны установить программу экспорта X-файлов от Microsoft. Если у вас в списке нет пункта для сохранения X-файлов, установите программу экспортера.

Загрузка объекта

Теперь, когда у вас есть замечательный X-файл, загрузите его в программу D3DFrame_ObjectLoader, которая вместе с исходными кодами находится среди сопроводительных файлов. Вам потребуется лишь слегка изменить код, чтобы считывался файл wineglass.x. Внесите изменения в код, скомпилируйте его и перед тем, как запустить программу, убедитесь, что в каталоге с программой есть файл wineglass.x. Если все сделано правильно, вы увидите окно, похожее на то, что изображено на рис. 11.14.

Рис. 11.14. Просмотр бокала в Direct3D

Рис. 11.14. Просмотр бокала в Direct3D

На иллюстрации изображена модель бокала. загруженная в программу для просмотра объектов. Обратите внимание, что счетчик частоты кадров показывает около 3100 кадров в секунду. Неплохо, да? На самом деле, мне уже давно пора обновить компьютер. Но это как нибудь в другой раз.

Программа Convert 3DS

Если у вас нет доступа к 3ds max или вы не хотите компилировать плагин для экспорта файлов .x, можно воспользоваться программой Conv3ds.exe, поставляемой вместе с DirectX SDK. Это старая утилита и на данный момент она может быть уже недоступна, но она позволяет вам преобразовывать файлы .3ds из командной строки.

Самое замечательное в Conv3ds.exe то, что для создания файла .x ей необходим только объект в формате .3ds. К счастью, большинство программ позволяют экспортировать объекты в файлы .3ds. Благодаря этому создание файлов .x в программах, отличающихся от 3ds max оказывается достаточно простым делом.


Глава 11 - Редактирование анимации

Редактирование анимации

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

Готовые редакторы анимации

Использование готового редактора может казаться лучшим вариантом, но в этом случае вам придется разобраться как считывать данные редактора и как использовать их в своей игре. Вы также будете зависеть от графика выпуска новых версий программы. Что произойдет, если в новой версии производитель изменит формат данных? Вам придется заново переписывать код чтения файлов редактора.

Несмотря на перечисленные недостатки коммерческих редакторов, у них есть одно важное преимущество: вам не придется тратить время на программирование собственного редактора! Программирование инструментальных средств — одна из самых больших частей игрового проекта, и чем меньше вам придется программировать — тем лучше.

Программирование собственного редактора

Большинство разработчиков любят писать собственный код, так что для них создание своего редактора может на первый взгляд показаться лучшим вариантом. Это не совсем справедливо в случае редактирования анимации. Как я говорил минуту назад, если вы решите сами программировать редактор анимации, то потратите значительное время на разработку редактора, а не на работу над самой игрой. Но в конце вы получите собственный редактор анимации, который работает независимо от того, что делают другие компании. Это — самая большая польза от написания собственного редактора.

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

Итак, у нас получилась абсолютно двусмысленная дискуссия о том, что лучше — написать собственный редактор или воспользоваться готовым. Я оставляю выбор за вами, так как мое мнение — это всего лишь мнение. Тем времением, позвольте мне показать вам как начать разработку собственного редактора анимации. Прежде чем перейти к коду, необходимо познакомиться с составными частями редактора анимации. Далее я приведу описание следующих компонентов:

  • Интерфейс
  • Команды
  • Процесс редактирования
  • Сохранение и загрузка

Интерфейс редактора анимации

Для редактора анимации вам потребуется область редактирования и панель инструментов для кнопок команд. Эти базовые компоненты интерфейса показаны на рис. 11.15.

Рис. 11.15. Интерфейс редактора анимации

Рис. 11.15. Интерфейс редактора анимации

Рисунок выглядит очень похожим на программу редактирования карт. Слева расположено окно редактирования, а справа — панель инструментов. Кроме того, в верхнем левом углу окна редактирования отображается отладочная информация. В окне панели инструментов располагаются кнопки и информация об анимации.

Команды редактора анимации

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

  • Команды работы с кадрами
  • Команды работы с объектами
  • Команды воспроизведения
  • Команды работы с файлами
Команды работы с кадрами

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

Команды работы с объектами

Для трехмерных сцен нужны объекты, так что вам необходим способ добавлять объекты к редактируемой сцене. Потребуются команды для загрузки объектов, их удаления и выбора требуемого объекта в сцене.

Команды воспроизведения

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

Команды работы с файлами

Любой редактор будет не слишком хорош, если у вас нет возможности сохранить полученные данные анимации и загрузить их. Поэтому абсолютно необходимы кнопки Load и Save. Начните с простейших операций сохранения и загрузки, а затем можете их расширить, добавив дополнительные функции. Как насчет навигационной системы, позволяющей вам выбирать загружаемую анимацию, видя ее начальный кадр? Это будет выглядеть очень круто!

Процесс редактирования

Процесс редактирования может быть настолько прост или настолько сложен, как вам хочется. Возьмем для примера 3ds max. В этой программе есть тысячи параметров и команд для редактирования анимации. Вы можете потратить годы, прежде чем узнаете все об их назначении и действии. Я работал с этой программой как профессиональный аниматор более четырех лет, и все еще продолжаю узнавать о ней что-то новое почти каждый день! Так как возможности безграничны, я предлагаю начать вам с самых основ.

Поскольку вам нужна сцена, первое, что должен сделать редактор — предоставить вам возможность загрузить трехмерные объекты. Как только у вас будут объекты, потребуется возможность создавать для объектов ключевые кадры. После этого потребуется возможность модифицировать ключевые кадры для анимации объекта. Этот процесс показан на рис. 11.16.

Рис. 11.16. Процесс редактирования

Рис. 11.16. Процесс редактирования

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

Чтобы изменять ключевые кадры вам требуется по крайней мере возможность менять местоположение и угол поворота объектов сцены. Позднее вы, возможно, захотите добавить возможность менять масштаб объектов. Я предпочитаю предоставлять пользователям комбинации клавиш, нажатие на которые меняет местоположение и разворот объекта, а также поля редактирования для ввода точных значений. Поля редактирования облегчают ввод повторяющихся команд. Гораздо проще ввести в поле число 180, чем 180 раз нажимать на кнопку «плюс».

Сохранение и загрузка

Теперь, когда подготовлена вся информация для анимации, с ней необходимо что-то сделать. Здесь в игру вступают функции сохранения и загрузки. Как помните, анимационные наборы очень важны для создания динамических анимаций в вашей игре. Поэтому редактор должен быть способен сохранить созданный в нем анимационный набор. Вам также необходима возможность загрузить созданную ранее анимацию, чтобы отредактировать или просто воспроизвести ее. Для этого вам нужны, по крайней мере, кнопки Save и Load.


Глава 11 - Программирование редактора

Программирование редактора

Для вашего удовольствия я включил в сопроводительные файлы к книге очень простой редактор анимации. Как это ни странно, проект называется D3D_AnimationEditor. Пожалуйста, загрузите его, и давайте проследуем дальше.

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

ВНИМАНИЕ!
Кнопка Save Anim перезапишет пример анимации, который я создал для вас. Не выполняйте запись, если не хотите чтобы пример анимации был перезаписан.

ВНИМАНИЕ!
Кнопка Load Anim стирает всю проделанную работу и заменяет ее на последнюю записанную на диск анимацию. Не загружайте анимацию, если не хотите потерять текущее состояние сцены.

Чтобы получить представление о программе, взгляните на рис. 11.17, где изображено ее окно, или просто запустите приложение.

Рис. 11.17. Окно программы D3D_AnimationEditor

Рис. 11.17. Окно программы D3D_AnimationEditor

На рис. 11.7 изображено как реализация редактора анимации выглядит в реальной жизни. Вы, возможно, скажете, что она очень похожа на интерфейс редактора, созданного в предыдущей главе. Окно слева содержит область редактирования с отладочной информацией, а окно справа — панель команд и информацию. В окне редактирования вы видите загруженные и готовые к использованию объекты механоида и антены радара. Если вы скомпилировали и запустили программу, чтобы она стала похожа на рис. 11.17, щелкните по кнопке Load Anim.

Элементы управления программы D3D_AnimationEditor

Если вы еще не сделали этого, запустите редактор анимации и щелкните по кнопке Load Anim. В результате будет загружена предварительно созданная мной анимация. После выполнения загрузки щелкните по кнопке Start/Stop Anim, чтобы начать воспроизведение анимации. Если все прошло хорошо, вы увидите как расположенная на голове механоида радарная антена начнет вращаться.

Вы можете остановить воспроизводимую анимацию, еще раз щелкнув по кнопке Start/Stop Anim. Фактически эта кнопка переключает состояние анимации между воспроизведением и паузой. Пока анимация воспроизводится вы можете даже добавлять в нее ключевые кадры и изменять данные. Это подводит меня к следующему набору элементов управления — элементы управления кадрами. На панели команд есть три команды для работы с кадрами: Prev, Next и New. Щелчок по кнопке Prev Frame приводит к переходу от текущего кадра анимации к предшествующему в списке. Если вы достигли начала анимации, последовательность кадров замыкается и вы перейдете к последнему кадру последовательности. Перебирая кадры вы будете видеть их данные, выводимые в области отладочной информации главного окна редактирования. Кроме того, данные местоположения и вращения появляются в текстовых полях, расположенных в окне панели команд.

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

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

Следом на панели инструментов расположены команды для работы с объектами: Prev Obj, Next Obj и Load Objects. Щелчок по кнопке Prev Obj приведет к тому, что текущим станет объект, который был добавлен к сцене перед выбранным в данный момент. Если вы достигли начала списка объектов, текущим станет последний добавленный к сцене объект. Кнопка Next Obj работает точно так же, но перебирает объекты в прямом, а не в обратном направлении. В рассматриваемом примере есть всего лишь два объекта (механоид и антена радара). Кнопка Load Objects используется для загрузки в сцену трехмерных объектов. Чтобы упростить пример я жестко запрограммировал эту кнопку на загрузку механоида и антены радара. В реальном редакторе вам надо будет добавить возможность выбора произвольного объекта для загрузки, а не прописывать все в коде.

И, наконец, кнопки Load Anim и Save Anim. Кнопка Load Anim загружает файл анимации с именем RobotIdle. Он содержит анимацию, специально созданную мной для данного примера. В реальном редакторе вам надо указывать имя загружаемого файла, а в примере для простоты я его жестко прописал в коде. Кнопка Save Anim записывает данные о созданной в редакторе анимации в файл с именем RobotIdle. Будьте очень аккуратны, чтобы по неосторожности не перезаписать файл!

Проект D3D_AnimationEditor

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

Рис. 11.18. Структура файлов проекта D3D_AnimationEditor

Рис. 11.18. Структура файлов проекта D3D_AnimationEditor

На рисунке видно, как в файл main.cpp включается файл main.h. Файл main.h в свою очередь включает заголовочный файл C3DAnimation.h. Заголовочный файл C3DAnimation.h содержит сведения о классе анимации и заголовочный файл Object3DClass.h. Заголовочный файл Object3Dclass.h включает информацию для загрузки объектов из файлов .x и их визуализации. Кроме того, он включает заголовочный файл ExceptionClass.h. Заголовочный файл ExceptionClass.h содержит информацию о классе исключений и заголовочный файл DXUtil.h, предоставляемый DirectX SDK.

Если посмотреть на файлы, которые не являются заголовочными, то основная логика редактора находится в файле main.cpp. Класс анимации расположен в файле C3DAnimation.cpp, класс трехмерных объектов — в файле Object3DClass.cpp, а класс исключений — в файле ExceptionClass.cpp.

Что касается библиотек, то для успешной компиляции проекта нам потребуются: d3d9.lib, dxguid.lib, d3dx9dt.lib, d3dxof.lib, comctl32.lib, winmm.lib и dinput8.lib.

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

Класс C3DAnimation

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

Рис. 11.19. Структура класса C3DAnimation

Рис. 11.19. Структура класса C3DAnimation

На рисунке методы класса сгруппированы согласно их назначению. Например, сгруппированы все функции, относящиеся к работе с кадрами. Перед тем, как описывать функции, я приведу код заголовка класса:

const int g_iMaxObjects = 16;
const int g_iMaxKeys = 1024;

struct stKeyFrame
{
   D3DXVECTOR3  m_vecRot;
   D3DXVECTOR3  m_vecTrans;
   D3DXVECTOR3  m_vecScale;
   long         m_lTimeDelay;
};

class C3DAnimation
{
   public:
      stKeyFrame          *m_keyFrames[g_iMaxObjects][g_iMaxKeys];
      Object3DClass       *m_objObject[g_iMaxObjects];
      char                m_szObjectName[g_iMaxObjects][32];
      int                 m_iNumFrames;
      int                 m_iNumObjects;
      char                m_szAnimName[64];
      int                 m_iCurFrame;
      long                m_lCurTime;
      LPDIRECT3DDEVICE9   m_pd3dDevice;
      C3DAnimation();
      ~C3DAnimation();
      void   vNewFrame(void);
      int    iNextFrame(void);
      int    iPrevFrame(void);
      int    iStartFrame(void);
      int    iNewObj(char *szObjName);
      void   vUpdateTrans(int iObj, int iKey,
                          D3DXVECTOR3 vecTrans);
      void   vUpdateRot(int iObj, int iKey,
                        D3DXVECTOR3 vecRot);
      void   vUpdateScale(int iObj, int iKey,
                          D3DXVECTOR3 vecScale);
      void   vSave(char *szFileName);
      void   vLoad(char *szFileName);
      void   vSet3DDevice(LPDIRECT3DDEVICE9 pd3dDevice);
      void   vReset(void);
};

Методы класса C3DAnimation

Методы класса анимации реализуют базовый набор действий, необходимых для редактирования. Ниже приводится их краткое описание.

Функция C3DAnimation() — это стандартный конструктор класса, который инициализирует члены данных класса анимации.

Функция ~C3DAnimation() — это деструктор, освобождающий выделенную память при уничтожении объекта.

Функция vSave() вызывается для записи данных анимации в указанный файл.

Функция vLoad() вызывается для загрузки указанного файла анимации.

Функция vSet3Ddevice() применяется для установки внутреннего указателя на устройство Direct3D.

Функция vReset() освобождает всю выделенную для объекта анимации память и выполняет инициализацию данных. Ее вызывает конструктор во время инициализации объекта.

Функция vNewFrame() создает новый кадр для каждого объекта анимации.

Функция iNextFrame() осуществляет переход к следующему кадру.

Функция iPrevFrame() осуществляет возврат к предыдущему кадру.

Функция iStartFrame() выполняет возврат анимации к начальному кадру.

Функция iNewObj() добавляет трехмерный объект к анимируемой сцене.

Функция vUpdateTrans() получает вектор и прибавляет его к текущему вектору местоположения выбранного объекта в заданном кадре.

Функция vUpdateRot() получает вектор и прибавляет его к текущему вектору вращения выбранного объекта в заданном кадре.

Функция vUpdateScale() получает вектор и прибавляет его к текущему вектору масштабирования выбранного объекта в заданном кадре.

Члены данных класса C3DAnimation

Методы класса не выглядят слишком сложными, но члены данных могут сильно удивить вас. К примеру, член данных для хранения ключевых кадров является многомерным массивом ключевых кадров. Размер массива определяется количеством объектов в сцене и числом кадров в анимации. Это вызвано тем, что для каждого кадра анимации вам необходим ключевой кадр для каждого объекта сцены. Чтобы было доступно достаточное количество ключевых кадров, вы создаете массив заданного размера, как показано в приведенном выше коде. Все это иллюстрирует рис. 11.20.

Рис. 11.20. Массивы в классе C3DAnimation

Рис. 11.20. Массивы в классе C3DAnimation

Обратите внимание, что массив ключевых кадров двухмерный. Первое измерение содержит кадры для каждого объекта в анимации, а второе — кадры для каждого ключа анимации. Массив объектов, с другой стороны, одномерный. Он хранит все трехмерные объекты, участвующие в анимации.

Еще один одномерный массив называется m_szObjectName. Его назначение — хранить имена участвующих в анимации трехмерных объектов. Это требуется для анимации, чтобы знать какие трехмерные объекты загружать когда используется функция vLoad().

Член данных m_iNumFrames применяется для того, чтобы отслеживать количество кадров в анимации. Вам следует помнить, что для каждого ключа должен существовать кадр каждого объекта. Например, у вас есть пять объектов и в вашей анимации 30 ключей — для хранения этой анимации потребуется 150 кадров: количество объектов * количество ключей = количество кадров.

Переменная m_iNumObjects отслеживает количество задействованных в анимации объектов. Для каждого объекта необходимо задать его имя.

В символьном массиве m_szAnimName хранится имя анимации.

Член данных m_iCurFrame отслеживает номер текущего кадра. Этот номер используется при воспроизведении анимации и не сохраняется при ее записи на диск.

Член данных m_lCurTime отслеживает сколько времени прошло с момента последней смены кадра. Благодаря ему можно реализовать различные временные задержки между сменой кадров.

Переменная m_pd3dDevice хранит указатель на активное устройство визуализации Direct3D. Она необходима для загрузки файлов .x, содержащих используемые в анимации объекты.

Структура stKeyFrame

В начале заголовочного файла расположено объявление структуры stKeyFrame. Это небольшая структура созданная мной для хранения всей информации кадра для каждого ключа анимации. Ниже приведен список членов этой структуры с кратким описанием их назначения.

Член m_vecRot содержит информацию о вращении для ключа.

Член m_vecTrans содержит информацию о перемещении, или местоположении, для ключа.

Член m_vecScale содержит информацию о масштабировании для ключа.

Член m_lTimeDelay содержит информацию о временной задержке для ключа.

Реализация методов класса C3DAnimation

Имейте в виду, что главная цель класса анимации — хранение и воспроизведение трехмерных анимаций. Это не значит, что вы ограничены воспроизведением переходных сцен межу уровнями или чем-то подобным. Главная цель класса — хранить анимацию для движения объектов, сражений и других действий, происходящих во время игры. Хорошо, давайте вернемся к нашей постоянно откладываемой программе.

Файл C3DAnimation.cpp содержит код реализации методов класса, и первыми нам на глаза попадаются конструктор и деструктор.

Конструктор класса C3DAnimation

Конструктор вызывается в момент создания нового объекта класса анимации. Вот как выглядит его код:

C3DAnimation::C3DAnimation()
{
   int i, j;
   // Установка количества объектов
   m_iNumObjects = 0;
   // Установка количества кадров
   m_iNumFrames = 0;
   // Установка начального состояния анимации
   m_iCurFrame = 0;
   m_lCurTime = 0;
   // Инициализация объектов, имен и информации о ключах
   for(i = 0; i < g_iMaxObjects; i++) {
      // Объекты
      m_objObject[i] = NULL;
      // Имена
      strcpy(&m_szObjectName[i][0], "");
      // Ключи
      for(j = 0; j < g_iMaxKeys; j++) {
         m_keyFrames[i][j] = NULL;
      }
   }
}

Конструктор начинается с присвоения нулевых значений различным членам данных класса. Обнуляется количество объектов и кадров, номер текущего кадра и счетчик времени. Далее расположены два цикла. Внешний цикл присваивает всем указателям на объекты значение NULL, а внутренний цикл присваивает значение NULL указателям на ключевые кадры объектов. Я делаю это для того, чтобы в дальнейшем правильно работали проверки выделения памяти. Как вы возможно знаете, большинство компиляторов не выполняет автоматическое обнуление неинициализированных данных; поэтому данный этап абсолютно необходим.

Деструктор класса C3DAnimation

Деструктор вызывается когда объект класса анимации удаляется или выходит из области видимости. Вот как выглядит его код:

C3DAnimation::~C3DAnimation()
{
   // Освобождение памяти
   vReset();
}

Держу пари, вы подумали, что легко отделались? Но на самом деле я лишь отсрочил неизбежное. Деструктор выглядит так просто потому, что он только вызывает функцию vReset(). На этом перейдем к функции сброса значений.

Функция C3DAnimation::vReset()

Функция vReset() вызывается для установки содержимого объекта анимации в исходное состояние. Она полезна, когда требуется загрузить новую анимацию поверх существующего объекта. Также она используется при уничтожении объекта анимации. Вот ее код:

void C3DAnimation::vReset(void)
{
   int i, j;

   // Освобождение объектов
   for(i = 0; i < m_iNumObjects; i++) {
      if(m_objObject[i]) {
         delete m_objObject[i];
         m_objObject[i] = NULL;
      }
   }

   // Освобождение данных ключевых кадров
   for(i = 0; i < m_iNumObjects; i++) {
      for(j = 0; j < m_iNumFrames; j++) {
         if(m_keyFrames[i][j]) {
            delete m_keyFrames[i][j];
            m_keyFrames[i][j] = NULL;
         }
      }
   }

   // Установка количества объектов
   m_iNumObjects = 0;
   // Установка количества кадров
   m_iNumFrames = 0;
   // Установка начального состояния анимации
   m_iCurFrame = 0;
   m_lCurTime = 0;

   // Инициализация объектов, имен и информации о ключах
   for(i = 0; i < g_iMaxObjects; i++) {
      // Объекты
      m_objObject[i] = NULL;
      // Имена
      strcpy(&m_szObjectName[i][0], "");
      // Ключи
      for(j = 0; j < g_iMaxKeys; j++) {
         m_keyFrames[i][j] = NULL;
      }
   }
}

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

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

Затем обнуляется количество объектов, количество кадров, номер текущего кадра и счетчик времени. Этот код должен быть понятен без дополнительных комментариев.

Заключительный цикл очищает строки с именами трехмерных объектов.

Функция C3DAnimation::vNewFrame()

Функция vNewFrame() используется для создания ключевого кадра для каждого объекта сцены. Если создается первый кадр анимации, ему присваиваются ключевые значения по умолчанию. Если кадр не первый, то значения ключей копируются из предыдущего кадра. Благодаря этому упрощается создание анимации, поскольку не надо каждый раз при создании нового кадра заново позиционировать, вращать и масштабировать объект. Вот как выглядит код функции:

void C3DAnimation::vNewFrame(void)
{
   int   iFrame = 0;
   stKeyFrame   *ptrFrame;
   stKeyFrame   *ptrPrevFrame;

   // Увеличение количества кадров в анимации
   m_iNumFrames++;
   // Получение индекса ключевого кадра
   iFrame = m_iNumFrames - 1;
   // Создаем новый кадр для каждого объекта
   // в анимации.
   for(int iObj = 0; iObj < m_iNumObjects; iObj++) {
      // Выделяем память для кадра
      m_keyFrames[iObj][iFrame] = new stKeyFrame;
      // Получаем указатель на новый кадр
      ptrFrame = m_keyFrames[iObj][iFrame];
      // Присваиваем первому кадру значения по умолчанию
      if(iFrame == 0) {
         ptrFrame->m_vecScale = D3DXVECTOR3(1.0, 1.0, 1.0);
         ptrFrame->m_vecRot = D3DXVECTOR3(0.0, 0.0, 0.0);
         ptrFrame->m_vecTrans = D3DXVECTOR3(0.0, 0.0, 0.0);
         ptrFrame->m_lTimeDelay = 10;
      }
      // Остальным кадрам присваиваем значения,
      // скопированные из предыдущего кадра
      else {
         // Получаем указатель на предыдущий кадр
         ptrPrevFrame = m_keyFrames[iObj][(iFrame - 1)];

         // Копируем данные из предыдущего кадра в текущий
         ptrFrame->m_vecScale = ptrPrevFrame->m_vecScale;
         ptrFrame->m_vecRot = ptrPrevFrame->m_vecRot;
         ptrFrame->m_vecTrans = ptrPrevFrame->m_vecTrans;
         ptrFrame->m_lTimeDelay = ptrPrevFrame->m_lTimeDelay;
      }
   }
   m_iCurFrame = iFrame;
}

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

Затем я инициализирую временную переменную, присваивая ей значение на единицу меньшее количества кадров. В результате значение переменной iFrame будет равно номеру создаваемого кадра.

Затем в цикле перебираются все объекты сцены и выделяется память для нового ключевого кадра для каждого объекта. Затем для созданных кадров устанавливаются значения масштабирования, вращения, перемещения и счетчика времени. Как я уже говорил ранее, эти значения зависят от того, является кадр первым в анимации или нет.

И, наконец, я делаю номер текущего кадра равным значению переменной iFrame. В результате программа перейдет к редактированию только что созданного кадра.

Функция C3DAnimation::iNextFrame()

Следующая функция работы с кадрами очень проста, поскольку она всего лишь перемещает указатель текущего кадра на следующий кадр. Если же кадров больше нет, то указатель возвращается назад к самому первому кадру. А вот и код функции:

int C3DAnimation::iNextFrame(void)
{
   // Переход к следующему кадру
   m_iCurFrame++;
   // Если кадр был последним, переходим к началу анимации
   if(m_iCurFrame >= m_iNumFrames) {
      m_iCurFrame = 0;
   }
   // Возвращаем номер кадра
   return(m_iCurFrame);
}

В коде видно как я сначала увеличиваю номер текущего кадра, а затем прверяю существует ли кадр с таким номером в анимационной последовательности. Если номер превышает число кадров в анимации, я возвращаюсь к начальному кадру с номером 0.

Функция C3DAnimation::iPrevFrame()

Функция перехода к предыдущему кадру работает точно так же, как функция перехода к следующему кадру, но смена кадров осуществляется в обратном направлении. Код уменьшает номер текущего кадра и проверяет не стал ли номер равен –1. Если номер равен –1, то выбирается последний кадр анимации. Вот как выглядит код функции:

int C3DAnimation::iPrevFrame(void)
{
   // Переход к предыдущему кадру
   m_iCurFrame--;
   // Если номер кадра меньше нуля, переходим к последнему кадру.
   // Если кадров нет, переходим к нулевому кадру
   if(m_iCurFrame < 0) {
      // Проверяем есть ли кадры
      if(m_iNumFrames) {
         // Переход к последнему кадру
         m_iCurFrame = m_iNumFrames - 1;
      }
      // Кадров нет
      else {
         // Переход к нулевому кадру
         m_iCurFrame = 0;
      }
   }
   // Возвращаем номер кадра
   return(m_iCurFrame);
}

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

Рис. 11.21. Выполнение функции перехода к предыдущему кадру

Рис. 11.21. Выполнение функции перехода к предыдущему кадру

Функция C3DAnimation::iStartFrame()

Функция перехода к начальному кадру просто перематывает анимацию до первого кадра. Вот ее код:

int C3DAnimation::iStartFrame(void)
{
   // Переход к первому кадру
   m_iCurFrame = 0;
   // Возвращаем номер кадра
   return(m_iCurFrame);
}

Я не буду объяснять самодокументируемый код. Разве вы любите это?

Функция C3DAnimation::iNewObj()

Функция создания нового объекта получает имя файла .x и загружает его в ближайший доступный слот объекта. Вот как выглядит код функции:

int C3DAnimation::iNewObj(char *szObjName)
{
   char szFileName[512];
   // Создаем имя файла
   sprintf(szFileName, "Data\\3DObjects\\%s.x", szObjName);
   // Устанавливаем указатель на объект
   m_objObject[m_iNumObjects] = new Object3DClass;
   m_objObject[m_iNumObjects]->hLoad(szFileName, m_pd3dDevice);
   // Сохраняем имя для последующего использования
   strcpy(&m_szObjectName[m_iNumObjects][0], szObjName);
   // Увеличиваем внутренний счетчик
   m_iNumObjects++;
   // Возвращаем количество объектов
   return(m_iNumObjects);
}

В первой части функции создается полностью квалифицированное имя файла. Оно включает путь и имя файла.

В следующем фрагменте кода создается новый объект Object3DClass для хранения данных модели из файла .x. Затем только что созданный объект загружает данные из файла .x с помощью собственной функции hLoad().

Как только данные объекта загружены, код устанавливает имя объекта для дальнейших ссылок. Затем увеличивается счетчик количества объектов и его значение возвращается вызывающей программе.

ПРИМЕЧАНИЕ
Класс Object3DClass создан мной для того, чтобы упростить работу с моделями из файлов .x.

Помните, что функция создания нового объекта добавляет новый объект ко всей анимации, а не только к одному ключевому кадру.

Функция C3DAnimation::vUpdateTrans()

Функция изменения позиции получает вектор перемещения, номер кадра и номер объекта и добавляет значение перемещения к текущему вектору местоположения заданного объекта в указанном кадре. Ее полезно использовать для изменения местоположения объектов. Вот как выглядит код функции:

void C3DAnimation::vUpdateTrans(
   int iObj, int iKey,
   D3DXVECTOR3 vecTrans)
{
   // Проверяем правильность номеров ключа и объекта
   if(iObj < m_iNumObjects && iObj >= 0
      && iKey < m_iNumFrames && iKey >= 0) {
      // Обновляем вектор
      m_keyFrames[iObj][iKey]->m_vecTrans += vecTrans;
   }
}

Сперва в коде выполняется проверка того, что указанные объект и ключ существуют. Если да, данные вектора изменяются с учетом переданных значений.

Функция C3DAnimation::vUpdateRot()

Функция изменения поворота получает вектор поворота, номер кадра и номер объекта и и добавляет значение вращения к текущему вектору поворота заданного объекта в указанном кадре. Ее хорошо применять для изменения угла поворота объектов. Вот код функции:

void C3DAnimation::vUpdateRot(int iObj, int iKey, D3DXVECTOR3 vecRot)
{
   // Проверяем правильность номеров ключа и объекта
   if(iObj < m_iNumObjects && iObj >= 0
      && iKey < m_iNumFrames && iKey >= 0) {
      // Обновляем вектор
      m_keyFrames[iObj][iKey]->m_vecRot += vecRot;
   }
}

Код начинается с проверки того, существуют ли указанные объект и ключ. Если да, данные вектора изменяются с учетом переданных значений.

Функция C3DAnimation::vUpdateScale()

Функция изменения масштаба получает вектор масштабирования, номер кадра и номер объекта и и добавляет значение масштабирования к текущему вектору масштабирования заданного объекта в указанном кадре. Функция полезна для изменения масштаба объектов. Вот ее код:

void C3DAnimation::vUpdateScale(
   int iObj, int iKey,
   D3DXVECTOR3 vecScale)
{
   // Проверяем правильность номеров ключа и объекта
   if(iObj < m_iNumObjects && iObj >= 0
      && iKey < m_iNumFrames && iKey >= 0) {
      // Обновляем вектор
      m_keyFrames[iObj][iKey]->m_vecScale += vecScale;
   }
}

Код начинается с проверки того, являются ли верными полученные номера объекта и ключа. Если да, данные вектора изменяются с учетом переданных значений.

Функция C3DAnimation::vSave()

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

void C3DAnimation::vSave(char *szFileName)
{
   FILE  *fp;
   int   i, j;
   char  szFullFileName[512];

   // Создаем квалифицированное имя файла
   sprintf(szFullFileName, "Data\\Anims\\%s.anim", szFileName);
   // Открываем файл для записи
   fp = fopen(szFullFileName, "wb");
   if(fp == NULL) {
      return;
   }
   // Вывод заголовка
   // Количество объектов
   fwrite(&m_iNumObjects, 1, sizeof(int), fp);
   // Количество кадров
   fwrite(&m_iNumFrames, 1, sizeof(int), fp);
   // Вывод имен объектов
   for(i = 0; i < m_iNumObjects; i++) {
       fwrite(&m_szObjectName[i][0], 32,
          sizeof(char), fp);
   }
   // Вывод информации о ключевых кадрах
   for(i = 0; i < m_iNumObjects; i++) {
      for(j = 0; j < m_iNumFrames; j++) {
          // Задержка
          fwrite(&m_keyFrames[i][j]->m_lTimeDelay,
             1, sizeof(long), fp);
          // Поворот
          fwrite(&m_keyFrames[i][j]->m_vecRot,
             1, sizeof(D3DXVECTOR3), fp);
          // Масштаб
          fwrite(&m_keyFrames[i][j]->m_vecScale,
             1, sizeof(D3DXVECTOR3), fp);
          // Местоположение
          fwrite(&m_keyFrames[i][j]->m_vecTrans,
             1, sizeof(D3DXVECTOR3), fp);
      }
   }
   // Закрываем файл анимации
   fclose(fp);
   // Сохраняем имя анимации
   strcpy(m_szAnimName, szFileName);
}

Сперва код формирует полностью квалифицированное имя файла и открывает указанный файл для записи. Затем в файл анимации выводится информация заголовка. Заголовок содержит количество объектов и количество кадров. Информация заголовка необходима для того, чтобы во время загрузки вы знали сколько данных нужно прочесть. Подробная структура файла показана на рис. 11.22.

Рис. 11.22. Структура формата файла анимации

Рис. 11.22. Структура формата файла анимации

На рис. 11.22 видно, что файл анимации состоит из заголовка, за которым следуют имена объектов, а далее располагается информация о ключевых кадрах.

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

Далее следует информация о кадрах. Код в цикле перебирает все кадры анимации и выводит информацию о задержке, вращении, масштабировании и местоположении каждого присутствующего в кадре объекта. Цикл продолжается до тех пор, пока не будет выведена информация обо всех кадрах.

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

Функция C3DAnimation::vLoad()

Функция загрузки читает ранее сохраненные функцией записи данные. Звучит просто, не так ли? А вот и код функции:

void C3DAnimation::vLoad(char *szFileName)
{
   FILE  *fp;
   int   i, j;
   int   iNumObjs;
   int   iNumFrames;
   char  szFullFileName[512];

   // Создание квалифицированного имени файла
   sprintf(szFullFileName, "Data\\Anims\\%s.anim", szFileName);
   // Открытие файла для чтения
   fp = fopen(szFullFileName, "rb");
   if(fp == NULL) {
      return;
   }
   // Сброс объектов в состояние по умолчанию
   vReset();
   // Чтение заголовка
   // Количество объектов
   fread(&iNumObjs, 1, sizeof(int), fp);
   // Количество кадров
   fread(&iNumFrames, 1, sizeof(int), fp);
   // Загрузка информации об объектах
   for(i = 0; i < iNumObjs; i++) {
       // Чтение имени объекта
       fread(&m_szObjectName[i][0], 32, sizeof(char), fp);
       // Загрузка данных объекта
       iNewObj(&m_szObjectName[i][0]);
   }
   // Выделение памяти для кадров
   for(i = 0; i < iNumFrames; i++) {
      vNewFrame();
   }
   // Чтение информации о ключевом кадре
   for(i = 0; i < m_iNumObjects; i++) {
      for(j = 0; j < m_iNumFrames; j++) {
         // Задержка
         fread(&m_keyFrames[i][j]->m_lTimeDelay,
            1, sizeof(long), fp);
         // Вращение
         fread(&m_keyFrames[i][j]->m_vecRot,
            1, sizeof(D3DXVECTOR3), fp);
         // Масштаб
         fread(&m_keyFrames[i][j]->m_vecScale,
            1, sizeof(D3DXVECTOR3), fp);
         // Местоположение
         fread(&m_keyFrames[i][j]->m_vecTrans,
            1, sizeof(D3DXVECTOR3), fp);
      }
   }
   // Закрываем файл анимации
   fclose(fp);
   // Сохраняем имя анимации
   strcpy(m_szAnimName, szFileName);
}

Ход выполнения функции загрузки показан на рис. 11.23.

Рис. 11.23. Ход выполнения функции загрузки

Рис. 11.23. Ход выполнения функции загрузки

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

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

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

И в самом конце код закрывает файл и сохраняет имя анимации в данных класса анимации. Вот и все!

Функция C3DAnimation::vSet3DDevice()

Функция задания трехмерного устройства применяется для установки внутреннего указателя на устройство Direct3D. Этот указатель необходим для загрузки трехмерных моделей из X-файлов. Вот код функции:

void C3DAnimation::vSet3DDevice(LPDIRECT3DDEVICE9 pd3dDevice)
{
   m_pd3dDevice = pd3dDevice;
}

Программа D3D_AnimationEditor

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

Ход выполнения программы редактора очень похож на работу других программ, описываемых в этой книге. Чтобы увидеть ход выполнения программы, взгляните на рис. 11.24.

Рис. 11.24. Ход выполнения программы D3D_AnimationEditor

Рис. 11.24. Ход выполнения программы D3D_AnimationEditor

На рис. 11.24 видно как главная функция инициализирует DirectInput, клавиатуру, Direct3D, интерфейс, панель команд, данные анимации и освещение. После завершения инициализации цикл обработки сообщений контроллирует поступающие от пользователя данные и визуализирует трехмерную сцену, пока не будет завершена работа программы. Здесь нет ничего принципиально нового за исключением функции инициализации анимации.

Функция vInitAnimation()

Функция инициализации анимации всего лишь устанавливает внутренний указатель на устройство Direct3D, присваивая ему значение, полученное при вызове функции InitD3D(). Вот как выглядит код этой процедуры:

void vInitAnimation(void)
{
   // Установка трехмерного устройства
   animTest.vSet3DDevice(g_pd3dDevice);
}

В функции я обращаюсь к глобальному объекту класса анимации. Функция vSet3DDevice() вызывается чтобы установить его внутренний указатель на устройство визуализации. Я делаю это потому, что объекту класса анимации необходим указатель на устройство Direct3D для загрузки трехмерных объектов, образующих сцену. Вам надо вызвать эту функцию только один раз, так что процедура инициализации — самое подходящее для этого место.

Функция vLoadObject()

Чтобы начать редактирование анимации, вам необходима анимируемая сцена. Как я говорил ранее, сцена состоит из трехмерных объектов, поэтому вы должны загрузить какие-нибудь объекты для редактирования. Здесь в игру вступает функция vLoadObject(). Она загружает составляющие сцену объекты. Когда вы щелкаете по расположенной на панели команд кнопке Load Objects выполняется следующий код:

void vLoadObject(void)
{
   // Сброс текущей анимации
   animTest.vReset();
   // Загрузка заданных объектов
   animTest.iNewObj("droid_still");
   animTest.iNewObj("radar_dish");
   // Визуализация сцены
   vRender();
   // Обновление панели команд
   vUpdateToolbarStats();
}

Первая вещь, которую делает функция загрузки объектов, — инициализация объекта класса анимации. Это действие удаляет все проделанные с анимацией к данному моменту манипуляции и возвращает ее к исходному состоянию.

ВНИМАНИЕ!
Не щелкайте по кнопке Load Objects, если у вас есть не сохраненные данные анимации. Все они будут стерты!

Следующий фрагмент кода обращается к функции создающему новый объект методу класса анимации и загружает объекты из двух X-файлов с именами droid_still и radar_dish. Объект droid_still — это механоид, а объект radar_dish — это небольшая антена радара.

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

В конце я вызываю функцию vUpdateToolbarStats(). Она выводит в панели инструментов значения вращения и местоположения объекта в текущем кадре.

Создание кадров

Теперь, когда на вашей сцене есть трехмерные объекты, вам требуются данные кадров. Щелкните по кнопке New Frame и в обработчике сообщений будет выполнен следующий код:

case ID_BUTTON_NEWFRAME:
   // Создание нового кадра анимации
   animTest.vNewFrame();
   SetActiveWindow(g_hWnd);
   // Обновление информации на панели инструментов
   vUpdateToolbarStats();
   break;

Как видно в коде, при щелчке по кнопке New Frame вызываются несколько функций объекта класса анимации. Вызов метода класса vNewFrame() создает новый кадр для каждого объекта сцены. Поскольку у вас загружено два объекта, будет создано два кадра.

Следующая строка кода делает окно редактирования активным. Я делаю это для того, чтобы после создания нового кадра вам не надо было щелкать по окну редактора для его активизации.

И в самом конце я вызываю функцию обновления состояния панели инструментов. Замечательно то, что теперь вы должны увидеть объекты сцены на экране. У вас появились данные кадров и программе есть что показать!

Выбор необходимого объекта

У вас есть кадр и объекты, что теперь? Давайте займемся редактированием! Щелкните по кнопке Next Obj, пока антена не станет отображаться нормально, а не в виде каркаса. При каждом щелчке по кнопке Next Obj будет выполняться следующий код в обработчике сообщений:

case ID_BUTTON_NEXTOBJ:
   // Увеличение номера текущего объекта
   g_iCurObj++;
   // Если достигли конца, вернемся к началу
   if(g_iCurObj >= animTest.m_iNumObjects) {
      g_iCurObj = 0;
   }
   SetActiveWindow(g_hWnd);
   vUpdateToolbarStats();
   break;

Код перехода к следующему объекту начинается с увеличения глобальной переменной, хранящей номер текущего объекта. Затем я проверяю, не превысило ли значение счетчика количество объектов в сцене. Если да, я возвращаюсь к началу списка объектов. Код перехода к предыдущему объекту работает почти так же, как рассмотренная функция за исключением того, что значение глобальной переменной с номером кадра уменьшается, а не увеличивается.

Изменение местоположения объекта

Теперь, когда антена радара выбрана, необходимо переместить ее. Возможно, вы уже заметили, что антена находится на земле, а не наверху механоида! Это легко исправить, ведь все что надо сделать — ввести соответствующие значения в текстовых полях, задающих местоположение объекта. В данном случае нам нужно среднее поле в левом столбце. Укажите в нем значение 20.0, и увидите как антена займет свое место на верхушке робота. Вуаля! Разве не здорово? Если все сделано правильно, вы увидите антену радара на предназначенном для нее месте. Код, обрабатывающий это перемещение, выглядит так:

case ID_EDITBOX_Y:
   memset(szBuffer, 0x00, 32);
   GetWindowText(hEDITBOX_Y, szBuffer, 32);
   if(animTest.m_iNumFrames > 0) {
      animTest.m_keyFrames
      [g_iCurObj][animTest.m_iCurFrame]->m_vecTrans.y
      = (float)atof(szBuffer);
   }
   break;

В коде я извлекаю значение из поля редактирования и преобразую его в число с плавающей запятой. Затем я устанавливаю новое значение смещения по оси Y выбранного объекта в текущем кадре. Фактически, все что я сделал — взял новое значение местоположения из поля редактирования и применяю его к антене радара. Здорово?

Вы можете поэкспериментировать и с перемещением робота. Для этого перебирайте объекты, пока не будет выбран робот. После того, как робот выбран, изменяйте значения его местоположения, пока робот не займет то местоположение, которое вам нравится.

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

Добавление ключевых кадров

Анимация пока, возможно, не слишком захватывающая. Теперь, когда антена радара находится на предназначенном для нее месте, добавьте к анимации несколько ключевых кадров. Если вы щелкните по кнопке New Frame три раза, в вашей анимации будет четыре кадра. Эти изменения отразятся в отладочной информации, выводимой в окне редактирования.

Помните, что ключевой кадр создается для каждого объекта сцены; так что, если вы щелкнули по кнопке New Frame три раза, всего у вас будет восемь ключевых кадров.

Теперь у вас есть пачка кадров и некуда идти. Выберите антену радара, если вы еще этого не сделали, и затем перебирайте кадры, пока текущим не станет кадр с номером 2. При щелчке по кнопке Next Frame выполняется следующий код:

case ID_BUTTON_NEXTFRAME:
   animTest.iNextFrame();
   SetActiveWindow(g_hWnd);
   vUpdateToolbarStats();
   break;

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

Теперь, когда у вас выбран второй кадр, измените поворот антены радара, чтобы ее центр был направлен немного влево. Сделав это, перейдите к третьему кадру и поверните антену еще на несколько градусов. Продолжайте процесс, пока не вернетесь к кадру номер 1. Затем щелкните по кнопке Start/Stop Anim, чтобы воспроизвести небольшую анимацию, которую вы только что создали. Будет выполнен следующий код:

case ID_BUTTON_STARTSTOP:
   // Если анимация активна, остановить ее
   if(g_iAnimActive) {
      g_iAnimActive = 0;
   }
   // Если анимация остановлена, запустить ее
   else {
      g_iAnimActive = 1;
   }
   SetActiveWindow(g_hWnd);
   vUpdateToolbarStats();
   break;

В коде воспроизведения я переключаю значение флага g_iAnimActive в зависимости от его текущего состояния. Это указывает системе визуализации, что она должна делать с классом анимации во время визуализации. Когда анимация активна, функция визуализации продвигается по ней после каждого цикла отображения. Если анимация остановлена, функция визуализации просто отображает объекты и не меняет текущий кадр анимации.

Функция vRender()

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

// Очистка вторичного буфера синим цветом
g_pd3dDevice->Clear(0, NULL, D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER,
             D3DCOLOR_RGBA(200, 250, 255, 255), 1.0f, 0);
// Начало сцены
g_pd3dDevice->BeginScene();
// Установка материала по умолчанию
g_pd3dDevice->SetMaterial(&g_mtrlDefault);
// Установка режима сплошной заливки
g_pd3dDevice->SetRenderState(D3DRS_FILLMODE, D3DFILL_SOLID);
// Визуализация пола
vDraw3DObject(D3DXVECTOR3(  0.0,   0.0,   0.0) ,
              D3DXVECTOR3(200.0, 200.0, 200.0),
              D3DXVECTOR3( 90.0,   0.0,   0.0),
              0);
// Визуализация трехмерных объектов
if(animTest.m_iNumFrames && animTest.m_iNumObjects) {
   for(int i = 0; i < animTest.m_iNumObjects; i++) {
      // Объекты не являющиеся текущими отображаем в каркасном режиме
      if(i != g_iCurObj) {
         g_pd3dDevice->SetRenderState(D3DRS_FILLMODE,
                         D3DFILL_WIREFRAME);
      }
      // Текущий объект отображаем в режиме сплошной заливки
      else {
         g_pd3dDevice->SetRenderState(D3DRS_FILLMODE,
                         D3DFILL_SOLID);
      }
      // Установка текущего кадра
      iCFrame = animTest.m_iCurFrame;
      // Визуализируем объекты, используя информацию кадра
      // хранящуюся в объекте анимации
      animTest.m_objObject[i]->vDisplayXYZ(
      animTest.m_keyFrames[i][iCFrame]->m_vecTrans.x,
      animTest.m_keyFrames[i][iCFrame]->m_vecTrans.y,
      animTest.m_keyFrames[i][iCFrame]->m_vecTrans.z,
      animTest.m_keyFrames[i][iCFrame]->m_vecRot.x,
      animTest.m_keyFrames[i][iCFrame]->m_vecRot.y,
      animTest.m_keyFrames[i][iCFrame]->m_vecRot.z,
      animTest.m_keyFrames[i][iCFrame]->m_vecScale.x,
      animTest.m_keyFrames[i][iCFrame]->m_vecScale.y,
      animTest.m_keyFrames[i][iCFrame]->m_vecScale.z);
      // Анимация объекта
      if(g_iAnimActive) {
         animTest.m_lCurTime++;
         // Переход к следующему кадру
         if(animTest.m_lCurTime >= animTest.m_keyFrames[i]
                [animTest.m_iCurFrame]->m_lTimeDelay) {
            animTest.m_iCurFrame++;
            bFrameChanged = 1;
            animTest.m_lCurTime = 0;
            // Сброс счетчика кадров
            if(animTest.m_iCurFrame >= animTest.m_iNumFrames) {
                  animTest.m_iCurFrame = 0;
            }
         }
      }
   }
}

Я знаю, что код визуализации объектов может выглядеть сложным, но все не так плохо. Сперва я вызываю мою функцию vDraw3DObject() чтобы нарисовать пол. Если вы не заметили, пол я рисую для того, чтобы облегчить расположение объектов. Он представляет собой большую серую решетку, расположенную вдоль оси Y.

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

Поскольку анимационная сцена может содержать несколько объектов, я создал цикл, который перебирает каждый объект сцены. Логика цикла изображена на рис. 11.25.

Рис. 11.25. Логика визуализации

Рис. 11.25. Логика визуализации

Если в цикле обрабатывается тот объект, который выбран в данный момент в редакторе, он отображается закрашенным, если нет — объект отображается в виде каркаса. Сама визуализация выполняется путем вызова функции vDisplayXYZ(). Она получает информацию о местоположении, вращении и масштабировании объекта и визуализирует его согласно этим данным. Данные о местоположении вращении и масштабировании берутся из соответствующих векторов объекта, хранящихся в данных кадра.

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

Оставшаяся часть функции визуализации выводит отладочную информацию, которую вы видите в окне редактирования. В ней нет ничего нового, и вы можете исследовать ее самостоятельно.

Управление с клавиатуры

В функции vCheckInput() я проверяю состояние клавиш, отвечающих за перемещение камеры или выбранного в данный момент объекта по сцене. Управление камерой осуществляется клавишами перемещения курсора, а перемещение объектов — символьными клавишами, расположенными в левой части клавиатуры. Чтобы определить какая клавиша какое действие выполняет, взгляните на код функции.

Что еще можно сделать

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

  • Добавьте элементы управления для редактирования временной задержки кадра.
  • Добавьте возможность создавать кадры отдельно для каждого объекта.
  • Создйте диспетчер ресурсов для хранения моделей.
  • Добавьте возможность управлять камерой с помощью мыши.
  • Добавьте диспетчер анимационных наборов.
  • Добавьте в окно редактирования несколько видов сцены.


Глава 11 - Реализация анимации

Реализация анимации

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

Я не хочу оставить вас в недоумении, так что создал специальный проект D3D_AnimationPlayback, который находится среди сопроводительных файлов. Эта простейшая программа загружает анимацию ожидающего робота и воспроизводит ее в бесконечном цикле. Основная особенность программы заключается в том, что я исключил из нее все, что не является абсолютно необходимым. Это дает вам шанс изучить систему анимации безо всяких украшений и дополнительных функций, которые присутствуют в редакторе. Окно программы изображено на рис. 11.26.

Рис. 11.26. Окно программы D3D_AnimationPlayback

Рис. 11.26. Окно программы D3D_AnimationPlayback

Теперь загрузите проект, скомпилируйте его и посмотрите как он работает.


Глава 11 - Итоги

Итоги

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

  • Коммерческие анимационные пакеты обладают тысячами различных функций и соответствующей стоимостью.
  • Коммерческие анимационные пакеты очень трудно или вообще невозможно модифицировать.
  • Самодельный редактор анимации достаточно трудно написать, и его разработка отнимет много времени.
  • Самодельный редактор предоставляет вам достаточную гибкость, чтобы управлять своей судьбой и вы можете гарантировать его совместимость с вашими программами, поскольку все пишете сами.
  • Анимационные наборы замечательно подходят для управления данными анимации и воспроизведения динамического содержимого.


Глава 12. Поиск пути

Краткий обзор

Вы когда нибудь думали о том, что требуется чтобы из пункта А попасть в пункт В? Просыпались ли вы когда-нибудь в 6 утра, с ужасной головной болью, думая о том, как вы попали в пункт С? Решение этой, кажущейся на первый взгляд элементарной, задачи достаточно сложно. Хотя существует много способов снять шкуру с кошки, не все они одинаковы. Одни методы требуют больших вычислительных мощностей, а другие дольше выполняются. Ключ здесь — соблюдение баланса между скоростью и использованием процессора. Чтобы помочь вам принять обоснованное решение, относительно того, какой метод использовать, я рассмотрю в этой главе следующие темы:

  • Задача поиска пути.
  • Простое решение.
  • Поиск пути по алгоритму A*.
  • Реализация в коде.


Глава 12 - Задача поиска пути

Задача поиска пути

Для начала взгляните на рис. 12.1, где изображен общий случай задачи поиска пути.

Рис. 12.1. Задача поиска пути

Рис. 12.1. Задача поиска пути

На рис. 12.1 изображена карта, на которой отмечены начальная и конечная точки. Начальная точка выглядит как набор концентрических окружностей, а конечная — как большая буква Х. Чтобы переместиться от начальной точки к конечной вы должны определить, в каком именно месте карты вы находитесь и принять обоснованное решение о том, в каком направлении следует двигаться. Поскольку в игре определить свое местоположение (координаты X, Y и Z) достаточно просто, остается решить только куда нам двигаться.


Глава 12 - Простое решение

Простое решение

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

Если мы слева от цели, перемещаемся вправо
Если мы справа от цели, перемещаемся влево
Если мы выше цели, перемещаемся вниз
Если мы ниже цели, перемещаемся вверх

Если вы будете следовать приведенному выше псевдокоду, то первый шаг на приведенной в примере карте будет выглядеть так, как показано на рис. 12.2.

Рис. 12.2. Работа простого алгоритма поиска пути

Рис. 12.2. Работа простого алгоритма поиска пути

На рис. 12.2 вы проверяете местоположение игрока и, выяснив, что он находится слева от цели, перемещаете его вправо на одну клетку. Этот процесс повторяется, пока вы не достигнете цели, как показано на рис. 12.3.

Рис. 12.3. Общее решение проблемы поиска пути

Рис. 12.3. Общее решение проблемы поиска пути

На рис. 12.3 видно, как псевдокод помог нам достичь цели. Каждый раз код выполнял проверку и обнаруживал, что игрок находится слева от цели. В результате программа перемещала игрока на один квадрат вправо, пока он не достиг цели. Замечательный результат, но что произойдет, если карта будет выглядеть так, как показано на рис. 12.4?

Рис. 12.4. Более сложный путь

Рис. 12.4. Более сложный путь

Путь на рис. 12.4 несколько сложнее. Здесь между начальным и конечным пунктами расположено небольшое препятствие. Насколько хорошо простой код справляется с этой проблемой? Не так хорошо, как хотелось бы. Простое решение замечательно начинает работу, но терпит полную неудачу как только сталкивается со стеной. Вы можете попробовать несколько способов справиться с этой проблемой, например, такой:

Пока не достигли цели
   Если мы слева от цели, перемещаемся вправо
   Если мы справа от цели, перемещаемся влево
   Если мы выше цели, перемещаемся вниз
   Если мы ниже цели, перемещаемся вверх
   Если путь заблокирован, перемещаемся в случайном направлении
Конец цикла

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


Глава 12 - Поиск пути по алгоритму A*

Поиск пути по алгоритму A*

Существует множество доступных алгоритмов поиска пути, но моим личным фаворитом является алгоритм с названием A*. Это великолепный алгоритм, позволяющий находить путь обхода препятствий и определять наилучший путь на изменяющемся ландшафте. Это означает, что данный метод не просто найдет путь из точки А в точку В, но и что найденный путь из точки А в точку В будет наилучшим.

Чтобы помочь вам, я написал программу, показывающую алгоритм А* в действии. Загрузите проект D3D_PathFinding и запустите его, чтобы увидеть работу алгоритма А*. Если все выполнено правильно, вы увидите окно, похожее на изображенное на рис. 12.5.

Рис. 12.5. Окно программы D3D_PathFinding

Рис. 12.5. Окно программы D3D_PathFinding

Запустите программу и щелкните по расположенной на панели команд кнопке Go. В результате будет запущен алгоритм поиска пути. Как видно на рис. 12.5 программа ищет путь из начальной точки в конечную и отображает решение в виде стрелок. Вы можете загружать различные ландшафты, находящиеся в сопроводительных файлах, и смотреть, как алгоритм справляется с ними. Самое лучшее, что алгоритм A* всегда находит лучший путь с учетом имеющегося времени и ресурсов.

Как работает программа D3D_PathFinding? Не беспокойтесь; на этот раз я не буду сразу переходить к описанию исходного кода. Вместо этого я сначала приведу теоретическое описание работы алгоритма A*.

Основы A*

Давайте познакомимся с терминами, которые используются при описании алгоритма A*.

    Узел — Позиция на карте.

    Открытый список — Список узлов в которые может переместиться игрок и которые являются смежными с закрытыми узлами.

    Закрытый список — Спиок узлов, в которые может переместиться игрок и которые уже были пройдены им.

Чтобы понять, как эти термины применяются, взгляните на рис. 12.6.

Рис. 12.6. Терминология в алгоритме A*

Рис. 12.6. Терминология в алгоритме A*

На рис 12.6 изображены узлы, составляющие карту. Фактически, узлом является каждый квадрат карты. Я понимаю, что термин «узел» может звучать странно, но он подходит больше, чем «квадрат» или «клетка». Дело в том, что алгоритм A* может применяться и для тех карт, где форма блоков отличается от квадрата.

На карте как обычно отмечены начальная и конечная позиции. Помимо этого, начальная позиция обведена тонкой рамкой. Это показывает, что данный узел находится в закрытом списке. Поскольку начальная позиция всегда будет являться частью искомого пути, она автоматически включается в закрытый список пройденных узлов.

Узлы, соседствующие с единственным узлом из закрытого списка будут помещены в открытый список. В результате у вас будет один узел в закрытом списке и восемь узлов в открытом. Это показано на рис. 12.7.

Рис. 12.7. Добавление узлов в открытый список

Рис. 12.7. Добавление узлов в открытый список

На рис. 12.7 изображены восемь узлов входящих в открытый список и один узел, входящий в закрытый список. Узлы, входящие в открытый список очень просто определить по нарисованным в них стрелкам. Эти стрелки показывают направление перемещения из того закрытого узла, к которому относятся данные открытые узлы. Закрытый узел в этом случае называется родительским узлом каждого из открытых узлов.

Начало поиска

Вот вы и узнали о терминологии, применяемой в алгоритме А*, но как использовать сам алгоритм? Первое, что делает алгоритм А* — это добавление начального узла в закрытый список. Это делается потому, что начальный узел всегда будет первым узлом полученного пути. Сделав это вы должны найти все узлы, которые являются смежными с начальным и в которые может переместиться игрок. Если смежный узел доступен, он добавляется в открытый список. Так как в самом начале нет никаких открытых узлов, перед началом работы алгоритма открытый список пуст.

Итак, вот этапы поиска:

  1. Поместить начальный узел в закрытый список.
  2. Поместить доступные смежные узлы в открытый список.

На рис. 12.7 я выполнил эти два шага и теперь у меня один узел в закрытом списке и восемь узлов в открытом. Что дальше?

Вычисление стоимости узлов

В мире А* узлы не равны между собой. Одни из них лучше подходят для создания пути, чем другие. Чтобы выяснить, какой узел является самым лучшим, необходимо каждому узлу в закрытом и открытом списках назначить его стоимость. Как только всем узлам назначена стоимость, достаточно простой сортировки, чтобы выяснить какой узел является самым дешевым. Для вычисления стоимости узлов в алгоритме А* необходимы следующие значения:

  • Базовая стоимость узла.
  • Стоимость возврата к начальному узлу.
  • Стоимость достижения цели.

Базовая стоимость узла

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

Таблица 12.1. Базовая стоимость узлов
Тип узла | Стоимость
Трава | 1
Грязь | 2
Песок | 3
Скалы | 4
Болото | 5

В таблице 12.1 перечислены пять типов узлов и их базовая стоимость. Назначив каждому типу узла собственную базовую стоимость вы сможете находить наилучший путь на карте. Чтобы не усложнять пример, я назначаю всем узлам карты одинаковую базовую стоимость. Вам же ничто не мешает назначать в реальной игре различные стоимости разным узлам.

Стоимость относительно начального узла

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

Стоимость относительно цели

Последний компонент стоимости — это стоимость достижения цели из данного узла. Она вычисляется путем сложения количества строк и столбцов на которые текущий узел отстоит от цели. Предположим, текущий узел расположен на один ряд ниже и на десять столбцов левее цели. Стоимость этого узла относительно цели будет 10 + 1 = 11. Правда, просто?

Общая стоимость

Как только все три упомянутых выше стоимости вычислены, вам надо собрать их воедино и получить общую стоимость узла. Это может звучать непонятно, так что взгляните на рис. 12.8, где показаны стоимости всех рассматриваемых в примере узлов из открытого списка.

Рис. 12.8. Стоимость узлов из открытого списка

Рис. 12.8. Стоимость узлов из открытого списка

На рис. 12.8 показаны узлы из открытого списка с их стоимостью. Из чего составляется стоимость каждого узла показано на рис. 12.9.

Рис. 12.9. Составляющие стоимости узла

Рис. 12.9. Составляющие стоимости узла

Как видно на рис. 12.8 и рис. 12.9, общая стоимость узла показана в левом верхнем углу. В правом верхнем углу приводится базовая стоимость, в левом нижнем — стоимость относительно начального узла и в правом нижнем — стоимость относительно цели.

Поиск наилучшего узла

Вооружившись общей стоимостью каждого узла, очень просто найти наилучший узел, для добавления его в закрытый список. Отсортируйте узлы по значению общей стоимости и выберите тот из них у которого она меньше всего. На рис. 12.8 наименьшая общая стоимость у узла, расположенного справа от стартовой точки. Она равна 10 и других таких же дешевых узлов нет. Я даже обвел на рисунке этот узел рамкой, чтобы показать, что именно его следует выбрать.

После того, как узел с наименьшей общей стоимостью найден, добавьте его в закрытый список в качестве кандидата на участие в итоговом пути. Не забудьте удалить этот узел из открытого списка, чтобы он не был обработан снова. Итак, давайте подытожим пройденные шаги:

  1. Поместить начальный узел в закрытый список.
  2. Поместить доступные смежные узлы в открытый список.
  3. Найти узел с наименьшей общей стоимостью и добавить его в закрытый список.
  4. Удалить узел с наименьшей общей стоимостью из открытого списка.

Продолжение поиска

Если в открытом списке отсутствует конечный пункт маршрута, следует продолжать поиск пути добавив в открытый список те узлы, которые находятся вокруг узла только что добавленного в закрытый список. После этого вы снова найдете открытый узел с наименьшей стоимостью и добавите его в закрытый список. Эти действия будут повторяться до тех пор, пока конечный пункт маршрута не окажется в открытом списке.

Обратная трассировка для нахождения пути

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

Рис. 12.10. Найденный путь

Рис. 12.10. Найденный путь

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


Глава 12 - Реализация в коде

Реализация в коде

Теперь, продравшись через дебри теории, загрузите проект D3D_PathFinding и следуйте за мной. Проект содержит следующие файлы с исходным кодом: main.h, main.cpp, CPathFinder.h и CPathFinder.cpp. Наиболее важны два файла с именами CPathFinder. Они содержат код класса поиска пути.

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

Функция инициализации пути

На рис. 12.11 изображен ход выполнения кода поиска пути.

Рис. 12.11. Ход выполнения кода поиска пути в main.cpp

Рис. 12.11. Ход выполнения кода поиска пути в main.cpp

Обратите внимание, как функция vInitPathing() использует при вычислении пути объект класса CPathFinder. Кроме того, на рисунке изображена функция iGetMapCost(), которая вычисляет базовую стоимость для данного узла карты. Вот ее код:

int iGetMapCost(int iX, int iY)
{
   // Узел непроходим, если находится вне горизонтальных границ карты
   if(iX < 0 || iX >= g_iTilesWide)
      return(-1);

   // Узел непроходим, если находится вне вертикальных границ карты
   if(iY < 0 || iY >= g_iTilesHigh)
      return(-1);

   // Узел непроходим, если номер блока карты отличается от 0
   if(g_iTileMap[iX + (iY * g_iMapWidth)][1] != 0) {
      return(-1);
   }
   // Для всех остальных случаев возвращаем стоимость блока
   else {
      return(g_iTileMap[iX + (iY * g_iMapWidth)][1]);
   }
}

Функция получения стоимости узла карты принимает в параметрах пару координат и возвращает значение, зависящее от того, какой блок находится в точке с указанными координатами. Если точка с заданными координатами находится вне карты, функция возвращает –1. Если в указанной точке находится блок, через который нельзя пройти, функция также возвратит –1. И только когда точка с заданными координатами находится в пределах карты и через соответствующий блок можно передвигаться, функция вернет его стоимость.

Как я говорил раньше, функция vInitPathing() использует функцию получения стоимости узла карты при обращении к объекту поиска пути. Вот код функции инициализации пути:

void vInitPathing(void)
{
   bool         bRet;
   int          iTempX;
   int          iTempY;
   int          iDir;
   // Начальная и конечная позиции на карте
   int          iNodeStartX;
   int          iNodeStartY;
   int          iNodeEndX;
   int          iNodeEndY;
   // Таймеры
   DWORD        dwStartTime;
   DWORD        dwTotalTime;
   // Объект класса пути
   CPathFinder  pathMyPath;

   // Очистить карту со стрелками
   // Она используется в дальнейшем для отображения пути
   for(int i = 0; i < g_iMapWidth * g_iMapHeight; i++) {
      g_iArrowMap[i] = -1;
   }

   // Ищем на карте исходный пункт
   for(int y = 0; y < g_iMapHeight; y++) {
      for(int x = 0; x < g_iMapWidth; x++) {
         if(g_iTileMap[x + (y * g_iMapWidth)][0] == 19) {
            g_iRabbitXPos = x;
            g_iRabbitYPos = y;
            // Сохраняем исходное состояние
            iNodeStartX = g_iRabbitXPos;
            iNodeStartY = g_iRabbitYPos;
            break;
         }
      }
   }
   // Ищем на карте конечный пункт
   for(y = 0; y < g_iMapHeight; y++) {
      for(int x = 0; x < g_iMapWidth; x++) {
         if(g_iTileMap[x + (y * g_iMapWidth)][0] == 20) {
            iNodeEndX = x;
            iNodeEndY = y;
            break;
         }
      }
   }

   // Обновляем отображаемое сообщение
   sprintf(g_szPathStatus, "CALCULATING PATH");
   vRender();

   // Задаем функцию получения стоимости
   pathMyPath.vSetCostFunction(iGetMapCost);
   // Запуск таймера
   dwStartTime = timeGetTime();
   // Задаем начальную и конечную позиции
   pathMyPath.vSetStartState(iNodeStartX, iNodeStartY,
                             iNodeEndX, iNodeEndY);
   // Ищем путь - максимальная длина 300 узлов
   bRet = pathMyPath.bFindPath(300);
   // Остановка таймера
   dwTotalTime = timeGetTime() - dwStartTime;

   // Выход в случае сбоя
   if(!bRet) {
      // Обновляем отображаемое сообщение
      sprintf(g_szPathStatus,
             "FAILED, OPEN = %d, CLOSED = %d, TIME = %ld",
             pathMyPath.m_iActiveOpenNodes,
             pathMyPath.m_iActiveClosedNodes,
             dwTotalTime);
      return;
   }
   else {
      // Обновляем отображаемое сообщение
      sprintf(g_szPathStatus,
             "COMPLETE, OPEN = %d, CLOSED = %d, TIME = %ld",
             pathMyPath.m_iActiveOpenNodes,
             pathMyPath.m_iActiveClosedNodes,
             dwTotalTime);
   }

   // Теперь следуем по пути
   CPathNode *GoalNode = pathMyPath.m_CompletePath->m_Path[0];
   int    iTotalNodes = 0;

   // Устанавливаем временную позицию,
   // чтобы определить направление стрелки
   iTempX = GoalNode->m_iX;
   iTempY = GoalNode->m_iY;

   // Старт из позиции 1, а не 0
   iTotalNodes++;
   GoalNode = pathMyPath.m_CompletePath->m_Path[iTotalNodes];

   // Перебираем в цикле составляющие путь узлы
   // Для каждого шага рисуем стрелку
   while(iTotalNodes < pathMyPath.m_CompletePath->m_iNumNodes) {
      // Определяем направление стрелки
      iDir = vFindDirection(iTempX, iTempY,
                GoalNode->m_iX, GoalNode->m_iY);

      // Сохраняем стрелку в карте стрелок
      g_iArrowMap[GoalNode->m_iX + (GoalNode->m_iY * g_iMapWidth)]
         = iDir;

      // Визуализируем сцену
      vRender();

      // Устанавливаем временную позицию,
      // чтобы определить направление стрелки
      iTempX = GoalNode->m_iX;
      iTempY = GoalNode->m_iY;

      // Увеличиваем счетчик узлов
      iTotalNodes++;

      // Получаем следующий узел
      GoalNode =
         pathMyPath.m_CompletePath->m_Path[iTotalNodes];
   };
}

Гм-м — придется просмотреть весьма много кода. Я знаю, что код выглядит сложно, но большая его часть предназначена для отображения стрелок, представляющих найденный путь. В первой части кода определяется где на карте кролик находится сначала и где он должен оказаться в конце. Поскольку начало и конец пути представлены на карте специальными блоками, код просто ищет их и сохраняет координаты их местоположения.

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

Все самое интересное происходит когда программа вызывает принадлежащую объекту поиска пути функцию bFindPath(). Именно она выполняет работу по поиску наиболее эффективного пути на карте от начального до конечного пункта. Если путь найден, функция возвращает 1; если путь найти не удалось, функция возвращает 0.

Чтобы отобразить найденный путь на экране программа перебирает в цикле все входящие в путь узлы карты, начиная с первого, пока не доберется до цели. Проходя по пути она отображает стрелки, чтобы показать путь по которому кролик добирается от одного узла к другому. Направление вычисляется на основании данных о местоположении предыдущего узла пути относительно текущего. Здесь вступает в игру функция vFindDirection(). Она очень простая, поскольку лишь вычисляет какую именно стрелку необходимо отображать.

Функция CPathFinder::bFindPath()

Я могу потратить 50 страниц на описание кода, но в классе CPathFinder есть только одна заслуживающая внимания функция. Это функция bFindPath(), которая выполняет всю работу по нахождению наиболее эффективного пути из одного пункта в другой. Взгляните на рис. 12.12, где изображено как работает эта функция.

Рис. 12.12. Ход выполнения функции bFindPath()

Рис. 12.12. Ход выполнения функции bFindPath()

Код начинается с помещения стартового узла в закрытый список. Затем он ищет все открытые узлы, расположенные вокруг текущего узла (им является стартовый узел). После того, как найдены все открытые узлы, код поверяет есть ли среди них цель. Если да, то путь инвертируется и функция возвращает код успешного завершения. Если цель отсутствует в открытом списке, код ищет открытый узел с наименьшей стоимостью и добавляет его в закрытый список. Процесс повторяется и будет завершен если найден путь до цели, либо если в открытом списке не осталось узлов, либо если превышено максимальное количество узлов пути.

Функция поиска пути не самая сложная и не самая большая, но именно она координирует усилия по поиску пути. Остальные функции выполняют лишь вспомогательные роли и вы должны приспособить их к вашей программе.

ПРИМЕЧАНИЕ
Код поиска пути не оптимизирован. Не используйте его в своих проектах без предварительной оптимизации.


Глава 12 - Итоги и оптимизация

Итоги и оптимизация

Существует множество других вещей, которые следует учесть в вашем коде поиска пути. Поработайте над упомянутыми ниже моментами, чтобы превратить заготовку аггоритма поиска пути в готовое к выпуску изделие.

  • Выполняя поиск среди открытых узлов, начинайте его с тех узлов, которые расположены ближе к цели. Во многих случаях это поможет сократить количество узлов, которые требуется просмотреть при поиске пути.
  • Не помещайте другие подразделения на карту препятствий. Если вы поступите так, перемещения подразделений могут вызвать проблемы. Одно из возможных решений состоит в том, чтобы при поиске пути не учитывать другие подразделения и в случае столкновения с другим подразделением заново рассчитывать путь. Если подразделения не блокируют друг друга, это не требуется.
  • Используйте диспетчер путей для управления фиксированным пулом путей. Если вы используете пул путей, это поможет вам сократить нагрузку на процессор, вызванную необходимостью динамического формирования каждого пути.
  • Используйте многоуровневый поиск пути. Для этого применяйте крупномасштабную карту препятствий, каждый узел которой соответствует нескольким блокам обычной карты, что позволит маневрировать среди больших ландшафтных препятствий. Как только подразделение достигнет более сложной области, переключитесь на карту препятствий более мелкого масштаба с большим количеством узлов. Многоуровневый поиск пути может значительно ускорить работу кода.
  • Никогда не вычисляйте перемещение всех подразделений сразу. Создайте очередь путей и вычисляйте на каждом такте игры пути для небольшого числа подразделений.
  • Если в вашей игре разворот подразделений занимает какое-то время, добавьте к вычислениям общей стоимости каждого узла карты стоимость смены направления движения. Это позволит выбирать для подразделений наиболее эффективный путь с наименьшим количеством поворотов. Кроме того, вы можете сделать ряд других подобных дополнений к стоимости узла карты. Например, вы можете увеличить стоимость узлов, расположенных рядом со вражескими позициями, чтобы предотвратить случайное вступление ваших подразделений в бой!
  • Алгоритм А* работает на картах различных типов, в том числе и на тех, где форма блоков отличается от квадратной. Испытайте его на картах с шестиугольными блоками или даже на маршрутных картах.


Глава 13. Визуализация частиц

Краткий обзор

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

  • Основные сведения о частицах.
  • Характеристики частиц.
  • Структура класса частицы.
  • Реализация частиц.


Глава 13 - Основные сведения о частицах

Основные сведения о частицах

Сейчас вы в лагере работающих с частицами новобранцев. Первый вопрос повестки дня — что такое частицы? Если вы откроете корпус своего компьютера и дунете внутрь, то, скорее всего, увидите летающие по комнате частицы пыли. Если вы изучали электромагнитные явления, то наверняка использовали железные опилки. Запустив фейерверк вы увидите летящие в разные стороны искры. Фактически, частица это очень маленькая часть чего-нибудь. Слово «чего-нибудь» допускает многочисленные толклвания. У вас могут быть частицы древесины, песка, грязи, воды и т.д. Вот несколько примеров частиц, которые часто встречаются в стратегических играх:

  • Частицы огня для взрывов.
  • Частицы дыма для следов ракет.
  • Частицы пыли при передвижении подразделений.
  • Частицы воды для дождя.
  • Частицы льда для снега.
  • Частицы энергии для силовых лучей.


Глава 13 - Характеристики частиц

Характеристики частиц

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

  • Изображение
  • Движение
  • Анимация

Изображение частиц

Прежде всего вашим частицам необходимы изображения или наборы изображений. Одним из преимуществ частиц является то, что они могут быть настолько замысловатыми или настолько простыми, насколько вы пожелаете. Хотя вы можете обнаружить, что чаще требуются простые изображения частиц.

Поскольку частица представляет собой маленькую часть чего-то большего, ее изображение тоже будет небольшим. Например, если вы программируете систему частиц для изображения взрывов, вашими частицами будут искры белого, оранжевого и красного цветов. Если вы программируете систему частиц для изображения дождя, частицами будут серые штрихи.

Движение частиц

Чтобы частицы произвели какой-нибудь эффект, они должны двигаться. Возьмем для примера фейерверк; когда заряд разрывается на сотни частиц, именно их движение определяет, насколько красивым будет салют. Одни заряды образуют сферы, другие — огенные полосы. Так же работают и системы частиц в играх. Вы, как разработчик, должны написать алгоритм движения, которому будут следовать частицы. Два примера показаны на рис. 13.1.

Рис. 13.1. Примеры движения частиц

Рис. 13.1. Примеры движения частиц

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

Анимация частиц

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

Рис. 13.2. Изменение частиц с течением времени

Рис. 13.2. Изменение частиц с течением времени

На рис. 13.2 изображена ракета, след за которой образуют частицы дыма. Интересно то, что изображающие дым частицы со временем меняют свой цвет от темно-серого до светло-серого. Вместо того, чтобы для каждой позиции дымового следа использовать собственную частицу, вы используете одни и те же частицы, но добавляете анимацию, изменяющую их цвет от темно-серого до светло-серого. Это базовый принцип анимации частиц. Существуют и другие варианты. Вы можете анимировать размер частиц или используемую текстуру. Границы задает только ваше воображение.


Глава 13 - Структура класса частиц

Структура класса частиц

В качестве примера работы с классом частиц я включил в сопроводительные файлы книги проект с именем D3D_Particles. Загрузите его и следуйте вперед к созданному мной примеру класса. Код класса находится в двух файлах проекта: CParticle.cpp и CParticle.h. Вот как выглядит заголовочный файл:

class CVector
{
   public:
      float fX;
      float fY;
      float fZ;

      CVector() { fX=0.0f, fY=0.0f, fZ=0.0f; };
};

class CParticle
{
   public:
      CVector   m_vecPos;
      CVector   m_vecCurSpeed;
      CVector   m_vecAcceleration;
      CVector   m_vecGravity;
      int       m_iLife;
      int       m_iTextureStart;
      int       m_iTextureEnd;
      int       m_iTextureType;
      int       m_iTextureCur;
      int       m_iTextureSteps;
      int       m_iTextureCurStep;

      CParticle();
      ~CParticle();
      void vUpdate(void);
      bool bIsAlive(void);
      void vSetTextures(int iType, int iStart,
                        int iStop, int iSteps);
      void vSetPos(float x, float y, float z);
      void vSetAcceleration(float x, float y, float z);
      void vSetGravity(float x, float y, float z);
      void vSetSpeed(float x, float y, float z);
      void vSetLife(int iLife);
};

Класс CVector

В начале заголовочного файла расположена реализация очень простого класса вектора. Я мог бы воспользоваться для представления векторов вспомогательным классом DirectX, но предпочел создать собственный класс, чтобы обеспечить переносимость кода. Мой класс вектора используется для хранения значений X, Y и Z таких параметров частиц, как местоположение и скорость. Как видно из кода, класс является только хранилищем данных и ничем более.

Класс CParticle

Класс CParticle предназначен для хранения всей информации, необходимой системе частиц для управления отдельной частицей. Он не предназначен для управления набором частиц. Для создания системы частиц вам потребуется написать диспетчер частиц.

Члены данных

В классе частицы я объявляю несколько переменных, предназначенных для описания характеристик частицы. Вот они:

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

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

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

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

Элемент m_iLife сообщает сколько игровых тактов должно пройти, прежде чем частица станет неактивной и будет уничтожена. На каждом игровом такте значение этой переменной уменьшается на единицу. Это очень полезно для таких частиц, продолжительность жизни которых ограничена. Некоторые частицы, например, вода, могут постоянно присутствовать в игре, в то время как частицы дыма и огня обычно исчезают через какой-то период времени.

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

Элемент m_iTextureType сообщает как именно должна анимироваться текстура. Можно использовать единственную текстуру (т.е. анимация отсутствует), либо можно в цикле перебрать текстуры от начальной до конечной и остановиться. Также можно перебрать текстуры от начальной до конечной (или в обратном порядке), а затем повторять цикл анимации снова. Основное назначение этого параметра — предоставить возможность контроллировать стиль анимации текстур.

Элемент m_iTextureCur сообщает частице, какая именно текстура используется в данный момент времени.

Элемент m_iTextureSteps сообщает сколько тактов игры следует ожидать, прежде чем сменить одну текстуру на другую. Это позволяет задавать паузу в анимации текстуры на несколько тактов игры.

Элемент m_iTextureCurStep сообщает сколько еще тактов игры должно проити до смены текстуры. Отсчет начинается с 0 и идет до значения, заданного переменной m_iTextureSteps. Как только счетчик достигнет максимального значения, будет изменено значение переменной m_iTextureCur а отсчет в переменной m_iTextureCurStep начнется снова с 0.

Методы класса

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

Функция CParticle() является конструктором класса и ее основная задача — очистить все члены данных, присвоив им значения по умолчанию.

Функция ~CParticle() — это деструктор класса, и она освобожает занятую память, когда объект класса уничтожается.

Функция vUpdate() вызывается на каждом такте игры и обновляет местоположение, скорость и состояние текстур частицы.

Функция bIsAlive() сообщает вам жива еще частица или нет. Если она возвращает 0, значит частица уже уничтожена. Если она возвращает 1 — частица еще жива. Чтобы определить, какое значение возвращать, функция проверяет значение члена данных m_iLife.

Функция vSetTextures() устанавливает информацию об анимации текстур, которая будет использоваться частицей.

Функция vSetPos() устанавливает начальное местоположение частицы.

Функция vSetAcceleration() устанавливает начальное ускорение частицы.

Функция vSetGravity() задает гравитационное воздействие на частицу.

Функция vSetSpeed() задает начальную скорость частицы.

Функция vSetLife() устанавливает период жизни частицы.

Функция CParticle::vUpdate()

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

// Изменяем скорость с учетом ускорения
m_vecCurSpeed.fX += m_vecAcceleration.fX;
m_vecCurSpeed.fY += m_vecAcceleration.fY;
m_vecCurSpeed.fZ += m_vecAcceleration.fZ;
// Изменяем скорость с учетом гравитации
m_vecCurSpeed.fX += m_vecGravity.fX;
m_vecCurSpeed.fY += m_vecGravity.fY;
m_vecCurSpeed.fZ += m_vecGravity.fZ;
// Обновляем местоположение
m_vecPos.fX += m_vecCurSpeed.fX;
m_vecPos.fY += m_vecCurSpeed.fY;
m_vecPos.fZ += m_vecCurSpeed.fZ;
//
// Обновление текстуры
//
// Статическая структура
if(m_iTextureType == 0) {
   m_iTextureCur = m_iTextureStart;
}
// Покадровая анимация
else {
   m_iTextureCurStep++;
   if(m_iTextureCurStep >= m_iTextureSteps) {
      // Линейная
      if(m_iTextureType == 1) {
         if(m_iTextureCur != m_iTextureEnd) {
            m_iTextureCur++;
         }
      }
      // Циклическая прямая
      else if(m_iTextureType == 2) {
         m_iTextureCur++;
         if(m_iTextureCur > m_iTextureEnd) {
            m_iTextureCur = m_iTextureStart;
         }
      }
      // Циклическая обратная
      else if(m_iTextureType == 3) {
         m_iTextureCur--;
         if(m_iTextureCur < m_iTextureStart) {
            m_iTextureCur = m_iTextureEnd;
         }
      }
      // Сброс счетчика текстур
      m_iTextureCurStep = 0;
   }
}
// Уменьшение счетчика времени жизни
m_iLife--;

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

Следующая часть кода учитывает значение гравитации и также прибавляет его к скорости. Благодаря ей у вас есть два способа управления скоростью частицы: ускорение и гравитация. Как только вычислено результирующее значение скорости, оно прибавляется к текущему местоположению частицы. В результате частица переместится на новое место.

Следующий блок кода проверяет используемый метод анимации текстуры и соответствующим образом меняет текущую текстуру.

В коде реализованы четыре типа анимации. Первый тип — использование статической текстуры. Это означает, что в цикле анимации используется только одна, стартовая текстура и никаких изменений с ней в ходе игры не происходит.

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

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

Четвертый тип анимации — обратная циклическая анимация. Она похожа на предыдущий тип, но выполняется в обратном направлении — от конечной текстуры к начальной. Процесс повторяется в течение всей жизни частицы.

Последняя вешь, которую выполняет функция обновления, — уменьшение на единицу счетчика времени жизни частицы.


Глава 13 - Реализация системы частиц

Реализация системы частиц

Теперь загрузите проект D3D_Particles, если вы еще не сделали этого, и скомпилируйте его. Запустите программу и вы увидите сцену, похожую на ту, что показана на рис. 13.3.

Рис. 13.3. Окно программы D3D_Particles

Рис. 13.3. Окно программы D3D_Particles

Обратите внимание на разбросанные по экрану разноцветные частицы. Программа показывает как создать случайный набор частиц и подбрасывать их в воздух. Вы можете назвать это демонстрацией попкорна, я оставляю выбор за вами. Кажется я знаю, что вы думаете сейчас: «Какое это имеет отношение к программированию стратегических игр?». Ответ прост — предполагается, что программа покажет вам как можно быстро начать работать с частицами. Более сложные формирования, такие как взрывы и ударные волны, придут позднее, когда вы освоитесь с основными приемами использования частиц.

Проект состоит из четырех файлов: main.cpp, main.h, CParticle.cpp и CParticle.h. Кроме того, для компиляции потребуются следующие библиотеки: d3d9.lib, dxguid.lib, d3dx9dt.lib и d3dxof.lib.

Структура проекта D3D_Particles

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

Рис. 13.4. Ход выполнения программы D3D_Particles

Рис. 13.4. Ход выполнения программы D3D_Particles

На рис. 13.4 видно, как функция WinMain() выполняет инициализацию системы, последовательно обращаясь к функциям InitD3D(), vInitInterfaceObjects() и vInitParticles(). С первыми двумя функциями мы уже встречались в предыдущих примерах, а вот функция инициазизации частиц новая и появляется в этом примере впервые. Ее цель — создание частиц для сцены и установка их атрибутов для анимации.

Инициализация частиц

Код инициализации частиц выглядит следующим образом:

void vInitParticles(void)
{
   // Инициализация каждой частицы
   for(int i = 0; i < TOTAL_PARTICLES; i++) {
      // Установка последовательности анимации текстур
      g_partExplosion[i].vSetTextures(
         rand() % 3, // Тип анимации
         0,          // Начальная текстура
         5,          // Конечная текстура
         10);        // Пауза между сменой текстур
      // Установка начального местоположения
      g_partExplosion[i].vSetPos(
         0.0f + (rand() % g_iWindowWidth),     // X
         0.0f + (rand() % g_iWindowHeight),    // Y
         0.0f);                                // Z
      // Установка начальной скорости
      g_partExplosion[i].vSetSpeed(
         -1.0f + rand() % 2,                   // X
         -8.0f + rand() % 4,                   // Y
         0.0f);                                // Z
      // Установка гравитационного воздействия
      g_partExplosion[i].vSetGravity(
         0.0f,                             // X
         0.1f,                            // Y
         0.0f);                           // Z
      // Установка длительности жизни частицы
      g_partExplosion[i].vSetLife(200);
   }
}

Функция в цикле перебирает все частицы, количество которых задается определенной в заголовочном файле константой TOTAL_PARTICLES. Для каждой частицы задается случайное местоположение и скорость. Затем задается гравитационное воздействие и, в самом конце, продолжительность жизни частицы устанавливается равной 200. Это сообщает системе, что частица будет существовать в течение 200 игровых тактов.

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

Тип анимации выбирается случайным образом, а диапазон используемых при анимации текстур для всех частиц будет от 0 до 5. Кроме того, я задаю паузу между сменой текстур, равной 10 для того, чтобы кадры анимации не сменяли друг друга слишком быстро.

Визуализация частиц

Теперь, когда частицы инициализированы, пришло время отображать их. Это делается в привычной функции vRender(). Вот часть ее кода, отвечающая за визуализацию частиц:

// Цикл перебора частиц
for(int i = 0; i < TOTAL_PARTICLES; i++) {
   // Проверяем, жива ли частица
   if(g_partExplosion[i].bIsAlive()) {
       // Визуализация частицы
       vDrawInterfaceObject(g_partExplosion[i].m_vecPos.fX,
                    g_partExplosion[i].m_vecPos.fY,
                    (float)g_iParticleSize,
                    (float)g_iParticleSize,
                    g_pTexture[
                    g_partExplosion[i].m_iTextureCur
                    ]);
       // Обновление частицы
       g_partExplosion[i].vUpdate();
   }
   else {
      // Сброс частицы, если она уничтожена
      vInitParticles();
   }
}

Функция визуализации в цикле перебирает все частицы, количество которых задается определенной в заголовочном файле константой TOTAL_PARTICLES. Для каждой частицы функция сперва проверяет жива ли она еще. Если частица жива, функция отображает ее в текущем местоположении. Чтобы сообщить функции визуализации, какую текстуру следует использовать, применяется хранящийся в объекте частицы номер текущей текстуры. После визуализации частицы ее данные обновляются путем вызова функции vUpdate().

СОВЕТ
Если вы хотите увеличить или уменьшить количество частиц, поменяйте значение константы TOTAL_PARTICLES в заголовочном файле. Попробуйте делать его больше и больше, пока не заметите влияние на систему. Мой компьютер заметно замедляет работу, когда я создаю около 6000 частиц.

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


Глава 13 - Итоги

Итоги

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

  • Частицы — это мелкие фрагметы крупных объектов.
  • Хотя частицы небольшие, вы можете использовать для их анимации любые графические изображения.
  • Анимация текстур улучшит внешний вид ваших частиц и обеспечит дополнительную гибкость проекту.
  • Для управления частицами можно использовать класс системы частиц.


Глава 14. Пример сетевого программирования

Краткий обзор

Разве вы не огорчитесь, если, прочитав целую книгу о разработке игр, ничего не найдет в ней о том, как добавить в программу возможность игры по локальной сети или через Интернет? Не знаю, как насчет вас, но сам всегда ищу подобные сведения! Я не хочу, чтобы моя книга оказалась в той же ловушке. Как и со всеми остальными темами, вам сперва потребуется учебник для начинающих, рассказывающий о сетях и о том, как они работают с точки зрения разработки игр. В этой главе будут охвачены следующие темы:

  • Сетевые соединения.
  • Сетевые протоколы.
  • Пакеты.
  • Задержка.
  • Одноранговые сети.
  • Сети клиент-сервер.
  • Сокеты и DirectPlay.
  • Пример программирования сокетов.


Глава 14 - Сетевые соединения

Сетевые соединения

Чтобы играть с другим человеком, сидящим за другим компьютером вам сперва необходимо установить соединение с этим компьютером. Существует множество типов соединений, но в игровом сообществе все они делятся на две основные категории: локальная сеть и Интернет.

Игра по локальной сети

Игра по локальной сети (LAN) позволяет вам играть против других компьютеров, находящихся в закрытой сети. Некоторые игры позволяют одновременно играть лишь паре игроков, в то время как другие поддерживают одновременную игру десятков пользователей. Самое лучшее в игре по локальной сети — отсутствие задержек, связанных с сетевым траффиком. Играя в закрытой сети вы не страдаете от непредсказуемости Интернета.

BNC — шаг вперед!

Давным давно в далекой галактике... Ну, дальше вы знаете сами. Так или иначе, в не столь отдаленном прошлом существовало несколько методов объединения компьютеров в локальную сеть. Один старый метод использовал BNC-коннекторы и коаксиальный кабель. Я видел и такую сеть, где использовалось самодельное оборудование и параллельные кабели. К счастью для вас, большинство локальных сетей сегодня используют витую пару и соответствующие коннекторы. Витая пара и коаксиальный кабель с коннекторами показаны на рис. 14.1.

Рис. 14.1. Витая пара и коаксиальный кабель

Рис. 14.1. Витая пара и коаксиальный кабель

Самое досадное в BNC и коаксиальных кабелях — необходимость создать цепочку из компьютеров соединенных вместе, проходя от одного к другому. Вы не можете просто поставить концентратор (hub), чтобы все подключались к нему; вам необходимо объединить все компьютеры одной линией и поставить на ее концах терминаторы, чтобы предотвратить блуждание сигнала туда-сюда по линии. На рис. 14.2 изображена сеть, использующая BNC и коаксиальный кабель.

Рис. 14.2. Компьютеры, соединенные коаксиальным кабелем

Рис. 14.2. Компьютеры, соединенные коаксиальным кабелем

Как видно на иллюстрации, компьютер A соединен с компьютером B. Компьютер B соединен с компьютером C, а компьютер C соединен с компьютером D. На компьютерах A и D установлены терминаторы, блокирующие сигнал. Они очень важны — без них сеть не будет правильно функционировать. Если вы когда-нибудь пользовались устройствами SCSI, то должны знать назначение терминаторов. В основе работы терминаторов лежит следующее физическое явление: электрический сигнал в сети распространяется от компьютера к компьютеру, пока не будет поглощен терминатором. Если в конце линии не будет терминатора, сигнал будет отражен и станет распространяться в обратном направлении. В результате компьютеры в сети будут многократно получать одну и ту же информацию из-за отражений сигнала. Можете представить себе, сколько проблем это вызовет.

Соединения витой парой

При использовании витой пары открывается гораздо больше возможностей. Наиболее часто игроки используют следующее оборудование:

  • Кросс-кабель
  • Концентратор
  • Коммутатор
  • Маршрутизатор
Соединение через кросс-кабель

Когда в сети всего два компьютера, можно использовать специальный сетевой кабель, обычно называемый кросс-кабелем. Фактически это обычная витая пара со специальным порядком подключения проводов в разъемах. Она позволяет соединять компьютеры не используя никакой дополнительной аппаратуры. Вы можете приобрести такой кабель в CompUSA или другой фирме примерно за $3 USD. На рис. 14.3 изображены два компьютера, соединенные кросс-кабелем.

Рис. 14.3. Компьютеры, соединеные кросс-кабелем

Рис. 14.3. Компьютеры, соединеные кросс-кабелем

Как видно на рис. 14.3, компьютер A соединен с компьютером B единственным кабелем. Это достаточно просто — и в этом главная красота. Если у вас только два компьютера, я рекомендую пользоваться именно этим способом. Я также рекомендую приобрести кросс-кабель, если у вас есть ноутбук. Никогда не знаешь, в какой момент понадобится подключиться к другому компьютеру для обмена файлами.

Сеть с концентратором

Концентраторы (hub) используются для объединения нескольких компьютеров в сеть витой парой. Некоторые концентраторы очень маленькие, а другие достаточно большие. Есть два параметра по которым разделяются концентраторы: количество портов и скорость.

Поскольку концентратор позволяет соединять между собой несколько компьютеров, у него должен быть отдельный порт для каждого подключаемого компьютера. Именно поэтому так важно количество портов. У большинства концентраторов есть как минимум пять портов, так что они позволяют объединить четыре или пять компьютеров. Вы удивляетесь, почему я сказал четыре или пять, а не пять? Дело в том, что некоторые концентраторы позволяют объединять их в последовательную цепочку. В этом случае последний порт используется для подключения следующего в цепочке концентратора (или коммутатора). Перед тем, как я подробнее расскажу об этом, взгляните на рис. 14.4, где изображена базовая архитектура сети с концентратором.

Рис. 14.4. Компьютеры, соединенные через единственный концентратор

Рис. 14.4. Компьютеры, соединенные через единственный концентратор

Простейший концентратор, позволяющий объединить четыре или пять компьютеров, стоит около $30 USD. Некоторые из них продаются в комплекте с несколькими сетевыми картами. Когда вы переходите к концентраторам, позволяющим подключать до восьми компьютеров, цена обычно подскакивает вдвое. Для концентраторов, позволяющих подключать 16 и более компьютеров цена еще выше. Обычно дешевле приобрести несколько простых концентраторов и объединить их в последовательную цепочку, чем приобретать единственный концентратор, рассчитанный на большое количество соединений. На рис. 14.5 изображены несколько соединенных вместе концентраторов.

Рис. 14.5. Компьютеры, соединенные с помощью нескольких концентраторов

Рис. 14.5. Компьютеры, соединенные с помощью нескольких концентраторов

Главная проблема концентраторов заключается в том, что все подключенные к нему компьютеры совместно используют одну внутреннюю полосу пропускания. Это значит, что каждый подключенный к концентратору компьютер соревнуется с другими за установление соединения. Результатом является увеличение объема передаваемых ненужных данных и потеря пакетов в том случае, если к концентратору подключено много компьютеров. Лучшей аналогией концентратора, которую я могу придумать, является спаренная телефонная линия. Если к такой линии подключено пять человек, то между тем, кто говорит, и теми кто хочет говорить будут постоянно возникать конфликты. Те же проблемы есть и у концентраторов.

Если говорить о скорости концентратора, то на текущий момент доступны три варианта: 10 мегабит, 100 мегабит и 1 гигабит. Скорость задает ширину полосы пропускания, доступной каждому каналу концентратора. Если на ваших компьютерах установлены 10-мегабитные сетевые карты, вам будет достаточно 10-мегабитного концентратора. Если вы используете 100-мегабитные сетевые карты, потребуется 100-мегабитный концентратор. То же самое справедливо и для гигабитных концентраторов и сетевых карт. Естественно, увеличение скорости концентратора увеличивает и его стоимость.

Сеть с коммутатором

Следующий логический шаг после использования концентраторов — коммутаторы (switch). Основной принцип действия коммутаторов ничем не отличается от принципа действия концентраторов, но они предоставляют каждому подключенному к ним компьютеру собственный канал связи. Это устраняет имеющиеся у концентраторов проблемы с выдачей ненужных данных и потерей пакетов. Главное различие между концентраторами и коммутаторами — их стоимость. Пятипортовый коммутатор будет стоить от $50 до $70 USD, в то время как стоимость пятипортового концентратора составляет от $20 до $30 USD. Лично я всегда приобретаю коммутаторы. Может они и стоят больше, но и работают гораздо лучше, чем концентраторы.

Вы можете найти коммутаторы и концентраторы, которые стоят дешевле, чем упомянутые выше цены. Некоторые фирмы, например Linksys, производят очень дешевое оборудование. Глваная проблема в этом, что скупой, как уже давно известно, платит дважды. Я предпочитаю заплатьть на 50 процентов больше за высококачественное сетевое оборудование, чем сэкономить деньги, приобретая дешевые устройства. Лично мне больше всего нравятся устройства фирмы Netgear. Они не обременены сотнями дополнительных возможностей, но надежны и хорошо выполняют свою работу.

Маршрутизаторы — ворота в Интернет

Пришло время познакомиться с маршрутизаторами (router), поскольку они используются во многих конфигурациях. У каждого маршрутизатора есть четыре основных параметра:

  • Количество портов
  • Скорость
  • Поддержка брандмауэров
  • Возможность беспроводных соединений

Перед тем, как я перейду к рассмотрению этих параметров маршрутизаторов, взгляните на рис. 14.6, где изображена сеть персональных компьютеров, использующая маршрутизатор.

Рис. 14.6. Компьютеры, соединенные через маршрутизатор

Рис. 14.6. Компьютеры, соединенные через маршрутизатор

Как видно на рис. 14.6, все компьютеры соединены между собой через маршрутизатор. Главное отличие между этой иллюстрацией и теми, где была показана сеть с концентраторами, в том, что маршрутизатор обеспечивает также и выход в Интернет. Это означает, что каждый компьютер в сети имеет доступ не только к другим компьютерам, но и к Интернету. Здорово, да?

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

Маршрутизаторы со встроенными концентраторами или коммутаторами точно так же различаются по скорости. Вы можете приобрести маршрутизатор, поддерживающий 10-мегабитные, 100-мегабитные или гигабитные подключения. Для выполнения большинства прикладных задач достаточно полосы пропускания в 100 мегабит.

Количество портов

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

Скорость

Маршрутизаторы, в зависимости от модели, поддерживают скорость обмена 10 мегабит, 100 мегабит или 1 гигабит. Лично меня вполне устраивают маршрутизаторы со скоростью 100 мегабит, поскольку они быстрее, чем DSL.

Поддержка брандмауэра

У большинства маршрутизаторов есть встроенный брандмауэр. Что он дает вам? Помощь в защите вашего компьютера от нежелательных подключений из внешнего мира. Я знаю, что сперва это может звучать странно, учитывая что вы хотите чтобы другие игроки подключались к вашему компьютеру, но поверьте мне — открытые подключения это плохо. Качество и функциональность брандмауэров варьируются в зависимости от производителя. При покупке оборудования следует обратить внимание на наличие поддержки переадресации портов. Если вы не можете открыть порты для подключений, то при попытке поиграть столкнетесь с трудностями.

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

Беспроводные соединения

В наши дни можно приобрести практически любое сетевое оборудование с встроенной поддержкой беспроводных соединений. Беспроводные соединения позволяют объединять компьютеры через высокочастотный радиоканал. Если вы приобретаете беспроводной маршрутизатор, как сделал я, убедитесь, что приобретаемые для компьютеров беспроводные сетевые карты соответствуют тому же стандарту. Некоторые стандарты совместимы с предыдущими версиями, а многие — нет. Также следует обратить внимание на поддержку вашего беспроводного оборудования. Когда я проверял последний раз, некоторые игровые компании отказались предоставлять техническую поддержку людям, использующим беспроводные соединения. Не буду показывать пальцем на кого-нибудь конкретного, но скажу, что изготовитель поселившейся под моим телевизором черной коробки с большой буквой X на крышке не любит поддерживать мои беспроводные соединения.


Глава 14 - Сетевые протоколы

Сетевые протоколы

Когда речь идет о разработке игр, есть только два протокола, которые следует рассмотреть: TCP/IP и UDP. Это два основных используемых сегодня протокола, и, если вы не читаете эту книгу через много лет после ее издания, только они представляют разумный выбор для разработчика игр.

Протокол TCP/IP

Протокол TCP/IP обеспечивает обмен сообщениями между компьютерами с гарантированной доставкой данных. Он также делает такие приятные вещи, как упорядочивание пакетов. Главное преимущество TCP/IP в том, что он делает за вас большую часть работы, гарантируя доставку сообщений. Что означает гарантия доставки информации? Это значит, что если система сообщает вам о том, что отправила сообщение другому компьютеру, вы можете быть уверены, что данные действительно отправлены. Я знаю, что данная особенность кажется очевидной, но в мире сетевого программирования это не так.

Основной недостаток протокола TCP/IP — медленная передача данных. Это вызвано гарантируемой доставкой данных. Чтобы гарантировать доставку, система должна отправить информацию, получить ответ и проконтроллировать правильность передачи. Это отнимает драгоценное время и на сегодняшнем оборудовании задержки часто оказываются недопустимыми для игр.

Протокол UDP

Протокол UDP — это более простая версия TCP/IP. С одной стороны он не гарантирует доставку информации. Может быть данные добрались до цели, а может и нет — кто знает? Кроме отсутствия гарантии доставки, UDP передает данные в произвольном порядке. Возьмем, к примеру, следующую фразу:

    «Шустрая рыжая лиса перепрыгнула через ленивую собаку»

Если вы передаете эту фразу по протоколу UDP, данные могут следовать в таком порядке:

    «собаку рыжая Шустрая лиса ленивую перепрыгнула через»

Как видно из приведенного выше примера, данная особенность может вызвать проблемы при разработке игры. Существуют технические способы ее решения, но вы должны реализовать их самостоятельно, или использовать какой-нибудь API, который сделает это за вас.

Главное преимущество UDP — его исключительная быстрота и гибкость. Если не надо беспокоиться о таких вещах, как порядок данных и гарантированная доставка, он даст значительный выигрыш в скорости. Кроме того, вы можете написать систему, гарантирующую доставку данных и работающую поверх протокола и включать ее когда нужно. Это позволит в случае необходимости передавать данные с гарантированной доставкой и поддерживать более быструю передачу, когда данные не являются критически важными.

TCP/IP против UDP

Может быть вы думаете о том, какой протокол и когда следует использовать. Позвольте мне помочь вам, рассмотремв несколько сценариев.

  • Пошаговые стратегические игры

    Для пошаговых стратегических игр вы, скорее всего, должны остановить свой выбор на TCP/IP. Поскольку данные о ходах передаются регулярно, вам не нужна высокая скорость и проблемы с UDP.

  • Стратегические игры реального времени

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

  • Многопользовательские сетевые ролевые игры (MMORPG)

    Эй, это же книга о стратегических играх! (Шутка.) Здесь ответ не столь однозначен. Многопользовательские ролевые игры передают огромные объемы данных, так что они кажутся созданными для использования протокола UDP. Проблема в том, что гарантированная доставка с протоколом UDP не столь эффективна, как с TCP/IP, а в многопользовательских ролевых играх вам очень часто будет требоваться именно гарантированная доставка данных. Я не хочу оставить вас в растерянности, так что советую попробовать сначала UDP, а затем, если не сможете заставить его работать, переключиться на TCP/IP.


Глава 14 - Пакеты

Пакеты

Пакеты, пакеты, кому пакеты? Хм-м, я думаю, что эту фразу использую в какой-нибудь другой книге. Так или иначе, пакеты это всего лишь блоки информации. Когда вы отправляете данные другому игроку, они передаются в виде пакетов. Обычно пакеты содержат такую информацию, как адрес получателя, адрес отправителя и фактические данные. Взгляните на пример пакета, изображенный на рис. 14.7.

Рис. 14.7. Пример пакета

Рис. 14.7. Пример пакета

На рис. 14.7 показана структура простого пакета. Первая часть пакета, называемая заголовком пакета, на рисунке выделена более темным цветом фона. Заголовок содержит информацию о пакете, а область с более светлым цветом фона содержит передаваемые в пакете данные. Как видно на иллюстрации, в заголовке есть ячейки для данных отправителя, данных получателя и типа пакета. В данных отправителя и получателя чаще всего передаются соответствующие IP-адреса, а поле типа пакета, сообщает, какие именно в нем содержатся данные. В рассматриваемом примере тип пакета — пакет данных о передвижении подразделения. Для того, чтобы сообщить о передвижении подразделения в паккете передается идентификатор подразделения, новые координаты по осям X и Y и новая ориентация подразделения. Эта информация говорит получателю, какое подразделение переместилось, куда, и как оно расположено на новом месте.

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


Глава 14 - Задержка

Задержка

Ох, ужасное слово для разработчиков многопользовательских игр. Задержка (latency) — это время, которое тратится на передачу пакета от одного компьютера к другому. В локальных сетях задержка создает не слишком много проблем, поскольку высокоскоростные соединения сверхдешевы и отвечают большинству потребностей. Но когда на сцене появляется Интернет, возникают новые проблемы. Пакеты могут задерживаться из-за аппаратуры отправителя, аппаратуры получателя или аппаратуры, расположенной между отправителем и получателем. Главное то, что Интернет вностит фактор неопределенности в любые прогнозы относительно работы сетевых игр. Этого не следует бояться — существуют варианты обхода подобных явлений.

Чтобы бороться с задержками, сделайте ваш код надежным. Никогда не основывайте его на предположениях о длительности задержек. Если вы пишете код, который может принять надолго задержавшийся пакет, программа будет защищена. Но если вы предполагаете, что все пакеты будут доставлены вовремя — готовьтесь к разочарованиям.


Глава 14 - Одноранговые сети

Одноранговые сети

В разработке игр используются две сетевых архитектуры: одноранговая и клиент-сервер. В одноранговых сетях каждый игрок отправляет свои данные другим игрокам. Здесь нет центрального пункта через который проходит передача информации. В результате игроки передают данные один другому. Взгляните на рис. 14.8, который иллюстрирует вышесказанное.

Рис. 14.8. Пример одноранговой сети

Рис. 14.8. Пример одноранговой сети

На рис. 14.8 вы видите линии соединений между компьютерами, вовлеченными в четырехпользовательскую игру. Поскольку каждый компьютер соединен со всеми остальными системами в сети, линий траффика достаточно много. Это хорошо работает пока все подключенные к сети компьютеры могут использовать высокоскоростные соединения, и вызывает проблемы, если у одного или нескольких человек установлено низкоскоростное оборудование.


Глава 14 - Сети клиент-сервер

Сети клиент-сервер

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

Рис. 14.9. Пример сети клиент-сервер

Рис. 14.9. Пример сети клиент-сервер

На иллюстрации показана четырехпользовательская игра основанная на архитектуре клиент-сервер. Первое, что бросается в глаза, — сократившееся количество соединений между компьютерами. Поскольку клиенты общаются только с сервером, количество открытых линий резко сокращается. Главное преимущество этого в том, что только серверу необходимо высокоскоростное подключение к Интернету. Другое преимущество сетей клиент-сервер заключается в том, что они могут обслуживать значительное количество игроков. И последнее преимущество таких сетей состоит в том, что сервер может выполнять проверку достоверности поступающей информации. Это неоценимо для ловли мошенников.

Сравнение одноранговых сетей и сетей клиент-сервер

И сновы вы в недоумении. Какую архитектуру использовать: одноранговую или клиент-сервер? Приведенные ниже сценарии должны прояснить для вас этот вопрос.

  • Походовые стратегические игры

    Используйте для них архитектуру клиент-сервер. Поскольку такие сети могут обслуживать сотни игроков, у вас не будет никаких ограничений. Вы также получите пользу от санитарных проверок данных на сервере, позволяющих предотвратить мошенничество в игре.

  • Стратегические игры реального времени

    И для этого типа игр больше подходят сети клиент-сервер. Причины остаются теми же, что и для пошаговых игр.

  • Многопользовательские сетевые ролевые игры (MMORPG)

    Здесь вы обязаны использовать архитектуру клиент-сервер. Вы не сможете обеспечить одновременную игру тысяч пользователей в одноранговой сети.

Уловили общую мысль? По-моему, одноранговые сети устарели и не стоит с ними связываться. Они не только неэффективны, но и вызывают массу проблем при использовании брандмауэров и в ряде других случаев. Вы можете попробовать работать с ними, но я вам этого не советую.


Глава 14 - Сокеты и DirectPlay

Сокеты и DirectPlay

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

Что такое сокеты? Это каналы связи между компьютерами, передающие данные в обоих направлениях. Вы можете одновременно открыть несколько сокетов, и ничто не ограничивает максимальное число открытых сокетов.

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

Главное преимущество DirectPlay заключается в том, что он выполняет за вас значительную часть работы. Вам уже не придется беспокоиться об упорядочивании пакетов, гарантии доставки и даже об управлении сессиями. Главный вред от DirectPlay — утрата полного контроля за происходящим. Еще одна проблема заключается в том, что DirectPlay нельзя использовать на операционных системах, отличных от Windows. Чтобы сделать правильный выбор учтите следующие правила:

  • Если вам необходимо переносить вашу игру на различные платформы, используйте сокеты.
  • Если вы хотите, чтобы DirectX делал за вас всю сложную работу, используйте DirectPlay.


Глава 14 - Пример программирования сокетов

Пример программирования сокетов

Я люблю учить на примерах, так что как насчет программы, использующей TCP/IP, которая подключается к Интернету, посылает HTTP-запрос на Web-сервер и отображает главную страницу сайта? Перед тем, как я перейду к коду, посмотрите на рис. 14.10, где показано что именно мы будем делать.

Рис. 14.10. Ход выполнения простой программы, использующей сокеты

Рис. 14.10. Ход выполнения простой программы, использующей сокеты

Здесь вы можете видеть этапы, необходимые для того, чтобы подключиться к Веб-серверу и загрузить с него главную страницу. Сперва вы инициализируете сокеты, чтобы коммуникационный уровень был готов к работе. Затем вы создаете сокет, который будет использоваться для подключения к Web-серверу. Когда сокет готов, вы находите IP-адрес Web-сервера и устанавливаете соединение с ним. После установки соединения вы отправляете HTTP-запрос на получение содержимого главной страницы. После этого вам остается только ждать, когда запрошенная информация придет в буфер ответа. Получив данные вы закрываете сокет и отключаете всю систему сокетов.

Программа Sockets_Receive

Я реализовал код, необходимый для воссоздания этапов, изображенных на рис. 14.10. Загрузите программу Sockets_Receive и следуйте за мной. Проект состоит из файла main.cpp и единственной библиотеки ws2_32.lib, которая содержит все, что необходимо для программирования сокетов в Windows. Скомпилируйте программу и запустите ее. Вы увидите окно консольного приложения, в котором отображается содержимое главной страницы сайта, имя которого задано в коде. Как это должно выглядеть, показано на рис. 14.11.

Рис. 14.11. Окно программы Sockets_Receive

Рис. 14.11. Окно программы Sockets_Receive

Открыв файл main.cpp вы увидите следующий код:

#include <iostream.h>
#include <winsock.h>
#include <stdio.h>

void main(void)
{
   SOCKET    skSocket;
   sockaddr_in saServerAddress;
   int       iPort = 80;
   int       iStatus;
   WSADATA   wsaData;
   WORD      wVersionRequested;
   LPHOSTENT lpHost;
   char      szHost[128];
   char      szSendBuffer[256];
   char      szRecvBuffer[32768];
   int       iBytesSent;
   int       iBytesReceived;

   sprintf(szHost,"www.lostlogic.com");
   // Сообщаем WinSock, что нам нужна версия 2
   wVersionRequested = MAKEWORD(2, 0);
   // Инициализируем дескриптор сокета
   skSocket = INVALID_SOCKET;
   // Запускаем WinSock
   iStatus = WSAStartup(wVersionRequested, &wsaData);
   // Создаем сокет
   skSocket = socket(AF_INET, SOCK_STREAM, 0);
   // Проверяем наличие ошибок
   if(skSocket == INVALID_SOCKET) {
      cout << "**ERROR** Could Not Create Socket" << endl;
      exit(1);
   }
   memset(&saServerAddress, 0, sizeof(sockaddr_in));
   saServerAddress.sin_family = AF_INET;
   saServerAddress.sin_addr.s_addr = inet_addr(szHost);

   if(saServerAddress.sin_addr.s_addr == INADDR_NONE)
   {
      lpHost = gethostbyname(szHost);
      if (lpHost != NULL) {
         // Получаем адрес сервера из информации хоста
         saServerAddress.sin_addr.s_addr =
            ((LPIN_ADDR)lpHost->h_addr)->s_addr;
      }
      else {
         cout << "**ERROR** Could Not Locate Host" << endl;
         exit(1);
      }
   }
   // Задаем порт сервера
   saServerAddress.sin_port = htons(iPort);
   // Пытаемся подключиться к серверу
   iStatus = connect(skSocket,
          (struct sockaddr*)&saServerAddress,
          sizeof(sockaddr));

   // Проверяем наличие ошибок
   if(iStatus == SOCKET_ERROR) {
      cout << "**ERROR** Could Not Connect To Server" << endl;
      exit(1);
   }
   sprintf(szSendBuffer,"GET / HTTP/1.0\n\n");
   // Отправляем HTTP-запрос
   iBytesSent = send(skSocket, szSendBuffer, 256, 0);
   memset(szRecvBuffer, 0x00, 32768);
   // Получаем данные
   iBytesReceived = recv(skSocket, szRecvBuffer, 32768, 0);
   cout << szRecvBuffer << endl;
   // Завершаем работу
   closesocket(skSocket);
   WSACleanup();
}

Включение заголовочного файла WinSock

Заголовочный файл winsock.h содержит всю необходимую информацию для работы с библиотекой сокетов ws2_32.lib. Убедитесь, что включаете его в любой ваш код, который работает с сокетами. Остальные заголовочные файлы применяются ежедневно в обычном программировании.

Установка версии WinSock

Перед тем, как вы вообще сможете использовать какие-либо сокеты, необходимо инициализировать систему сокетов Windows, вызвав функцию WSAStartup(). Эта функция получает номер версии сокетов, которую вы намереваетесь использовать и инициализирует коммуникационную систему. Раз вы хотите использовать сокеты версии 2, установите номер запрашиваемой версии равным 2.

Создание сокета

Чтобы подключиться к внешнему миру вам нужен канал связи в виде сокета. Чтобы создать такой канал вызовите функцию socket(), предоставляемую библиотекой сокетов. Успешно завершившаяся функция возвращает идентификационный номер (дескриптор) сокета.

СОВЕТ
Дескриптор сокета это всего лишь обычное число. Для каждого, создаваемого вами сокета, система возвращает уникальное представляющее его число.

Поиск сервера по URL

Если вы хотите установить соединение с сервером, используя URL, а не IP-адрес, вам сперва надо будет найти IP-адрес по URL. Это делается с помощью функции gethostbyname(). Она получает имя сервера и преобразует его в соответствующий IP-адрес.

Установка номера порта

Сервер может принимать подключения по нескольким линиям связи. Каждая из таких линий называется портом. Поскольку предоставляется несколько портов на выбор, необходимо указывать конкретный порт, с которым вы хотите соединиться. Это делается путем указания номера порта во время инициализации структуры адреса сервера sockaddr_in. Требуемая информация содержится в поле sin_port вышеупомянутой структуры. Задайте значение переменной sin_port и вы готовы идти дальше.

Подключение к серверу

После того, как заданы IP-адрес и порт, вы можете подключаться к серверу. Это делается путем вызова предоставляемой сокетом функции connect(). Ей передаются дескриптор сокета, который вы хотите использовать, и параметры сервера к которому вы хотите подключиться. Если функция возвращает значение SOCKET_ERROR, значит произошла ошибка; в противном случае соединение установлено.

Отправка данных серверу

Теперь, когда соединение с сервером установлено, можно отправить пакет с HTTP-запросом. Этот пакет сообщает серверу, что вы хотите увидеть содержимое предоставляемой по умолчанию веб-страницы. Для отправки пакета необходимо воспользоваться функцией send(). Она получает сокет, через который будут отправлены данные, сами отправляемые данные и их размер. В рассматриваемом примере я отправляю содержимое буфера szSendBuffer через сокет, идентификатор которого хранится в переменной skSocket.

Если какие-либо данные были переданы, функция возвращает количество отправленных байт.

Получение данных от сервера

После того, как вы отправили HTTP-запрос серверу, следует получить ответ от него. Чтобы увидеть ответ, вы должны вызвать функцию recv(), которая вернет данные из буфера связи сокета. Самое лучшее в сокетах то, что они автоматически принимают данные и помещают их в системный буфер, так что вам не следует беспокоиться, что данные могут быть потеряны из-за того, что ваша программа занята. Тем не менее, следует проявлять осторожность и не ждать слишком долго, поскольку данные, которые находятся в буфере слишком долго будут потеряны.

Функции приема в параметрах передаются идентификатор сокета, от которого вы хотите получить информацию, буфер для размещения данных и его размер. Как только появятся какие-нибудь данные для получения, они будут переданы в буфер приема и программа продолжит работу. Если данные никогда не будут отправлены, функция будет ждать вечно, пока вы не завершите программу. Такова природа синхронных сокетов (blocking socket).

ПРИМЕЧАНИЕ
Синхронный сокет ждет завершения выполнения команды, прежде чем продолжить работу. Это может приводить к такми проблемам, как блокировка программы. Гораздо лучше использовать асинхронные сокеты, которые возвращают управление программе сразу после вызова функции. Основной недостаток асинхронных сокетов в том, что программы, использующие их, труднее писать.

После того, как функция приема данных вернет управление программе, вы можете взглянуть на содержимое веб-страницы, выведя на экран буфер szRecvBuffer.

Закрытие сокета

Теперь, когда работа с сокетом завершена, необходимо закрыть его. Это делает функция closesocket(). Она получает идентификатор сокета, который будет закрыт и отключен.

Отключение сокетов

Когда вы полностью завершили работу с сокетами, необходимо отключить коммуникационную систему сокетов, вызвав функцию WSACleanup(). Ее требуется вызывать один раз в конце вашей программы.

Мы вихрем промчались по теме синхронных сокетов! Я знаю что действительно сильно разогнался, но моей главной целью было заложить основы для более интересного материала.

Программирование сетевых походовых игр

Как вы знаете, есть два типа стратегических игр: походовые и реального времени. Хотели ли вы когда-нибудь создать походовую игру, в которую можно играть через Интернет? Я говорю о том, что коллективные игры доставляют много удовольствия, но не слишком удобны, потому что игрокам необходимо собраться в одном месте. Здесь на сцену выходит программа, которую я сейчас опишу. В сопроводительных файлах к книге есть проект Sockets_TurnGame. Будучи скомпилированной, эта программа демонстрирует как осуществляется походовая игра в локальной сети или через Интернет. Пойдемте дальше и загрузим этот проект.

Ход выполнения походовой игры

Походовые сетевые игры следуют очень прямолинейной схеме работы. Чтобы узнать, как она выглядит, взгляните на рис. 14.12.

Рис. 14.12. Ход выполнения походовой сетевой игры

Рис. 14.12. Ход выполнения походовой сетевой игры

На иллюстрации изображены клиент и сервер для походовой сетевой игры. Процесс начинается с запуска сервера, который прослушивает указанный порт, ожидая подключения клиента. Как только клиент установил соединение, сервер принимает его и ждет, пока клиент сделает свой ход. Как только клиент будет готов, он посылает пакет с данными своего хода серверу и ждет ответного хода сервера. Когда игрок на сервере будет готов, он завершает свой ход и отправляет пакет с данными хода клиенту. Этот процесс повторяется до завершения игры. Получается игра в которой вы получаете право хода, делаете ход и затем ждете, пока другой игрок сделает то же самое.

Программа Sockets_TurnGame

Программа Sockets_TurnGame предлагает пример реализации схемы, изображенной на рис. 14.12. Запустите программу и вы увидите окно, изображенное на рис. 14.13.

Рис. 14.13. Окно программы Sockets_TurnGame

Рис. 14.13. Окно программы Sockets_TurnGame

На рис. 14.13 изображено небольшое окно с элементами управления, позволяющими работать как главный компьютер или установить соединение. Щелчок по кнопке Host переключает программу в режим игрового сервера, а щелчок по кнопке Connect переключает программу в режим клиента. Так как нельзя получить цыпленка раньше яйца, вы должны сначала запустить программу главного компьютера и уже потом подключаться к ней посредством клиента.

Если вы до сих пор не поняли, чтобы программа работала должным образом, вам необходимо запустить ее дважды. Это необходимо потому, что для демонстрации передачи ходов туда и обратно необходим как ведущий компьютер, так и клиент. Если вы еще не сделали этого, запустите программу дважды и в одном из экземпляров щелкните по кнопке Host. После этого в другом экземпляре программы щелкните по кнопке Connect. В результате вы должны увидеть что-нибудь, напоминающее рис. 14.14.

Рис. 14.14. Клиент установил соединение с сервером

Рис. 14.14. Клиент установил соединение с сервером

На рис. 14.14 и на вашем экране вы видите два экземпляра программы. Программа сервера должна ожидать, пока клиент сделает свой ход, и у программы клиента должна быть готовая к использованию кнопка Turn Done. Игрок, у которого видна кнопка Turn Done в данный момент получил контроль над игрой и может передать его другому игроку, щелкнув по кнопке. Так вы можете передавать ход туда и обратно, щелкая по появляющейся кнопке. Я знаю, что понадобится напрячь воображение, но попытайтесь представить себе, что между щелчками по кнопке вы выполняете сложные игровые ходы.

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

Рис. 14.15. Ход выполнения программы Sockets_TurnGame

Рис. 14.15. Ход выполнения программы Sockets_TurnGame

На иллюстрации показано, что программа начинает работу с инициализации элементов управления и сокетов. После того, как инициализация успешно завершена, программа переходит в цикл обработки сообщений и ждет, пока пользователь щелкнет по кнопке Host или Connect. Если пользователь выбрал кнопку Connect, программа ждет, пока он закончит свой ход. Если же пользователь выберет кнопку Host, программа ждет подключения клиента.

Код проекта содержится в файлах main.cpp и main.h. Для работы программе требуются две библиотеки: winmm.lib и ws2_32.lib. Библиотека winmm.lib не требуется для работы с сетью, я использую ее для воспроизведения звукового сигнала, когда пользователь щелкает по кнопке завершения хода.

Глобальные переменные программы Sockets_TurnGame

Загрузите заголовочный файл main.h, и вы увидите в нем такой код:

// Переменные сокетов
SOCKET g_skListenSocket;
SOCKET g_skClientSocket;
bool      g_bIsServer = 0;
bool      g_bMyTurn = 0;
bool      g_bConnected = 0;

В приведенном выше коде объявлены два дескриптора сокетов. Ведущий компьютер для прослушивания сети в ожидании новых соединений использует дескриптор g_skListenSocket. Другой дескриптор, g_skClientSocket, используется клиентом для подключения к серверу или сервер назначает его подключившемуся клиенту. Так или иначе, клиентский сокет используется для управления соединением между клиентом и сервером.

Логическое значение g_bIsServer сообщает вам работает ли программа в режиме ведущего компьютера. Если значение равно 1, значит программа является сервером и должна ожидать подключения клиента. Если значение равно 0, программа является клиентом и должна установить соединение с ведущим игровым компьютером.

Логическое значение g_bMyTurn сообщает принадлежит ли вам в данный момент право хода в игре. Если сейчас ваш ход, будет отображаться кнопка Turn Done, щелкнув по которой вы передадите ход другому игроку. Если сейчас ваш ход, значение переменной равно 1, если нет — значение переменной равно 0.

Логическое значение g_bConnected сообщает вам установила ли программа соединение с другим игроком. 1 означает что соединение существует, 0 — что нет.

Есть и еще несколько глобальных переменных, но они относятся к элементам управления Windows и другим подобным вещам.

Функции программы Sockets_TurnGame

Далее в заголовочном файле main.h приведены прототипы используемых в программе функций. Вот они:

void vHost();
void vInitializeSockets(void);
void vShutdownSockets(void);
void vConnect(void);
void vSendTurnMessage(void);
void vReceiveTurnMessage(void);
void vTurnDone(void);

Функция vHost() вызывается когда пользователь щелкает по кнопке Host. Она прослушивает указанный порт на ведущем компьютере и ждет входящих подключений. Как только клиент установит соединение, ведущий компьютер принимает его, после чего можно производить обмен данными.

Функция vInitializeSockets() используется для начальной инициализации WinSock.

Функция vShutdownSockets() отключает все активные соединения и систему WinSock.

Функция vConnect() вызывается когда пользователь щелкает по кнопке Connect. Она пытается подключиться к ведущему компьютеру, чей IP-адрес указан в окне. После того, как соединение установлено, клиент получает контроль над игрой и может закончить ход в выбранный им момент времени.

Функция vSendTurnMessage() отправляет сообщающий о завершении хода пакет другому игроку. На самом деле пакет не содержит никакой полезной информации, он просто показывает вам как пересылать данные по проводам.

Функция vReceiveTurnMessage() ждет, пока другой игрок не пришлет сообщающий о завершении хода пакет. Функция будет сидеть и ждать, пока пока рак на горе не свистнет.

Функция vTurnDone() вызывается функциями отправки и приема хода для завершения хода. Это происходит когда пользователь щелкает по кнопке Turn Done.

Остальные перечисленные в заголовочном файле main.h функции являются стандартным каркасом приложения для Windows и не слишком интересны, поэтому я не буду их рассматривать. Вы же не хотите, чтобы я по сто раз описывал одно и то же? Лучше я лишний раз сыграю в Age of Mythology!

Функция vHost()

Представьте на минуту, что вы загрузили программу походовой игры и щелкнули по кнопке Host. Начнет выполняться следующий код:

sockaddr_in saServerAddress;
sockaddr_in saClientAddress;
int    iClientSize = sizeof(sockaddr_in);
int    iPort = 6001;
int    iStatus;

// Установка глобальных переменных
g_bIsServer = 1;
// Инициализация дескриптора сокета
g_skListenSocket = INVALID_SOCKET;
// Создание сокета
g_skListenSocket = socket(AF_INET, SOCK_STREAM, 0);
// Проверим, не произошла ли ошибка
if(g_skListenSocket == INVALID_SOCKET) {
   vShowText("** ERROR ** Could Not Create Socket");
   return;
}
vShowText("<- Socket Created ->");
// Очищаем структуру адреса сокета
memset(&saServerAddress, 0, sizeof(sockaddr_in));
// Инициализируем структуру адреса сокета
saServerAddress.sin_family = AF_INET;
saServerAddress.sin_addr.s_addr = htonl(INADDR_ANY);
saServerAddress.sin_port = htons(iPort);
// Пытаемся выполнить привязку
if(bind(g_skListenSocket, (sockaddr*) &saServerAddress, sizeof(sockaddr)) ==
          SOCKET_ERROR) {
   vShowText("** ERROR ** Could Not Bind Socket");
   return;
}
vShowText("<- Socket Bound ->");
// Прослушиваем подключения
iStatus = listen(g_skListenSocket, 32);
if(iStatus == SOCKET_ERROR) {
   vShowText("** ERROR ** Could Not Listen");
   // Закрываем сокет
   closesocket(g_skListenSocket);
   return;
}
vShowText("<- Socket Listening ->");
g_skClientSocket = accept(g_skListenSocket, (struct sockaddr*)&saClientAddress,
                          &iClientSize);
if(g_skClientSocket == INVALID_SOCKET) {
   vShowText("** ERROR ** Could Not Accept Client");
   // Закрываем сокет
   closesocket(g_skListenSocket);
   return;
}
// Убираем кнопки
DestroyWindow(hBU_Connect);
DestroyWindow(hBU_Host);
vShowText("<- Client Connected ->");
// Устанавливаем флаг подключения
g_bConnected = 1;
// Устанавливаем флаг, сообщающий, что сейчас
// ход делает другой игрок
g_bMyTurn = 0;
// Ждем первый ход клиента
vTurnDone();

Первая часть кода реализует логику для подключения клиента. Фактически программа прослушивает порт, ожидая соединения и принимает соединение, когда оно происходит. После этого код убирает кнопки Host и Connect, чтобы пользователь не мог щелкнуть по ним еще раз. Затем программа устанавливает переменную хода, чтобы она указывала, что контроль над игрой находится у клиента. И, наконец, ход заканчивается вызовом функции завершения хода. Это переводит сервер в режим приема, чтобы он мог получить сообщение о завершении хода от клиента. Все эти действия показаны на рис. 14.16.

Рис. 14.16. Ход выполнения функции vHost()

Рис. 14.16. Ход выполнения функции vHost()

Функция vConnect()

Функция vConnect() вызывается, когда игрок щелкает по кнопке Connect. Вот как выглядит ее код:

sockaddr_in saServerAddress;
int iPort = 6001,iStatus;
LPHOSTENT lpHost;
char szHost[128];

// Установка глобальных переменных
g_bIsServer = 0;
// Инициализация параметров сервера, смените указанный здесь IP-адрес
// на корректный IP-адрес вашей сети
sprintf(szHost, "192.168.0.2");
// Инициализация дескриптора сокета
g_skClientSocket = INVALID_SOCKET;
// Создание сокета
g_skClientSocket = socket(AF_INET, SOCK_STREAM, 0);
// Проверка наличия ошибок
if(g_skClientSocket == INVALID_SOCKET) {
   vShowText("** ERROR ** Could Not Create Socket");
   return;
}
vShowText("<- Socket Created ->");
// Инициализация структуры данных адреса сервера
memset(&saServerAddress, 0, sizeof(sockaddr_in));
// Установка значений по умолчанию
saServerAddress.sin_family = AF_INET;
// Загрузка IP-адреса
saServerAddress.sin_addr.s_addr = inet_addr(szHost);

// Если задано имя сервера, а IP-адрес отсутствует,
// попытаемся получить требуемое значение
if(saServerAddress.sin_addr.s_addr == INADDR_NONE) {
   vShowText("<- Looking Up Host ID ->");
   // Получаем имя сервера
   lpHost = gethostbyname(szHost);
   // Проверяем, получили ли мы что-нибудь
   if (lpHost != NULL) {
      // Загружаем адрес сервера из его данных
      saServerAddress.sin_addr.s_addr = ((LPIN_ADDR)lpHost->h_addr)->s_addr;
   }
   else {
      vShowText("** ERROR ** Could Not locate host");
      return;
   }
}
// Устанавливаем порт сервера
saServerAddress.sin_port = htons(iPort);
// Пытаемся подключиться к серверу
iStatus = connect(g_skClientSocket, (struct
              sockaddr*)&saServerAddress, sizeof(sockaddr));
// Проверяем наличие ошибок
if(iStatus == SOCKET_ERROR) {
   vShowText("** ERROR ** Could Not Connect To Server");
   return;
}
// Убираем кнопки
DestroyWindow(hBU_Connect);
DestroyWindow(hBU_Host);
vShowText("<- Connected To Server ->");
// Устанавливаем флаг подключения
g_bConnected = 1;
// Устанавливаем флаг, указывающий что право хода принадлежит нам
g_bMyTurn = 1;
// Отображаем кнопку Turn Done
hBU_TurnDone = CreateWindow(
   "BUTTON",
   "Turn Done",
   WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
   5,
   280,
   100,
   28,
   g_hWnd, (HMENU)IDC_hBU_TurnDone, g_hInst, NULL);
vShowText(":Server waiting, make your turn");

Код подключения очень похож на тот, который я показывал вам в примере подключения к веб-серверу. Клиент сперва получает адрес сервера, а затем пытается установить соединение с ним. Как только соединение успешно установлено, клиент убирает кнопки Connect и Host и отображает новую кнопку Turn Done. В этот момент клиенту дается время, чтобы он мог сделать свой ход. Ход выполнения функции показан на рис. 14.17.

Рис. 14.17. Ход выполнения функции vConnect()

Рис. 14.17. Ход выполнения функции vConnect()

Функция vTurnDone()

Функция завершения хода выполняет две различных задачи. Если сейчас ваш ход, она отправляет сообщение о завершении хода другому игроку и ждет получения сообщения. Если сейчас ход другого игрока, функция ждет, пока он не завершит свой ход. Ход выполнения функции показан на рис. 14.18.

Рис. 14.18. Ход выполнения функции vTurnDone()

Рис. 14.18. Ход выполнения функции vTurnDone()

Хотя блок-схема и выглядит запутанной, сам код не слишком сложен. Большая его часть занимается обработкой сообщений от окна. Если ее отбросить, оставшийся код будет выглядеть примерно так:

// Если соединение установлено, проверяем
// надо получать или отправлять сообщение о ходе
if(g_bConnected) {
   // Мой ход, отправляю сообщение
   if(g_bMyTurn) {
      // Убираем кнопку завершения хода
      DestroyWindow(hBU_TurnDone);
      // Отправляем сообщение о завершении хода
      vSendTurnMessage();
      // Ждем получения сообщения
      vReceiveTurnMessage();
   }
   else {
      // Ждем получения сообщения
   }
}

Если вы сравните приведенный выше код с тем, который находится в файле main.cpp, то увидите что здесь код значительно короче. Я удалил из него текстовые сообщения, чтобы вам проще было увидеть, что происходит.

Функция vSendTurnMessage()

Когда приходит время отправлять сообщение о завершении хода, вызывается функция отправки сообщений. Ее код выглядит так:

void vSendTurnMessage(void)
{
   char szTurnPacket[32];
   intiBytes = 0;

   // Создаем пакет-заглушку
   sprintf(szTurnPacket, "turnpacket");
   // Отправляем пакет
   iBytes = send(g_skClientSocket, szTurnPacket, 32, 0);
   if(iBytes != SOCKET_ERROR) {
   }
   else {
      vShowText("** ERROR ** Sending");
      return;
   }
   // Устанавливаем режим приема
   g_bMyTurn = 0;
}

Код начинается с создания пакета для отправки его клиенту или серверу. Для демонстрационных целей в пакет помещается строка текста. Как только пакет собран, код посылает его другому игроку. Программа блокируется и ждет, пока получатель не подтвердит, что данные приняты. Так только подтверждение получено, сбрасывается флаг хода и функция завершает работу.

Функция vReceiveTurnMessage()

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

void vReceiveTurnMessage(void)
{
   char szTurnPacket[32];
   intiBytes = 0;

   iBytes = recv(g_skClientSocket, szTurnPacket, 32, 0);
   // Проверка возвращенного кода
   if(iBytes != SOCKET_ERROR) {
   }
   else {
      vShowText("** ERROR ** Receiving");
      return;
   }
   // Переключение в режим отправки
   g_bMyTurn = 1;

   // Отображение кнопки Turn Done
   hBU_TurnDone = CreateWindow(
      "BUTTON",
      "Turn Done",
      WS_CHILD | WS_VISIBLE | BS_PUSHBUTTON,
      5,
      280,
      100,
      28,
      g_hWnd, (HMENU)IDC_hBU_TurnDone, g_hInst, NULL);
}

В функции приема я вызываю функцию recv для приема пакета от другого игрока. Как только пакет пришел, код устанавливает флаг хода и создает кнопку Turn Done. Вот и все об отправке и получении пакетов!

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


Глава 14 - Итоги

Итоги

Вспомните следующую информацию о сетях и сокетах:

  • TCP/IP обеспечивает гарантированную доставку пакетов за счет скорости.
  • UDP обеспечивает быструю доставку пакетов, но требует больше усилий от разработчика.
  • Непосредственная работа с сокетами подходит для тех приложений, которые должны работать на разных платформах и тех случаев, кода требуется полный контроль.
  • DirectPlay хорошо использовать в том случае, если вы не хотите писать все самостоятельно.


Приложение А. Photoshop - лучший в мире редактор текстур

Приложение А. Photoshop — лучший в мире редактор текстур

Краткий обзор

Возможно, вы интересовались как художники и разработчики игр создают те замечательные текстуры и графику, которые вы видите в компьютерных играх? Лично я думаю, что здесь 10 процентов технических навыков, 40 процентов упорства и 50 процентов таланта. Возможно вы удивляетесь, почему на долю таланта я оставил лишь 50 процентов. Дело в том, что без упорства даже самый большой талант не поможет довести работу до завершения. Первое правило разработки игр заключается в том, что вы должны довести проект до завершения любой ценой. Мало того, что в результате вы будете чуствовать себя лучше, но вы еще и получите репутацию человека, выполняющего обещанное.

Достаточно общих наставлений. В художественном оформлении современных игр есть два краеугольных камня: двухмерные текстуры и трехмерные модели. Чтобы осознать мощь текстур, попробуйте отобразить трехмерную модель без них. Вы сразу же заметите, что модель стала скучной и похожа на пластиковую игрушку. Но если добавить к ней текстуры, модель выглядит более реалистично и обретает внутреннюю жизнь. Взгляните на рис. А.1, где изображена трехмерная модель без текстур и с ними. Не надо быть Рембрандтом, чтобы заметить, что модель с текстурами выглядит лучше.

Рис. A.1. Слева изображена трехмерная модель без текстур, справа - та же модель, но с текстурами

Рис. A.1. Слева изображена трехмерная модель без текстур, справа — та же модель, но с текстурами

Даже если у вас отсутствует талант для создания великолепных текстур, необходимо как минимум иметь общее представление о том, как работают используемые при этом инструменты. Не раз в производственном цикле вам потребуется создавать черновые образцы для текстур и других визуальных элементов. Это делается для того, чтобы не ждать завершения работы художника для продолжения работы. В этом разделе я рассмотрю следующие темы:

  • Adobe Photoshop
  • Создание снимков экрана с помощью Adobe Photoshop
  • Использование слоев в Photoshop
  • Создание изображений с альфа-каналом в Photoshop


Приложение А - Adobe Photoshop

Adobe Photoshop

Существует множество программ редактирования двухмерной графики, но ни одна из них не может сравниться по полезности для разработчика игр с Adobe Photoshop. Седьмая версия Photoshop является стандартом де-факто среди пакетов для редактирования двухмерной графики. Он стоит достаточно дорого для человека, занимающегося программированием игр в свободное время, но полностью соответствует своей цене.

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

Когда вы запустите Photoshop, вам будет показан экран, похожий на тот, что изображен на рис. A.2.

Рис. A.2. Интерфейс Adobe Photoshop 6.0, отображаемый при первом запуске программы

Рис. A.2. Интерфейс Adobe Photoshop 6.0, отображаемый при первом запуске программы

Для нас на этом рисунке интерес представляют несколько ключевых областей: перемещаемая панель инструментов слева, стандартное меню сверху и перемещаемая панель справа. Перед тем, как подробнее рассказать об этих элементах, я покажу вам как можно сделать снимок вашего рабочего стола и поместить его в Photoshop.


Приложение А - Создание снимков экрана с помощью Photoshop

Создание снимков экрана с помощью Photoshop

Первый шаг при создании снимков экрана с помощью Photoshop — поместить находящееся на экране изображение в буфер обмена Windows. Для этого вам надо нажать кнопку Print Screen, расположенную на вашей клавиатуре. Сразу же после нажатия кнопки изображение рабочего стола будет помещено в буфер обмена, откуда может быть вставлено в другие приложения.

Итак, у вас есть изображение в памяти буфера обмена, так что настало время вставить его в Photoshop. Для начала вам потребуется чистое изображение, или холст, на который будет помещена картина. К счастью, разработчики Adobe позаботились о вас и для выполнения данной задачи достаточно нажать комбинацию клавиш Ctrl+N. Запустите программу и попробуйте проделать это самостоятельно. На вашем экране должно появиться диалоговое окно, изображенное на рис. A.3.

Рис. A.3. Диалоговое окно создания нового изображения в Adobe Photoshop 6.0

Рис. A.3. Диалоговое окно создания нового изображения в Adobe Photoshop 6.0

Диалоговое окно создания нового изображения

Ничего себе! Ничто так не возбуждает, как новое диалоговое окно! Ладно, я знаю, что это не слишком волнующе, но у этого окна есть одна замечательная особенность. Обратите внимание на поля Width и Height. Видите, что в них автоматически помещены значения, соответствующие вашему разрешению экрана? Разве не здорово? Ладно, если вы не думаете что это здорово, позвольте объяснить, почему я так говорю.

Установка атрибутов изображения

В большинстве программ для рисования, когда вы создаете новое изображение для снимка экрана вам необходимо указать его размеры. И это приходится делать снова и снова. Adobe убирает эту рутинную задачу и, если в системном буфере обмена находится какое-нибудь изображение, автоматически устанавливает соответствующие размеры. Если в буфере обмена никаких изображений нет, будут использоваться те значения, которые указывались в предыдущий раз. Так, если в момент открытия диалогового окна создания нового изображения у вас в буфере обмена находится изображение, размером 800 x 600 точек, для параметров размера изображения будут автоматически подставлены значения 800 и 600. Если вы вырезаете часть изображения, скажем, размером 100 x 100 точек, и открываете диалоговое окно создания нового изображения, по умолчанию будут использованы значения 100 и 100. Это очень полезная возможность, на которую вы станете полагаться, когда приобретете опыт работы с Photoshop.

Достаточно говорить об одной особенности. Если вы хотите, чтобы размер изображения отличался от тех значений, которые представлены в диалоговом окне, просто измените эти значения. Кроме того, вам вероятно придется менять единицы измерения, используемые при задании размеров изображения. Обычно Photoshop оперирует с размерами в дюймах, а вам нужны размеры в пикселях. Небольшие выпадающие списки, расположенные справа от полей Width и Height позволят вам выбрать в качестве единиц измерения пикселы, дюймы, сантиметры, пункты, пики или столбцы.

Следующее поле, Resolution, позволяет задать, сколько точек изображения должно размещаться в одном дюйме. Эта характеристика важна при печати и сканировании, так что сейчас о ней можно не беспокоиться. Я всегда оставляю предлагаемое по умолчанию разрешение 72 точки на дюйм.

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

Последний представляющий для нас интерес элемент диалогового окна — имя изображения. По умолчанию присваивается имя Untitled, и в большинстве случаев я оставляю его. Пойдемте дальше: щелкните по находящейся в окне кнопке OK. Если вы тщательно следовали всем моим наставлениям, на вашем экране сейчас должно быть изображение, похожее на рис. A.4.

Рис. A.4. Готовое для работы новое изображение

Рис. A.4. Готовое для работы новое изображение

Вы можете недоумевать, почему изображение выглядит меньше, чем рабочий стол Windows. Это вызвано тем, что программа масштабирует изображение таким образом, чтобы оно полностью помещалось в рабочее окно Photoshop. В заголовке отображается имя нового изображения и используемый коэффициет масштабирования. На моей машине для того, чтобы изображение рабочего стола поместилось в рабочую область программы применяется коэффициент масштабирования 66.7%.

Вставка изображения из буфера

Вы в одном шаге до создания снимка экрана. Нажмите клавишу F4. Оп-па! Если все сделано правильно, вы теперь должны видеть вставленный в окно снимок экрана, похожий на рис. A.5.

Рис. A.5. Снимок экрана во всей красе

Рис. A.5. Снимок экрана во всей красе

Компоновка изображения

Вы почти готовы к записи только что созданного снимка экрана. Откройте в Photoshop меню Layer и выберите команду Flatten Image, как показано на рис. A.6.

Рис. A.6. Меню Layer в Photoshop

Рис. A.6. Меню Layer в Photoshop

Теперь, когда изображение скомпоновано, вы можете двигаться дальше и сохранить его, выбрав команду Save As из меню File. Выбор команды Save As приводит к выводу диалогового окна, изображенного на рис. A.7.

Рис. A.7. Диалоговое окно Save As в Photoshop

Рис. A.7. Диалоговое окно Save As в Photoshop

Сохранение изображения

Существует множество вариантов сохранения изображения. Во-первых, для вас доступны десятки различных форматов. Одни форматы подходят для разработки игр, а другие — нет. Различные форматы, применяемые при разработке игр, перечислены в таблице A.1.

Таблица A.1. Форматы изображений Photoshop
Расширение файла | Описание
PSD | Родной формат изображений Photoshop. Полезен для тех изображений, которые находятся в работе, поскольку сохраняет всю необходимую информацию, включая сведения о слоях. Тем не менее, не следует использовать этот формат для готовых изображений, поскольку DirectX не поддерживает работу с ним. Еще одна причина не использовать эти файлы в игре — их большой размер.
BMP | Стандартный формат изображений в Windows. Может применяться в играх, но файлы занимают много места из-за отсутствия сжатия. Еще одна проблема формата — отсутствие поддержки 32-разрядной глубины цвета. Не используйте этот формат, если вам требуется реализация прозрачности с использованием альфа-канала.
GIF | Формат, принадлежащий CompuServe. Он обычно используется для веб-страниц и не слишком подходит для разработки игр. Главная проблема — необходимость покупки лицензии у CompuServe для законного использования. Именно поэтому я настоятельно рекомендую вам держаться подальше от этого формата.
JPG | Это древний формат со сжатием. JPG великолепно работает в случаях, когда необходим небольшой размер файла, поскольку вы сами можете задавать степень сжатия изображения. Главный недостаток формата в том, что сжатие происходит с потерей информации и качество изображений ухудшается. Конечно, можно задать меньшую степень сжатия, но при этом исчезает смысл использования данного формата. Другая проблема изображений в формате JPG — длительное время загрузки, вызванное необходимостью предварительной распаковки. Кроме того, работая с форматом JPG я наткнулся на еще одни грабли — недостаточная стандартизация. Если вы попытаетесь написать собственный код для загрузки JPG, готовьтесь к кошмарам.
PNG | Данный формат разработан открытым сообществом разработчиков и может свобдно использоваться. Фактически, группа людей не желающих платить CompuServe за использование формата GIF разработала свой собственный формат. Получился замечательный формат для изображений с небольшим размером формируемого файла. Основной недостаток PNG в Photoshop — отсутствие поддержки 32-разрядной глубины цвета. Из-за этого формат сложно использовать, если вам нужна прозрачность.
TGA | Формат файлов Targa является моим любимым потому, что он поддерживает изображения с 32-разрядной глубиной цвета и его просто загружать. Сейчас в DirectX встроена поддержка загрузки изображений в этом формате, но так было не всегда. Если вы планируете создать собственную поцедуру загрузки изображений, предусмотрите в ней поддержку файлов Targa.

Введите имя вашего снимка экрана в поле File name. Выберите формат файлов Targa и щелкните по кнопке Save. На вашем экране появится изображенное на рис. A.8 диалоговое окно, где необходимо задать глубину цвета для сохраняемого изображения.

Рис. A.8. Диалоговое окно Targa Options в Photoshop

Рис. A.8. Диалоговое окно Targa Options в Photoshop

Диалоговое окно предлагает на выбор три варианта: 16 бит/пиксел, 24 бит/пиксел и 32 бит/пиксел. Для снимка экрана достаточно значения 24 бит/пиксел, так что выберите его и щелкните по кнопке OK. Если вы создаете изображение с альфа-каналом, вы должны выбрать 32 бит/пиксел.

Вот чего мы добились: снимок экрана сохранен на вашем жестком диске. Видите, это не так уж и трудно, правда?


Приложение А - Слои

Слои

Возможно, вы задаетесь вопросом с какой целью я выполнил команду Flatten Image из меню Image? Взгляните на окно с тремя вкладками, расположенное в правом верхнем углу интерфейса Photoshop, как показано на рис. A.5. Вкладки называются Layers, Channels и Paths. В окне вы видите строку с названием Layer 1 и строку с названием Background. Так как вы смотрите на вкладку Layers, эти строки представляют слои изображения с которым вы работаете. Теперь позвольте мне подробнее рассказать о слоях, поскольку они — одна из важнейших возможностей Photoshop.

Сперва надо вернуться на шаг назад, к тому моменту, когда мы создавали новое изображение. Вернитесь к рис. A.4, где у вас есть пустое изображение, ожидающее вставки картинки из буфера обмена. Взгляните на вкладку Layers и обратите внимание, что там одна строка Background. Отличие между рис. A.4 и рис. A.5 в том, что на рис. A.5 присутствуют два слоя — один с именем Background и другой с именем Layer 1. Как видите, когда вы создаете изображение, оно начинается с нижнего слоя с именем Background. Подобно чистому холсту художника, слой Background является основой всех ваших изображений.

При вставке изображения из буфера обмена автоматически создается новый слой. Это очень полезная особенность, поскольку вам необходимо только скопировать изображение в память, а остальное Photoshop сделает за вас, когда вы будете вставлять изображение из буфера обмена. Благодаря этому создание сложных изображений становится очень простым. На рис. A.9 показаны слои, которые вы создали для снимка экрана.

Рис. A.9. Слои Background и Layer 1

Рис. A.9. Слои Background и Layer 1

На рис. A.9 слой Background обозначен как Layer 1. Это тот пустой слой, который создается при создании нового изображения. Слой, который находится над слоем Background — представляет содержимое буфера обмена, добавленное в новое изображение. Выбрав команду Flatten Image вы объединяете два слоя в один. На рис. A.10 показано полученное скомбинированное изображение.

Рис. A.10. Снимок экрана после объединения слоев

Рис. A.10. Снимок экрана после объединения слоев

Взгляните снова на вкладку Layers и обратите внимание, что на рис. A.10 снова только один слой с именем Background. Слой единственный потому что вы объединили все слои вместе командой Flatten Image.

Эксперименты со слоями

Закройте созданный снимок экрана и откройте файл mylayer.psd, находящийся в папке ChapterAA на прилагаемом к книге компакт-диске. Если все сделано правильно, вы должны увидеть на своем экране изображение, приведенное на рис. A.11.

Рис. A.11. Изображение из файла mylayer.psd

Рис. A.11. Изображение из файла mylayer.psd

Я не Пикассо, но изображение вполне позволяет продемонстрировать работу с несколькими слоями. Первое, на что вам следует взглянуть — вкладка Layers в нижней правой части рабочей области Photoshop. На рис. A.12 показано, что вы должны увидеть.

Рис. A.12. Вкладка Layers для изображения mylayer.psd

Рис. A.12. Вкладка Layers для изображения mylayer.psd

На рис. A.12 слои отображаются в отдельных строках с именами Background, Clouds, Border, MY LAYER, Purple Ball, Green Ball и Red Ball. Вы легко можете установить соответствие между названием слоя и отображаемым на экране элементом изображения mylayer.psd. Фактически каждый слой содержит один элемент загруженной картинки. Самое замечательное то, что вы можете перемещать слои не изменяя остальные элементы.

Перемещение слоев

Первый этап перемещения слоя — выбор того слоя, который вы будете передвигать. Выберите слой Green Ball, щелкнув левой кнопкой мыши по строке с названием слоя Green Ball. (Когда я ссылаюсь на строку вкладки Layers, я имею в виду то, что изображено на рис. A.13.)

Рис. A.13. Строка Green Ball на вкладке Layers

Рис. A.13. Строка Green Ball на вкладке Layers

В каждой строке присутствуют несколько элементов. Первое поле слева показывает, видим ли слой в данный момент. Когда слой видим, в поле отображается изображение глаза. Если слои невидим, поле будет пустым. Достаточно просто, да?

Следующее поле показывает является ли слой активным. Когда слой активен в поле отображается небольшое изображение кисти. Если слой не активен, поле будет пустым. Другое использование этого поля будет обсуждаться позднее.

Далее располагается уменьшенное изображение содержимого слоя. Оно полезно для получения информации о том, какие объекты изображения содержит слой.

Последний элемент строки — имя слоя. По умолчанию слоям даются такие интересные имена, как Layer 1, Layer 2 и т.д. Для изображения mylayer.psd я изменил эти имена, чтобы они описывали каждый слой. Позднее я покажу вам, как это делается.

После того, как вы выбрали слой Green Ball, соответствующая строка становится синей, сообщая что слой активен. Также в левой части строки на вкладке Layers отображается маленькое изображение кисти. Переведите взгляд на панель инструментов, располагающуюся с левой стороны интерфейса Photoshop. Она содержит различные инструменты, которые вы можете использовать для редактирования изображений. Рассматриваемая панель инструментов изображена на рис. A.14.

Рис. A.14. Основная панель инструментов в интерфейсе Photoshop

Рис. A.14. Основная панель инструментов в интерфейсе Photoshop

На панели находится множество инструментов, но я хочу чтобы вы сосредоточились на одном из них, расположенном в верхнем правом углу панели инструментов. Как он выглядит показано на рис. A.15.

Рис. A.15. Инстумент Move главной панели инструментов

Рис. A.15. Инстумент Move главной панели инструментов

Этот инструмент подходящим образом назван инструментом Move. Единственная цель его жизни — перемещать элементы изображения. Щелкните по нему, чтобы активировать функции перемещения. После того, как инструмент Move активирован, перетащите зеленый круг на изображении в нижний левый угол картинки. Обратили внимание, что перемещение круга не оказывает влияния на другие слои? Не знаю как вы, а я думаю что это замечательная возможность. На рис. A.16 показано, как должен выглядеть ваш экран после того, как зеленый круг перемещен в новую позицию.

Рис. A.16. Изображение после перемещения зеленого круга

Рис. A.16. Изображение после перемещения зеленого круга

Если вы придирчивы или одержимы идеей наведения порядка, вас вероятно волнует тот факт, что зеленый круг перекрывает изображение рамки. Это вызвано тем, что слой с зеленым кругом в иерархии находится выше слоя с изображением рамки. Не беспокойтесь — это легко поправить.

Изменение порядка слоев

Изменить порядок слоев очень просто — достаточно только перетащить строку слоя на вкладке Layers. Выберите слой Border и перетащите на слой Red Ball. Па-пам! Теперь слой Border является самым верхним слоем и скрывает все, что находится под ним. Результат показан на рис. A.17.

Рис. A.17. Изображение, показываемое после перемещения слоя Border

Рис. A.17. Изображение, показываемое после перемещения слоя Border

Теперь все в порядке и рамка закрывает зеленый круг. Поэкспериментируйте с перемещением содержимого слоев и с изменением порядка слоев, чтобы набить руку.

Изменение прозрачности слоя

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

Посмотрите снова на рис. A.12 и обратите внимание на параметр Opacity. Обычно значение непрозрачности слоя равно 100%. Это означает, что сквозь него ничего не видно. Справа от поля Opacity есть стрелка, которая активирует регулирующий прозрачность движок. Щелкните левой кнопкой мыши по стрелке, чтобы увидеть движок. Когда движок стал видимым, сдвиньте его влево, пока значение непрозрачности не станет равным 35%. Отпустите движок, чтобы увидеть изображение обновленное с учетом внесенных изменений. Взгляните на рис. A.18, чтобы увидеть новый, более прозрачный слой.

Рис. A.18. Изображение с новым значением прозрачности слоя Border

Рис. A.18. Изображение с новым значением прозрачности слоя Border

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

Эффекты

Поговорим о еще одном интересном применении слоев — эффектах. Перейдите на вкладку Layers и щелкните правой кнопкой мыши по строке слоя Green Ball. На экран будет выведено меню Layer Options, показанное на рис. A.19.

Рис. A.19. Меню Layer Options

Рис. A.19. Меню Layer Options

На рис. A.19 вы видите четыре пункта: Layer Properties, Blending Options, Duplicate Layer и Delete Layer.

Свойства слоя

Выберите команду Layer Properties из меню Layer Options и посмотрите на представленное вам диалоговое окно Layer Properties. Здесь вы можете изменить имя слоя или цвет строки слоя. Зачем менять цвет строки? Это очень полезно, когда у вас много слоев и некоторые из них надо как-нибудь выделить. Попробуйте выбрать другой цвет, чтобы увидеть какой эффект это произведет.

Параметры смешивания

Пристегните ремни — вот еще одна вещь экстра-класса! Вернитесь к меню Layer Options, выберите пункт Blending Options и на экране появится диалоговое окно, изображенное на рис. A.20.

Рис. A.20. Диалоговое окно Blending Options

Рис. A.20. Диалоговое окно Blending Options

Взгляните на левую сторону рис. A.20 где перечислены десятки эффектов, которые вы можете использовать для слоя. Вот список этих эффектов:

  • Drop Shadow
  • Inner Shadow
  • Outer Glow
  • Inner Glow
  • Bevel and Emboss
  • Satin
  • Color Overlay
  • Gradient Overlay
  • Pattern Overlay
  • Stroke

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

Откройте файл LayerF_X.psd из папки ChapterAA находящейся в сопроводительных файлах. На экране вы должны увидеть изображение, представленное на рис. A.21.

Рис. A.21. Изображение сферы без применения эффектов

Рис. A.21. Изображение сферы без применения эффектов

На рисунке нет ничего необычного. Это просто обычная белая сфера на сером фоне. Я выбрал это изображение потому что оно подходит для демонстрации различных эффектов.

Падающая тень

Выведите на экран диалоговое окно Blending Options, выполнив шаги, которые я описал раньше. Выберите эффект с названием Drop Shadow, чтобы активировать отбрасывание тени. На экране должна появиться панель Drop Shadow, показанная на рис. A.22.

Рис. A.22. Диалоговое окно параметров смешивания с выбранным эффектом Drop Shadow

Рис. A.22. Диалоговое окно параметров смешивания с выбранным эффектом Drop Shadow

Если позволит разрешение рабочего стола, вы сразу увидите что эффект Drop Shadow активен. Переместите диалоговое окно Blending Options, чтобы увидеть сферу с только что сформированной отбрасываемой тенью. Вы всегда можете вернуться назад и убрать тень, сняв флажок Drop Shadow.

Есть несколько параметров отбрасывания тени: Blend Mode, Opacity, Angle, Distance, Spread, Size, Contour и Noise. Держу пари, вы уже догадались за что отвечает параметр Opacity! В таблице A.2 приведен список параметров с их кратким описанием.

Таблица A.2. Параметры эффекта Drop Shadow
Параметр | Назначение
Blend Mode | Определяет как слой смешивается с другими слоями.
Opacity | Задает прозрачность отбрасываемой тени. Чем больше процентное значение, тем темнее тень.
Angle | Задает угол под которым «солнце» освещает объект. Это влияет на то как будет падать тень.
Distance | Определяет насколько далеко находится объект от тени. Используйте этот параметр для иммитации высоты солнца.
Spread | Задает резкость тени. Используйте параметр для точной настройки вида краев тени.
Size | Задает четкость тени. Используйте больший размер для размытой тени и меньший размер для густой тени.
Contour | Задает контур тени. Лучший способ понять на что влияет этот параметр — самостоятельно поэкспериментировать с его значениями.
Noise | Определяет количество шумов в тени. Используйте параметр, если хотите, чтобы тень выглядела как помехи на экране телевизора.

Для практики установите значение Distance равным 61, значение Spread равным 37, значение Size равным 40 и значение Noise равным 20. В результате сфкера должна выглядеть так, как показано на рис. A.23.

Рис. A.23. Белая сфера с примененным к ней эффектом Drop Shadow

Рис. A.23. Белая сфера с примененным к ней эффектом Drop Shadow

На иллюстрации отбрасывается очень большая тень. Обычно вы будете создавать более тонкие тени; я выбрал такие экстремальные значения чтобы подать вам идею об открывающихся возможностях. Поиграйтесь с различными параметрами Drop Shadow и увидите, что можно придумать.

Я лишь коснулся возможностей, открываемых диалоговым окном Blending Options. Теперь вы сами можете поэкспериментировать с различными эффектами, чтобы удовлетворить свои потребности.

Фильтры

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

В главном меню Photoshop щелкните по меню Filter. Затем щелкните по пункту Texture, чтобы открылся список различных фильтров текстур. Меню показано на рис. A.24.

Рис. A.24. Подменю со списком фильтров текстур

Рис. A.24. Подменю со списком фильтров текстур

Здесь перечислены шесть фильтров: Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass и Texturizer.

Поэкспериментируйте с различными фильтрами, чтобы увидеть какой эффект они дают. Я думаю, вы обнаружите, что фильтр Texturizer очень полезен для добавления рельефности вашим текстурам.

СОВЕТ
Я рекомендую вам поискать в Интернете свободно распространяемые фильтры. Доступны сотни фильтров и многие из них реализуют поистине удивительные эффекты. Не забывайте остерегаться вирусов.

Вот я и рассказал вам о слоях. Может поучимся создавать альфа-карты?


Приложение А - Альфа-карты

Альфа-карты

Вы, наверное, уже использовали при разработке игр альфа-маски. Они позволяют получить текстуры с участками различной степени прозрачности. Альфа-карта (alpha map) — это маска для текстуры. Там, где в маске есть отверстия, сквозь них видна текстура. Где маска непрозрачна — текстура скрыта. Подумав вы поймете, что это фундаментальная тема.

Я имел смелость создать очень простую структуру, показанную на рис. A.25.

Рис. A.25. Простая текстура

Рис. A.25. Простая текстура

Я знаю, что в текстуре нет ничего особенного, но она вполне соответствует текущим потребностям. Теперь притворимся, что в текстуре нет альфа-канала и вы накладываете ее поверх текстуры фона. Результат выполнения этих операций показан на рис. A.26.

Рис. A.26. Текстура, размещенная поверх фоновой текстуры

Рис. A.26. Текстура, размещенная поверх фоновой текстуры

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

Теперь вообразите, что мы взяли квадратный кусок картона и проделали в нем отверстие. В результате мы получили маску для мультипликационной рожицы. Поместите маску поверх изображения и вообразите, что все, что скрывает картон, невидимо. Результат показан на рис. A.27.

Рис. A.27. Текстура с альфа-маской, размещенная поверх фоновой текстуры

Рис. A.27. Текстура с альфа-маской, размещенная поверх фоновой текстуры

Па-пам! Посмотрите, как воображаемая картонная маска делает рамку вокруг текстуры невидимой. Теперь мы видим прелестную круглую мультипликационную рожицу. Достаточно резать воображаемый картон, пришла пора реальных дел!

Делаем альфа-карту

Загрузите из сопроводительных файлов файл cartoonface.psd и следуйте дальше. После загрузки вы снова увидите изображение с рис. A.25.

Вкладка Channels

В разделе «Слои» вы познакомились со вкладкой Layers. Теперь пришло время познакомится с ее соседкой — вкладкой Channels. Она находится справа от вкладки Layers. Щелкните по вкладке Channels, чтобы на экран было выведено диалоговое окно Channels. Вкладка Channels во всей ее славе показана на рис. A.28.

Рис. A.28. Вкладка Channels для картинки с мультипликационной рожицей

Рис. A.28. Вкладка Channels для картинки с мультипликационной рожицей

Обратите внимание на четыре канала изображения: RGB, Red, Green и Blue. На самом деле существуют только три канала. Канал RGB — это всего лишь простой способ одним щелчком включить три другие канала.

Выбирая различные каналы вы можете изменять отдельные цветовые компоненты изображения. Предположим, вы хотите изменить только красную компоненту изображения. Для этого вам надо выбрать канал Red на вкладке Channels и затем редактировать изображение как обычно.Большинство основных операций может выполняться и для отдельных каналов.

Итак, вы один в лесу с тремя цветовыми каналами. Что делать дальше? Вы создадите новый канал! Да, вам необходимо создать альфа-канал чтобы создать маску для улыбающейся мультяшной рожицы.

Инструмент Magic Wand

Один из самых важных инструментов Photoshop называется Magic Wand. Он находится во втором ряду значков главной панели инструментов. Щелкните по значку, чтобы активировать инструмент.

Инструмент Magic Wand позволяет вам выбрать область изображения, основываясь на цвете или диапазоне цветов. Когда вы выберете инструмент, в верхней части окна Photoshop вам будет представлен набор параметров. Вы сможете установить величину допуска, задать использование сглаживания, указать выделять ли только смежные пиксели и использовать ли все слои.

Параметр Tolerance задает величину допустимого отклонения при выборе цвета. Если целевой цвет — красный и вы хотите, чтобы были выбраны и темно-красные и светло-красные точки, установите величину допуска равной примерно 50. В результате Magic Wand выберет цвета, величины компонент которых находятся в пределах 50 единиц от выбранного цвета. Если вы хотите, чтобы был выбран только указанный цвет, установите допуск равным 0.

Флажок Anti-aliased указывает будет ли инструмент Magic Wand выполнять сглаживание краев выбранной области. При создании альфа-маски сглаживание используется почти всегда, так как оно дает хорошо выглядящие гладкие края. Если выключить сглаживание, края маски будут выглядеть зазубренными.

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

Флажок Use All Layers указывает будет или нет выбранная область объединять содержимое всех слоев. Это полезно, если вы хотите, чтобы в область выбора вошло содержимое нескольких слоев, но не хотите объединять эти слои насовсем. Для рассматриваемого примера просто оставьте этот флажок в состоянии по умолчанию.

Выбор фрагмента

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

Теперь, когда маска выбрана, надо записать выбранную область в новый канал. Для этого выберите пункт Save Selection в меню Select, находящемся в верхней части интерфейса Photoshop. Само меню показано на рис. A.29.

Рис. A.29. Меню Select

Рис. A.29. Меню Select

Выберите пункт Save Selection и на экран будет выведено диалоговое окно Save Selection. В диалоговом окне нет ничего сложного; просто задайте имя нового канала, например, Happy Alpha, и щелкните по кнопке OK.

Создание маски

Вернитесь к вкладке Channels и обратите внимание, что новый канал перечислен в списке после канала Blue. Это значит, что новый канал создан и готов для редактирования. Активируйте канал, щелкнув по строке с его названием.

Вы обратили внимание, что когда вы выбрали новый канал, остальные каналы отключились? Это нормально и легко исправимо — выберите канал RGB, чтобы снова включить каналы.

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

ПРИМЕЧАНИЕ
Adobe использует красный цвет для маскируемых областей потому что художники традиционно используют для маски гель такого цвета.

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

Инвертирование маски

Если вы не щелкали по изображению, выделенная область все еще отмечена бегущим пунктиром. Если же выделенная область не отмечена, снова выберите инструмент Magic Wand и щелкните по серой области.

Первый этап — заполнение рамки 100-процентно непрозрачным цветом. Альфа-маска состоит только из градаций серого, так что следуйте трем простым правилам:

  • Черный цвет — Полностью непрозрачный. Сквозь черные области ничего не видно.
  • Серый цвет — Полупрозрачный. Степень прозрачности зависит от того, насколько темный (или светлый) оттенок серого используется.
  • Белый цвет — Полностью прозрачный. Сквозь белые области все видно.

Из приведенного выше списка вы знаете, что область вокруг лица надо заполнить черным цветом. Для этого в окне палитры выберите черный цвет, а затем выберите команду Fill из меню Edit. Если все сделано правильно, на вашем экране все изображение будет окрашено в красный цвет. Это значит, что маскируется все изображение целиком.

Затем необходимо заполнить занимаемую мультяшной рожицей область полностью прозрачным, то есть белым, цветом. Для этого необходимо инвертировать текущую маску выбора. Раскройте меню Select и выберите команду Inverse. Это инвертирует активную выделенную область.

Вы заметили, что граница области выбора теперь окружает мультипликационную рожицу, а не область вокруг нее? Если нет, вам, возможно, следует вернуться к предыдущим этапам. Выберите в окне палитры белый цвет и снова выберите команду Fill из меню Edit. Если вы все сделали правильно, вкладка Channels должна выглядеть так, как показано на рис. A.30.

Рис. A.30. Вкладка Channels с новым альфа-каналом

Рис. A.30. Вкладка Channels с новым альфа-каналом

Вы получили это! Теперь мультипликационная рожица очищена от красного цвета, а рамка вокруг нее — закрашена. Это значит, что лицо будет видимо, а область вокруг него — замаскирована.

Сохранение изображения

Первый ключ к пониманию работы альфа-канала вы получите когда будете сохранять изображение. Когда у изображения нет альфа-канала, вам не предлагают сохранять его в 32-разрядном формате. Если же у изображения есть альфа-канал, вы можете сохранить его в 32-разрядном формате. Сохраните изображение в формате Targa и вам будет предложено выбрать 16-, 24- или 32-разрядный формат. Сохраните изображение в 32-разрядном формате, и на этом наш урок завершен.


Приложение А - Итоги

Итоги

Чтобы создать с помощью Photoshop снимок экрана, выполните следующие действия:

  1. Нажмите клавишу Print Screen, чтобы поместить изображение в буфер обмена.
  2. Запустите Photoshop.
  3. Создайте новое изображение, нажав комбинацию клавиш Ctrl+N.
  4. Вставьте содержимое буфера обмена в новое изображение, нажав клавишу F4.
  5. Скомпонуйте изображение с помощью команды Flatten Image из меню Layer.
  6. Сохраните изображение, нажав комбинацию клавиш Ctrl+S.

Чтобы создать в Photoshop альфа-маску, выполните следующие действия:

  1. Выберите области изображения, которые хотите замаскировать.
  2. Сохраните выбор области, с помощью команды Save Selection из меню Select.
  3. Выберите новый канал на вкладке Channels.
  4. Выберите канал RGB на вкладке Channels.
  5. Заполните выбраную область черным цветом, воспользовавшись командой Fill из меню Edit.
  6. Инвертируйте выбор области, с помощью команды Inverse из меню Select.
  7. Заполните выбраную область белым цветом, снова воспользовавшись командой Fill.
  8. Сохраните изображение в 32-разрядном формате.


Приложение B. Ресурсы для разработчика

Существует множество ресурсов для разработчиков, так что здесь я перечислю только свои любимые.

Двухмерная графика

    Adobe Photoshop — это наилучший из имеющихся на рынке редакторов двухмерной графики. Огромная гибкость и возможность сделать с двухмерным изображением все, что вам может потребоваться. URL: www.adobe.com.

Трехмерная графика

    3ds max — эта программа, созданная компанией Discreet, мой любимый инструмент для трехмерного моделирования и анимации. Вы можете использовать его для создания используемых в игре моделей, их анимации и даже для разработки целых сцен. URL: www.discreet.com.

    LightWave — эту программу, которой я пользуюсь уже несколько лет, создала компания Newtek. Она очень популярна среди разработчиков игр, и я рекомендую вам пристально присмотреться к ней, прежде чем приобрести что-нибудь другое. URL: www.newtek.com.

Сообщества разработчиков игр

    GameDev.net — это мой любимый сайт разработчиков игр, содержащий множество статей и великолепный форум. Вы можете найти меня среди модераторов раздела Multiplayer. URL: www.gamedev.net.

    Flip Code — еще один из моих любимых сайтов разработчиков игр с большим количеством великолепных статей и активным сообществом. URL: www.flipcode.com.

Аппаратное обеспечение

    ATI — ATI делает самые лучшие видеокарты, и их новая серия Radeon вне конкуренции. Если вы ищете хорошую видеокарту для практики в программировании шейдеров, загляните на www.ati.com.

    Nvidia — Nvidia также выпускает замечательные видеокарты. Лично у меня нет многих из выпускаемых моделей, но они всегда получают хорошие оценки в обзорах. URL: www.nvidia.com.


Алфавитный указатель


Универсальная страница для вывода данных о книге

Программирование стратегических игр с DirectX 9.0

Тодд Баррон

Программирование стратегических игр с DirectX 9.0


Файлы к книге

Файл | Размер | Описание
chap02.zip | 5 583 | Программа CreateWindow, рассматриваемая в главе 2.


Программирование стратегических игр с DirectX 9.0

Ваша версия WEB-броузера не поддерживает фреймы!