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;
}
}