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

Шаг шестой. основные объекты

Постепенно начнем наполнять пакет

model

необходимыми для игрового процесса классами.


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

Die

) характеризуется типом (цветом) и размером. Для типов кубика заведем отдельное перечисление (

Die.Type

), размер отметим целым числом от 4 до 12. Также реализуем метод

roll()

, который будет выдавать произвольное, равномерно распределенное число из доступного кубику диапазона (от 1 до значения размера включительно).

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

class Die(val type: Type, val size: Int) : Comparable<Die> {

    enum class Type {
        PHYSICAL, //Blue
        SOMATIC, //Green
        MENTAL, //Purple
        VERBAL, //Yellow
        DIVINE, //Cyan
        WOUND, //Gray
        ENEMY, //Red
        VILLAIN, //Orange
        OBSTACLE, //Brown
        ALLY //White
    }

    fun roll() = (1.. size).random()

    override fun toString() = "d$size"

    override fun compareTo(other: Die): Int {
        return compareValuesBy(this, other, Die::type, { -it.size })
    }
}

Чтобы не пылились, кубики хранятся в сумочках (экземплярах класса

Bag

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

equals()hashCode()

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

put()

) или непосредственно перед выдачей (в методе

draw()

Метод examine() подойдет для случаев, когда уставший от неопределенности игрок в сердцах вытряхнет содержимое сумки на стол (обратите внимание на сортировку), а метод clear() — если вытряхнутые кубики больше в сумку не вернутся.

open class Bag {

    protected val dice = LinkedList<Die>()
    val size
        get() = dice.size

    fun put(vararg dice: Die) {
        dice.forEach(this.dice::addLast)
        this.dice.shuffle()
    }

    fun draw(): Die = dice.pollFirst()
    fun clear() = dice.clear()
    fun examine() = dice.sorted().toList()
}

Помимо сумок с кубиками, нужны также кучи с кубиками (экземпляры класса

Pile

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

removeDie()

class Pile : Bag() {
    fun removeDie(die: Die) = dice.remove(die)
}


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

Character

в Java). Герои бывают разных типов (сиречь классов, хотя слово

class

лучше тоже не использовать), но для нашего рабочего прототипа возьмем лишь два:

Brawler

(то есть, Fighter с упором на стойкость и силу) и

Hunter

(он же Ranger/Thief, с упором на ловкость и скрытность). Класс героя определяет его характеристики, умения и начальный набор кубиков, но как будет позже видно, строгой привязки к классам герои иметь не будут, а потому их персональные настройки можно будет с легкостью менять в одном-единственном месте.

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

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

data class Hero(val type: Type) {

    enum class Type {
        BRAWLER
        HUNTER
    }

    var name = ""
    var isAlive = true
    var favoredDieType: Die.Type = Die.Type.ALLY
    val hand = Hand(0)
    val bag: Bag = Bag()
    val discardPile: Pile = Pile()

    private val diceLimits = mutableListOf<DiceLimit>()
    private val skills = mutableListOf<Skill>()
    private val dormantSkills = mutableListOf<Skill>()

    fun addDiceLimit(limit: DiceLimit) = diceLimits.add(limit)
    fun getDiceLimits(): List<DiceLimit> = Collections.unmodifiableList(diceLimits)
    fun addSkill(skill: Skill) = skills.add(skill)
    fun getSkills(): List<Skill> = Collections.unmodifiableList(skills)
    fun addDormantSkill(skill: Skill) = dormantSkills.add(skill)
    fun getDormantSkills(): List<Skill> = Collections.unmodifiableList(dormantSkills)

    fun increaseDiceLimit(type: Die.Type) {
        diceLimits.find { it.type == type }?.let {
            when {
                it.current < it.maximal -> it.current  
                else -> throw IllegalArgumentException("Already at maximum")
            }
        } ?: throw IllegalArgumentException("Incorrect type specified")
    }

    fun hideDieFromHand(die: Die) {
        bag.put(die)
        hand.removeDie(die)
    }

    fun discardDieFromHand(die: Die) {
        discardPile.put(die)
        hand.removeDie(die)
    }

    fun hasSkill(type: Skill.Type) = skills.any { it.type == type }

    fun improveSkill(type: Skill.Type) {
        dormantSkills
                .find { it.type == type }
                ?.let {
                    skills.add(it)
                    dormantSkills.remove(it)
                }
        skills
                .find { it.type == type }
                ?.let {
                    when {
                        it.level < it.maxLevel -> it.level  = 1
                        else -> throw IllegalStateException("Skill already maxed out")
                    }
                } ?: throw IllegalArgumentException("Skill not found")
    }
}

Рука героя (кубики, которыми он располагает в данный момент), описывается отдельным объектом (класс

Hand

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

diceallies

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

null

class Hand(var capacity: Int) {

    private val dice = LinkedList<Die>()
    private val allies = LinkedList<Die>()

    val dieCount
        get() = dice.size
    val allyDieCount
        get() = allies.size

    fun dieAt(index: Int) = when {
        (index in 0 until dieCount) -> dice[index]
        else -> null
    }

    fun allyDieAt(index: Int) = when {
        (index in 0 until allyDieCount) -> allies[index]
        else -> null
    }

    fun addDie(die: Die) = when {
        die.type == Die.Type.ALLY -> allies.addLast(die)
        else -> dice.addLast(die)
    }

    fun removeDie(die: Die) = when {
        die.type == Die.Type.ALLY -> allies.remove(die)
        else -> dice.remove(die)
    }

    fun findDieOfType(type: Die.Type): Die? = when (type) {
        Die.Type.ALLY -> if (allies.isNotEmpty()) allies.first else null
        else -> dice.firstOrNull { it.type == type }
    }

    fun examine(): List<Die> = (dice   allies).sorted()
}


Коллекция объектов класса

DiceLimit

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

class DiceLimit(val type: Die.Type, val initial: Int, val maximal: Int, var current: Int)

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

HitShoot

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

levelmaxLevelmodifier1modifier2

class Skill(val type: Type) {

    enum class Type {
        //Brawler
        HIT,
        //Hunter
        SHOOT,
    }

    var level = 1
    var maxLevel = 3
    var isActive = true
    var modifier1 = 0
    var modifier2 = 0
}


Обратите внимание на вспомагательные методы класса

Hero

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

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

«Чего-то мне поплохело. Пойду покурю, что ли…»

А мы продолжим.Героев и их способности описали, пора перейти к противоборствуюшим силам — великим и ужасным Игровым Механикам. А вернее объектам, с которыми нашим героям предстоит взаимодействовать.

Противостоять нашим доблестным протагонистам будут кубики и карты трех видов: злодеи (класс

Villain

), враги (класс

Enemy

) и преграды (класс

Obstacle

), объединенные под общим термином «угрозы» (

Threat

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

Trait

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

sealed class Threat {
    var name: String = ""
    var description: String = ""
    private val traits = mutableListOf<Trait>()

    fun addTrait(trait: Trait) = traits.add(trait)
    fun getTraits(): List<Trait> = traits
}

class Obstacle(val tier: Int, vararg val dieTypes: Die.Type) : Threat()

class Villain : Threat()

class Enemy : Threat()

enum class Trait {
    MODIFIER_PLUS_ONE, //Add  1 modifier
    MODIFIER_PLUS_TWO, //Add  2 modifier
}


Обратите внимание, список объектов класса

Trait

определен как изменяемый (

MutableList

), но наружу отдается в виде неизменяемого интерфейса

List

. Хоть в Kotlin это и будет работать, подход однако небезопасный, поскольку ничего не мешает преобразовать полученный список к изменяемому интерфейсу и произвести различные модификации — особенно просто это сделать, если обращаться к классу из кода на Java (где интерфейс

List

— изменяемый). Наиболее параноидальный способ защитить свою коллекцию — сделать что-то вроде этого:

fun getTraits(): List<Trait> = Collections.unmodifiableList(traits)

но мы не станем настолько скрупулезно подходить к вопросу (вы, однако, предупреждены).

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

Карты угроз (а если вы внимательно читали дизайн-документ, то помните, что это карты) объединяются в колоды, представленные классом Deck:

Шаг пятнадцатый. тесты

Теперь, когда основная часть кода первого рабочего прототипа написана, неплохо бы добавить парочку модульных тестов…

«Как? Только сейчас? Да тесты нужно было в самом начале писать, а потом уже код!»

Многие читатели справедливо заметят, что написание модульных тестов должно предварять разработку рабочего кода (TDD и прочие модные методологии). Другие возмутятся: нечего людям мозги дурить своими тестами, пусть хотя б что-то разрабатывать начнут, иначе вся мотивация пропадет.

Еще пара-тройка человек вылезет из щели в плинтусе и робко сообщит: «я вообще не понимаю, зачем эти тесты нужны — у меня и так все работает»… После чего будут двинуты сапогом в лицо и быстренько запихнуты обратно. Я не стану начинать идеологических противостояний (их и так уже полным полно на просторах интернета), а потому отчасти соглашусь со всеми.

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

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

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

public class DieGeneratorTest {

    @Test
    public void testGetMaxLevel() {
        assertEquals("Max level should be 3", 3, DieGeneratorKt.getMaxLevel());
    }

    @Test
    public void testDieGenerationSize() {
        DieTypeFilter filter = new SingleDieTypeFilter(Die.Type.ALLY);
        List<? extends List<Integer>> allowedSizes = Arrays.asList(
                null,
                Arrays.asList(4, 6, 8),
                Arrays.asList(4, 6, 8, 10),
                Arrays.asList(6, 8, 10, 12)
        );
        IntStream.rangeClosed(1, 3).forEach(level -> {
            for (int i = 0; i < 10; i  ) {
                int size = DieGeneratorKt.generateDie(filter, level).getSize();
                assertTrue("Incorrect level of die generated: "   size, allowedSizes.get(level).contains(size));
                assertTrue("Incorrect die size: "   size, size >= 4);
                assertTrue("Incorrect die size: "   size, size <= 12);
                assertTrue("Incorrect die size: "   size, size % 2 == 0);
            }
        });
    }

    @Test
    public void testDieGenerationType() {
        List<Die.Type> allowedTypes1 = Arrays.asList(Die.Type.PHYSICAL);
        List<Die.Type> allowedTypes2 = Arrays.asList(Die.Type.PHYSICAL, Die.Type.SOMATIC, Die.Type.MENTAL, Die.Type.VERBAL);
        List<Die.Type> allowedTypes3 = Arrays.asList(Die.Type.ALLY, Die.Type.VILLAIN, Die.Type.ENEMY);
        for (int i = 0; i < 10; i  ) {
            Die.Type type1 = DieGeneratorKt.generateDie(new SingleDieTypeFilter(Die.Type.PHYSICAL), 1).getType();
            assertTrue("Incorrect die type: "   type1, allowedTypes1.contains(type1));
            Die.Type type2 = DieGeneratorKt.generateDie(new StatsDieTypeFilter(), 1).getType();
            assertTrue("Incorrect die type: "   type2, allowedTypes2.contains(type2));
            Die.Type type3 = DieGeneratorKt.generateDie(new MultipleDieTypeFilter(Die.Type.ALLY, Die.Type.VILLAIN, Die.Type.ENEMY), 1).getType();
            assertTrue("Incorrect die type: "   type3, allowedTypes3.contains(type3));
        }
    }

}

Или так:

public class BagGeneratorTest {

    @Test
    public void testGenerateBag() {
        BagTemplate template1 = new BagTemplate();
        template1.addPlan(0, 10, new SingleDieTypeFilter(Die.Type.PHYSICAL));
        template1.addPlan(5, 5, new SingleDieTypeFilter(Die.Type.SOMATIC));
        template1.setFixedDieCount(null);
        BagTemplate template2 = new BagTemplate();
        template2.addPlan(10, 10, new SingleDieTypeFilter(Die.Type.DIVINE));
        template2.setFixedDieCount(5);
        BagTemplate template3 = new BagTemplate();
        template3.addPlan(10, 10, new SingleDieTypeFilter(Die.Type.ALLY));
        template3.setFixedDieCount(50);

        for (int i = 0; i < 10; i  ) {
            Bag bag1 = BagGeneratorKt.generateBag(template1, 1);
            assertTrue("Incorrect bag size: "   bag1.getSize(), bag1.getSize() >= 5 && bag1.getSize() <= 15);
            assertEquals("Incorrect number of SOMATIC dice", 5, bag1.examine().stream().filter(d -> d.getType() == Die.Type.SOMATIC).count());
            Bag bag2 = BagGeneratorKt.generateBag(template2, 1);
            assertEquals("Incorrect bag size", 5, bag2.getSize());
            Bag bag3 = BagGeneratorKt.generateBag(template3, 1);
            assertEquals("Incorrect bag size", 50, bag3.getSize());
            List<Die.Type> dieTypes3 = bag3.examine().stream().map(Die::getType).distinct().collect(Collectors.toList());
            assertEquals("Incorrect die types", 1, dieTypes3.size());
            assertEquals("Incorrect die types", Die.Type.ALLY, dieTypes3.get(0));
        }

    }

}


Или даже так:

public class LocationGeneratorTest {

    private void testLocationGeneration(String name, LocationTemplate template) {
        System.out.println("Template: "   template.getName());
        assertEquals("Incorrect template type", name, template.getName());
        IntStream.rangeClosed(1, 3).forEach(level -> {
            Location location = LocationGeneratorKt.generateLocation(template, level);
            assertEquals("Incorrect location type", name, location.getName().get(""));
            assertTrue("Location not open by default", location.isOpen());
            int closingDifficulty = location.getClosingDifficulty();
            assertTrue("Closing difficulty too small", closingDifficulty > 0);
            assertEquals("Incorrect closing difficulty", closingDifficulty, template.getBasicClosingDifficulty()   level * 2);
            Bag bag = location.getBag();
            assertNotNull("Bag is null", bag);
            assertTrue("Bag is empty", location.getBag().getSize() > 0);
            Deck<Enemy> enemies = location.getEnemies();
            assertNotNull("Enemies are null", enemies);
            assertEquals("Incorrect enemy threat count", enemies.getSize(), template.getEnemyCardsCount());
            if (bag.drawOfType(Die.Type.ENEMY) != null) {
                assertTrue("Enemy cards not specified", enemies.getSize() > 0);
            }
            Deck<Obstacle> obstacles = location.getObstacles();
            assertNotNull("Obstacles are null", obstacles);
            assertEquals("Incorrect obstacle threat count", obstacles.getSize(), template.getObstacleCardsCount());
            List<SpecialRule> specialRules = location.getSpecialRules();
            assertNotNull("SpecialRules are null", specialRules);
        });

    }

    @Test
    public void testGenerateLocation() {
        testLocationGeneration("Test Location", new TestLocationTemplate());
        testLocationGeneration("Test Location 2", new TestLocationTemplate2());
    }

}

«Стоп, стоп, стоп! Это что? Java???»

Читайте также  Бесплатные конструкторы игр | Gamin

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

И еще. Помните, класс HandMaskRule и его наследников? А теперь представьте, что в какой-то момент для использования навыка герою необходимо взять из руки три кубика, причем типы этих кубиков заняты жесткими ограничениями (например, «первый кубик должен быть синим, зеленым или белым, второй — желтым, белым или голубым, а третий — синим или фиолетовым» — чуете сложность?).

Как подойти к реализации класса? Ну… для начала можете определиться с входными и выходными параметрами. Очевидно, нужно, чтобы класс принимал три массива (или набора), каждый из которых содержит допустимые типы для, соответственно, первого, второго и третьего кубиков. А дальше что? Переборы? Рекурсии?

public class TripleDieHandMaskRuleTest {

    private Hand hand;

    @Before
    public void init() {
        hand = new Hand(10);
        hand.addDie(new Die(Die.Type.PHYSICAL, 4)); //0
        hand.addDie(new Die(Die.Type.PHYSICAL, 4)); //1
        hand.addDie(new Die(Die.Type.SOMATIC, 4)); //2 
        hand.addDie(new Die(Die.Type.SOMATIC, 4)); //3
        hand.addDie(new Die(Die.Type.MENTAL, 4)); //4
        hand.addDie(new Die(Die.Type.MENTAL, 4)); //5
        hand.addDie(new Die(Die.Type.VERBAL, 4)); //6
        hand.addDie(new Die(Die.Type.VERBAL, 4)); //7
        hand.addDie(new Die(Die.Type.DIVINE, 4)); //8
        hand.addDie(new Die(Die.Type.DIVINE, 4)); //9
        hand.addDie(new Die(Die.Type.ALLY, 4)); //A (0)
        hand.addDie(new Die(Die.Type.ALLY, 4)); //B (1)
    }

    @Test
    public void testRule1() {
        HandMaskRule rule = new TripleDieHandMaskRule(
                hand,
                new Die.Type[]{Die.Type.PHYSICAL, Die.Type.SOMATIC},
                new Die.Type[]{Die.Type.MENTAL, Die.Type.VERBAL},
                new Die.Type[]{Die.Type.PHYSICAL, Die.Type.ALLY}
        );
        HandMask mask = new HandMask();
        assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0));
        assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 1));
        assertTrue("Should be on", rule.isPositionActive(mask, 0));
        assertTrue("Should be on", rule.isPositionActive(mask, 1));
        assertTrue("Should be on", rule.isPositionActive(mask, 2));
        assertTrue("Should be on", rule.isPositionActive(mask, 3));
        assertTrue("Should be on", rule.isPositionActive(mask, 4));
        assertTrue("Should be on", rule.isPositionActive(mask, 5));
        assertTrue("Should be on", rule.isPositionActive(mask, 6));
        assertTrue("Should be on", rule.isPositionActive(mask, 7));
        assertFalse("Should be off", rule.isPositionActive(mask, 8));
        assertFalse("Should be off", rule.isPositionActive(mask, 9));
        assertFalse("Rule should not be met yet", rule.checkMask(mask));
        mask.addPosition(0);
        assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0));
        assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 1));
        assertTrue("Should be on", rule.isPositionActive(mask, 0));
        assertTrue("Should be on", rule.isPositionActive(mask, 1));
        assertTrue("Should be on", rule.isPositionActive(mask, 2));
        assertTrue("Should be on", rule.isPositionActive(mask, 3));
        assertTrue("Should be on", rule.isPositionActive(mask, 4));
        assertTrue("Should be on", rule.isPositionActive(mask, 5));
        assertTrue("Should be on", rule.isPositionActive(mask, 6));
        assertTrue("Should be on", rule.isPositionActive(mask, 7));
        assertFalse("Should be off", rule.isPositionActive(mask, 8));
        assertFalse("Should be off", rule.isPositionActive(mask, 9));
        assertFalse("Rule should not be met yet", rule.checkMask(mask));
        mask.addPosition(4);
        assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0));
        assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 1));
        assertTrue("Should be on", rule.isPositionActive(mask, 0));
        assertTrue("Should be on", rule.isPositionActive(mask, 1));
        assertTrue("Should be on", rule.isPositionActive(mask, 2));
        assertTrue("Should be on", rule.isPositionActive(mask, 3));
        assertTrue("Should be on", rule.isPositionActive(mask, 4));
        assertFalse("Should be off", rule.isPositionActive(mask, 5));
        assertFalse("Should be off", rule.isPositionActive(mask, 6));
        assertFalse("Should be off", rule.isPositionActive(mask, 7));
        assertFalse("Should be off", rule.isPositionActive(mask, 8));
        assertFalse("Should be off", rule.isPositionActive(mask, 9));
        assertFalse("Rule should not be met yet", rule.checkMask(mask));
        mask.addAllyPosition(0);
        assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0));
        assertFalse("Ally should be off", rule.isAllyPositionActive(mask, 1));
        assertTrue("Should be on", rule.isPositionActive(mask, 0));
        assertFalse("Should be off", rule.isPositionActive(mask, 1));
        assertFalse("Should be off", rule.isPositionActive(mask, 2));
        assertFalse("Should be off", rule.isPositionActive(mask, 3));
        assertTrue("Should be on", rule.isPositionActive(mask, 4));
        assertFalse("Should be off", rule.isPositionActive(mask, 5));
        assertFalse("Should be off", rule.isPositionActive(mask, 6));
        assertFalse("Should be off", rule.isPositionActive(mask, 7));
        assertFalse("Should be off", rule.isPositionActive(mask, 8));
        assertFalse("Should be off", rule.isPositionActive(mask, 9));
        assertTrue("Rule should be met", rule.checkMask(mask));
        mask.removePosition(0);
        assertTrue("Ally should be on", rule.isAllyPositionActive(mask, 0));
        assertFalse("Ally should be off", rule.isAllyPositionActive(mask, 1));
        assertTrue("Should be on", rule.isPositionActive(mask, 0));
        assertTrue("Should be on", rule.isPositionActive(mask, 1));
        assertTrue("Should be on", rule.isPositionActive(mask, 2));
        assertTrue("Should be on", rule.isPositionActive(mask, 3));
        assertTrue("Should be on", rule.isPositionActive(mask, 4));
        assertFalse("Should be off", rule.isPositionActive(mask, 5));
        assertFalse("Should be off", rule.isPositionActive(mask, 6));
        assertFalse("Should be off", rule.isPositionActive(mask, 7));
        assertFalse("Should be off", rule.isPositionActive(mask, 8));
        assertFalse("Should be off", rule.isPositionActive(mask, 9));
        assertFalse("Rule should not be met again", rule.checkMask(mask));
    }

}

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

