Tuesday, July 17, 2018

Simple Arcade Game 4: User Input and Spatial Controls

Welcome back to our simple arcade game tutorial where we look into what it takes to make a very simple, flock herding game using jMonkeyEngine. Today we are going to start adding logic to our game. We are going to start with a simple way to steer the white cube we made last time by reading user input, and applying the movements to our game objects via Controls.

First, let's look at our GameState class. Our GameState extends AbstractAppState and overrides the Initialize method. We also have a custom method to spawn our player object. We are going to add a few more lines in our Initialize method to handle user input.

First let's grab the SimpleApplications input manager. The input manager needs to have Mappings added which we will later listen for, so let's define some static mapping names so they are easy to find later. We then take our InputManager and add our mappings to specific key bindings. For keyboard keys we use a KeyTrigger and grab the keycode from the KeyInput class.

public class GameState extends AbstractAppState{
    //We make the input mappings public and static so they are easy to find later
    public static final String PLAYER_FORWARD = "Forward_Move";
    public static final String PLAYER_BACKWARD = "Backward_Move";
    public static final String PLAYER_LEFT = "Left_Move";
    public static final String PLAYER_RIGHT = "Right_Move";
    private Spatial player;
    private Node scene;

    @Override
    public void initialize(AppStateManager stateManager, Application app) {
        SimpleApplication application = (SimpleApplication)app;
        AssetManager am = application.getAssetManager();
        Node rootNode = application.getRootNode();
        scene = new Node("Game_Scene");
        rootNode.attachChild(scene);
        
        player = createPlayer(am);
        scene.attachChild(player);
        
        //configure user input
        InputManager input = application.getInputManager();
        input.addMapping(PLAYER_FORWARD, new KeyTrigger(KeyInput.KEY_UP));
        input.addMapping(PLAYER_BACKWARD, new KeyTrigger(KeyInput.KEY_DOWN));
        input.addMapping(PLAYER_LEFT, new KeyTrigger(KeyInput.KEY_LEFT));
        input.addMapping(PLAYER_RIGHT, new KeyTrigger(KeyInput.KEY_RIGHT));
    }
    
    ...
}

If you run the game now and start pressing the cursor buttons... nothing happens! We still need to register a listener for these mappings. Let's make GameState implement ActionListener so anything we want to do with player input can be done very easily.

The ActionListener interface has a single method we need to override, the onAction(String name, boolean isPressed, float tpf) method. When we register a listener to the key input, the onAction method gets called from ALL keybindings we register. Therefor, we need to sort through all possible inputs with if or switch statements. I prefer if statements because it is easier to assign blocking vs non blocking keybindings.

public class GameState extends AbstractAppState implements ActionListener{
    ...

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

        ...
        
        //disable the default flycam
        application.getStateManager().getState(FlyCamAppState.class).setEnabled(false);

        //configure user input
        InputManager input = application.getInputManager();
        input.addMapping(PLAYER_FORWARD, new KeyTrigger(KeyInput.KEY_UP));
        input.addMapping(PLAYER_BACKWARD, new KeyTrigger(KeyInput.KEY_DOWN));
        input.addMapping(PLAYER_LEFT, new KeyTrigger(KeyInput.KEY_LEFT));
        input.addMapping(PLAYER_RIGHT, new KeyTrigger(KeyInput.KEY_RIGHT));
        //here we register GameState as a listener with the input manager and assign
        //it all the mappings we want to listen to
        input.addListener(this, PLAYER_FORWARD, PLAYER_BACKWARD, PLAYER_LEFT, PLAYER_RIGHT);
    }
    
    ...

    @Override
    public void onAction(String name, boolean isPressed, float tpf) {
        System.out.println(name+" was pressed");
        //We WANT the forward button press to block a backward button press, so we use if/else
        if(name.equals(PLAYER_FORWARD)){
            
        } else if(name.equals(PLAYER_BACKWARD)){
            
        }
        //we DON'T want the forward or backward buttons to block left and right buttons
        if(name.equals(PLAYER_LEFT)){
            
        } else if(name.equals(PLAYER_RIGHT)){
            
        }
    }
}

