Release #5

It’s been a big week since the last release, mostly filled with things that aren’t in the game (or aren’t apparent) yet. But it’s exciting to see so much external progress, all the same.

Dumping a new release anyway mostly for the bug fixes and character profiles. (More coming in that department.)

Version: v0.11011

As usual, you can get it on the download page.

Changes in this release:

  • New sprite system is in place (no apparent effects yet)
  • Leigh/Ashleigh are now even more like real people
  • New scenes with Leigh, Ashleigh, and Steve
  • Characters now react differently to certain names
  • Characters now react slightly differently to a lack of studying
  • Character profiles are now filled in
  • New achievements
  • Achievements now show a notification when gained
  • Minor bug fixes

Stats for this release: 27,584 dialog blocks, 215,801 words, 1,129,531 characters

Sprites: More Adam Sketches, James Preview

More from the talented hands of @stollcomics. Here’s Adam looking good. 🙂 I think his sprite design is done at this point, and he’ll soon get inked and then colored.


Here’s a preview of a couple iterations in design on James.

The second is the more recent one. I think he’s going to be repositioned, and have some slight tweaks, but that’s the general idea.

Codes: Sprite expression changes

Last time, we added a custom showsp command to easily show sprites that obey game settings automatically. But that can be tedious to use if all you want to do in a scene is change a character’s expression when they’re already onscreen. Cue showexp!

A couple of changes to the old code are necessary. First of all, we need to store the raw names for the clothing (you’ll see why in a moment).

python early:
class CharPoses:
pose = ""
pants = ""
shirt = ""
expression = ""
extras = ""
oldclothes = ""
basepantsstr = ""
baseshirtstr = ""
baseextrasstr = ""
def execute_showsp(o):
…same as before…
if not who in store.spposes_data:
store.spposes_data[who] = CharPoses()
store.spposes_data[who].pose = pose
store.spposes_data[who].baseshirtstr = shirt
store.spposes_data[who].basepantsstr = pants
store.spposes_data[who].baseextrasstr = extras
store.spposes_data[who].shirt = help_get_filepathsp(who, pose, "s", shirt)
store.spposes_data[who].pants = help_get_filepathsp(who, pose, "p", pants)
store.spposes_data[who].expression = help_get_filepathsp(who, pose, "e", expr)
store.spposes_data[who].extras = help_get_filepathsp(who, pose, "x", extras)
…same as before…

Next, we define the parser and lint for showexp. Note that we’re allowing changes to the pose, as well, so this can be called as either showexp adam happy or showexp adam happy crossed.

python early:
def parse_showexp(lex):
who = lex.simple_expression()
expr = lex.simple_expression()
if lex.eol():
return (who, None, expr)
pose = lex.simple_expression()
return (who, pose, expr)
def lint_showexp(o):
who, pose, expr = o
if len(o) != 3 or expr is None:
renpy.error("Invalid showsp declaration")
return
if not who in showsp_valid_combos:
renpy.error("Invalid person " + who)
return
if pose is None:
# don't know the pose so check across poses
# may fail if a given expr only exists for one pose
# but we can't track current pose state
hasExpr = "e_" + expr in showsp_valid_combos[who]["!any"]
if not hasExpr:
for pose in showsp_valid_combos[who]:
hasExpr = "e_" + expr in showsp_valid_combos[who][pose]
if hasExpr:
break
if not hasExpr:
renpy.error("Invalid expression " + expr + " for " + who)
else:
if not pose in showsp_valid_combos[who]:
renpy.error("Invalid pose " + pose + " for " + who)
return
if not "e_" + expr in showsp_valid_combos[who][pose] and not "e_" + expr in showsp_valid_combos[who]["!any"]:
renpy.error("Invalid expression " + expr + " for " + who + pose)

Note the documented limitation in linting. Since we don’t always have the pose information, if you have an expression that exists only for one pose, this will be missed by linting. (So make sure all expressions exist in some base form, and override them on a per-pose basis as necessary, to prevent this.)

Finally, the actual execute command, where we use the information we’d previously stored in the new fields:

    def execute_showexp(o):
who, pose, expr = o
if not who in store.spposes_data:
renpy.error("Invalid person " + who)
if pose is not None:
if store.spposes_data[who].pose != pose:
# force all other components to re-render as necessary when pose changes
store.spposes_data[who].shirt = help_get_filepathsp(who, pose, "s", store.spposes_data[who].baseshirtstr)
store.spposes_data[who].pants = help_get_filepathsp(who, pose, "p", store.spposes_data[who].basepantsstr)
store.spposes_data[who].extras = help_get_filepathsp(who, pose, "x", store.spposes_data[who].baseextrasstr)
store.spposes_data[who].pose = pose
else:
pose = store.spposes_data[who].pose
store.spposes_data[who].expression = help_get_filepathsp(who, pose, "e", expr)
renpy.register_statement("showexp", parse=parse_showexp, execute=execute_showexp, lint=lint_showexp)