class TripleDieHandMaskRule(
        hand: Hand,
        types1: Array<Die.Type>,
        types2: Array<Die.Type>,
        types3: Array<Die.Type>)
    : HandMaskRule(hand) {

    private val types1 = types1.toSet()
    private val types2 = types2.toSet()
    private val types3 = types3.toSet()

    override fun checkMask(mask: HandMask): Boolean {
        if (mask.positionCount   mask.allyPositionCount != 3) {
            return false
        }
        return getCheckedDice(mask).asSequence()
                .filter { it.type in types1 }
                .any { d1 ->
                    getCheckedDice(mask)
                            .filter { d2 -> d2 !== d1 }
                            .filter { it.type in types2 }
                            .any { d2 ->
                                getCheckedDice(mask)
                                        .filter { d3 -> d3 !== d1 }
                                        .filter { d3 -> d3 !== d2 }
                                        .any { it.type in types3 }
                            }
                }
    }

    override fun isPositionActive(mask: HandMask, position: Int): Boolean {
        if (mask.checkPosition(position)) {
            return true
        }
        val die = hand.dieAt(position) ?: return false
        return when (mask.positionCount   mask.allyPositionCount) {
            0 -> die.type in types1 || die.type in types2 || die.type in types3
            1 -> with(getCheckedDice(mask).first()) {
                (this.type in types1 && (die.type in types2 || die.type in types3))
                        || (this.type in types2 && (die.type in types1 || die.type in types3))
                        || (this.type in types3 && (die.type in types1 || die.type in types2))
            }
            2-> with(getCheckedDice(mask)) {
                val d1 = this[0]
                val d2 = this[1]
                (d1.type in types1 && d2.type in types2 && die.type in types3) ||
                (d2.type in types1 && d1.type in types2 && die.type in types3) ||
                (d1.type in types1 && d2.type in types3 && die.type in types2) ||
                (d2.type in types1 && d1.type in types3 && die.type in types2) ||
                (d1.type in types2 && d2.type in types3 && die.type in types1) ||
                (d2.type in types2 && d1.type in types3 && die.type in types1)
            }
            3 -> false
            else -> false
        }
    }

    override fun isAllyPositionActive(mask: HandMask, position: Int): Boolean {
        if (mask.checkAllyPosition(position)) {
            return true
        }
        if (hand.allyDieAt(position) == null) {
            return false
        }
        return when (mask.positionCount   mask.allyPositionCount) {
            0 -> ALLY in types1 || ALLY in types2 || ALLY in types3
            1 -> with(getCheckedDice(mask).first()) {
                (this.type in types1 && (ALLY in types2 || ALLY in types3))
                        || (this.type in types2 && (ALLY in types1 || ALLY in types3))
                        || (this.type in types3 && (ALLY in types1 || ALLY in types2))
            }
            2-> with(getCheckedDice(mask)) {
                val d1 = this[0]
                val d2 = this[1]
                (d1.type in types1 && d2.type in types2 && ALLY in types3) ||
                        (d2.type in types1 && d1.type in types2 && ALLY in types3) ||
                        (d1.type in types1 && d2.type in types3 && ALLY in types2) ||
                        (d2.type in types1 && d1.type in types3 && ALLY in types2) ||
                        (d1.type in types2 && d2.type in types3 && ALLY in types1) ||
                        (d2.type in types2 && d1.type in types3 && ALLY in types1)
            }
            3 -> false
            else -> false
        }
    }

}

