DZone>开放源代码区 > diceroller,一个Rust项目的样本
diceroller, A Sample Rust Project
在这篇文章中,我开发了一个示例应用程序,用于处理Champion's HERO掷骰子系统的伤害生成子系统(一部分)。
-
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
}
}
- 检索懒惰地初始化的线程本地PRNG。
- 返回一个介于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
}
- 如果没有使用就不要警告,这是预期的。
- 参数化函数名。
- 参数化函数体。
- 尽情享受吧!
但代码无法编译。
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。
这个板块提供了一种灵活的方式,可以在宏中粘贴标识符,包括使用粘贴的标识符来定义新的项目。
让我们把这个板条箱添加到我们的项目中。
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)
}
)*
}
};
}
- 打开
paste指令。 - 使用
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 }
}
}
- 将
u8包装成一个非零的类型 - 将其解包为一个非零类型
Option - 如果它是严格意义上的正数,则获取底层的
u8,否则就获取panic。
当我写这段代码时,我认为这是个好主意。当我在写这篇博文时,我认为这是一个过度工程的好例子。
这个想法是为了快速失败。否则,我们将需要在整个应用中应对Option 。if faces == 0 { panic!("Value must be strictly positive {}", faces); } ,会简单得多,并达到同样的效果。KISS。
为伤害打滚
RPG游戏意味着战斗,而战斗意味着对你的对手造成伤害。HERO系统也不例外。它模拟了一个角色的两个属性:保持意识和保持生命的能力,分别是STUN 和BODY 。
伤害本身可以有两种不同的类型:钝器创伤,即 NormalDamage ,和KillingDamage 。我们先来关注一下前一种类型。
对于每个正常的伤害模子,规则很简单。
STUN伤害的数量是卷BODY的数量取决于卷轴:0是指1,2是指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
}
}
-
对于每个伤害模子
-
创建一个D6。
-
滚动它。
-
执行业务规则。
-
用
STUN和BODY创建Damage。 -
聚合它。
上面的代码不能编译。
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 加在一起!这就像把它们的STUN 和BODY 相加一样简单。为了解决这个编译错误,我们需要为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 ,我们需要它的stun 和body 属性。
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 。然后我们掷出一个_乘数_。STUN 是BODY 乘以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 ,以纠正编译错误。
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;
}
最后,我们需要为NormalDamage 和KillingDamage 实现Display 。
总结
在这篇文章中,我写了我为HERO系统实现伤害滚动的步骤以及Rust上最令人兴奋的部分。这个项目还没有停止。我可能会继续开发它,以进一步加深我的理解,因为它是一个很好的用例。
顺便说一句,你可能已经注意到我没有写任何测试。这不是一个疏忽。原因是随机性使大多数低级别的测试变得无用。在元层面上,尽管人们普遍认为,这意味着人们可以在没有TDD的情况下进行增量设计。
这篇文章的完整源代码可以在Github上找到。
更进一步。
原文发表于2021年7月25日的A Java Geek。
主题。
初学者, 学习, rust, 角色扮演, 随机性, 教程
经Nicolas Fränkel, DZone MVB许可发表于DZone。点击这里查看原文。
DZone贡献者所表达的观点属于他们自己。
在DZone上很受欢迎
评论