Simple virtual joystick for JME using Lemur

Hi

As requested in

Sharing the simple virtual joystick I have created to control player movent.

package game;

import app.view.PlayerMovementState;
import com.dalwiestudio.jmeutils.gui.ScreenResizeListener;
import com.dalwiestudio.jmeutils.gui.WindowManagerState;
import com.dalwiestudio.rpg.scene.view.SceneViewState;
import com.jme3.app.Application;
import com.jme3.app.state.BaseAppState;
import com.jme3.bounding.BoundingBox;
import com.jme3.bounding.BoundingSphere;
import com.jme3.bounding.BoundingVolume;
import com.jme3.input.event.MouseButtonEvent;
import com.jme3.input.event.MouseMotionEvent;
import com.jme3.material.Material;
import com.jme3.material.RenderState;
import com.jme3.math.ColorRGBA;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.scene.Geometry;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.shape.Quad;
import com.jme3.terrain.geomipmap.TerrainQuad;
import com.jme3.texture.Texture;
import com.rvandoosselaer.jmeutils.ApplicationGlobals;
import com.simsilica.lemur.GuiGlobals;
import com.simsilica.lemur.Label;
import com.simsilica.lemur.component.QuadBackgroundComponent;
import com.simsilica.lemur.core.GuiMaterial;
import com.simsilica.lemur.event.*;

/**
 * @author Ali-RS
 */
public class VirtualJoystickState extends BaseAppState {

    private Node panel;
    private Node pointer;
    private Geometry blocker;
    private TerrainQuad terrain;
    private PlayerMovementState movementState;

    private final ColorRGBA defaultBackgroundColor = new ColorRGBA(0, 0, 0, 0);
    private final DragListener dragListener = new DragListener();
    private final ResizeListener resizeListener = new ResizeListener();
    private final Vector3f vTemp = new Vector3f();

    public VirtualJoystickState() {
        super("VirtualJoystickState");
    }

    /**
     *  Calculates that maximum Z value given the current contents of
     *  the GUI node.
     */
    protected float getMaxGuiZ() {
        BoundingVolume bv = ApplicationGlobals.getInstance().getGuiNode().getWorldBound();
        return getMaxZ(bv);
    }

    protected float getMaxZ( BoundingVolume bv ) {
        if( bv instanceof BoundingBox) {
            BoundingBox bb = (BoundingBox)bv;
            return bb.getCenter().z + bb.getZExtent();
        } else if( bv instanceof BoundingSphere) {
            BoundingSphere bs = (BoundingSphere)bv;
            return bs.getCenter().z + bs.getRadius();
        } else if( bv == null ) {
            // Apparently this can happen for empty nodes...
            return 0;
        }
        Vector3f offset = bv.getCenter().add(0, 0, 1000);
        return offset.z - bv.distanceTo(offset);
    }


    protected Geometry createBlocker( float z, ColorRGBA backgroundColor ) {
        Camera cam = getApplication().getCamera();

        Node guiNode = ApplicationGlobals.getInstance().getGuiNode();
        // Get the inverse scale of whatever the current guiNode is so that
        // we can find a proper screen size
        float width = cam.getWidth() / guiNode.getLocalScale().x;
        float height = cam.getHeight() / guiNode.getLocalScale().y;

        Quad quad = new Quad(width, height);
        Geometry result = new Geometry("blocker", quad);
        GuiMaterial guiMat = createBlockerMaterial(backgroundColor);
        result.setMaterial(guiMat.getMaterial());
        //result.setQueueBucket(Bucket.Transparent); // no, it goes in the gui bucket.
        result.setLocalTranslation(0, 0, z);
        return result;
    }

    protected GuiMaterial createBlockerMaterial( ColorRGBA color ) {
        GuiMaterial result = GuiGlobals.getInstance().createMaterial(color, false);
        Material mat = result.getMaterial();
        mat.getAdditionalRenderState().setBlendMode(RenderState.BlendMode.Alpha);
        return result;
    }

    @Override
    protected void initialize(Application app) {
        terrain = getState(SceneViewState.class, true).getTerrain();
        movementState = getState(PlayerMovementState.class, true);

        panel = new Node();
        Label joystickLabel = new Label("");
        Texture texture = GuiGlobals.getInstance().loadTexture("Interface/Icons/Gui/play_postion_bg.png", false, false);
        joystickLabel.setBackground(new QuadBackgroundComponent(texture, 60, 50));
        panel.attachChild(joystickLabel);
        Vector3f halfSize = joystickLabel.getPreferredSize().mult(0.5f);
        joystickLabel.move(-halfSize.x, halfSize.y, 0);

        pointer = new Node();
        Label pointerLabel = new Label("");
        texture = GuiGlobals.getInstance().loadTexture("Interface/Icons/Gui/play_postion_point.png", false, false);
        pointerLabel.setBackground(new QuadBackgroundComponent(texture, 25, 15));
        pointer.attachChild(pointerLabel);
        halfSize = pointerLabel.getPreferredSize().mult(0.5f);
        pointerLabel.move(-halfSize.x, halfSize.y, 0);
        panel.attachChild(pointer);

        float zBase = getMaxGuiZ() + 100;
        this.blocker = createBlocker(zBase, defaultBackgroundColor);
        MouseEventControl.addListenersToSpatial(blocker, new BlockerListener());

        getState(WindowManagerState.class).addScreenResizeListener(resizeListener);
    }