Шаг седьмой. шаблоны и генераторы

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

Location

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

Die

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

val location = Location().apply {
    name = "Some location"
    description = "Some description"
    isOpen = true
    closingDifficulty = 4
    bag = Bag().apply {
        put(Die(Die.Type.PHYSICAL, 4))
        put(Die(Die.Type.SOMATIC, 4))
        put(Die(Die.Type.MENTAL, 4))
        put(Die(Die.Type.ENEMY, 6))
        put(Die(Die.Type.OBSTACLE, 6))
        put(Die(Die.Type.VILLAIN, 6))
    }
    villain = Villain().apply {
        name = "Some villain"
        description = "Some description"
        addTrait(Trait.MODIFIER_PLUS_ONE)
    }
    enemies = Deck<Enemy>().apply {
        addToTop(Enemy().apply {
            name = "Some enemy"
            description = "Some description"
        })
        addToTop(Enemy().apply {
            name = "Other enemy"
            description = "Some description"
        })
        shuffle()
    }
    obstacles = Deck<Obstacle>().apply {
        addToTop(Obstacle(1, Die.Type.PHYSICAL, Die.Type.VERBAL).apply {
            name = "Some obstacle"
            description = "Some Description"
        })
    }
}


Это еще спасибо языку Kotlin и конструкции

