制作起始城镇
关于本教程
本教程是免费且开源的,所有代码均使用 MIT 许可证 - 因此您可以随意使用它。 我希望您会喜欢本教程,并制作出色的游戏!
如果您喜欢这个教程并希望我继续写作,请考虑支持我的 Patreon。
城镇的用途是什么?
回到 设计文档 我们决定:游戏开始于城镇。在城镇中,只有极少的敌人(扒手、暴徒)。你将在一个尚未命名的酒馆(tavern)中开始游戏,身上只有少量的钱袋,最少的初始装备,一杯啤酒,一根干香肠,一个背包和宿醉。城镇允许你访问各种供应商。
从开发的角度来看,这告诉我们几件事:
- 城镇具有故事层面,因为你在那里开始游戏,它奠定了故事的基础 - 给出了一个起点,一个命运(在这种情况下是醉酒后拯救世界的承诺)。因此,城镇暗示了一个特定的舒适的起点,暗示了一些交流来帮助你理解为什么你要踏上冒险者的生活,等等。
- 城镇有供应商。 在这一点上,这可能没有意义,因为我们还没有价值/货币系统 - 但我们知道我们需要一个地方来安置他们。
- 城镇有一个酒馆/客栈/酒吧 - 这是一个起始位置,但它显然足够重要,需要做一些事情!
- 在设计文档的其他地方,我们提到你可以城镇传送门返回到定居点。 这再次暗示了某种舒适/安全感,也暗示了这样做是有用的 - 因此城镇提供的服务需要在整个游戏中保持其效用。
- 最后,城镇是获胜条件:一旦你拿到了亚拉护身符 - 返回城镇就可以拯救世界。 这意味着城镇应该有一些神圣的结构,你必须将护身符归还给它。
- 城镇是新玩家将遇到的第一件事 - 因此它必须看起来生机勃勃且有点流畅,否则玩家只会关闭窗口并尝试其他游戏。 它也可能作为一些教程的地点。
这种讨论对于游戏设计至关重要; 你不希望仅仅因为你能做到就实现某些东西(在大多数情况下;大型开放世界游戏在这方面有所放松)。城镇有目的,而这个目的指导着它的设计。
那么城镇中必须包含什么?
因此,讨论让我们确定城镇必须包含:
- 一个或多个商人。 我们尚未实现商品的销售,但他们需要一个经营场所。
- 一些友善/中立的 NPC 来增添色彩。
- 一座神庙。
- 一家酒馆。
- 城镇传送门到达的地点。
- 一条通往冒险起点的道路。
我们还可以稍微思考一下是什么构成了城镇:
- 通常有一条交通路线(陆路或海路),否则城镇将不会繁荣。
- 通常,会有一个市场(周围的村庄使用城镇进行商业活动)。
- 几乎可以肯定的是,要么有一条河流,要么有一个深层的天然水源。
- 城镇通常有权威人物,至少以警卫或守望者的形式出现。
- 城镇通常也有阴暗面。
我们希望如何生成我们的城镇?
我们可以选择预制城镇。 这样做的好处是,城镇可以进行调整,直到恰到好处,并且运行流畅。 缺点是,在最初几次游戏流程(“runs”)之后,离开城镇变成了一个纯粹的机械步骤; 看看《卡瓦拉的洞穴》(Caves of Qud)中的约帕(Joppa)——它几乎变成了一个“拿走箱子里的东西,和这些人谈话,然后出发”的速度障碍,才进入一个令人惊叹的游戏。
所以——我们想要一个程序生成的城镇,但我们希望保持它的功能性——并使其美观。 要求不多!
制作一些新的瓦片类型
从上面来看,听起来我们需要一些新的瓦片。 城镇中想到的瓦片类型是道路、草地、水(深水和浅水)、桥梁、木地板和建筑墙壁。 我们可以肯定一件事:随着我们的进展,我们将添加大量新的瓦片类型,所以我们最好花时间预先使其成为无缝体验!
如果不小心,map.rs
可能会变得非常复杂,所以让我们把它变成一个独立的模块,带有一个目录。 我们首先创建一个目录 map/
。 然后我们将 map.rs
移动到其中,并将其重命名为 mod.rs
。 现在,我们将 TileType
从 mod.rs
中取出,并将其放入一个新文件 - tiletype.rs
:
#![allow(unused)] fn main() { use serde::{Serialize, Deserialize}; #[derive(PartialEq, Eq, Hash, Copy, Clone, Serialize, Deserialize)] pub enum TileType { Wall, Floor, DownStairs } }
在 mod.rs
中,我们将接受该模块并共享它公开的公共类型:
#![allow(unused)] fn main() { mod tiletype; pub use tiletype::TileType; }
这目前并没有给我们带来太多好处……但现在我们可以开始支持各种瓦片类型了。 随着我们添加功能,您有望明白为什么使用单独的文件可以更容易地找到相关代码:
#![allow(unused)] fn main() { #[derive(PartialEq, Eq, Hash, Copy, Clone, Serialize, Deserialize)] pub enum TileType { Wall, Floor, DownStairs, Road, Grass, ShallowWater, DeepWater, WoodFloor, Bridge } }
这只是整个情况的一部分,因为现在我们需要处理一堆繁琐的工作:你是否可以进入这种类型的瓦片,它们是否会阻挡视野,它们是否具有不同的寻路成本等等。 我们还在地图生成器中做了很多“如果是地板就生成”的代码; 如果你可以有多种地板类型,也许那不是一个好主意? 无论如何,当前的 map.rs
提供了一些我们需要的东西,以便满足 RLTK 的 BaseMap
特征。
我们将编写一些函数来帮助满足此要求,同时将我们的瓦片功能保持在一个地方:
#![allow(unused)] fn main() { pub fn tile_walkable(tt : TileType) -> bool { match tt { TileType::Floor | TileType::DownStairs | TileType::Road | TileType::Grass | TileType::ShallowWater | TileType::WoodFloor | TileType::Bridge => true, _ => false } } pub fn tile_opaque(tt : TileType) -> bool { match tt { TileType::Wall => true, _ => false } } }
现在我们将回到 mod.rs
,并导入这些 - 并使它们对任何想要使用它们的人公开:
#![allow(unused)] fn main() { mod tiletype; pub use tiletype::{TileType, tile_walkable, tile_opaque}; }
我们还需要更新我们的一些函数以使用此功能。 我们使用 blocked
系统确定了很多寻路,所以我们需要更新 populate_blocked
以使用我们刚刚创建的函数来处理各种类型:
#![allow(unused)] fn main() { pub fn populate_blocked(&mut self) { for (i,tile) in self.tiles.iter_mut().enumerate() { self.blocked[i] = !tile_walkable(*tile); } } }
我们还需要更新我们的视野确定代码:
#![allow(unused)] fn main() { impl BaseMap for Map { fn is_opaque(&self, idx:i32) -> bool { let idx_u = idx as usize; if idx_u > 0 && idx_u < self.tiles.len() { tile_opaque(self.tiles[idx_u]) || self.view_blocked.contains(&idx_u) } else { true } } ... }
最后,让我们看看 get_available_exits
。 这使用 blocked 系统来确定出口是否可能,但到目前为止,我们已经硬编码了我们所有的成本。 当只有地板和墙壁可供选择时,这毕竟是一个非常容易的选择! 一旦我们开始提供选择,我们可能希望鼓励某些行为。 如果人们更喜欢在道路上而不是草地上行走,并且绝对更现实的是,除非他们需要,否则他们会避免站在浅水中,那肯定看起来更真实。 所以我们将构建一个成本函数(在 tiletype.rs
中):
#![allow(unused)] fn main() { pub fn tile_cost(tt : TileType) -> f32 { match tt { TileType::Road => 0.8, TileType::Grass => 1.1, TileType::ShallowWater => 1.2, _ => 1.0 } } }
然后我们更新我们的 get_available_exits
以使用它:
#![allow(unused)] fn main() { fn get_available_exits(&self, idx:i32) -> Vec<(i32, f32)> { let mut exits : Vec<(i32, f32)> = Vec::new(); let x = idx % self.width; let y = idx / self.width; let tt = self.tiles[idx as usize]; // Cardinal directions // 基数方向 if self.is_exit_valid(x-1, y) { exits.push((idx-1, tile_cost(tt))) }; if self.is_exit_valid(x+1, y) { exits.push((idx+1, tile_cost(tt))) }; if self.is_exit_valid(x, y-1) { exits.push((idx-self.width, tile_cost(tt))) }; if self.is_exit_valid(x, y+1) { exits.push((idx+self.width, tile_cost(tt))) }; // Diagonals // 对角线方向 if self.is_exit_valid(x-1, y-1) { exits.push(((idx-self.width)-1, tile_cost(tt) * 1.45)); } if self.is_exit_valid(x+1, y-1) { exits.push(((idx-self.width)+1, tile_cost(tt) * 1.45)); } if self.is_exit_valid(x-1, y+1) { exits.push(((idx+self.width)-1, tile_cost(tt) * 1.45)); } if self.is_exit_valid(x+1, y+1) { exits.push(((idx+self.width)+1, tile_cost(tt) * 1.45)); } exits } }
我们将所有成本 1.0
替换为对 tile_cost
函数的调用,并将对角线成本乘以 1.45,以鼓励更自然的移动。
修复我们的相机
我们还需要能够渲染这些瓦片类型,所以我们打开 camera.rs
并将它们添加到 get_tile_glyph
中的 match
语句中:
#![allow(unused)] fn main() { fn get_tile_glyph(idx: usize, map : &Map) -> (rltk::FontCharType, RGB, RGB) { let glyph; let mut fg; let mut bg = RGB::from_f32(0., 0., 0.); match map.tiles[idx] { TileType::Floor => { glyph = rltk::to_cp437('.'); fg = RGB::from_f32(0.0, 0.5, 0.5); } TileType::WoodFloor => { glyph = rltk::to_cp437('.'); fg = RGB::named(rltk::CHOCOLATE); } TileType::Wall => { let x = idx as i32 % map.width; let y = idx as i32 / map.width; glyph = wall_glyph(&*map, x, y); fg = RGB::from_f32(0., 1.0, 0.); } TileType::DownStairs => { glyph = rltk::to_cp437('>'); fg = RGB::from_f32(0., 1.0, 1.0); } TileType::Bridge => { glyph = rltk::to_cp437('.'); fg = RGB::named(rltk::CHOCOLATE); } TileType::Road => { glyph = rltk::to_cp437('~'); fg = RGB::named(rltk::GRAY); } TileType::Grass => { glyph = rltk::to_cp437('"'); fg = RGB::named(rltk::GREEN); } TileType::ShallowWater => { glyph = rltk::to_cp437('≈'); fg = RGB::named(rltk::CYAN); } TileType::DeepWater => { glyph = rltk::to_cp437('≈'); fg = RGB::named(rltk::NAVY_BLUE); } } if map.bloodstains.contains(&idx) { bg = RGB::from_f32(0.75, 0., 0.); } if !map.visible_tiles[idx] { fg = fg.to_greyscale(); bg = RGB::from_f32(0., 0., 0.); // Don't show stains out of visual range // 不显示视野范围外的血迹 } (glyph, fg, bg) } }
开始构建我们的城镇
我们希望停止随机生成地图,而是开始对我们制作的内容进行一些预测。 所以当你开始深度 1 时,你总是得到一个城镇。 在 map_builders/mod.rs
中,我们将创建一个新函数。 现在,它只会回退到随机:
#![allow(unused)] fn main() { pub fn level_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { random_builder(new_depth, rng, width, height) } }
跳转到 main.rs
并更改构建器函数调用以使用我们的新函数:
#![allow(unused)] fn main() { fn generate_world_map(&mut self, new_depth : i32) { self.mapgen_index = 0; self.mapgen_timer = 0.0; self.mapgen_history.clear(); let mut rng = self.ecs.write_resource::<rltk::RandomNumberGenerator>(); let mut builder = map_builders::level_builder(new_depth, &mut rng, 80, 50); ... }
现在,我们将开始充实我们的 level_builder
; 我们希望深度 1 生成城镇地图 - 否则,我们暂时坚持使用随机。 我们还希望通过 match
语句清楚地了解我们如何路由每个级别的程序生成:
#![allow(unused)] fn main() { pub fn level_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { rltk::console::log(format!("Depth: {}", new_depth)); match new_depth { 1 => town_builder(new_depth, rng, width, height), _ => random_builder(new_depth, rng, width, height) } } }
在 mod.rs
文件的顶部,添加:
#![allow(unused)] fn main() { mod town; use town::town_builder; }
在一个新文件 map_builders/town.rs
中,我们将开始我们的函数:
#![allow(unused)] fn main() { use super::BuilderChain; pub fn level_builder(new_depth: i32, rng: &mut rltk::RandomNumberGenerator, width: i32, height: i32) -> BuilderChain { let mut chain = BuilderChain::new(new_depth, width, height); chain.start_with(TownBuilder::new()); let (start_x, start_y) = super::random_start_position(rng); chain.with(AreaStartingPosition::new(start_x, start_y)); chain.with(DistantExit::new()); chain } }
AreaStartingPosition
和 DistantExit
是临时的,用于获得有效的起点/终点。 重点是对 TownBuilder
的调用。 我们还没有编写它,所以我们将逐步完成,直到我们拥有一个我们喜欢的城镇!
这是一个空的骨架开始:
#![allow(unused)] fn main() { pub struct TownBuilder {} impl InitialMapBuilder for TownBuilder { #[allow(dead_code)] fn build_map(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.build_rooms(rng, build_data); } } impl TownBuilder { pub fn new() -> Box<TownBuilder> { Box::new(TownBuilder{}) } pub fn build_rooms(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { } } }
让我们制作一个渔村
让我们从在该区域添加草地、水和码头开始。 我们将首先编写骨架:
#![allow(unused)] fn main() { pub fn build_rooms(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { self.grass_layer(build_data); self.water_and_piers(rng, build_data); // Make visible for screenshot // 为截图设置为可见 for t in build_data.map.visible_tiles.iter_mut() { *t = true; } build_data.take_snapshot(); } }
函数 grass_layer
非常简单:我们将所有内容替换为草地:
#![allow(unused)] fn main() { fn grass_layer(&mut self, build_data : &mut BuilderMap) { // We'll start with a nice layer of grass // 我们将从一个漂亮的草地层开始 for t in build_data.map.tiles.iter_mut() { *t = TileType::Grass; } build_data.take_snapshot(); } }
添加水更有趣。 我们不希望每次都一样,但我们希望保持相同的基本结构。 这是代码:
#![allow(unused)] fn main() { fn water_and_piers(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) { let mut n = (rng.roll_dice(1, 65535) as f32) / 65535f32; let mut water_width : Vec<i32> = Vec::new(); for y in 0..build_data.height { let n_water = (f32::sin(n) * 10.0) as i32 + 14 + rng.roll_dice(1, 6); water_width.push(n_water); n += 0.1; for x in 0..n_water { let idx = build_data.map.xy_idx(x, y); build_data.map.tiles[idx] = TileType::DeepWater; } for x in n_water .. n_water+3 { let idx = build_data.map.xy_idx(x, y); build_data.map.tiles[idx] = TileType::ShallowWater; } } build_data.take_snapshot(); // Add piers // 添加码头 for _i in 0..rng.roll_dice(1, 4)+6 { let y = rng.roll_dice(1, build_data.height)-1; for x in 2 + rng.roll_dice(1, 6) .. water_width[y as usize] + 4 { let idx = build_data.map.xy_idx(x, y); build_data.map.tiles[idx] = TileType::WoodFloor; } } build_data.take_snapshot(); } }
这里发生了很多事情,所以我们将逐步介绍:
- 我们通过掷一个 65,535 面的骰子(如果存在这样的骰子不是很好吗?)并将结果除以最大数,使
n
等于一个介于0.0
和1.0
之间的随机浮点数。 - 我们创建一个名为
water_width
的新向量。 我们将在其中存储每行水瓦片的数量,因为我们在生成它们。 - 对于地图上的每一行
y
:- 我们创建
n_water
。 这是存在的瓦片数量。 我们首先取n
的sin
(正弦)(我们随机化了它以给出随机梯度)。 正弦波很棒,它们给出了一个很好的可预测曲线,你可以沿着它们读取任何位置来确定曲线的位置。 由于sin
给出的数字从 -1 到 1,我们乘以 10 以给出 -10 到 +10。 然后我们加上 14,保证 4 到 24 个水瓦片。 为了使其看起来不规则,我们还添加了一点随机性。 - 我们将它
push
到water_width
向量中,存储起来以供以后使用。 - 我们将
0.1
添加到n
,沿着正弦波前进。 - 然后我们从 0 迭代到
n_water
(作为x
),并将DeepWater
瓦片写入每个水瓦片的位置。 - 我们从
n_water
到n_water+3
添加一些边缘的浅水。
- 我们创建
- 我们拍摄快照,以便您可以观看地图的进程。
- 我们从 0 迭代到 1d4+6,生成 10 到 14 个码头。
- 我们随机选择
y
。 - 我们查找该
y
值的水的放置位置,并从 2+1d6 到water_width[y]+4
开始绘制木地板 - 给出一个码头,该码头延伸到水中一段距离,并方正地结束在陆地上。
- 我们随机选择
如果您 cargo run
,您现在将看到像这样的地图:
添加城镇围墙、碎石路和道路
现在我们有了一些地形,我们应该为城镇添加一些初始轮廓。 使用另一个函数调用扩展 build
函数:
#![allow(unused)] fn main() { let (mut available_building_tiles, wall_gap_y) = self.town_walls(rng, build_data); }
该函数如下所示:
#![allow(unused)] fn main() { fn town_walls(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap) -> (HashSet<usize>, i32) { let mut available_building_tiles : HashSet<usize> = HashSet::new(); let wall_gap_y = rng.roll_dice(1, build_data.height - 9) + 5; for y in 1 .. build_data.height-2 { if !(y > wall_gap_y-4 && y < wall_gap_y+4) { let idx = build_data.map.xy_idx(30, y); build_data.map.tiles[idx] = TileType::Wall; build_data.map.tiles[idx-1] = TileType::Floor; let idx_right = build_data.map.xy_idx(build_data.width - 2, y); build_data.map.tiles[idx_right] = TileType::Wall; for x in 31 .. build_data.width-2 { let gravel_idx = build_data.map.xy_idx(x, y); build_data.map.tiles[gravel_idx] = TileType::Gravel; if y > 2 && y < build_data.height-1 { available_building_tiles.insert(gravel_idx); } } } else { for x in 30 .. build_data.width { let road_idx = build_data.map.xy_idx(x, y); build_data.map.tiles[road_idx] = TileType::Road; } } } build_data.take_snapshot(); for x in 30 .. build_data.width-1 { let idx_top = build_data.map.xy_idx(x, 1); build_data.map.tiles[idx_top] = TileType::Wall; let idx_bot = build_data.map.xy_idx(x, build_data.height-2); build_data.map.tiles[idx_bot] = TileType::Wall; } build_data.take_snapshot(); (available_building_tiles, wall_gap_y) } }
同样,让我们逐步了解它是如何工作的:
- 我们创建一个名为
available_building_tiles
的新HashSet
。 我们将返回它,以便其他函数以后可以使用它。 - 我们将
wall_gap_y
设置为地图上的一个随机y
位置,介于 6 和map.height - 8
之间。 我们将使用它来确定穿过城镇的道路的位置,以及城墙上的大门。 - 我们在地图上迭代
y
轴,跳过最顶部和最底部的瓦片。- 如果
y
在“墙壁间隙”之外(以wall_gap_y
为中心的 8 个瓦片):- 我们在位置
30,y
绘制一个墙瓦,在29,y
绘制一条道路。 这在海岸线后方给出了一个墙壁,并在其前方留出了明显的间隙(显然他们有草坪管理人员!) - 我们还在地图的最东端绘制了一堵墙。
- 我们用砾石填充中间区域。
- 对于获得砾石的瓦片,我们将其添加到
available_building_tiles
集合中。
- 我们在位置
- 如果它在间隙中,我们绘制一条道路。
- 如果
- 最后,我们用墙壁填充 30 和
width-2
之间的行1
和height-2
。
如果您现在 cargo run
,您将拥有城镇的轮廓:
添加一些建筑物
一个没有建筑物的城镇既相当无意义又相当不寻常! 所以让我们添加一些。 我们将向构建器函数添加另一个调用,这次传递我们创建的 available_building_tiles
结构:
#![allow(unused)] fn main() { let mut buildings = self.buildings(rng, build_data, &mut available_building_tiles); }
建筑物代码的核心如下所示:
#![allow(unused)] fn main() { fn buildings(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap, available_building_tiles : &mut HashSet<usize>) -> Vec<(i32, i32, i32, i32)> { let mut buildings : Vec<(i32, i32, i32, i32)> = Vec::new(); let mut n_buildings = 0; while n_buildings < 12 { let bx = rng.roll_dice(1, build_data.map.width - 32) + 30; let by = rng.roll_dice(1, build_data.height)-2; let bw = rng.roll_dice(1, 8)+4; let bh = rng.roll_dice(1, 8)+4; let mut possible = true; for y in by .. by+bh { for x in bx .. bx+bw { if x < 0 || x > build_data.width-1 || y < 0 || y > build_data.height-1 { possible = false; } else { let idx = build_data.map.xy_idx(x, y); if !available_building_tiles.contains(&idx) { possible = false; } } } } if possible { n_buildings += 1; buildings.push((bx, by, bw, bh)); for y in by .. by+bh { for x in bx .. bx+bw { let idx = build_data.map.xy_idx(x, y); build_data.map.tiles[idx] = TileType::WoodFloor; available_building_tiles.remove(&idx); available_building_tiles.remove(&(idx+1)); available_building_tiles.remove(&(idx+build_data.width as usize)); available_building_tiles.remove(&(idx-1)); available_building_tiles.remove(&(idx-build_data.width as usize)); } } build_data.take_snapshot(); } } // Outline buildings // 建筑物轮廓 let mut mapclone = build_data.map.clone(); for y in 2..build_data.height-2 { for x in 32..build_data.width-2 { let idx = build_data.map.xy_idx(x, y); if build_data.map.tiles[idx] == TileType::WoodFloor { let mut neighbors = 0; if build_data.map.tiles[idx - 1] != TileType::WoodFloor { neighbors +=1; } if build_data.map.tiles[idx + 1] != TileType::WoodFloor { neighbors +=1; } if build_data.map.tiles[idx-build_data.width as usize] != TileType::WoodFloor { neighbors +=1; } if build_data.map.tiles[idx+build_data.width as usize] != TileType::WoodFloor { neighbors +=1; } if neighbors > 0 { mapclone.tiles[idx] = TileType::Wall; } } } } build_data.map = mapclone; build_data.take_snapshot(); buildings } }
再次,让我们逐步了解此算法:
- 我们创建一个元组向量,每个元组包含 4 个整数。 这些是建筑物的
x
和y
坐标,以及它在每个维度上的大小。 - 我们创建一个变量
n_buildings
来存储我们放置了多少建筑物,并循环直到我们有 12 个。 对于每个建筑物:- 我们为建筑物选择一个随机的
x
和y
位置,以及一个随机的width
和height
。 - 我们将
possible
设置为true
- 然后循环遍历候选建筑物位置中的每个瓦片。 如果它不在available_building_tiles
集合中,我们将possible
设置为false
。 - 如果
possible
仍然为真,我们再次循环遍历每个瓦片 - 设置为WoodenFloor
。 然后,我们从available_building_tiles
列表中删除该瓦片以及所有四个周围的瓦片 - 确保建筑物之间有间隙。 我们还递增n_buildings
,并将建筑物添加到已完成建筑物的列表中。
- 我们为建筑物选择一个随机的
- 现在我们有 12 个建筑物,我们复制一份地图。
- 我们循环遍历地图“城镇”部分中的每个瓦片。
- 对于每个瓦片,我们计算不是
WoodenFloor
的相邻瓦片数量(在所有四个方向上)。 - 如果相邻瓦片计数大于零,那么我们可以在此处放置墙壁(因为它必须是建筑物的边缘)。 我们写入地图的副本 - 以免影响对后续瓦片的检查(否则,您将看到建筑物被墙壁替换)。
- 对于每个瓦片,我们计算不是
- 我们将副本放回我们的地图中。
- 我们返回已放置建筑物的列表。
如果您现在 cargo run
,您将看到我们有建筑物了!
添加一些门
建筑物很棒,但是没有门。 所以你永远无法进入或离开它们。 我们应该修复这个问题。 使用另一个调用扩展构建器函数:
#![allow(unused)] fn main() { let doors = self.add_doors(rng, build_data, &mut buildings, wall_gap_y); }
add_doors
函数如下所示:
#![allow(unused)] fn main() { fn add_doors(&mut self, rng: &mut rltk::RandomNumberGenerator, build_data : &mut BuilderMap, buildings: &mut Vec<(i32, i32, i32, i32)>, wall_gap_y : i32) -> Vec<usize> { let mut doors = Vec::new(); for building in buildings.iter() { let door_x = building.0 + 1 + rng.roll_dice(1, building.2 - 3); let cy = building.1 + (building.3 / 2); let idx = if cy > wall_gap_y { // Door on the north wall // 北墙上的门 build_data.map.xy_idx(door_x, building.1) } else { build_data.map.xy_idx(door_x, building.1 + building.3 - 1) }; build_data.map.tiles[idx] = TileType::Floor; build_data.spawn_list.push((idx, "Door".to_string())); doors.push(idx); } build_data.take_snapshot(); doors } }
此函数非常简单,但我们将逐步介绍它:
- 我们创建一个新的门位置向量; 我们稍后需要它。
- 对于建筑物列表中的每个建筑物:
- 将
door_x
设置为建筑物水平侧面的一个随机点,不包括角。 - 计算
cy
为建筑物的中心。 - 如果
cy > wall_gap_y
(还记得那个吗?道路在哪里!),我们将门 的y
坐标放置在北侧 - 即building.1
。 否则,我们将其放置在南侧 -building.1 + building.3 - 1
(y
位置加上高度,减一)。 - 我们将门瓦片设置为
Floor
。 - 我们将一个
Door
添加到生成列表中。 - 我们将门添加到 doors 向量中。
- 将
- 我们返回 doors 向量。
如果您现在 cargo run
,您将看到每栋建筑物都出现门:
通往门的路径
用一些通往城镇中各个门的路径来装饰砾石路会很好。 这是有道理的——即使是步行往返建筑物造成的磨损也会侵蚀出一条路径。 因此,我们向构建器函数添加另一个调用:
#![allow(unused)] fn main() { self.add_paths(build_data, &doors); }
add_paths
函数有点长,但非常简单:
#![allow(unused)] fn main() { fn add_paths(&mut self, build_data : &mut BuilderMap, doors : &[usize]) { let mut roads = Vec::new(); for y in 0..build_data.height { for x in 0..build_data.width { let idx = build_data.map.xy_idx(x, y); if build_data.map.tiles[idx] == TileType::Road { roads.push(idx); } } } build_data.map.populate_blocked(); for door_idx in doors.iter() { let mut nearest_roads : Vec<(usize, f32)> = Vec::new(); let door_pt = rltk::Point::new( *door_idx as i32 % build_data.map.width as i32, *door_idx as i32 / build_data.map.width as i32 ); for r in roads.iter() { nearest_roads.push(( *r, rltk::DistanceAlg::PythagorasSquared.distance2d( door_pt, rltk::Point::new( *r as i32 % build_data.map.width, *r as i32 / build_data.map.width ) ) )); } nearest_roads.sort_by(|a,b| a.1.partial_cmp(&b.1).unwrap()); let destination = nearest_roads[0].0; let path = rltk::a_star_search(*door_idx, destination, &mut build_data.map); if path.success { for step in path.steps.iter() { let idx = *step as usize; build_data.map.tiles[idx] = TileType::Road; roads.push(idx); } } build_data.take_snapshot(); } } }
让我们逐步了解:
- 我们首先创建一个
roads
向量,存储地图上每个道路瓦片的地图索引。 我们通过快速扫描地图并将匹配的瓦片添加到我们的列表中来收集此信息。 - 然后我们遍历我们放置的所有门:
- 我们创建另一个向量 (
nearest_roads
),其中包含索引和一个浮点数。 - 我们添加每条道路,以及它的索引以及到门的计算距离。
- 我们按距离对
nearest_roads
向量进行排序,确保元素0
将是最接近的道路位置。 请注意,我们正在为每扇门执行此操作:如果最近的道路是我们添加到另一扇门的道路,它将选择该道路。 - 我们调用 RLTK 的 a 星寻路算法来查找从门到最近道路的路线。
- 我们迭代路径,在路线上的每个位置写入道路瓦片。 我们还将其添加到
roads
向量中,因此它将影响未来的路径。
- 我们创建另一个向量 (
如果您现在 cargo run
,您将看到一个非常不错的城镇起点:
起始位置和出口
我们并不真正想要完全随机的起始位置,也不想要在此地图上故意远离的出口。 因此,我们将编辑我们的 TownBuilder
构造函数以删除提供此功能的其他元构建器:
#![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); chain.start_with(TownBuilder::new()); chain } }
现在我们必须修改我们的 build
函数以提供这些功能。 放置出口很容易 - 我们希望它在东边,在道路上:
#![allow(unused)] fn main() { let exit_idx = build_data.map.xy_idx(build_data.width-5, wall_gap_y); build_data.map.tiles[exit_idx] = TileType::DownStairs; }
放置入口更加困难。 我们希望玩家在酒馆开始他们的旅程 - 但我们还没有决定哪栋建筑物是酒馆! 我们将使酒馆成为地图上最大的建筑物。 毕竟,它对游戏最重要! 以下代码将按大小对建筑物进行排序(在 building_size
向量中,第一个元组元素是建筑物的索引,第二个是它的“正方形瓦片面积”):
#![allow(unused)] fn main() { let mut building_size : Vec<(usize, i32)> = Vec::new(); for (i,building) in buildings.iter().enumerate() { building_size.push(( i, building.2 * building.3 )); } building_size.sort_by(|a,b| b.1.cmp(&a.1)); }
请注意,我们按降序排序(通过执行 b.cmp(&a)
而不是反过来) - 因此最大的建筑物是建筑物 0
。
现在我们可以设置玩家的起始位置:
#![allow(unused)] fn main() { // Start in the pub // 在酒馆开始 let the_pub = &buildings[building_size[0].0]; build_data.starting_position = Some(Position{ x : the_pub.0 + (the_pub.2 / 2), y : the_pub.1 + (the_pub.3 / 2) }); }
如果您现在 cargo run
,您将在酒馆开始 - 并且能够在一个空旷的城镇中导航到出口:
总结
本章介绍了如何使用我们对地图生成的了解来制作一个有针对性的程序生成项目——一个渔村。 西边有一条河流,一条道路,城镇围墙,建筑物和小路。 对于一个起点来说,它看起来一点也不差!
它完全没有 NPC、道具和任何可做的事情。 我们将在下一章纠正这一点。
本章的源代码可以在这里找到
在您的浏览器中使用 Web Assembly 运行本章的示例 (需要 WebGL2)
版权所有 (C) 2019, Herbert Wolverson。