80/20 в шаблонах разработки. Путь к ультра эффективности на примере "HARVEST"

80/20 в шаблонах разработки. Путь к ультра эффективности на примере "HARVEST"

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

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

80/20 в шаблонах разработки. Путь к ультра эффективности на примере "HARVEST"

1. Паттерн «Наблюдатель» (Observer): Держим всех в курсе (без хаоса)

Представьте себе оживленную систему квестов в игре. Когда квест принимается, обновляется (например, «Собрано 5/10 трав») или завершается, различные части игры должны об этом узнать: пользовательский интерфейс должен обновить журнал квестов, может проиграться звуковой сигнал, или другая игровая система может разблокировать новую область.

Паттерн «Наблюдатель» предлагает чистое решение. Он определяет зависимость «один ко многим» между объектами таким образом, что когда один объект («Субъект» или «Наблюдаемый») изменяет свое состояние, все его зависимые объекты («Наблюдатели») автоматически уведомляются и обновляются.

В коде нашей системы квестов UQuestSubsystem выступает в роли Субъекта, а различные элементы пользовательского интерфейса или другие игровые системы могут выступать в роли Наблюдателей. Мы ясно видим это благодаря использованию делегатов в QuestSubsystem.h:

// QuestSubsystem.h // Declaring OnAddedQuestDelegate DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FOnQuestAddedSignature, FName, ObjectNamePar, EItemType, ItemTypePar, int32, ItemQuantityPar); // ... other delegates ... UCLASS() class QUESTSYSTEM_API UQuestSubsystem : public UGameInstanceSubsystem { GENERATED_BODY() public: UPROPERTY(BlueprintAssignable, Category = "Quest System | Delegates") FOnQuestAddedSignature OnQuestAddedDelegate; // ... other delegate properties ... void AddQuest(AActor* OverlappedActor, AActor* OtherActorPar) { // ... logic ... if (CurrentQuestData != nullptr) { ActiveQuests.Add(CurrentQuestData); // FIRE THE EVENT! OnQuestAddedDelegate.Broadcast(CurrentQuestData->ObjectName, CurrentQuestData->ItemType, CurrentQuestData->NumberOfInteractions); OnQuestUpdatedDelegate.Broadcast(); } } // ... more functions broadcasting events ... };

А Наблюдатель, такой как AQuestObserver, будет привязываться к этим делегатам:

// QuestObserver.cpp (Simplified) void AQuestObserver::BeginPlay() { Super::BeginPlay(); QuestSubsystem = GetGameInstance()->GetSubsystem<UQuestSubsystem>(); if (QuestSubsystem) { // Listen to the 20% of signals that matter to this observer QuestSubsystem->OnQuestAddedDelegate.AddDynamic(this, &AQuestObserver::RefreshQuests); } } void AQuestObserver::RefreshQuests(FName ObjectNamePar, EItemType ItemTypePar, int32 ItemsToCollectPar) { // React! Update UI, whatever. Minimal direct coupling to QuestSubsystem's guts. ActiveQuests = QuestSubsystem->ActiveQuests; CompletedQuests = QuestSubsystem->CompletedQuests; }

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

80/20 в шаблонах разработки. Путь к ультра эффективности на примере "HARVEST"

2. Доступ в стиле «Одиночки» (Singleton): Подсистема GameInstance (GameInstance Subsystem)

Хотя классический паттерн «Одиночка» (класс, который гарантирует существование только одного своего экземпляра и предоставляет глобальную точку доступа к нему) иногда может вызывать споры из-за проблем с тестируемостью и сильной связанностью, его цель – предоставление единого, доступного менеджера для определенной задачи – часто оправдана.

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

// QuestSubsystem.h UCLASS() class QUESTSYSTEM_API UQuestSubsystem : public UGameInstanceSubsystem { /* ... */ }; // Any other actor or class can grab it: // QuestLocationPoint.cpp void AQuestLocationPoint::ArrangeQuestCompletion(AActor* OverlappedActor, AActor* OtherActor) { UGameInstance* CurrentGameInstance = GetGameInstance(); if (CurrentGameInstance) { QuestSubsystem = CurrentGameInstance->GetSubsystem<UQuestSubsystem>(); // Direct, clean access } // ... now use QuestSubsystem to complete the quest ... }

Практическое применение: Это делает UQuestSubsystem легкодоступным из различных частей игрового кода (Actors, Components и т.д.) без необходимости постоянно передавать ссылки или прибегать к более рискованным глобальным статическим экземплярам. Unreal Engine управляет его жизненным циклом.

3. Проектирование, управляемое данными (Data-Driven Design): Отделение данных от логики

Хотя это и не паттерн из «Банды Четырех», проектирование, управляемое данными, является важнейшей архитектурной практикой. Вместо жесткого кодирования деталей квеста (названий, целей, наград) непосредственно в классах C++, эти детали часто хранятся во внешних ресурсах данных, таких как таблицы данных (Data Tables) Unreal Engine.

Код явно на это намекает:

// QuestSubsystem.cpp - Constructor UQuestSubsystem::UQuestSubsystem() { // Find the Data Table asset where quests are defined by designers ConstructorHelpers::FObjectFinder<UDataTable> QuestDTRef( TEXT("/Script/Engine.DataTable'/Game/JulisQuestSystem/QuestData/DT_QuestData.DT_QuestData'")); if (QuestDTRef.Succeeded()) { QuestDT = QuestDTRef.Object; } } // QuestTriggerBox.cpp void AQuestTriggerBox::InitializeQuestInstances() { // QuestRowHandle is an FDataTableRowHandle - a direct link to a row in your Quest DT if (!QuestRowHandle.DataTable) return; if (const FQuestData* QuestData = QuestRowHandle.DataTable->FindRow<FQuestData>(QuestRowHandle.RowName, TEXT("QuestContext"))) { // Create a quest instance and PUMP its properties from the Data Table row UQuestBase* QuestInstance = NewObject<UQuestBase>(this, UQuestBase::StaticClass(), *QuestRowHandle.RowName.ToString()); if (QuestInstance) { QuestInstance->SetupFromQuestData(QuestRowHandle.RowName, *QuestData); } } }// QuestSubsystem.cpp - Constructor UQuestSubsystem::UQuestSubsystem() { // Find the Data Table asset where quests are defined by designers ConstructorHelpers::FObjectFinder<UDataTable> QuestDTRef( TEXT("/Script/Engine.DataTable'/Game/JulisQuestSystem/QuestData/DT_QuestData.DT_QuestData'")); if (QuestDTRef.Succeeded()) { QuestDT = QuestDTRef.Object; } } // QuestTriggerBox.cpp void AQuestTriggerBox::InitializeQuestInstances() { // QuestRowHandle is an FDataTableRowHandle - a direct link to a row in your Quest DT if (!QuestRowHandle.DataTable) return; if (const FQuestData* QuestData = QuestRowHandle.DataTable->FindRow<FQuestData>(QuestRowHandle.RowName, TEXT("QuestContext"))) { // Create a quest instance and PUMP its properties from the Data Table row UQuestBase* QuestInstance = NewObject<UQuestBase>(this, UQuestBase::StaticClass(), *QuestRowHandle.RowName.ToString()); if (QuestInstance) { QuestInstance->SetupFromQuestData(QuestRowHandle.RowName, *QuestData); } } }
80/20 в шаблонах разработки. Путь к ультра эффективности на примере "HARVEST"

4. Паттерн «Фабричный метод» (Factory Method): Создание объектов из данных

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

В AQuestTriggerBox::InitializeQuestInstances() создание QuestInstance с использованием NewObject(this, QuestBaseClass, ...) с последующим SetupFromQuestData предполагает движение в этом направлении. Если бы QuestBaseClass можно было динамически определять из QuestData (например, если бы структура FQuestData содержала TSubclassOf), это больше походило бы на Фабрику.

// QuestTriggerBox.cpp (current) // UClass* QuestBaseClass = UQuestBase::StaticClass(); // QuestInstance = NewObject<UQuestBase>(this, QuestBaseClass, ...); // To make it a true Factory based on Data Table: // Assume FQuestData struct in your Data Table has: // TSubclassOf<UQuestBase> SpecificQuestClass; // Designer picks this in the DT // Then in InitializeQuestInstances(): // UClass* ClassToInstantiate = QuestData->SpecificQuestClass ? QuestData->SpecificQuestClass : UQuestBase::StaticClass(); // QuestInstance = NewObject<UQuestBase>(this, ClassToInstantiate, *QuestRowHandle.RowName.ToString());

Практическое применение: Это позволяет системе создавать различные типы квестов (например, UCollectItemsQuest, UGoToThePointQuest, UEscortQuest) из одного и того же механизма запуска, управляемого данными, просто указав желаемый класс квеста в таблице данных. Файл QuestBase.cpp действительно определяет UGoToThePointQuest и UCollectItemsQuest, наследующиеся от UQuestBase, что делает этот паттерн весьма применимым.

В QuestBase.cpp мы видим логику для UCollectItemsQuest:

// QuestBase.cpp void UCollectItemsQuest::HandleRequirements(UInventoryComponent* InventoryRef, UQuestSubsystem* QuestSubsystemRef) { // Specialized logic for item collection if ((InventoryRef->HasItemByID(QuestInstance->ObjectName)) && (InventoryRef->CountItemByID(QuestInstance->ObjectName) >= QuestInstance->NumberOfInteractions)) { /* Complete quest */ } } // UGoToThePointQuest would have its own HandleRequirements...

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

Ценность паттернов

Паттерны проектирования – это не слепое применение решения. Это понимание проблемы и выбор признанной, эффективной структуры, которая ее решает. В предоставленном коде системы квестов:

  • Паттерн «Наблюдатель» (через делегаты) обеспечивает слабосвязанное взаимодействие для изменений состояния квеста.
  • Подсистемы GameInstance предлагают управляемую точку доступа в стиле «Одиночки» для глобальных систем, таких как управление квестами.
  • Проектирование, управляемое данными, делает систему гибкой и удобной для дизайнеров.
  • Паттерн «Фабричный метод» (или аналогичная логика создания) может использоваться для инстанцирования различных типов квестов на основе данных.

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

80/20 в шаблонах разработки. Путь к ультра эффективности на примере "HARVEST"
Начать дискуссию