Layeredimages dynamic control using adjust_attributes

A place for Ren'Py tutorials and reusable Ren'Py code.
Forum rules
Do not post questions here!

This forum is for example code you want to show other people. Ren'Py questions should be asked in the Ren'Py Questions and Announcements forum.
Post Reply
Message
Author
Gouvernathor
Newbie
Posts: 23
Joined: Thu Aug 06, 2020 9:27 am
Github: Gouvernathor
Discord: Armise#6515
Contact:

Layeredimages dynamic control using adjust_attributes

#1 Post by Gouvernathor »

This tutorial is based upon the awesome graphics made by BoysLaugh+ for their game //TODO: today (used here with permission, don't use in your game). Find them in the zip, to try the code I'm showing here !

The set is simple : you have a character called Phoenix, which is created with a layeredimage because that's a really awesome and powerful thing.
Your character is fairly simple, with two outfits, 8 faces, a blush overlay and a sweat/embarassed overlay.

Let's say you want to let the user customize some aspect of the character, typically its gender.
This could be asked to the user directly via a prompt or binary choice, or more indirectly via some questionnaire (like in todo:today)... doesn't really matter, the point is, it's decided based upon the state of a python variable.

If it were like having the first wristband or the second, you could implement it like :

Code: Select all

layeredimage phoenix:
	if phoenix_wristband==1:
		"phoenix_wristband_green.png"
	elif phoenix_wristband==2:
		"phoenix_wristband_yellow.png"
But here, it's different : every sprite of your character (or at least a lot of them) need to change when the gender is changed. (And also in that case you wouldn't be able to opt-out of wearing the wristband by something like show phoenix -wristband - which is the main purpose of layeredimages after all.)
So, how would you make such conditional ?

First of all, a disclaimer :
It is (almost) impossible to make a layeredimage react in real time when the underlying variable changes. That's not covered in this tutorial.

One possible answer would be a (not so) clever use of LayeredImageProxy. Little is known (and for good reasons) about its capacity to handle square-brackets string substitution.
So, you would define two layeredimages, mphoenix and fphoenix, with the same exact structure and taking the same exact attributes, a control variable, and a layeredimage making the switch between the two based upon the state of the control variable.
This would look like that :

Code: Select all

layeredimage fphoenix:
    zoom .5
    group base auto:
        attribute default default
    attribute blush
    group face auto:
        attribute smile default
    attribute sweat

layeredimage mphoenix:
    zoom .5
    group base auto:
        attribute default default
    attribute blush
    group face auto:
        attribute smile default
    attribute sweat

default phoenixgender = "m"
image phoenixproxy = LayeredImageProxy("[phoenixgender]phoenix")
To fit this structure, the sprite files have to be named [f/m]phoenix_[group]_[attribute].
As you can see, each layeredimage has to have the same structure, dimensions and all because when our code will call it, it won't know which of mphoenix or fphoenix will appear, so treatment of both need to be as identical as possible. For that reason, their attributes need to be exactly the same, or it will cause issues in renpy.

This could seem fair and square, but two issues arise :
  1. The square-brackets substitution feature of LayeredImageProxy is not documented, so it may break unexpectedly between a version of renpy and the other, breaking your game along the way.
  2. This "works" with one variable having two possible values, but what about managing both gender, body shape, and, say, favorite outfit like in Arcade Spirits 2, each parameter having repercussions on several sprites ? Will you define genders*shapes*outfits different layeredimages, each time repeating the attribute which are in common between versions ?
No, you need to find something else. Something modular, without... or with less code duplication.

