'Waybound', первый промежуточный отчёт о разработке
Введение
Продолжение предыдущей статьи. :::
Это отчёт о разработке моего четвёртого майлстоуна, который я решил повторить, потому что мне понравилось. Нужно было подвести промежуточные итоги, заодно систематизируя заметки, сделанные в процессе. Я кратко описал результат примерно месяца работы. Вот что было сделано на этом этапе:
-
Игровые системы
- Плавное перемещение камеры по касанию
- Выбор, управление и взаимодействие с объектами на экране
- Гарантия, что количество объектов под контролем не превышает определённого лимита
- Некоторые объекты получают случайную индивидуальность в заданном диапазоне
- Гарантия, что объекты существуют только в пределах видимости камеры
-
Добавленные объекты
- 2 вида живых объектов: человек, голубь
- 7 видов фоновых объектов: дом, метро и т.д.
- 6 видов уличных объектов: гидрант, дорожный конус и т.д.
Архив
Когда были реализованы люди. Игрок может стать любым объектом и взаимодействовать с окружающей средой.
Создание ассетов
Изображения
Нарисованные фоновые изображения
Чтобы создать фон, похожий на пригородную местность, я искал и использовал в качестве референсов городские иллюстрации, фотографии домов и панорамы улиц. Я хотел оставить возможность для будущей локализации, поэтому не добавлял элементы с текстом (рекламные листовки, газеты, вывески с каллиграфией). Хотелось, чтобы чувствовалась рука художника, поэтому я использовал линии с грубой текстурой и намеренно избегал применения инструментов для рисования прямых линий. В итоге рисунки получились слегка кривоватыми, но аккуратными, и я доволен.
В целом, я доволен и рад, но, пожалуй, разрешение слишком высокое. Я пробовал уменьшать масштаб, но изображения изначально не создавались в низком разрешении, поэтому они сильно пикселизировались, что выглядело плохо. Жаль, что не нарисовал в более низком разрешении — можно было бы добиться того же эффекта.
Шейдер спрайтов
С этим возникла трудность в процессе разработки. Все объекты в проекте используют компонент Sprite. Среди стандартных шейдеров Unity нет подходящего для спрайтов, который поддерживал бы получение теней (Receive Shadow), поэтому я использую шейдер, написанный другим разработчиком.
Этот шейдер работает хорошо, но, поскольку он основан на Unlit, он не отбрасывает тени (Cast Shadow). Я хотел, чтобы объекты отбрасывали тени друг на друга, но, как выяснилось, для Unlit-шейдеров реализовать Cast Shadow невозможно. Нужен шейдер, применимый к спрайтам, который может создавать тени без отражения света, но я совершенно не разбираюсь в шейдерах, поэтому реализовать это сложно. Надо будет изучить этот вопрос подробнее.
Анимации

