用户界面


关于本教程

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

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

Hands-On Rust


与城镇一起,玩家首先看到的就是你的用户界面(User Interface, UI)。我们已经用一个相当不错的界面凑合了一段时间,但它还不够出色。理想情况下,用户界面应该使新玩家容易上手,同时仍然为老玩家提供足够的深度。它应该支持键盘和鼠标输入(我知道许多资深的 Roguelike 玩家讨厌鼠标;但许多新玩家喜欢它),并提供关于符号汤实际含义的反馈。符号是表示世界的好方法,但在你的大脑将 g 与 goblin(哥布林)联系起来并想象出可怕的小家伙时,会有一个学习曲线。

Cogmind 是 ASCII (以及简单的 tile(图块)) 用户界面的灵感来源。如果您还没有玩过,我衷心建议您去看看。此外,在与 Red Blob Games 的创建者的一次对话中,他对良好 UI 的重要性给出了一些非常有见地的评论:预先构建 UI 可以帮助你意识到你是否可以向玩家展示你正在制作的东西,并且可以为你的构建提供非常好的“感觉”。因此,一旦你完成了最初的原型设计(prototyping),构建用户界面就可以作为其余工作的指南。他是一位非常聪明的人,所以我们将采纳他的建议!

原型设计用户界面

我喜欢在 Rex Paint 中草绘 UI。这是我最初为本教程游戏想到的:

Screenshot

就 ASCII 用户界面而言,这算是一个不错的开始。一些相关的注意事项:

  • 我们已将终端(terminal)扩展到 80x60,这对于这些游戏来说是一个非常常见的分辨率(Cogmind 默认使用它)。
  • 我们缩小了屏幕上用于地图(map)的区域,以便在屏幕上同时显示更多相关信息;它实际上是 50x48
  • 底部面板是日志(log),我给它着色并添加了一些愚蠢的虚假文本,只是为了清楚地表明它的用途。我们肯定希望改进我们的日志记录体验,以帮助玩家沉浸其中。
  • 右上方显示了一些重要信息:你的生命值(health)和魔法值(mana),都以数字和条形图显示。在下面,我们显示了你的属性(attributes) - 并突出显示了以某种方式得到提升的属性(我们没有说明是如何提升的!)。
  • 下一个面板列出了你已装备的物品栏(equipped inventory)。
  • 在下面,我们展示了你的消耗品(consumables) - 完整地显示了激活它们的热键(hot-key)(shift + 数字)。
  • 在下面,我们展示了一个示例咒语(spell) - 尚未实现,但这个想法仍然存在。
  • 在右侧面板的底部,我们列出了状态效果(status effects)。设计文档说你开始时会宿醉(hangover),所以我们列出了它(即使它还没有编写出来)。你也会以*饱腹(well fed)*状态开始,所以我们也会显示它。

更改控制台大小

main.rs 中,我们的 main 函数首先要做的是引导 RLTK。我们在这里指定分辨率和窗口标题。因此,我们将更新它以匹配我们想要的内容:

#![allow(unused)]
fn main() {
use rltk::RltkBuilder;
    let mut context = RltkBuilder::simple(80, 60)
        .unwrap()
        .with_title("Roguelike Tutorial")
        .build()?;
}

如果你现在 cargo run,你会看到一个更大的控制台 - 并且没有任何东西利用额外的空间!

Screenshot

我们稍后会担心修复主菜单。让我们首先使游戏看起来像原型草图。

限制渲染的地图

原型中的地图从 1,1 开始,一直延伸到 48,44。因此打开 camera.rs,我们将更改边界。我们将使用我们期望的视口(viewport),而不是使用屏幕边界:

#![allow(unused)]
fn main() {
pub fn get_screen_bounds(ecs: &World, _ctx: &mut Rltk) -> (i32, i32, i32, i32) {
    let player_pos = ecs.fetch::<Point>();
    //let (x_chars, y_chars) = ctx.get_char_size();
    let (x_chars, y_chars) = (48, 44); // 设置地图视口大小为 48x44 (Set map viewport size to 48x44)

    let center_x = (x_chars / 2) as i32;
    let center_y = (y_chars / 2) as i32;

    let min_x = player_pos.x - center_x;
    let max_x = min_x + x_chars as i32;
    let min_y = player_pos.y - center_y;
    let max_y = min_y + y_chars as i32;

    (min_x, max_x, min_y, max_y)
}
}

