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

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

Я играю в ролевые игры с 11 лет. Естественно, я начинал с Dungeons & Dragons (долгое время играл основном в так называемую Advanced Edition), но через несколько лет я увлекся Champions и в особенности их системой HERO. Эта система основана на распределении очков и позволяет практически все, что касается способностей персонажа. Чтобы понимать, что из себя представляет эта система, пожалуйста, ознакомьтесь с этим блестящим, хотя и не слишком подробным ответом на Stack Exchange. Для этого поста я разработал пример приложения, которое реализует подсистему генерации урона (на самом деле только ее часть).

Бросок дайсов

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

По этой причине самой первой нашей задачей будет моделирование броска дайса (игральной кости). В ролевых играх дайсы могут быть не только шестигранными (поэтому мы называем их “дайсы”, а не просто “кубики”).

struct Die {
    faces: u8,
}

Теперь, когда мы определили дайс, нам нужно иметь возможность его бросить: это привносит в наш проект случайность. Давайте добавим соответствующий крейт в нашу сборку:

[dependencies]
rand = "0.8.4"

Этот крейт предлагает несколько генераторов случайных чисел (PRNG). Мы разрабатываем не лотерейное приложение, поэтому нам подойдет самое стандартное решение:

impl Die {
    pub fn roll(self) -> u8 {
        let mut rng = rand::thread_rng();            // 1
        rng.gen_range(1..=self.faces)                // 2
    }
}
  1. Получаем лениво инициализированный локальный генератор случайных чисел.

  2. Возвращает случайное число от 1 до количества граней (включая обе границы).

На этом этапе мы уже можем создать кубик:

let d6 = Die { faces: 6 };

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

impl Die {
    pub fn new(faces: u8) -> Die {
        Die { faces }
    }
    pub fn d2() -> Die {
        Self::new(2)
    }
    pub fn d4() -> Die {
        Self::new(4)
    }
    pub fn d6() -> Die {
        Self::new(6)
    }
    // И еще много функций для остальных игральных костей
}

Макрос во имя DRY

Приведенный выше код явно не DRY (“Don’t repeat yourself ”). Все функции dN выглядят одинаково. Было бы неплохо написать макрос, который параметризует N, чтобы нам было достаточно написать всего  одну функцию, а компилятор сгенерировал бы для нас разные реализации:

macro_rules! gen_dice_fn_for {
    ( $( $x:expr ),* ) => {
        $(
            #[allow(dead_code)]                           // 1
            pub fn d$x() -> Die {                         // 2
                Self::new($x)                             // 3
            }
        )*
    };
}

impl Die {
    pub fn new(faces: u8) -> Die {
        Die { faces }
    }
    gen_dice_fn_for![2, 4, 6, 8, 10, 12, 20, 30, 100];   // 4
}
  1. Не будет варнинга, если этот код не используется – все в порядке. 

  2. Параметризуем имя функции.

  3. Параметризуем тело функции.

  4. Наслаждаемся!

Но код не компилируется:

error: expected one of `(` or `<`, found `2`
  --> src/droller/dice.rs:9:21
   |
