游戏属性


关于本教程

本教程是免费且开源的,所有代码均使用 MIT 许可证——因此您可以随意使用。 我希望您会喜欢本教程,并制作出伟大的游戏!

如果您喜欢本教程并希望我继续创作,请考虑支持我的 Patreon

Hands-On Rust


到目前为止,我们只有非常原始的属性:力量和防御。这并没有提供太多的变化空间,也不符合 Roguelike 游戏中让玩家沉浸在数字中的理想(好吧,这有点夸张了)。在设计文档中,我们提到希望采用类似 D&D 的游戏属性方法。这为各种玩法提供了大量空间,允许带有各种奖励(和惩罚)的物品,并且对于大多数可能玩这类游戏的玩家来说应该会感到熟悉。这也需要一些 UI 工作,但我们会将大部分工作推迟到下一章。

基础 6 属性 - 浓缩为 4 种

任何玩过 D&D 的人都知道,角色——以及后来的版本中的所有人——都拥有六个属性:

  • 力量 (Strength),决定了你能携带多少东西、你击打东西的力度以及你一般的身体能力。
  • 敏捷 (Dexterity),决定了你躲避事物的速度、你杂技般跳跃的能力,以及像开锁和瞄准弓箭之类的技能。
  • 体质 (Constitution),决定了你的身体健康程度,调整你的生命值总量并帮助抵抗疾病。
  • 智力 (Intelligence),代表你的聪明程度,帮助你施法、阅读事物。
  • 感知 (Wisdom),代表你拥有的常识,以及与神灵的有益互动。
  • 魅力 (Charisma),代表你与他人互动的能力。

对于我们正在制作的游戏来说,这有点过头了。智力和感知不需要分开(感知最终会成为每个人为了在其他地方获得点数而抛弃的“废属性”!),而魅力实际上只在与供应商互动时有用,因为我们在游戏中没有进行大量的社交互动。因此,我们将为这款游戏选择一组浓缩的属性:

  • 力量 (Might),决定你击中目标的一般能力。
  • 体魄 (Fitness),你的一般健康状况。
  • 迅捷 (Quickness),你的一般敏捷替代属性。
  • 智力 (Intelligence),实际上结合了 D&D 术语中的智力和感知。

这在其他游戏中也是相当常见的组合。让我们打开 components.rs 并创建一个新的组件来保存它们:

#![allow(unused)]
fn main() {
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Attribute {
    pub base : i32,
    pub modifiers : i32,
    pub bonus : i32
}

#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct Attributes {
    pub might : Attribute,
    pub fitness : Attribute,
    pub quickness : Attribute,
    pub intelligence : Attribute
}
}

所以我们为属性创建了一个结构体,并存储了三个值:

  • 基础 (base) 值,这是完全未修改的值。
  • 修正值 (modifiers),表示属性的任何有效奖励或惩罚(并且必须不时重新计算)。
  • 奖励值 (bonus),它从最终的修正值派生而来——并且在大多数情况下,是我们实际要使用的值。

不要忘记在 main.rssaveload_system.rs 中注册 AttributesAttribute 实际上不是一个组件——它只是被一个组件使用——所以你不必注册它。

给玩家一些属性

现在,我们应该给玩家一些属性。我们将从简单的开始,并为每个属性赋予 11 的值(我们使用 D&D 风格的 3-18,3d6 生成的属性)。在 spawner.rs 中,修改 player 函数如下:

#![allow(unused)]
fn main() {
pub fn player(ecs : &mut World, player_x : i32, player_y : i32) -> Entity {
    ecs
        .create_entity()
        .with(Position { x: player_x, y: player_y })
        .with(Renderable {
            glyph: rltk::to_cp437('@'),
            fg: RGB::named(rltk::YELLOW),
            bg: RGB::named(rltk::BLACK),
            render_order: 0
        })
        .with(Player{})
        .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
        .with(Name{name: "Player".to_string() })
        .with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 })
        .with(HungerClock{ state: HungerState::WellFed, duration: 20 })
        .with(Attributes{
            might: Attribute{ base: 11, modifiers: 0, bonus: 0 },
            fitness: Attribute{ base: 11, modifiers: 0, bonus: 0 },
            quickness: Attribute{ base: 11, modifiers: 0, bonus: 0 },
            intelligence: Attribute{ base: 11, modifiers: 0, bonus: 0 },
        })
        .marked::<SimpleMarker<SerializeMe>>()
        .build()
}
}