我们没有读取屏幕尺寸并缩放它,而是将地图限制为所需的视口。我们保留了 ctx 参数,即使我们没有使用它,这样就不会破坏所有其他使用它的地方。

地图视口现在被很好地限制了:

Screenshot

绘制框

我们将进入 gui.rs(特别是 draw_ui),并开始放置构成用户界面的基本框(box)。我们还将注释掉我们尚未使用的部分。RLTK 的 box 函数效果很好,但它会填充框。这不是我们这里需要的,所以在 gui.rs 的顶部我添加了一个新函数:

#![allow(unused)]
fn main() {
pub fn draw_hollow_box(
    console: &mut Rltk,
    sx: i32,
    sy: i32,
    width: i32,
    height: i32,
    fg: RGB,
    bg: RGB,
) {
    use rltk::to_cp437;

    console.set(sx, sy, fg, bg, to_cp437('┌'));
    console.set(sx + width, sy, fg, bg, to_cp437('┐'));
    console.set(sx, sy + height, fg, bg, to_cp437('└'));
    console.set(sx + width, sy + height, fg, bg, to_cp437('┘'));
    for x in sx + 1..sx + width {
        console.set(x, sy, fg, bg, to_cp437('─'));
        console.set(x, sy + height, fg, bg, to_cp437('─'));
    }
    for y in sy + 1..sy + height {
        console.set(sx, y, fg, bg, to_cp437('│'));
        console.set(sx + width, y, fg, bg, to_cp437('│'));
    }
}
}

这实际上是从 RLTK 复制过来的,但去除了填充(fill)。

接下来,我们开始处理 draw_ui

#![allow(unused)]
fn main() {
pub fn draw_ui(ecs: &World, ctx : &mut Rltk) {
    use rltk::to_cp437;
    let box_gray : RGB = RGB::from_hex("#999999").expect("Oops");
    let black = RGB::named(rltk::BLACK);

    draw_hollow_box(ctx, 0, 0, 79, 59, box_gray, black); // Overall box (整体框)
    draw_hollow_box(ctx, 0, 0, 49, 45, box_gray, black); // Map box (地图框)
    draw_hollow_box(ctx, 0, 45, 79, 14, box_gray, black); // Log box (日志框)
    draw_hollow_box(ctx, 49, 0, 30, 8, box_gray, black); // Top-right panel (右上角面板)
}
}

这为我们提供了一个裁剪后的地图,以及原型图形中的基本框轮廓:

Screenshot

现在我们添加一些框连接符(box connectors)进去,使其看起来更平滑:

#![allow(unused)]
fn main() {
ctx.set(0, 45, box_gray, black, to_cp437('├'));
ctx.set(49, 8, box_gray, black, to_cp437('├'));
ctx.set(49, 0, box_gray, black, to_cp437('┬'));
ctx.set(49, 45, box_gray, black, to_cp437('┴'));
ctx.set(79, 8, box_gray, black, to_cp437('┤'));
ctx.set(79, 45, box_gray, black, to_cp437('┤'));
}

Screenshot

添加地图名称

在顶部显示地图名称看起来确实不错 - 但地图目前没有名称!让我们纠正一下。打开 map/mod.rs 并修改 Map 结构:

#![allow(unused)]
fn main() {
#[derive(Default, Serialize, Deserialize, Clone)]
pub struct Map {
    pub tiles : Vec<TileType>,
    pub width : i32,
    pub height : i32,
    pub revealed_tiles : Vec<bool>,
    pub visible_tiles : Vec<bool>,
    pub blocked : Vec<bool>,
    pub depth : i32,
    pub bloodstains : HashSet<usize>,
    pub view_blocked : HashSet<usize>,
    pub name : String, // 地图名称 (Map name)

    #[serde(skip_serializing)]
    #[serde(skip_deserializing)]
    pub tile_content : Vec<Vec<Entity>>
}
}

