Codes: Achievements, Take Two

You might want to use my achievement framework instead of following this guide.

Last time, we added achievements to our game, in a rather crappy and user un-friendly way, but that should theoretically integrate with Steam properly. Today, we improve that by defining a custom achievement command and showing a message to the user!

The first thing to do is to define a command:

python early:
basic_achievements = (
("First Steps", "Starting your college life", "Starting your college life"),
…More achievements…
)
def parse_achieve(lex):
what = lex.rest()
return what
def lint_achieve(what):
if not any(what == a for a,lkd,unk in basic_achievements):
renpy.error("Invalid achievement " + what)
def execute_achieve(what):
if achievement.has(what):
return
achievement.grant(what)
renpy.show_screen("gain_achieve", _layer="screens", achievetext=what)
renpy.register_statement("achieve", parse=parse_achieve, execute=execute_achieve, lint=lint_achieve)

Note the screen that we show to notify the user. That screen is defined as follows:

screen gain_achieve(achievetext):
tag achievementalert
fixed:
image "sp/achievementbg.png" at Position(xpos=0.85, ypos=0.1, xanchor=0.5, yanchor=0.5)
text "{size=22}You unlocked an achievement:\n" + achievetext + "{/size}" id "achievetext" at Position(xpos=0.85, ypos=0.1, xanchor=0.5, yanchor=0.5) xmaximum 290 ymaximum 90
timer 3.0 action Hide("gain_achieve")

Basically, we’ve added a new screen that that shows up in the upper right of the screen, tells the user they unlocked an achievement, and then dismisses itself after 3 seconds.

One important thing I want to point out here is the _layer=“screens”parameter when you show the screen with renpy.show_screen(). Assuming I understand RenPy properly, by default, there are a few different threads to render the GUI. In particular, when you show a screen, the main rendering thread is blocked on the screen, and will not do anything else until you dismiss it. In this case, because we’re showing a screen that dismisses itself, RenPy freaks out and throws an error on the next call that does anything other than hides the screen.

When we direct the screen to be displayed in the overlay layer, instead, it does not tie up the main thread. Indeed, gameplay continues normally and the user can keep playing.

The final part of this is to actually grant achievements within your game script. Because we’ve defined a custom command, this is now easy:

"You feel an immense sense of relief, as well."
achieve Nothing But Love
show adam grin
"Adam turns to you, grinning."

One caveat that I have not figured out yet: What to do if your achievement name contains quote characters. For example, I have an achievement called “Journey’s End” with an apostrophe in the name. Trying to grant it directly like this results in an error:

achieve Journey's End

Instead, I’ve special-cased that achievement, and grant it as JourneysEnd instead. I’m sure there’s a way to escape the apostrophe, but I also didn’t care to keep banging my head against it tonight.

One other important thing to note: These statements are notequivalent.

achieve Nothing But Love
achieve "Nothing But Love"

The version without quotes is almost certainly what you want. (I’m assuming the quotes are read literally, instead of denoting a string, but I also did not test to see exactly why it was failing to work properly.)

Codes: Achievements

You might want to use my achievement framework instead of following this guide.

I actually haven’t seen a full writeup of using RenPy’s achievements module. Thankfully, it’s super easy to set up, but I have no idea if this will actually integrate into Steam and such properly.

In any case: The game had collectibles. Yay. But I wanted some larger accomplishments (such as finding lots of collectibles) to be noted in a more prominent way.

The first thing to note is that RenPy’s achievements module is part of core RenPy and not its game scripts. (This is probably obvious, but I figured I’d point it out anyway.) As such, you have to call it from python code blocks.

To simplify things, I defined two kinds of achievements: Ones in a set list that are just boolean (you have it or you don’t) toggles, and ones that have a bit more complicated tracking logic. (I believe the latter will matter more if you actually integrate into Steam, but again, I have not actually tested any of this.)

