物品属性


关于本教程

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

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

Hands-On Rust


在上一章中,我们讨论了使用先攻值来使重型盔甲具有移动成本,并使某些武器比其他武器更快。 设计文档还提到了商人。 最后,哪个 RPG/roguelike 游戏在物品栏管理方面,没有烦人的“你超重了”消息(以及随之而来的速度惩罚)是不完整的呢? 这些功能都指向一个方向:额外的物品统计数据,并将它们整合到游戏系统中。

定义物品信息

我们已经有了一个名为 Item 的组件; 所有物品都已经拥有它,因此它似乎是添加此信息的理想场所! 打开 components.rs,我们将编辑 Item 结构以包含先攻惩罚、负重和商人所需的信息:

#![allow(unused)]
fn main() {
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct Item {
    pub initiative_penalty : f32,
    pub weight_lbs : f32,
    pub base_value : f32
}
}

所以我们定义了一个 initiative_penalty(先攻惩罚) - 当装备(或在武器的情况下使用)时,它将被添加到你的先攻掷骰中以减慢你的速度; weight_lbs(重量-磅) - 定义物品的重量,以磅为单位; 以及 base_value(基础价值) - 物品以金币为单位的基础价格(十进制,因此我们也可以允许银币)。

我们需要一种输入此信息的方法,因此我们打开 raws/item_structs.rs 并编辑 Item 结构:

#![allow(unused)]
fn main() {
#[derive(Deserialize, Debug)]
pub struct Item {
    pub name : String,
    pub renderable : Option<Renderable>,
    pub consumable : Option<Consumable>,
    pub weapon : Option<Weapon>,
    pub wearable : Option<Wearable>,
    pub initiative_penalty : Option<f32>,
    pub weight_lbs : Option<f32>,
    pub base_value : Option<f32>
}
}

请注意,我们将这些设为可选的 - 如果您没有在 spawns.json 文件中定义它们,它们将默认为零。 最后,我们需要修复 raws/rawmaster.rsspawn_named_item 函数以加载这些值。 将添加 Item 的行替换为:

#![allow(unused)]
fn main() {
eb = eb.with(crate::components::Item{
    initiative_penalty : item_template.initiative_penalty.unwrap_or(0.0),
    weight_lbs : item_template.weight_lbs.unwrap_or(0.0),
    base_value : item_template.base_value.unwrap_or(0.0)
});
}

这里利用了 Optionunwrap_or 函数 - 它要么返回包装的值(如果存在),要么返回 0.0。 方便的功能,可以节省打字!

这些值在您进入 spawns.json 并开始添加它们之前不会存在。 我一直在从 roll20 compendium 中获取重量和价值的值,并凭空捏造先攻惩罚的数字。 我已将它们输入到 源代码 中,而不是在这里重复所有内容。 这是一个例子:

{
    "name" : "Longsword",
    "renderable": {
        "glyph" : "/",
        "fg" : "#FFAAFF",
        "bg" : "#000000",
        "order" : 2
    },
    "weapon" : {
        "range" : "melee",
        "attribute" : "Might",
        "base_damage" : "1d8",
        "hit_bonus" : 0
    },
    "weight_lbs" : 3.0,
    "base_value" : 15.0,
    "initiative_penalty" : 2
},

计算负重和先攻惩罚

一个简单的方法是循环遍历每个实体,并在每回合中计算它们的总重量和先攻惩罚。 这样做的问题是,它可能相当慢; 很多 实体都有装备(他们中的大多数!),而我们实际上只需要在某些东西发生变化时重新计算它。 我们使用与可见性相同的方法,通过标记它为“dirty”(脏的/需要更新的)。 因此,让我们首先扩展 Pools 以包含两个字段来表示总数。 在 components.rs 中:

#![allow(unused)]
fn main() {
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct Pools {
    pub hit_points : Pool,
    pub mana : Pool,
    pub xp : i32,
    pub level : i32,
    pub total_weight : f32,
    pub total_initiative_penalty : f32
}
}

您需要打开 spawner.rs 并将这些字段添加到 Player 的初始 Pools 设置中(我们将使用零并依靠计算它):

#![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,
    total_weight : 0.0,
    total_initiative_penalty : 0.0
})
}

同样,在 rawmaster.rs 中,spawn_named_mob 需要在其 Pools 初始化中获得这些字段:

#![allow(unused)]
fn main() {
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},
    total_weight : 0.0,
    total_initiative_penalty : 0.0
};
eb = eb.with(pools);
}