    @Override
    protected void cleanup(Application app) {
        getState(WindowManagerState.class).removeScreenResizeListener(resizeListener);
        terrain = null;
        movementState = null;
        panel = null;
        pointer = null;
    }

    @Override
    protected void onEnable() {
        MouseEventControl.addListenersToSpatial(terrain, dragListener);
    }

    @Override
    protected void onDisable() {
        if (isAttached()) {
            detach();
        }

        MouseEventControl.removeListenersFromSpatial(terrain, dragListener);
    }

    @Override
    public void update(float tpf) {
        if (isAttached()) {
            Vector2f cursorPosition = getApplication().getInputManager().getCursorPosition();
            vTemp.set(cursorPosition.x, cursorPosition.y, 0);
            panel.worldToLocal(vTemp, vTemp);
            if (vTemp.lengthSquared() > 3500) {
                vTemp.normalizeLocal().multLocal(59.1607f);
            }

            pointer.setLocalTranslation(vTemp);
            vTemp.set(vTemp.x, 0, -vTemp.y).normalizeLocal();
            movementState.setMoveDirection(vTemp);
        }
    }

    protected boolean isAttached() {
        return panel.getParent() != null;
    }

    protected void attach(Vector2f cursorPosition) {
        if (!isEnabled()) {
            return;
        }

        panel.setLocalTranslation(cursorPosition.x, cursorPosition.y, 0);
        Node guiNode = ApplicationGlobals.getInstance().getGuiNode();
        guiNode.attachChild(panel);
        guiNode.attachChild(blocker);
    }

    protected void detach() {
        if (!isAttached()) {
            return;
        }

        panel.removeFromParent();
        blocker.removeFromParent();
        movementState.setMoveDirection(Vector3f.ZERO);
    }

    private class DragListener extends DefaultMouseListener {
        private float xDown;
        private float yDown;
        private boolean pressed;

        @Override
        public void mouseButtonEvent(MouseButtonEvent event, Spatial target, Spatial capture) {
            pressed = event.isPressed();
            if( pressed ) {
                xDown = event.getX();
                yDown = event.getY();
            }
        }

        @Override
        public void mouseMoved(MouseMotionEvent event, Spatial target, Spatial capture) {
            if(pressed && !isAttached()) {
                float x = event.getX();
                float y = event.getY();
                if( Math.abs(x-xDown) > 3 || Math.abs(y-yDown) > 3 ) {
                    attach(new Vector2f(xDown, yDown));
                }
            }
        }
    }

    private class BlockerListener implements MouseListener {

        public BlockerListener( ) {
        }

        @Override
        public void mouseButtonEvent(MouseButtonEvent event, Spatial target, Spatial capture) {
            event.setConsumed();
            if (!event.isPressed()) {
                detach();
            }
        }

        @Override
        public void mouseEntered( MouseMotionEvent event, Spatial target, Spatial capture ) {
            event.setConsumed();
        }

        @Override
        public void mouseExited( MouseMotionEvent event, Spatial target, Spatial capture ) {
            event.setConsumed();
        }

        @Override
        public void mouseMoved( MouseMotionEvent event, Spatial target, Spatial capture ) {
            event.setConsumed();
        }
    }

    private class ResizeListener implements ScreenResizeListener {
        @Override
        public void resize(int width, int height) {
            Node guiNode = ApplicationGlobals.getInstance().getGuiNode();
            // Get the inverse scale of whatever the current guiNode is so that
            // we can find a proper screen size
            float w = width / guiNode.getLocalScale().x;
            float h = height / guiNode.getLocalScale().y;

            Quad quad = (Quad) blocker.getMesh();
            quad.updateGeometry(w, h);
            quad.clearCollisionData();
            blocker.updateModelBound();
        }
    }
}

It is not documented (:sweat_smile:) so below I will explain how it works. Please ask here if you have a question.

HOW IT WORKS

I added a drag listener to terrain and when a drag happens I display the joystick panel, also I am adding a full-screen transparent blocker quad to the screen when showing the joystick. Blocker is used to consume all mouse events while dragging the joystick and also for detecting the mouse release and thus detaching the joystick. (I have copy-pasted blocker code from Lemur PopupState).

