10
\$\begingroup\$

I’ve been working on a Rampart inspired multiplayer game for a few weeks now, and it is finally in a playable state. The big thing left to do before going alpha was to add a tutorial to the game.

I did a tutorial once before for my city building game, but I was very unhappy with how I coded it. I had a Tutorial class and a TutorialPhase enum, and every render loop the GameScreen checked to see whether the tutorial was enabled, and if so it told the Tutorial object to check for whether the conditions of the current state were achieved. Then the Tutorial would call methods of the GameScreen to display the next part of the tutorial.

This time I wanted to separate everything tutorial related from the regular classes as much as possible. I created a TutorialGameScreen class that extends the GameScreen class. All of the conditional logic is inside that. There is still a TutorialPhase enum which contains the strings that will be displayed for each phase, as well as whether or not a click is required to advance. I override only the methods of the GameScreen class that will be necessary to check the conditions.

I think that this way is much cleaner. I still need the regular GameScreen to check whether the tutorial is enabled (with a simple boolean) so that I can prevent the game timer from ticking down while the tutorial is running. I’d love to hear opinions about my approach.

You can try the game here Castleparts

TutorialGameScreen

public class TutorialGameScreen extends GameScreen {

    private Table tutorialTable;

    private LibGDXGame libGDXGame;

    private Table overlayTable;

    private Label tutLabel1;
    private Label tutLabel2;
    private Label tutLabel3;

    private TutorialPhase tutorialPhase;

    public TutorialGameScreen(LibGDXGame libGDXGame, GameType gameType, Difficulty difficulty) {
        super(libGDXGame, gameType, difficulty);

        this.libGDXGame = libGDXGame;

        this.buildTutorialUI();

        this.tutorialPhase = TutorialPhase.START;
        this.tutorialMode = true;

        this.setLabelTextForPhase();
    }

    private void buildTutorialUI() {
        this.tutorialTable = new Table(this.libGDXGame.skin);
        this.tutorialTable.setFillParent(true);
        this.libGDXGame.hudStage.addActor(this.tutorialTable);

        this.tutLabel1 = new Label("", this.libGDXGame.smallButtonFontStyle);
        this.tutorialTable.add(this.tutLabel1).padBottom(-10);
        this.tutorialTable.row();

        this.tutLabel2 = new Label("", this.libGDXGame.smallButtonFontStyle);
        this.tutorialTable.add(this.tutLabel2).padTop(-10).padBottom(-10);
        this.tutorialTable.row();

        this.tutLabel3 = new Label("", this.libGDXGame.smallButtonFontStyle);
        this.tutorialTable.add(this.tutLabel3).padTop(-10).padBottom(-10);

        this.addOverlayTable();
    }

    private void setLabelTextForPhase() {
        this.tutLabel1.setText(this.tutorialPhase.tut1);
        this.tutLabel2.setText(this.tutorialPhase.tut2);
        this.tutLabel3.setText(this.tutorialPhase.tut3);
    }

    private void addOverlayTable() {
        this.overlayTable = new Table(this.libGDXGame.skin);
        this.overlayTable.setFillParent(true);
        //this.overlayTable.setDebug(true);
        Image image = new Image(this.libGDXGame.alpha);
        this.overlayTable.add(image).expand().fill();
        this.overlayTable.addListener(new ClickListener() {
            @Override
            public void clicked(InputEvent event, float x, float y) {
                TutorialGameScreen.this.advanceTutorial();
            }
        });
        this.libGDXGame.hudStage.addActor(this.overlayTable);
    }

    private void advanceTutorial() {
        this.overlayTable.remove();

        int phaseNumber = this.tutorialPhase.ordinal();

        //if its the last phase
        //end the tutorial
        if (phaseNumber == TutorialPhase.values().length - 1) {
            this.tutLabel1.remove();
            this.tutLabel2.remove();
            this.tutLabel3.remove();
            this.tutorialMode = false;
            return;
        }

        this.tutorialPhase = TutorialPhase.values()[(phaseNumber + 1)];

        if (this.tutorialPhase == TutorialPhase.CREEP_MODE) {
            this.world.setGameType(GameType.CREEP);
        }

        if (this.tutorialPhase == TutorialPhase.SHOOT) {
            SequenceAction sequence = new SequenceAction();
            sequence.addAction(Actions.scaleTo(2, 2, 0.5f, Interpolation.sine));
            sequence.addAction(Actions.scaleTo(1, 1, 0.5f, Interpolation.sine));
            this.playerCannonBox.addAction(Actions.forever(sequence));
        }

        this.setLabelTextForPhase();

        if (this.tutorialPhase.needsOverlay) {
            this.addOverlayTable();
        }
    }

    @Override
    protected void cannonDragStopped(float x, float y) {
        super.cannonDragStopped(x, y);

        if (this.tutorialPhase == TutorialPhase.SHOOT) {
            this.advanceTutorial();

            this.playerCannonBox.clearActions();
            this.playerCannonBox.setScale(1);
        }
    }

    @Override
    public void cannonballHitTile(final Tile tile, Tile building) {
        super.cannonballHitTile(tile, building);

        if (building.getType() != TileType.NONE &&
            this.tutorialPhase == TutorialPhase.SHOOT_WALLS &&
            !this.world.isPlayerTile(tile.position)) {
            this.advanceTutorial();
        }
    }

    @Override
    public void wallBuiltAtPoint(MapPoint point) {
        super.wallBuiltAtPoint(point);

        if (this.tutorialPhase == TutorialPhase.BUILD_WALLS &&
            this.world.isPlayerTile(point)) {
            this.advanceTutorial();
        }
    }

