Ren'Py Event handler for variable bar fill / overfilling

osanaiko

Engaged Member
Modder
Jul 4, 2017
2,547
4,631
So, I've taken on a personal project to try to recreate in Renpy the core "minigame" of the abandoned game "OnEdge". However I am at a loss on how to recreate on aspect of the game, and humbly ask for help.

In the game there are a two score counters: a "progress bar" and a "hearts counter". As each round comes up, the player chooses an action which will add a certain amount of "progress". When the "progress bar" fills up to 100%, a "heart" is added to the heart counter and progress resets to 0%. There is a nice animation with the progress bar incrementing smoothly to the new value.

However, the player action might add more "progress points" than can fit in the remaining space of the progress bar i.e. there is an "overflow". The "overflow" value is not lost, it is added onto the progress after the heart is generated and the bar resets to zero. There is even the possibility that the number of points added in one action might be 200 or 250% i.e. multiple hearts are generated for the action.

In my thinking , there needs to be a series of events take place on the screens:
- bar animated increment (can use AnimatedValue),
- bar reaches top and "triggers" the heart to be added. (update the global variable holding the heart state. Ideally this causes the Heart screen to show the new state)
- bar value and visual state is reset to 0 (does not need to be animated)
- bar animated increment to the new value (the "overflow" amount)

I'm just not sure how to do things like:
- "trigger" a global variable change when the progress bar animation reaches 100%
- then reset the progress bar value to 0
- then start a new progress bar animation to the next value.

From my previous programming experience I really want to add "event listeners", but it seems like Renpy screens do not work like that... I have tried many things with timers, but I can't get them to do what I want - especially as the number and sequence of timers will be different in different progress points situations.

Anyway, here is some sample code that shows a simplified example, I hope that someone with more experience can suggest what to try next:
Code:
define state_on = 1
define state_off = 0

default hearts_counter_states = [state_off, state_off, state_off]
default progress = 0
default progress_max = 100

# using placeholders instead of images for this example
image heart_on = Solid("#A22", xsize=100, ysize=100)
image heart_off = Solid("#777", xsize=100, ysize=100)

transform heart_dissolve():
    anchor (0.5, 0.5)
    on show:
        alpha 0.0 zoom 0.0
        time 0.2
        easein 0.2 alpha 1.0 zoom 1.0
    on hide:
        easeout 0.2 alpha 0.0 zoom 0.0

screen hearts_counter():
    fixed xpos 0.3 ypos 0.1:
        vbox:
            text "Hearts"
            hbox:
                for i in range(0,3):
                    fixed xsize 120 xpos 50 ypos 50:
                        showif hearts_counter_states[i] == state_off:
                            add "heart_off" at heart_dissolve
                        elif hearts_counter_states[i] == state_on:
                            add "heart_on" at heart_dissolve

screen progress_bar():
    fixed xpos 0.3 ypos 0.3:
        vbox:
            text "Progress"
            hbox:
                bar value AnimatedValue(value=progress, range=progress_max, delay=0.8) xsize 340

screen player_actions():
    fixed xpos 0.3 ypos 0.5:
        text "Actions"
        hbox:
            spacing 20
            frame xsize 200 ysize 150:
                textbutton "Add progress 60" xfill True yfill True:
                    action [SetVariable("progress", progress + 60), Return(True)]
            frame xsize 200 ysize 150:
                textbutton "Add progress 110" xfill True yfill True:
                    action [SetVariable("progress", progress + 110), Return(True)]
            frame xsize 200 ysize 150:
                textbutton _("Quit") xfill True yfill True:
                    action Return(False)

label start:

    show screen hearts_counter() with dissolve
    show screen progress_bar()

    label .loop:
        call screen player_actions()
        if _return == False:
            jump .end
        jump .loop

    label .end:
    hide screen hearts_counter
    hide screen progress_bar

    "done"
    return
 
  • Like
Reactions: Quintillian

anne O'nymous

I'm not grumpy, I'm just coded that way.
Modder
Donor
Respected User
Jun 10, 2017
10,964
16,207
- bar animated increment (can use AnimatedValue),
This, yes, it should do it. But with reserve (see below).


