动起来

控制太空船移动

如果屏幕中的东西都不会动,是不能称其为做游戏的。现在基地中的太空船都不受任何控制,至少我们应该能够操作它左右移动才对。下面将演示如何通过左右移动鼠标指针,来控制飞船运动。听起来不错,是吧?

当然这只是一种非常简单的做法,如果你想详细了解如何处理用户输入可以看看这个

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.input.MouseInput;
import com.jme3.input.controls.AnalogListener;
import com.jme3.input.controls.MouseAxisTrigger;
import com.jme3.math.FastMath;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.simsilica.es.EntityData;
import com.simsilica.es.EntitySet;
import com.simsilica.es.Filters;

public class ControlAppState extends AbstractAppState {

    private static final String MOVE_RIGHT = "MOVE_RIGHT";
    private static final String MOVE_LEFT = "MOVE_LEFT";

    private SimpleApplication app;
    private EntityData ed;
    private Vector3f position;
    private EntitySet ship;

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

        super.initialize(stateManager, app);
        this.app = (SimpleApplication) app;

        ed = this.app.getStateManager().getState(EntityDataState.class).getEntityData();
        ship = ed.getEntities(
                Filters.fieldEquals(Model.class, "name", Model.SpaceShip),
                Model.class,
                Position.class
        );

        this.position = new Vector3f(0, -20, 0);
        this.app.getInputManager().addMapping(MOVE_LEFT, new MouseAxisTrigger(MouseInput.AXIS_X, true));
        this.app.getInputManager().addMapping(MOVE_RIGHT, new MouseAxisTrigger(MouseInput.AXIS_X, false));

        this.app.getInputManager().addListener(analogListener, MOVE_LEFT, MOVE_RIGHT);
    }

    private final AnalogListener analogListener = (String name, float value, float tpf) -> {
        if (name.equals(MOVE_LEFT) || name.equals(MOVE_RIGHT)) {
            Vector2f mousePos = app.getInputManager().getCursorPosition();
            float x = FastMath.clamp((mousePos.getX() - app.getCamera().getWidth() / 2) * 0.05f, -22, 22);
            position = new Vector3f(x, -20, 0);
        }
    };

    @Override
    public void update(float tpf) {
        ship.applyChanges();
        ship.stream().findFirst().ifPresent(e -> {
            e.set(new Position(position));
        });
    }

    @Override
    public void cleanup() {
    }
}

游戏中可能有多个太空船,而我们只控制其中的一个(实体)。在本例中,我们只创建了一艘太空船。也许我们可以创建三个太空船:一个用来表示活动的太空船,另外的就缩小一点放在屏幕左上角,用来表示玩家还有几条命。

在下面的代码片段中,我使用了 Filters 类来查询太空船实体。

        ship = ed.getEntities(
                Filters.fieldEquals(Model.class, "name", Model.SpaceShip),
                Model.class,
                Position.class
        );

(译者注:这段代码具体的含义是,在所有包含 Model.class 组件和 Position.class 组件的实体中,查找Model组件中name属性值为 Model.SpaceShip 的实体。)

就如你在上面代码中看到的,这个 Filters 在运行时,只会对拥有 Model.class 组件(和Position.class组件)的实体起作用。我们也可以定义一个不含任何数据的空组件,把它当做标记来查找活动的太空船实体。我不知道这两种方式哪个更合适,具体要视情况而定。就目前而言,现在的方法也够用了。

我们在 analogListener 中读取了鼠标的 x 坐标,并根据它来计算太空船在屏幕中的 x 坐标。这是(控制太空船移动)最简单的办法了,对于本文来说已经足够。如果你觉得我的方法不够真实,也可以根据自己的需要来改进算法。

    private final AnalogListener analogListener = (String name, float value, float tpf) -> {
        if (name.equals(MOVE_LEFT) || name.equals(MOVE_RIGHT)) {
            Vector2f mousePos = app.getInputManager().getCursorPosition();
            float x = FastMath.clamp((mousePos.getX() - app.getCamera().getWidth() / 2) * 0.05f, -22, 22);
            position = new Vector3f(x, -20, 0);
        }
    };

不过仅仅计算出 position 的值,是不会让太空船动起来的,需要在AppState的update循环中更新太空船的 Position 组件。记得吗?所有的 AppState 内部都包含主循环方法。

    @Override
    public void update(float tpf) {
        ship.applyChanges();
        ship.stream().findFirst().ifPresent(e -> {
            e.set(new Position(position));
        });
    }

如你所见,我从太空船实体集(好吧,我知道这个集合中只有一个太空船)中查询到了第一个太空船实体,并用一个 new Position() 对象改变了它的位置。不可修改,记得吗?

译注:出于线程安全考虑,Zay-ES的组件被设计为“Immutalble”,即不可修改。所有的Component的属性都只应提供get方法,不提供set方法。在修改太空船的位置时,正确的做法是实例化一个新的Position对象来代替原来的Position组件,而不是使用set方法来修改旧Position的值。如果你担心在循环中大量实例化对象会导致内存溢出,大可不必担心,JVM的GC回收这种朝生夕死的新生代对象非常拿手,而且耗时极短。关键是别忘了给JVM加上-Xmx参数,控制一下堆内存的大小,否则随便一个小游戏都可能消耗1GB以上的内存。

随后,在 Main.class 中注册我们写好的控制系统。

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

现在我们的太空船能动了吧?真的吗?是的,通过我们在前文中实现的 VisualAppState,能够看到太空船已经能动了。很酷吧?我可以在不修改其他AppState代码的前提下,就让太空船动起来,仅仅是增加了一个控制系统。当然,添加新的系统,意味着要给太空船或入侵者实体增加不同的组件。