NPC 的属性

我们可能不想为 spawns.json 中的每个 NPC 都写出每个属性,但我们希望能够在需要时这样做。以普通的 NPC,比如 Barkeep(酒保)为例。我们可以使用以下语法来表示他在所有方面都具有“正常”属性,但比普通农民更聪明:

{
    "name" : "Barkeep",
    "renderable": {
        "glyph" : "☺",
        "fg" : "#EE82EE",
        "bg" : "#000000",
        "order" : 1
    },
    "blocks_tile" : true,
    "stats" : {
        "max_hp" : 16,
        "hp" : 16,
        "defense" : 1,
        "power" : 4
    },
    "vision_range" : 4,
    "ai" : "vendor",
    "attributes" : {
        "intelligence" : 13
    }
},

这种模式很强大,因为我们可以忽略基本上是背景装饰的人物的细节——但可以为重要的怪物填写我们想要的尽可能多的细节!如果您没有指定属性,它将默认为一个中间值。

让我们扩展 raws/mob_structs.rs 中的结构体以支持这种灵活的格式:

#![allow(unused)]
fn main() {
use serde::{Deserialize};
use super::{Renderable};

#[derive(Deserialize, Debug)]
pub struct Mob {
    pub name : String,
    pub renderable : Option<Renderable>,
    pub blocks_tile : bool,
    pub stats : MobStats,
    pub vision_range : i32,
    pub ai : String,
    pub quips : Option<Vec<String>>,
    pub attributes : MobAttributes
}

#[derive(Deserialize, Debug)]
pub struct MobStats {
    pub max_hp : i32,
    pub hp : i32,
    pub power : i32,
    pub defense : i32
}

#[derive(Deserialize, Debug)]
pub struct MobAttributes {
    pub might : Option<i32>,
    pub fitness : Option<i32>,
    pub quickness : Option<i32>,
    pub intelligence : Option<i32>
}
}

请注意,我们将 attributes 设置为必需——因此您必须拥有一个属性才能加载 JSON。然后我们将所有属性值设置为可选;如果您没有指定它们,我们将使用一个不错的、正常的值。

现在让我们打开 raws/rasmaster.rs 并修改 spawn_named_mob 以生成此数据:

#![allow(unused)]
fn main() {
let mut attr = Attributes{
    might: Attribute{ base: 11, modifiers: 0, bonus: 0 },
    fitness: Attribute{ base: 11, modifiers: 0, bonus: 0 },
    quickness: Attribute{ base: 11, modifiers: 0, bonus: 0 },
    intelligence: Attribute{ base: 11, modifiers: 0, bonus: 0 },
};
if let Some(might) = mob_template.attributes.might {
    attr.might = Attribute{ base: might, modifiers: 0, bonus: 0 };
}
if let Some(fitness) = mob_template.attributes.fitness {
    attr.fitness = Attribute{ base: fitness, modifiers: 0, bonus: 0 };
}
if let Some(quickness) = mob_template.attributes.quickness {
    attr.quickness = Attribute{ base: quickness, modifiers: 0, bonus: 0 };
}
if let Some(intelligence) = mob_template.attributes.intelligence {
    attr.intelligence = Attribute{ base: intelligence, modifiers: 0, bonus: 0 };
}
eb = eb.with(attr);
}

这将检查 JSON 中是否存在每个属性,并将其分配给 mob(怪物)。

属性奖励值

到目前为止,一切都很好——但是 bonus(奖励值)字段呢?每个值的奖励值都为 0 是不对的!我们将需要进行大量的游戏系统计算——所以我们将在主项目中创建一个新文件 gamesystem.rs

#![allow(unused)]
fn main() {
pub fn attr_bonus(value: i32) -> i32 {
    (value-10)/2 // 参见:https://roll20.net/compendium/dnd5e/Ability%20Scores#content
}
}

这使用了标准的 D&D 规则来确定属性奖励值:减去 10 再除以 2。所以我们的 11 将给出 0 的奖励值——它是平均水平。我们的酒保将获得 1 点智力检定奖励。

