Yet Another jMonkey GUI

In developing the user options GUI screens for my game, I ran into some significant issues with Nifty. In searching for an alternative, I found the native jMonkey implementation for Lemur, but Lemur lacked the XML configuration ability that I leveraged with Nifty.

I have reworked the Lemur code into a new project called ‘Loris’. The Lemur visual presentation metaphor has been retained, but the configuration setup is now XML based.
The source is available at the LorisGUI SourceForge site along with libraries and a working example application. Documentation is available on the SourceForge website.

If you would like a native jMonkey GUI that is XML based, please give it a try (but note: Loris has NO backwards/cross compatibility with Lemur or Nifty). I have just released Loris as an initial beta version. Everything that I have tested works, but I could use a lot more people banging on it. Give it try and report problems/issues/enhancements via the SourceForge ticketing system. I am happy to help out anyone who is interested.

4 Likes

Impressed by the documentation… good work! :slight_smile:

Cool soon we will open a zoo :stuck_out_tongue:.
Great work on the documentation, I will surely try it.
the top bar of the documentation is taking too much space that I often find my self-looking at it rather than reading the doc.

Have you tested this with a second viewport, ie. a minimap window?

By tested I mean actually click on the buttons when they are using the second viewports Gui node and inside the second viewport.

I never thought of it and I have done no such testing.
Do you have some sample code that opens a second viewport?

I have shrunk down the header a bit

1 Like

much better :wink:

This is something I setup for @pspeed.
EDIT: Forgot to mention this is an AppState.

Needs Lemur, slf4j, test-data.

•There is one line in initMiniMap that can be uncommented to show the difference when miniCam is full screen.
• addCollisionRoot is in initMiniMap.
•When miniCam is in the top right corner, click in the top left corner of main view to fire listener.

package mygame;

import com.jme3.app.Application;
import com.jme3.app.SimpleApplication;
import com.jme3.app.state.BaseAppState;
import com.jme3.collision.CollisionResult;
import com.jme3.collision.CollisionResults;
import com.jme3.input.MouseInput;
import com.jme3.input.controls.ActionListener;
import com.jme3.input.controls.MouseButtonTrigger;
import com.jme3.material.Material;
import com.jme3.math.ColorRGBA;
import com.jme3.math.Ray;
import com.jme3.math.Vector2f;
import com.jme3.math.Vector3f;
import com.jme3.renderer.Camera;
import com.jme3.renderer.RenderManager;
import com.jme3.renderer.ViewPort;
import com.jme3.renderer.queue.RenderQueue;
import com.jme3.scene.Geometry;
import com.jme3.scene.Node;
import com.jme3.scene.Spatial;
import com.jme3.scene.shape.Box;
import com.jme3.scene.shape.Sphere;
import com.jme3.ui.Picture;
import com.simsilica.lemur.GuiGlobals;
import com.simsilica.lemur.event.CursorButtonEvent;
import com.simsilica.lemur.event.CursorEventControl;
import com.simsilica.lemur.event.DefaultCursorListener;
import com.simsilica.lemur.event.PickState;
import com.simsilica.lemur.style.BaseStyles;
import java.util.logging.Level;
import java.util.logging.Logger;

public class TestGuiNodeState extends BaseAppState implements ActionListener {

    private static final Logger LOG = Logger.getLogger(TestGuiNodeState.class.getName());
    private CollisionResult closest;
    private Geometry mark;
    private Camera miniCam;
    private Node miniMapNode;
    private Node miniMapGuiNode;
    
