[SOLVED] CharacterControl update not called automatically

Hey folks,
So in my current project, I’ve extended the Minie CharacterControl as a basis for a player character control system - probably overkill for my project as it stands, but I don’t have the time or energy to learn how to write one from scratch. Anyway, I put a lot of code in the update() method, which the Minie documentation (here) says is supposed to be called once a frame, but when I hit ‘execute’, the update method doesn’t seem to be running (as confirmed with a simple print statement check). I created and enabled the BulletAppState and added the control to the physics space - in debug view, the collision object falls correctly - but no dice on the update method.

I found a workaround by adding the control’s update method to a running AppState’s update method, but I feel like that’s probably not the intended use of that. Did I forget to do something simple that I just haven’t thought of?

1 Like

Is the control added to some Spatial in your main scene graph? Is the control enabled? Is the control added to a physics space?

In order:
The control is added to a Spatial (CharacterControl.setSpatial()) before the Spatial is added to the scene graph (RootNode.attachChild()).
Setting setEnabled to false stops the collision from appearing in debug mode, but setting it to true does nothing different from how it is currently (because it’s enabled on construction, per the docs)
The control is added to a physics space via the CharacterControl.setPhysicsSpace() method. In debug view, the collision of the character can be seen falling due to gravity, but the spatial does not follow the collision unless I manually tell it to update.

1 Like

If you haven’t already, you might study/test the example apps that use CharacterControl. Here are a few:

The next step would for you to share some code, ideally a simple-but-complete application that illustrates the problem you’re seeing.

Alright, here are the most relevant files.

First is my player character control:

package com.mygame;

import com.jme3.bullet.collision.shapes.CapsuleCollisionShape;
import com.jme3.bullet.control.CharacterControl;
import com.jme3.input.InputManager;
import com.jme3.input.controls.ActionListener;
import com.jme3.math.FastMath;
import com.jme3.math.Quaternion;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.scene.Spatial;
import java.util.EnumMap;


/**
 * Class that encapsulates the movement functions of the player character on the overworld.
 * @author cameron
 */
public class OverworldCharacter extends CharacterControl implements ActionListener{
    private EnumMap<Control, Boolean> controls = new EnumMap<>(Control.class);
    private Vector3f walkDirection = Vector3f.ZERO.clone();
    private Vector3f lastWalkDirection;
    private final float ACCEL = 0.273f; //arbitrary but it works
    
    private Camera cam;
    
    //movement values are arbitrary, can be adjusted
    private final float walkSpeed = 10.0f;
    private final float runMultiplier = 2.5f;
    //private final Vector3f forecast = Vector3f.UNIT_X.clone(); //will be used to check for interactable objects
    
    /**
     * Basic constructor, sets the player model
     * @param playerModel 
     */
    public OverworldCharacter(Spatial playerModel){
        super (new CapsuleCollisionShape(1.0f, 4.5f), 5);
        this.setSpatial(playerModel);
    }
    
    /**
     * Adds listeners for the controls that matter to the OverworldCharacter controller
     * (i.e. movement and interacting with objects (not yet implemented))
     * @param im 
     */
    public void addInputListeners(InputManager im){
        im.addListener(this, Control.FORWARD.name());
        im.addListener(this, Control.BACKWARD.name());
        im.addListener(this, Control.STRAFE_L.name());
        im.addListener(this, Control.STRAFE_R.name());
        im.addListener(this, Control.INTERACT.name());
        im.addListener(this, Control.RUN.name());
        
        controls.put(Control.FORWARD, false);
        controls.put(Control.BACKWARD, false);
        controls.put(Control.STRAFE_L, false);
        controls.put(Control.STRAFE_R, false);
        controls.put(Control.INTERACT, false);
        controls.put(Control.RUN, false);
    }
    