And finally, in the update() function I am calculating the walk direction and sending it to “PlayerMovementState” to do whatever he wants with that!

Also, the “ResizeListener” is to listen for windows resize and update blocker quad size to fit the screen size. That is only needed for desktop and you can rip this part out for Android as the game runs in fullscreen mode on Android and I believe window size can not be changed after the game has run.

But in case you are curious how I am listening to window resize, In the “WindowManagerState” I am adding an empty SceneProcessor to GUI viewport, and when reshape() method is called I am notifying all the registered ScreenResizeListener’s .

/**
 * @author Ali-RS
 */
@FunctionalInterface
public interface ScreenResizeListener {

    public void resize(int width, int height);

}

A demo video:

Hope you find it useful!

Edit:
Sorry, forgot to mention I am not able to share the textures here because of the license, so you need to replace them with your own.

Kind Regards

8 Likes

Thanks for sharing !!

1 Like

thanks for sharing! ! :grinning: :grinning:

1 Like

You’re welcome, guys :slightly_smiling_face:

2 Likes

Concise and helpful code! :+1: :+1: :+1:

1 Like

I think your idea is very good. That’s why I looked at your code.

In your code you write in the comment that this and that implementation is the same as in the Lemur popup or that is copied out of Lemur popup.
Why not use Lemut directly?
Here is my counter-proposal:

package com.simsilica.lemur;

import com.jme3.app.Application;
import com.jme3.input.event.MouseButtonEvent;
import com.jme3.input.event.MouseMotionEvent;
import com.jme3.math.ColorRGBA;
import com.jme3.math.FastMath;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.jme3.scene.Spatial;
import com.simsilica.lemur.component.IconComponent;
import com.simsilica.lemur.component.JoyLayout;
import com.simsilica.lemur.event.MouseListener;
import com.simsilica.lemur.event.PopupState;

/**
 *
 * @author Janusch Rentenatus
 */
public class SimJoystick extends Container {

    private boolean uptodate;
    private MoveJoystick moveJoystick;
    private boolean moving;

    public SimJoystick() {
        uptodate = false;
        moveJoystick = new MoveJoystick();
        moving = false;
    }

    protected void rebuild() {
        clearChildren();
        JoyLayout layout = new JoyLayout();
        setLayout(layout);
        IconComponent imDir = new IconComponent("Textures/joystick/directions.png");
        imDir.setColor(new ColorRGBA(0.78f, 0.83f, 0.88f, 0.72f));
        IconComponent imDot = new IconComponent("Textures/joystick/dotdir.png");
        imDot.setColor(new ColorRGBA(0.92f, 0.97f, 1f, 1f));
        Panel dirPanel = layout.addChild(new Panel());
        Panel dotPanel = layout.addChild(new Panel());
        dirPanel.setBackground(imDir);
        dotPanel.setBackground(imDot);
        setBackground(null);
        moveJoystick.setDotPanel(dotPanel);
        removeMouseListener(moveJoystick);
        addMouseListener(moveJoystick);
        uptodate = true;
    }

    protected void show() {
        if (!uptodate) {
            rebuild();
        }
        PopupState popupState = GuiGlobals.getInstance().getPopupState();
        popupState.showPopup(this);
        moving = true;
    }

    public void close() {
        PopupState popupState = GuiGlobals.getInstance().getPopupState();
        if (popupState.isPopup(this)) {
            popupState.closePopup(this);
        }
        moving = false;
    }

    public void show(Application app) {
        show();

        final Vector2f click2d = app.getInputManager().getCursorPosition();
        float width = app.getCamera().getWidth();

        Vector3f size = getPreferredSize();
        Vector3f trans = new Vector3f(
                click2d.x - size.x * 0.5f,
                click2d.y + size.x * 0.5f,
                102);
        moveJoystick.resetDirection(click2d.clone());
        setLocalTranslation(trans);
    }

    private class MoveJoystick implements MouseListener {

        private Vector2f direction;
        private Panel dotPanel;
        private Vector2f startPos;

        public MoveJoystick() {
            direction = Vector2f.ZERO.clone();
            startPos = Vector2f.ZERO.clone();
            dotPanel = null;
        }

        public void resetDirection(Vector2f start) {
            direction = Vector2f.ZERO.clone();
            startPos = start;
        }

        public void setDotPanel(Panel dp) {
            dotPanel = dp;
        }

        @Override
        public void mouseButtonEvent(MouseButtonEvent arg0, Spatial arg1, Spatial arg2) {
            if (!arg0.isPressed()) {
                close();
            }
        }

        @Override
        public void mouseEntered(MouseMotionEvent arg0, Spatial arg1, Spatial arg2) {
        }

        @Override
        public void mouseExited(MouseMotionEvent arg0, Spatial arg1, Spatial arg2) {
            close();
        }

