Drag and Drop#

Minigame “Café”#

To learn drag and drop in Ren’Py, create a minigame:

  • Mix ingredients by dragging them to Table.

  • Coffee and Milk will make Latte.

  • Egg and Milk will make Cake.

Ren’Py Documentation: Drag and Drop

Solution#

Let’s initialize variables that we’ll use:

  • All ingredients - Coffee, Egg, Milk - in a set (because we don’t need to have indexes and the order of items here is not important, we don’t use list or dict).

  • Recipes - Cake, Latte - we need dict (because we map item names, like “Cake”, to their ingredients).

  • Pictures of food - likewise, a dictionary, mapping item names to their pictures. (Though I will just use here colored squares as pictures.)

  • What items are on the table - a set, originally empty.

# All the ingredients that we have:
default all_ingredients = set(("Coffee", "Egg", "Milk"))

# Which kinds of food we can make:
default recipes = {
    "Cake":     set(("Egg", "Milk")),
    "Latte":    set(("Coffee", "Milk")),
    }

# Food pictures (I'll just use colored squares here):
default food_images = {
    "Cake":     Solid("#F88",    xysize=(200, 200)),
    "Coffee":   Solid("#7B3500", xysize=(200, 200)),
    "Egg":      Solid("#FF8",    xysize=(200, 200)),
    "Latte":    Solid("#E19855", xysize=(200, 200)),
    "Milk":     Solid("#FFF",    xysize=(200, 200)),
    }

# What is on the table
default on_table = set()

Our dragging-cooking screen will have ingredients and the table. As a child, drags will have Frame with the item’s picture as background and text (the item’s name) as foreground:

screen cooking():

    draggroup:

        # Ingredients on the left.
        # We'll number them to set their positions in a neat column:

        $ ingredient_num = 0

        for i in all_ingredients:

            # For each ingredient, we get its name as `i` in a loop
            # and create a draggable:

            drag:
                drag_name i

                # Position them top to bottom:
                pos (10, 50 + 210 * ingredient_num)
                xysize (200, 200)

                droppable False
                dragged check_ingredients

                frame:
                    background food_images[i]
                    foreground Text(i, color="#000",
                                    outlines=[(1, "#FFFC")])

            # Increase the counter for the next loop:
            $ ingredient_num += 1


        # Table - in the center

        drag:
            drag_name "table"
            draggable False
            pos (400, 200)
            xysize (600, 500)
            frame:
                background "#888"
                foreground Text("Table")

As you see, drags of our ingredients have droppable False, which means we won’t drop other stuff on them. And they can be dragged around.

“Table”, quite the opposite, has draggable False, which means it can’t be dragged around. But it can “receive” other drags dropped on it.

Another difference between ingredients and Table is that ingredients have attribute dragged check_ingredients. It assigns function check_ingredients to run as soon as the draggable was released (after being dragged).

That function should process the drag-drop operation. Let’s write it:

init python:
    def check_ingredients(drags, drop):

        if drop:
            # Ingredient was dropped on Table. Add it to the table:

            store.on_table.add(drags[0].drag_name)
            # (`drags` is a list with one element, drags[0], so we
            # get its name and add it to `on_table` set).

        else:
            # Ingredient was dropped outside of Table.
            # Remove it from the table if it was there:

            store.on_table.discard(drags[0].drag_name)

        # Loop through recipes and see if the ingredients
        # on the table will make a food item:

        for food_name, food_ingreds in store.recipes.items():

            if food_ingreds == store.on_table:

                # This recipe corresponds with the ingredients.
                # => Return from the screen with the name of
                # prepared item:

                return food_name

Now let’s use that screen:

label start:

    call screen cooking

    "You have prepared [_return]! Wow."

label main_menu:
    return

Put all this code in a Ren’Py project (e.g. in file script.rpy) and play with it.

_images/drag_coffee.webp

This is a very basic code, but we could build much more on its basis.

