可视化

在教程的第一部分中,我们完成了基本的初始化工作,建立了游戏的骨架,并在屏幕上显示了一个孤零零的太空船,但是依然与实体系统没什么关系。好吧,其实我们创建了一个关于ES的类,只是没有实际用上它。我们在这篇教程中会用到它的,我保证。

我们需要入侵者

一个“太空入侵者”游戏没有“入侵者”,是称不上“太空入侵者”游戏的。所以请启动你的Blender,做个“入侵者”模型吧。当然,如果你不想浪费时间,我在google drive为你分享了一个我做的模型,将其命名为"BasicInvader"。

译注:无法访问google drive的同学,我已经分享到了百度网盘

现在,朋友,我们要使用实体系统来保存一些“入侵者”和一艘太空船的基本数据了。我们需要哪些数据才能让它们在屏幕上正确显示出来呢?我们需要知道它们的位置(Position)和模型(Model)。

OK,一般来说,这时候你可能会根据传统面向对象编程(OOP)的思维来设计游戏,并采用继承、多态等特性来设计你的游戏系统,创建诸如SpaceShip、Invader之类的游戏对象。也许你还会使用JME3的Spatial.setUserData()来保存一些逻辑数据,并用Control来控制一部分游戏逻辑,结果是得到一个乱的像意大利面一样的程序架构。至少对我来说,这是经常发生的。

因此,把程序的逻辑和显示分层,是个非常不错的想法。使用实体系统时,这是自然而言的事情。在实体系统中,通过给太空船添加任何我们能够想到的组件,就可以在不修改原有代码的基础上,扩展太空船的功能。甚至,(实体的)数据和方法也是分离的,这与面向对象编程(OOP)思想完全对立(在OOP中,对象的数据和方法是一体的)。如果你有很深的OOP思想,那么本文描述的开发模式可能会让你十分困惑。但是请对我耐心点,也对自己耐心点。

我在哪

我们的太空船,以及入侵者,需要有自己的位置(Position),所以下面我们来定义一个Position组件。

package mygame;

import com.jme3.math.Vector3f;
import com.simsilica.es.EntityComponent;

public class Position implements EntityComponent {

    private final Vector3f location;

    public Position(Vector3f location) {
        this.location = location;
    }

    public Vector3f getLocation() {
        return location;
    }

    @Override
    public String toString() {
        return getClass().getSimpleName() + "[" + location + "]";
    }
}

注意,这个组件的值是不可改变的(final,且没有setLocation()方法),而且只有纯数据(没有逻辑方法)。这种设计有助于在多线程模式下运行,不必顾虑数据同步的问题。我们晚点会涉及到多线程。

我是谁

我们还需要知道每个实体应加载哪个模型,因此要定义一个Model组件。它看起来和上面的Position组件很像,也只有纯数据。

package mygame;

import com.simsilica.es.EntityComponent;

public class Model implements EntityComponent {
    private final String name;
    public final static String SpaceShip = "SpaceShip";
    public final static String BasicInvader = "BasicInvader";

    public Model(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    @Override
    public String toString() {
        return "Model[" + name + "]";
    }
}

为了避免打错字,我定义了两个常量字符串,分别存储太空船和入侵者模型的名称。那么,这玩意该怎么用呢?

生成实体

现在,我们要使用实体系统(EntityData对象)来保存“入侵者”和“太空船”的数据,暂时还不显示出来。“实体”在这个框架中只有ID,别的什么都没有。你得把“组件”设置给“实体”,这个“实体”才有意义。然后你需要查询具有特定“组件”的实体集合,比如本例中定义的Position和Model。它(EntityData)就像是个数据库。

下面我们需要一个GameAppState类,用来建造游戏关卡。(这个类将控制游戏的主要过程,)从“开局”到“游戏”,从“游戏”到“死亡”,从“死亡”再到“重新开局”。但是,我们现在先不做地那么复杂,一点点丰满这个游戏比较好。

package mygame;

import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.AbstractAppState;
import com.jme3.app.state.AppStateManager;
import com.jme3.math.Vector3f;
import com.simsilica.es.EntityData;
import com.simsilica.es.EntityId;

public class GameAppState extends AbstractAppState {

    private EntityData ed;
    private SimpleApplication app;

    @Override
    public void initialize(AppStateManager stateManager, Application app) {

        this.app = (SimpleApplication) app;
        this.app.setPauseOnLostFocus(true);
        this.app.setDisplayStatView(true);

        this.ed = this.app.getStateManager().getState(EntityDataState.class).getEntityData();

        // 创建太空船实体
        EntityId ship = ed.createEntity();
        this.ed.setComponents(ship,
                new Position(new Vector3f(0, -20, 0)),
                new Model(Model.SpaceShip));
        
        // 创建入侵者实体
        for (int x = -20; x < 20; x += 4) {
            for (int y = 0; y < 20; y += 4) {
                EntityId invader = ed.createEntity();
                this.ed.setComponents(invader,
                        new Position(new Vector3f(x, y, 0)),
                        new Model(Model.BasicInvader));
            }
        }
    }

    @Override
    public void cleanup() {
    }