    @Override
    protected void initialize(Application app) {
                // Create a simple container for our elements
        // Initialize the globals access so that the defualt
        // components can find what they need.
        GuiGlobals.initialize(app);
            
        // Load the 'glass' style
        BaseStyles.loadGlassStyle();
            
        // Set 'glass' as the default style when not specified
        GuiGlobals.getInstance().getStyles().setDefaultStyle("glass");
        
        Box b = new Box(1, 1, 1);
        Geometry geom = new Geometry("Box", b);

        Material mat = new Material(app.getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
        mat.setColor("Color", ColorRGBA.Green);
        geom.setMaterial(mat);
        
        Node terrainNode = new Node("terrainNode");
        terrainNode.setLocalTranslation(new Vector3f(-3.0f, 0.0f, 0.0f));
        ((SimpleApplication) app).getRootNode().attachChild(terrainNode);
        terrainNode.attachChild(geom);
        
        //setup minimap Nodes
        miniMapNode           = new Node("miniMapNode"); //self managed
        miniMapGuiNode        = new Node("miniMapGuiNode"); //self managed
        miniMapGuiNode.setQueueBucket(RenderQueue.Bucket.Gui);
        miniMapGuiNode.setCullHint(Spatial.CullHint.Never);
                
        intiMiniMap(terrainNode);
        initKeys();
        initMark();
    }
    
    private void intiMiniMap(Node terrainNode) {
        
        miniCam = getApplication().getCamera().clone();
        miniCam.lookAt(terrainNode.getLocalTranslation(), miniCam.getUp().clone());
        
        //Change to make full screen
        miniCam.setViewPort(.5f, 1.0f, 0.5f, 1.0f);
//        miniCam.setViewPort(0.0f, 1.0f, 0.0f, 1.0f);
        
        ViewPort miniMapViewPort = getApplication().getRenderManager().createMainView("MiniMapView", miniCam);
        miniMapViewPort.setClearFlags(true, true, true);
        miniMapViewPort.setBackgroundColor(ColorRGBA.Gray);

        //Attach scenes
        miniMapViewPort.attachScene(miniMapNode);
        miniMapViewPort.attachScene(terrainNode);
        
        // Create a new cam for the guiNode
        Camera miniMapGuiCam = miniCam.clone();

        ViewPort miniMapGuiViewPort = getApplication().getRenderManager().createPostView("MiniMapGuiViewPort", miniMapGuiCam);
        miniMapGuiViewPort.setClearFlags(false, false, false);
        
        //Attach scenes
        miniMapGuiViewPort.attachScene(miniMapGuiNode);  
        
        //Add picking to the guiNode
        getStateManager().getState(PickState.class).addCollisionRoot(miniMapGuiNode, miniMapGuiViewPort, PickState.PICK_LAYER_GUI);

        Node monkeyNode = addButton("Monkey", "Interface/icons/SmartMonkey128.png", 128, 128, 0.15f, 0.85f, true, miniCam);
        miniMapGuiNode.attachChild(monkeyNode);

        CursorEventControl.addListenersToSpatial(monkeyNode, new DefaultCursorListener() {
            @Override
            protected void click( CursorButtonEvent event, Spatial target, Spatial capture ) {
                System.out.println("I've been clicked:" + target + " " + event.getViewPort().getName() + " " + event.getLocation() );
            }
        });
    }
    
    private void initKeys() {
        getApplication().getInputManager().addMapping("Pick", new MouseButtonTrigger(MouseInput.BUTTON_LEFT));
        getApplication().getInputManager().addListener(this, "Pick");
    }
    
    /**
     * A red ball that marks the last spot that was "hit" by the "shot".
     */
    protected void initMark() {
        Sphere sphere = new Sphere(30, 30, 0.1f);
        mark = new Geometry("BOOM!", sphere);
        Material mark_mat = new Material(getApplication().getAssetManager(), "Common/MatDefs/Misc/Unshaded.j3md");
        mark_mat.setColor("Color", ColorRGBA.Red);
        mark.setMaterial(mark_mat);
    }
    
        /**
     * Creates and positions a picture to be attached to a gui node.
     * @param name the string name for the picture. Will be appended with the 
     * word "Picture".
     * @param asset the path of the asset to be used.
     * @param butWidth the width of the picture. Scales to this width if not 
     * the actual width of image size.
     * @param butHeight the height of the picture. Scales to this height if not 
     * the actual height of image size.
     * @param screenWidth the position to place the picture in the cameras 
     * view. Must be a float between 0 and 1 where 0 = all the way to the left 
     * side of the view and 1 = all the way to the right side of the view. 
     * @param screenHeight the position to place the picture in the cameras 
     * view. Must be a float between 0 and 1 where 0 = all the way to the bottom 
     * of the view and 1 = all the way to the top of the view.
     * @param alpha set to true if you want to use the supplied images Alpha 
     * channel.
     * @param camera Cam used to calculate node position.
     * @return returns a node that needs to be attached to a gui node.
     */
    public Node addButton(String name, String asset, int butWidth, int butHeight, 
            float screenWidth, float screenHeight, boolean alpha, Camera camera) {
        
        int width = (int) (camera.getWidth() * screenWidth);
        int height = (int) (camera.getHeight()* screenHeight);

        //Image to use for the button.
        Picture button = new Picture(name + "Picture");
        button.setImage(getApplication().getAssetManager(), asset, alpha);
        button.setWidth(butWidth);
        button.setHeight(butHeight);
        
        //Center the image.
        button.center();
        //Add to the GUI bucket.
        button.setQueueBucket(RenderQueue.Bucket.Gui);

        Node node = new Node(name + "Node");
        node.attachChild(button);
        node.setLocalTranslation(width, height, 0);
        
        return node;
    }
    
        /**
     * Checks if the cursor position is within the given ViewPort.
     * @param vp ViewPort to check cursor position against.
     * @return true if the cursor is within the supplied ViewPort.
     */
    private boolean miniViewHasFocus(ViewPort vp) {
        
        //No ViewPort to check.
        if (vp == null) {
            LOG.log(Level.SEVERE, "viewPortHasFocus: Null ViewPort.");
            return false;
        }
        
        float x1   = vp.getCamera().getViewPortLeft();
        float x2   = vp.getCamera().getViewPortRight();
        float y1   = vp.getCamera().getViewPortBottom();
        float y2   = vp.getCamera().getViewPortTop();
        int width  = vp.getCamera().getWidth();
        int height = vp.getCamera().getHeight();
        
        float x = getApplication().getInputManager().getCursorPosition().getX();
        float y = getApplication().getInputManager().getCursorPosition().getY();
        
        return x >= (x1*width) && x <= (x2*width) && y >= (y1*height) && y <= (y2*height);
    }

    @Override
    protected void cleanup(Application app) {
        //TODO: clean up what you initialized in the initialize method,
        //e.g. remove all spatials from rootNode
    }

    //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() {
        //Called when the state is fully enabled, ie: is attached and 
        //isEnabled() is true or when the setEnabled() status changes after the 
        //state is attached.
    }