我们需要多写几行代码来为实体增加这些组件,而不是在“太空船类”中定义更多的属性。如果你依然坚持面向对象编程思想,脑子可能会越来越混乱。实体组件系统(ECS)框架的核心思路并不是“面向对象”,而是“数据驱动”。至少请不要把这些抽象层次的概念搞混。

飞翔的入侵者

现在我们已经能用鼠标控制太空船运动了,而入侵者还是一动不动。为了让入侵者飞船动起来,我将实现一个非常非常简单的AI系统。

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.Entity;
import com.simsilica.es.EntityData;
import com.simsilica.es.EntitySet;
import com.simsilica.es.Filters;

public class InvadersAIAppState extends AbstractAppState {

    private SimpleApplication app;
    private EntityData ed;
    private EntitySet invaders;
    private float xDir;
    private float yDir;

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

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

        invaders = ed.getEntities(
                Filters.fieldEquals(Model.class, "name", Model.BasicInvader),
                Model.class,
                Position.class);
        xDir = 1f;
        yDir = -1f;
    }

    @Override
    public void update(float tpf) {
        invaders.applyChanges();
        wabbeling(tpf);
    }

    private void wabbeling(float tpf) {
        float xMin = 0;
        float xMax = 0;
        float yMin = 0;
        float yMax = 0;

        for (Entity e : invaders) {
            Vector3f location = e.get(Position.class).getLocation();
            if (location.getX() < xMin) {
                xMin = location.getX();
            }
            if (location.getX() > xMax) {
                xMax = location.getX();
            }
            if (location.getY() < yMin) {
                yMin = location.getY();
            }
            if (location.getY() > yMax) {
                yMax = location.getY();
            }            
            e.set(new Position(location.add(xDir * tpf * 2, yDir * tpf * 0.5f, 0)));
        }
        if (xMax > 22) {
            xDir = -1;
        }
        if (xMin < -22) {
            xDir = 1;
        }
        if (yMax > 20) {
            yDir = -1;
        }
        if (yMin < 0) {
            yDir = 1;
        }
    }

    @Override
    public void cleanup() {
        super.cleanup();
    }
}

别忘了在 Main.class 中注册AI系统。

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

现在入侵者飞船会从左到右、从上到下往复飞行。Enough to show you more chilling entity system approaches. But you can see even now how clean the representation and the logic is separated.

Let's improve that to make the visual a little more Wow. To make it look cooler and more vivid rotate the invaders around there y axis. I use the position component and add there as well a rotation. Of course you also can add a rotation component and do it that way, but then you have to touch more code. So just replace your current position component with the following code

package mygame;

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

public class Position implements EntityComponent {

    private final Vector3f location;
    private final Vector3f rotation;

    public Position(Vector3f location, Vector3f rotation) {
        this.location = location;
        this.rotation = rotation;
    }
    
    public Position(Vector3f location) {
        this (location, new Vector3f(0, 0, 0));
    }

    public Vector3f getLocation() {
        return location;
    }

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

Now we have to deal as well with the rotation in the VisualAppState system. So let's slightly change the updateModelSpatial method of it like that

    private void updateModelSpatial(Entity e, Spatial s) {
        Position p = e.get(Position.class);
        s.setLocalTranslation(p.getLocation());
        float angles[] = new float[3];
        angles[0] = p.getRotation().x;
        angles[1] = p.getRotation().y;
        angles[2] = p.getRotation().z;
        s.setLocalRotation(new Quaternion(angles));
    }

Maybe there is a more elegant way doing this, it's just the way I know. By now the visual state can handle as well rotation. To make them rotate we have to improve our InvadersAIAppState slightly. Replace the code of the wabbeling method in InvadersAIAppState with the following snippet.

    private void wabbeling(float tpf) {
        float xMin = 0;
        float xMax = 0;
        float yMin = 0;
        float yMax = 0;

        for (Entity e : invaders) {
            Vector3f location = e.get(Position.class).getLocation();
            if (location.getX() < xMin) {
                xMin = location.getX();
            }
            if (location.getX() > xMax) {
                xMax = location.getX();
            }
            if (location.getY() < yMin) {
                yMin = location.getY();
            }
            if (location.getY() > yMax) {
                yMax = location.getY();
            }            
            Vector3f rotation = e.get(Position.class).getRotation();
            rotation = rotation.add(0, tpf * FastMath.DEG_TO_RAD * 90, 0);
            if (rotation.y > FastMath.RAD_TO_DEG * 360) {
                rotation.setY(rotation.getY() - FastMath.DEG_TO_RAD * 360);
            }
            e.set(new Position(location.add(xDir * tpf * 2, yDir * tpf * 0.5f, 0), rotation));
        }
        if (xMax > 22) {
            xDir = -1;
        }
        if (xMin < -22) {
            xDir = 1;
        }
        if (yMax > 20) {
            yDir = -1;
        }
        if (yMin < 0) {
            yDir = 1;
        }
    }

Only this has actually changed

            Vector3f rotation = e.get(Position.class).getRotation();
            rotation = rotation.add(0, tpf * FastMath.DEG_TO_RAD * 90, 0);
            if (rotation.y > FastMath.RAD_TO_DEG * 360) {
                rotation.setY(rotation.getY() - FastMath.DEG_TO_RAD * 360);
            }
            e.set(new Position(location.add(xDir * tpf * 2, yDir * tpf * 0.5f, 0), rotation));

and gives a 90 degree per second y axis rotation to all invaders. I added some logic to clamp the rotation to 360 degrees.