【译】diceroller:一个示例 Rust 项目

329 阅读9分钟

DZone>开放源代码区 > diceroller,一个Rust项目的样本

diceroller, A Sample Rust Project

在这篇文章中,我开发了一个示例应用程序,用于处理Champion's HERO掷骰子系统的伤害生成子系统(一部分)。

Nicolas Fränkel user avatar通过

尼古拉斯-弗雷克尔(Nicolas Fränkel

-

Aug. 07, 21 -开源区 -教程

喜欢 (1)

评论

保存

Tweet

2.02K浏览次数

加入DZone社区,获得完整的会员体验。

免费加入

对我来说,最好的学习过程是定期在学习与实践、理论与实践之间切换。上一篇文章是研究,因此,这一篇将是编码。

我从11岁开始就一直是角色扮演游戏的玩家。当然,我玩过《龙与地下城》(主要是所谓的高级版),但几年后,我对《冠军》和它的HERO系统产生了兴趣。该系统以点数分配为基础,几乎允许关于角色能力的一切。关于这个系统的简要描述,请查看这个精彩的、虽然简短的Stack Exchange答案。我为这篇文章开发了一个样本程序来处理(部分)伤害生成子系统。

掷骰子

在RPG游戏中,有些行动可能成功,也可能失败,_例如,_爬上悬崖或打中敌人:成功与否取决于掷骰子。HERO系统也不例外。

由于这个原因,我们的第一个任务应该是掷骰子的建模。在RPG游戏中,骰子并不局限于6面的。

struct Die {
    faces: u8,
}

现在我们已经定义了一个骰子,我们需要能够掷出它:它包含了随机性。让我们把相关的板条箱添加到我们的构建中。

TOML

[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. 检索懒惰地初始化的线程本地PRNG。
  2. 返回一个介于1和面数之间的随机数,两端都包括在内。

在这一点上,可以创建一个骰子。

Rust

let d6 = Die { faces: 6 };

为了获得更好的开发者体验,我们应该创建实用函数来创建骰子。

Rust

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)
    }
    // Many more functions for other dice
}

用宏进行DRY

上面的代码显然不是DRY。所有的dN 函数看起来都一样。创建一个宏,将N ,这样我们就可以写一个单一的函数,而编译器将为我们生成不同的实现方式,这将是很有帮助的。

Rust

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. 尽情享受吧!

但代码无法编译。

Rust

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 crate。

这个板块提供了一种灵活的方式,可以在宏中粘贴标识符,包括使用粘贴的标识符来定义新的项目。

--crates.io

让我们把这个板条箱添加到我们的项目中。

TOML

[dependencies]
paste = "1.0.5"

然后使用它。

Rust

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将其定义为

Rust

pub trait Default: Sized {
    /// Returns the "default value" for a type.
    ///
    /// Default values are often some kind of initial value, identity value, or anything else that
    /// may make sense as a default.
    #[stable(feature = "rust1", since = "1.0.0")]
    fn default() -> Self;
}

对于Die ,实现Default ,并返回一个6面的骰子是有意义的。

Rust

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

我们现在可以调用Die::default() ,得到一个D6。

非零检查

使用u8 可以防止出现无效的负数面。但是一个骰子应该至少有一个面。因此,在创建一个新的Die ,我们可以从添加一个非零检查中受益。

最直接的方法是在new()dN() 函数的开头添加一个if 检查。但是我做了一些研究,偶然发现了非零整数类型。我们可以据此重写我们的Die 实现。

Rust

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

当我写这段代码时,我认为这是个好主意。当我在写这篇博文时,我认为这是一个过度工程的好例子。

这个想法是为了快速失败。否则,我们将需要在整个应用中应对Optionif faces == 0 { panic!("Value must be strictly positive {}", faces); } ,会简单得多,并达到同样的效果。KISS。

为伤害打滚

RPG游戏意味着战斗,而战斗意味着对你的对手造成伤害。HERO系统也不例外。它模拟了一个角色的两个属性:保持意识和保持生命的能力,分别是STUNBODY

伤害本身可以有两种不同的类型:钝器创伤, NormalDamage ,和KillingDamage 。我们先来关注一下前一种类型。

对于每个正常的伤害模子,规则很简单。

  • STUN 伤害的数量是卷
  • BODY 的数量取决于卷轴:0 是指12 是指6 ,而在所有其他情况下,1

我们可以按以下方式实现。

Rust

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 }
    }
}

虽然它是可行的,但它涉及到_变异性_。让我们重写一个函数版本。

Rust

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. STUNBODY 创建Damage

  6. 聚合它。

上面的代码不能编译。

Rust

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 加在一起!这就像把它们的STUNBODY 相加一样简单。为了解决这个编译错误,我们需要为NormalDamage 实现Sum 特质。

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 ,我们需要它的stunbody 属性。

Rust

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

打印Damage 是一个相当标准的用例。我们想写以下内容。

Rust

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

为此,我们需要为Damage 实现Display

Rust

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

我相信对于你的大部分struct ,这样做是一个好的做法。

让损害成为一个特质

下一步是实现KillingDamageDice 。计算方法与普通伤害不同。对于每一个模子,我们都要滚出BODY 。然后我们掷出一个_乘数_。STUNBODY 乘以mult 。我们目前的代码滚动mult ,但我们没有将其存储在Damage 结构中。要做到这一点,我们需要引入一个KillingDamage 结构。

Rust

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函数不能返回trait,所以代码不能再编译了。

普通文本

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// T指向堆上的数据

让我们把返回值包在一个Box ,以纠正编译错误。

Rust

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

现在编译成功了。

特质的显示

由于Damage 是一个trait,我们需要改变应用程序的println!() 部分。

Rust

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 的 "子trait"。

Rust

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

最后,我们需要为NormalDamageKillingDamage 实现Display

总结

在这篇文章中,我写了我为HERO系统实现伤害滚动的步骤以及Rust上最令人兴奋的部分。这个项目还没有停止。我可能会继续开发它,以进一步加深我的理解,因为它是一个很好的用例。

顺便说一句,你可能已经注意到我没有写任何测试。这不是一个疏忽。原因是随机性使大多数低级别的测试变得无用。在元层面上,尽管人们普遍认为,这意味着人们可以在没有TDD的情况下进行增量设计。

这篇文章的完整源代码可以在Github上找到。

更进一步。

原文发表于2021年7月25日的A Java Geek

主题。

初学者, 学习, rust, 角色扮演, 随机性, 教程

经Nicolas Fränkel, DZone MVB许可发表于DZone。点击这里查看原文。

DZone贡献者所表达的观点属于他们自己。

在DZone上很受欢迎


评论

开源 合作伙伴资源