我们还将修改构造器(constructor),使用我们在其他地方使用的 to_string 模式,以便你可以发送任何类似字符串的内容:

#![allow(unused)]
fn main() {
/// Generates an empty map, consisting entirely of solid walls (生成一个空地图,完全由坚固的墙壁组成)
pub fn new<S : ToString>(new_depth : i32, width: i32, height: i32, name: S) -> Map {
    let map_tile_count = (width*height) as usize;
    Map{
        tiles : vec![TileType::Wall; map_tile_count],
        width,
        height,
        revealed_tiles : vec![false; map_tile_count],
        visible_tiles : vec![false; map_tile_count],
        blocked : vec![false; map_tile_count],
        tile_content : vec![Vec::new(); map_tile_count],
        depth: new_depth,
        bloodstains: HashSet::new(),
        view_blocked : HashSet::new(),
        name : name.to_string()
    }
}
}

map_builders/waveform_collapse/mod.rs 中(第 39、62 和 78 行)更新对 Map::new 的调用,使其读取 build_data.map = Map::new(build_data.map.depth, build_data.width, build_data.height, &build_data.map.name);

map_builders/mod.rs 中更新 BuilderChain 构造器:

#![allow(unused)]
fn main() {
impl BuilderChain {
    pub fn new<S : ToString>(new_depth : i32, width: i32, height: i32, name : S) -> BuilderChain {
        BuilderChain{
            starter: None,
            builders: Vec::new(),
            build_data : BuilderMap {
                spawn_list: Vec::new(),
                map: Map::new(new_depth, width, height, name),
                starting_position: None,
                rooms: None,
                corridors: None,
                history : Vec::new(),
                width,
                height
            }
        }
    }
    ...
}

此外,第 268 行更改为:let mut builder = BuilderChain::new(new_depth, width, height, "New Map");

main.rs 第 465 行更改为:gs.ecs.insert(Map::new(1, 64, 64, "New Map"));

最后,在 map_builders/town.rs 中更改构造器以命名我们的城镇。我建议你选择一个不是我公司的名字!

#![allow(unused)]
fn main() {
pub fn town_builder(new_depth: i32, _rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain {
    let mut chain = BuilderChain::new(new_depth, width, height, "The Town of Bracketon");
    chain.start_with(TownBuilder::new());
    chain
}
}

唷!完成所有这些之后,让我们在 gui.rs 中绘制地图名称:

#![allow(unused)]
fn main() {
// Draw the town name (绘制城镇名称)
let map = ecs.fetch::<Map>();
let name_length = map.name.len() + 2;
let x_pos = (22 - (name_length / 2)) as i32;
ctx.set(x_pos, 0, box_gray, black, to_cp437('┤'));
ctx.set(x_pos + name_length as i32, 0, box_gray, black, to_cp437('├'));
ctx.print_color(x_pos+1, 0, white, black, &map.name);
std::mem::drop(map);
}

因此,我们从 ECS World 中获取地图,计算名称的长度(加上 2 个用于包装字符)。然后我们计算出居中的位置(在地图窗格上方;所以是 22,窗格宽度的一半,减去名称长度的一半)。然后我们绘制端盖和名称。你可以 cargo run 来查看改进:

Screenshot

显示生命值、魔法值和属性

我们可以修改现有的生命值和魔法值代码。以下代码可以工作:

#![allow(unused)]
fn main() {
// Draw stats (绘制属性)
let player_entity = ecs.fetch::<Entity>();
let pools = ecs.read_storage::<Pools>();
let player_pools = pools.get(*player_entity).unwrap();
let health = format!("Health: {}/{}", player_pools.hit_points.current, player_pools.hit_points.max);
let mana =   format!("Mana:   {}/{}", player_pools.mana.current, player_pools.mana.max);
ctx.print_color(50, 1, white, black, &health);
ctx.print_color(50, 2, white, black, &mana);
ctx.draw_bar_horizontal(64, 1, 14, player_pools.hit_points.current, player_pools.hit_points.max, RGB::named(rltk::RED), RGB::named(rltk::BLACK));
ctx.draw_bar_horizontal(64, 2, 14, player_pools.mana.current, player_pools.mana.max, RGB::named(rltk::BLUE), RGB::named(rltk::BLACK));
}

在下面,我们想要显示属性。由于我们以相同的方式格式化每个属性,让我们引入一个函数:

#![allow(unused)]
fn main() {
fn draw_attribute(name : &str, attribute : &Attribute, y : i32, ctx: &mut Rltk) {
    let black = RGB::named(rltk::BLACK);
    let attr_gray : RGB = RGB::from_hex("#CCCCCC").expect("Oops");
    ctx.print_color(50, y, attr_gray, black, name);
    let color : RGB =
        if attribute.modifiers < 0 { RGB::from_f32(1.0, 0.0, 0.0) } // 负面修改器为红色 (Negative modifier is red)
        else if attribute.modifiers == 0 { RGB::named(rltk::WHITE) } // 无修改器为白色 (No modifier is white)
        else { RGB::from_f32(0.0, 1.0, 0.0) }; // 正面修改器为绿色 (Positive modifier is green)
    ctx.print_color(67, y, color, black, &format!("{}", attribute.base + attribute.modifiers));
    ctx.print_color(73, y, color, black, &format!("{}", attribute.bonus));
    if attribute.bonus > 0 { ctx.set(72, y, color, black, rltk::to_cp437('+')); }
}
}

因此,此属性以较浅的灰色在 50,y 处打印名称。然后我们根据修改器确定颜色;如果没有修改器,我们使用白色。如果它们是负面的(negative),我们使用红色。如果它们是正面的(positive),我们使用绿色。这样我们就可以在 67,y 处打印值 + 修改器(总计)。我们将在 73,y 处打印 bonus。如果 bonus 为正数,我们将添加一个 + 符号。

现在我们可以从我们的 draw_ui 函数中调用它:

#![allow(unused)]
fn main() {
// Attributes (属性)
let attributes = ecs.read_storage::<Attributes>();
let attr = attributes.get(*player_entity).unwrap();
draw_attribute("Might:", &attr.might, 4, ctx);
draw_attribute("Quickness:", &attr.quickness, 5, ctx);
draw_attribute("Fitness:", &attr.fitness, 6, ctx);
draw_attribute("Intelligence:", &attr.intelligence, 7, ctx);
}

现在 cargo run,你会看到我们肯定在取得进展:

Screenshot

添加已装备物品

原型 UI 的一个不错的功能是它显示了我们已装备的装备。这实际上非常容易,所以让我们来做吧!我们迭代 Equipped 物品,如果它们的 owner 等于玩家,我们显示它们的 Name

#![allow(unused)]
fn main() {
// Equipped (已装备)
let mut y = 9;
let equipped = ecs.read_storage::<Equipped>();
let name = ecs.read_storage::<Name>();
for (equipped_by, item_name) in (&equipped, &name).join() {
    if equipped_by.owner == *player_entity {
        ctx.print_color(50, y, white, black, &item_name.name);
        y += 1;
    }
}
}

添加消耗品

这也很容易:

#![allow(unused)]
fn main() {
// Consumables (消耗品)
y += 1;
let green = RGB::from_f32(0.0, 1.0, 0.0);
let yellow = RGB::named(rltk::YELLOW);
let consumables = ecs.read_storage::<Consumable>();
let backpack = ecs.read_storage::<InBackpack>();
let mut index = 1;
for (carried_by, _consumable, item_name) in (&backpack, &consumables, &name).join() {
    if carried_by.owner == *player_entity && index < 10 {
        ctx.print_color(50, y, yellow, black, &format!("↑{}", index));
        ctx.print_color(53, y, green, black, &item_name.name);
        y += 1;
        index += 1;
    }
}
}

我们将 y 加 1,以强制它下降一行。然后将 index 设置为 1(不是零,因为我们的目标是键盘上的按键!)。然后我们 join backpackconsumablesname。对于每个物品,我们检查 owner 是否是玩家,并且 index 仍然小于 10。如果是,我们以 ↑1 Dried Sausage 的格式打印名称 - 其中 1index。将索引加一,递增 y,我们就完成了。

现在 cargo run,你会看到我们肯定越来越接近了:

我们稍后会担心使消耗品快捷键工作。让我们先完成 UI!

Screenshot

状态效果

我们将稍微略过这一点,因为我们目前只有一个状态效果。这是先前代码的直接移植,因此无需过多解释:

#![allow(unused)]
fn main() {
// Status (状态)
let hunger = ecs.read_storage::<HungerClock>();
let hc = hunger.get(*player_entity).unwrap();
match hc.state {
    HungerState::WellFed => ctx.print_color(50, 44, RGB::named(rltk::GREEN), RGB::named(rltk::BLACK), "Well Fed"), // 饱腹
    HungerState::Normal => {}
    HungerState::Hungry => ctx.print_color(50, 44, RGB::named(rltk::ORANGE), RGB::named(rltk::BLACK), "Hungry"), // 饥饿
    HungerState::Starving => ctx.print_color(50, 44, RGB::named(rltk::RED), RGB::named(rltk::BLACK), "Starving"), // 饥荒
}
}

显示日志

同样,这几乎是直接复制:

#![allow(unused)]
fn main() {
// Draw the log (绘制日志)
let log = ecs.fetch::<GameLog>();
let mut y = 46;
for s in log.entries.iter().rev() {
    if y < 59 { ctx.print(2, y, s); }
    y += 1;
}
}

同样,使其具有漂亮的颜色是未来的主题。

工具提示

我们将恢复对绘制工具提示(tool-tips)的调用:

#![allow(unused)]
fn main() {
draw_tooltips(ecs, ctx);
}

draw_tooltips 内部,我们首先必须补偿地图现在偏离屏幕的情况。我们只需将 1 添加到 mouse_map_pos

#![allow(unused)]
fn main() {
mouse_map_pos.0 += min_x - 1;
mouse_map_pos.1 += min_y - 1;
}

闪亮的新工具提示!

这使我们的工具提示系统正常工作 - 但原型显示了一个漂亮的新显示!因此,我们需要创建一种方法来制作这些漂亮的工具提示并排列它们。由于工具提示可以被认为是独立的实体,我们将创建一个对象来定义它们:

#![allow(unused)]
fn main() {
struct Tooltip {
    lines : Vec<String>
}

impl Tooltip {
    fn new() -> Tooltip {
        Tooltip { lines : Vec::new() }
    }

    fn add<S:ToString>(&mut self, line : S) {
        self.lines.push(line.to_string());
    }

    fn width(&self) -> i32 {
        let mut max = 0;
        for s in self.lines.iter() {
            if s.len() > max {
                max = s.len();
            }
        }
        max as i32 + 2i32
    }

    fn height(&self) -> i32 { self.lines.len() as i32 + 2i32 }

    fn render(&self, ctx : &mut Rltk, x : i32, y : i32) {
        let box_gray : RGB = RGB::from_hex("#999999").expect("Oops");
        let light_gray : RGB = RGB::from_hex("#DDDDDD").expect("Oops");
        let white = RGB::named(rltk::WHITE);
        let black = RGB::named(rltk::BLACK);
        ctx.draw_box(x, y, self.width()-1, self.height()-1, white, box_gray);
        for (i,s) in self.lines.iter().enumerate() {
            let col = if i == 0 { white } else { light_gray };
            ctx.print_color(x+1, y+i as i32+1, col, black, &s);
        }
    }
}
}

这里的想法是考虑构成工具提示的要素:

  • 工具提示最重要的部分是文本:因此我们有一个向量(String 类型)来表示每一行。
  • 我们需要一种制作新工具提示的方法,因此我们定义了一个构造函数 - new 函数。这将创建一个空的工具提示。
  • 我们需要一种添加行的方法,因此我们定义了一个 add 函数。
  • 我们需要知道提示有多,以便我们可以确定将其放在屏幕上的哪个位置。因此我们有一个 width 函数。它遍历工具提示的每一行,找到最长的宽度(我们尚不支持换行),并使用它 - 并加上 2,以考虑边框。
  • 我们还需要知道工具提示的高度 - 因此 height 函数是行数加上 2,以考虑边框。
  • 我们需要能够将工具提示渲染到控制台。我们首先使用 RLTK 的 draw_box 功能绘制一个框并填充它(这里不需要空心框!),然后依次添加每一行。

所以现在我们实际上需要使用一些工具提示。我们将很大程度上替换 draw_tooltips 函数。我们将首先获得访问我们需要的 ECS 各个部分:

#![allow(unused)]
fn main() {
fn draw_tooltips(ecs: &World, ctx : &mut Rltk) {
    use rltk::to_cp437;

    let (min_x, _max_x, min_y, _max_y) = camera::get_screen_bounds(ecs, ctx);
    let map = ecs.fetch::<Map>();
    let names = ecs.read_storage::<Name>();
    let positions = ecs.read_storage::<Position>();
    let hidden = ecs.read_storage::<Hidden>();
    let attributes = ecs.read_storage::<Attributes>();
    let pools = ecs.read_storage::<Pools>();
    let entities = ecs.entities();
    ...
}

这没有什么我们以前没有做过的,所以无需过多解释。你只是在向 ECS 请求 read_storage 以访问组件存储(component stores),并使用 fetch 获取地图。接下来,我们查询控制台中的鼠标位置,并将其转换为地图上的位置:

#![allow(unused)]
fn main() {
...
let mouse_pos = ctx.mouse_pos();
let mut mouse_map_pos = mouse_pos;
mouse_map_pos.0 += min_x - 1;
mouse_map_pos.1 += min_y - 1;
if mouse_map_pos.0 >= map.width-1 || mouse_map_pos.1 >= map.height-1 || mouse_map_pos.0 < 1 || mouse_map_pos.1 < 1
{
    return;
}
...
}

请注意,如果鼠标光标在地图之外,我们是如何调用 return 的。我们还添加了 min_xmin_y 以找到地图空间中的屏幕位置,并减去 1 以考虑地图的边框。mouse_map_pos 现在包含鼠标光标在地图上的位置。接下来,我们看看这里有什么,以及它是否需要工具提示:

#![allow(unused)]
fn main() {
...
let mut tip_boxes : Vec<Tooltip> = Vec::new();
for (entity, name, position, _hidden) in (&entities, &names, &positions, !&hidden).join() {
    if position.x == mouse_map_pos.0 && position.y == mouse_map_pos.1 {
...
}

因此,我们正在创建一个新的向量 tip_boxes。这将包含我们决定需要的任何工具提示。然后我们使用 Specs 的 join 函数来搜索具有所有名称、位置并且没有 hidden 组件的实体(这就是感叹号的作用)。左侧列表是变量,它将保存每个匹配实体的组件;右侧列表是我们正在搜索的实体存储。然后我们检查实体的 Position 组件的位置是否与我们创建的 mouse_map_pos 结构相同。

现在我们已经确定实体在我们正在检查的 tile(图块)上,我们开始构建工具提示内容:

#![allow(unused)]
fn main() {
let mut tip = Tooltip::new();
tip.add(name.name.to_string());
}

这将创建一个新的工具提示对象,并将实体的名称添加为第一行。

#![allow(unused)]
fn main() {
// Comment on attributes (属性注释)
let attr = attributes.get(entity);
if let Some(attr) = attr {
    let mut s = "".to_string();
    if attr.might.bonus < 0 { s += "Weak. " }; // 虚弱
    if attr.might.bonus > 0 { s += "Strong. " }; // 强壮
    if attr.quickness.bonus < 0 { s += "Clumsy. " }; // 笨拙
    if attr.quickness.bonus > 0 { s += "Agile. " }; // 敏捷
    if attr.fitness.bonus < 0 { s += "Unheathy. " }; // 不健康
    if attr.fitness.bonus > 0 { s += "Healthy." }; // 健康
    if attr.intelligence.bonus < 0 { s += "Unintelligent. "}; // 不聪明
    if attr.intelligence.bonus > 0 { s += "Smart. "}; // 聪明
    if s.is_empty() {
        s = "Quite Average".to_string(); // 非常普通
    }
    tip.add(s);
}
}

现在我们看看实体是否具有属性。如果它有,我们查看每个属性的 bonus,并为其添加一个描述性词语;因此,低力量是“虚弱” - 高力量是“强壮”(依此类推,对于每个属性)。如果没有修改器,我们使用“非常普通” - 并添加描述行。

#![allow(unused)]
fn main() {
// Comment on pools (属性池注释)
let stat = pools.get(entity);
if let Some(stat) = stat {
    tip.add(format!("Level: {}", stat.level)); // 等级
}
}

我们还检查实体是否具有 Pools 组件,如果它们有 - 我们将它们的等级添加到工具提示中。最后,我们将工具提示添加到 tip_boxes 向量并退出循环:

#![allow(unused)]
fn main() {
        tip_boxes.push(tip);
    }
}
}

如果没有工具提示,那么我们现在不妨退出该函数:

#![allow(unused)]
fn main() {
if tip_boxes.is_empty() { return; }
}

所以如果我们已经走到这一步,就工具提示!我们将使用类似于我们之前使用的代码来确定是将提示放在目标的左侧还是右侧(哪一侧有更多空间):

#![allow(unused)]
fn main() {
let box_gray : RGB = RGB::from_hex("#999999").expect("Oops");
let white = RGB::named(rltk::WHITE);

let arrow;
let arrow_x;
let arrow_y = mouse_pos.1;
if mouse_pos.0 < 40 {
    // Render to the left (渲染到左侧)
    arrow = to_cp437('→');
    arrow_x = mouse_pos.0 - 1;
} else {
    // Render to the right (渲染到右侧)
    arrow = to_cp437('←');
    arrow_x = mouse_pos.0 + 1;
}
ctx.set(arrow_x, arrow_y, white, box_gray, arrow);
}

看看我们是如何设置 arrow_xarrow_y 的?如果鼠标在屏幕的左半部分,我们将其放置在目标左侧的一个 tile(图块)处。如果它在屏幕的右半部分,我们将提示放置在右侧。我们还注意到要绘制哪个字符。接下来,我们将计算所有工具提示的总高度

#![allow(unused)]
fn main() {
let mut total_height = 0;
for tt in tip_boxes.iter() {
    total_height += tt.height();
}
}

这只是所有提示的 height() 函数的总和。现在我们向上移动所有框以使堆栈居中:

#![allow(unused)]
fn main() {
let mut y = mouse_pos.1 - (total_height / 2);
while y + (total_height/2) > 50 {
    y -= 1;
}
}

最后,我们实际绘制这些框:

#![allow(unused)]
fn main() {
for tt in tip_boxes.iter() {
    let x = if mouse_pos.0 < 40 {
        mouse_pos.0 - (1 + tt.width())
    } else {
        mouse_pos.0 + (1 + tt.width())
    };
    tt.render(ctx, x, y);
    y += tt.height();
}
}

如果你现在 cargo run 并将鼠标悬停在角色上方,你将看到类似这样的内容:

Screenshot

这看起来很像我们的原型了!

启用消耗品快捷键

由于我们添加了一种使用 UI 中消耗品的漂亮新方法,我们应该让它们做一些事情!我们在 player.rs 中处理所有玩家输入,所以让我们去那里 - 并查看适当命名的 player_input 函数。我们将在开头添加一个部分,以查看是否按住 shift(这通常是向上箭头的指示),如果 shift 和数字键被按下,则调用一个新函数:

#![allow(unused)]
fn main() {
pub fn player_input(gs: &mut State, ctx: &mut Rltk) -> RunState {
    // Hotkeys (快捷键)
    if ctx.shift && ctx.key.is_some() {
        let key : Option<i32> =
            match ctx.key.unwrap() {
                VirtualKeyCode::Key1 => Some(1),
                VirtualKeyCode::Key2 => Some(2),
                VirtualKeyCode::Key3 => Some(3),
                VirtualKeyCode::Key4 => Some(4),
                VirtualKeyCode::Key5 => Some(5),
                VirtualKeyCode::Key6 => Some(6),
                VirtualKeyCode::Key7 => Some(7),
                VirtualKeyCode::Key8 => Some(8),
                VirtualKeyCode::Key9 => Some(9),
                _ => None
            };
        if let Some(key) = key {
            return use_consumable_hotkey(gs, key-1);
        }
    }
    ...
}

这应该很容易理解:如果 shift 被按下并且一个键被按下(因此 ctx.key.is_some() 返回 true),那么我们 match 数字键码并将 key 设置为匹配的数字(如果调用了其他键,则将其保留为 None)。之后,如果有 Some 键被按下,我们调用 use_consumable_hotkey;如果它返回 true,我们返回新的运行状态 RunState::PlayerTurn 以指示我们做了一些事情。否则,我们让输入像往常一样运行。这留下了编写新函数 use_consumable_hotkey 的工作:

#![allow(unused)]
fn main() {
fn use_consumable_hotkey(gs: &mut State, key: i32) -> RunState {
    use super::{Consumable, InBackpack, WantsToUseItem};

    let consumables = gs.ecs.read_storage::<Consumable>();
    let backpack = gs.ecs.read_storage::<InBackpack>();
    let player_entity = gs.ecs.fetch::<Entity>();
    let entities = gs.ecs.entities();
    let mut carried_consumables = Vec::new();
    for (entity, carried_by, _consumable) in (&entities, &backpack, &consumables).join() {
        if carried_by.owner == *player_entity {
            carried_consumables.push(entity);
        }
    }

    if (key as usize) < carried_consumables.len() {
        use crate::components::Ranged;
        if let Some(ranged) = gs.ecs.read_storage::<Ranged>().get(carried_consumables[key as usize]) {
            return RunState::ShowTargeting{ range: ranged.range, item: carried_consumables[key as usize] };
        }
        let mut intent = gs.ecs.write_storage::<WantsToUseItem>();
        intent.insert(
            *player_entity,
            WantsToUseItem{ item: carried_consumables[key as usize], target: None }
        ).expect("Unable to insert intent");
        return RunState::PlayerTurn;
    }
    RunState::PlayerTurn
}
}

让我们逐步了解一下:

  1. 我们添加了一些 use 语句来引用组件。如果你愿意,你也可以将它们放在文件的顶部,但由于我们只是在函数中使用它们,我们就在这里这样做。
  2. 我们从 ECS 获取一些东西的访问权限;我们已经经常这样做,你现在应该掌握了!
  3. 我们迭代携带的消耗品,完全像我们渲染 GUI 一样 - 但没有名称。我们将这些存储在一个 carried_consumables 向量中,存储物品的 entity
  4. 我们检查请求的按键是否落在向量的范围内;如果不是,我们忽略按键并返回 false。
  5. 我们检查以查看物品是否需要远程目标(ranged targeting);如果需要,我们返回 ShowTargeting 运行状态,就像我们通过菜单使用它一样。
  6. 如果是,那么我们插入一个 WantsToUseItem 组件,就像我们在一段时间前的物品栏处理程序中所做的那样。它属于 player_entity - 玩家正在使用物品。要使用的 item 是来自 carried_consumables 列表的 Entity
  7. 我们返回 PlayerTurn,使游戏进入 PlayerTurn 运行状态。

其余的将自动发生:我们已经编写了 WantsToUseItem 的处理程序,这只是提供了另一种指示玩家想要做什么的方法。

所以现在我们为玩家提供了一种不错的、简单的方法来快速使用物品!

总结

在本章中,我们构建了一个非常不错的 GUI。它还没有像它可以达到的那样流畅 - 我们将在接下来的章节中对其进行添加,但它提供了一个良好的工作基础。本章说明了一个构建 GUI 的良好过程:绘制原型,然后逐步实现它。

这是迭代过程:

Screenshot

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

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

版权 (C) 2019, Herbert Wolverson.