第五章 - 视野


关于本教程

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

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

实践中的 Rust


我们有一张绘制精美的地图,但它显示了整个地牢!这降低了探索的实用性——如果我们已经知道所有东西的位置,为什么还要费心探索呢?本章将添加“视野范围”,并调整渲染以显示我们已经发现的地图部分。它还将把地图重构为其自己的结构,而不是仅仅是一个瓦片向量。 本章从第 4 章的代码开始。

地图重构

我们将把与地图相关的功能和数据放在一起,以保持清晰,因为我们正在制作一个越来越复杂的游戏。大部分工作是创建一个新的Map结构,并将我们的辅助函数移到其实现中。

#![allow(unused)]
fn main() {
use rltk::{ RGB, Rltk, RandomNumberGenerator };
use super::{Rect};
use std::cmp::{max, min};

#[derive(PartialEq, Copy, Clone)]
pub enum TileType {
    Wall, Floor
}

pub struct Map {
    pub tiles : Vec<TileType>,
    pub rooms : Vec<Rect>,
    pub width : i32,
    pub height : i32
}

impl Map {
    pub fn xy_idx(&self, x: i32, y: i32) -> usize {
        (y as usize * self.width as usize) + x as usize
    }

    fn apply_room_to_map(&mut self, room : &Rect) {
        for y in room.y1 +1 ..= room.y2 {
            for x in room.x1 + 1 ..= room.x2 {
                let idx = self.xy_idx(x, y);
                self.tiles[idx] = TileType::Floor;
            }
        }
    }

    fn apply_horizontal_tunnel(&mut self, x1:i32, x2:i32, y:i32) {
        for x in min(x1,x2) ..= max(x1,x2) {
            let idx = self.xy_idx(x, y);
            if idx > 0 && idx < self.width as usize * self.height as usize {
                self.tiles[idx as usize] = TileType::Floor;
            }
        }
    }

    fn apply_vertical_tunnel(&mut self, y1:i32, y2:i32, x:i32) {
        for y in min(y1,y2) ..= max(y1,y2) {
            let idx = self.xy_idx(x, y);
            if idx > 0 && idx < self.width as usize * self.height as usize {
                self.tiles[idx as usize] = TileType::Floor;
            }
        }
    }

    /// Makes a new map using the algorithm from http://rogueliketutorials.com/tutorials/tcod/part-3/
    /// This gives a handful of random rooms and corridors joining them together.
    pub fn new_map_rooms_and_corridors() -> Map {
        let mut map = Map{
            tiles : vec![TileType::Wall; 80*50],
            rooms : Vec::new(),
            width : 80,
            height: 50
        };

        const MAX_ROOMS : i32 = 30;
        const MIN_SIZE : i32 = 6;
        const MAX_SIZE : i32 = 10;

        let mut rng = RandomNumberGenerator::new();

        for i in 0..MAX_ROOMS {
            let w = rng.range(MIN_SIZE, MAX_SIZE);
            let h = rng.range(MIN_SIZE, MAX_SIZE);
            let x = rng.roll_dice(1, map.width - w - 1) - 1;
            let y = rng.roll_dice(1, map.height - h - 1) - 1;
            let new_room = Rect::new(x, y, w, h);
            let mut ok = true;
            for other_room in map.rooms.iter() {
                if new_room.intersect(other_room) { ok = false }
            }
            if ok {
                map.apply_room_to_map(&new_room);

                if !map.rooms.is_empty() {
                    let (new_x, new_y) = new_room.center();
                    let (prev_x, prev_y) = map.rooms[map.rooms.len()-1].center();
                    if rng.range(0,2) == 1 {
                        map.apply_horizontal_tunnel(prev_x, new_x, prev_y);
                        map.apply_vertical_tunnel(prev_y, new_y, new_x);
                    } else {
                        map.apply_vertical_tunnel(prev_y, new_y, prev_x);
                        map.apply_horizontal_tunnel(prev_x, new_x, new_y);
                    }
                }

                map.rooms.push(new_room);
            }
        }

        map
    }
}
}

mainplayer中也有变化——详见示例源代码。这使我们的代码整洁了不少——我们可以传递一个Map,而不是一个向量。如果我们想教Map做更多的事情——我们有一个地方可以这样做。

