Architecting a Console-Style RPG in Lingo: Part Three
July 2, 2004
by CC Chamberlin
Introduction
In part one of this series, we looked at the high-level architecture for a console-style role playing game called Sacraments. In part two, we looked at the mechanisms we need to give the player a window into the game world.
In this final part of the series, we'll bring the game world to life by moving the characters around and supplying scripted cutscenes which we can use to advance the story of the game. At the end of this article, we'll wrap up with a few thoughts on fleshing out the rest of the game.
Moving Characters Around on the Map
So far, we have a map rendering system, but no way to give it life. Our characters can be drawn on the map, but they don't move around and explore their world. Let's fix that.
Rather than writing code to move props around directly as a consequence of game code, I wanted a more generic way of controlling the props. This is because I wanted props to move around as a result of the player's actions, but also as a result of cutscenes, and I felt both should go through the same pipeline. To accomplish this, I built a "sequencer" object that could be pushed onto the interface stack to let character walk around on the game world map. The sequencer would collect information about what motions were required, and then play them back until done, and then pop itself off the stack.
Analyzing Actions
The first step in writing a sequencer is to determine the actions that it will be able to sequence. Here's the list of actions the sequencer in Sacraments can perform:
- Move a prop relative to its current position
- Move a prop to an absolute position (teleport)
- Change the current pose of a prop
- Create a new prop
- Delete a prop
- Change the prop the camera looks at
- Change a map tile
- Call a handler in an object (for notification purposes)
- Some special effects, like screen fades and washes (not covered in this article)
With these actions, the sequencer can control the vast majority of the map engine's capabilities. All that is left is a way to actually sequence out the actions.
Time-based Animation
The sequencer handles its events on a frame-by-frame basis, so each action the sequencer is supposed to take is keyed to a particular frame. Here's the meat of the setup:
on addActionAtFrame me, theFrame, action
global gInterfaceStack
theFrame = max(1,theFrame)
-- First, make sure we have space for enough frames ahead
repeat while pAction.count < theFrame
pAction.add([])
end repeat
pAction[theFrame].add(action)
if (gInterfaceStack.findpos(me) < 1) then gInterfaceStack.add(me)
end addActionAtFrame
Here, pAction holds the information on what actions to take on each frame. It is a linear list, and each element refers to a subsequent frame. In other words, the first element is for frame one, the second element is for frame two, and so on.
Each frame element is a linear list of actions to take on that frame. This allows for multiple actions to take place on any given frame, in case you want more than one character walking around at once, or if you want to change someone's pose and move them in the same frame.
The actions themselves are simple property lists containing the relevant information for the particular action. Each of these property lists contains a property called #action which indicates which action (out of the above list) to perform, such as #moveRel or #lookAt. The remaining properties contain the data the sequencer needs to perform that action, and vary from action type to action type. For instance, this action will move a prop two pixels to the left:
[ #action: #moveRel, #prop: someProp, #dir: point(-2, 0) ]
As you can see from the addActionAtFrame() handler, the action will be dropped into the frame element for the given frame. By calling the routine repeatedly, you can drop many actions in many frames to create an animation of any theoretical size. For instance, to create an animation of someone walking, you would push a command to change the character's pose to the "Walking West" pose, and then push a series of #moveRel actions on subsequent frames to move the character over time to the desired location.
The last line of the handler checks to see if it is already on the Interface Stack, and if it is not, it pushes itself onto the stack, so that it will start receiving "render" and "timepasses" events.
Playback
Now that the sequencer is receiving timePasses events, it needs to play them back. Each time it receives the timePasses event, it unshifts the first frame list from pAction, and then steps through each action in it, performing whatever action is described. Thus, the first list in pAction is always the next frame to display. When pAction is empty, there are no more frames to display, and the sequencer pops itself back off the interface stack, returning control to whatever is below it.
The timePasses() handler, then, is basically one big case statement (error checking and some other code stripped out for clarity):
on timePasses me
global gProps
thisFrame = pAction[1]
pAction.deleteat(1)
repeat with theAction in thisFrame
-- Convert a named prop to the prop object
p = theAction[#prop]
if (stringp(p)) then theAction[#prop] = prop(p)
case theAction.action of
#setTile:
setMapLayer(theAction.x, theAction.y, \
theAction.layer, theAction.tile)
#lookAt:
gMap.lookAt(theAction.prop.pLocation)
#moveRel:
theAction.prop.pLocation = \
theAction.prop.pLocation + theAction.dir
#moveAbs:
theAction.prop.pLocation = theAction.location
#newProp:
p = makeStandardProp(theAction.id, theAction.ss)
p.teleportToTile(theAction.x, theAction.y)
#killProp:
gProps.deleteProp(theAction.id)
#pose:
theAction.prop.setPose(theAction.pose)
#call:
call(theAction.hnd, [theAction.obj], \
theAction[#pass])
end case
end repeat
if (pAction.count = 0) then
-- No more frames!
gInterfaceStack.deleteone(me)
end if
end timePasses
These two routines are the heart of the sequencer, and this sort of mechanism can be used to script out nearly any in-game sequence. The 'call' action, in particular, adds enormous flexibility to the engine, since you can have it spawn dialog boxes, change maps, or anything else that you can do with Lingo.
Moving the avatar around
Once the sequencer is working, it is a simple matter to prod the little avatar into walking around on the map. All you need is an interface to watch for keystrokes, and when it detects one, pipe the necessary actions into the sequencer. For instance, since we are limiting our avatar to only walking perfectly on tiles, when the avatar moves, we can drop sixteen move-by-two's into the sequencer to move him 32 pixels. If we want him to run, we can drop in eight move-by-four's or four move-by-eight's. The sequencer will handle all of the animation.
Once that's ready to go, all that is left is to keep track of where the avatar is on the map, and when the player wants to move, to check the movability to that tile in the manner discussed above, and to fire off events like bump, walk, and leave as appropriate.
Cutscene Engine
As you probably noticed, the walkaround engine is a fairly trivial use of the sequencer. With all that power at your disposal, why not make more interesting animations? That's where the cutscene engine comes in.
The goal of a cutscene engine is to provide in-game dramatic sequences to advance the story. Like the map editor, it must be easy enough to use that you can be creative with it. You shouldn't be bogged down in the technical details of the cutscene engine's architecture while writing out the scripts for your cutscenes. To accommodate that, I wrote a cutscene engine that would parse sequence files that I could write in a text editor, almost as if I were writing a script.
Here's a sample snippet from the first cutscene where Garrick meets with the Magistrate (dialogue edited for brevity).
-- Garrick walks up to the Magistrate Group Magistrate Pose North Ungroup Group Garrick Walk North Walk North Walk North Walk East Walk East Pose East Ungroup Talk Garrick\n\c9999FFYou wanted to see me, sir? Bottom Group Magistrate Pose West Ungroup Talk Magistrate\n\c9999FFAh, good to see you, Garrick!
It almost reads like a script, doesn't it? You can picture in your mind's eye what is going on. The only esoteric bits are keeping track of where the props are on the map and the escape codes in the text strings (which indicate line feeds and color changes). If I were to redo the cutscene engine today, I would make some changes to this, like renaming "Group" to "Tell", and literally associating speech with characters so they could append their own names, and even have their own custom-colored dialogue and dialog boxes.
Cutscene Parser
So how do you translate the sequence file into something that can be flowed to the sequencer? You need to write a parser.
The heart of the cutscene parser lies in a little handler I wrote called explode(). This handler is very similar to explode() in other languages, in that it takes a string and returns an array formed by splitting the string up based on a delimiter. However, the cutscene-specific version chews through the whole file line by line and explodes each subline, creating a list of lists. In other words, it is like using the traditional explode() command to first explode a string based on newlines, and then exploding each returned string on tabs.
In addition, the explode() version for the cutscene engine does a little extra processing on each line. First of all, it ignores any initial empty strings, allowing you to indent with tabs to your heart's content (although a better approach would have been to strip out all whitespace characters before the first item). It also turns the first element of each line (if any) into a symbol:
on explode me, t
the itemdelimiter = tab
pCommands = []
lc = t.lines.count
repeat with theLineOrd = 1 to lc
theLine = t.line[theLineOrd]
cmd = []
repeat with looper = 1 to 7
theItem = theLine.item[looper]
if (theItem starts "--") then exit repeat
if (theItem <> "") then cmd.add(theItem)
end repeat
if (cmd.count > 0) then
cmd[1] = symbol(cmd[1])
pCommands.add(cmd)
end if
end repeat
end explode
Upon running this routine on the sequence file - assuming it is well formed - it can now run through each item with a similar case statement as in the sequencer, using the first element of each list to determine what to do. This time, instead of sending messages to the map props and other objects on the fly, it is flowing actions to the sequencer.
Here are all the keywords that the cutscene engines:
- #group - Collect a group of props to send messages to.
- #ungroup - Clear the prop collection.
- #walk - Slowly moves the selected props one tile in the given direction.
- #run - Quickly moves the selected props one tile in the given direction.
- #pose - Causes selected props to assume the given pose.
- #jump - Teleports a prop to a given location.
- #talk - Displays a dialog box with some text.
- #setTile - Sets a tile layer (or movement mask or script layer).
- #newProp - Creates a new prop.
- #killProp - Kills a prop.
- #pause - Inserts a pause in the cutscene.
- #time - Marks the current time with a label.
- #setTime - Rolls the current time back to a previously labelled time.
Most of these commands map fairly simply onto the sequencer, or do so with only a little work, like translating a #walk cutscene command into sixteen #moveRel sequencer commands.
There are some other commands to simplify matters on the scriptwriting side of things. One of these is the #group cutscene command. All this does is add prop names to a list, and whenever a prop command is issued (#walk, #run, #pose, or #jump), it applies that to all the props in the current group. Usually, this just refers to a single prop, in which case, the economy lies in not having to specify the target prop in each line. Sometimes, though, it can apply to several props, which gives you a way for, say, a troop of soldiers to all do the same thing.
The other nice element here are the #time and #setTime commands. Any command that inserts a sequencer action in more than one frame advances the 'current' time ahead to allow sequential actions. For instance, two subsequent #walk actions would follow one after another in time, for a total of 32 frames. If another #walk action is issued, it would begin on the 33rd frame. This is good for writing a script, because you wouldn't want to have to specify frames for each action, but it does make simultaneous actions a little tricky.
To address this problem, I just keep a property list of frame numbers, keyed with labels. At any point in the script, you can reset the current time back to one of those labelled frame numbers, and resume writing the script - the new actions will appear at the restored frame number.
For instance, if you wanted Garrick to walk north and Sarah to walk east at the same time, you would write this script:
Time BeforeWalking Group Garrick Walk North UnGroup SetTime BeforeWalking Group Sarah Walk East UnGroup
Here, the first line labels frame one with the label "BeforeWalking". Then it flows animation frames to the sequencer. If we were then to have Sarah walk east, she would wait until Garrick finished walking north before moving. Instead, we return the current time back to frame one by calling up the frame labelled "BeforeWalking", and then flow the frames of Sarah walking into the same frames that have Garrick walking. Now, the animation commands for both characters are in the same range of frames.
Finishing out the Game
We now have the raw building blocks of an RPG: a map engine, a way to create dialog boxes, a way to have characters walk around on the map, and a mechanism for displaying cutscenes. Pretty much every console-style RPG out there has these elements as the baseline. Now, you can get to the exciting business of making your RPG unique.
There are about as many ways to define the game mechanics of an RPG as there are programmers who would try their hand at it. When you set out on your own RPG project, you'll have a lot of decisions to make, and you'll likely do things very differently than the way Sacraments does them. For instance, there are dozens of ways combat could be done. Sacraments uses a glorified dialog box for combat, but you could just as easily have backgrounds with combat sprites, or even have combat occur on the world map. If you've gotten this far, you'll be able to handle the combat engine.
At this point, your biggest challenge is simply making the game fun. This is a combination of having engaging gameplay and good story. If your engine is easy enough to work with, with a powerful, easy-to-use map editor and a straightforward way of writing cutscenes, you'll be able to apply creativity to your story quite easily. Good gameplay, on the other hand, requires lots and lots of playtesting - get beta testers if you can, because you can easily overlook elements that can vastly unbalance your game, making it discouragingly difficult or mind-numbingly easy. Personally, I think game balance is the most difficult part of game design (and is probably the weakest part of Sacraments), but with some careful design in the beginning and lots of play testing near the end, you should be able to produce a game engine that most of your players can enjoy.
Conclusion
For those of you who are wary of starting a project like Sacraments on your own, remember that although this project may seem large and complicated, it grew out of a lot of small, simple excercises in problem-solving. The nice thing about object-oriented programming is that you only have to solve one problem at a time, and usually, it's a pretty simple one. The only real hard part of this process is knowing what objects to build and what they should do. Hopefully, the discussion in these articles provided ideas for a workable roadmap that you can follow for your own projects.
For further reading, here are some other links to related sites:
- For more discussion of the development of Sacraments, visit the Sacraments web site for a development log and a project postmortem. (Be sure to download and play the game!)
- Pixelation is a community of pixel artists who can help you find or create tile-based pixel graphics for RPG's.
- So You Want To Be A Pixel Artist has some superb discussions of pixel techniques for tile art.
- Both rpgdx.net and rpg-dev.net have community boards for the discussion of the development of RPG's, and host regular competitions to help you get motivated.
- ...and of course, there are many articles here on Director-Online that can help you with implementation details for things like bitmap font renderers, file saving and loading, Imaging Lingo, etc.
I hope this article has encouraged you to try your hand at making your own RPG in Director. It's a lot of work - the graphics alone can be a formidable task - but it's also very rewarding. If not, maybe the techniques here are applicable to your other Director-based projects. Either way, I hope you have as much fun with the concepts we've discussed here as I've had sharing them.
Copyright 1997-2024, Director Online. Article content copyright by respective authors.