Advanced drag-dropping#

Well, simple drag-and-dropping is pretty easy and straightforward, but if you want something more complex, you might need to know about some tricks, traps and pitfalls.

Let’s suppose that we want to improve our Café Minigame, to make Cake appear on screen as another draggable when we mixed Egg and Milk.

Usually it’s easy: we add a variable (e.g., food_ready):

# Last cooked food is...
default food_ready = ""

…and when that variable is assigned some item name (e.g., “Cake”), we show that item in the screen:

screen cooking():

    draggroup:

        #...skip...

        # Prepared food

        if food_ready:
            drag:
                drag_name food_ready
                droppable False
                pos (600, 300)
                xysize (200, 200)
                frame:
                    background food_images[food_ready]
                    foreground Text(food_ready, color="#000",
                                    outlines=[(1, "#FFF")])

Usually Ren’Py updates the state of every screen pretty fast. Therefore, when food_ready becomes truthy, “if” condition is satisfied, and we see that new drag on display.

Let’s modify our check_ingredients function accordingly:

init python:
    def check_ingredients(drags, drop):

        if drop:
            # Ingredient was dropped on Table:
            store.on_table.add(drags[0].drag_name)

        else:
            # Ingredient was dropped outside of Table:
            store.on_table.discard(drags[0].drag_name)

        # Whether a recipe has its ingredients on the table:
        for food_name, food_ingreds in store.recipes.items():

            if food_ingreds == store.on_table:

                store.food_ready = food_name

I removed here return food_name, which would return from the called screen. So when Cake appears, we still see the cooking screen.

Let’s run it:

Hmm… We see that food_ready variable gets updated. (To watch it, I switched to Console (shift-o) and entered command watch food_ready). But the Cake image doesn’t appear.

(A propos, for debugging purposes, we can also add notifications to see, for example, if drag was dropped on the table:

if drop:
    # Ingredient was dropped on Table:
    store.on_table.add(drags[0].drag_name)

    renpy.notify(drags[0].drag_name)

renpy.notify() is rather useful in such cases).

Anyway, it looks like the screen doesn’t get refreshed. Let’s add renpy.restart_interaction(). Usually it refreshes the screen. In function check_ingredients:

if food_ingreds == store.on_table:

    store.food_ready = food_name

    renpy.restart_interaction()

And…

Cake appears, but Milk and Egg disappear. I mean, that’s logical, OK, ingredients get used up when making food… But we didn’t command these drags to disappear! What’s going on?

After some experiments you can come to conclusion that Ren’Py 8.3 just doesn’t allow to add draggables like that, on the fly. That would work with less interactive elements, like images or text, but drags require more initialization.

And here’s what the documentation says:

DragGroup.add()

Adds child, which must be a Drag, to this DragGroup. This child will be added above all other children of this DragGroup.

DragGroup.remove()

Removes child from this DragGroup.

Apparently Ren’Py does all this initialization when preparing to show a screen. But when refreshing the screen, it omits that stuff, likely for the sake of optimization.

What are we going to do?

One way is to assign DragGroup and Drag’s to some variables, I mean, constructing them in Python code. Then we could manipulate them using add(), remove() etc.

This is a decent solution for relatively simple drags, and we will explore it a bit later.

But for complex drags, with nested children, it’s easier to write them in screen language. And to add a new drag we can just exit the screen and then call it again with new contents.

Here’s a full script of that approach:

default all_ingredients = set(("Coffee", "Egg", "Milk"))
default recipes = {
    "Cake":     set(("Egg", "Milk")),
    "Latte":    set(("Coffee", "Milk")),
    }
default food_images = {
    "Cake":     Solid("#F88",    xysize=(200, 200)),
    "Coffee":   Solid("#7B3500", xysize=(200, 200)),
    "Egg":      Solid("#FF8",    xysize=(200, 200)),
    "Latte":    Solid("#E19855", xysize=(200, 200)),
    "Milk":     Solid("#FFF",    xysize=(200, 200)),
    }