现在,我们需要一种方法来向游戏指示装备已更改。 这可能由于各种原因而发生,因此我们希望尽可能通用! 打开 components.rs,并创建一个新的“tag”(标签)组件(然后在 main.rssaveload_system.rs 中注册它):

#![allow(unused)]
fn main() {
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct EquipmentChanged {}
}

打开 spawner.rs,我们将开始玩家的生命时应用此标签:

#![allow(unused)]
fn main() {
.with(EquipmentChanged{})
}

同样,在 rawmaster.rsspawn_named_mob 中,我们将做同样的事情:

#![allow(unused)]
fn main() {
eb = eb.with(EquipmentChanged{});
}

现在,我们将创建一个新的系统来计算这个。 创建一个新文件 ai/encumbrance_system.rs(并在 ai/mod.rs 中包含 modpub use 语句):

#![allow(unused)]
fn main() {
use specs::prelude::*;
use crate::{EquipmentChanged, Item, InBackpack, Equipped, Pools, Attributes, gamelog::GameLog};
use std::collections::HashMap;

pub struct EncumbranceSystem {}

impl<'a> System<'a> for EncumbranceSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = (
        WriteStorage<'a, EquipmentChanged>,
        Entities<'a>,
        ReadStorage<'a, Item>,
        ReadStorage<'a, InBackpack>,
        ReadStorage<'a, Equipped>,
        WriteStorage<'a, Pools>,
        ReadStorage<'a, Attributes>,
        ReadExpect<'a, Entity>,
        WriteExpect<'a, GameLog>
    );

    fn run(&mut self, data : Self::SystemData) {
        let (mut equip_dirty, entities, items, backpacks, wielded,
            mut pools, attributes, player, mut gamelog) = data;

        if equip_dirty.is_empty() { return; }

        // 构建需要更新的地图
        let mut to_update : HashMap<Entity, (f32, f32)> = HashMap::new(); // (重量, 先攻值)
        for (entity, _dirty) in (&entities, &equip_dirty).join() {
            to_update.insert(entity, (0.0, 0.0));
        }

        // 移除所有 dirty 声明
        equip_dirty.clear();

        // 统计装备物品
        for (item, equipped) in (&items, &wielded).join() {
            if to_update.contains_key(&equipped.owner) {
                let totals = to_update.get_mut(&equipped.owner).unwrap();
                totals.0 += item.weight_lbs;
                totals.1 += item.initiative_penalty;
            }
        }

        // 统计携带物品
        for (item, carried) in (&items, &backpacks).join() {
            if to_update.contains_key(&carried.owner) {
                let totals = to_update.get_mut(&carried.owner).unwrap();
                totals.0 += item.weight_lbs;
                totals.1 += item.initiative_penalty;
            }
        }

        // 将数据应用到 Pools
        for (entity, (weight, initiative)) in to_update.iter() {
            if let Some(pool) = pools.get_mut(*entity) {
                pool.total_weight = *weight;
                pool.total_initiative_penalty = *initiative;

                if let Some(attr) = attributes.get(*entity) {
                    let carry_capacity_lbs = (attr.might.base + attr.might.modifiers) * 15;
                    if pool.total_weight as i32 > carry_capacity_lbs {
                        // Overburdened (超重)
                        pool.total_initiative_penalty += 4.0;
                        if *entity == *player {
                            gamelog.entries.push("You are overburdened, and suffering an initiative penalty.".to_string());
                        }
                    }
                }
            }
        }
    }
}
}

让我们逐步了解它的作用:

  1. 如果我们不在 Ticking 运行状态,我们将返回(在等待输入时无需保持循环!)。
  2. 如果没有任何 EquipmentChanged 条目,我们将返回(如果没有什么可做的,则无需进行额外的工作)。
  3. 我们循环遍历所有带有 EquipmentChanged 条目的实体,并将它们存储在 to_update HashMap 中,以及重量和先攻值的零值。
  4. 我们移除所有 EquipmentChanged 标签。
  5. 我们循环遍历所有装备的物品。 如果它们的所有者在 to_update 列表中,我们将每个物品的重量和惩罚添加到该实体在 to_update 映射中的总数。
  6. 我们循环遍历所有携带的物品并执行相同的操作。
  7. 我们迭代 to_update 列表,使用解构使其易于使用友好的名称访问字段。
    1. 对于每个更新的实体,我们尝试获取它们的 Pools 组件(如果无法获取则跳过)。
    2. 我们将 pool 的 total_weighttotal_initiative_penalty 设置为我们构建的总数。
    3. 我们查看实体是否具有 Might 属性; 如果他们有,我们将总携带能力计算为每点力量 15 磅(就像 D&D!)。
    4. 如果他们超过了他们的携带能力,我们会对他们处以额外的 4 点先攻惩罚(哎哟)。 如果是玩家,我们在日志文件中宣布他们的超重状态。