9  |             pub fn d$x() -> Die {
   |                     ^^ expected one of `(` or `<`
...
21 |     gen_dice_fn_for![2, 4, 6, 8, 10, 12, 20, 30, 100];
   |     -------------------------------------------------- in this macro invocation
   |
   = note: this error originates in a macro (in Nightly builds, run with -Z macro-backtrace for more info)

Макросы Rust не позволяют параметризовать имя функции – только ее тело.

После небольшого ресерча я нашел крейт paste:

Этот крейт предоставляет гибкий способ вставки идентификаторов в макрос, позволяя использовать вставленные идентификаторы для определения новых элементов. -- crates.io

Давайте добавим этот крейт в наш проект:

[dependencies]
paste = "1.0.5"

Затем используем его:

macro_rules! gen_dice_fn_for {
    ( $( $x:expr ),* ) => {
        paste! {                            // 1
            $(
            #[allow(dead_code)]
            pub fn [// d$x>]() -> Die {       <2
                Self::new($x)
            }
            )*
        }
    };
}
  1. Открываем директиву paste.

  2. Генерируем имя функции, используя x.

Дефолтный дайс

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

Здесь бы отлично смотрелся трейт Default. Rust определяет его следующим образом:

pub trait Default: Sized {
    /// Возвращает "дефолтное значение" для типа.
    ///
    /// В качестве дефолтного значения обычно выступает какое-нибудь начальное значения, значение идентификатора, или что-либо
    /// что бы подошло на роль дефолтного значения этого конкретного типа.
    #[stable(feature = "rust1", since = "1.0.0")]
    fn default() -> Self;
}

Реализовать Default для Die и возвращать шестигранный кубик – действительно хорошая идея в нашем случае.

impl Default for Die {
    fn default() -> Self {
        Die::d6()
    }
}

Теперь мы можем вызвать Die::default() и получить d6.

Проверка на нулевые значения

Использование u8 предотвращает саму возможность существования недопустимого отрицательного количества сторон. Но у дайса должна быть хотя бы одна сторона. Следовательно, было бы неплохо добавить проверку на нулевое значение при создании нового Die.

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

impl Die {
    pub fn new(faces: u8) -> Die {
        let faces = NonZeroU8::new(faces)       // 1
            .unwrap()                           // 2
            .get();                             // 3
        Die { faces }
    }
}
  1. Оборачиваем u8 в ненулевой тип.

  2. Разворачиваем его в Option.

  3. Получаем завернутое значение в u8, если оно строго положительное, или panic‘у в противном случае.

Когда я писал этот код, я думал, что это хорошая идея. Теперь же, когда я пишу этот пост, я думаю, что это хороший пример оверинжиниринга.

Суть заключается в том, чтобы если что-то пойдет не так, то мы просто быстро словим ошибку и дело с концом. В противном случае нам пришлось бы возиться с типом Option на протяжении всего приложения. if faces == 0 { panic!("Value must be strictly positive {}", faces); } было бы намного проще при том же результате. Вот.

Бросаем урон

Ролевые игры подразумевают драки, а драки подразумевают нанесение урона вашим противникам. Это не чуждо и системе HERO. Она моделирует два свойства персонажа: его способность оставаться в сознании и оставаться в живых, чему соответствуют характеристики STUN и BODY.

Сам урон может быть двух разных типов: тупая (blunt) травма, то есть, NormalDamage (обычный урон), и KillingDamage (убийственный урон). Сначала рассмотрим первый тип.

Для каждого кубика обычного урона правила таковы:

  • Количество урона в STUN равно выпавшему значению. 

  • Количество урона в BODY зависит от значения следующим образом: 0 для 1, 2 для 6, и 1 во всех остальных случаях.

Мы можем реализовать это, например, так:

pub struct Damage {
    pub stun: u8,
    pub body: u8,
}

pub struct NormalDamageDice {
    number: u8,
}

impl NormalDamageDice {
    pub fn new(number: u8) -> NormalDamageDice {
        let number = NonZeroU8::new(number).unwrap().get();
        NormalDamageDice { number }
    }
    pub fn roll(self) -> Damage {
        let mut stun = 0;
        let mut body = 0;
        for _ in 0..self.number {
            let die = Die::default();
            let roll = die.roll();
            stun += roll;
            if roll == 1 {
            } else if roll == 6 {
                body += 2
            } else {
                body += 1
            }
        }
        Damage { stun, body }
    }
}

Хоть он и работает, он предполагает мутабельность. Давайте перепишем его в функциональную версию:

impl NormalDamageDice {
    pub fn roll(self) -> Damage {
        (0..self.number)                     // 1
            .map(|_| Die::default())         // 2
            .map(|die| die.roll())           // 3
            .map(|stun| {
                let body = match stun {      // 4
                    1 => 0,
                    6 => 2,
                    _ => 1,
                };
                Damage { stun, body }        // 5
            })
            .sum()                           // 6
    }
}
  1. Для каждого броска на урон:

  2. Создаем d6;

  3. Бросаем его;

  4. Реализуем бизнес-правило;

  5. Создаем Damage со STUN и BODY;

  6. Суммируем урон.

Приведенный выше код не компилируется:

error[E0277]: the trait bound `NormalDamage: Sum` is not satisfied
  --> src/droller/damage.rs:89:14
   |
89 |             .sum::// NormalDamage();
   |              ^^^ the trait `Sum` is not implemented for `NormalDamage`

Rust не умеет складывать два Damage вместе! Но нам всего-навсего нужно добавить их STUN и BODY. Чтобы исправить эту ошибку компиляции, нам нужно реализовать трейт Sum для NormalDamage.

impl Sum for NormalDamage {
    fn sum// I: Iterator<Item = Self>>(iter: I) - Self {
        iter.fold(NormalDamage::zero(), |dmg1, dmg2| NormalDamage {
            stun: dmg1.stun + dmg2.stun,
            body: dmg1.body + dmg2.body,
        })
    }
}

Вывод урона

Пока что для вывода Damage нам нужны только его свойства stun и body:

let one_die = NormalDamageDice::new(1);
let damage = one_die.roll();
println!("stun: {}, body: {}", damage.stun, damage.body);

В выводе Damage нет ничего сверхъестественного. Нам просто нужно написать следующее:

let one_die = NormalDamageDice::new(1);
let damage = one_die.roll();
println!("damage: {}", damage);