main.rs 的顶部,添加 mod gamesystempub use gamesystem::* 以使这个模块在任何地方都可用。

现在修改 spawner.rs 中的玩家生成代码以使用它:

#![allow(unused)]
fn main() {
use crate::attr_bonus;
...
.with(Attributes{
    might: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) },
    fitness: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) },
    quickness: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) },
    intelligence: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) },
})
...
}

rawmaster.rs 中做同样的事情:

#![allow(unused)]
fn main() {
use crate::attr_bonus; // 在顶部!
...
let mut attr = Attributes{
    might: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) },
    fitness: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) },
    quickness: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) },
    intelligence: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) },
};
if let Some(might) = mob_template.attributes.might {
    attr.might = Attribute{ base: might, modifiers: 0, bonus: attr_bonus(might) };
}
if let Some(fitness) = mob_template.attributes.fitness {
    attr.fitness = Attribute{ base: fitness, modifiers: 0, bonus: attr_bonus(fitness) };
}
if let Some(quickness) = mob_template.attributes.quickness {
    attr.quickness = Attribute{ base: quickness, modifiers: 0, bonus: attr_bonus(quickness) };
}
if let Some(intelligence) = mob_template.attributes.intelligence {
    attr.intelligence = Attribute{ base: intelligence, modifiers: 0, bonus: attr_bonus(intelligence) };
}
eb = eb.with(attr);
...
}

在编译/运行游戏之前,在 spawns.json 中为每个 mob 添加一个空白的 attributes 条目,以避免错误。这是一个例子:

{
    "name" : "Shady Salesman",
    "renderable": {
        "glyph" : "h",
        "fg" : "#EE82EE",
        "bg" : "#000000",
        "order" : 1
    },
    "blocks_tile" : true,
    "stats" : {
        "max_hp" : 16,
        "hp" : 16,
        "defense" : 1,
        "power" : 4
    },
    "vision_range" : 4,
    "ai" : "vendor",
    "attributes" : {}
},

技能

在我们开始使用属性之前,我们应该考虑与属性密切相关的另一个元素:技能。对于这个游戏,我们不想搞得太复杂,弄出数百个技能;我们永远无法完成本教程!相反,让我们使用一些非常基础的技能:近战、防御和魔法。我们以后总是可以添加更多技能(但删除它们可能会引起用户的一片嘲笑!)。

components.rs 中,让我们创建一个技能持有组件:

#![allow(unused)]
fn main() {
#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)]
pub enum Skill { Melee, Defense, Magic }

#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct Skills {
    pub skills : HashMap<Skill, i32>
}
}

因此,如果我们要添加技能,将来我们需要将它们添加到 enum 中——但我们的基本 skills 结构可以容纳我们想出的任何技能。不要忘记将 Skills(而不是 Skill)添加到 main.rssaveload_system.rs 中进行注册!

打开 spawners.rs,让我们给 player 在所有技能中都赋予 1 的技能等级:

#![allow(unused)]
fn main() {
pub fn player(ecs : &mut World, player_x : i32, player_y : i32) -> Entity {
    let mut skills = Skills{ skills: HashMap::new() };
    skills.skills.insert(Skill::Melee, 1);
    skills.skills.insert(Skill::Defense, 1);
    skills.skills.insert(Skill::Magic, 1);

    ecs
        .create_entity()
        .with(Position { x: player_x, y: player_y })
        .with(Renderable {
            glyph: rltk::to_cp437('@'),
            fg: RGB::named(rltk::YELLOW),
            bg: RGB::named(rltk::BLACK),
            render_order: 0
        })
        .with(Player{})
        .with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })
        .with(Name{name: "Player".to_string() })
        .with(CombatStats{ max_hp: 30, hp: 30, defense: 2, power: 5 })
        .with(HungerClock{ state: HungerState::WellFed, duration: 20 })
        .with(Attributes{
            might: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) },
            fitness: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) },
            quickness: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) },
            intelligence: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) },
        })
        .with(skills)
        .marked::<SimpleMarker<SerializeMe>>()
        .build()
}
}

对于 mob,我们也将假定每个技能的技能等级为 1,除非另有说明。在 raws/mob_structs.rs 中,更新 Mob