Голубь в полёте
Анимации я иногда рисовал сам. Например, для движений голубя, которые было трудно реализовать с помощью анимационного компонента Unity, я рисовал каждый кадр и склеивал их, как в традиционной анимации. У меня не было опыта рисования анимации движений животных, поэтому я находил и изучал видео с ходящими и летающими голубями.
В процессе я разделил поток анимации на фазы, вместо того чтобы создавать одну простую анимацию. Например, полёт голубя был разбит на три отдельных набора: анимация взлёта (EnterFly), анимация полёта (BeingFly) и анимация приземления (EndFly), которые затем привязывались к паттерну состояния. Благодаря этому результат, как видно в архиве, выглядит довольно правдоподобно.
Идущий человек и атмосферные светлячки
В основном, однако, я использовал анимационный компонент Unity. На примере выше показан эпизод, где в зависимости от изменения положения персонажа автоматически регулируется отражение по горизонтали и скорость воспроизведения анимации ходьбы. Это не очень хорошо видно, так как я не сохранил предварительные материалы, но голова, тело и конечности собраны из отдельных фрагментов, положение которых регулируется независимо, без покадровой анимации.
Кстати, работа с анимацией, пожалуй, самая сложная. В отличие от программирования, в анимации на индивидуальном уровне нет какого-то особого прорыва, и это каждый раз даёт о себе знать. Эффективность работы целиком зависит от личного мастерства. Интересно, как профессиональные аниматоры справляются с такой работой.
Процесс разработки
Были предприняты усилия для улучшения того, что не устраивало в предыдущем опыте. В частности, я старался не упускать из виду поддерживаемость кода, руководствуясь принципами SOLID. Если мне казалось, что класс становится слишком большим, я без колебаний разделял его, чтобы соблюсти принцип единственной ответственности, более тщательно использовал модификаторы доступа, а на более детальном уровне — активно применял атрибуты классов и #region.
Также, подумав, что необходимо делать резервные копии, я попробовал Unity VCS и остался очень доволен. Если вы знакомы с GitHub, к нему можно быстро привыкнуть. Особенно понравилось, что можно загружать файлы прямо из интерфейса Unity в любой момент работы.
Проектирование классов
classDiagram
class MainManager {
+ State: Phase
+ SelectedObject: GameObject
+ ActivatedObject: GameObject
}
class ObjectGenerator {
+ Livings: List~GameObject~
+ NonLivings: List~GameObject~
+ Population: Dictionary~string,int~
}
class Living {
+ IsSelected: bool
+ IsActivated: bool
+ Speed: float
}
class NonLiving {
+ InteractionDistance: float
+ ObjectAttractCycle: float
+ Feature: List~Sprite~
}
Living <|-- People
Living <|-- Pigeon
NonLiving <|-- VendingMachine
NonLiving <|-- Bench
Перед началом разработки я продумал основную структуру, учитывая роли классов и отношения между ними. Я не стал рисовать полноценные UML-диаграммы, а лишь формализовал структуру настолько, чтобы предотвратить появление излишне сложной архитектуры из-за спонтанного проектирования. Помимо перечисленного, есть и другие классы, но диаграмма стала бы слишком большой и сложной, поэтому я ограничился наиболее показательными.
Помимо членов скрипта, я заранее предусмотрел, что MainManager будет использоваться как синглтон с применением событийно-ориентированного программирования, а Living и NonLiving как родительские скрипты будут использовать паттерн состояния. Всё это было реализовано на практике.
В процессе разработки я внёс несколько программных паттернов, выделил код, связанный с касаниями, из разросшегося MainManager.cs{: .filepath } в отдельный TouchManager.cs{: .filepath } — фактическая форма изменилась во многом, но сперва задать общую структуру было определённо удобно. Этот подход очень помог, и если в будущем мне снова доведётся что-то разрабатывать, я постараюсь хотя бы набросать простую диаграмму.
Генерация и управление картой