    @Override
    public void floorBuiltOnTile(Tile tile) {
        if (this.tutorialPhase == TutorialPhase.BUILD_FLOOR &&
            this.world.isPlayerTile(tile.position)) {
            this.advanceTutorial();
        }
    }
}

TutorialPhase

public enum TutorialPhase {

    START("Welcome to Castleparts.",
          "Click to continue.",
          "",
          false),

    SHOOT("See the white square",
          "around your cannon?",
          "Click, drag, release to shoot.",
          false),

    SHOOT_WALLS("Good shot!",
                "Now try to hit your enemy.",
                "Aim for one of the walls.",
                false),

    BUILD_WALLS("Nice! See the wall",
                "buttons at the bottom?",
                "Drag and drop to place one.",
                false),

    BUILD_FLOOR("Very good.",
                "Fully enclose to build floors.",
                "Floors score points.",
                false),

    INFO("Great! Have the most",
         "floors at the end to win.",
         "And watch your energy!",
         true),

    CREEP_MODE("In creep mode, the",
               "enemy floors spread out",
               "continuously.",
               true),

    ALL_DONE("Have fun!",
             "And make sure to",
             "try the multiplayer.",
             true);

    public final String tut1;
    public final String tut2;
    public final String tut3;

    public final boolean needsOverlay;

    private TutorialPhase(String tut1, String tut2, String tut3, boolean needsOverlay) {
        this.tut1 = tut1;
        this.tut2 = tut2;
        this.tut3 = tut3;
        this.needsOverlay = needsOverlay;
    }
}

And here's a screenshot: enter image description here

\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

Subclassing

In short, I definitely think you made the correct decision here to extend GameScreen. This is the classic "Is-A" vs. "Has-A" (ie. Composition vs. Inheritance). In this case, the TutorialGameScreen is certainly a GameScreen (has everything that a standard GameScreen would have), but with added functionality (eg. ability to not run the timer, display additional UI elements, etc.).

TutorialPhase

  1. Internationalization Any time you are displaying text to a user, you should consider the needs of your audience - including languages! Take a look at ResourceBundle for how to do this. Also this tutorial. For an enum, I find it easy to just use the enum element name as the key for the resource file. Then on your getDisplayString() method (or whatever it is called) you can simply do:

    public enum TutorialPhase {
        // ...
        public String getDisplayString() {
            return ResourceBundle.getBundle(getClass().getName()).getString(name());
        }
        // ...
    }
    
  2. Multiple lines As a result of internationalization you'll want to avoid hardcoding the multiple lines of text. You can't be certain that a bit of text that fits in English will also fit in, say, German. Also, there's no guarantee that a users system font settings will match your own. That said, I would advice combining those three Strings into one. I realize this poses a bit of a technical challenge (How do I split a label onto multiple lines?), but I would argue that the responsibility of solving this problem does not belong to TutorialPhase. Instead, there may be Label implementations that allow for multilines, text-wrapping, etc.

  3. Public fields Now that we're down to one String, it should really be private. TutorialPhase can then expose a single method (eg. getDisplayText()) to access the String. Similarly, needsOverlay should be private and we can add a method (eg. isOverlayNeeded()) to access the boolean.

Updated code suggestion (you can document this enum better):

public enum TutorialPhase {

    /**
     * The START.
     */
    START(false),

    /**
     * The SHOOT.
     */
    SHOOT(false),

    /**
     * The SHOOT_WALLS.
     */
    SHOOT_WALLS(false),

    /**
     * The BUILD_WALLS.
     */
    BUILD_WALLS(false),

    /**
     * The BUILD_FLOOR.
     */
    BUILD_FLOOR(false),

    /**
     * The INFO.
     */
    INFO(true),

    /**
     * The CREEP_MODE.
     */
    CREEP_MODE(true),

    /**
     * The ALL_DONE.
     */
    ALL_DONE(true);

    private final boolean needsOverlay;

    private TutorialPhase(final boolean needsOverlay) {
        this.needsOverlay = needsOverlay;
    }

    /**
     * Returns the text to display during the phase.
     * @return The non-null, non-empty display text.
     */
    public String getDisplayText() {
        return ResourceBundle.getBundle(getClass().getName()).getString(name());
    }

    /**
     * Returns whether or not an overlay is needed for the phase.
     * @return {@code true} if an overlay is needed, otherwise {@code false}.
     */
    public boolean isOverlayNeeded() {
        return needsOverlay;
    }
}

TutorialGameScreen

Still related to your enum, but it happens in this class - relying on the order of enum elements is very brittle. What if you (or someone else) added a new element, or accidentally swapped them around? This would break the progression of your tutorial! Instead, you could do the following:

private static final List<TutorialPhase> TUTORIAL_PHASES = Arrays.asList(
        TutorialPhase.START,
        TutorialPhase.SHOOT,
        TutorialPhase.SHOOT_WALLS,
        TutorialPhase.BUILD_WALLS,
        // ...etc...
);

private final Iterator<TutorialPhase> phaseIterator = TUTORIAL_PHASES.iterator();

...and use the Iterator to progress through the phases you have defined.

Misc.

  1. Overuse of 'this'. In your code, the only place that needs it is in the constructor. The general consensus is to only use the 'this' keyword when necessary in order to maintain clean code.
  2. Make your private fields final whenever possible. See: Use final liberally
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.