default on_table = set()
default food_ready = ""


screen cooking():
    draggroup:
        $ ingredient_num = 0
        for i in all_ingredients:
            drag:
                drag_name i
                pos (10, 50 + 210 * ingredient_num)
                xysize (200, 200)
                droppable False
                dragged check_ingredients
                frame:
                    background food_images[i]
                    foreground Text(i, color="#000", xalign=.5,
                                    outlines=[(1, "#FFFC")])
            $ ingredient_num += 1

        drag:
            drag_name "table"
            draggable False
            pos (400, 200)
            xysize (600, 500)
            frame:
                background "#888"
                foreground Text("Table", xalign=.5)

        if food_ready:
            drag:
                drag_name food_ready
                droppable False
                dragged check_ingredients
                pos (400, 250)
                xysize (200, 200)
                frame:
                    background food_images[food_ready]
                    foreground Text(food_ready, color="#000", xalign=.5,
                                    outlines=[(1, "#FFF")])

        drag:
            drag_name "counter"
            draggable False
            pos (1020, 160)
            xysize (260, 560)
            frame:
                background "#9AC43D"
                foreground Text("Counter", xalign=.5)


init python:
    def check_ingredients(drags, drop):

        if drop:
            if drop.drag_name == "table":
                # Drag was dropped on Table:
                store.on_table.add(drags[0].drag_name)
                renpy.notify(f"You put {drags[0].drag_name} on Table.")
            else:
                # Drag was dropped on Counter.
                #   - Remove it from Table if it was there
                store.on_table.discard(drags[0].drag_name)
                #   - Is it cooked food?
                if drags[0].drag_name in store.recipes:
                    # Food delivered! Exit with victory
                    return drags[0].drag_name
        else:
            # Ingredient was dropped outside of Table:
            store.on_table.discard(drags[0].drag_name)

        # Whether a recipe has its ingredients on the table:
        for food_name, food_ingreds in store.recipes.items():
            if food_ingreds == store.on_table:
                store.food_ready = food_name
                return True


label start:

    call screen cooking

    if _return is True:
        # Some food was cooked. Reset table
        $ on_table.clear()
        jump start
    "{i}(me){/i} [_return] for you, Master!"
    "Thank you, thank you! Attaboy!"
    "{i}(me){/i} I'm not a boy!"
    "Whatever. Get back to work!"
    $ food_ready = ""
    jump start

label main_menu:
    return

You can see that I added a drag named “counter”. When you drag some cooked food (Cake or Latte) to the counter, function check_ingredients returns the name of that food, so the screen call ends and the returned value is in variable _return.

Another exit from the screen happens when check_ingredients returns True - if some food was cooked on Table.

So after call screen cooking we check the return value. It it was True, we just clear the table (of ingredients) and call the screen again, and a new food item will appear there, because we updated food_ready.

Otherwise the return value is the name of the item delivered to the counter. In that case we show a little dialog and clean food_ready before going back to cooking.

I hope all this is very clear. Enjoy!

Game “Exterminator”#

Now let’s learn and practice how to create drags with Python code (so that they are assigned to variables and can be easily controlled by various methods).


Note

You may notice that, as the Ren’Py documentation says,

The as clause can be used to bind a drag to variable, which can then be used to call methods on the drag.

However, in my experience this bound variable is local - not visible in global/store space, so e.g. it can’t be referred in “external” functions, which seems to limit its usefulness.


All right, it makes sense to use drags constructed with Python code when

  • there can be many of them, dynamically added and removed,

  • we need other methods applied to them, such as snap().

Imagine a game where lots of rats are running around, and you need to catch them (grab them and drag them to a chest).

That would be the type of game we are talking about, as we’ll:

  • add rats to the running crowd,

  • remove them when they were handled,

  • slide them across the screen with snap.