---
title: MapGenerator
---
flowchart LR
A[Нет созданных объектов?]
B[Создать фоновые объекты]
C[Недостаточно объектов в поле зрения?]
D[Переместить существующие объекты]
E[Придать индивидуальность, если возможно]
A -->|Да| B
B --> C
A -->|Нет| C
C --> |Да| B
C -->|Нет| D
D --> C
D --> E
D --> E
void GenerateObjects(List<GameObject> instantiated, List<GameObject> instantiable)
{
GameObject tempInstantiated = instantiated[instantiated.Count - 1];
for (int i=instantiated.Count - 1; i>0; i--)
instantiated[i] = instantiated[i - 1];
instantiated[0] = tempInstantiated;
instantiated[0].transform.position = new Vector3(
instantiated[1].transform.position.x - objectSize, 0, 0
);
/* ... */
}
Генерацию карты я делал впервые. Я изучил такие алгоритмы процедурной генерации, как BSP, но они не подходили под мои задачи, и, поскольку такая сложная система была не нужна, я решил сделать всё сам.
- Удовлетворяются следующие условия:
- Однажды сгенерированная карта сохраняется до конца игры.
- Объекты карты видны только на экране.
- Порядок списка каждый раз перемешивается, чтобы карта выглядела по-разному.
В итоге я создал поэтапную процедуру генерации карты, работающую на основе поля зрения камеры, чтобы она корректно отображалась на устройствах с любым соотношением сторон экрана. С помощью списка игровых объектов первый элемент в нём всегда соответствует крайнему левому объекту, а последний — крайнему правому. В зависимости от поля зрения камеры объекты либо создаются заново, либо их порядок корректируется. В результате всё работает даже лучше, чем я ожидал.
Создание объектов
---
title: ObjectGenerator
---
flowchart LR
A[Численность ниже нормы?]
B[Получить координаты левого и правого краёв камеры]
C[Создать объект за пределами поля зрения]
D[Подождать n секунд]
A -->|Да| B
A -->|Нет| D
B --> C
C --> D
D --> A
void GenerateObject(GameObject targetObject)
{
bool spawnAtLeft = Random.value > .5f;
float spawnPosX = spawnAtLeft
? MainCamera.GetRenderWidth(gameObject).Left - 1.8f
: MainCamera.GetRenderWidth(gameObject).Right + 1.8f;
GameObject generatedObject = Instantiate(
targetObject,
new Vector3(
spawnPosX,
targetObject.GetComponent<BoxCollider>().size.y / 2,
Random.Range(-3.5f, 3.5f)
),
Quaternion.identity
);
generatedObject.transform.parent = standardObject.transform;
GeneratedObjects.Add(generatedObject);
}
IEnumerator ManagePopulation()
{
while (true)
{
GenerateLiving(LivingToGenerate);
yield return new WaitForSeconds(GenerationDelay);
}
}
Создание объектов не составило большого труда, так как я уже имел опыт написания похожего кода. Объекты создаются за пределами поля зрения с помощью ViewportToWorldPoint() и уничтожаются, если остаются за его пределами более n секунд.
Однако есть ещё что доработать. Например, при быстром перемещении камеры влево или вправо в какое-то мгновение можно увидеть пустую улицу без людей, а затем по прошествии времени слева и справа начинают появляться люди по одному-два. Это выглядит очень неестественно, поэтому, вероятно, нужно решить эту проблему, поддерживая плотность объектов слева и справа от камеры на постоянном уровне.
Взаимодействие
---
title: Пример взаимодействия человека и торгового автомата
---
sequenceDiagram
autonumber
VendingMachine.cs ->> People.cs: Attract()
People.cs ->> PeopleStateMachine.cs: CurrentState = PeopleVendingMachineState
PeopleStateMachine.cs ->> People.cs: PlayInteractionAnimation()
People.cs -->> People.cs: StopInteraction()
Взаимодействие между объектами, являющееся ключевой механикой игры, реализовано так, что инициирующий объект вызывает взаимодействие. С помощью корутины через определённые промежутки времени находятся объекты в пределах досягаемости с помощью Physics.OverlapBox, и для случайного из них вызывается взаимодействие. Используется паттерн состояния, который работает, как показано выше.
Возможно, из-за того, что я ещё недостаточно освоил паттерн состояния, процесс кажется мне слишком запутанным. Интересно, есть ли способ реализовать взаимодействие проще.
Заключение
Работа над разработкой до сих пор была, несомненно, увлекательной и приносила удовлетворение. Сначала продумываешь систему, затем на основе плана собираешь материалы, а если их не хватает — создаёшь и применяешь сам. Результат этого комплексного процесса даёт чёткую визуальную обратную связь, и это приносит совершенно особенное чувство достижения.
- В будущем остаются следующие задачи:
- Добавить звуковые эффекты
- Использовать процедурную анимацию
- Разнообразить объекты и взаимодействия
- Или хотелось бы попробовать следующее:
- Toast-уведомления
- Воздушная перспектива
В процессе разработки я потратил около двух недель на активное обновление блога. Надеюсь, в оставшийся месяц мне удастся оставаться сосредоточенным, не отвлекаясь, и завершить проект в срок.
gantt
title Первая дорожная карта
Section Планирование
Планирование :a1, 2024-02-28, 1d
Section Разработка
Разработка прототипа :a2, 2024-02-28, 85d
Визуальное оформление: a3, 2024-05-23, 10d
Section Релиз
Релиз и поддержка :a4, 2024-06-01, 213d
%% a2["Завершение прототипа"] : разработка и тестирование функциональности начальной версии прототипа.