Для этого нам нужно реализовать для Damage Display:

impl Display for Damage {
    fn fmt(&self, f: &mut Formatter// '_>) - std::fmt::Result {
        write!(f, "stun: {}, body: {}", self.stun, self.body)
    }
}

Я считаю, что делать это для большинства ваших структур – это хорошая практика.

Превращаем урон в трейт

Следующим нашим шагом будет реализация KillingDamageDice. Здесь расчет отличается от обычного урона. Для каждого кубика мы бросаем BODY. Затем мы бросаем множитель. Значение STUN будет равно значению BODY умноженному на mult. Наш текущий код бросает mult, но мы не храним его в структуре Damage. Для этого нам нужно добавить структуру KillingDamage:

pub struct KillingDamage {
    pub body: u8,
    pub mult: u8,
}

Но при таком подходе мы не сможем получить значение STUN. Следовательно, следующий наш шаг – это сделать Damage трейтом.

pub trait Damage {
    fn stun(self) -> u8;
    fn body(self) -> u8;
}

impl Damage for NormalDamage {
    fn stun(self) -> u8 {
        self.stun
    }
    fn body(self) -> u8 {
        self.body
    }
}

impl Damage for KillingDamage {
    fn stun(self) -> u8 {
        self.body * self.mult
    }
    fn body(self) -> u8 {
        self.body
    }
}

На данный момент код не компилируется, так как функции Rust не могут возвращать трейты.

error[E0277]: the size for values of type `(dyn Damage + 'static)` cannot be known at compilation time
  --> src/droller/damage.rs:86:26
   |
86 |     pub fn roll(self) -> Damage {
   |                          ^^^^^^ doesn't have a size known at compile-time
   |
   = help: the trait `Sized` is not implemented for `(dyn Damage + 'static)`
   = note: the return type of a function must have a statically known size

Это можно быстро исправить с помощью типа Box.

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

Давайте обернем возвращаемое значение в Box, чтобы исправить ошибку компиляции.

pub fn roll(self) -> Box// dyn Damage {
    // let damage = ...
    Box::new(damage)
}

Теперь все успешно компилируется.

Display и трейты

Сделав наш Damage трейтом, нам теперь нужно изменить часть с println!():

let normal_die = NormalDamageDice::new(1);
let normal_dmg = normal_die.roll();
println!("normal damage: {}", normal_dmg);
let killing_die = KillingDamageDice::new(1);
let killing_dmg = killing_die.roll();
println!("killing damage: {}", killing_dmg);

Но этот фрагмент кода не компилируется:

error[E0277]: `dyn Damage` doesn't implement `std::fmt::Display`
 --> src/main.rs:8:35
  |
8 |     println!("normal damage: {}", normal_dmg);
  |                                   ^^^^^^^^^^ `dyn Damage` cannot be formatted with the default formatter
  |
  = help: the trait `std::fmt::Display` is not implemented for `dyn Damage`
  = note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
  = note: required because of the requirements on the impl of `std::fmt::Display` for `Box// dyn Damage`
  = note: required by `std::fmt::Display::fmt`
  = note: this error originates in a macro (in Nightly builds, run with -Z macro-backtrace for more info)

Чтобы исправить это, нам нужно сделать Damage "сабтрейтом" Display.

pub trait Damage: Display {
    fn stun(self) -> u8;
    fn body(self) -> u8;
}

Наконец, нам нужно реализовать Display для NormalDamage и KillingDamage.

Заключение

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

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

Полный исходный код для этого поста можно найти на Github.


Завтра вечером пройдет открытый урок, посвященный основным концепциям технологии blockchain и леджер. Рассмотрим базовые понятия о blockchain, а также популярные библиотеки, разберём процесс написания blockchain, отработаем создание реализации blockchain и леджера на практике.

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

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


  1. segment
    17.05.2023 20:49

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


    1. DarkEld3r
      17.05.2023 20:49
      +2

      А что в данной статье "синтаксически замысловатого"? Ну кроме разве что макросов. Всё остальное — структуры и трейты. На последние можно смотреть как на привычные интерфейсы.


      1. olegkusov
        17.05.2023 20:49

        Вот стоило как раз в статье добавить некоторые связи с привычными людям сущностями. Типа «трейты это что-то типа интерфейсов». Сейчас если чел не знает ничего про раст ему будет сложно понимать все эти концепции. С одной стороны статья для новичков с другой она слишком поверхностно проходит по всем концепциям. В итоге непонятно для кого она написана


        1. Widowan
          17.05.2023 20:49

          pub trait Damage {
              fn stun(self) -> u8;
              fn body(self) -> u8;
          }
          

          Вроде конструкция говорит сама за себя, это ж один в один объявление интерфейса в условной джаве - набор методов без тела.