我们还需要在 run_systems(在 main.rs 中)中调用系统。 将其放在调用先攻系统之前:

#![allow(unused)]
fn main() {
let mut encumbrance = ai::EncumbranceSystem{};
encumbrance.run_now(&self.ecs);
}

如果您现在 cargo run,它将计算每个人的负重 - 一次,且仅一次! 我们在更改后没有添加 EquipmentChanged 标签。 我们需要更新 inventory_system.rs,以便拾取、掉落和使用物品(可能会销毁它们)触发更新。

拾取物品的系统是一个非常简单的更改:

#![allow(unused)]
fn main() {
impl<'a> System<'a> for ItemCollectionSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( ReadExpect<'a, Entity>,
                        WriteExpect<'a, GameLog>,
                        WriteStorage<'a, WantsToPickupItem>,
                        WriteStorage<'a, Position>,
                        ReadStorage<'a, Name>,
                        WriteStorage<'a, InBackpack>,
                        WriteStorage<'a, EquipmentChanged>
                      );

    fn run(&mut self, data : Self::SystemData) {
        let (player_entity, mut gamelog, mut wants_pickup, mut positions, names,
            mut backpack, mut dirty) = data;

        for pickup in wants_pickup.join() {
            positions.remove(pickup.item);
            backpack.insert(pickup.item, InBackpack{ owner: pickup.collected_by }).expect("Unable to insert backpack entry");
            dirty.insert(pickup.collected_by, EquipmentChanged{}).expect("Unable to insert");

            if pickup.collected_by == *player_entity {
                gamelog.entries.push(format!("You pick up the {}.", names.get(pickup.item).unwrap().name));
            }
        }

        wants_pickup.clear();
    }
}
}

对于使用物品,我们做了几乎相同的事情:

#![allow(unused)]
fn main() {
impl<'a> System<'a> for ItemUseSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( ReadExpect<'a, Entity>,
                        WriteExpect<'a, GameLog>,
                        WriteExpect<'a, Map>,
                        Entities<'a>,
                        WriteStorage<'a, WantsToUseItem>,
                        ReadStorage<'a, Name>,
                        ReadStorage<'a, Consumable>,
                        ReadStorage<'a, ProvidesHealing>,
                        ReadStorage<'a, InflictsDamage>,
                        WriteStorage<'a, Pools>,
                        WriteStorage<'a, SufferDamage>,
                        ReadStorage<'a, AreaOfEffect>,
                        WriteStorage<'a, Confusion>,
                        ReadStorage<'a, Equippable>,
                        WriteStorage<'a, Equipped>,
                        WriteStorage<'a, InBackpack>,
                        WriteExpect<'a, ParticleBuilder>,
                        ReadStorage<'a, Position>,
                        ReadStorage<'a, ProvidesFood>,
                        WriteStorage<'a, HungerClock>,
                        ReadStorage<'a, MagicMapper>,
                        WriteExpect<'a, RunState>,
                        WriteStorage<'a, EquipmentChanged>
                      );

    #[allow(clippy::cognitive_complexity)]
    fn run(&mut self, data : Self::SystemData) {
        let (player_entity, mut gamelog, map, entities, mut wants_use, names,
            consumables, healing, inflict_damage, mut combat_stats, mut suffer_damage,
            aoe, mut confused, equippable, mut equipped, mut backpack, mut particle_builder, positions,
            provides_food, mut hunger_clocks, magic_mapper, mut runstate, mut dirty) = data;

        for (entity, useitem) in (&entities, &wants_use).join() {
            dirty.insert(entity, EquipmentChanged{});
            ...
}

对于掉落物品:

#![allow(unused)]
fn main() {
impl<'a> System<'a> for ItemDropSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( ReadExpect<'a, Entity>,
                        WriteExpect<'a, GameLog>,
                        Entities<'a>,
                        WriteStorage<'a, WantsToDropItem>,
                        ReadStorage<'a, Name>,
                        WriteStorage<'a, Position>,
                        WriteStorage<'a, InBackpack>,
                        WriteStorage<'a, EquipmentChanged>
                      );

    fn run(&mut self, data : Self::SystemData) {
        let (player_entity, mut gamelog, entities, mut wants_drop, names, mut positions,
            mut backpack, mut dirty) = data;

        for (entity, to_drop) in (&entities, &wants_drop).join() {
            let mut dropper_pos : Position = Position{x:0, y:0};
            {
                let dropped_pos = positions.get(entity).unwrap();
                dropper_pos.x = dropped_pos.x;
                dropper_pos.y = dropped_pos.y;
            }
            positions.insert(to_drop.item, Position{ x : dropper_pos.x, y : dropper_pos.y }).expect("Unable to insert position");
            backpack.remove(to_drop.item);
            dirty.insert(entity, EquipmentChanged{}).expect("Unable to insert");

            if entity == *player_entity {
                gamelog.entries.push(format!("You drop the {}.", names.get(to_drop.item).unwrap().name));
            }
        }

        wants_drop.clear();
    }
}
}

如果您 cargo run,您可以在调试器中看到修改器正在生效。

向玩家展示正在发生的事情

然而 - 您的玩家不太可能运行调试器! 我们应该让玩家看到他们行为的效果,以便他们可以相应地计划。 我们将修改 gui.rs 中的用户界面(函数 draw_ui)以实际显示玩家正在发生的事情。

首先,我们将装备物品列表(以及其下方的热键)向下移动四行(示例源代码的第 99 行):

#![allow(unused)]
fn main() {
// Equipped (已装备)
let mut y = 13;
}

为什么要移动四行? 这样我们就可以有一些空白,一行用于显示先攻值,一行用于显示重量,以及未来一行用于显示金钱,当我们实现金钱系统的时候! 让我们实际打印信息。 在 // Equipped 注释之前:

#![allow(unused)]
fn main() {
// Initiative and weight (先攻值和重量)
ctx.print_color(50, 9, white, black,
    &format!("{:.0} lbs ({} lbs max)",
        player_pools.total_weight,
        (attr.might.base + attr.might.modifiers) * 15
    )
);
ctx.print_color(50,10, white, black, &format!("Initiative Penalty: {:.0}", player_pools.total_initiative_penalty));
}

请注意,format! 宏对占位符使用了 {:.0}; 这告诉 Rust 格式化为零位小数(它是一个浮点数)。 如果您现在 cargo run,您将看到我们正在显示我们的总数。 如果您掉落物品,总数会发生变化:

Screenshot

实际更新先攻值

我们遗漏了一个相当重要的步骤:实际使用先攻惩罚! 打开 ai/initiative_system.rs,我们将纠正这个问题。 还记得我们留在那里的 TODO 语句吗? 现在我们有东西可以放在那里了! 首先,我们将 Pools 添加到可用的读取资源中:

#![allow(unused)]
fn main() {
impl<'a> System<'a> for InitiativeSystem {
    #[allow(clippy::type_complexity)]
    type SystemData = ( WriteStorage<'a, Initiative>,
                        ReadStorage<'a, Position>,
                        WriteStorage<'a, MyTurn>,
                        Entities<'a>,
                        WriteExpect<'a, rltk::RandomNumberGenerator>,
                        ReadStorage<'a, Attributes>,
                        WriteExpect<'a, RunState>,
                        ReadExpect<'a, Entity>,
                        ReadExpect<'a, rltk::Point>,
                        ReadStorage<'a, Pools>);

    fn run(&mut self, data : Self::SystemData) {
        let (mut initiatives, positions, mut turns, entities, mut rng, attributes,
            mut runstate, player, player_pos, pools) = data;
            ...
}

然后,我们将当前的先攻惩罚总值添加到先攻值中:

#![allow(unused)]
fn main() {
// Apply pool penalty (应用 pool 惩罚)
if let Some(pools) = pools.get(entity) {
    initiative.current += f32::floor(pools.total_initiative_penalty) as i32;
}

// TODO: More initiative granting boosts/penalties will go here later (更多先攻值增益/惩罚将稍后在此处添加)
}

好的 - 先攻惩罚生效了! 您可以玩一会儿游戏,看看这些值如何影响游戏玩法。 您已经使更大/更具破坏性的武器会产生速度惩罚(以及更重的盔甲),因此现在实体装备越多 - 它们移动得越慢。 这为游戏应用了一些平衡性; 快速的匕首使用者相对于较慢的、穿着盔甲的长剑使用者可以获得更多的打击次数。 装备选择不再仅仅是获得最大的奖励 - 它还会影响速度/重量。 换句话说,这是一个平衡行为 - 为玩家提供多种方式来优化“他们的 build”(如果您让人们发布关于您游戏的“build”,请庆祝:这意味着他们真的在享受它!)。

关于现金的一切

我们已经向物品添加了 base_value 字段,但没有对其做任何事情。 实际上,我们根本没有货币的概念。 让我们使用简化的“金币”系统; 金币是主要数字(小数点前),银币是小数部分(10 银币兑换 1 金币)。 我们将不担心更小的硬币。

在许多方面,货币是一个 pool - 就像生命值和类似的东西一样。 你花费它,获得它,最好将其作为抽象数字处理,而不是试图追踪每枚硬币(尽管使用 ECS 完全有可能做到这一点!)。 因此,我们将进一步扩展 Pools 组件以指定金币:

#![allow(unused)]
fn main() {
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct Pools {
    pub hit_points : Pool,
    pub mana : Pool,
    pub xp : i32,
    pub level : i32,
    pub total_weight : f32,
    pub total_initiative_penalty : f32,
    pub gold : f32
}
}

将其应用于 pools 意味着玩家和所有 NPC 都可能拥有金币! 打开 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,
    total_weight : 0.0,
    total_initiative_penalty : 0.0,
    gold : 0.0
})
}

NPC 也应该携带金币,这样你就可以在他们被杀死时将他们从重商主义思想的负担中解放出来! 打开 raws/mob_structs.rs,我们将向 NPC 定义添加一个“gold”(金币)字段:

#![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 movement : 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>,
    pub equipped : Option<Vec<String>>,
    pub natural : Option<MobNatural>,
    pub loot_table : Option<String>,
    pub light : Option<MobLight>,
    pub faction : Option<String>,
    pub gold : Option<String>
}
}

