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.

Join the Conversation

3 Comments

Leave a comment

Your email address will not be published. Required fields are marked *