apply{}

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

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

Таким образом, для каждого класса наших объектов необходимо задать две новых сущности: интерфейс-шаблон и класс-генератор. А поскольку объектов поднакопилось приличное количество, то и сущностей тоже окажется количество… неприличное:

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

Начнем с чего-нибудь простого — генерации кубиков. «Как? — скажете вы. — Разве нам мало конструктора? Да-да, вот того самого, с типом и размером». Нет, отвечу, недостаточно. Ведь во многих случаях (читайте правила) кубики необходимо генерировать произвольным образом в произвольном количестве (например: «от одного до трех кубиков либо синего, либо зеленого цвета»).

interface DieTypeFilter {
    fun test(type: Die.Type): Boolean
}

Различные реализации этого интерефейса будут проверять, соответствует ли тип кубика различным наборам правил (любым, какие только в голову прийдут). Например, соответствует ли тип строго заданному значению («синий») или диапазону значений («синий, желтый или зеленый»); или, наоборот, соответствует любому типу кроме заданного («лишь бы не белый ни в коем случае» — все, что угодно, только не это).

class SingleDieTypeFilter(val type: Die.Type): DieTypeFilter {
    override fun test(type: Die.Type) = (this.type == type)
}

class InvertedSingleDieTypeFilter(val type: Die.Type): DieTypeFilter {
    override fun test(type: Die.Type) = (this.type != type)
}