This will probably also fail if a particular shirt, pants, or extra exists only for one pose and you change pose as part of the command. Again, to be safe, make sure everything a character could be wearing exists at some form at the base level, or just don’t change poses around when using the command.

In any case, now you can more easily carry on a conversation without having to remember what a character was wearing in the same scene (or even where they’re standing, which is an improvement over the basic game’s show command, as well).

showsp adam sides greent jeans happy
adam "This is only a test. How do I look?"
showexp adam worried
adam "Does this look okay?"
showsp adam crossed bluet slacks curious jacket left
adam "Now I'm over here, and in blue. Huh."
showexp adam confused
adam "Strange."
showexp adam default crossed
adam "Oh well."
showexp adam grin sides
adam "It's nice to meet you anyway."

Yay! One last thing: Calling showexp before showsp will probably do nothing, since changing variables around while a sprite is offscreen has no effect. So if your script has a lot of jumps in it, you probably want to lead each label with a showsp for all of your characters, again, just to be safe.

Codes: Sprites, the YAGS way

One thing I needed was the ability to easily show sprites that obeyed various game settings automatically. For example, there’s a toggle in preferences to revert to the legacy stick figure sprites, there’s a nudity toggle, and there may eventually be an option to toggle body hair. In addition, the guys have a lot of different possible combinations of clothing and expressions.

There are a lot of “automatic” sprite scripts out there already, this oneprobably being one of the best ones. And for most people, I’m sure that will work just fine.

My requirements would have made that script tedious to use, though, since I would have had to case on different flags every time I wanted to show a sprite. Instead, I threw together a custom show command (showsp) that gives me the flexibility to change sprites depending on persistent and game state.

The first thing to do is to define the actual sprites. Since I’m supporting both the new style and old stick figures, each character has to be defined twice. Note the file structure requirements in the comment. It’s also worth noting that each expression must exist both in the old and new sprite directories. 

default spposes_data = {}

#file structure: base folder adam
#under that, folders with name of poses (sides, crossed) with base.png under it
#then either in base folder or in poses subfolder
#s_.png, p_.png, e_.png, x_.png
#in poses folder if differs by pose, base folder if not
#x_blank.png and p_cover.png is required for all characters
#s_shirtless, p_underwear, and p_naked are handled specially for legacy sprites, if they exist
image adam adamnew = LiveComposite(
(342, 600),
(0, 0), "chnew/adam/[spposes_data[adam].pose]/base.png",
(0, 0), "chnew/adam/[spposes_data[adam].pants].png",
(0, 0), "chnew/adam/[spposes_data[adam].shirt].png",
(0, 0), "chnew/adam/[spposes_data[adam].expression].png",
(0, 0), "chnew/adam/[spposes_data[adam].extras].png",
)
#c_blank.png and c_basic.png are required for clothing in old sprites
image adam adamold = LiveComposite(
(500, 600),
(0, 0), "chold/adam/base.png",
(0, 0), "chold/adam/c_[spposes_data[adam].oldclothes].png",
(0, 0), "chold/adam/[spposes_data[adam].expression].png",
)

Also note we’re making use of dynamic images, which is particularly useful, and would work on its own, except you don’t get linting checks for invalid images, and it would make assignment within the game a bit of a pain.

Next, we actually define a command to show sprites. This is the heart of the framework, so I’ll go over it piece by piece.

First is the file processing. This automatically picks up all images from the chnew directory, sorts them by pose, and builds a map of all allowable assets. 

python early:
class CharPoses:
pose = ""
pants = ""
shirt = ""
expression = ""
extras = ""
oldclothes = ""
showsp_valid_combos = {}
showsp_valid_locations = ("left", "right", "slightleft", "moreright", "lessright")
def process_file(charname, pose, filename):
itemname = filename.replace(".png", "")
if itemname != "base":
if not pose in showsp_valid_combos[charname]:
showsp_valid_combos[charname][pose] = set()
showsp_valid_combos[charname][pose].add(itemname)
def process_all():
for path in renpy.list_files():
if path.startswith("images/chnew/"):
pathlist = path.split("/")
charname = pathlist[2]
if not charname in showsp_valid_combos:
showsp_valid_combos[charname] = {}
showsp_valid_combos[charname]["!any"] = set()
if pathlist[3].endswith(".png"):
process_file(charname, "!any", pathlist[3])
else:
process_file(charname, pathlist[3], pathlist[4])
process_all()