First let’s set styles and initialize variables.

We will use Ren’Py notifications to indicate which rat escaped or was caught, so let’s increase notify_text size to make it more noticeable. And we’ll use Window screen element as child for drags, to set different backgrounds for different states (dragged, hovered etc.). So we’ll give those “window” elements an “empty-ish” style:

style notify_text:
    size 48

style drags is empty
style drags:
    anchor (0., 0.)

To give each rat a unique personality, I pulled 148 “funny rat names” from the internet. Adding 1 rat per second, it will be enough for ~2½ minutes of game play:

default rat_names = set((
    'Aurora', 'Azalea', 'Bella', 'Binky', 'Binkyboo', 'Biscuit',
    'Boomerang', 'Bouncy', 'Bristle', 'Bubbles', 'Buttercup', 'Cascade',
    'Charlie', 'Cheddar', 'Cheesy', 'Cheezit', 'Cheezy', 'Chip',
    'Chloe', 'Chompers', 'Chuck', 'Chuckles', 'Coco', 'Cricket',
    'Crumbcatcher', 'Daisy', 'Dexter', 'Dizzy', 'Doodle', 'Doodlebug',
    'Echo', 'Ember', 'Enigma', 'Fiddlesticks', 'Finn', 'Fizz',
    'Fluffernutter', 'Furrykins', 'Fuzzball', 'Fuzzbutt', 'Giggler',
    'Giggles', 'Gouda', 'Gummy', 'Infinity', 'Jasper', 'Jellybean',
    'Jumbo', 'Juniper', 'Leo', 'Lily', 'Luna', 'Marshmallow', 'Max',
    'Milo', 'Mirage', 'Moonbeam', 'Munchkin', 'Mystique', 'Nebula',
    'Nibbler', 'Nibbles', 'Niblet', 'Nimbus', 'Noodle', 'Nugget',
    'Nutter', 'Nuzzle', 'Oliver', 'Ollie', 'Patter', 'Peanut', 'Peewee',
    'Pickles', 'Pippin', 'Pipsqueak', 'Pipsy', 'Pitter', 'Pudding',
    'Pudge', 'Puffy', 'Quasar', 'Quiver', 'Rascal', 'Ratatouille',
    'Ratthew McConaughey', 'Ratty-pants', 'Remington', 'Rolo', 'Rosie',
    'Ruby', 'Sable', 'Saffron', 'Scooter', 'Scrabble', 'Scruffy',
    'Scurrier', 'Scurry', 'Serendipity', 'Sir Nibbles-a-lot', 'Skitter',
    'Slinky', 'Snack', 'Snackums', 'Snickerdoodle', 'Snickers',
    'Sniffle', 'Sniffles', 'Sniffy', 'Snuffles', 'Snugglebug',
    'Solstice', 'Sophie', 'Spazz', 'Sprinkles', 'Sprocket', 'Sprout',
    'Squeakums', 'Squeaky', 'Squeakzilla', 'Squiggle', 'Squirt',
    'Stardust', 'Tater Tot', 'Teddy', 'Templeton', 'Tinker', 'Tundra',
    'Twilight', 'Twinkle', 'Twitch', 'Twitcher', 'Twitchy', 'Wally',
    'Whimsy', 'Whisk', 'Whiskerino', 'Whiskers', 'Whisper', 'Wiggler',
    'Wiggles', 'Willow', 'Wobble', 'Wombat', 'Wriggly', 'Zenith',
    'Zephyr', 'Ziggy'
    ))

We’ll use several lists to track our rats:

  • rats — to keep drags currently present on screen,

  • escapees — names of rats that escaped,

  • prisoners — names of rats that were dragged to the chest.

We’ll define Drag chest (which will be droppable, not draggable) and DragGroup cellar (the space where all this is going on):