#![allow(unused)]
fn main() {
#[derive(Deserialize, Debug)]
pub struct Mob {
    pub name : String,
    pub renderable : Option<Renderable>,
    pub blocks_tile : bool,
    pub stats : MobStats,
    pub vision_range : i32,
    pub ai : String,
    pub quips : Option<Vec<String>>,
    pub attributes : MobAttributes,
    pub skills : Option<HashMap<String, i32>>
}
}

这允许我们在许多情况下完全省略它(没有非默认技能),这将避免在我们忘记给他们技能时给 mob 带来惩罚! mob 可以覆盖技能,如果我们愿意的话。它们必须与 HashMap 结构对齐。让我们在 spawns.json 中给我们的酒保一个技能奖励(它不会任何事情,但它可以作为一个例子):

{
    "name" : "Barkeep",
    "renderable": {
        "glyph" : "☺",
        "fg" : "#EE82EE",
        "bg" : "#000000",
        "order" : 1
    },
    "blocks_tile" : true,
    "stats" : {
        "max_hp" : 16,
        "hp" : 16,
        "defense" : 1,
        "power" : 4
    },
    "vision_range" : 4,
    "ai" : "vendor",
    "attributes" : {
        "intelligence" : 13
    },
    "skills" : {
        "Melee" : 2
    }
},

让我们修改我们的 raws/rawmaster.rsspawn_named_mob 函数来使用这些数据:

#![allow(unused)]
fn main() {
let mut skills = Skills{ skills: HashMap::new() };
skills.skills.insert(Skill::Melee, 1);
skills.skills.insert(Skill::Defense, 1);
skills.skills.insert(Skill::Magic, 1);
if let Some(mobskills) = &mob_template.skills {
    for sk in mobskills.iter() {
        match sk.0.as_str() {
            "Melee" => { skills.skills.insert(Skill::Melee, *sk.1); }
            "Defense" => { skills.skills.insert(Skill::Defense, *sk.1); }
            "Magic" => { skills.skills.insert(Skill::Magic, *sk.1); }
            _ => { rltk::console::log(format!("Unknown skill referenced: [{}]", sk.0)); }
        }
    }
}
eb = eb.with(skills);
}

将等级、经验和生命值设为组件,添加法力值

components.rs 中,继续添加另一个组件(然后在 main.rssaveload_system.rs 中注册它):

#![allow(unused)]
fn main() {
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Pool {
    pub max: i32,
    pub current: i32
}

#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct Pools {
    pub hit_points : Pool,
    pub mana : Pool,
    pub xp : i32,
    pub level : i32
}
}

这里有很多内容:

  • 我们创建了一个新类型来表示可消耗的资源,Pool(池)。池具有最大值和当前值。这表示受伤或耗尽魔法力量;最大值不变,但当前值会波动。
  • 我们使用 Pool 来存储 hit_points(生命值)和 mana(法力值)。
  • 我们还存储 xp,表示“经验值 (Experience Points)”。
  • 我们存储 level(等级),表示你(或 NPC)的等级。

我们应该为这些定义一些默认值,并确定你的属性如何影响它们。在 gamesystem.rs 中,我们将使用以下函数:

#![allow(unused)]
fn main() {
pub fn attr_bonus(value: i32) -> i32 {
    (value-10)/2 // 参见:https://roll20.net/compendium/dnd5e/Ability%20Scores#content
}

pub fn player_hp_per_level(fitness: i32) -> i32 {
    10 + attr_bonus(fitness)
}

pub fn player_hp_at_level(fitness:i32, level:i32) -> i32 {
    player_hp_per_level(fitness) * level
}

pub fn npc_hp(fitness: i32, level: i32) -> i32 {
    let mut total = 1;
    for _i in 0..level {
        total += i32::max(1, 8 + attr_bonus(fitness));
    }
    total
}

pub fn mana_per_level(intelligence: i32) -> i32 {
    i32::max(1, 4 + attr_bonus(intelligence))
}

pub fn mana_at_level(intelligence: i32, level: i32) -> i32 {
    mana_per_level(intelligence) * level
}

pub fn skill_bonus(skill : Skill, skills: &Skills) -> i32 {
    if skills.skills.contains_key(&skill) {
        skills.skills[&skill]
    } else {
        -4
    }
}
}