Note the un-ideal manual declaration of supported Positions. This will show up again later, but I’m not sure how to take a position string and convert it to a Position object automatically.

Next, the custom command itself. The parsing logic is slightly complex, because we want to be able to omit the position and omit extras, in any combination. (Note that this technically means you can’t have both a Position and an extra with the same name, but you probably don’t want something like x_right.png anyway.)

python early:
def parse_showsp(lex):
who = lex.simple_expression()
pose = lex.simple_expression()
shirt = lex.simple_expression()
pants = lex.simple_expression()
expr = lex.simple_expression()
if lex.eol():
return (who, pose, None, shirt, pants, expr, None)
extraorpos = lex.simple_expression()
if lex.eol():
if extraorpos in showsp_valid_locations:
return (who, pose, extraorpos, shirt, pants, expr, None)
else:
return (who, pose, None, shirt, pants, expr, extraorpos)
else:
return (who, pose, lex.simple_expression(), shirt, pants, expr, extraorpos)

Next, the linting. It’s technically unnecessary, but it’s invaluable unless you want to be tracking down runtime errors from invalid clothing or expressions in your game. Note the returns that prevent errors when you try to access a map item that doesn’t exist (due to invalid pose or character).

    def lint_showsp(o):
who, pose, where, shirt, pants, expr, extras = o
if where is not None and where not in showsp_valid_locations:
renpy.error("Invalid location " + where)
if not who in showsp_valid_combos:
renpy.error("Invalid person " + who)
return
if not pose in showsp_valid_combos[who]:
renpy.error("Invalid pose " + pose + " for " + who)
return
if not "s_" + shirt in showsp_valid_combos[who][pose] and not "s_" + shirt in showsp_valid_combos[who]["!any"]:
renpy.error("Invalid shirt " + shirt + " for " + who + pose)
if not "p_" + pants in showsp_valid_combos[who][pose] and not "p_" + pants in showsp_valid_combos[who]["!any"]:
renpy.error("Invalid pants " + pants + " for " + who + pose)
if not "e_" + expr in showsp_valid_combos[who][pose] and not "e_" + expr in showsp_valid_combos[who]["!any"]:
renpy.error("Invalid expression " + expr + " for " + who + pose)
if extras is not None and not "x_" + extras in showsp_valid_combos[who][pose] and not "x_" + extras in showsp_valid_combos[who]["!any"]:
renpy.error("Invalid extra " + extras + " for " + who + pose)

Finally, the actual command logic:

    def help_get_filepathsp(who, pose, prefix, item):
if prefix == "x" and item is None:
return "x_blank"
if prefix + "" + item in showsp_valid_combos[who][pose]: return pose + "/" + prefix + "" + item
elif prefix + "" + item in showsp_valid_combos[who]["!any"]: return prefix + "" + item
else:
renpy.error("Could not find resource " + prefix + "_" + item + " for " + who + pose)
def execute_showsp(o):
who, pose, where, shirt, pants, expr, extras = o
whereobj = None
if where is not None:
if where == "left":
whereobj = left
elif where == "right":
whereobj = right
elif where == "slightleft":
whereobj = slightleft
elif where == "moreright":
whereobj = moreright
elif where == "lessright":
whereobj = lessright
else:
renpy.error("Unknown location " + where)
else:
whereobj = default
# TODO: Special variable checks to override things here
# possibly on a character-specific basis
if pants == "naked" and not persistent.nudes:
pants = "cover"
# End special checks
if not who in store.spposes_data:
store.spposes_data[who] = CharPoses()
store.spposes_data[who].pose = pose
store.spposes_data[who].shirt = help_get_filepathsp(who, pose, "s", shirt)
store.spposes_data[who].pants = help_get_filepathsp(who, pose, "p", pants)
store.spposes_data[who].expression = help_get_filepathsp(who, pose, "e", expr)
store.spposes_data[who].extras = help_get_filepathsp(who, pose, "x", extras)
# handle old sprites; either they're dressed or they're not
if pants == "naked" or pants == "underwear" or pants == "cover" or shirt == "shirtless":
store.spposes_data[who].oldclothes = "blank"
else:
store.spposes_data[who].oldclothes = "basic"
if persistent.oldsprites:
renpy.show(who + " " + who + "old", at_list=[whereobj])
else:
renpy.show(who + " " + who + "new", at_list=[whereobj])
renpy.register_statement("showsp", parse=parse_showsp, execute=execute_showsp, lint=lint_showsp)