default rats = [ ]
define escapees = [ ]
define prisoners = [ ]
# Chest idle icon:
# https://www.iconarchive.com/show/seaside-icons-by-iconarchive/
default chest = Drag(
            d=Window(
                Solid("#0000"),
                style="drags",
                background="chest_idle",
                selected_idle_background="chest_selected_idle",
                ),
            drag_name="Chest",
            draggable=False,
            pos=(832, 824),
            xysize=(256, 256)
            )
default cellar = DragGroup(chest)

As you see, for chest I defined two different backgrounds:

  • idle background for when it’s undisturbed,

  • selected_idle background for when a rat is dragged on top of it.

Now let’s define the main element of our “exterminator” engine: the functions to deal with drags:

init python:
    def rat_snapped(drag, x, y, completed):
        if completed:
            renpy.notify(f"{drag.drag_name} escaped!")
            store.escapees.append(drag.drag_name)
            store.cellar.remove(drag)
            store.rats.remove(drag)

    def rat_dragged(drags, drop):
        dn = drags[0].drag_name
        if drop:
            store.prisoners.append(dn)
            renpy.notify(f"☺️ {dn} was caught!")
        else:
            store.escapees.append(dn)
            renpy.notify(f"{dn} escaped!")
        store.cellar.remove(drags[0])
        store.rats.remove(drags[0])

    def add_rat():
        if not store.rat_names:
            # No more names left
            return
        if store.rats:
            # Start running (previous rat)
            store.rats[-1].snap(-100, store.rats[-1].y, 5.)
        r_ypos = renpy.random.randint(0, 880)
        rat = Drag(
            d=Window(
                Solid("#0000"),
                style="drags",
                background="rat_idle",
                hover_background="rat_hover",
                selected_hover_background="rat_selected_hover",
                ),
            drag_name=store.rat_names.pop(),
            droppable=False,
            dragged=rat_dragged,
            snapped=rat_snapped,
            pos=(1900, r_ypos),
            xysize=(128, 128)
            )
        store.rats.append(rat)
        store.cellar.add(rat)

Here rat_snapped is the function that runs when the drag’s snapping finished (or was interrupted by grabbing that rat). If snapping completed, it means running across the screen has finished, the rat has escaped. In that case we’ll put their name into escapees list, and remove the drag from DragGroup cellar and from list rats.

Function rat_dragged runs when a rat was dragged (and dropped). If it’s dropped in the chest, we append their name to prisoners list. Otherwise we consider them escaped and record to escapees. In both cases we remove the drag from cellar and from rats.

Finally, function add_rat is used to add a drag (i.e. another rat) to cellar and to rats. That rat gets its name from rat_names, using pop(), so the name gets removed from the set, and when there are no more names left, this function stops adding rats.

These lines there:

if store.rats:
    # Start running (previous rat)
    store.rats[-1].snap(-100, store.rats[-1].y, 5.)

mean when there’s a previous rat, it starts to be dragged across the screen. First I tried to apply snap to a rat right away, but for some reason it didn’t work, so we are “snapping” the preivious rat (when creating another one).

Here’s the rest of the code:

screen rat_hunt():
    add "ground"
    add cellar:
        xysize (1920, 1080)
        align (0., 0.)
    timer 1.0 repeat True:
        action add_rat
    button:
        background "#853"
        hover_background "#D65E0E"
        text "Finish" size 64 align (.5, .5)
        align (1., 1.)
        action Return()


label start:
    "Dangerous criminals escaped from jail! CATCH THEM!!!"
    window hide
    call screen rat_hunt
    $ l = len(escapees)
    $ escapees += [x.drag_name for x in rats] + list(rat_names)
    "Caught: [len(prisoners)].\nEscaped: [l] ([len(escapees)])"

    # Reset and restart
    python:
        rat_names |= set(escapees) | set(prisoners)
        del escapees[:]
        del prisoners[:]
        del rats[:]
        cellar = DragGroup(chest)

label main_menu:
    return

Enjoy!

Images of this game can be downloaded here.