如果您一直在关注,这些应该非常容易理解:玩家每级获得 10 点生命值,并由他们的体魄属性修正。NPC 每级获得 8 点,也由体魄修正——每级至少 1 点(对于糟糕的掷骰结果)。

因此,在 spawner.rs 中,我们可以修改 player 函数来为初始角色填充这些池:

#![allow(unused)]
fn main() {
.with(Pools{
    hit_points : Pool{
        current: player_hp_at_level(11, 1),
        max: player_hp_at_level(11, 1)
    },
    mana: Pool{
        current: mana_at_level(11, 1),
        max: mana_at_level(11, 1)
    },
    xp: 0,
    level: 1
})
}

同样,我们需要让 NPC 能够拥有池。至少,我们必须在他们的定义中添加一个 level 属性——但让我们使其可选,如果省略则默认为 1(这样您就不需要修改每个普通的 NPC!)。我们还将使 hpmana 字段可选——这样您就可以使用随机默认值,或者为重要的怪物覆盖它们。这是调整后的 raws/mob_structs.rs

#![allow(unused)]
fn main() {
#[derive(Deserialize, Debug)]
pub struct Mob {
    pub name : String,
    pub renderable : Option<Renderable>,
    pub blocks_tile : bool,
    pub vision_range : i32,
    pub ai : String,
    pub quips : Option<Vec<String>>,
    pub attributes : MobAttributes,
    pub skills : Option<HashMap<String, i32>>,
    pub level : Option<i32>,
    pub hp : Option<i32>,
    pub mana : Option<i32>
}
}

我们还应该修改 spawn_named_mob(来自 raws/rawmaster.rs)以包含此内容:

#![allow(unused)]
fn main() {
let mut mob_fitness = 11;
let mut mob_int = 11;
let mut attr = Attributes{
    might: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) },
    fitness: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) },
    quickness: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) },
    intelligence: Attribute{ base: 11, modifiers: 0, bonus: attr_bonus(11) },
};
if let Some(might) = mob_template.attributes.might {
    attr.might = Attribute{ base: might, modifiers: 0, bonus: attr_bonus(might) };
}
if let Some(fitness) = mob_template.attributes.fitness {
    attr.fitness = Attribute{ base: fitness, modifiers: 0, bonus: attr_bonus(fitness) };
    mob_fitness = fitness;
}
if let Some(quickness) = mob_template.attributes.quickness {
    attr.quickness = Attribute{ base: quickness, modifiers: 0, bonus: attr_bonus(quickness) };
}
if let Some(intelligence) = mob_template.attributes.intelligence {
    attr.intelligence = Attribute{ base: intelligence, modifiers: 0, bonus: attr_bonus(intelligence) };
    mob_int = intelligence;
}
eb = eb.with(attr);

let mob_level = if mob_template.level.is_some() { mob_template.level.unwrap() } else { 1 };
let mob_hp = npc_hp(mob_fitness, mob_level);
let mob_mana = mana_at_level(mob_int, mob_level);

let pools = Pools{
    level: mob_level,
    xp: 0,
    hit_points : Pool{ current: mob_hp, max: mob_hp },
    mana: Pool{current: mob_mana, max: mob_mana}
};
eb = eb.with(pools);
}

我们在构建 NPC 时捕获了相关的属性,并调用了新函数来帮助构建 NPC 的池。

是时候破坏东西了:删除旧的属性!

components.rs 中,删除 CombatStats。您还需要在 main.rssaveload_system.rs 中删除它。注意您的 IDE 将整个城镇都涂成红色——我们已经用了很多次了!由于我们正在制定一个新的类似 D&D 的系统,所以必须这样做……这也让我们有机会查看我们实际使用它的地方,并做出一些明智的决定。

如果您不想直接遵循所有这些更改,或者感到困惑(我们所有人都会发生这种情况!),本章的源代码 包含可用的版本。