First off: Defining your achievements. They just need a name, but I put a small description with them for UI display. Note the tuple instead of map, this time, so ordering is maintained:

python early:
basic_achievements = (
("First Steps", "Starting your college life", "Starting your college life"),
("Nothing But Love", "???", "Coming out to your parents"),
("Achievement Name", "Description when not unlocked", "Description when unlocked"),
…other achievements…
)

Then, you’ll want to register them with the backend. I believe this isn’t strictly necessary, but it doesn’t hurt to do.

init python:
for a, lockdesc, unlockdesc in basic_achievements:
achievement.register(a)
achievement.register("Growing Pains", stat_max=100, stat_modulo=1)
achievement.register("Compulsive Hoarder", stat_max=100, stat_modulo=1)

Note the manually-registered ones that include stat maxes. You could probably just register them with the actual number of collectibles, but I figured it’d be cleaner to deal with completion in terms of percentage, especially as I was still increasing the number of collectibles in the game.

Next, you actually want to grant these achievements in the game. This is, thankfully, straightforward.

"You feel an immense sense of relief, as well."
$ achievement.grant("Nothing But Love")
show adam grin
"Adam turns to you, grinning."

So this grants achievements (silently, to the player). How do you display them? I added them to the collectibles screen I defined in the previous post.

screen mycollection():
tag menu
default collectshow = "collectibles"
use game_menu(("Collection"), scroll="viewport"): vbox: spacing 10 hbox: spacing 20 textbutton ("Collectibles") action SetScreenVariable("collectshow", "collectibles")
textbutton _("Achievements") action SetScreenVariable("collectshow", "achievements")
if collectshow == "achievements":
for aname, lockdesc, unlockdesc in basic_achievements:
if achievement.has(aname):
text aname + ": {color=#777}" + unlockdesc + "{/color}"
else:
text "{color=#ccc}???: " + lockdesc + "{/color}"
if achievement.has("Growing Pains"):
text "Growing Pains: {color=#777}Finding " + str(int(len(collectitems)/2)) + " collectibles{/color}"
else:
text "{color=#ccc}???: Finding " + str(int(len(collectitems)/2)) + " collectibles{/color}"
if achievement.has("Compulsive Hoarder"):
text "Compulsive Hoarder: {color=#777}Finding all " + str(int(len(collectitems))) + " collectibles{/color}"
else:
text "{color=#ccc}???: Finding all " + str(int(len(collectitems))) + " collectibles{/color}"
else:
text "Found " + str(len(persistent.collection)) + "/" + str(len(collectitems)) + " collectibles" id "collectiblescount"
for k in sorted(persistent.collection):
if k in collectitems:
textbutton collectitems[k][0] action Show("itemdesc", itemid=k)

The last part is to increment progress as you acquire collectibles. This is also straightforward.

    def execute_collect(what):
if what in persistent.collection:
return
formatmsg = "{color=#090}{b}You found a collectible:{/b} "
if what in collectitems:
formatmsg += collectitems[what][0]
else:
renpy.error("Invalid collectable " + what)
persistent.collection.add(what)
totitemcount = len(collectitems)
curitemcount = len(persistent.collection)
if curitemcount >= (totitemcount):
if not achievement.has("Compulsive Hoarder"):
achievement.grant("Compulsive Hoarder")
else:
achievement.progress("Compulsive Hoarder", int(totitemcount100/curitemcount)) if curitemcount >= (totitemcount/2): if not achievement.has("Growing Pains"): achievement.grant("Growing Pains") else: achievement.progress("Growing Pains", int(totitemcount50/curitemcount))
if len(persistent.collection) == 1:
formatmsg += "\n(Access your collection from the main menu or pause menu. Collectible items will persist between games.)"
formatmsg += "{/color}"
renpy.say("", formatmsg)

As usual, I make no claims that this code is the best way to do things, or even a good way to do things. But it works. And, assuming achievements work the way I assume they will, doing it this way should also hook properly into things like Steam.

