Let's Build a Text Adventure - Part 1
So far we've set up our premise and started writing some basic ruby code that will be the foundation for our single-file implementation of our SuperTextAdventure app! In this part we'll greatly flesh out the functionality and start designing details on how our world-builder will work!
Hey! You came back! That's awesome. Or perhaps you're new here... if that's true then I'd encourage you to check out the previous posts. As a reminder, the repo we're working from is here and changes from this post will be up at the time of posting this:
Encapsulation
Right now our single-file program's important parts looks like this (I did some tweaking since the last article:
prompt = TTY::Prompt.new
prompt.ok intro
player_name = prompt.ask("What is your name, adventurer?".colorize(:light_blue))
DATA[:player][:name] = player_name
newline
prompt.ok("Welcome, #{DATA[:player][:name]}!")
newline
wait
choices = { "build" => "Build an Adventure", "play" => "Play a Game"}
selection = prompt.select("What would you like to do?", choices.invert)
prompt.say("You chose to #{choices[selection]}.", color: :yellow)
newline
wait
This is nice and simple. I had never actually used invert
before, though. I wanted to easily lookup the "display" text of the choices after the user made a selection and my original attempt, with the keys and values flipped, required some like this:
choices.find { _2 == selection }.first
Which is fine but gross.
In any case, our code is going to rapidly get stringy so let's build some classes that can help us. First, let's create a class that encapsulates the process of this introduction bit.
1. Modify The DATA Object
We want to store the user's selection of what type of "mode" they want to be in for this session: Building or Playing.
DATA = {
player: {
name: nil,
},
session_mode: nil, # <- added
game: {
rooms: []
}
}
Now we can set the DATA[:session_mode]
and use it to determine what block of logic to run
2. Base Class
Now, let's talk about classes. Ideally, we want to keep each section of the app separate so it can do the following:
- Be called by the user
- Run and display text/options for the user
- Accept user input and modify the DATA object accordingly
- Loop as needed, or quit and move on to the next part
I'm imagining a few steps ahead where we build the part that prompts the user to create a room. It would start with asking the user what the name of the room is, what objects are in the room, what the exits are, what other rooms it connects to, and then looping back to ask the user "Create another room?" and either starting over or stopping depending on the answer.
So the more re-usable these blocks of logic are the better. It'll be easier to read, easier to modify, and easier to test.
So I think we'll start with a Base class that defines the prompt. Other classes can inherit from this so we only have to instantiate the TTY::Prompt once. We can also create a helper so that we don't have to write "prompt." a thousand times:
class Base
attr_accessor :prompt
def initialize
@prompt = TTY::Prompt.new
end
def p
@prompt
end
end
There. Now we can write "p." a thousand times!
Now I wanted to make it even easier and use method_missing
so that we could just write say
and ok
:
def method_missing(m, *args, &block)
@prompt.send(m, *args, &block)
end
This worked for some, but the select
method is not undefined and would have to be overridden in the base class and then passed to the @prompt
instance variable and at that point I felt like the universe was trying to tell me something. I might change my mind later.
3. Intro Class
Let's move our intro code we wrote into a new class called Intro
that inherits from Base
and basically just does what our loose script did earlier:
class Intro < Base
CHOICES = {
"build" => "Build an Adventure",
"play" => "Play a Game"
}
def self.run
new.run
end
def run
p.ok(INTRO)
player_name = p.ask("What is your name, adventurer?".colorize(:light_blue))
DATA[:player][:name] = player_name
newline
p.ok("Welcome, #{DATA[:player][:name]}!")
newline
wait
selection = p.select("What would you like to do?", CHOICES.invert)
DATA[:session_mode] = selection
p.say("You chose to #{CHOICES[selection]}.".colorize(:yellow))
newline
wait
end
end
Alrighty. Now we can just add this at the bottom of the file:
Intro.run
puts DATA
Let's also dump our DATA
object at the end of the run to see what was loaded into it.
What would you like to do? Build an Adventure
You chose to Build an Adventure.
{player: {name: "Rick"}, session_mode: "build", game: {rooms: []}}
Nice! Okay we're now that we have more structure we can move forward and create classes as we need to build different sets of functionality.
4. Let's Get Complicated - Game Builder Design
Okay let's mentally sketch this out a bit...
GameBuilder
┌─────────┐ │
│ Select │ │
│ A │ ┌──────▼───────┐
│ Room │ │ Room │
│ │ │ │
└────▲────┘ │ -> Name │
│ │ -> Desc. │ ┌──────────┐
│ │ -> Items?----┼─────►│ Item │
│ │ -> Monster┐ │ │ │
┌────┴─────┐ │ -> Doors┐ │ │ │ - Name │
│Direction │ └─────────│─┼──┘ │ -> Type │
│ │◄────────────┘ │ │ -> Desc. │
│ N S E W │ │ └──────────┘
│ │ ┌─────▼─────┐
└──────────┘ │ Monster │
│ │
│ -> Name │
│ -> Desc │
│ -> HP │
└───────────┘
This is pretty rudimentary (thanks ASCIIFlow) but the idea is we have the game builder act as a "wizard" that asks a bunch of guided questions that helps a user create a world. We'll start with the concept of a "Room" as the basic building block and things will get added around that.
Now, quick aside. Let's step over here by the snack table. This will be a super tedious way to build a world. I plan for the final version to have a nice UI where folks can lay out a map with multiple floors and "see" the world they are building all at once. But for now, this helps us design the basic building blocks and forces us to think about the data and structure in detail so that when we're done we have all of the logic we need. Alright, let's get back out there.
So in a nutshell: let's build a main class that asks the user for data about the world and can loop as many times as needed.
5. GameBuilder Class
Let's build the primary GameBuilder class that will be the entry point.
I moved the self.run
method to the Base
class so it won't have to be duplicated in every class.
class GameBuilder < Base
def run
p.ok("Starting Game Builder...".colorize(:yellow))
newline
wait
p.say("Let's build your adventure, #{DATA[:player][:name]}!".colorize(:light_blue))
newline
name_game if DATA[:game][:name].nil?
build_process
end
def name_game;end
def build_process;end
end
Naming the Game
This is all up for changing later but I set the initial method up to check to see if the DATA[:game][:name]
has a value, and if not, we'll call a separate method that prompts them to set it.
Let's write that method first:
#...
def name_game
game_name = p.ask("What is the name of your game?".colorize(:light_blue))
DATA[:game][:name] = game_name.strip
p.ok("Game name set to '#{DATA[:game][:name]}'.")
newline
end
#...
That gives us a result like this:
Building a Loop to Add Data
Let's flesh out our build_process
method now. The plan for this is to ask the user what they want to do, redirect them to that process, and when they're done loop them back.
#...
def build_process
action = p.select("What would you like to do?") do |menu|
menu.choice name: "Add a Room", value: :add_room
menu.choice name: "List Rooms", value: :list_rooms
menu.choice name: "Exit Game Builder", value: :exit_builder
end
case action
when :add_room
add_room
when :list_rooms
list_rooms
when :exit_builder
exit_builder
end
end
def add_room
p.say("We're working on this part".colorize(:yellow))
build_process
end
def list_rooms
if DATA[:game][:rooms].empty?
p.say("No rooms have been added yet.".colorize(:red))
else
p.ok("We'll do this later.")
end
build_process
end
def exit_builder
p.say("Exiting Game Builder...".colorize(:light_blue))
end
#...
So as you can see the add_room
and list_rooms
don't do much but the main thing is they return back to build_process
. exit_builder
just exits.
How does it look?
Next Steps
That's enough for now. We've got a good design going and a solid plan for the future.
In the next installment we'll start adding actual rooms and items and build some classes for each of those things.
As always, please shoot me an email:
rick [at] makethingswork [dot] dev
Or email about the SuperTextAdventure project:
hub [at] supertextadventure [dot] com
Thanks for sticking with me, see you soon!
-- Rick