以下是更简单的更改:

  • mob_structs.rs 中,您可以删除 MobStats 及其在 Mob 中的引用。
  • rawmaster.rs 中,删除将 CombatStats 分配给 NPC 的代码。
  • spawns.json 中,您可以删除所有属性块。
  • damage_system.rs 中,将所有对 CombatStats 的引用替换为 Pools,并将所有对 stats.hp 的引用替换为 stats.hit_points.currentstats.hit_points.max(对于 max_hp)。
  • inventory_system.rs 中,将所有对 CombatStats 的引用替换为 Pools,并将引用 max_hphp 的行替换为 stats.hit_points = i32::min(stats.hit_points.max, stats.hit_points.current + healer.heal_amount);

以及不太容易的更改:

player.rs 中,将 CombatStats 替换为 Pools——它将起到相同的作用。此外,找到 can_heal 部分并将其替换为:

#![allow(unused)]
fn main() {
if can_heal {
    let mut health_components = ecs.write_storage::<Pools>();
    let pools = health_components.get_mut(*player_entity).unwrap();
    pools.hit_points.current = i32::min(pools.hit_points.current + 1, pools.hit_points.max);
}
}

main.rs(第 345 行)中,我们引用了玩家的生命值——他们已经改变了地下城等级,我们给他们恢复了一些生命值。让我们不要那么好心,完全删除它。现在不友好的代码看起来像这样:

#![allow(unused)]
fn main() {
let player_entity = self.ecs.fetch::<Entity>();
let mut gamelog = self.ecs.fetch_mut::<gamelog::GameLog>();
gamelog.entries.push("You descend to the next level.".to_string());
}

gui.rs 是一个简单的修复。将 CombatStats 的导入替换为 Pools;这是相关的部分:

#![allow(unused)]
fn main() {
...
use super::{Pools, Player, gamelog::GameLog, Map, Name, Position, State, InBackpack,
    Viewshed, RunState, Equipped, HungerClock, HungerState, rex_assets::RexAssets,
    Hidden, camera };

pub fn draw_ui(ecs: &World, ctx : &mut Rltk) {
    ctx.draw_box(0, 43, 79, 6, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK));

    let combat_stats = ecs.read_storage::<Pools>();
    let players = ecs.read_storage::<Player>();
    let hunger = ecs.read_storage::<HungerClock>();
    for (_player, stats, hc) in (&players, &combat_stats, &hunger).join() {
        let health = format!(" HP: {} / {} ", stats.hp, stats.max_hp);
        ctx.print_color(12, 43, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), &health);

        ctx.draw_bar_horizontal(28, 43, 51, stats.hit_points.current, stats.hit_points.max, RGB::named(rltk::RED), RGB::named(rltk::BLACK));
...
}

更新近战战斗系统。

我们只剩下一个“红色文件”(有错误的文件),在 melee_combat_system.rs 中,但它们现在与依赖于旧系统的核心游戏系统有关。我们希望使其更像 D20 (D&D) 游戏,因此无论如何都应该替换它们。

这意味着是时候讨论我们想要的战斗系统了。让我们采用非常类似 D&D 的(但又不完全是)设置:

  1. 我们查看攻击者正在使用什么武器。我们需要确定它是基于力量 (Might) 还是迅捷 (Quickness)。如果您是徒手,我们将使用力量 (Might)
  2. 攻击者掷出 1d20(一个 20 面骰子)。
  3. 如果掷出的点数是自然值、未修改的 20,则总是命中。
  4. 自然值 1 总是未命中。
  5. 攻击者根据武器添加力量或迅捷的属性奖励值
  6. 攻击者添加技能奖励值,等于在近战 (Melee) 技能上花费的点数。
  7. 攻击者添加武器本身赋予的任何奖励(以防它是魔法武器)。
  8. 攻击者添加任何情境或状态奖励,这些奖励尚未实现,但最好记住。
  9. 如果总攻击掷骰值等于或大于目标的护甲等级 (armor class),则目标被击中并将受到伤害。

护甲等级由以下因素决定:

  1. 从基础数字 10 开始。
  2. 添加防御 (Defense) 技能。
  3. 添加装备的护甲(尚未实现!和盾牌)的护甲奖励值

然后根据近战武器确定伤害:

  1. 武器将指定骰子类型和奖励(例如 1d6+1);如果没有装备武器,则徒手战斗造成 1d4 伤害。
  2. 添加攻击者的力量 (Might) 奖励值。
  3. 添加攻击者的近战 (Melee) 奖励值。

