Search code examples
javalibgdxevent-driven

Designing a simple event-driven GUI


I am creating a simple event-driven GUI for a video game I am making with LibGDX. It only needs to support buttons (rectangular) with a single act() function called when they are clicked. I would appreciate some advice on structuring because the solution I've thought of so far seems to be far from ideal.

My current implementation involves all buttons extending a Button class. Each button has a Rectangle with its bounds and an abstract act() method.

Each game screen (e.g. main menu, character select, pause menu, the in-game screen) has a HashMap of Buttons. When clicked, the game screen iterates through everything in the HashMap, and calls act() on any button that was clicked.

The problem I'm having is that Buttons have to have their act() overridden from their superclass in order to perform their action, and that the Buttons aren't a member of the Screen class which contains all the game code. I am subclassing Button for each button in the game. My main menu alone has a ButtonPlay, ButtonMapDesigner, ButtonMute, ButtonQuit, etc. This is going to get messy fast, but I can't think of any better way to do it while keeping a separate act() method for each button.

Since my mute button isn't a part of the main menu screen and can't access game logic, it's act() is nothing more than mainMenuScreen.mute();. So effectively, for every button in my game, I have to create a class class that does nothing more than <currentGameScreen>.doThisAction();, since the code to actually do stuff must be in the game screen class.

I considered having a big if/then to check the coordinates of each click and call the appropriate action if necessary. For example,

if (clickWithinTheseCoords)
   beginGame();
else if(clickWithinTheseOtherCoords)
   muteGame();
...

However, I need to be able to add/remove buttons on the fly. When a unit is clicked from the game screen, a button to move it needs to appear, and then disappear when the unit is actually moved. With a HashMap, I can just map.add("buttonMove", new ButtonMove()) and map.remove("buttonMove") in the code called when a unit is clicked or moved. With the if/else method, I won't need a separate class for every button, but I would need to keep track of whether each clickable area tested is visible and clickable by the user at this point in the game, which seems like an even bigger headache that what I have right now.


Solution

  • Sneh's response reminded me of a fairly major oversight - instead of having to create a separate class for every button, I could use anonymous inner classes whenever I created a button, specifying its coordinates and act() method every time. I explored lambda syntax as a possible shorter method to do this, but ran into limitations with it. I ended up with a flexible solution, but ended up reducing it a bit further to fit my needs. Both ways are presented below.

    Each game screen in my game is subclassed from a MyScreen class, which extends LibGDX's Screen but adds universal features like updating the viewport on resize, having a HashMap of Buttons, etc. I added to the MyScreen class a buttonPressed() method, which takes in as its one parameter an enum. I have ButtonValues enum which contains all the possible buttons (such as MAINMENU_PLAY, MAINMENU_MAPDESIGNER, etc.). In each game screen, buttonPressed() is overriden and a switch is used to perform the correct action:

    public void buttonPressed(ButtonValues b) {
        switch(b) {
            case MAINMENU_PLAY:
                beginGame();
            case MAINMENU_MAPDESIGNER:
                switchToMapDesigner();
        }
    }
    

    The other solution has the button store a lambda expression so that it can perform actions on its own, instead of requiring buttonPressed() to act as an intermediary that performs the correct action based on what button was pressed.

    To add a button, it is created with its coordinates and type (enum), and added to the HashMap of buttons:

        Button b = new Button(this,
                new Rectangle(300 - t.getRegionWidth() / 2, 1.9f * 60, t.getRegionWidth(), t.getRegionHeight()),
                tex, ButtonValues.MAINMENU_PLAY);
        buttons.put("buttonPlay", b);
    

    To remove it, just buttons.remove("buttonPlay"). and it'll disappear from the screen and be forgotten by the game.

    The arguments are the game screen which owns it (so the button can call buttonPressed() on the game screen), a Rectangle with its coordinates, its texture (used to draw it), and its enum value.

    And here's the Button class:

    public class Button {
    
        public Rectangle r;
        public TextureRegion image;
    
        private MyScreen screen;
        private ButtonValues b;
    
        public Button(MyScreen screen, Rectangle r, TextureRegion image, ButtonValues b) {
            this.screen = screen;
            this.r = r;
            this.image = image;
            this.b = b;
        }
    
        public void act() {
            screen.buttonPressed(b);
        }
    
        public boolean isClicked(float x, float y) {
            return x > r.x && y > r.y && x < r.x + r.width && y < r.y + r.height;
        }
    }
    

    isClicked() just takes in an (x, y) and checks whether that point is contained within the button. On mouse click, I iterate through all the buttons and call act() if a button isClicked.

    The second way I did it was similar, but with a lambda expression instead of the ButtonValues enum. The Button class is similar, but with these changes (it's a lot simpler than it sounds):

    The field ButtonValues b is replaced with Runnable r, and this is removed from the constructor. Added is a setAction() method which takes in a Runnable and sets r to the Runnable passed to it. The act() method is just r.run(). Example:

    public class Button {
    
        [Rectangle, Texture, Screen]
        Runnable r;
    
        public Button(screen, rectangle, texture) {...}
    
        public void setAction(Runnable r) { this.r = r; }
    
        public void act() { r.run(); }
    }
    

    To create a button, I do the following:

        Button b = new Button(this,
                new Rectangle(300 - t.getRegionWidth() / 2, 1.9f * 60, t.getRegionWidth(), t.getRegionHeight()),
                tex);
        b.setAction(() -> b.screen.doSomething());
        buttons.put("buttonPlay", b);
    

    First, a button is created with its containing game screen class, its bounding box, and its texture. Then, in the second command, I set its action - in this case, b.screen.doSomething();. This can't be passed to the constructor, because b and b.screen don't exist at that point. setAction() takes a Runnable and sets it as that Button's Runnable that is called when act() is called. However, Runnables can be created with lambda syntax, so you don't need to create an anonymous Runnable class and can just pass in the function it performs.

    This method allows much more flexibility, but with one caveat. The screen field in Button holds a MyScreen, the base screen class from which all of my game screens are extended. The Button's function can only use methods that are part of the MyScreen class (which is why I made buttonPressed() in MyScreen and then realized I could just scrap the lambda expressions completely). The obvious solution is to cast the screen field, but for me it wasn't worth the extra code when I could just use the buttonPressed() method.

    If I had a beginGame() method in my MainMenuScreen class (which extends MyScreen), the lambda expression passed to the button would need to involve a cast to MainMenuScreen:

        b.setAction(() -> ((MainMenuScreen) b.screen).beginGame());
    

    Unfortunately, even wildcard syntax doesn't help here.

    And finally, for completeness, the code in the game loop to operate the buttons:

    public abstract class MyScreen implements Screen {
    
        protected HashMap<String, Button> buttons; // initialize this in the constructor
    
        // this is called in every game screen's game loop
        protected void handleInput() {
            if (Gdx.input.justTouched()) {
                Vector2 touchCoords = new Vector2(Gdx.input.getX(), Gdx.input.getY());
                g.viewport.unproject(touchCoords);
                for (HashMap.Entry<String, Button> b : buttons.entrySet()) {
                    if (b.getValue().isClicked(touchCoords.x, touchCoords.y))
                        b.getValue().act();
                }
            }
        }
    }
    

    And to draw them, located in a helper class:

    public void drawButtons(HashMap<String, Button> buttons) {
        for (HashMap.Entry<String, Button> b : buttons.entrySet()) {
            sb.draw(b.getValue().image, b.getValue().r.x, b.getValue().r.y);
        }
    }