Skip to content

Part 1 Screen Management

Jeremy Cerise edited this page Apr 14, 2020 · 4 revisions

Screen Management

We're going to start writing our Roguelike game with Gogue by setting up a way to handle the various screens that the player will presented with as they interact with the game. A screen, in this context, is what is actually rendered, or presented, to the player. A screen, in the context of Gogue, is what is currently being presented to the user. This could be the main menu, the main play area (complete with message log and stats area), a menu to select equipment, or just something that tells the player they have won or lost.

We can think of our screen management as a simple finite state machine. At any given point, the player is presented with exactly one screen, and from that screen can transition to another screen, based on the criteria for doing so. An example would be opening the inventory screen from the main play screen. Upon hitting the i key, the play screen would be replaced with the inventory screen, which the player could then interact with.

Gogue comes with the facilities to manage screens. In fact, it comes with a bit more than just the ability to transition from one screen to the next. We can also give each screen its own render logic, as well as its own bound controls (meaning that the i key on one screen may not behave the same on a different screen. This is convenient for handling menus that have different navigation schemes from the main gameplay keys).

Lets dig right in. Starting with the example from Setting-up-a-new-Gogue-project, we should have an application that looks something like this:

package main

import (
	"github.com/jcerise/gogue/ui"
)

var (
	windowHeight int
	windowWidth int
)

func init() {
	// Initialize a new Gogue terminal
	windowWidth = 150
	windowHeight = 50
	ui.InitConsole(windowWidth, windowHeight, "My Golang Roguelike", false)
}

func main() {

	// Set the global composition mode
	ui.SetCompositionMode(0)

	ui.Refresh()

	// Initialize our main game loop
	for {

		text := "Welcome to Gogue!"

		ui.PrintText((windowWidth / 2) - (len(text) / 2), windowHeight / 2, 0, 0, text, "white", "", 0)

		ui.Refresh()
	}

	ui.CloseConsole()
}

As you may recall, this does nothing more than open a BearLibTerminal window with some centered text displayed in it. Not very exciting. Lets add a couple of screens to make it a bit more interactive.

First, lets create a new go file called screens.go. We'll use this to store all the screens we define for our game. Inside that file, we're going to define our first screen, the title screen:

package main

// Title Screen
type TitleScreen struct {
}

What we've done is define a new struct, called TitleScreen. This struct on its own is nothing special, it doesn't even have any member variables attached to. But, we're going to make it implement Gogue's ScreenManager interface, so we can treat it as a Screen going forward. If you are not familiar with how interfaces in Go function, I highly recommend taking a minute to read up on them here: https://tour.golang.org/methods/10. Basically, a type implements an interface if it implements all of the interfaces methods. So, we need to have our TitleScreen type implement all of the methods off of the Screen interface from Gogue.

Looking at the Gogue docs, we can see that the Screen interface looks like this:

type Screen interface {
	Enter()
	Exit()
	Render()
	HandleInput()
	UseEcs() bool
}

So, we'll need our TitleScreen to implement all of those methods. Briefly: Enter is called when the screen is set as the current screen. Likewise, Exit is called when the screen transitioned away from the current screen.

