šŸŒ³ Evergreen noteworthy

Example usage of my Golang Scene Director package

planted on in: Programming, GameDev, Go and Go Lang.
~1,954 words, about a 10 min read.

About four years ago I got the itch to write a game engine based upon libtcod in #Golang, suffice to say that side project never had much time off the shelf but over the years I did return to it. I most recently took a look four months ago because I wanted to make use of the scene director I had written, which I actually partitioned off into go-rogue/scenes.

In game development, a scene director (or scene manager) is a stack that your various scenes get pushed onto, with the scene at the top of the stack being the current one being executed. Think about a pause menu, your player pauses the game and the PauseScene gets pushed onto the stack, your MainGame scene is still there, in memory but as it's not the top of the stack anymore its effectively halted until the PauseScene is popped off the stack.

To facilitate this functionality my library provides a Director struct with four methods: PushState to add a scene to the stack, PopState to remove the current scene, ChangeState to replace the current scene and PeekState to obtain the current scene. Both PushState and ChangeState expect the scene struct passed to them to implement the IScene interface, while PeekState returns the current scene as an interface{} so that you might cast it to your own extension of IScene.

Included with the director is a minimal implementation of IScene in the Scene struct and this is included with the intention that you extend from it with your own scene structure, for example:

package main

import (
"github.com/go-rogue/scenes"
)
type Scene interface {
scenes.IScene
HandleEvent(ev tcell.Event) bool
Draw()
}

The IScene describes three mandatory methods: Pushed executed when the scene is added to the stack, Popped executed when the scene is removed from the stack and GetName[1] which should return the scenes name.

Finally, the Director struct has a ShouldQuit boolean which is a hold-over from when I first wrote this code for a Rogue Like game, I am unsure whether to remove it.

Before we continue, in order to save you a lot of copy and paste, the code within this page has been published as go-rogue/scenes-demo that demo and this page will be kept up to date as-and-when I modify the scenes package.

ā€”

Onwards to the example usage; I will be making use of the gdamore/tcell package to create a basic TUI application with three scenes: Introduction, MainGame and Settings. Settings will be navigable from Introduction or MainGame, while MainGame will replace the Introduction scene.

We begin not with main but with defining the three scenes, each will be implementing the Scene interface as shown above.

type WelcomeScene struct {
scenes.Scene
screen tcell.Screen
}

type GameScene struct {
scenes.Scene
screen tcell.Screen
}

type SettingsScene struct {
scenes.Scene
screen tcell.Screen
}

func (s *WelcomeScene) Draw() {
drawText(s.screen, 0, 1, 80, 1, tcell.StyleDefault, "[N]ew Game")
drawText(s.screen, 0, 2, 80, 2, tcell.StyleDefault, "[S]ettings")
}

func (s *GameScene) Draw() {
drawText(s.screen, 0, 1, 80, 1, tcell.StyleDefault, "[S]ettings")
}

func (s *SettingsScene) Draw() {
drawText(s.screen, 0, 1, 80, 1, tcell.StyleDefault, "[B]ack")
}

func (s *WelcomeScene) HandleEvent(ev tcell.Event) bool {
switch e := ev.(type) {
case *tcell.EventKey:
if e.Key() == tcell.KeyRune {
switch e.Rune() {
case 'Q', 'q':
s.Director.ShouldQuit = true
return true
case 'N', 'n':
s.screen.Clear()
s.Director.ChangeState(NewGameScene(s.screen))
return true
case 'S', 's':
s.screen.Clear()
s.Director.PushState(NewSettingsScene(s.screen))
return true
}
}
}

return false
}

// ... snip

Within the event handler of WelcomeScene you can see usage of both ChangeState and PushState, the former of these is one way, the WelcomeScene will be removed from the stack and the GameScene replace it, while the latter will push the SettingsScene onto the stack, temporarily pausing execution of WelcomeState.

The event handler for GameScene is almost identical to the WelcomeScene one shown above, except it's missing the N/n input handler:

switch ev.Rune() {
case 'Q', 'q':
s.ShouldQuit = true
return true
case 'S', 's':
s.screen.Clear()
s.Director.PushState(NewSettingsScene(s.screen))
return true
}

The SettingsScene is slightly different too in that it makes use of PopState to return to the previous scene:

switch ev.Rune() {
case 'Q', 'q':
s.ShouldQuit = true
return true
case 'B', 'b':
s.screen.Clear()
s.Director.PopState()
return true
}

The constructor function for these three scenes looks like the following:

func NewWelcomeScene(screen tcell.Screen) *WelcomeScene {
return &WelcomeScene{
scenes.Scene{
Name: "Welcome",
},
screen,
}
}

Finally, in order to draw a string to the window I have borrowed the drawText function from tcell's tutorial:

func drawText(s tcell.Screen, x1, y1, x2, y2 int, style tcell.Style, text string) {
row := y1
col := x1
for _, r := range []rune(text) {
s.SetContent(col, row, r, nil, style)
col++
if col >= x2 {
row++
col = x1
}
if row > y2 {
break
}
}
}

Finally, in order to tie everything together, we return to the main function:

func main() {
screen, err := tcell.NewScreen()

if err != nil {
log.Fatalf("%+v", err)
}

if err := screen.Init(); err != nil {
log.Fatalf("%+v", err)
}

screen.Clear()

director := scenes.NewDirector(NewWelcomeScene(screen))
for director.ShouldQuit == false {
scene := director.PeekState().(Scene)
scene.Draw()

screen.Show()

ev := screen.PollEvent()
scene.HandleEvent(ev)
}
}

Boom, that's everything!

When built the program will begin executing the WelcomeScene, upon pressing the S key the SettingsScene will be pushed onto the stack and become the executed scene, this scene removes itself via PopState returning you to the previous scene, which in this case was the WelcomeScene, pressing N to go through to the GameScene will replace the WelcomeScene removing it from the stack completely and from the GameScene you can switch to the SettingsScene which will return back to the GameScene.

If you would like to see that for yourself you can pull down the go-rogue/scenes-demo repository and run the demo application in your own terminal.

ā€”

I wrote this package mostly for my own needs, which is why up until now it had remained public albeit undocumented. In 2024 I am going to do some TUI Game Development such as picking up my Roguelike game from several years ago, and making the 30 year old BASIC game I ported to Go look a little nicer.


  1. I am likely to remove GetName in a future version as it really belongs in the interface extending IScene rather than within the library itself. ā†©ļøŽ

Page History

This page was first added to the repository on December 30, 2023 in commit b1183005. View the source on GitHub.