- bar reaches top and "triggers" the heart to be added. (update the global variable holding the heart state. Ideally this causes the Heart screen to show the new state)
Here you're looking in the wrong direction.

The bar value will raise when the player do an action, it's at the action level that everything should be handled.

Something like:
Python:
screen whatever():

    textbutton "action 1":
        action [ Function( raisePoints, NUMBER OF POINTS ), WHATEVER THE ACTION IMPLY ]
    textbutton "action 2":
        action [ Function( raisePoints, NUMBER OF POINTS ), WHATEVER THE ACTION IMPLY ]
    textbutton "action 3":
        action [ Function( raisePoints, NUMBER OF POINTS ), WHATEVER THE ACTION IMPLY ]
Then the "raisePoints" function is where the magic happen. Something like:
Python:
default barPoints = 0
defaul heartPoints = 0

init python:

    def raisePoints( howmany ):
  
        barPoints += howmany

        while barPoints > MAX NUMBER OF BAR POINT:
            heartPoints += 1
            barPoints -= MAX NUMBER OF BAR POINT


And now come the reserves regarding AnimatedValue.
I haven't tested it, but I guess that it works both when the value is increased or decreased. And the second one is less interesting, especially if you can have the bar reset and immediately get a new value.
Ideally, it should be:
  • Animation that raise the bar to 100 ;
  • Animation that send back the bar to 0 ;
  • Animation that raise the bar to its new value.
But what you would get would just be an animation that decrease the bar to it's new value.

There's also an issue with the delay. It's a constant, therefore I guess that whatever if you add 1 point or 100 points, the animation will last the same number of seconds. What would be ugly...


This lead to the solution being a more manual approach:
/!\ Wrote on the fly, it's more a draft than an effective code. /!\
Python:
# The current value for the progression bar.
default barPoints = 0
# The current value for the heart bar.
defaul heartPoints = 0
# The temporary increment value.
default incrementPoints = 0
# Flag raised when the bar need to be emptied.
default barReset = False

init python:

    # Raise the bar value.
    def raiseBar():
        barPoints += 1
        incrementPoints -= 1
        # When the bar is full, assuming the max value for the bar is 100...
        if barPoints > 100:
            # Add one heart point...
            hearPoints += 1
            # Trigger the animation to reset the bar.
            barReset = True

    # Decrease the bar value to 0.
    def emptyBar():
        #  Remove 10 points, so with a 0.01 delay and a 100 maximal value,
        # the bar will be animated for 0.1 second. The purge being 10 time faster than
        # the raise feel correct.
        barPoints -= 10
        # When the bar is finally empty...
        if barPoints == 0:
            # Stop the reset animation.
            barReset = False


screen whatever():

    # --- Handle the animation for the bars ---
    # If the bar is going back to 0...
    if barReset:
        # Call the /emptyBar/ function every 0.01 second.
        # Like there's no parameters, you don't need to use /Function/ here.
        timer 0.01 repeat True action emptyBar

    # *Else* if the bar value should change...
    elif incrementPoints != 0:
        # Call the /raiseBar/ function every 0.01 second.
        # The increment being 1, it would need 1 second to pass from 0 to 100, what seem reasonable.
        timer 0.01 repeat True action raiseBar

    vbox:
        textbutton "action 1":
            action [ SetVariable( "incrementPoints", 10 ), WHATEVER ACTION 1 NEED ]
        textbutton "action 2":
            action [ SetVariable( "incrementPoints", 50 ), WHATEVER ACTION 2 NEED ]

    vbox:
        bar value barPoints
        bar value heartPoints
I'm not totally sure that the repeat True is really needed in this context, but better safe that sorry.


From my previous programming experience I really want to add "event listeners", but it seems like Renpy screens do not work like that...
They secretly works like that.
The screen is expected to be updated every time one of the value it use change, as well as every time a button is hovered or clicked. But in the code I gave, it will anyway be updated every time the timer is triggered.
 

osanaiko

Engaged Member
Modder
Jul 4, 2017
2,547
4,631
Thank you very much anne O'nymous, your code example helped my thinking.
It worked (with a few minor cleanups) pretty well. One thing that did not work well was this:
Code:
       # Like there's no parameters, you don't need to use /Function/ here.
        timer 0.01 repeat True action emptyBar