    @Override
    protected void onDisable() {
        //Called when the state was previously enabled but is now disabled 
        //either because setEnabled(false) was called or the state is being 
        //cleaned up.
    }
    
    @Override
    public void update(float tpf) {
        miniMapNode.updateLogicalState(tpf);
        miniMapGuiNode.updateLogicalState(tpf);
    }
    
    @Override
    public void render(RenderManager rm) {
        miniMapNode.updateGeometricState();
        miniMapGuiNode.updateGeometricState();
    }

    @Override
    public void onAction(String name, boolean isPressed, float tpf) {
        if (name.equals("Pick") && !isPressed && !miniViewHasFocus(getApplication().getRenderManager().getMainView("MiniMapView"))) {
            CollisionResults results = new CollisionResults();
            Vector2f click2d = getApplication().getInputManager().getCursorPosition().clone();
            Vector3f click3d = getApplication().getCamera().getWorldCoordinates(click2d, 0f).clone();
            Vector3f dir = getApplication().getCamera().getWorldCoordinates(
                    click2d, 1f).subtractLocal(click3d).normalizeLocal();
            Ray ray = new Ray(click3d, dir);
            ((SimpleApplication) getApplication()).getRootNode().collideWith(ray, results);

            for (int i = 0; i < results.size(); i++) {
                // For each hit, we know distance, impact point, name of geometry.
                float dist = results.getCollision(i).getDistance();
                Vector3f pt = results.getCollision(i).getContactPoint();
                String hit = results.getCollision(i).getGeometry().getName();
                System.out.println("* Collision #" + i);
                System.out.println(
                        "  You shot " + hit
                        + " at " + pt
                        + ", " + dist + " wu away.");
            }

            if (results.size() > 0) {
                // The closest collision point is what was truly hit:
                closest = results.getClosestCollision();
                // Let's interact - we mark the hit with a red dot.
                mark.setLocalTranslation(closest.getContactPoint());
                ((SimpleApplication) getApplication()).getRootNode().attachChild(mark);
            } else {
                // No hits? Then remove the red mark.
                ((SimpleApplication) getApplication()).getRootNode().detachChild(mark);
            }
        }
        if (name.equals("Pick") && !isPressed && miniViewHasFocus(getApplication().getRenderManager().getMainView("MiniMapView"))) {
            CollisionResults results = new CollisionResults();
            Vector2f click2d = getApplication().getInputManager().getCursorPosition().clone();
            Vector3f click3d = miniCam.getWorldCoordinates(click2d, 0f).clone();
            Vector3f dir = miniCam.getWorldCoordinates(
                    click2d, 1f).subtractLocal(click3d).normalizeLocal();
            Ray ray = new Ray(click3d, dir);
            ((SimpleApplication) getApplication()).getRootNode().collideWith(ray, results);

            for (int i = 0; i < results.size(); i++) {
                // For each hit, we know distance, impact point, name of geometry.
                float dist = results.getCollision(i).getDistance();
                Vector3f pt = results.getCollision(i).getContactPoint();
                String hit = results.getCollision(i).getGeometry().getName();
                System.out.println("* Collision #" + i);
                System.out.println(
                        "  You shot miniMap " + hit
                        + " at " + pt
                        + ", " + dist + " wu away.");
            }

            if (results.size() > 0) {
                // The closest collision point is what was truly hit:
                closest = results.getClosestCollision();
                // Let's interact - we mark the hit with a red dot.
                mark.setLocalTranslation(closest.getContactPoint());
                miniMapNode.attachChild(mark);
                
            } else {
                // No hits? Then remove the red mark.
                miniMapNode.detachChild(mark);
            }
        }
    }
    
}