    @Override
    public void update(float tpf) {
    }

}

在上面的代码中,我们只是创建了一些“太空船”和“入侵者”实体,这就够了。至少足够为你展示实体系统工作时的样子。

显示实体

重点来了。下面我们要显示实体系统生成的“入侵者”,哦还有“太空船”。把你当前的 VisualAppState ,改成下面的代码。

package mygame;

import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.AbstractAppState;
import com.jme3.app.state.AppStateManager;
import com.jme3.light.DirectionalLight;
import com.jme3.math.Vector3f;
import com.jme3.scene.Spatial;
import com.simsilica.es.Entity;
import com.simsilica.es.EntityData;
import com.simsilica.es.EntityId;
import com.simsilica.es.EntitySet;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

public class VisualAppState extends AbstractAppState {
    private SimpleApplication app;
    private EntityData ed;
    private EntitySet entities;
    private final Map<EntityId, Spatial> models;
    private ModelFactory modelFactory;

    public VisualAppState() {
        this.models = new HashMap<EntityId, Spatial>();
    }
    
    @Override
    public void initialize(AppStateManager stateManager, Application app) {
        super.initialize(stateManager, app);
        this.app = (SimpleApplication) app;

        // 筛选用于显示的实体
        ed = this.app.getStateManager().getState(EntityDataState.class).getEntityData();
        entities = ed.getEntities(Position.class, Model.class);
        
        // 初始化摄像机,从Z轴正上方往下看。
        app.getCamera().lookAt(Vector3f.UNIT_Z, Vector3f.UNIT_Y);
        app.getCamera().setLocation(new Vector3f(0, 0, 60));
        
        // 添加定向光源
        DirectionalLight light = new DirectionalLight();
        light.setDirection(new Vector3f(1, 1, -1));
        this.app.getRootNode().addLight(light);
        
        // 加载太空船模型
        modelFactory = new ModelFactory(this.app.getAssetManager());
    }

    @Override
    public void cleanup() {
        entities.release();
        entities = null;
    }

    @Override
    public void update(float tpf) {
        if (entities.applyChanges()) {
            removeModels(entities.getRemovedEntities());
            addModels(entities.getAddedEntities());
            updateModels(entities.getChangedEntities());
        }
    }

    private void removeModels(Set<Entity> entities) {
        for (Entity e : entities) {
            Spatial s = models.remove(e.getId());
            s.removeFromParent();
        }
    }

    private void addModels(Set<Entity> entities) {
        for (Entity e : entities) {
            Spatial s = createVisual(e);
            models.put(e.getId(), s);
            updateModelSpatial(e, s);
            this.app.getRootNode().attachChild(s);
        }
    }

    private void updateModels(Set<Entity> entities) {
        for (Entity e : entities) {
            Spatial s = models.get(e.getId());
            updateModelSpatial(e, s);
        }
    }

    private void updateModelSpatial(Entity e, Spatial s) {
        Position p = e.get(Position.class);
        s.setLocalTranslation(p.getLocation());
    }

    private Spatial createVisual(Entity e) {
        Model model = e.get(Model.class);
        return modelFactory.create(model.getName());
    }
}

上面的代码中使用了实体系统编程的一种设计模式,你几乎会在所有的游戏中使用这种模式,至少在客户端如此。

详细分析

这段代码具体做了什么呢?首先,我们筛选了需要显示的实体集合。

        ed = this.app.getStateManager().getState(EntityDataState.class).getEntityData();
        entities = ed.getEntities(Position.class, Model.class);

在VisualAppState中,我们只关心那些拥有Position和Model的实体。这段代码会把拥有这2个组件的“太空船”和“入侵者”实体筛选出来。

然后是主循环,每个游戏的核心。JME3为每个AppState提供了update方法作为主循环。

    public void update(float tpf) {
        if (entities.applyChanges()) {
            removeModels(entities.getRemovedEntities());
            addModels(entities.getAddedEntities());
            updateModels(entities.getChangedEntities());
        }
    }

在主循环中,我们检查筛选出来的实体集合是否更新,并处理那些消亡的新生的以及产生了变化的实体。所有实体的模型及其对应的ID,都保存在一个“本地”HashMap对象中。

    private final Map<EntityId, Spatial> models;

通过这种方式,我们就可以管理场景中的实体模型了。下面我们先直接看看 removeModels() 方法是怎么工作的。

    private void removeModels(Set<Entity> entities) {
        for (Entity e : entities) {
            Spatial s = models.remove(e.getId());
            s.removeFromParent();
        }
    }

实体的模型被保存在HashMap对象中,根据实体ID查询出对应的Spatial对象,然后把它从可视节点(visual node)中删除掉。这就搞定了。

好吧,当然,在删除之前得先添加点东西到场景中。添加实体模型由 addModels() 方法负责处理。

    private void addModels(Set<Entity> entities) {
        for (Entity e : entities) {
            Spatial s = createVisual(e);
            models.put(e.getId(), s);
            updateModelSpatial(e, s);
            this.app.getRootNode().attachChild(s);
        }
    }

在上面的代码中,我们把所有创建好的Spatial对象保存在HashMap中。同时,还把它们添加到场景图的根节点(root node)显示出来。

updateModel() 方法相当简单。通过实体ID,就能在HashMap中找到对应的Spatial,然后根据实体的Position组件来更新它的位置。

最后,你得在Main类中注册新写的AppState,代码如下。

    public Main() {
        super(new VisualAppState(),
                new GameAppState(),
                new EntityDataState());
    }

运行程序,就能看到下面的结果。

就是这样,你的第一个基于实体系统驱动的软件模块。