class MultipleDieTypeFilter(vararg val types: Die.Type): DieTypeFilter {
    override fun test(type: Die.Type) = (type in types)
}

class InvertedMultipleDieTypeFilter(vararg val types: Die.Type): DieTypeFilter {
    override fun test(type: Die.Type) = (type !in types)
}


Размер кубика тоже будет задаваться произвольным образом, но об этом позже. А пока напишем генератор кубиков (

Читайте также  Как сделать чтобы было много сообщений в вк — All Vk net

DieGenerator

), который, в отличие от конструктора класса

Die

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

private val DISTRIBUTION_LEVEL1 = intArrayOf(4, 4, 4, 4, 6, 6, 6, 6, 8)
private val DISTRIBUTION_LEVEL2 = intArrayOf(4, 6, 6, 6, 6, 8, 8, 8, 8, 10)
private val DISTRIBUTION_LEVEL3 = intArrayOf(6, 8, 8, 8, 10, 10, 10, 10, 12, 12, 12)
private val DISTRIBUTIONS = arrayOf(
        intArrayOf(4),
        DISTRIBUTION_LEVEL1,
        DISTRIBUTION_LEVEL2,
        DISTRIBUTION_LEVEL3
)

fun getMaxLevel() = DISTRIBUTIONS.size - 1

