对于游戏开发者,可能或多或少听说过或者使用过 ECS 的结构。现在大多数游戏使用的还是面向对象的模式,面向对象编程并没有什么问题,但是在游戏开发过程中,当游戏不断的超出原有的设计时,从整个工程的扩展来说,ECS 拥有更好的扩展性。
下面是一个面向对象的例子
BaseEntity
Monster
MeleeMob
OrcWarrior
ArcherMob
OrcArcher
BaseEntity 是一个基类,包含了基本的数据和代码,用于表示地图上的一个实体。Monster 是一个怪物,MeleeMob 是一个近战单位,它会发现近处的目标,然后干掉它。ArcherMob 是一个可以远程攻击的单位。完成这样一个实体逻辑,可能首先需要按照不同的单位,将逻辑分开,并且抽出共同的逻辑,放到基类中。如果这时增加了一个兽人的逻辑,它即可以近战,又可以远程,并且在完成某些任务时,会变得更友好,怎么办?那就改逻辑呗,继续抽象,分离和整理代码。有很多已经上线的游戏是这样做的。但是,如果使用 ECS,这样的扩展会变得更加容易。
Entity Component 的模式会消除面向对象模式中的层级结构,取而代之的是使用 Components 来描述一切东西,例如一个兽人,一只狼,一瓶药水等等。Component 只是数据,给一个实体,添加多个 Component。
例如,我们可以创建一个实体,然后赋予它一些 Components,Position,Renderable,MelleAI,RangedAI,Hostile,Friendly 等等。对于近战单位,可以给它 MelleAI,Renderable 等。对于远程单位,可以给它 RangedAI,Renderable。对于兽人,在完成任务前,它有 Hostile 组件,而完成任务后,会变得更加友好,那就移除 Hostile 然后添加 Friendly。可以按照游戏逻辑随意地去组合。
ECS 中的 “S” 叫做 System。System 会从 Components 中读取数据,然后执行自己的逻辑代码。例如在单位的显示逻辑中,用于显示的 System 会从单位的 Position 和 Renderable 组件中拿取数据,然后用于在地图上显示实体。
对于小型游戏来说,在使用 ECS 时可能会感觉比面向对象写了更多的代码,前期确实是这样,但是对于后期添加逻辑,ECS 就变得容易了很多。
使用 ECS 很重要的一点就是要知道 ECS 只是处理组合的一种方式,但并不是唯一的方式。对于实现功能来说,并没有什么唯一的方式,在了解了原理后,按照自己的习惯和喜好来就行。
接着上一节的 hellorust 工程,使用 Specs 库
cargo add specs
cargo add specs-derive
Cargo.toml
[package]
name = "helloworld"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rltk = "0.8.1"
specs = "0.17.0"
specs-derive = "0.4.1"
#[macro_use]
use rltk::{GameState, Rltk, RGB, VirtualKeyCode};
use specs::prelude::*;
use specs_derive::Component;
// 定义一个 Position Component
#[derive(Component, Debug)]
struct Position {
x: i32,
y: i32,
}
#[derive(Component, Debug)]
struct Renderable {
glyph: rltk::FontCharType,
fg: RGB,
bg: RGB,
}
struct State {
ecs: World,
}
impl GameState for State {
fn tick(&mut self, ctx: &mut Rltk){
ctx.cls();
let positions = self.ecs.read_storage::<Position>();
let renderables = self.ecs.read_storage::<Renderable>();
for(pos, render) in (&positions, &renderables).join() {
ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph);
}
}
}
fn main() -> rltk::BError {
use rltk::RltkBuilder;
let context = RltkBuilder::simple80x50()
.with_title("Roguelike Toturial")
.build()?;
let mut gs = State {
ecs: World::new()
};
gs.ecs.register::<Position>();
gs.ecs.register::<Renderable>();
gs.ecs
.create_entity()
.with(Position { x: 40, y: 25 })
.with(Renderable {
glyph: rltk::to_cp437('@'),
fg: RGB::named(rltk::YELLOW),
bg: RGB::named(rltk::BLACK),
})
.build();
for i in 0..10 {
gs.ecs
.create_entity()
.with(Position {x: i * 7, y: 20 })
.with(Renderable {
glyph: rltk::to_cp437('☺'),
fg: RGB::named(rltk::RED),
bg: RGB::named(rltk::BLACK)
})
.build();
}
rltk::main_loop(context, gs)
}
上面的代码中首先定义了 Position 和 Renderable 组件,然后在原来的 State 中定义一个 World。在 main 函数中,先在 World 中注册所用到的组件,可以理解为让 world 知道都有哪些组件。然后向 world 添加实体。最后在 tick 函数中,先从 world 中集所有的 position 和 renderable 组件,然后遍历,使用组件所包含的数据,用来显示。cargo run 结果如下

