对于游戏开发者,可能或多或少听说过或者使用过 ECS 的结构。现在大多数游戏使用的还是面向对象的模式,面向对象编程并没有什么问题,但是在游戏开发过程中,当游戏不断的超出原有的设计时,从整个工程的扩展来说,ECS 拥有更好的扩展性。

下面是一个面向对象的例子

BaseEntity
    Monster
        MeleeMob
            OrcWarrior
        ArcherMob
            OrcArcher

BaseEntity 是一个基类,包含了基本的数据和代码,用于表示地图上的一个实体。Monster 是一个怪物,MeleeMob 是一个近战单位,它会发现近处的目标,然后干掉它。ArcherMob 是一个可以远程攻击的单位。完成这样一个实体逻辑,可能首先需要按照不同的单位,将逻辑分开,并且抽出共同的逻辑,放到基类中。如果这时增加了一个兽人的逻辑,它即可以近战,又可以远程,并且在完成某些任务时,会变得更友好,怎么办?那就改逻辑呗,继续抽象,分离和整理代码。有很多已经上线的游戏是这样做的。但是,如果使用 ECS,这样的扩展会变得更加容易。

Entity Component 的模式会消除面向对象模式中的层级结构,取而代之的是使用 Components 来描述一切东西,例如一个兽人,一只狼,一瓶药水等等。Component 只是数据,给一个实体,添加多个 Component。

例如,我们可以创建一个实体,然后赋予它一些 Components,PositionRenderableMelleAIRangedAIHostileFriendly 等等。对于近战单位,可以给它 MelleAIRenderable 等。对于远程单位,可以给它 RangedAIRenderable。对于兽人,在完成任务前,它有 Hostile 组件,而完成任务后,会变得更加友好,那就移除 Hostile 然后添加 Friendly。可以按照游戏逻辑随意地去组合。

ECS 中的 “S” 叫做 System。System 会从 Components 中读取数据,然后执行自己的逻辑代码。例如在单位的显示逻辑中,用于显示的 System 会从单位的 PositionRenderable 组件中拿取数据,然后用于在地图上显示实体。

对于小型游戏来说,在使用 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)
}

上面的代码中首先定义了 PositionRenderable 组件,然后在原来的 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 运行,然后通过上下左右键就可以控制玩家实体的移动

萌一小栈

欢迎关注微信公众号 萌一小栈,博客文章同步推送