难度


关于本教程

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

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

Hands-On Rust


目前,你可以通过多个地下城层级 - 但它们都具有相同的生成物 (spawn)。 随着你的前进,难度没有提升,也没有简单的模式可以帮助你度过初期。 本章旨在改变这一点。

添加等待按键

大多数 Roguelike 游戏的一个重要的战术要素是能够跳过回合 - 让怪物向你靠近(而不是让你先受到攻击!)。 作为将游戏转变为更具战术挑战性的一部分,让我们快速实现回合跳过。 在 player.rs 中(以及其余的输入),我们将数字键盘 5 和空格键添加为跳过:

#![allow(unused)]
fn main() {
// 跳过回合
VirtualKeyCode::Numpad5 => return RunState::PlayerTurn,
VirtualKeyCode::Space => return RunState::PlayerTurn,
}

这为游戏增加了一个不错的战术维度:你可以引诱敌人向你靠近,并从战术位置中获益。 Roguelike 游戏中另一个常见的功能是,如果没有附近的敌人,等待可以提供一些治疗。 我们只为玩家实现这一点,因为怪物突然治疗会让人感到不安! 因此,我们将其更改为:

#![allow(unused)]
fn main() {
// 跳过回合
VirtualKeyCode::Numpad5 => return skip_turn(&mut gs.ecs),
VirtualKeyCode::Space => return skip_turn(&mut gs.ecs),
}

现在我们实现 skip_turn

#![allow(unused)]
fn main() {
fn skip_turn(ecs: &mut World) -> RunState {
    let player_entity = ecs.fetch::<Entity>();
    let viewshed_components = ecs.read_storage::<Viewshed>();
    let monsters = ecs.read_storage::<Monster>();

    let worldmap_resource = ecs.fetch::<Map>();

    let mut can_heal = true;
    let viewshed = viewshed_components.get(*player_entity).unwrap();
    for tile in viewshed.visible_tiles.iter() {
        let idx = worldmap_resource.xy_idx(tile.x, tile.y);
        for entity_id in worldmap_resource.tile_content[idx].iter() {
            let mob = monsters.get(*entity_id);
            match mob {
                None => {}
                Some(_) => { can_heal = false; }
            }
        }
    }

    if can_heal {
        let mut health_components = ecs.write_storage::<CombatStats>();
        let player_hp = health_components.get_mut(*player_entity).unwrap();
        player_hp.hp = i32::min(player_hp.hp + 1, player_hp.max_hp);
    }

    RunState::PlayerTurn
}
}

这段代码查找了各种实体,然后使用 tile_content 系统迭代玩家的视野范围 (viewshed)。 它检查玩家可以看到的怪物; 如果没有怪物出现,它会治疗玩家 1 点生命值。 这鼓励了动脑筋的玩法 - 并且可以在稍后通过包含饥饿时钟来平衡。 这也使游戏变得非常容易 - 但我们正在解决这个问题!

随着深入地下城难度增加:生成表 (spawn table)

到目前为止,我们一直在使用一个简单的生成系统:它随机选择一些怪物和物品,然后以相等的权重选择每个。 这不太像“正常”游戏,正常游戏倾向于使某些东西稀有 - 而某些东西常见。 我们将创建一个通用的 random_table 系统,用于生成系统。 创建一个新文件 random_table.rs 并将以下内容放入其中:

#![allow(unused)]
fn main() {
use rltk::RandomNumberGenerator;

pub struct RandomEntry {
    name : String,
    weight : i32
}

impl RandomEntry {
    pub fn new<S:ToString>(name: S, weight: i32) -> RandomEntry {
        RandomEntry{ name: name.to_string(), weight }
    }
}

#[derive(Default)]
pub struct RandomTable {
    entries : Vec<RandomEntry>,
    total_weight : i32
}

impl RandomTable {
    pub fn new() -> RandomTable {
        RandomTable{ entries: Vec::new(), total_weight: 0 }
    }

    pub fn add<S:ToString>(mut self, name : S, weight: i32) -> RandomTable {
        self.total_weight += weight;
        self.entries.push(RandomEntry::new(name.to_string(), weight));
        self
    }

    pub fn roll(&self, rng : &mut RandomNumberGenerator) -> String {
        if self.total_weight == 0 { return "None".to_string(); }
        let mut roll = rng.roll_dice(1, self.total_weight)-1;
        let mut index : usize = 0;

        while roll > 0 {
            if roll < self.entries[index].weight {
                return self.entries[index].name.clone();
            }

            roll -= self.entries[index].weight;
            index += 1;
        }

        "None".to_string()
    }
}
}

因此,这创建了一个新类型 random_table。 它为其添加了一个 new 方法,以方便创建一个新的 random_table。 它还创建了一个 vector 或条目 (entry),每个条目都有一个权重和一个名称(传递字符串不是很有效,但可以使示例代码清晰!)。 它还实现了一个 add 函数,该函数允许你传入一个新的名称和权重,并更新结构的 total_weight。 最后,roll0 .. total_weight - 1 进行一次掷骰子 (dice roll),并遍历条目。 如果掷骰子的结果低于权重,则返回它 - 否则,它会从掷骰子的结果中减去权重并测试下一个条目。 这为表中的任何给定项目提供了等于条目相对权重的机会。 其中有一些额外的工作来帮助将方法链接在一起,以实现链式函数调用的 Rust 风格外观。 我们将在 spawner.rs 中使用它来创建一个新函数 room_table

#![allow(unused)]
fn main() {
fn room_table() -> RandomTable {
    RandomTable::new()
        .add("Goblin", 10)
        .add("Orc", 1)
        .add("Health Potion", 7)
        .add("Fireball Scroll", 2)
        .add("Confusion Scroll", 2)
        .add("Magic Missile Scroll", 4)
}
}

这包含了我们到目前为止添加的所有物品和怪物,并附加了权重。 我对这些权重不是很在意; 我们稍后会调整它们! 这确实意味着调用 room_table().roll(rng) 将返回一个随机房间条目。

现在我们简化一下。 删除 spawner.rs 中的 NUM_MONSTERSrandom_monsterrandom_item 函数。 然后我们将房间生成代码替换为:

#![allow(unused)]
fn main() {
#[allow(clippy::map_entry)]
pub fn spawn_room(ecs: &mut World, room : &Rect) {
    let spawn_table = room_table();
    let mut spawn_points : HashMap<usize, String> = HashMap::new();

    // Scope to keep the borrow checker happy
    {
        let mut rng = ecs.write_resource::<RandomNumberGenerator>();
        let num_spawns = rng.roll_dice(1, MAX_MONSTERS + 3) - 3;

        for _i in 0 .. num_spawns {
            let mut added = false;
            let mut tries = 0;
            while !added && tries < 20 {
                let x = (room.x1 + rng.roll_dice(1, i32::abs(room.x2 - room.x1))) as usize;
                let y = (room.y1 + rng.roll_dice(1, i32::abs(room.y2 - room.y1))) as usize;
                let idx = (y * MAPWIDTH) + x;
                if !spawn_points.contains_key(&idx) {
                    spawn_points.insert(idx, spawn_table.roll(&mut rng));
                    added = true;
                } else {
                    tries += 1;
                }
            }
        }
    }

    // Actually spawn the monsters
    for spawn in spawn_points.iter() {
        let x = (*spawn.0 % MAPWIDTH) as i32;
        let y = (*spawn.0 / MAPWIDTH) as i32;

        match spawn.1.as_ref() {
            "Goblin" => goblin(ecs, x, y),
            "Orc" => orc(ecs, x, y),
            "Health Potion" => health_potion(ecs, x, y),
            "Fireball Scroll" => fireball_scroll(ecs, x, y),
            "Confusion Scroll" => confusion_scroll(ecs, x, y),
            "Magic Missile Scroll" => magic_missile_scroll(ecs, x, y),
            _ => {}
        }
    }
}
}