I found that it did need the action Function(emptyBar) syntax to work as I expected.

After the basic example worked, I added an "overflow" variable, so that once the bar is full, the remaining increment is saved until the bar is emptied and then the increment starts again. This also allows the increment points to be added >100, and the bar goes up and down multiple times until the user action effect is "finished".

Now, onto the next thought: based on this solution to my problem, and also on various other more complex examples I've seen on the lemmasoft forum... I'm sad that the way to control timed/sequential visual stuff in renpy seems to always rely on "timer action" combined with various global control flag variables. I wish there was a better way to "sequence" a series of calculations/function calls and UI updates.

I'd like to have more things happen in the example:
- disable the action buttons until the calculations are done (also wait until the character animation (not part of this example yet) is complete) -> I guess another flag that is set when any action is clicked, and is cleared when increment+overflow points to finally get down to zero and any other chained processing/animations are complete
- add the increment points to the bar, add heart when bar is full -> THIS IS DONE, yay!
- after each heart is added, run another function to check if a condition is met (i.e. if 3 red hearts in a row then show another animation/state update triggers). While this hearts animation plays, delay the increment of the bar, then restart incrementing once the animation completes. -> maybe another flag to stop the raiseBar/emptyBar functions from doing anything while it is set?
- after every heart, check if the hearts bar is now full, and if so stop incrementing and run another logic sequence.

While I can now see how this would be possible with many timer actions and control flags, it seems to be unnecessarily complex. Maybe Renpy is not very well suited to this style of minigame.
 

anne O'nymous

I'm not grumpy, I'm just coded that way.
Modder
Donor
Respected User
Jun 10, 2017
10,964
16,207
I'm sad that the way to control timed/sequential visual stuff in renpy seems to always rely on "timer action" combined with various global control flag variables. I wish there was a better way to "sequence" a series of calculations/function calls and UI updates.
There's another way, the and config.periodic_callbacks (undocumented) values ; the second being like the first, but relying on a list.
Being at 20Hz (20 times by seconds), they have less granularity than a timer (that can goes down to 0.01 seconds). And they are also a bit less precise because they have a lower priority than timers. But they still permit to catch the time running.

Python:
init python:

    def ScriptTimer():
        def __init__( self, delay, action ):
            self.delay = int( delay * 20 )
            self.fnct = action
            self.ticks = self.delay

        def __call__( self ):
            # One more tick.
            self.ticks -= 1

            # Delay reached...
            if self.ticks == 0:
                # Reset the counter,
                self.ticks = self.delay
                # Call the /action/.
                self.fnct()

    def myFunction():
        [whatever]

label whatever:
    $ myTimer = ScriptTimer( 1.0, myFunction )
But it's less interesting, because most of the time what you want to do every x seconds will have a visual impact, and therefore will need a screen.

Also be careful, when defined in an init block, the object will be rollback compliant. Therefore the number of tick will decrease if the player do a rollback. It's both interesting (because if he rollback before the last time the action was triggered, the action will be undone as expected) and annoying (because you'll never be sure to effectively have a real control over the time passing).


- disable the action buttons until the calculations are done (also wait until the character animation (not part of this example yet) is complete) -> I guess another flag that is set when any action is clicked, and is cleared when increment+overflow points to finally get down to zero and any other chained processing/animations are complete
It's the way, yes.