我们将 gold 设置为 Option - 因此它不必存在(毕竟,为什么老鼠要携带现金?)。 我们也将其设置为 String - 因此它可以是骰子投掷结果,而不是特定数字。 让土匪总是掉落一个金币是很无聊的! 现在我们需要修改 rawmaster.rsspawn_named_mob 函数,以实际将金币应用于 NPC:

#![allow(unused)]
fn main() {
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},
    total_weight : 0.0,
    total_initiative_penalty : 0.0,
    gold : if let Some(gold) = &mob_template.gold {
            let mut rng = rltk::RandomNumberGenerator::new();
            let (n, d, b) = parse_dice_string(&gold);
            (rng.roll_dice(n, d) + b) as f32
        } else {
            0.0
        }
};
}

因此,我们告诉生成器:如果没有指定金币,则使用零。 否则,解析骰子字符串并掷骰子 - 并使用该数量的金币。

接下来,当玩家杀死某人时 - 我们应该掠夺他们的现金。 你几乎总是想捡起钱,所以没有真正的必要把它掉下来并让玩家记住去收集它。 在 damage_system.rs 中,首先在 xp_gain 旁边添加一个新的可变变量:

#![allow(unused)]
fn main() {
let mut xp_gain = 0;
let mut gold_gain = 0.0f32;
}

然后在我们添加 XP 的位置旁边:

#![allow(unused)]
fn main() {
if stats.hit_points.current < 1 && damage.from_player {
    xp_gain += stats.level * 100;
    gold_gain += stats.gold;
}
}

然后在我们更新玩家的 XP 时,我们也更新他们的金币:

#![allow(unused)]
fn main() {
if xp_gain != 0 || gold_gain != 0.0 {
    let mut player_stats = stats.get_mut(*player).unwrap();
    let player_attributes = attributes.get(*player).unwrap();
    player_stats.xp += xp_gain;
    player_stats.gold += gold_gain;
}

接下来,我们应该向玩家展示他们有多少金币。 再次打开 gui.rs,并在我们放入重量和先攻值的位置旁边再添加一行:

#![allow(unused)]
fn main() {
ctx.print_color(50,11, rltk::RGB::named(rltk::GOLD), black, &format!("Gold: {:.1}", player_pools.gold));
}

最后,我们应该给土匪一些金币。 在 spawns.json 中,打开 Bandit 条目并应用金币:

{
    "name" : "Bandit",
    "renderable": {
        "glyph" : "☻",
        "fg" : "#FF0000",
        "bg" : "#000000",
        "order" : 1
    },
    "blocks_tile" : true,
    "vision_range" : 6,
    "movement" : "random_waypoint",
    "quips" : [ "Stand and deliver!", "Alright, hand it over" ],
    "attributes" : {},
    "equipped" : [ "Dagger", "Shield", "Leather Armor", "Leather Boots" ],
    "light" : {
        "range" : 6,
        "color" : "#FFFF55"
    },
    "faction" : "Bandits",
    "gold" : "1d6"
},

(在 源代码 中,我也给地精、兽人和其他人形生物添加了金币。 你也应该这样做!)

如果您现在 cargo run,您将能够通过杀死敌人来获得金币(您还可以看到我在杀死土匪后给自己装备了物品,先攻值和重量会正确更新):

Screenshot

与商人交易

获得金币(并释放你的物品栏)的另一种好方法是将其出售给商人。 我们希望保持界面简单,因此我们希望走进商人会触发商人界面。 让我们修改 Barkeep 条目,以包含他 a) 是商人,b) 销售食物的注释。