Render is called to draw the screen onto the terminal, and HandleInput is used to handle any custom key input the screen requires. UseEcs dictates whether this screen should initialize systems from the ECS (we'll get into this later, it can be safely ignored for now).

Lets go ahead and implement all of those methods:

func (ts TitleScreen) Enter() {

}

func (ts TitleScreen) Exit() {

}

Enter and Exit can both be empty, as we don't currently need any special logic for entering or leaving this screen, all its going to do is display some text.

func (ts TitleScreen) Render() {
	// Clear the entire window
	ui.ClearWindow(windowWidth, windowHeight, 0)

	centerX := windowWidth / 2
	centerY := windowHeight / 2

	title := "Gogue Roguelike"
	instruction := "Press {Up Arrow} to begin! Or Press {ESC} to exit"

	ui.PrintText(centerX - len(title) / 2, centerY, 0, 0, title, "", "", 0)
	ui.PrintText(centerX - len(instruction) / 2, centerY + 2, 0, 0, instruction, "", "", 0)
}

Render is a little more exciting. Recall, this will be responsible for displaying this screen to the user. First up, we're going to clear the entire BearLibTerminal window of everything. Since this is the only active screen, we only want its contents to be displayed. Next, we've got a bit of code to get the center point of the window. We'll use this to print our text in the center of the screen. You may notice that we're using the variables windowWidth and windowHeight, but we haven't passed them into this method, or defined them in this file. These variables are defined in our main file, and since we are in the main namespace, we have access to them. This is global state, which generally is not good practice, but for now, its fine.

Finally, we're going to set a message and some instructions, and print that to the screen. Again, since we're in the main namespace, when we invoke the gogue library to to anything with our BearLibTerminal window, it will always act on the currently instantiated window.

Next up, HandleInput:

func (ts TitleScreen) HandleInput() {
	key := ui.ReadInput(false)

	if key == ui.KeyEscape {
		os.Exit(0)
	}

	if key == ui.KeyUp {
		// Change the screen to the play screen
		screenManager.SetScreenByName("game")

		// Immediately call the render method of the new current screen, otherwise, the existing screen will stay
		// rendered until the user provides input
		screenManager.CurrentScreen.Render()
	}
}

There's a couple things we haven't seen yet in here. The first statement gets the value of any key presses from the user, and assigns them to the value key. ReadInput takes a boolean value, nonBlocking. We're setting it to false. This means that the ReadInput statement will block execution until the user presses a key, at which point, execution continues. If we had said nonBlocking was true, ReadInput would check for a key, and if none was pressed, allow execution to continue. The main difference is that with blocking key inputs, our game will feel almost turn-based, waiting for the player to decide their move, processing it, and then processing the rest of the world, until finally returning to the key input step again. With nonBlocking inputs, the world will be processed whether the player reacts or not, making our game more real-time. In keeping with a traditional Roguelike experience, I've decided to keep our game turn based.

Once we have a key from the user, we're going to check if its one of two keys, either ESC or UPARROW. Any other keys will ignored on this screen. If the user hits ESC, the game will simply exit. If the user hits UPARROW, we're going to transition the screen to the game screen. Transitioning a screen simply involves setting the screen, and then rendering it. We'll be using Gogue's ScreenManager class in a bit to handle these in our game loop, but for now, if we were to run this, it would simply change screens to game.

Finally, lets fill in the final method, UseEcs, so our TitleScreen type fully implements the Screen interface:

func (ts TitleScreen) UseEcs() bool {
	return false
}

This method can simply return false for now, as we're not currently implementing the ECS.

And with that, we have a TitleScreen type that implements Gogue's Screen. Your file should look like this:

package main

import (
	"github.com/jcerise/gogue/ui"
)

// Title Screen
type TitleScreen struct {
}

func (ts TitleScreen) Enter() {

}

func (ts TitleScreen) Exit() {

}

func (ts TitleScreen) Render() {
	// Clear the entire window
	for i := 0; i <= 2; i++ {
		ui.ClearWindow(windowWidth, windowHeight, i)
	}

	centerX := windowWidth / 2
	centerY := windowHeight / 2

	title := "Gogue Roguelike"
	instruction := "Press {Up Arrow} to begin! Or Press {ESC} to exit"

	ui.PrintText(centerX - len(title) / 2, centerY, 0, 0, title, "", "", 0)
	ui.PrintText(centerX - len(instruction) / 2, centerY + 2, 0, 0, instruction, "", "", 0)
}

func (ts TitleScreen) HandleInput() {
	key := ui.ReadInput(false)

	if key == ui.KeyEscape {
		os.Exit(0)
	}

	if key == ui.KeyUp {
		// Change the screen to the play screen
		screenManager.SetScreenByName("game")

		// Immediately call the render method of the new current screen, otherwise, the existing screen will stay
		// rendered until the user provides input
		screenManager.CurrentScreen.Render()
	}
}

func (ts TitleScreen) UseEcs() bool {
	return false
}

In the same file, lets go ahead and add a new screen for gameplay (previously referenced above). This is going to be very similar to the main menu screen, except it will render different text, and have a different set of controls. I won't go into the details of each function, as it is very similar to the previous screen:

// Game Screen
type GameScreen struct {
}

func (gs GameScreen) Enter() {

}

func (gs GameScreen) Exit() {

}

func (gs GameScreen) Render() {
	// Clear the entire window
	for i := 0; i <= 2; i++ {
		ui.ClearWindow(windowWidth, windowHeight, i)
	}

	centerX := windowWidth / 2
	centerY := windowHeight / 2

	title := "Main Game Screen"
	instruction := "Press {Down Arrow} to return to menu!"

	ui.PrintText(centerX - len(title) / 2, centerY, 0, 0, title, "", "", 0)
	ui.PrintText(centerX - len(instruction) / 2, centerY + 2, 0, 0, instruction, "", "", 0)
}

func (gs GameScreen) HandleInput() {
	key := ui.ReadInput(false)

	if key == ui.KeyEscape {
		os.Exit(0)
	}

	if key == ui.KeyDown {
		// Change the screen to the title screen
		screenManager.SetScreenByName("title")

		// Immediately call the render method of the new current screen, otherwise, the existing screen will stay
		// rendered until the user provides input
		screenManager.CurrentScreen.Render()
	}
}

func (gs GameScreen) UseEcs() bool {
	return false
}

The only difference here, aside from the name of the Struct, is the text we print to the screen, and the controls that are activated (in this case, the down arrow will return the player back to the menu screen).

Now that we've got a couple of screens defined, and set up to interact with each other, lets go wire them up in our main class.

The first thing we'll need to do is define a Gogue ScreenManager:

var (
	...
	// Screen Management
	screenManager *screens.ScreenManager
)

Our screen manager will be responsible for switching between screens when we need it, say, when the user opens their inventory. It will also keep a reference to which screen is active, and the previously active screen, to make navigating through menus easier.

Lets define a function that will register existing screens to the ScreenManager:

// RegisterScreens initializes and adds any gameScreens needed for the game
func registerScreens() {
  // titleScreen is the title screen for the game, and will be the first screen the player sees
  titleScreen := TitleScreen{}

  // gameScreen is the main gameplay screen
  gameScreen := GameScreen{}


  // Add all initialized screens to the screenManager
  screenManager.AddScreen("title", titleScreen)
  screenManager.AddScreen("game", gameScreen)
}

All this function does is define variables for the two screen structs we created, and the add them to the ScreenManager. The AddScreen method takes a name (which can be anything we want), and a struct that fulfills the Gogue Screen interface, which our two screens do. Once added, we can use the ScreenManager to render one as active, and switch between them as needed.

Finally, lets add the rendering of the titleScreen to the game loop, so it will display when the player launches the game:

func main() {
	...

	// Register all of our screens
	registerScreens()

	// Set the screen to the title screen
	screenManager.SetScreenByName("title")

	// Render the current screen
	screenManager.CurrentScreen.Render()

	// Refresh the screen, so our content shows up
    ui.Refresh()

	// Init the main game loop
	for {
	  screenManager.CurrentScreen.HandleInput()
	  screenManager.CurrentScreen.Render()
	  ui.Refresh()
	}

	ui.CloseConsole()
}

Lets walk through this. The very first thing we do, before we get into our main game loop, is to call our registerScreens function. This will add all our defined screens to the ScreenManger. Next, we call SetScreenByName from the ScreenManager. This method will set the screen we specify as the CurrentScreen. We could also call SetScreen here, which lets you pass in a struct implementing the Screen interface, but calling it by name is a bit easier.

Next, now that we have a CurrentScreen, we call Refresh from Gogue, which will clear the screen of anything.

Finally, we get into our main game loop. Three things occur: first, we call HandleInput on our current screen. This will block further execution until the user presses a key (remember earlier how we decided we wanted a turn based game? Well, we enforce that by not redrawing or processing anything else until the user provides input). Based on what keys the user presses, and depending on what's defined for our screen input handling, the key will trigger an action, or will be ignored. Next, once the user has provided input, and that input has been handled, we render the current screen. If you recall, each screen can modify the current screen, so if the user hit a key that switches screens, the new screen will be rendered, instead of the old current screen, and next time through the loop, the new screens input handling will active. Lastly, we refresh the display, and loop back around to do it again.

At this point, you should be able to run the game, and navigate between the menu and game screen by pressing the up arrow, and down arrow, depending on the screen you are on. The game should switch between the screens as you do so.

We've now got the basis for the rest of our game. Screens will play a big role as we develop further, being for things like menus, settings pages, stats pages, etc.

The code for this part of the tutorial can be found here, tagged as v1.0.0-tutorial1

Homework

Implement a Win and Lose screen, that let the player know they have won or lost. The user should be able to navigate to these screens via a new key for each, from the game screen. They should provide a way to exit the application, or navigate back to the title screen.