添加向左移动的 System
首先定义一个组件 LeftMover
#[derive(Component)]
struct LeftMover {}
然后像上面一样,注册 Component
gs.ecs.register::<LeftMover>();
将 LeftMover 组件赋予新添加的 10 个实体
for i in 0..10 {
gs.ecs
.create_entity()
.with(Position { x: i * 7, y: 20 })
.with(Renderable {
glyph: rltk::to_cp437('☺'),
fg: RGB::named(rltk::RED),
bg: RGB::named(rltk::BLACK),
})
.with(LeftMover{})
.build();
}
接下来定义我们的第一个 System。System 是完全独立的逻辑,它会从传入的 ECS 中拿取自己所需要的数据,进行一定的逻辑或修改,然后告诉 ecs 数据有变动。
struct LeftWalker {}
impl<'a> System<'a> for LeftWalker {
type SystemData = (ReadStorage<'a, LeftMover>,
WriteStorage<'a, Position>);
fn run(&mut self, (lefty, mut pos) : Self::SystemData) {
for (_lefty,pos) in (&lefty, &mut pos).join() {
pos.x -= 1;
if pos.x < 0 { pos.x = 79; }
}
}
}
上面的 type SystemData 是告诉 Specs 库,这个系统所需要的数据来自什么 Component。
为 State 实现一个函数,用来运行系统
impl State {
fn run_systems(&mut self) {
let mut lw = LeftWalker{};
lw.run_now(&self.ecs);
self.ecs.maintain();
}
}
上面的代码首先创建了一个可写的 LeftWalker 实例,然后将 ecs 传给它,它会从传入的 ecs 中读取所需要的组件,然后执行自己的逻辑。self.ecs.maintain() 是告诉 Specs 库,如果 Component 的数据有任何修改,则要 Apply。
最后在 tick 函数中调用 run_systems
impl GameState for State {
fn tick(&mut self, ctx: &mut Rltk){
ctx.cls();
self.run_systems();
let positions = self.ecs.read_storage::<Position>();
let renderables = self.ecs.read_storage::<Renderable>();
for(pos, render) in (&positions, &renderables).join() {
ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph);
}
}
}
汇总所有的代码,如下
#[macro_use]
use rltk::{GameState, Rltk, RGB, VirtualKeyCode};
use specs::prelude::*;
use specs_derive::Component;
// 定义一个 Position Component
#[derive(Component, Debug)]
struct Position {
x: i32,
y: i32,
}
#[derive(Component, Debug)]
struct Renderable {
glyph: rltk::FontCharType,
fg: RGB,
bg: RGB,
}
#[derive(Component)]
struct LeftMover{}
struct LeftWalker {}
impl<'a> System<'a> for LeftWalker {
type SystemData = (ReadStorage<'a, LeftMover>, WriteStorage<'a, Position>);
fn run(&mut self, (lefty, mut pos): Self::SystemData) {
for(_lefty, pos) in (&lefty, &mut pos).join() {
pos.x -= 1;
if pos.x < 0 { pos.x = 79 };
}
}
}
struct State {
ecs: World,
}
impl State {
fn run_systems(&mut self) {
let mut lw = LeftWalker{};
lw.run_now(&self.ecs);
self.ecs.maintain();
}
}
impl GameState for State {
fn tick(&mut self, ctx: &mut Rltk){
ctx.cls();
self.run_systems();
let positions = self.ecs.read_storage::<Position>();
let renderables = self.ecs.read_storage::<Renderable>();
for(pos, render) in (&positions, &renderables).join() {
ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph);
}
}
}
fn main() -> rltk::BError {
use rltk::RltkBuilder;
let context = RltkBuilder::simple80x50()
.with_title("Roguelike Toturial")
.build()?;
let mut gs = State {
ecs: World::new()
};
gs.ecs.register::<Position>();
gs.ecs.register::<Renderable>();
gs.ecs.register::<LeftMover>();
gs.ecs
.create_entity()
.with(Position { x: 40, y: 25 })
.with(Renderable {
glyph: rltk::to_cp437('@'),
fg: RGB::named(rltk::YELLOW),
bg: RGB::named(rltk::BLACK),
})
.build();
for i in 0..10 {
gs.ecs
.create_entity()
.with(Position {x: i * 7, y: 20 })
.with(Renderable {
glyph: rltk::to_cp437('☺'),
fg: RGB::named(rltk::RED),
bg: RGB::named(rltk::BLACK)
})
.with(LeftMover{})
.build();
}
rltk::main_loop(context, gs)
}
使用 cargo run 将产生下面的效果