{
    "name" : "Barkeep",
    "renderable": {
        "glyph" : "☻",
        "fg" : "#EE82EE",
        "bg" : "#000000",
        "order" : 1
    },
    "blocks_tile" : true,
    "vision_range" : 4,
    "movement" : "static",
    "attributes" : {
        "intelligence" : 13
    },
    "skills" : {
        "Melee" : 2
    },
    "equipped" : [ "Cudgel", "Cloth Tunic", "Cloth Pants", "Slippers" ],
    "faction" : "Townsfolk",
    "gold" : "2d6",
    "vendor" : [ "food" ]
},

我们需要更新 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 movement : 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>,
    pub equipped : Option<Vec<String>>,
    pub natural : Option<MobNatural>,
    pub loot_table : Option<String>,
    pub light : Option<MobLight>,
    pub faction : Option<String>,
    pub gold : Option<String>,
    pub vendor : Option<Vec<String>>
}
}

我们还需要创建一个 Vendor 组件。 您可能还记得我们之前有一个,但它与 AI 绑定 - 这次,它实际上旨在处理买卖。 将其添加到 components.rs(并在 main.rssaveload_system.rs 中注册):

#![allow(unused)]
fn main() {
#[derive(Component, Debug, Serialize, Deserialize, Clone)]
pub struct Vendor {
    pub categories : Vec<String>
}
}

rawmaster.rsspawn_named_mob 进行快速更改,使此组件附加到商人:

#![allow(unused)]
fn main() {
if let Some(vendor) = &mob_template.vendor {
    eb = eb.with(Vendor{ categories : vendor.clone() });
}
}

让我们打开 main.rs 并向 RunState 添加一个新状态:

#![allow(unused)]
fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum VendorMode { Buy, Sell }

#[derive(PartialEq, Copy, Clone)]
pub enum RunState {
    AwaitingInput,
    PreRun,
    Ticking,
    ShowInventory,
    ShowDropItem,
    ShowTargeting { range : i32, item : Entity},
    MainMenu { menu_selection : gui::MainMenuSelection },
    SaveGame,
    NextLevel,
    PreviousLevel,
    ShowRemoveItem,
    GameOver,
    MagicMapReveal { row : i32 },
    MapGeneration,
    ShowCheatMenu,
    ShowVendor { vendor: Entity, mode : VendorMode }
}
}

现在我们需要更新 player.rstry_move_player 函数,以便在我们走进商人时触发商人模式:

#![allow(unused)]
fn main() {
...
let vendors = ecs.read_storage::<Vendor>();
let mut result = RunState::AwaitingInput;

let mut swap_entities : Vec<(Entity, i32, i32)> = Vec::new();

for (entity, _player, pos, viewshed) in (&entities, &players, &mut positions, &mut viewsheds).join() {
    if pos.x + delta_x < 1 || pos.x + delta_x > map.width-1 || pos.y + delta_y < 1 || pos.y + delta_y > map.height-1 { return RunState::AwaitingInput; }
        let destination_idx = map.xy_idx(pos.x + delta_x, pos.y + delta_y);

        result = crate::spatial::for_each_tile_content_with_gamemode(destination_idx, |potential_target| {
            if let Some(_vendor) = vendors.get(potential_target) {
                return Some(RunState::ShowVendor{ vendor: potential_target, mode : VendorMode::Sell });
            }
...
}

我们还需要一种方法来确定商人有哪些商品出售。 在 raws/item_structs.rs 中,我们将向物品定义添加一个新的可选字段:商人类别:

#![allow(unused)]
fn main() {
#[derive(Deserialize, Debug)]
pub struct Item {
    pub name : String,
    pub renderable : Option<Renderable>,
    pub consumable : Option<Consumable>,
    pub weapon : Option<Weapon>,
    pub wearable : Option<Wearable>,
    pub initiative_penalty : Option<f32>,
    pub weight_lbs : Option<f32>,
    pub base_value : Option<f32>,
    pub vendor_category : Option<String>
}
}

进入 spawns.json,并将商人类别标签添加到 Rations

{
    "name" : "Rations",
    "renderable": {
        "glyph" : "%",
        "fg" : "#00FF00",
        "bg" : "#000000",
        "order" : 2
    },
    "consumable" : {
        "effects" : {
            "food" : ""
        }
    },
    "weight_lbs" : 2.0,
    "base_value" : 0.5,
    "vendor_category" : "food"
},

现在我们可以将此函数添加到 raws/rawmaster.rs,以检索类别中出售的物品:

#![allow(unused)]
fn main() {
pub fn get_vendor_items(categories: &[String], raws : &RawMaster) -> Vec<(String, f32)> {
    let mut result : Vec<(String, f32)> = Vec::new();

    for item in raws.raws.items.iter() {
        if let Some(cat) = &item.vendor_category {
            if categories.contains(cat) && item.base_value.is_some() {
                result.push((
                    item.name.clone(),
                    item.base_value.unwrap()
                ));
            }
        }
    }

    result
}
}

我们将前往 gui.rs 并创建一个新函数 show_vendor_menu,以及两个辅助函数和一个枚举! 让我们从枚举开始:

#![allow(unused)]
fn main() {
#[derive(PartialEq, Copy, Clone)]
pub enum VendorResult { NoResponse, Cancel, Sell, BuyMode, SellMode, Buy }
}

这表示玩家在与商人交谈时可能做出的选择:无响应、取消对话、买卖物品以及在买卖模式之间切换。

显示待售物品的函数与用于掉落物品的 UI 非常相似(它是修改后的副本):

#![allow(unused)]
fn main() {
fn vendor_sell_menu(gs : &mut State, ctx : &mut Rltk, _vendor : Entity, _mode : VendorMode) -> (VendorResult, Option<Entity>, Option<String>, Option<f32>) {
    let player_entity = gs.ecs.fetch::<Entity>();
    let names = gs.ecs.read_storage::<Name>();
    let backpack = gs.ecs.read_storage::<InBackpack>();
    let items = gs.ecs.read_storage::<Item>();
    let entities = gs.ecs.entities();

    let inventory = (&backpack, &names).join().filter(|item| item.0.owner == *player_entity );
    let count = inventory.count();

    let mut y = (25 - (count / 2)) as i32;
    ctx.draw_box(15, y-2, 51, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK));
    ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Sell Which Item? (space to switch to buy mode)"); // 卖哪个物品?(空格键切换到购买模式)
    ctx.print_color(18, y+count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel"); // ESCAPE 取消

    let mut equippable : Vec<Entity> = Vec::new();
    let mut j = 0;
    for (entity, _pack, name, item) in (&entities, &backpack, &names, &items).join().filter(|item| item.1.owner == *player_entity ) {
        ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('('));
        ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), 97+j as rltk::FontCharType);
        ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')'));

        ctx.print(21, y, &name.name.to_string());
        ctx.print(50, y, &format!("{:.1} gp", item.base_value * 0.8));
        equippable.push(entity);
        y += 1;
        j += 1;
    }

    match ctx.key {
        None => (VendorResult::NoResponse, None, None, None),
        Some(key) => {
            match key {
                VirtualKeyCode::Space => { (VendorResult::BuyMode, None, None, None) }
                VirtualKeyCode::Escape => { (VendorResult::Cancel, None, None, None) }
                _ => {
                    let selection = rltk::letter_to_option(key);
                    if selection > -1 && selection < count as i32 {
                        return (VendorResult::Sell, Some(equippable[selection as usize]), None, None);
                    }
                    (VendorResult::NoResponse, None, None, None)
                }
            }
        }
    }
}
}

购买也很相似,但是我们没有查询背包,而是使用我们之前编写的 get_vendor_items 函数来获取要出售的物品列表:

#![allow(unused)]
fn main() {
fn vendor_buy_menu(gs : &mut State, ctx : &mut Rltk, vendor : Entity, _mode : VendorMode) -> (VendorResult, Option<Entity>, Option<String>, Option<f32>) {
    use crate::raws::*;

    let vendors = gs.ecs.read_storage::<Vendor>();

    let inventory = crate::raws::get_vendor_items(&vendors.get(vendor).unwrap().categories, &RAWS.lock().unwrap());
    let count = inventory.len();

    let mut y = (25 - (count / 2)) as i32;
    ctx.draw_box(15, y-2, 51, (count+3) as i32, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK));
    ctx.print_color(18, y-2, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "Buy Which Item? (space to switch to sell mode)"); // 买哪个物品?(空格键切换到出售模式)
    ctx.print_color(18, y+count as i32+1, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), "ESCAPE to cancel"); // ESCAPE 取消

    for (j,sale) in inventory.iter().enumerate() {
        ctx.set(17, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437('('));
        ctx.set(18, y, RGB::named(rltk::YELLOW), RGB::named(rltk::BLACK), 97+j as rltk::FontCharType);
        ctx.set(19, y, RGB::named(rltk::WHITE), RGB::named(rltk::BLACK), rltk::to_cp437(')'));

        ctx.print(21, y, &sale.0);
        ctx.print(50, y, &format!("{:.1} gp", sale.1 * 1.2));
        y += 1;
    }

    match ctx.key {
        None => (VendorResult::NoResponse, None, None, None),
        Some(key) => {
            match key {
                VirtualKeyCode::Space => { (VendorResult::SellMode, None, None, None) }
                VirtualKeyCode::Escape => { (VendorResult::Cancel, None, None, None) }
                _ => {
                    let selection = rltk::letter_to_option(key);
                    if selection > -1 && selection < count as i32 {
                        return (VendorResult::Buy, None, Some(inventory[selection as usize].0.clone()), Some(inventory[selection as usize].1));
                    }
                    (VendorResult::NoResponse, None, None, None)
                }
            }
        }
    }
}
}

最后,我们提供一个公共函数,它只是定向到相关模式:

#![allow(unused)]
fn main() {
pub fn show_vendor_menu(gs : &mut State, ctx : &mut Rltk, vendor : Entity, mode : VendorMode) -> (VendorResult, Option<Entity>, Option<String>, Option<f32>) {
    match mode {
        VendorMode::Buy => vendor_buy_menu(gs, ctx, vendor, mode),
        VendorMode::Sell => vendor_sell_menu(gs, ctx, vendor, mode)
    }
}
}

回到 main.rs,我们需要将交易添加到游戏的整体状态机中:

#![allow(unused)]
fn main() {
RunState::ShowVendor{vendor, mode} => {
    let result = gui::show_vendor_menu(self, ctx, vendor, mode);
    match result.0 {
        gui::VendorResult::Cancel => newrunstate = RunState::AwaitingInput,
        gui::VendorResult::NoResponse => {}
        gui::VendorResult::Sell => {
            let price = self.ecs.read_storage::<Item>().get(result.1.unwrap()).unwrap().base_value * 0.8;
            self.ecs.write_storage::<Pools>().get_mut(*self.ecs.fetch::<Entity>()).unwrap().gold += price;
            self.ecs.delete_entity(result.1.unwrap()).expect("Unable to delete");
        }
        gui::VendorResult::Buy => {
            let tag = result.2.unwrap();
            let price = result.3.unwrap();
            let mut pools = self.ecs.write_storage::<Pools>();
            let player_pools = pools.get_mut(*self.ecs.fetch::<Entity>()).unwrap();
            if player_pools.gold >= price {
                player_pools.gold -= price;
                std::mem::drop(pools);
                let player_entity = *self.ecs.fetch::<Entity>();
                crate::raws::spawn_named_item(&RAWS.lock().unwrap(), &mut self.ecs, &tag, SpawnType::Carried{ by: player_entity });
            }
        }
        gui::VendorResult::BuyMode => newrunstate = RunState::ShowVendor{ vendor, mode: VendorMode::Buy },
        gui::VendorResult::SellMode => newrunstate = RunState::ShowVendor{ vendor, mode: VendorMode::Sell }
    }
}
}

您现在可以从商人处买卖商品了! UI 可能需要一些改进(在未来的章节中!),但功能已经具备。 现在您有理由捡起无用的战利品和现金了!

Screenshot

最后,遍历 spawns.json 以将物品添加到商人类别是一个好主意 - 并将商人设置为销售这些类别。 您已经看到了 Rations 作为示例 - 现在是时候在物品上尽情发挥了! 在源代码 中,我已经填写了我认为合理的默认值。

总结

游戏现在有了金钱、买卖系统! 这为回到城镇并捡起原本无用的物品提供了一个很好的理由。 游戏现在还具有物品重量和负重,以及使用较小武器的好处。 这为更深入的游戏奠定了基础。

...

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

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

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