第五章 - 视野
关于本教程
本教程是免费且开源的,所有代码使用 MIT 许可证——因此您可以随意使用它。我希望您会喜欢这个教程,并制作出很棒的游戏!
如果你喜欢这个并且希望我继续写作,请考虑支持我的 Patreon。
我们有一张绘制精美的地图,但它显示了整个地牢!这降低了探索的实用性——如果我们已经知道所有东西的位置,为什么还要费心探索呢?本章将添加“视野范围”,并调整渲染以显示我们已经发现的地图部分。它还将把地图重构为其自己的结构,而不是仅仅是一个瓦片向量。 本章从第 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 } } }
在main
和player
中也有变化——详见示例源代码。这使我们的代码整洁了不少——我们可以传递一个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; }
这实际上还没有做任何事情,但我们已经在调度器中添加了一个系统,一旦我们完善代码以实际绘制可见性,它将适用于具有Viewshed 和Position组件的每个实体。
向 RLTK 请求视域:特征实现
RLTK 的设计初衷是不关心你如何选择布局地图:我希望它对任何人都有用,并不是每个人都按照这个教程的方式来做地图。为了在我们的地图实现和 RLTK 之间架起桥梁,它为我们提供了一些 特性 来支持。在这个例子中,我们需要 BaseMap
和 Algorithm2D
。别担心,它们实现起来很简单。
在我们的 map.rs
文件中,我们添加如下内容:
#![allow(unused)] fn main() { impl Algorithm2D for Map { fn dimensions(&self) -> Point { Point::new(self.width, self.height) } } }
RLTK 能够从 dimensions
函数中推断出许多其他特性:点索引(及其倒数)、边界检查和类似的功能。我们使用已经使用的维度,self.width
和 self.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
方法删除任何不符合我们指定条件的条目。这是一个 lambda 或 closure - 它遍历向量,传递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.