    /**
     * Marks that a control was pressed or released.
     * @param name - name of control, corresponds with "Control" enum (see addInputListeners for checked values)
     * @param pressed - true if pressed, false otherwise
     * @param tpf - unused but needed to match method signature
     */
    @Override
    public void onAction(String name, boolean pressed, float tpf) {
        controls.put(Control.valueOf(name), pressed);
        System.out.println("Control " + name + "set to " + pressed);
    }
    
    /**
     * Sets the camera for easy access to calculate movement direction
     * @param cam 
     */
    public void setCamera(Camera cam){
        this.cam = cam;
    }
    
    /**
     * LERP interpolation for turning towards the new movement angle;
     *  uses a tertiary point if the player is turning ~180 degrees
     * @param newFaceAngle 
     */
    private void turnToward(Vector3f newFaceAngle){
        //normalize the view directors
        Vector3f faceAngle = this.getViewDirection(null).normalize();
        Vector3f newFace = newFaceAngle.normalize();
        
        if(faceAngle.negate().subtractLocal(newFace).length() < 0.1){ //estimate 180 degree turnaround
            //Tertiary point necessary, use 2d normal of the current facing vector
            Vector3f normal = new Vector3f(-1 * faceAngle.z, 0, faceAngle.x);
            newFace = FastMath.interpolateLinear(0.125f, FastMath.interpolateLinear(0.125f, faceAngle, normal), newFace);
        }
        else //no tertiary point needed, simply interpolate
            newFace = FastMath.interpolateLinear(0.25f, faceAngle, newFace);
        
        //Set view direction
        setViewDirection(newFace);
    }
    
    /**
     * Update method that does the movement relative to the camera.
     * /Should/ be called every frame, but is not unless manually called from OverworldAppState
     * @param tpf - time per frame
     */
    @Override
    public void update(float tpf){
        //System.out.println("OverworldCharacter updated");
        lastWalkDirection = walkDirection.clone();
        //I use a member vector to avoid creating new vectors every frame, but I'm not sure if that 'optimization' is worth the effort
        walkDirection.set(0,0,0);
        
        //Default vectors for camera adjustment, if camera is not set then player will move based on initial rotation
        Vector3f camAdjust_F = Vector3f.UNIT_Z;
        Vector3f camAdjust_L = Vector3f.UNIT_X;
        
        //If camera is set, get the camera's view vectors to adjust the player movement
        if (cam != null){
            Quaternion cameraRot = cam.getRotation();
            camAdjust_F = cameraRot.mult(Vector3f.UNIT_Z).multLocal(1,0,1);
            camAdjust_L = cameraRot.mult(Vector3f.UNIT_X);
        }
        
        //Not super proud of this, but it works.  No else because if player presses forward and backward at once then they will negate and do nothing rather than one having precedence.
        if(controls.get(Control.FORWARD))
            walkDirection.addLocal(camAdjust_F);
        if(controls.get(Control.BACKWARD))
            walkDirection.addLocal(camAdjust_F.negateLocal());
        if(controls.get(Control.STRAFE_L))
            walkDirection.addLocal(camAdjust_L);
        if(controls.get(Control.STRAFE_R))
            walkDirection.addLocal(camAdjust_L.negate());
        
        //Set y value of walk direction to 0 in case of rounding errors from above then normalize.
        walkDirection.setY(0.0f);
        walkDirection.normalizeLocal(); // to prevent the "fast diagonal" problem
        
        //Direction has been calculated and normalized.
        //If walking, turn toward the direction of the walk.
        if(! walkDirection.equals(Vector3f.ZERO)){
            turnToward(walkDirection);
        }
        
        //multiply the walking speed, and multiply the running multiplier if run is held
        walkDirection.multLocal(walkSpeed);
        if(controls.get(Control.RUN))
            walkDirection.multLocal(runMultiplier);
        //TODO animation here
        
        //LERP to get a more smooth transition for walking speed
        walkDirection = FastMath.interpolateLinear(ACCEL, lastWalkDirection, walkDirection);
        this.setWalkDirection(walkDirection);
        
        //Finally, call the superclass update to actually implement the walk, do physics, etc.
        super.update(tpf);
    }
    
}