fun generateDie(filter: DieTypeFilter, level: Int) = Die(generateDieType(filter), generateDieSize(level))

private fun generateDieType(filter: DieTypeFilter): Die.Type {
    var type: Die.Type
    do {
        type = Die.Type.values().random()
    } while (!filter.test(type))
    return type
}

private fun generateDieSize(level: Int) =
        DISTRIBUTIONS[if (level < 1 || level > getMaxLevel()) 0 else level].random()
        

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

https://www.youtube.com/watch?v=6s-Odaut0ww

Два приватных метода генерируют отдельно тип и размер кубика — про каждый можно сказать что-то интересное. Метод generateDieType() можно загнать в бесконечный цикл, передав на вход фильтр с

override fun test(filter: DieTypeFilter) = false

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

generateDieSize()

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

Dice

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

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

class BagTemplate {

    class Plan(val minQuantity: Int, val maxQuantity: Int, val filter: DieTypeFilter)

    val plans = mutableListOf<Plan>()

    fun addPlan(minQuantity: Int, maxQuantity: Int, filter: DieTypeFilter) {
        plans.add(Plan(minQuantity, maxQuantity, filter))
    }
}

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

private fun realizePlan(plan: BagTemplate.Plan, level: Int): Array<Die> {
    val count = (plan.minQuantity..plan.maxQuantity).shuffled().last()
    return (1..count).map { generateDie(plan.filter, level) }.toTypedArray()
}

fun generateBag(template: BagTemplate, level: Int): Bag {
    return template.plans.asSequence()
            .map { realizePlan(it, level) }
            .fold(Bag()) { b, d -> b.put(*d); b }
    }
}

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

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

interface LocationTemplate {

    val name: String

    val description: String

    val bagTemplate: BagTemplate

    val basicClosingDifficulty: Int

    val enemyCardsCount: Int

    val obstacleCardsCount: Int

    val enemyCardPool: Collection<EnemyTemplate>

    val obstacleCardPool: Collection<ObstacleTemplate>

    val specialRules: List<SpecialRule>
}

В языке Kotlin вместо методов

getЧтоТо()

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

basicClosingDifficulty

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

SpecialRule

) местности реализуются простым перечислением (

enum class

), а потому отдельного шаблона не требуют.