I guess I better explain it some.

Here’s the SimpleApplication so you can just copy and go,

package mygame;

import com.jme3.app.DebugKeysAppState;
import com.jme3.app.SimpleApplication;
import com.jme3.app.StatsAppState;
import com.jme3.renderer.RenderManager;
import com.jme3.system.AppSettings;

/**
 * This is the Main Class of your Game. You should only do initialization here.
 * Move your Logic into AppStates or Controls
 * @author normenhansen
 */
public class Main extends SimpleApplication {

    public Main() {
        super( new StatsAppState(), new DebugKeysAppState(), new TestGuiNodeState());
    }

    public static void main(String[] args) {
        Main app = new Main();
        AppSettings appSettings = new AppSettings(true);
        appSettings.setTitle("TestGuiNodeState");
        app.setSettings(appSettings);
        app.start();
    }

    @Override
    public void simpleInitApp() {
        
    }

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

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

What you will see is two viewports.
Main has a green box.
MiniMap shows the green box and has a monkey button.

Click the green box in main viewport.
Click the box in minimap viewport.
Click the button in minimap viewport.
Click around the top left corner of the main viewport until the Listener fires.

You will get a readout like so.

* Collision #0
  You shot Box at (-2.5474138, -0.062131982, 1.0), 8.31447 wu away.
* Collision #1
  You shot Box at (-3.1135056, -0.07593909, -0.99999905), 10.393086 wu away.
* Collision #0
  You shot miniMap Box at (-3.1810417, -0.3620212, 1.000001), 8.550602 wu away.
* Collision #1
  You shot miniMap Box at (-3.8879402, -0.44247037, -0.99999905), 10.673378 wu away.
I've been clicked:MonkeyNode (Node) MiniMapGuiViewPort (62.0, 453.0)

Clicking the monkey button inside the minimap viewport and you get no event fired because the button node is actually located in the top left corner.

This is because the minimap Viewport screen coordinates are actually the main viewports screen coordinates as reported by event.getLocation().

Its entirely possible I don’t setup a second Gui node correctly. If you setup a second GuiNode and use your code in a similar manner, maybe it will reveal if its me or something else.

And I’m going to try to look tonight/tomorrow as part of my “patch-stravaganza”.

Was it in a PR or just a thread? (I will find it either way I just can’t remember.)

To OP, I’ve been trying to think how to ask this question without sounding disapproving… because I’m not. I think it’s cool when people use my stuff no matter how.

But I am curious what was needed that required forking Lemur instead of just writing an XML add-on? Just wondering if there is potentially some to-do item in there for me.

1 Like

It was in private message.

The truth of the matter is that I took the lazy way out. Playing with jMonkey
is my hobby. I have written business-based java since java first stopped being
an applet toy. But I am a total novice with 3D graphics.

When I poked into Lemur, I realized I would be doing some gut-level changes to
incorporate the XML structures that I wanted. As I learn best while doing, I
anticipated making a lot of changes just to experiment and have fun. And all
that means that I did not want to fight the backward-compatibility fight. (as I
said, just being lazy) I could have kept all my changes for my personal use, but
I appreciate the philosophy behind open source, and wanted to make my efforts
available to anyone who might be interested.

There is absolutely no ‘to-do’ item for you and nothing you missed. I totally
love the layered component architecture for the visuals. Thank you for building
Lemur and making it open source so that plodders like me can have some fun.

I have tried to credit Lemur as the foundation of Loris both in the code and in
the documentation. If there is something more that you would like me add,
either in the code or in the documentation, just let me know.

Thanks again…

1 Like

In regards to a secondary GUI viewport.

I was able to take your sample code and create a simple test case.
The issue is that when you create such a viewport which is not full size, the
mouse cursor detection no longer functions as expected. The mouse cursor always
comes in screen coordinates. But a partial viewport no longer maps to the screen
pixels 1 for 1.

I was able to hack up the mouse collision code, and could detect the mouse over
GUI elements in the partial viewport. However, a partial viewport does horrible
things to the GUI element scaling. It becomes illegible and rather useless. So
I will not be including the collision hack, it is not worth the overhead.

I would think there is a solution to the partial viewport scaling issue, but
that requires graphics expertise far beyond my pay grade. A couple of alternate
approaches to think about:

  1. Use the standard GUI viewport, and map a Loris Screen to overlay the minimap
    viewport area. The GUI then renders at the proper scaling so it looks good, and
    you can keep the layout constrained to fit over the minimap.
  2. Use Loris in 3D mode and attach directly into the minimap viewport (not the
    minimap GUI viewport) I tried this and it seems to work fine. You just have the
    scaling issues inherent whenever rendering in 3D.

I checked the test code into SourceForce
https://sourceforge.net/p/jmonkeyarchitecture/code/HEAD/tree/WCOmoArchitecture/test/net/wcomohundro/jme3/loris/test/TestMultiview.java

That’s what scaling is for…

You scale the image by the amount you reduce the viewport and it looks no different.

Edit:

This is not desirable since the icon will scale outside the minimap, see my test case below for the 640x480 running man Nifty control.

By using the minimap guinode, your images will scale according to the minimap viewport they are inside and won’t overlap the main viewport.

This is not how you do things. Anything in the 3d scene is for geometry. Anything in GuiNode is for 2d images/text.

Excuse the edits please, was interrupted and had no time to go into detail at first post.

This is a 640x480 main viewport / miniMap test with one lemur node (compass) attached to the minimap guinode, position based on minimap coordinates, and two Nifty controls, the lock attached to minimap guinode, the running man attached to main viewport guinode, positions based off each viewport cordinates.

This is the same test with 1914 x 1052 main viewport.

I don’t see any quality degradation.

Edit:
The trick is you design your graphics for the screen size you are targeting for your game. These are designed for a 1280x800 resolution so if they shrink they look even better but if the scale up they look almost as good as target resolution.

Images scale down better than up.

When I meant illegible, I was referring to a typical use of GUI controls for buttons, borders, text, etc.
TestMultiviewFull
When you scale it down, it gets pretty ugly.
TestMultiviewMini

And it has just hit me that there is a trivial way to adjust the scaling. You just scale the mini-Gui node with the inverse of the scale on Viewport. Loris does not have to be involved at all. You end up with
TestMultiviewMiniScaled
The sizing gets a bit tight, but the appearance is fine.

I had not considered a use like you have suggested. I will include the fix for hit detection in a partial viewport in the next release. So Loris components will properly work in a mini-window, it is up to the developer to come up with visuals that look good.

Thanks for all your input and helping me understand what you are trying to accomplish.