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

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

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

@Getter
@Setter
@Entity(name = "reward")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "type",
        discriminatorType = DiscriminatorType.STRING)
@DynamicUpdate
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, include = "non-lazy")
public abstract class Reward implements GameItem {

    /**
     * Идентификатор сущности
     */
    @Id
    @GeneratedValue(
            strategy = GenerationType.SEQUENCE,
            generator = "seq_reward"
    )
    @SequenceGenerator(
            name = "seq_reward",
            allocationSize = 1
    )
    @Column(name = "id", nullable = false, length = 36, unique = true)
    private Long id;

    @Column(name = "alias", length = 255)
    private String alias; // short description

    @Column(name = "min_count")
    private Integer minCount = 0; // it is diff card count for card reward, can be null for some rewards

    @Column(name = "max_count")
    private Integer maxCount = 0; // it is diff card count for card reward, can be null for some rewards

И создадим более конкретную награду "золото" ("GOLD").

@Entity
@DiscriminatorValue("GOLD")
@NoArgsConstructor
public class GoldReward extends Reward {

    /**
     * Generate gold currency for profile
     */
    @Override
    public List<?> rewardFor(Profile profile, ContentGenerator contentGenerator) {
        int count = contentGenerator.generateCount(this.getMinCount(), this.getMaxCount(), this.getCount());
        return List.of(new GoldCurrency(profile, count));

    }

    @Override
    public RewardType getType() {
        return RewardType.GOLD;
    }

Можно заметить что GoldReward возвращает тип RewardType.GOLD. Это enum всех типов наград. В целом это может быть строка, чтобы в будущем небыли проблем с расширением этого класса.  

public interface GameItem {
    List<?> rewardFor(Profile profile, ContentGenerator contentGenerator);
}
INSERT INTO reward (id, alias, type, min_count, max_count)
VALUES (1, 'Золото немного', 'GOLD', 30, 60);

INSERT INTO reward (id, alias, type, min_count, max_count)
VALUES (2, 'Золото среднее', 'GOLD', 100, 200);

INSERT INTO reward (id, alias, type, min_count, max_count)
VALUES (3, 'Золото большое', 'GOLD', 500, 700);

И добавим репозиторий для получения наград на сервере. Я использую Spring Data для этого.

public interface RewardRepository<T extends Reward> extends JpaRepository<T, Long> {
}

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

public interface ContentGenerator {

    /**
     * Should drop reward
     */
    boolean shouldDropReward(int chance);

    /**
     * Generate random count in interval
     */
    int generateCount(Integer from, Integer to, Integer count);
}

Я привожу несколько вариантов взятия награды. Мы пока сосредоточимся на получении награды по ее id. 

public class RewardServiceImpl implements RewardService {

    @Override
    @SneakyThrows
    public List<Object> claimReward(long rewardId) {
        Profile currentProfile = profileService.getCurrentProfile();
        Reward reward = (Reward) rewardRepository.findById(rewardId).orElseThrow(() -> new RuntimeException("Reward not found"));
        return claimReward(currentProfile, reward);
    }

    @Override
    @SneakyThrows
    public List<Object> claimReward(Profile profile, Reward reward) {
         List<?> rewards = reward.rewardFor(profile, contentGenerator);
        Map<Class, GameContentService> mapContentServices = gameContentServices
                .stream()
                .collect(Collectors.toMap(GameContentService::getSupportedType, gameContentService -> gameContentService));
        List<Object> resultList = new ArrayList<>();

        /*
         * Save and apply reward for profile
         */
        processReward(rewards, mapContentServices, resultList);
        return resultList;
    }
}

Для того чтобы применить награду на профиль нам потребуются различные GameContentService. 

public interface GameContentService<T> {

    /**
     * Save or update content to profile
     */
    T saveOrUpdate(T content);

    /**
     * Get supported type of Service, for add reward to profile
     */
    Class<T> getSupportedType();

}

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

В целом это все что необходимо в такой архитектуре, для создания и применения награды. Не сложно заметить, что достаточно удобно докидывать новые типы наград и процессоры для них. Мы по сути не меняем старый код (кроме одного enum, от которого в целом можно отказаться). Поддерживается принцип open-close. 

Что думаете по поводу такого решения? Как у вас реализован такой механизм на backend?

Комментарии (8)


  1. derikn_mike
    26.07.2021 11:16

    я конечно извиняюсь но функция claimReward явно не атомарная и не вижу локов. Тоесть многопоточность в вашем приложении отсутствует?


    1. deft31 Автор
      26.07.2021 11:17
      +2

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


  1. Sensimilla
    26.07.2021 14:57

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


    1. deft31 Автор
      26.07.2021 19:46
      +1

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


  1. Antharas
    26.07.2021 19:36

    А это такой новый тренд хранить статичные списки данных в бд? Ладно инвентарь игрока, куда складируются предметы, но список наград..


    1. deft31 Автор
      26.07.2021 19:45

      А что не так с конфигурацией наград? Мы задаем и меняем именно конфигурацию награды. Это скажем так справочник.


      1. SadOcean
        27.07.2021 21:09

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

        Для баз нужны специальные инструменты и у них есть проблемы с версионированием.


        1. vkushni
          03.08.2021 15:24

          скорее за все ответ "так сложилось исторически", обычно зависит от важности награды если это реал (или "коины" покупаемые за реал) то лучше в СУБД если "фантики" из вакуума то можно где угодно