And here comes the adjust_attribute part.
Adjust_attributes allows you (in the case of layeredimages) to compute, add, filter, change, what attributes a layeredimage will be called with, when the show instruction is executed.
This means you can communicate to your layeredimage (we'll show exactly how later) whether you want the male version or the female version, based upon the control variable, each time phoenix is called to be displayed !
How is that communicated ? Using attributes. The adjust_attributes function, when calling the phoenix (layered)image, will add, say, a m attribute or a f attribute, based upon the state of the control variable (or computed in any other way really). We'll see what the phoenix layeredimage does with this later.
So, you need to define your adjust_attributes function :

Code: Select all

init python:
    def phoenix_adjust_attributes(name):
        names = set(name[1:])
        if phoenix_is_male:
            names.add("m")
        else:
            names.add("f")
        return (name[0],)+tuple(names)

default phoenix_is_male = False
define config.adjust_attributes["phoenix"] = phoenix_adjust_attributes
As you can see, phoenix_is_male is the control variable, and the function is stored in config.adjust_attributes to trigger when the "phoenix" tag is called, which means when show phoenix ... will be executed from the script.

The details of how the function works :
The name parameter, unlike what it seems, is a tuple of strings. If the image is called with show phoenix a b c, name will be ("phoenix", "a", "b", "c").
Name[0] is not interesting since it will always be "phoenix". The order is also non-important, because renpy doesn't care about it. Doubles are not important in the case of layeredimages.
Don't care about doubles ? No order ? Let's make it a set ! And to avoid mixing our two variables, let's call it names.
The next part is simple, if phoenix is male, tell the layeredimage to use the "m" attribute, otherwise tell it to use the "f" one. That's how we insert our message to the layeredimage. We could also have used the old phoenixgender variable and added it instead... doesn't really matter, there are several ways to do it.
Then, we reformat it as a tuple, while readding name[0] at the beginning, because we need to return the same kind of data we received.

So, now that it's done, what's left ? The layeredimage itself of course !
This time, we're going to use only one layeredimage, and no layeredimageproxy. That means our sprites will need to be renamed, as to all start with "phoenix" - and not "fphoenix" or "mphoenix", that wouldn't work (well actually it could work but it would break the auto behavior of layeredimages, wich would be a pita, so let's not do that).
We will rename them as phoenix_[group]_[f/m]_[attribute] - you'll see exactly why later.
So, let's sum up. We have different sets of sprite files, which match a more limited set of attributes. How do you change the image name while respecting the auto feature and not changing the attribute name ? Variants !
Here is the actual code for the layeredimage :

Code: Select all

layeredimage phoenix:
    zoom .5
    group gender:
        attribute m null default
        attribute f null
    group base auto variant "m" if_any "m":
        attribute default default
    group base auto variant "f" if_any "f":
        attribute default default
    group face auto variant "m" if_any "m":
        attribute smile default
    group face auto variant "f" if_any "f":
        attribute smile default
    group m multiple variant "m" if_any "m":
        attribute blush
        attribute sweat
    group f multiple variant "f" if_any "f":
        attribute blush
        attribute sweat
We have dummy f/m attributes. null means it's not linked to any file or displayable, and will have nothing to display. default means it's a male by default - but you don't have to set a default.
If we had other parameters, like "shape" (for body shape) for example, then we would have added another group with all-null attributes, and if_all/if_any conditions based upon the name of those attributes as well.
Each group now has two versions. Each group will use the files named after its variant - and that's why the f/m was placed as it is in the filenames. But we want a group to display only when it's supposed to be, a.k.a when the attribute of the gender of the variant is enabled ! So we add an if_any (but it could be an if_all as well, since there's only ever one of such control attributes to check) to the group, as a condition.
The last part, with the multiple groups, is an trick. Naively, you would have written something like

Code: Select all

attribute blush if_any "f":
	"...f_bplush.png"
attribute blush if_any "m":
	...
attribute sweat if_any "f":
	...
attribute sweat if_any "m":
	...
You can't use different attribute names, otherwise renpy wouldn't recognize blush to be a valid attribute, and variants only work for groups. That means you have to do the path to the image by hand, and can't use auto.
You could name the attributes f_blush, m_blush and so on, and make the switch in adjust_attributes, but it would be painful.
It's more simple to use multiple groups, which don't call the name of the group as a part of the image name in the auto behavior - hence why the filename is "phoenix_f_blush.png" and not "phoenix_f_f_blush.png". Multiple groups can have several of their attributes called to display at the same time, and they are actually a sneaky way to add a variant and/or an if_any/if_all clause to one or several attributes at once. So that's basically what we're doing here.

Voilà ! I think you're ready to use dynamic-ish (see my disclaimer above) layeredimages in renpy !
If you have any question about my explainations or if it's unclear, feel free to ask for them in replies ! If I saved your project, you can drop my name in the credits <3 but don't feel compelled to.
You can use BoysLaugh+'s sprites included here to test the tutorial, but, as said above, you need to ask for the boyz' permission to do anything other than local tests with them, such as including them in games, or distributing them.
Attachments
phoenix.zip
(1.12 MiB) Downloaded 68 times

User avatar
zmook
Veteran
Posts: 421
Joined: Wed Aug 26, 2020 6:44 pm
Contact:

Re: Layeredimages dynamic control using adjust_attributes

#2 Post by zmook »

Gouvernathor wrote: Sun Jan 16, 2022 7:56 pm Adjust_attributes allows you (in the case of layeredimages) to compute, add, filter, change, what attributes a layeredimage will be called with, when the show instruction is executed.
I feel the need to give props. This was super clever and helpful.
colin r
➔ if you're an artist and need a bit of help coding your game, feel free to send me a PM

Gouvernathor
Newbie
Posts: 23
Joined: Thu Aug 06, 2020 9:27 am
Github: Gouvernathor
Discord: Armise#6515
Contact:

Re: Layeredimages dynamic control using adjust_attributes

#3 Post by Gouvernathor »

Thank you ! Feedback like this is really appreciated. Let me know if you use it in a released game !

User avatar
Zetsubou
Miko-Class Veteran
Posts: 522
Joined: Wed Mar 05, 2014 1:00 am
Completed: See my signature
Github: koroshiya
itch: zetsuboushita
Contact:

Re: Layeredimages dynamic control using adjust_attributes

#4 Post by Zetsubou »

Thanks Gouvernathor! I think your combination of null, variant, and if_any is a good example of what someone making a more complicated layeredimage would want to know, but isn't necessarily obvious from the renpy docs.

For my own uses, your example of combining adjust_attributes and layeredimage was just what I was looking for.
My use case probably isn't what you had in mind, but it was a big help nonetheless.

My situation is that I'm migrating one of my old games from Renpy 6 (before layeredimages were a thing) to Renpy 7/8.
As part of that, I'm replacing thousands of LiveComposite images with a few dozen layeredimages.
Most of the sprites can be converted relatively easily, but there are some which use a combination of different variables to define the image, and that makes it difficult to redefine the sprite with a layeredimage.

For example, the game had code like

Code: Select all

init python:
    misa_outfit = "casual"
    misa_apron = "off"
    misa_pose = 'c'
    misa_extra = "off"
    misa_extra2 = 'off'
    
image misa c smile = LiveComposite((454, 830),(0,0),"s/misa/b.png",(0,0),"s/misa/p/c/pose.png",(0,0),"s/misa/p/c/[misa_outfit].png",(0,0),"s/misa/p/c/[misa_apron].png",(0,0),"s/misa/oe/[misa_extra].png",(0,0),"s/misa/e/smile.png",(0,0),"s/misa/oe/[misa_extra2].png")

label test:
    show misa c smile at left()
What makes these sorts of images difficult to convert to layeredimages is that the underlying images used for some layers (eg. the outfit) change depending on other layers (eg. the pose).

One solution to this would be to create a layeredimage with another couple of groups, optionally with variant and if_any.
But that would require replacing the variables with additional attributes in the show statements.
eg.

Code: Select all

label old:
    $ misa_outfit = 'dress'
    $ misa_apron = 'on'
    show misa c smile at left()
label new:
    show misa c dress apron smile at left() #with variant and if_any
    #or...
    show misa c dressc apronc smile at left() #without variant and if_any
That works fine. But a long game can have many thousands of show statements, outfit/pose changes, etc.
So rewriting every one of them can be quite an undertaking.
Plus it's easy to accidentally type in the wrong outfit when the variable you're referencing was last changed in a different label, or if there are multiple possible outfits the character could be wearing for a given scene.
Also, since the game is already released, changing this sort of thing might lead to problems with existing saves, since the image now requires additional attributes in order to be shown.

Going by the renpy docs, another solution here would be to have multiple layeredimage definitions for one character.
I couldn't see how to do that without doubling up on image assets unnecessarily, however.
Assuming it is possible to avoid that, I'd still be left with many layeredimages per character instead of just one, each with a bunch of if statements for outfits and such.
So it's doable, but not ideal since it involves repeat code.

Ultimately, I found using adjust_attributes to be the path of least resistance.
eg.

Code: Select all

init python:
    def misa_adjust_attributes(name):
        if len(name) == 3: #only apply this logic if we're changing more than just the expression
            pose = name[1]
            outfit = misa_outfit+pose #eg. casualac
            t = (name[0], pose, outfit)
            if misa_apron == 'apron':
                t += (misa_apron+pose, ) #eg. apronac
            else:
                t += ("off", )
            t += tuple(name[2:])
            return t
        return name

define config.adjust_attributes["misa"] = misa_adjust_attributes

layeredimage misa:
    always "misa_base"
    group pose auto
    group outfit auto
    group apron auto
    if misa_extra == 'emb':
        "misa_extra_emb"
    elif misa_extra == 'vemb':
        "misa_extra_vemb"
    elif misa_extra == 'bloody':
        "misa_extra_bloody"
    group exp auto
    if misa_extra2 == 'teary':
        "misa_extra_teary"
So here I'm still using the existing variables and existing show statements.
But thanks to adjust_attributes, those variables are used to translate my existing show statements into a format which the layeredimage is happy with.
That allows for the many thousands of LiveComposite images to be replaced with a single layeredimage, all without changing the in-game code.



Edit:
One gotcha I've noticed with this approach is that it doesn't carry over to side images.
Or at least in the case of a side image which is a LayeredImageProxy of the main layeredimage, and the sprite is only currently being shown as a side image. (I haven't tested other cases)
I wound up adding a method for the side image attributes as well.

Code: Select all

init python:
    def side_adjust_attributes(name):
        if name[1] == 'misa':
            return ('side', ) + misa_adjust_attributes(name[1:])
        #other characters here
        return name
    config.adjust_attributes["side"] = side_adjust_attributes
Finished games
-My games: Sickness, Wander No More, Max Massacre, Humanity Must Perish, Tomboys Need Love Too, Sable's Grimoire, My Heart Grows Fonder, Man And Elf, A Dragon's Treasure, An Adventurer's Gallantry
-Commissions: No One But You, Written In The Sky, Diamond Rose, To Libertad, Catch Canvas, Love Ribbon, Happy Campers, Wolf Tails

Working on:
Sable's Grimoire 2

https://zetsubou.games

Gouvernathor
Newbie
Posts: 23
Joined: Thu Aug 06, 2020 9:27 am
Github: Gouvernathor
Discord: Armise#6515
Contact:

Re: Layeredimages dynamic control using adjust_attributes

#5 Post by Gouvernathor »

Great solutions ! I wouldn't have advised anything better, I think.
For the side_adjust_attributes, if you want this behavior to apply to all of the side images, maybe you could try a more automated/generalized version ?

Code: Select all

def side_image_attributes(name):
    if len(name)>1 and name[1] in config.adjust_attributes:
        name[1:] = config.adjust_attributes[name[1]](name[1:])
    return name
And maybe also try the case of adjust_attributes[None] if you're using it.

As a general advice, when you have like this several LiveComposites for several image names with a common image tag, what I'd advise is to build a layeredimage for one or two, because you can have one "layeredimage eileen happy" and one "layeredimage eileen sad" in parallel (and even one "layeredimage eileen", but it becomes a bit harder to manage, so wouldn't recommend unless you know what you're doing). And then, try and merge the different layeredimages into one. But all ways lead to the same thing in the end, awesome work !

User avatar
Zetsubou
Miko-Class Veteran
Posts: 522
Joined: Wed Mar 05, 2014 1:00 am
Completed: See my signature
Github: koroshiya
itch: zetsuboushita
Contact:

Re: Layeredimages dynamic control using adjust_attributes

#6 Post by Zetsubou »

Gouvernathor wrote: Sat Jul 02, 2022 6:27 am

Code: Select all

def side_image_attributes(name):
    if len(name)>1 and name[1] in config.adjust_attributes:
        name[1:] = config.adjust_attributes[name[1]](name[1:])
    return name
Good idea. I wasn't too worried about that since the game in question only has 2 characters with side sprites, but this is definitely more reusable.
It does throw an error though.

Code: Select all

TypeError: 'tuple' object does not support item assignment
Instead you could do:

Code: Select all

    def side_adjust_attributes(name):
        if len(name) > 1 and name[1] in config.adjust_attributes:
            return (name[0], ) + config.adjust_attributes[name[1]](name[1:])
        return name
Finished games
-My games: Sickness, Wander No More, Max Massacre, Humanity Must Perish, Tomboys Need Love Too, Sable's Grimoire, My Heart Grows Fonder, Man And Elf, A Dragon's Treasure, An Adventurer's Gallantry
-Commissions: No One But You, Written In The Sky, Diamond Rose, To Libertad, Catch Canvas, Love Ribbon, Happy Campers, Wolf Tails

Working on:
Sable's Grimoire 2

https://zetsubou.games

Post Reply

Who is online

Users browsing this forum: No registered users