interface EnemyTemplate {

    val name: String

    val description: String

    val traits: List<Trait>
}

interface ObstacleTemplate {

    val name: String

    val description: String

    val tier: Int

    val dieTypes: Array<Die.Type>

    val traits: List<Trait>
}

interface VillainTemplate {

    val name: String

    val description: String

    val traits: List<Trait>
}


И пусть генератор создает не только отдельные объекты, но и целые колоды с ними.

fun generateVillain(template: VillainTemplate) = Villain().apply {
    name = template.name
    description = template.description
    template.traits.forEach { addTrait(it) }
}

fun generateEnemy(template: EnemyTemplate) = Enemy().apply {
    name = template.name
    description = template.description
    template.traits.forEach { addTrait(it) }
}

fun generateObstacle(template: ObstacleTemplate) = Obstacle(template.tier, *template.dieTypes).apply {
    name = template.name
    description = template.description
    template.traits.forEach { addTrait(it) }
}

fun generateEnemyDeck(types: Collection<EnemyTemplate>, limit: Int?): Deck<Enemy> {
    val deck = types
            .map { generateEnemy(it) }
            .shuffled()
            .fold(Deck<Enemy>()) { d, c -> d.addToTop(c); d }
    limit?.let {
        while (deck.size > it) deck.drawFromTop()
    }
    return deck
}

fun generateObstacleDeck(templates: Collection<ObstacleTemplate>, limit: Int?): Deck<Obstacle> {
    val deck = templates
            .map { generateObstacle(it) }
            .shuffled()
            .fold(Deck<Obstacle>()) { d, c -> d.addToTop(c); d }
    limit?.let {
        while (deck.size > it) deck.drawFromTop()
    }
    return deck
}

Если в колоде окажется больше карт, чем нам нужно (параметр

limit

), мы их оттуда уберем. Умея генерировать сумки с кубиками и колоды карт, мы наконец-то можем и местности создавать:

fun generateLocation(template: LocationTemplate, level: Int) = Location().apply {
    name = template.name
    description = template.description
    bag = generateBag(template.bagTemplate, level)
    closingDifficulty = template.basicClosingDifficulty   level * 2
    enemies = generateEnemyDeck(template.enemyCardPool, template.enemyCardsCount)
    obstacles = generateObstacleDeck(template.obstacleCardPool, template.obstacleCardsCount)
    template.specialRules.forEach { addSpecialRule(it) }
}


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

class SomeLocationTemplate: LocationTemplate {
    override val name = "Some location"
    override val description = "Some description"
    override val bagTemplate = BagTemplate().apply {
        addPlan(1, 1, SingleDieTypeFilter(Die.Type.PHYSICAL))
        addPlan(1, 1, SingleDieTypeFilter(Die.Type.SOMATIC))
        addPlan(1, 2, SingleDieTypeFilter(Die.Type.MENTAL))
        addPlan(2, 2, MultipleDieTypeFilter(Die.Type.ENEMY, Die.Type.OBSTACLE))
    }
    override val basicClosingDifficulty = 2
    override val enemyCardsCount = 2
    override val obstacleCardsCount = 1
    override val enemyCardPool = listOf(
            SomeEnemyTemplate(),
            OtherEnemyTemplate()
    )
    override val obstacleCardPool = listOf(
            SomeObstacleTemplate()
    )
    override val specialRules = emptyList<SpecialRule>()
}

class SomeEnemyTemplate: EnemyTemplate {
    override val name = "Some enemy"
    override val description = "Some description"
    override val traits = emptyList<Trait>()
}

class OtherEnemyTemplate: EnemyTemplate {
    override val name = "Other enemy"
    override val description = "Some description"
    override val traits = emptyList<Trait>()
}

class SomeObstacleTemplate: ObstacleTemplate {
    override val name = "Some obstacle"
    override val description = "Some description"
    override val traits = emptyList<Trait>()
    override val tier = 1
    override val dieTypes = arrayOf(
            Die.Type.PHYSICAL,
            Die.Type.VERBAL
    )
}

val location = generateLocation(SomeLocationTemplate(), 1)

Генерация сценариев будет происходить аналогичным образом.

interface ScenarioTemplate {

    val name: String

    val description: String

    val initialTimer: Int

    val staticLocations: List<LocationTemplate>

    val dynamicLocationsPool: List<LocationTemplate>

    val villains: List<VillainTemplate>

    val specialRules: List<SpecialRule>

    fun calculateDynamicLocationsCount(numberOfHeroes: Int) = numberOfHeroes   2
}

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

fun generateScenario(template: ScenarioTemplate, level: Int) = Scenario().apply {
    name =template.name
    description = template.description
    this.level = level
    initialTimer = template.initialTimer
    template.specialRules.forEach { addSpecialRule(it) }
}

fun generateLocations(template: ScenarioTemplate, level: Int, numberOfHeroes: Int): List<Location> {
    val locations = template.staticLocations.map { generateLocation(it, level) }  
            template.dynamicLocationsPool
                    .map { generateLocation(it, level) }
                    .shuffled()
                    .take(template.calculateDynamicLocationsCount(numberOfHeroes))
    val villains = template.villains
            .map(::generateVillain)
            .shuffled()
    locations.forEachIndexed { index, location ->
        if (index < villains.size) {
            location.villain = villains[index]
            location.bag.put(generateDie(SingleDieTypeFilter(Die.Type.VILLAIN), level))
        }
    }
    return locations
}

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