Now if you press play you can use the arrow keys to print the name of the binding you are currently pressing to the console. You may also notice the camera moving when you use the arrow keys, this is because SimpleApplication creates a FlyCam by default. It's easy enough to turn off by adding another line to our Initialize Method.

We have our user input registered with our input manager, now how do we get this to control our cube? The simple answer, Controls. Let's make a new package com.mrugames.unit and create a new class, Mob.java. Mobs in game design lingo are simply mobile entities, or anything that moves. That is exactly what our mob control is going to do, move whatever it is attached to at a specific speed every frame. Make our Mob class extend AbstractControl and override the 2 abstract methods controlUpdate(float tpf) and controlRender(RenderManager rm, ViewPort vp). We won't be using control render in our mob class, but it is a good place to put render specific code.

For our Mob class we want to store the maximum speed the mob can move as well as the current input direction for the mob. We will assume the input direction is a number between 0 and 1. Every frame that the input direction is greater than 0, we move the mob by it's maxSpeed*inputDirection*tpf (time per frame).

The Control class has a convenient "spatial" field which you can use to access the Spatial the control is attached to. Spatials also have a convenient "move" method which takes a Vector3f argument.

public class Mob extends AbstractControl{
    public float maxSpeed = 1f;
    public Vector2f dir = new Vector2f(0,0);

    @Override
    protected void controlUpdate(float tpf) {
        //every frame that dir is greater than 0, move in the direction of dir by
        //our max speed
        if(dir.lengthSquared() > 0){
            //spatial is a protected field inherited from AbstractControl
            spatial.move(dir.x*maxSpeed*tpf, dir.y*maxSpeed*tpf, 0);
        }
    }

    @Override
    protected void controlRender(RenderManager rm, ViewPort vp) {
        
    }
}

Now that we have our Mob control, we need to attach it to our player Spatial. We can then access our Mob control in our ActionListener to set the Mob.dir to match the players input.

public class GameState extends AbstractAppState implements ActionListener{    

    ...

    private Spatial createPlayer(AssetManager am){
        Mesh mesh = new Box(0.5f,0.5f,1f);
        Geometry geo = new Geometry("Player", mesh);
        Material mat = new Material(am, "Common/MatDefs/Misc/Unshaded.j3md");
        geo.setMaterial(mat);
        //Add a new Mob control to the player
        geo.addControl(new Mob());
        return geo;
    }    

    ...

    @Override
    public void onAction(String name, boolean isPressed, float tpf) {
        Mob playerMob = player.getControl(Mob.class);
        //We WANT the forward button press to block a backward button press, so we use if/else
        if(name.equals(PLAYER_FORWARD)){
            if(isPressed){
                //Called the frame the button was pressed down
                playerMob.dir.y =1 ;
            } else{
                //Called the frame the button was released
                playerMob.dir.y = 0;
            }
        } else if(name.equals(PLAYER_BACKWARD)){
            if(isPressed){
                playerMob.dir.y = -1;
            } else{
                playerMob.dir.y = 0;
            }
        }
        //we DON'T want the forward or backward buttons to block left and right buttons
        if(name.equals(PLAYER_LEFT)){
            if(isPressed){
                playerMob.dir.x = -1;
            } else{
                playerMob.dir.x = 0;
            }
        } else if(name.equals(PLAYER_RIGHT)){
            if(isPressed){
                playerMob.dir.x = 1;
            } else{
                playerMob.dir.x = 0;
            }
        }
    }

As you see there are some simple tricks we use to process the player input. The ifPressed variable will be true the frame that the player activates the mapping, and false on the frame that the mapping is deactivated. This makes on/off functionality easy. If you need to be notified every frame that a mapping is being activated, take a look at the AnalogListener class. We will cover the AnalogListener in more detail later.

Go ahead and press play! You'll see our little cube drift around the screen corresponding to your button presses.

No comments:

Post a Comment