Here’s the appstate for the overworld, as well:

import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.bullet.BulletAppState;
import com.jme3.bullet.PhysicsSpace;
import com.jme3.input.ChaseCamera;
import com.jme3.input.InputManager;
import com.jme3.light.DirectionalLight;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Vector3f;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;

/**
 * @author cameron 
 */
public class OverworldAppState extends BaseAppState {
    private final OverworldMap map;
    private volatile SimpleApplication sapp;
    private volatile BulletAppState bas;
    
    private Node rootNode;
    private ChaseCamera chaseCam;
    DirectionalLight sun;
    private Node player;
    private OverworldCharacter playerCharacter;
    private Spatial characterModel;
    
    /**
     * Simple constructor, instatiates map object which contains data incl spatial and encounter tables
     * @param map 
     */
    public OverworldAppState(OverworldMap map){
        this.map = map;
    }
    
    /**
     * AppState initialize function; grabs map and player data and constructs player movement system, camera, 
     * @param app 
     */
    @Override
    protected void initialize(Application app) {
        this.sapp = (SimpleApplication)app;
        rootNode = this.sapp.getRootNode();
        bas = sapp.getStateManager().getState(BulletAppState.class);
        PhysicsSpace ps = bas.getPhysicsSpace();
        
        
        rootNode.attachChild(map.getMapScene()); //map.getMapScene() returns a spatial node tree with attached Minie rigidBodyControls
        //ps.addAll(map.getMapScene()); //FIXME this is broken for now
        
        //construct player spatial and offset vertically
        player = new Node(); //created to offset the collision from the model
        characterModel = sapp.getAssetManager().loadModel("Models/robody.j3o");
        player.attachChild(characterModel);
        characterModel.setLocalTranslation(0, -4.0f, 0); //FIXME make values non-arbitrary
        
        //create control and attach to player, add input, attach construct to root node
        playerCharacter = new OverworldCharacter((Spatial)player);
        playerCharacter.setPhysicsLocation(map.getSpawnPoint());
        playerCharacter.setPhysicsSpace(ps);
        playerCharacter.addInputListeners(sapp.getInputManager());
        
        rootNode.attachChild(player);
        
        //boilerplate grabbed from previous project - will probably replace
        chaseCam = new ChaseCamera(sapp.getCamera(), characterModel, sapp.getInputManager());
        chaseCam.setMinDistance(20.0f);
        chaseCam.setMaxDistance(40.0f);
        chaseCam.setDefaultDistance(30.0f);
        chaseCam.setDefaultVerticalRotation(FastMath.PI / 6);
        chaseCam.setMinVerticalRotation(FastMath.PI / 24);
        chaseCam.setDownRotateOnCloseViewOnly(false);
        chaseCam.setMaxVerticalRotation((2 * FastMath.PI) / 6);
        chaseCam.setLookAtOffset(new Vector3f(0, 4, 0));
        chaseCam.setDragToRotate(false);
        chaseCam.setDefaultHorizontalRotation(FastMath.PI / 2 * 3); //start behind the player instead of in front
        playerCharacter.setCamera(sapp.getCamera());
        
        //palette sun code so we can actually see what we're doing
        /* A white, directional light source */ 
        sun = new DirectionalLight();
        sun.setDirection((new Vector3f(-0.5f, -0.5f, -0.5f)).normalizeLocal());
        sun.setColor(ColorRGBA.White);
        rootNode.addLight(sun); 
    }
    
    /**
     * AppState cleanup method, detaches map and player and removes input listeners
     * @param app 
     */
    @Override
    protected void cleanup(Application app) {
        InputManager im = sapp.getInputManager();
        
        //remove spatials
        rootNode.detachChild(map.getMapScene());
        rootNode.detachChild(player);
        
        //remove input listening
        im.removeListener(playerCharacter);
        chaseCam.cleanupWithInput(im); //feel like i'm missing something with the camera
        
        //remove sun
        rootNode.removeLight(sun);
    }
    