Ну вот, кажись ничего не забыли… Ах да, герои — их ведь тоже нужно генерировать, а значит им тоже нужны свои шаблоны. Вот такие, например:

interface HeroTemplate {

    val type: Hero.Type

    val initialHandCapacity: Int

    val favoredDieType: Die.Type

    val initialDice: Collection<Die>

    val initialSkills: List<SkillTemplate>

    val dormantSkills: List<SkillTemplate>

    fun getDiceCount(type: Die.Type): Pair<Int, Int>?
}

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

getDiceCount()

— что это вообще за муть такая??? Успокойтесь, это те самые

DiceLimit

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

class BrawlerHeroTemplate : HeroTemplate {
    
    override val type = Hero.Type.BRAWLER
    override val favoredDieType = PHYSICAL
    override val initialHandCapacity = 4

    override val initialDice = listOf(
            Die(PHYSICAL, 6),
            Die(PHYSICAL, 6),
            Die(PHYSICAL, 4),
            Die(PHYSICAL, 4),
            Die(PHYSICAL, 4),
            Die(PHYSICAL, 4),
            Die(PHYSICAL, 4),
            Die(PHYSICAL, 4),
            Die(SOMATIC, 6),
            Die(SOMATIC, 4),
            Die(SOMATIC, 4),
            Die(SOMATIC, 4),
            Die(MENTAL, 4),
            Die(VERBAL, 4),
            Die(VERBAL, 4)
    )

    override fun getDiceCount(type: Die.Type) = when (type) {
        PHYSICAL -> 8 to 12
        SOMATIC -> 4 to 7
        MENTAL -> 1 to 2
        VERBAL -> 2 to 4
        else -> null
    }

    override val initialSkills = listOf(
            HitSkillTemplate()
    )

    override val dormantSkills = listOf<SkillTemplate>()
}


class HunterHeroTemplate : HeroTemplate {

    override val type = Hero.Type.HUNTER
    override val favoredDieType = SOMATIC
    override val initialHandCapacity = 5

    override val initialDice = listOf(
            Die(PHYSICAL, 4),
            Die(PHYSICAL, 4),
            Die(PHYSICAL, 4),
            Die(SOMATIC, 6),
            Die(SOMATIC, 6),
            Die(SOMATIC, 4),
            Die(SOMATIC, 4),
            Die(SOMATIC, 4),
            Die(SOMATIC, 4),
            Die(SOMATIC, 4),
            Die(MENTAL, 6),
            Die(MENTAL, 4),
            Die(MENTAL, 4),
            Die(MENTAL, 4),
            Die(VERBAL, 4)
    )

    override fun getDiceCount(type: Die.Type) = when (type) {
        PHYSICAL -> 3 to 5
        SOMATIC -> 7 to 11
        MENTAL -> 4 to 7
        VERBAL -> 1 to 2
        else -> null
    }

    override val initialSkills = listOf(
            ShootSkillTemplate()
    )

    override val dormantSkills = listOf<SkillTemplate>()
}


Но прежде чем писать генератор, определим шаблон для навыков.

interface SkillTemplate {

    val type: Skill.Type

    val maxLevel: Int

    val modifier1: Int

    val modifier2: Int

    val isActive
        get() = true
}

class HitSkillTemplate : SkillTemplate {
    override val type = Skill.Type.HIT
    override val maxLevel = 3
    override val modifier1 =  1
    override val modifier2 =  3
}

class ShootSkillTemplate : SkillTemplate {
    override val type = Skill.Type.SHOOT
    override val maxLevel = 3
    override val modifier1 =  0
    override val modifier2 =  2
}

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

fun generateSkill(template: SkillTemplate, initialLevel: Int = 1): Skill {
    val skill = Skill(template.type)
    skill.isActive = template.isActive
    skill.level = initialLevel
    skill.maxLevel = template.maxLevel
    skill.modifier1 = template.modifier1
    skill.modifier2 = template.modifier2
    return skill
}

fun generateHero(type: Hero.Type, name: String = ""): Hero {
    val template = when (type) {
        BRAWLER -> BrawlerHeroTemplate()
        HUNTER -> HunterHeroTemplate()
    }
    val hero = Hero(type)
    hero.name = name
    hero.isAlive = true
    hero.favoredDieType = template.favoredDieType
    hero.hand.capacity = template.initialHandCapacity
    template.initialDice.forEach { hero.bag.put(it) }

    for ((t, l) in Die.Type.values().map { it to template.getDiceCount(it) }) {
        l?.let { hero.addDiceLimit(DiceLimit(t, it.first, it.second, it.first)) }
    }
    template.initialSkills
            .map { generateSkill(it) }
            .forEach { hero.addSkill(it) }
    template.dormantSkills
            .map { generateSkill(it, 0) }
            .forEach { hero.addDormantSkill(it) }
    return hero
}

https://www.youtube.com/watch?v=zreUT2jMR2k

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

Понравилась статья? Поделиться с друзьями:
ТВОЙ ВК
Добавить комментарий

Adblock
detector