I was expecting the achievement framework to already put up some sort of notification, but it looks like RenPy doesn’t do this by default, so using this doesn’t really have any immediate advantages over directly writing persistent data.

This could be improved by adding a custom command to wrap achievement.grant() that can lint against the known list of achievements, and that can also show a notification to the player that an achievement was unlocked (some new screen that displays temporarily in some corner). This is perhaps an enhancement I will make at some point. 

Edit: Improved achievements, building on this code, is here.

Codes: Collectibles

Another thing I wanted to include in my game was little items that were both fun to collect, and also gave some indication of how much of the game you’d explored.

So the game has collectibles: Small named items that you acquire throughout the game that you can view descriptions of in a dedicated section of the menu. This isn’t a novel idea, by any means, but it also wasn’t entirely straightforward to set up. (Probably because I was really new to RenPy at the time.)

First, the core code that makes it work. There’s two sections here: First is the declaration of the actual items you can pick up, to keep it in one place, along with the associated persistent storage code:

init python:
if persistent.collection is None:
persistent.collection = set()
def merge_collections(old, new, current):
current.update(old)
current.update(new)
return current
renpy.register_persistent('collection', merge_collections)
python early:
collectitems = {
"adamid": ("Adam's Student ID", "A student ID for one Adam James Prewitt. It looks well-worn."),
"jalapeno": ("Jalapeno", "A whole jalapeno from dinner with Adam."),
…other items here…
}

Second, because I wanted to be able to easily grant these items as part of the game script, there’s a custom command that grants items. Again, note the Lint check, which is super useful to catch typos and missed sections when you rename items:

python early:
def parse_collect(lex):
what = lex.rest()
return what
def lint_collect(what):
if not what in collectitems:
renpy.error("Invalid collectible " + what)
def execute_collect(what):
if what in persistent.collection:
return
formatmsg = "{color=#090}{b}You found a collectible:{/b} "
if what in collectitems:
formatmsg += collectitems[what][0]
else:
renpy.error("Invalid collectable " + what)
persistent.collection.add(what)
if len(persistent.collection) == 1:
formatmsg += "\n(Access your collection from the main menu or pause menu. Collectible items will persist between games.)"
formatmsg += "{/color}"
renpy.say("", formatmsg)
renpy.register_statement("collect", parse=parse_collect, execute=execute_collect, lint=lint_collect)

This can then be straightforwardly called as part of your game script. The best part is that you don’t have to put it behind a conditional, because the player will only be notified if they don’t already have the item.

adam "Anyway…"
"He smiles at you before quickly walking out the door."
collect adamid
hide adam

So that grants collectibles, and tells the player when they receive a new one, but how to show the collection to the player? You have to make a new screen, which is thankfully straightforward. First, the screen that actually shows item descriptions, pulling from the map of valid collectible items you defined earlier:

screen itemdesc(itemid):
tag itemd
modal True
image "sp/itembg.png" at Position(xpos=0.5, ypos=0.5, xanchor=0.5, yanchor=0.5)
vbox at Position(xpos=0.5, ypos=0.5, xanchor=0.5, yanchor=0.5, xmaximum=1280, ymaximum=250):
text collectitems[itemid][1] id "collectitemtext" xalign 0.5 yalign 0.5 xmaximum 570 ymaximum 200
null height 10
textbutton "Dismiss" action Hide("itemdesc") xalign 0.5 yalign 0.5

Then the screen that lists the collectibles and makes them clickable to show their descriptions:

screen mycollection():
tag menu
use game_menu(_("Collection"), scroll="viewport"):
vbox:
spacing 10
text "Found " + str(len(persistent.collection)) + "/" + str(len(collectitems)) + " collectibles" id "collectiblescount"
for k in sorted(persistent.collection):
if k in collectitems:
textbutton collectitems[k][0] action Show("itemdesc", itemid=k)

Finally, add your screen as an item in the standard menu:

textbutton _("Load") action ShowMenu("load")
textbutton _("My Collection") action ShowMenu("mycollection")

Note that this doesn’t feed into the achievements system, but you probably don’t want it to… hundreds of achievements for every little thing in your game is probably overkill. But doing it this way allows the ability to easily add new collectibles (just add a new row to your map, and then collect itemname in your script) that persist between different plays of your game.

Codes: Instant Messaging

One thing I knew I wanted in the game from the beginning was instant messenger conversations – I remember using it basically exclusively for short-form communication from middle school through college, and it seemed appropriate that college students in 2006 would be using it.

This would be straightforward to do normally in RenPy. You can make a ParameterizedText object and just show it with your desired text whenever you need a new line.

image imwindow = Image("sp/imwindow.png", xanchor=210, ypos=0.69)
image im1 = renpy.ParameterizedText(xanchor=190, ypos=0.1)
image im2 = renpy.ParameterizedText(xanchor=190, ypos=0.15)
…etc

And then

 show imwindow
show im1 "{color=#f00}Diocusin:{/color} {color=#000}This is a test.{/color}"
$ renpy.pause()
hide imwindow
hide im1

However, that gets very verbose very fast. I wanted to be able to easily script IM conversations using character names instead of raw text, and I wanted to automatically handle coloring and showing and hiding, as appropriate.

RenPy allows you to define your own commands, so I defined one to hide and show the background window. (The one to show it is technically not necessary, but it’s here for completeness.)

python early:
def parse_hideim(lex):
return "”
def execute_hideim(o):
renpy.hide("imwindow")
renpy.hide("im1")
renpy.hide("im2")
…etc…
_window_show(trans=None)
renpy.register_statement("hideim", parse=parse_hideim, execute=execute_hideim)
def parse_showim(lex):
return "”
def execute_showim(o):
_window_hide(trans=None)
renpy.show("imwindow")
renpy.register_statement("showim", parse=parse_showim, execute=execute_showim)

Then it’s fairly straightforward to define a command that shows a message.

    def parse_im(lex):
num = lex.simple_expression()
who = lex.simple_expression()
pause = lex.simple_expression()
msg = lex.rest()
return (num, who, pause, msg)
def lint_im(o):
num, who, pause, msg = o
if(who != "mc" and who != "janet" and …etc…):
renpy.error("Invalid IM speaker " + who)
if(pause != "p" and pause != "n"):
renpy.error("Invalid pause value " + pause)
if(int(num) < 1):
renpy.error("Invalid IM number " + num)
def execute_im(o):
num, who, pause, msg = o
formatmsg = ""
if(who == "mc"):
formatmsg += "\"{color=#f00}Diocusin:{/color} {color=#000}"
elif(who == "janet"):
formatmsg += "\"{color=#00f}CrazyCatLady2588:{/color} {color=#000}"
…etc…
else:
renpy.error("Invalid IM person " + who)
formatmsg += "\"" + msg + "\""
formatmsg += "{/color}\""
if(not renpy.showing("imwindow")):
execute_showim("foo")
imnum = "im"+num
renpy.show((imnum, formatmsg))
if(pause == "p"):
renpy.pause()
renpy.register_statement("im", parse=parse_im, execute=execute_im, lint=lint_im)

This command can then be used to carry on a conversation within a script.

im 1 mc p "This is a test."
im 2 janet n "this is only a test, and clicking just once"
im 3 janet p "shows both of these lines together"
hideim

Note that, if I were doing this now, I would keep a counter variable that automatically increments the im number, and reset it in the hideim command. It would simplify IM conversations to something like

im mc p "This is a test."
im janet p "this is only a test"
hideim

but there’s something to be said about knowing exactly how many lines into a conversation you are at any given moment. 

There are probably better ways to do this, and someone more comfortable with Python and the RenPy engine could undoubtedly make this cleaner. But this got the job done for me.

Also never underestimate the power of lint. It’s a most excellent way to catch stupid mistakes when using your custom commands.