    //onEnable()/onDisable() can be used for managing things that should     
    //only exist while the state is enabled. Prime examples would be scene     
    //graph attachment or input listener attachment.    
    @Override
    protected void onEnable() {
        //As of yet unused, add functions for pausing here later
    }
    
    @Override
    protected void onDisable() {
        //Also as of yet unused, add functions for unpausing here later
    }
    
    @Override
    public void update(float tpf) {
        // uncomment to enable the player character's movement...?
        //playerCharacter.update(tpf);
    }
}

Finally, here’s my Main.java at the moment:

package com.mygame;

import com.jme3.app.SimpleApplication;
import com.jme3.app.state.AppStateManager;
import com.jme3.bullet.BulletAppState;
import com.jme3.input.InputManager;
import com.jme3.input.Joystick;
import com.jme3.input.JoystickConnectionListener;
import com.jme3.input.controls.KeyTrigger;
import com.jme3.renderer.RenderManager;
import com.jme3.system.AppSettings;
import com.simsilica.lemur.GuiGlobals;

/**
 * Main class
 * @author normenhansen <- shout outs for the gradle template
 */
public class Main extends SimpleApplication {
    private AppStateManager asm;
    private OverworldAppState overworldState;
    
    private static AppSettings configureSettings(){
        AppSettings settings = new AppSettings(true);
        settings.put("Width", 1280);
        settings.put("Height", 720);
        settings.put("Title", "Untitled RPG");
        settings.setVSync(true);
        settings.setFrequency(60);
        return settings;
    }
    
    private void configureInput(){
        InputManager im = this.getInputManager();
        im.addJoystickConnectionListener(new JoystickConnectionListener(){
            @Override
            public void onConnected(Joystick arg0) {
                System.out.println("Joystick connected: " + arg0.getName());
            }

            @Override
            public void onDisconnected(Joystick arg0) {
                System.out.println("Joystick disconnected: " + arg0.getName());
            }
        });
        for(Control c : Control.values()){
            im.addMapping(c.name(), new KeyTrigger(c.getDefaultKey()));
        }
        
        //flyCam.setEnabled(false);
    }
    
    public static void main(String[] args) {
        Main app = new Main();
        app.setSettings(configureSettings());
        app.showSettings = false;
        app.start();
    }

    public Main(){
        asm = this.getStateManager();
    }
    
    /**
     * Encapsulate loading all of the game data (stored in .json files).
     */
    private void initializeData(){
        OverworldMap.loadMapData(this.getAssetManager());
    }
    
    /**
     * Initialize the gui state; currently unused.
     */
    private void initLemurGui(){
        GuiGlobals.initialize(this);
        //FIXME determine if a different gui style should be used or created
        GuiGlobals.getInstance().getStyles().setDefaultStyle("glass");
    }
    
    @Override
    public void simpleInitApp() {
        initializeData();
        configureInput();
        //initLemurGui();
        BulletAppState bas = new BulletAppState();
        bas.setDebugEnabled(true);
        stateManager.attach(bas);
        OverworldMap mapdata = new OverworldMap("testisle", this.getAssetManager());
        overworldState =  new OverworldAppState(mapdata);
        asm.attach(overworldState);
        overworldState.setEnabled(true);
    }

    @Override
    public void simpleUpdate(float tpf) {
        //TODO: add update code
    }

    @Override
    public void simpleRender(RenderManager rm) {
        //TODO: add render code
    }
}

It’s a lot of code to dump at once imo, but I tried to comment the important bits for clarity. A few changes might need to be made to directly load models rather than pull them from JSON files as I have, but I didn’t want to just post my entire project source code here at the moment. I can make those edits if someone requests that I do so.

The control is added to a Spatial (CharacterControl.setSpatial())

As it says in the javadoc, “Should be invoked only by a subclass or from Spatial. Do not invoke directly from user code.”

The correct way to add a Control to a Spatial is with spatial.addControl(). Try that instead.

1 Like

Yeah that’d about do it. Thanks. I knew it would be something simple like that.

1 Like