让我们逐步了解一下:

  1. 第一行告诉 Rust linter,我们确实喜欢检查 HashMap 的成员资格,然后插入其中 - 我们还设置了一个标志,但这与它的建议不太相符。
  2. 我们获取全局随机数生成器,并将生成数量设置为 1d7-3(范围为 -2 到 4)。
  3. 对于上面 0 的每个生成数量,我们选择房间中的一个随机点。 我们不断选择随机点,直到找到一个空点(或者我们超过 20 次尝试,在这种情况下我们放弃)。 找到一个点后,我们将其添加到 spawn 列表,其中包含位置和从我们的随机表中掷骰子的结果。
  4. 然后我们迭代生成列表,匹配掷骰子的结果并生成怪物和物品。

这绝对比以前的方法更简洁,现在你不太可能遇到兽人 - 而更有可能遇到地精和治疗药水。

快速的 cargo run 向你展示了改进的生成多样性。

随着深入地下城增加生成率

这给出了一个更好的分布,但没有解决后期关卡与早期关卡难度相同的问题。 一种快速而粗糙的方法是在你深入时生成更多实体。 这仍然不能解决问题,但这只是一个开始! 我们将首先修改 spawn_room 的函数签名以接受地图深度:

#![allow(unused)]
fn main() {
pub fn spawn_room(ecs: &mut World, room : &Rect, map_depth: i32) {
}

然后我们将更改生成的实体数量以使用它:

#![allow(unused)]
fn main() {
let num_spawns = rng.roll_dice(1, MAX_MONSTERS + 3) + (map_depth - 1) - 3;
}

我们将必须更改 main.rs 中的几个调用以传入深度:

#![allow(unused)]
fn main() {
for room in map.rooms.iter().skip(1) {
    spawner::spawn_room(&mut gs.ecs, room, 1);
}
}
#![allow(unused)]
fn main() {
// 构建新地图并放置玩家
let worldmap;
let current_depth;
{
    let mut worldmap_resource = self.ecs.write_resource::<Map>();
    current_depth = worldmap_resource.depth;
    *worldmap_resource = Map::new_map_rooms_and_corridors(current_depth + 1);
    worldmap = worldmap_resource.clone();
}

// 生成坏人
for room in worldmap.rooms.iter().skip(1) {
    spawner::spawn_room(&mut self.ecs, room, current_depth+1);
}
}

如果你现在 cargo run,第一层会非常安静。 随着你的深入,难度会略有提升,直到你拥有真正的怪物大军!

按深度增加权重

让我们修改 room_table 函数以包含地图深度:

#![allow(unused)]
fn main() {
fn room_table(map_depth: i32) -> RandomTable {
    RandomTable::new()
        .add("Goblin", 10)
        .add("Orc", 1 + map_depth)
        .add("Health Potion", 7)
        .add("Fireball Scroll", 2 + map_depth)
        .add("Confusion Scroll", 2 + map_depth)
        .add("Magic Missile Scroll", 4)
}
}

我们还在 spawn_room 中更改了对它的调用以使用它:

#![allow(unused)]
fn main() {
let spawn_table = room_table(map_depth);
}

cargo build 之后,瞧 - 你有了一个随着你的深入而增加找到兽人、火球和混乱卷轴的概率。 地精、治疗药水和魔法飞弹卷轴的总权重保持不变 - 但由于其他权重发生了变化,它们的总可能性降低了。

总结

你现在拥有了一个随着你的深入而难度增加的地下城! 在下一章中,我们还将研究如何通过装备让你的角色获得一些成长,以平衡局面。

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

在你的浏览器中使用 web assembly 运行本章的示例 (需要 WebGL2)


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