现在我们已经定义了它应该如何工作,我们可以开始实现它了。在我们改进一些装备之前,它将是不完整的——但至少我们可以让它编译。

这是一个替换 melee_combat_system.rs,它执行了我们描述的操作:

#![allow(unused)]
fn main() {
use specs::prelude::*;
use super::{Attributes, Skills, WantsToMelee, Name, SufferDamage, gamelog::GameLog,
    particle_system::ParticleBuilder, Position, HungerClock, HungerState, Pools, skill_bonus, Skill};

pub struct MeleeCombatSystem {}

impl<'a> System<'a> for MeleeCombatSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( Entities<'a>,
                        WriteExpect<'a, GameLog>,
                        WriteStorage<'a, WantsToMelee>,
                        ReadStorage<'a, Name>,
                        ReadStorage<'a, Attributes>,
                        ReadStorage<'a, Skills>,
                        WriteStorage<'a, SufferDamage>,
                        WriteExpect<'a, ParticleBuilder>,
                        ReadStorage<'a, Position>,
                        ReadStorage<'a, HungerClock>,
                        ReadStorage<'a, Pools>,
                        WriteExpect<'a, rltk::RandomNumberGenerator>
                      );

    fn run(&mut self, data : Self::SystemData) {
        let (entities, mut log, mut wants_melee, names, attributes, skills, mut inflict_damage,
            mut particle_builder, positions, hunger_clock, pools, mut rng) = data;

        for (entity, wants_melee, name, attacker_attributes, attacker_skills, attacker_pools) in (&entities, &wants_melee, &names, &attributes, &skills, &pools).join() {
            // 攻击者和防御者都活着吗? 只有当他们都活着时才攻击
            let target_pools = pools.get(wants_melee.target).unwrap();
            let target_attributes = attributes.get(wants_melee.target).unwrap();
            let target_skills = skills.get(wants_melee.target).unwrap();
            if attacker_pools.hit_points.current > 0 && target_pools.hit_points.current > 0 {
                let target_name = names.get(wants_melee.target).unwrap();

                let natural_roll = rng.roll_dice(1, 20);
                let attribute_hit_bonus = attacker_attributes.might.bonus;
                let skill_hit_bonus = skill_bonus(Skill::Melee, &*attacker_skills);
                let weapon_hit_bonus = 0; // TODO: 一旦武器支持这个
                let mut status_hit_bonus = 0;
                if let Some(hc) = hunger_clock.get(entity) { // 吃饱喝足状态给予 +1
                    if hc.state == HungerState::WellFed {
                        status_hit_bonus += 1;
                    }
                }
                let modified_hit_roll = natural_roll + attribute_hit_bonus + skill_hit_bonus
                    + weapon_hit_bonus + status_hit_bonus;

                let base_armor_class = 10;
                let armor_quickness_bonus = target_attributes.quickness.bonus;
                let armor_skill_bonus = skill_bonus(Skill::Defense, &*target_skills);
                let armor_item_bonus = 0; // TODO: 一旦护甲支持这个
                let armor_class = base_armor_class + armor_quickness_bonus + armor_skill_bonus
                    + armor_item_bonus;

                if natural_roll != 1 && (natural_roll == 20 || modified_hit_roll > armor_class) {
                    // 目标被击中! 在我们支持武器之前,我们使用 1d4
                    let base_damage = rng.roll_dice(1, 4);
                    let attr_damage_bonus = attacker_attributes.might.bonus;
                    let skill_damage_bonus = skill_bonus(Skill::Melee, &*attacker_skills);
                    let weapon_damage_bonus = 0;

                    let damage = i32::max(0, base_damage + attr_damage_bonus + skill_hit_bonus +
                        skill_damage_bonus + weapon_damage_bonus);
                    SufferDamage::new_damage(&mut inflict_damage, wants_melee.target, damage);
                    log.entries.push(format!("{} hits {}, for {} hp.", &name.name, &target_name.name, damage));
                    if let Some(pos) = positions.get(wants_melee.target) {
                        particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::ORANGE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0);
                    }
                } else  if natural_roll == 1 {
                    // 自然值 1 未命中
                    log.entries.push(format!("{} considers attacking {}, but misjudges the timing.", name.name, target_name.name));
                    if let Some(pos) = positions.get(wants_melee.target) {
                        particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::BLUE), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0);
                    }
                } else {
                    // 未命中
                    log.entries.push(format!("{} attacks {}, but can't connect.", name.name, target_name.name));
                    if let Some(pos) = positions.get(wants_melee.target) {
                        particle_builder.request(pos.x, pos.y, rltk::RGB::named(rltk::CYAN), rltk::RGB::named(rltk::BLACK), rltk::to_cp437('‼'), 200.0);
                    }
                }
            }
        }

        wants_melee.clear();
    }
}
}

