Сергей Морозов

Программист на Unity из Нижнего Новгорода.

Делаю мобильные игры

R&S Devlog 4

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

Туториал

Вот так вот выглядит описание первых 3 туторов:

[
    {
        "Id": 1,
        "Triggers": [
            {
                "$type": "StartGameTutorialTrigger"
            }
        ],
        "Stages": [
            {
                "$type": "StartConversationTutorialStage",
                "Conversation": "conversation-self-diag"
            }
        ]
    },

    {
        "Id": 2,
        "Triggers": [
            {
                "$type": "TutorialCompleteTrigger",
                "TutorialId": 1
            }
        ],
        "Stages": [
            {
                "$type": "ClickUITutorialStage",
                "UIButtonName": "Room_SecondRoom"
            }
        ]
    },

    {
        "Id": 3,
        "Triggers": [
            {
                "$type": "TutorialCompleteTrigger",
                "TutorialId": 2
            }
        ],
        "Stages": [
            {
                "$type": "ClickUITutorialStage",
                "UIButtonName": "RoomObject_1"
            },
            {
                "$type": "ClickUITutorialStage",
                "UIButtonName": "OpenChestButton"
            }
        ]
    }
]

А вот так классы этих моделек:

[MemoryPackable]  
public partial class Tutorial  
{  
    public int Id { get; private set;}  
    public ReadOnlyCollection<TutorialTrigger> Triggers { get; private set;} = null!;  
    public ReadOnlyCollection<TutorialStage> Stages { get; private set;} = null!;  
}

[MemoryPackable]  
[MemoryPackUnion(0, typeof(StartGameTutorialTrigger))]  
[MemoryPackUnion(1, typeof(TutorialCompleteTrigger))]  
public abstract partial class TutorialTrigger  
{  
}  
  
[MemoryPackable]  
public partial class StartGameTutorialTrigger : TutorialTrigger  
{  
}  
  
[MemoryPackable]  
public partial class TutorialCompleteTrigger : TutorialTrigger  
{  
    public int TutorialId { get; private set;}  
}

[MemoryPackable]  
[MemoryPackUnion(0, typeof(StartConversationTutorialStage))]  
[MemoryPackUnion(1, typeof(ClickUITutorialStage))]  
public abstract partial class TutorialStage  
{  
}  
  
[MemoryPackable]  
public partial class StartConversationTutorialStage : TutorialStage  
{     
public string Conversation { get; private set;}  
}  
  
[MemoryPackable]  
public partial class ClickUITutorialStage : TutorialStage  
{  
    public string UIButtonName { get; private set;}  
}

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

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

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

По-моему это отличная система, из фич ей не хватает только читерский пропуск туториалов, да может ещё откат нескольких стадий тутора.

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

Лут

Чтобы выдавать айтемы и всё остальное нужны классы-врапперы - это реварды (IReward). Чтобы выдавать реварды - нужны классы-контнейнеры - это лут (ILootTableElement).

ILootTableElement сделан рекурсивно, т.е. часть наследников - декораторы для других наследников. Итоговый лут - просто список ревардов -

[MemoryPackable]  
public partial class RewardLootTableElement : ILootTableElement  
{  
    public ReadOnlyCollection<IReward> Rewards { get; private set; } = null!;  
}

Пример декоратора:

[MemoryPackable]  
public partial class LootTableElement : ILootTableElement  
{  
    public float? Weight { get; private set; } // если не указан, то выдаётся всегда  
    public ILootTableElement Element { get; private set; } = null!;  
    public ReadOnlyCollection<Restriction>? Restrictions { get; private set; } }

Restriction - очень важная концепция. Пока её реализацию я не писал, но в целом она используется для А/Б тестов и любых настроек, зависящих от контекста. С помощью этой системы с мобов можно выдавать квестовый лут, только когда квест активен, или выдавать уникальный предмет только тогда, когда он ещё не был получен, или просто выдавать разный лут в зависимости от уровня игрока. Её можно будет применять много где, не только в ревардах.

Из возможных ревардов пока только простые айтемы - например, квестовые. Т.е. пока нет статов и всяких свойств, только иконка, название и описание. Система инвентаря тоже крайне простая.

private readonly Dictionary<Guid, IItemWrapper> _items = new();

Количество однотипных айтемов в свойства Count у IItemWrapper.

Следующим шагом на месяц будет как реализация эквип айтемов.