用户界面


关于本教程

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

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

实践中的 Rust


在本章中,我们将为游戏添加用户界面。

缩小地图

我们将从 map.rs 开始,并添加一些常量:MAPWIDTHMAPHEIGHTMAPCOUNT

#![allow(unused)]
fn main() {
const MAPWIDTH: usize = 80;
const MAPHEIGHT: usize = 50;
const MAPCOUNT: usize = MAPHEIGHT * MAPWIDTH;
}

然后我们将逐一更改对 80*50 的引用为MAPCOUNT,并对地图大小的引用使用常量。完成后并运行时,我们将MAPHEIGHT更改为 43 - 以便在屏幕底部留出用户界面面板的空间。

一些最小的图形用户界面元素

我们将创建一个新文件 gui.rs 来存放我们的代码。我们将从一个非常简单的开始:

#![allow(unused)]
fn main() {
use rltk::{ RGB, Rltk, Console };
use specs::prelude::*;

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

我们在main.rs顶部的导入块中添加一个mod gui,并在tick的末尾调用它:

#![allow(unused)]
fn main() {
gui::draw_ui(&self.ecs, ctx); 
}

如果我们现在运行 cargo run,我们会看到地图已经缩小了——并且我们有一个白色框代替了面板。

截图

添加生命条

这会帮助玩家了解他们还剩下多少生命值。幸运的是,RLTK 提供了方便的辅助工具。我们需要从 ECS 中获取玩家的生命值并进行渲染。这很简单,你现在应该已经很熟悉了。代码如下:

#![allow(unused)]
fn main() {
use rltk::{ RGB, Rltk, Console };
use specs::prelude::*;
use super::{CombatStats, Player};

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::<CombatStats>();
    let players = ecs.read_storage::<Player>();
    for (_player, stats) in (&players, &combat_stats).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.hp, stats.max_hp, RGB::named(rltk::RED), RGB::named(rltk::BLACK));
    }
}
}

添加消息日志

游戏日志作为资源是有意义的:任何想要告诉你一些信息的系统都可以使用它,而且几乎没有限制什么可能想要告诉你一些信息。我们将从建模日志本身开始。创建一个新文件,gamelog.rs。我们将从非常简单的方式开始:

#![allow(unused)]
fn main() {
pub struct GameLog {
    pub entries : Vec<String>
}
}

main.rs 中,我们添加一行 mod gamelog;,并将其作为资源插入,使用 gs.ecs.insert(gamelog::GameLog{ entries : vec!["Welcome to Rusty Roguelike".to_string()] });。我们在开始时插入一条日志记录,使用 vec! 宏来构造向量。这样我们就有东西可以显示了——所以我们将在 gui.rs 中开始编写日志显示代码。在我们的 GUI 绘制函数中,我们只需添加:

#![allow(unused)]
fn main() {
let log = ecs.fetch::<GameLog>();

let mut y = 44;
for s in log.entries.iter().rev() {
    if y < 49 { ctx.print(2, y, s); }
    y += 1;
}
}

如果你现在cargo run这个项目,你会看到类似这样的内容:

截图

记录攻击

在我们的 melee_combat_system 中,我们从 super 导入中添加 gamelog::GameLog,为日志添加读/写访问器(WriteExpect<'a, GameLog>,),并扩展解构以包含它:let (entities, mut log, mut wants_melee, names, combat_stats, mut inflict_damage) = data;。然后只需将 print! 宏替换为插入游戏日志即可。以下是结果代码:

#![allow(unused)]
fn main() {
use specs::prelude::*;
use super::{CombatStats, WantsToMelee, Name, SufferDamage, gamelog::GameLog};

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, CombatStats>,
                        WriteStorage<'a, SufferDamage>
                      );

    fn run(&mut self, data : Self::SystemData) {
        let (entities, mut log, mut wants_melee, names, combat_stats, mut inflict_damage) = data;

        for (_entity, wants_melee, name, stats) in (&entities, &wants_melee, &names, &combat_stats).join() {
            if stats.hp > 0 {
                let target_stats = combat_stats.get(wants_melee.target).unwrap();
                if target_stats.hp > 0 {
                    let target_name = names.get(wants_melee.target).unwrap();

                    let damage = i32::max(0, stats.power - target_stats.defense);

                    if damage == 0 {
                        log.entries.push(format!("{} is unable to hurt {}", &name.name, &target_name.name));
                    } else {
                        log.entries.push(format!("{} hits {}, for {} hp.", &name.name, &target_name.name, damage));
                        SufferDamage::new_damage(&mut inflict_damage, wants_melee.target, damage);                        
                    }
                }
            }
        }

        wants_melee.clear();
    }
}
}

现在如果你运行游戏并玩一会儿(cargo run,玩法由你决定!),你会在日志中看到战斗消息:

截图

通知死亡

我们可以用delete_the_dead来通知死亡。以下是完成的代码:

#![allow(unused)]
fn main() {
pub fn delete_the_dead(ecs : &mut World) {
    let mut dead : Vec<Entity> = Vec::new();
    // Using a scope to make the borrow checker happy
    {
        let combat_stats = ecs.read_storage::<CombatStats>();
        let players = ecs.read_storage::<Player>();
        let names = ecs.read_storage::<Name>();
        let entities = ecs.entities();
        let mut log = ecs.write_resource::<GameLog>();
        for (entity, stats) in (&entities, &combat_stats).join() {
            if stats.hp < 1 { 
                let player = players.get(entity);
                match player {
                    None => {
                        let victim_name = names.get(entity);
                        if let Some(victim_name) = victim_name {
                            log.entries.push(format!("{} is dead", &victim_name.name));
                        }
                        dead.push(entity)
                    }
                    Some(_) => console::log("You are dead")
                }
            }
        }
    }

    for victim in dead {
        ecs.delete_entity(victim).expect("Unable to delete");
    }    
}
}

鼠标支持和工具提示

让我们从如何从 RLTK 获取鼠标信息开始。这非常简单;在你的draw_ui函数的底部添加以下内容:

#![allow(unused)]
fn main() {
// Draw mouse cursor
let mouse_pos = ctx.mouse_pos();
ctx.set_bg(mouse_pos.0, mouse_pos.1, RGB::named(rltk::MAGENTA));
}

这将鼠标指向的单元格的背景设置为品红色。如您所见,鼠标信息作为上下文的一部分从 RLTK 到达。

现在我们将介绍一个新功能,draw_tooltips,并在draw_ui的末尾调用它。新功能如下所示:

#![allow(unused)]
fn main() {
fn draw_tooltips(ecs: &World, ctx : &mut Rltk) {
    let map = ecs.fetch::<Map>();
    let names = ecs.read_storage::<Name>();
    let positions = ecs.read_storage::<Position>();

    let mouse_pos = ctx.mouse_pos();
    if mouse_pos.0 >= map.width || mouse_pos.1 >= map.height { return; }
    let mut tooltip : Vec<String> = Vec::new();
    for (name, position) in (&names, &positions).join() {
        let idx = map.xy_idx(position.x, position.y);
        if position.x == mouse_pos.0 && position.y == mouse_pos.1 && map.visible_tiles[idx] {
            tooltip.push(name.name.to_string());
        }
    }

    if !tooltip.is_empty() {
        let mut width :i32 = 0;
        for s in tooltip.iter() {
            if width < s.len() as i32 { width = s.len() as i32; }
        }
        width += 3;

        if mouse_pos.0 > 40 {
            let arrow_pos = Point::new(mouse_pos.0 - 2, mouse_pos.1);
            let left_x = mouse_pos.0 - width;
            let mut y = mouse_pos.1;
            for s in tooltip.iter() {
                ctx.print_color(left_x, y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), s);
                let padding = (width - s.len() as i32)-1;
                for i in 0..padding {
                    ctx.print_color(arrow_pos.x - i, y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &" ".to_string());
                }
                y += 1;
            }
            ctx.print_color(arrow_pos.x, arrow_pos.y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &"->".to_string());
        } else {
            let arrow_pos = Point::new(mouse_pos.0 + 1, mouse_pos.1);
            let left_x = mouse_pos.0 +3;
            let mut y = mouse_pos.1;
            for s in tooltip.iter() {
                ctx.print_color(left_x + 1, y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), s);
                let padding = (width - s.len() as i32)-1;
                for i in 0..padding {
                    ctx.print_color(arrow_pos.x + 1 + i, y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &" ".to_string());
                }
                y += 1;
            }
            ctx.print_color(arrow_pos.x, arrow_pos.y, RGB::named(rltk::WHITE), RGB::named(rltk::GREY), &"<-".to_string());
        }
    }
}
}

它首先获取我们需要用于工具提示的组件的读取权限:名称和位置。它还获取地图本身的读取权限。然后我们检查鼠标光标是否实际在地图上,如果不在,则退出——没有必要为永远不会有任何东西的地方绘制工具提示!

余下的部分说“如果我们有任何工具提示,查看鼠标位置”——如果在左边,我们会将工具提示放在右边,否则放在左边。

如果你现在 cargo run 你的项目,它看起来像这样:

截图

可选的后处理,以获得真正的复古感觉

由于我们在讨论外观和感觉,让我们考虑启用一个 RLTK 功能:后处理以产生扫描线和屏幕烧伤,以获得真正的复古感觉。你是否想使用这个完全取决于你!在main.rs中,初始设置只是将第一个init命令替换为:

#![allow(unused)]
fn main() {
use rltk::RltkBuilder;
    let mut context = RltkBuilder::simple80x50()
        .with_title("Roguelike Tutorial")
        .build()?;
context.with_post_scanlines(true);
}

如果你选择这样做,游戏看起来有点像经典的Qud 的洞穴

截图

总结

现在我们有了一个图形用户界面,看起来相当不错!

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

运行本章的示例与 Web Assembly,在您的浏览器中(需要 WebGL2)


版权 (C) 2019, Herbert Wolverson.

版权 (C) 2024, myedgetech.com.