It can be either this:
Python:
screen whatever():

    textbutton "action 1":
        action [ SetVariable( "buttonsEnabled", False ), [...]
        sensitive buttonsEnabled

    textbutton "action 2":
        action [ SetVariable( "buttonsEnabled", False ), [...]
        sensitive buttonsEnabled
if you want the buttons to be still visible, but not enabled.

Or that:
Python:
screen whatever():

    if buttonsEnabled:
        textbutton "action 1":
            action [ SetVariable( "buttonsEnabled", False ), [...]

        textbutton "action 2":
            action [ SetVariable( "buttonsEnabled", False ), [...]
Which would hide the buttons during the periods where they are disabled.

With something like:
Python:
init python:

       def raiseBar():
           barPoints += 1
           incrementPoints -= 1
           if barPoints > 100:
               heartPoints += 1
               barReset = True
           if incrementPoints == 0:
              store.buttonsEnabled = True

- add the increment points to the bar, add heart when bar is full -> THIS IS DONE, yay!
A flag is enough:

Python:
init python:

    def raiseBar():
        barPoints += 1
        incrementPoints -= 1
        if barPoints > 100:
            heartPoints += 1
            barReset = True
            if heartPoints == MAX VALUE:
                store.gameCompleted = True  # Do not forget the "store." prefix.

screen whatever():

    if gameCompleted:
        #  Automatically close the screen after 5 seconds.
        # Or you can have a button to manually close the screen.
        timer 5.0 repeat False action Return()

        text "You won !!!"

    else:
        [the current content of the screen]

- after each heart is added, run another function to check if a condition is met (i.e. if 3 red hearts in a row then show another animation/state update triggers). While this hearts animation plays, delay the increment of the bar, then restart incrementing once the animation completes. -> maybe another flag to stop the raiseBar/emptyBar functions from doing anything while it is set?
A function called when you increment "heartPoints", with a flag working in the same way than when the bar is emptied. Here, there's two way to do:
Python:
screen whatever():

    if heartAnimation:
       [...]
    elif barReset:
       [...]
    elif incrementPoints != 0:
       [...]
If you want the bar to only be emptied once the heart part is done.

Python:
screen whatever():

    if barReset:
       [...]
    # Increment, and only increment.
    elif incrementPoints != 0 and not heartAnimation:
       [...]
    if heartAnimation:
       [...]
If you want the bar to be emptied while the heart part is done.

I'm not sure which one would be the best looking. It's probably the first one ; make more sense to freeze on a full bar than on an empty one.


- after every heart, check if the hearts bar is now full, and if so stop incrementing and run another logic sequence.
The same approach than above with "gameCompleted", but with used screens...
Python:
init python:

    def raiseBar():
        [...]
            if heartPoints == MAX VALUE:
                store.phaseOneDone = True

screen whatever():

    if not phaseOneDone:
        use phaseOne
    elif not phaseTwoDone:
        use phaseTwo

screen phaseOne():
    [the current content of the screen]

screen phaseTwo():
    [whatever]

But STOP ! I'm forgetting to remind a really important points: interactions happen at starts of a Ren'Py language line...

This mean that the buttons action must be external to the screen. Else saving then loading (among others possible actions) would send the player back to the very starts of the game.

Python:
screen whatever():

    textbutton "action 1":
        action Jump( "action1" )
    textbutton "action 2":
        action Jump( "action2" )

label genericAction( step ):
    $ buttonsEnabled = False
    $ incrementPoints += step
    return

label action1:
    # The part common to all actions.
    call genericAction( 10 )
    [whatever the action 1 imply]
    # Do not let the player rollback before that point.
    $ renpy.block_rollback()
    call screen NAME OF YOUR SCREEN

label action2:
    call genericAction( 50 )
    [whatever the action 2 imply]
    $ renpy.block_rollback()
    call screen NAME OF YOUR SCREEN
This will create a rollback point (and therefore also a savability point) each time the player trigger an action.

That way, he can save anytime he want (because he can't continue to play right now by example), he will be sent back to that point when he'll load.
The only thing that will be reset is the bar animations, what isn't really an issue. He'll see them again, what will leave him the time to remember where he was the last time he played.


While I can now see how this would be possible with many timer actions and control flags, it seems to be unnecessarily complex. Maybe Renpy is not very well suited to this style of minigame.
It's not designed for them, what doesn't necessarily mean that it's not suited for them.
The main issue is savability I addressed, for the rest a single time can perfectly works for all:


/!\ It's a draft wrote on the fly /!\
Python:
# The current value for the progression bar.
default barPoints = 0
# The current value for the heart bar.
defaul heartPoints = 0

init python:

    class EventControl( renpy.python.RevertableObject ):

        def __init__( self ):
            self.incrementPoints = 0
            self.barReset = False
            self.phaseOneDone = False

            self.tick = 0

       def addBarPoints( self, value ):
           self.incrementPoints += value
           self.tick = 0

       def __call__( self ):
           if self.barReset:
               self.emptyBar()
           elif self.incrementPoints:
               self.raiseBar()
           elif WHATEVER:
               self.example()

       def emptyBar( self ):
           store.barPoints -= 10
           if store.barPoints == 0:
               self.barReset = False

       def raiseBar( self ):
           store.barPoints += 1
           self.incrementPoints -= 1
           if store.barPoints > 100:
               store.heartPoints += 1
               self.barReset = True
               if store.heartPoints > MAX VALUE:
                  self.phaseOneDone = True

           if self.incrementPoints == 0:
               self.buttonEnabled = True

       def example( self ):
           self.tick -= 1
           #  Need the '<=' since you'll starts
           if self.tick <= 0:
              self.tick = 10
              [ something that have to be done every 0.1 second while code is called every 0.01 second]

           # It's important to reset the ticks when you're fully done here.
           if CONDITION FOR ALL ACTIONS HERE TO BE DONE:
               self.tick = 0


default myEventHandler = EventControl()

screen whatever():

    #  Call the code every 0.01 second, without having to care if something will happen or not.
    # The use of /Function/ is supposed optional, but like you said that it didn't worked as well
    # as expected I used it.
    timer 0.01 repeat True Function( myEventHandler )

    vbox:
        textbutton "action 1":
            sensitive myEventHandler.buttonEnabled
            action Jump( "action1" )

        textbutton "action 2":
            sensitive myEventHandler.buttonEnabled
            action Jump( "action2" )

label genericAction( step ):
    $ myEventHandler.buttonsEnabled = False
    $ myEventHandler.addBarPoints( step )
    return

label action1:
    call genericAction( 10 )
    [whatever the action 1 imply]
    $ renpy.block_rollback()
    call screen whatever
[...]
I left "barPoints" and "heartPoints" outside of the class, but you can perfectly put them inside if you want.

There's more code, behind the scene, but one timer is enough to control everything.

Starting there, you're opened to every weird ideas that can cross your mind:
You don't have permission to view the spoiler content. Log in or register now.


Edit: A typo in one of the code.
 
Last edited:
  • Like
Reactions: Quintillian

Quintillian

Member
Apr 15, 2019
124
239
That is a lot of interesting insights on this thread.

I also wanted to take a shot at this since I would be needing something similar for my project. The strategy I went with was to subclass AnimatedValue so it can accept a Stat object, and overwrite its periodic method so that when value >= range it calls a method of Stat.




Anyways, here is the code:
You don't have permission to view the spoiler content. Log in or register now.

And a quick demo:
You don't have permission to view the spoiler content. Log in or register now.

If my math was mathing correctly the overflow calculations should be ok. One thing I don't love though was forcing Renpy to restart the interaction, but that might just be me, and anyways I couldn't get it to work without it. And also other things I haven't check yet is how difficult would be to attach to an existing project. Sometimes stuff can work in a vacuum but be a pain to integrate, but I wanted to share it anyways in case someone finds it useful.
 

anne O'nymous

I'm not grumpy, I'm just coded that way.
Modder
Donor
Respected User
Jun 10, 2017
10,964
16,207
One thing I don't love though was forcing Renpy to restart the interaction, but that might just be me, and anyways I couldn't get it to work without it.
The function exist, and is fully documented, for a reason, it's precisely what permit screens to update when something change. And, indeed, Ren'Py itself use it a lot of times. Therefore, when you're doing the changes through purely custom code with nothing else to refresh the screen (in my example it was done by the timers), you'll have to rely on it.

This said, I must specify that it just restart the interaction, nothing more.
The rollback points are defined at the starts of an interaction, but aren't exactly part of it. So, when you restart the interaction, there's no new rollback point created.
This mean that renpy.restart_interaction() is not a way to have everything done in a screen and still let the player save his progress.


And also other things I haven't check yet is how difficult would be to attach to an existing project.
It's just half a problem. Even when not adaptable to an existing project, codes like this always present a new approach and can help someone find a solution for his own project.