视野组件

不仅仅是玩家有有限的视野!最终,我们希望怪物也能考虑它们能看到什么。因此,由于这是可重用的代码,我们将创建一个Viewshed组件。(我喜欢“viewshed”这个词;它来自制图领域——字面意思是“从这里我能看到什么?”——完美地描述了我们的问题)。我们将为每个拥有Viewshed的实体提供它们能看到的一组瓦片索引。在components.rs中我们添加:

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct Viewshed {
    pub visible_tiles : Vec<rltk::Point>,
    pub range : i32
}
}

main.rs 中,我们告诉系统关于新组件的信息:

#![allow(unused)]
fn main() {
gs.ecs.register::<Viewshed>();
}

最后,在 main.rs 中,我们也会给 Player 一个 Viewshed 组件:

#![allow(unused)]
fn main() {
gs.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),
    })
    .with(Player{})
    .with(Viewshed{ visible_tiles : Vec::new(), range : 8 })
    .build();
}

玩家现在变得相当复杂了——这很好,它展示了 ECS 的用途!

一个新系统:通用视域

我们将首先定义一个系统来为我们处理这个问题。我们希望它是通用的,因此它适用于任何可以从知道自己能看到什么中受益的东西。我们创建一个新文件,visibility_system.rs

#![allow(unused)]
fn main() {
use specs::prelude::*;
use super::{Viewshed, Position};

pub struct VisibilitySystem {}

impl<'a> System<'a> for VisibilitySystem {
    type SystemData = ( WriteStorage<'a, Viewshed>,
                        WriteStorage<'a, Position>);

    fn run(&mut self, (mut viewshed, pos) : Self::SystemData) {
        for (viewshed,pos) in (&mut viewshed, &pos).join() {
        }
    }
}
}

现在我们需要在main.rs中调整run_systems以实际调用系统:

#![allow(unused)]
fn main() {
impl State {
    fn run_systems(&mut self) {
        let mut vis = VisibilitySystem{};
        vis.run_now(&self.ecs);
        self.ecs.maintain();
    }
}
}

我们还需要告诉 main.rs 使用新模块:

#![allow(unused)]
fn main() {
mod visibility_system;
use visibility_system::VisibilitySystem;
}

这实际上还没有做任何事情,但我们已经在调度器中添加了一个系统,一旦我们完善代码以实际绘制可见性,它将适用于具有ViewshedPosition组件的每个实体。

向 RLTK 请求视域:特征实现

RLTK 的设计初衷是不关心你如何选择布局地图:我希望它对任何人都有用,并不是每个人都按照这个教程的方式来做地图。为了在我们的地图实现和 RLTK 之间架起桥梁,它为我们提供了一些 特性 来支持。在这个例子中,我们需要 BaseMapAlgorithm2D。别担心,它们实现起来很简单。

在我们的 map.rs 文件中,我们添加如下内容:

#![allow(unused)]
fn main() {
impl Algorithm2D for Map {
    fn dimensions(&self) -> Point {
        Point::new(self.width, self.height)
    }
}
}

RLTK 能够从 dimensions 函数中推断出许多其他特性:点索引(及其倒数)、边界检查和类似的功能。我们使用已经使用的维度,self.widthself.height

我们还需要支持BaseMap。我们目前不需要全部功能,所以我们将让它使用默认值。在map.rs中:

#![allow(unused)]
fn main() {
impl BaseMap for Map {
    fn is_opaque(&self, idx:usize) -> bool {
        self.tiles[idx as usize] == TileType::Wall
    }
}
}

is_opaque 简单地返回 true 如果该瓷砖是墙,否则返回 false。如果/当我们添加更多类型的瓷砖时,这将需要扩展,但现在可以工作。我们暂时将特性的其余部分保留为默认值(因此不需要输入其他内容)。

向 RLTK 请求视域:系统

所以回到 visibility_system.rs,我们现在有了从 RLTK 请求视野所需的内容。我们将 visibility_system.rs 文件扩展为如下所示:

#![allow(unused)]
fn main() {
use specs::prelude::*;
use super::{Viewshed, Position, Map};
use rltk::{field_of_view, Point};

pub struct VisibilitySystem {}

impl<'a> System<'a> for VisibilitySystem {
    type SystemData = ( ReadExpect<'a, Map>,
                        WriteStorage<'a, Viewshed>,
                        WriteStorage<'a, Position>);

    fn run(&mut self, data : Self::SystemData) {
        let (map, mut viewshed, pos) = data;

        for (viewshed,pos) in (&mut viewshed, &pos).join() {
            viewshed.visible_tiles.clear();
            viewshed.visible_tiles = field_of_view(Point::new(pos.x, pos.y), viewshed.range, &*map);
            viewshed.visible_tiles.retain(|p| p.x >= 0 && p.x < map.width && p.y >= 0 && p.y < map.height );
        }
    }
}
}

这里有很多内容,而视野范围实际上是最简单的部分:

  • 我们添加了一个 ReadExpect<'a, Map> - 这意味着系统应该传递我们的 Map 以供使用。我们使用了 ReadExpect,因为如果没有地图是一个失败。
  • 在循环中,我们首先清除可见瓦片列表。
  • 然后我们调用 RLTK 的 field_of_view 函数,提供起点(实体的位置,从 pos 获取),范围(从视野范围获取),以及一个稍微复杂的“解引用,然后获取引用”来从 ECS 中解包 Map
  • 最后我们使用向量的 retain 方法删除任何不符合我们指定条件的条目。这是一个 lambdaclosure - 它遍历向量,传递 p 作为参数。如果 p 在地图边界内,我们保留它。这防止其他函数尝试访问工作地图区域外的瓦片。

这将现在每帧运行(这是过度杀伤,稍后会详细介绍)——并存储一个可见瓦片列表。

渲染可见性 - 糟糕!

作为第一次尝试,我们将修改我们的 draw_map 函数以检索地图和玩家的视野。它只会绘制视野内的瓦片:

#![allow(unused)]
fn main() {
pub fn draw_map(ecs: &World, ctx : &mut Rltk) {
    let mut viewsheds = ecs.write_storage::<Viewshed>();
    let mut players = ecs.write_storage::<Player>();
    let map = ecs.fetch::<Map>();

    for (_player, viewshed) in (&mut players, &mut viewsheds).join() {
        let mut y = 0;
        let mut x = 0;
        for tile in map.tiles.iter() {
            // Render a tile depending upon the tile type
            // 根据瓦片类型渲染瓦片
            let pt = Point::new(x,y);
            if viewshed.visible_tiles.contains(&pt) {
                match tile {
                    TileType::Floor => {
                        ctx.set(x, y, RGB::from_f32(0.5, 0.5, 0.5), RGB::from_f32(0., 0., 0.), rltk::to_cp437('.'));
                    }
                    TileType::Wall => {
                        ctx.set(x, y, RGB::from_f32(0.0, 1.0, 0.0), RGB::from_f32(0., 0., 0.), rltk::to_cp437('#'));
                    }
                }
            }

            // Move the coordinates
            // 移动坐标
            x += 1;
            if x > 79 {
                x = 0;
                y += 1;
            }
        }
    }
}
}

如果你现在运行示例(cargo run),它会显示玩家所能看到的内容。没有记忆,性能非常糟糕——但它是存在的,而且大致正确。

很明显,我们走在正确的轨道上,但我们需要更有效的方法来做事情。如果玩家也能记住他们看到的地图,那就太好了。

扩展地图以包含已揭示的瓦片

为了模拟地图记忆,我们将扩展我们的Map类以包含一个revealed_tiles结构。它只是地图上每个瓦片的一个bool值——如果为真,那么我们就知道那里有什么。我们的Map定义现在看起来像这样:

#![allow(unused)]
fn main() {
#[derive(Default)]
pub struct Map {
    pub tiles : Vec<TileType>,
    pub rooms : Vec<Rect>,
    pub width : i32,
    pub height : i32,
    pub revealed_tiles : Vec<bool>
}
}

我们还需要扩展填充地图的函数以包含新类型。在 new_rooms_and_corridors 中,我们将地图创建扩展为:

#![allow(unused)]
fn main() {
let mut map = Map{
    tiles : vec![TileType::Wall; 80*50],
    rooms : Vec::new(),
    width : 80,
    height: 50,
    revealed_tiles : vec![false; 80*50]
};
}

这为每个图块添加了一个 false 值。 我们将 draw_map 改为查看这个值,而不是每次迭代组件。现在函数看起来像这样:

#![allow(unused)]
fn main() {
pub fn draw_map(ecs: &World, ctx : &mut Rltk) {
    let map = ecs.fetch::<Map>();

    let mut y = 0;
    let mut x = 0;
    for (idx,tile) in map.tiles.iter().enumerate() {
        // Render a tile depending upon the tile type
        // 根据瓦片类型渲染瓦片
        if map.revealed_tiles[idx] {
            match tile {
                TileType::Floor => {
                    ctx.set(x, y, RGB::from_f32(0.5, 0.5, 0.5), RGB::from_f32(0., 0., 0.), rltk::to_cp437('.'));
                }
                TileType::Wall => {
                    ctx.set(x, y, RGB::from_f32(0.0, 1.0, 0.0), RGB::from_f32(0., 0., 0.), rltk::to_cp437('#'));
                }
            }
        }

        // Move the coordinates
        // 移动坐标
        x += 1;
        if x > 79 {
            x = 0;
            y += 1;
        }
    }
}
}

这将呈现一个黑色屏幕,因为我们从未设置任何要显示的瓦片!所以现在我们扩展VisibilitySystem以知道如何标记瓦片为已显示。为此,它必须检查实体是否是玩家——如果是,它更新地图的显示状态:

#![allow(unused)]
fn main() {
use specs::prelude::*;
use super::{Viewshed, Position, Map, Player};
use rltk::{field_of_view, Point};

pub struct VisibilitySystem {}

impl<'a> System<'a> for VisibilitySystem {
    type SystemData = ( WriteExpect<'a, Map>,
                        Entities<'a>,
                        WriteStorage<'a, Viewshed>,
                        WriteStorage<'a, Position>,
                        ReadStorage<'a, Player>);

    fn run(&mut self, data : Self::SystemData) {
        let (mut map, entities, mut viewshed, pos, player) = data;

        for (ent,viewshed,pos) in (&entities, &mut viewshed, &pos).join() {
            viewshed.visible_tiles.clear();
            viewshed.visible_tiles = field_of_view(Point::new(pos.x, pos.y), viewshed.range, &*map);
            viewshed.visible_tiles.retain(|p| p.x >= 0 && p.x < map.width && p.y >= 0 && p.y < map.height );

            // If this is the player, reveal what they can see
            // 如果这是玩家,揭示他们能看到的东西
            let p : Option<&Player> = player.get(ent);
            if let Some(p) = p {
                for vis in viewshed.visible_tiles.iter() {
                    let idx = map.xy_idx(vis.x, vis.y);
                    map.revealed_tiles[idx] = true;
                }
            }
        }
    }
}
}

这里的主要变化是我们正在获取实体列表以及组件,并获得对玩家存储的只读访问权限。我们将这些添加到要迭代的列表中,并添加一个 let p : Option<&Player> = player.get(ent); 来检查这是否是玩家。相当隐晦的 if let Some(p) = p 仅在存在 Player 组件时运行。然后我们计算索引,并标记为已揭示。

如果你现在运行(cargo run)项目,它比之前的版本快得多,并且记得你曾经去过的地方。

进一步加速 - 在我们需要时重新计算可见性

它仍然没有达到应有的效率!让我们只在需要时更新视域。让我们在 Viewshed 组件中添加一个 dirty 标志:

#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct Viewshed {
    pub visible_tiles : Vec<rltk::Point>,
    pub range : i32,
    pub dirty : bool
}
}

我们还将更新main.rs中的初始化,以表明视野确实是不干净的:.with(Viewshed{ visible_tiles : Vec::new(), range: 8, dirty: true })

我们的系统可以扩展以检查dirty标志是否为真,并且只有在为真时才重新计算 - 完成后将dirty标志设置为假。现在我们需要在玩家移动时设置标志 - 因为他们能看到的内容已经改变了!我们在player.rs中更新try_move_player

#![allow(unused)]
fn main() {
pub fn try_move_player(delta_x: i32, delta_y: i32, ecs: &mut World) {
    let mut positions = ecs.write_storage::<Position>();
    let mut players = ecs.write_storage::<Player>();
    let mut viewsheds = ecs.write_storage::<Viewshed>();
    let map = ecs.fetch::<Map>();

    for (_player, pos, viewshed) in (&mut players, &mut positions, &mut viewsheds).join() {
        let destination_idx = map.xy_idx(pos.x + delta_x, pos.y + delta_y);
        if map.tiles[destination_idx] != TileType::Wall {
            pos.x = min(79 , max(0, pos.x + delta_x));
            pos.y = min(49, max(0, pos.y + delta_y));

            viewshed.dirty = true;
        }
    }
}
}

这应该现在很熟悉了:我们已经添加了viewsheds以获取写存储,并将其包含在我们正在迭代的组件类型列表中。然后在移动后调用一次将标志设置为true

游戏现在再次运行得非常快,如果你输入 cargo run

淡化我们记得但看不见的东西

再增加一个扩展:我们希望渲染那些我们知道存在但目前无法看到的部分地图。因此,我们将当前可见的瓦片列表添加到Map中:

#![allow(unused)]
fn main() {
#[derive(Default)]
pub struct Map {
    pub tiles : Vec<TileType>,
    pub rooms : Vec<Rect>,
    pub width : i32,
    pub height : i32,
    pub revealed_tiles : Vec<bool>,
    pub visible_tiles : Vec<bool>
}
}

我们的创建方法也需要知道如何添加所有假值,就像之前一样:visible_tiles : vec![false; 80*50]。接下来,在我们的 VisibilitySystem 中,我们在开始迭代之前清除可见瓦片列表,并在找到它们时标记当前可见的瓦片。因此,更新视域时运行的代码如下所示:

#![allow(unused)]
fn main() {
if viewshed.dirty {
    viewshed.dirty = false;
    viewshed.visible_tiles.clear();
    viewshed.visible_tiles = field_of_view(Point::new(pos.x, pos.y), viewshed.range, &*map);
    viewshed.visible_tiles.retain(|p| p.x >= 0 && p.x < map.width && p.y >= 0 && p.y < map.height );

    // If this is the player, reveal what they can see
    // 如果这是玩家,揭示他们能看到的东西
    let _p : Option<&Player> = player.get(ent);
    if let Some(_p) = _p {
        for t in map.visible_tiles.iter_mut() { *t = false };
        for vis in viewshed.visible_tiles.iter() {
            let idx = map.xy_idx(vis.x, vis.y);
            map.revealed_tiles[idx] = true;
            map.visible_tiles[idx] = true;
        }
    }
}
}

现在我们调整 draw_map 函数,以不同方式处理已揭示但当前不可见的瓦片。新的 draw_map 函数如下所示:

#![allow(unused)]
fn main() {
pub fn draw_map(ecs: &World, ctx : &mut Rltk) {
    let map = ecs.fetch::<Map>();

    let mut y = 0;
    let mut x = 0;
    for (idx,tile) in map.tiles.iter().enumerate() {
        // Render a tile depending upon the tile type
        // 根据瓦片类型渲染瓦片

        if map.revealed_tiles[idx] {
            let glyph;
            let mut fg;
            match tile {
                TileType::Floor => {
                    glyph = rltk::to_cp437('.');
                    fg = RGB::from_f32(0.0, 0.5, 0.5);
                }
                TileType::Wall => {
                    glyph = rltk::to_cp437('#');
                    fg = RGB::from_f32(0., 1.0, 0.);
                }
            }
            if !map.visible_tiles[idx] { fg = fg.to_greyscale() }
            ctx.set(x, y, fg, RGB::from_f32(0., 0., 0.), glyph);
        }

        // Move the coordinates
        // 移动坐标
        x += 1;
        if x > 79 {
            x = 0;
            y += 1;
        }
    }
}
}

如果你运行你的项目,你现在会看到浅青色的地板和绿色的墙壁——当它们移出视线时会变成灰色。性能应该很好!恭喜——你现在有一个很好的、可用的视野系统。

截图

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

在浏览器中使用 Web Assembly 运行本章示例(需要 WebGL2)


版权 (C) 2019, Herbert Wolverson.

版权 (C) 2024, myedgetech.com.