这是一大段代码,但它与我们创建的轮廓非常相似。让我们逐步了解一下:

  1. 我们仔细定义了我们需要访问的许多 ECS 资源。
  2. 我们迭代所有想要近战、并且具有名称、技能、属性和池的实体。
  3. 我们获取目标的技能和池。
  4. 我们检查攻击者和目标是否都活着,如果他们没有活着就跳过。
  5. 我们获取目标的名称,我们将在日志记录中需要它。
  6. 我们通过掷出 1d20 获得 natural_roll(自然掷骰值)。
  7. 我们计算属性命中奖励值,引用攻击者的力量 (Might) 奖励值。
  8. 我们计算技能命中奖励值,引用攻击者的近战 (Melee) 技能。
  9. 我们将 weapon_hit_bonus(武器命中奖励值)设置为 0,因为我们尚未实现它。
  10. 我们查看攻击者是否处于吃饱喝足状态,如果是,则给予他们 +1 情境奖励。
  11. 我们现在可以通过添加步骤 6 到 10 来计算 modified_hit_roll(修正后的命中掷骰值)。
  12. 我们将 base_armor_class(基础护甲等级)设置为 10。
  13. 我们从目标获取迅捷奖励值,并将其设置为 armor_quickness_bonus(护甲迅捷奖励值)。
  14. 我们从目标获取防御 (Defense) 技能,并将其设置为 armor_skill_bonus(护甲技能奖励值)。
  15. 我们设置 armor_item_bonus(护甲物品奖励值),因为我们尚未实现它。
  16. 我们通过添加步骤 12 到 15 来计算 armor_class(护甲等级)。
  17. 如果 natural_roll(自然掷骰值)不等于 1,并且是 20 或 modified_hit_roll(修正后的命中掷骰值)大于或等于 armor_class(护甲等级)——那么攻击者就命中了:
    1. 由于我们尚未正确支持战斗物品,因此我们掷 1d4 作为基础伤害。
    2. 我们添加攻击者的力量 (Might) 奖励值。
    3. 我们添加攻击者的近战 (Melee) 技能。
    4. 我们向伤害系统发送 inflict_damage(造成伤害)消息,记录攻击,并播放橙色粒子效果。
  18. 如果 natural_roll(自然掷骰值)为 1,我们会提到这是一个壮观的失误并显示蓝色粒子效果。
  19. 否则,这是一个普通的失误——播放青色粒子效果并记录失误。

最后,让我们打开 spawns.json 并使老鼠非常弱。否则,在没有装备的情况下,当您找到它们时,您会被直接杀死:

{
    "name" : "Rat",
    "renderable": {
        "glyph" : "r",
        "fg" : "#FF0000",
        "bg" : "#000000",
        "order" : 1
    },
    "blocks_tile" : true,
    "vision_range" : 8,
    "ai" : "melee",
    "attributes" : {
        "Might" : 3,
        "Fitness" : 3
    },
    "skills" : {
        "Melee" : -1,
        "Defense" : -1
    }
},

您现在可以 cargo run,慢跑到下楼梯处(它会在右边)或找到废弃的房屋,并进行战斗!由于尚未实现物品,因此内容仍然会相当缺乏——但基础知识已经存在,您可以亲眼看到“d20”系统的运作。战斗不再那么确定性,并且可能具有一些真正的紧张感,而不是“国际象棋般的紧张感”。

Screenshot

总结

我们现在已经实现了游戏属性和一个简单的类似 D&D 的近战系统。还有更多的事情要做,我们将在下一章中进入下一个阶段——装备。

本章的源代码可以在这里找到

使用 WebAssembly 在您的浏览器中运行本章的示例(需要 WebGL2)

版权所有 (C) 2019, Herbert Wolverson。