        @Override
        public void mouseMoved(MouseMotionEvent arg0, Spatial arg1, Spatial arg2) {
            float d = startPos.distanceSquared(arg0.getX(), arg0.getY());
            Vector3f size = getSize();
            float f = Math.min(size.x * size.y * 0.25f, d);
            direction = new Vector2f(arg0.getX() - startPos.x, arg0.getY() - startPos.y);
            if (f != d) {
                direction.multLocal(FastMath.sqrt(f / d));
            }
            dotPanel.setLocalTranslation(direction.x, direction.y, 1f);
        }
    }

    /**
     * Regularly query this method from an update in order to realize the
     * movement in the state.
     *
     * @return direction
     */
    public Vector2f getDirection() {
        if (moving) {
            return moveJoystick.direction;
        } else {
            return Vector2f.ZERO;
        }
    }
}

To get two panels on top of each other, I simplified the bordered layout:

package com.simsilica.lemur.component;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import com.jme3.math.Vector3f;
import com.jme3.scene.Node;
import com.simsilica.lemur.core.GuiControl;
import com.simsilica.lemur.core.GuiLayout;
import java.util.List;

/**
 *
 * @author Paul Speed
 * @author Janusch Rentenatus
 */
public class JoyLayout extends AbstractGuiComponent
        implements GuiLayout, Cloneable {

    private GuiControl parent;

    private List<Node> children = new ArrayList< Node>();

    private Vector3f lastPreferredSize = new Vector3f();

    public JoyLayout() {
    }

    @Override
    public JoyLayout clone() {
        // Easier and better to just instantiate with the proper
        // settings
        JoyLayout result = new JoyLayout();
        return result;
    }

    @Override
    protected void invalidate() {
        if (parent != null) {
            parent.invalidate();
        }
    }

    protected Vector3f getPreferredSize() {
        Vector3f size = Vector3f.ZERO.clone();
        for (Node child : children) {
            size = size.maxLocal(child.getControl(GuiControl.class).getPreferredSize());
        }
        return size;
    }

    @Override
    public void calculatePreferredSize(Vector3f size) {
        Vector3f pref;
        pref = getPreferredSize();
        size.addLocal(pref);
    }

    @Override
    public void reshape(Vector3f pos, Vector3f size) {
        // Note: we use the pos and size for scratch because we
        // are a layout and we should therefore always be last.

        // Make sure the preferred size book-keeping is up to date.
        // Some children don't like being asked to resize without
        // having been asked to calculate their preferred size first.
        calculatePreferredSize(new Vector3f());

        for (Node child : children) {
            child.setLocalTranslation(pos);
            child.getControl(GuiControl.class).setSize(size);
        }

    }

    public <T extends Node> T addChild(T n) {
        if (n.getControl(GuiControl.class) == null) {
            throw new IllegalArgumentException("Child is not GUI element:" + n);
        }
        children.add(n);
        if (parent != null) {
            parent.getNode().attachChild(n);
        }
        invalidate();
        return n;
    }

    public <T extends Node> T addChild(T n, Object... constraints) {
        return addChild(n);
    }

    public void removeChild(Node n) {
        if (!children.remove(n)) {
            throw new RuntimeException("Node is not a child of this layout:" + n);
        }
        if (parent != null) {
            parent.getNode().detachChild(n);
        }
        invalidate();
    }

    public Collection<Node> getChildren() {
        return Collections.unmodifiableCollection(children);
    }

    public void clearChildren() {
        if (parent != null) {
            // Need to detach any children    
            // Have to make a copy to avoid concurrent mod exceptions
            // now that the containers are smart enough to call remove
            // when detachChild() is called.  A small side-effect.
            // Possibly a better way to do this?  Disable loop-back removal
            // somehow?
            Collection<Node> copy = new ArrayList<Node>(children);
            for (Node n : copy) {
                // Detaching from the parent we know prevents
                // accidentally detaching a node that has been
                // reparented without our knowledge
                parent.getNode().detachChild(n);
            }
        }
        children.clear();
        invalidate();
    }

    @Override
    public void attach(GuiControl parent) {
        this.parent = parent;
        Node self = parent.getNode();
        for (Node child : children) {
            self.attachChild(child);
        }
    }

    @Override
    public void detach(GuiControl parent) {
        this.parent = null;
        // Have to make a copy to avoid concurrent mod exceptions
        // now that the containers are smart enough to call remove
        // when detachChild() is called.  A small side-effect.
        // Possibly a better way to do this?  Disable loop-back removal
        // somehow?
        Collection<Node> copy = new ArrayList<Node>(children);
        for (Node n : copy) {
            n.removeFromParent();
        }
    }
}
1 Like

Lemur Popup generates a new blocker mesh every time showing a popup. In case of the joystick panel, I wanted to cache and reuse the blocker geometry.

2 Likes