A few things to note here: The position stuff is really non-ideal (you need to manually handle every position you support for sprites in the game), but you can’t just do at_list=[where] as where is a string and not a position. There’s special logic to handle overrides, such as putting a covering over the character if they’re naked and nudity is turned off; it would be straightforward to handle body hair the same way.  You can also override special cases manually (for example, I’ll probably handle Juan’s cast this way by reading the current game state instead of having to put the cast as an extra every time).

This should also be easily extensible if you want to build expressions from pieces (eyebrows, eyes, mouth) by adding support for things like b_, y_, and m_.

In any case, all of this mess of code allows you to write your game script like this:

showsp adam sides greent jeans happy jacket left
adam "This is only a test. How do I look?"
showsp adam crossed bluet slacks curious right
adam "Now I'm over here"
showsp adam crossed shirtless jeans embarassed
adam "And losing my clothing."
showsp adam sides shirtless underwear embarassed jacket
adam "That's not really any better."

Which is really nice given it’s obeying all of your game state and preferences automatically.

Future enhancement: A showexp command that changes just the expression on the given sprite, without having to re-specify the rest of the clothing or position.

Edit: The showexp command has been defined here. Also, I found a way to grab the location object based on the string name for it.

        if where is not None:
if where in showsp_valid_locations:
whereobj = globals()[where]
else:
renpy.error("Unknown location " + where)
else:
whereobj = default

I kept the showsp_valid_locations list in place, because it seemed useful. (After all, you want to make sure what you’re grabbing is actually a Position object that you expect, and not some arbitrary RenPy object sitting there.) But this means adding a new position is just a matter of defining it and updating the list, instead of actually updating code.

Codes: Achievements, Take Two

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.)

Musak: YAGS Theme (Preliminary lyrics)

As I’ve probably mentioned elsewhere, this game has been a lot of firsts for me. Not only is it my first time using RenPy (or even writing Python code, at all), it’s also my first non-school-related creative writing project as well as my first video game.

To that list can be added songwriting. I threw together some lyrics for a theme song, and my esteemed musical colleague is helping fix it, record it, and put it to a pop/rock track.

Like most things I post here, there’s no guarantees about its quality. But hopefully it does a passable job at conveying the general sentiment of the game. This is also just the first cut, so it’s particularly rough.

It is, appropriately and unoriginally, titled Yearning.

Feeling alone, Off on my own
Kinda for the first time, and a
Secret inside, like something to hide
Like a mountain I must climb, and there’s
Looking back to who I’ve been
And finding comfort in my skin
So soon with just some time
I guess that I will find…
That I’m

(Chorus)
Yearning
To be who I’ll be
Finding myself, Finally free, I’m just
Yearning
So that I can see
That maybe what I neeeeeed….
Could be right here in front of me

(Instrumental)

People I meet, talks that I seek
Friendships forming as I go, and there’s
Times of support, sorrow cut short
Laughter, joy, and thoughts that flow, from my
Mind as I find something more
In those friends that I adore
Still with each and every day
Wish I could get away…
From this

(Chorus)
Yearning
To be who I’ll be
Finding myself, Finally free, I’m just
Yearning
So that I can see
That maybe what I neeeeeed…
Is what I’m finding in front of me

(Bridge)
Through thick and thin and broken limbs
And even times of trial
As we grow close, what helps the most
Is when I see you smile

Yeah, I love to see your smile
I’m still…

(Chorus)
Yearning
To be who I’ll be
Finding myself, Finally free, I’m just
Yearning
But now I can see
That all this time you’ve been there for me,
With this

(Chorus)
Yearning
To be who I’ll be
Finding myself, Finally free, I’m just
Yearning
So that I can see
That maybe what I needed, in the end…
Was you there standing in front of me

(Instrumental)

Thank you for being in front of me

(Instrumental)

I’m glad that you’re here in front of me

(Strum, end)

Will post demos and backing track stuffs once I have them. But songwriting is hard, yo.

Todo List #4

The goal of the next release is to get the remaining backend changes in place.

  • Write and test new sprite system. *
  • Fix achievements.
  • Finish character profiles.
  • More GUI tweaks.
  • More Leigh/Ashleigh/Steve scenes.
  • Continue to condense dialogue blocks to reduce number of clicks. 
  • Studying (or failing to do so) should have a larger impact on conversations and choices. **

* The amount of work here will depend somewhat on whether (and when) I get sprites from my sprite artist. If I get them somewhat soon, I want to actually start replacing sprites in the game with the next release. We shall see.

** Yes, this is back again. I failed at doing it last time.

Music is also in progress now, as I’ll be posting shortly. With luck, I’ll have a theme song by the next update I can throw in on the title screen.

Codes: Achievements

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.