添加控制玩家移动逻辑
接下来我们添加使用上下左右键来控制玩家移动,也就是 @ 符号的移动。
首先添加一个 Player 组件
#[derive(Component, Debug)]
struct Player {}
和上面一下,注册 Player 组件
gs.ecs.register::<Player>();
为玩家实体赋予 Player 组件
gs.ecs
.create_entity()
.with(Position { x: 40, y: 25 })
.with(Renderable {
glyph: rltk::to_cp437('@'),
fg: RGB::named(rltk::YELLOW),
bg: RGB::named(rltk::BLACK),
})
.with(Player{})
.build();
在 main.rs 文件中添加一个新的函数,try_move_player
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>();
for (_player, pos) in (&mut players, &mut positions).join() {
pos.x = min(79 , max(0, pos.x + delta_x));
pos.y = min(49, max(0, pos.y + delta_y));
}
}
上面的函数会接收 delta_x 和 delta_y 参数,用来控制玩家往哪里移动
然后再添加一下 player_input 函数,用来处理键盘输入
fn player_input(gs: &mut State, ctx: &mut Rltk) {
// Player movement
match ctx.key {
None => {} // Nothing happened
Some(key) => match key {
VirtualKeyCode::Left => try_move_player(-1, 0, &mut gs.ecs),
VirtualKeyCode::Right => try_move_player(1, 0, &mut gs.ecs),
VirtualKeyCode::Up => try_move_player(0, -1, &mut gs.ecs),
VirtualKeyCode::Down => try_move_player(0, 1, &mut gs.ecs),
_ => {}
},
}
}
最后在 tick 函数中调用 player_input
player_input(self, ctx);
最终所有代码如下
#[macro_use]
use rltk::{GameState, Rltk, RGB, VirtualKeyCode};
use specs::prelude::*;
use specs_derive::Component;
use std::cmp::{max, min};
// 定义一个 Position Component
#[derive(Component, Debug)]
struct Position {
x: i32,
y: i32,
}
#[derive(Component, Debug)]
struct Renderable {
glyph: rltk::FontCharType,
fg: RGB,
bg: RGB,
}
#[derive(Component, Debug)]
struct Player {}
#[derive(Component)]
struct LeftMover{}
struct LeftWalker {}
impl<'a> System<'a> for LeftWalker {
type SystemData = (ReadStorage<'a, LeftMover>, WriteStorage<'a, Position>);
fn run(&mut self, (lefty, mut pos): Self::SystemData) {
for(_lefty, pos) in (&lefty, &mut pos).join() {
pos.x -= 1;
if pos.x < 0 { pos.x = 79 };
}
}
}
struct State {
ecs: World,
}
impl State {
fn run_systems(&mut self) {
let mut lw = LeftWalker{};
lw.run_now(&self.ecs);
self.ecs.maintain();
}
}
impl GameState for State {
fn tick(&mut self, ctx: &mut Rltk){
ctx.cls();
player_input(self, ctx);
self.run_systems();
let positions = self.ecs.read_storage::<Position>();
let renderables = self.ecs.read_storage::<Renderable>();
for(pos, render) in (&positions, &renderables).join() {
ctx.set(pos.x, pos.y, render.fg, render.bg, render.glyph);
}
}
}
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>();
for(_player, pos) in (&mut players, &mut positions).join(){
pos.x = min(79, max(0, pos.x + delta_x));
pos.y = min(49, max(0, pos.y + delta_y));
}
}
fn player_input(gs: &mut State, ctx: &mut Rltk){
match ctx.key {
None => {}
Some(key) => match key {
VirtualKeyCode::Left => try_move_player(-1, 0, &mut gs.ecs),
VirtualKeyCode::Right => try_move_player(1, 0, &mut gs.ecs),
VirtualKeyCode::Up => try_move_player(0, -1, &mut gs.ecs),
VirtualKeyCode::Down => try_move_player(0, 1, &mut gs.ecs),
_ => {}
}
}
}
fn main() -> rltk::BError {
use rltk::RltkBuilder;
let context = RltkBuilder::simple80x50()
.with_title("Roguelike Toturial")
.build()?;
let mut gs = State {
ecs: World::new()
};
gs.ecs.register::<Position>();
gs.ecs.register::<Renderable>();
gs.ecs.register::<LeftMover>();
gs.ecs.register::<Player>();
gs.ecs
.create_entity()
.with(Position { x: 40, y: 25 })
.with(Renderable {
glyph: rltk::to_cp437('@'),
fg: RGB::named(rltk::YELLOW),
bg: RGB::named(rltk::BLACK),
})
.with(Player {})
.build();
for i in 0..10 {
gs.ecs
.create_entity()
.with(Position {x: i * 7, y: 20 })
.with(Renderable {
glyph: rltk::to_cp437('☺'),
fg: RGB::named(rltk::RED),
bg: RGB::named(rltk::BLACK)
})
.with(LeftMover{})
.build();
}
rltk::main_loop(context, gs)
}
使用 cargo run 运行,然后通过上下左右键就可以控制玩家实体的移动
猫语互动
欢迎关注微信公众号 猫语互动,博客文章同步推送
