Let's Count: Baby's First Android App

Kivy is a Python library with a supplemental high-level language (kivy language/kv) that is touted as a toolkit for rapid app development. I'd read good things about it, and it seemed like a good engine to try for my first tablet game. It will be a fruit counting game for my kid. No ads, no pay-to-play, no crap, just good old-fashioned fruit and numbers, the way God intended.

Concept: Each round the player is given a number of fruit and clicks them all to progress for 10 rounds.

The documentation is thorough and there is a Pong game tutorial, but I struggled to initially, primarily because I kept conflating kv and Python. Looking at other people's code is always the best way for me to learn and reviewing Kivy projects on github was very helpful, particularly this tic-tac-toe game.

Since I found everyone else's project code so useful, I decided to post the source for each version of my app as I go along.

Version 1: How Do

Screenshot

The first version of the game is a grid of 10 apples. Each time an apple is clicked, it has a bite taken out of it, until all 10 are clicked, then it resets.

import kivy
kivy.require('1.9.1')
from kivy.app import App
from kivy.uix.gridlayout import GridLayout
from kivy.uix.popup import Popup
from kivy.uix.button import Button

class MainApp(App):
    def build(self):
        return FruitBoard()

class ShowPopup(Popup):
    def __init__(self, message, **kwargs):
        self.size=(300,300)
        self.title=message
        self.content=Button(text="Close")
        self.content.bind(on_press=self.dismiss)
        super(ShowPopup, self).__init__(**kwargs)
        self.open()

class FruitBoard(GridLayout):
    def __init__(self, **kwargs):
        self.cols = 5
        self.rows = 2
        self.selected = 0
        self.num = 10
        super(FruitBoard, self).__init__(**kwargs)
        self.unclicked_bg = "images/apple.png"
        self.clicked_bg = "images/apple_clicked.png"
        for _ in xrange(self.num):
            self.add_widget(Button(font_size=100,text=str(_+1),background_normal=self.unclicked_bg, on_press=self.button_pressed))

    def reset(self):
        for child in self.children:
            child.background_normal = self.unclicked_bg
        self.selected = 0

    def button_pressed(self, b):
        self.selected +=1
        b.background_normal=self.clicked_bg
        if self.selected == self.num:
            self.counted_all()

    def counted_all(self):
        ShowPopup("You counted them all!")
        self.reset()

if __name__ == '__main__':
    MainApp().run()

Version 2: Crude Yet Effective

Screenshot

The second version is the working prototype. It incorporates screens and different types of fruit. After each round the board is reset. After 10 rounds the game ends.

At this stage I began to have issues with some of the baked-in widgets, particularly GridLayout, which is inflexible. I had trouble both with the layout itself and the fact that grid contents kept getting stretched.

import kivy
kivy.require('1.9.1')
from kivy.app import App
from kivy.uix.gridlayout import GridLayout
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.anchorlayout import AnchorLayout
from kivy.uix.screenmanager import Screen, ScreenManager
from kivy.uix.popup import Popup
from kivy.uix.label import Label
from kivy.uix.button import Button
from random import choice,shuffle

FRUITS = ["apple","banana","watermelon","orange"]
ROUNDS = 0
MAX_ROUNDS = 10
STARTING_NUM = 1
GRID_ORDER = [(1,1),(1,2),(2,2),(2,2),(3,2),(3,2),(3,3),(3,3),(3,3),(4,3)]

class MainApp(App):
    def build(self):
        app_screenmanager = ScreenManager()
        screen1 = MainMenu(name='main_menu')
        screen2 = GameScreen(name='game_screen')
        app_screenmanager.add_widget(screen1)
        app_screenmanager.add_widget(screen2)
        return app_screenmanager

class MenuButton(Button):
    def __init__(self, **kwargs):
        self.font_size = 80
        self.size_hint=(.35,.25)
        super (MenuButton, self).__init__(**kwargs)

class MainMenu(Screen):
    def __init__ (self,**kwargs):
        super (MainMenu, self).__init__(**kwargs)

        mm = AnchorLayout(anchor_x='center',anchor_y='top')
        mm_label = Label(text="Let's Count!",size_hint=(1,.3),font_size=150)
        mm.add_widget(mm_label)

        start_box = AnchorLayout(anchor_x='center',anchor_y='center')
        mm_start = MenuButton(text="Start",size=self.size)
        mm_start.bind(on_press=self.switch)
        start_box.add_widget(mm_start)

        quit_box = AnchorLayout(anchor_x='center',anchor_y='bottom')
        mm_quit = MenuButton(text="Quit")
        mm_quit.bind(on_press=self.quit)
        quit_box.add_widget(mm_quit)

        mm_box = BoxLayout(orientation='vertical',padding=100)
        mm_box.add_widget(mm)
        mm_box.add_widget(start_box)
        mm_box.add_widget(quit_box)
        self.add_widget(mm_box)

    def switch(self, *args):
        self.manager.current = "game_screen"

    def quit(self, *args):
        MainApp.get_running_app().stop()

class GameScreen(Screen):
    def __init__(self,**kwargs):
        super (GameScreen,self).__init__(**kwargs)

        return_box = AnchorLayout(anchor_x='center',anchor_y='bottom')
        game_return = Button(text="Return",size_hint=(.25,.1))
        game_return.bind(on_press=self.switch)
        return_box.add_widget(game_return)

        board_box = AnchorLayout(anchor_x='center',anchor_y='top')
        gameboard = FruitBoard(fruit=choice(FRUITS),num=STARTING_NUM,size_hint=(1,.9),cols=1,rows=1)
        board_box.add_widget(gameboard)

        game_box = FloatLayout()
        game_box.add_widget(board_box)
        game_box.add_widget(return_box)
        self.add_widget(game_box)

    def switch(self,*args):
        self.manager.current = 'main_menu'

class FruitBoard(GridLayout):
    def __init__(self, **kwargs):
        self.num = kwargs["num"]
        self.fruit = kwargs["fruit"]
        self.spacing = 50
        self.padding = 50
        self.cols = kwargs["cols"]
        self.rows = kwargs["rows"]
        self.selected = 0
        super(FruitBoard, self).__init__(**kwargs)
        self.build_board()

    def reset(self):
        self.clear_widgets()
        self.selected = 0

    def build_board(self):
        self.rows, self.cols = GRID_ORDER[self.num-1]
        for f in xrange(self.num):
            self.add_widget(Button(font_size=100,text="",background_normal=("images/%s.png" % self.fruit),background_disabled_normal=("images/%s_clicked.png" % self.fruit), on_press=self.button_pressed,border=(0,0,0,0),size=(150,150),size_hint=(.25,.25)))

    def restart(self):
        global ROUNDS, FRUITS
        ROUNDS +=1
        if ROUNDS < MAX_ROUNDS:
            self.reset()
            self.num+=1
            self.fruit = choice(FRUITS)
            self.build_board()
        else:
            ShowPopup("You counted all the fruit!")
            MainApp.get_running_app().stop()

    def button_pressed(self, b):
        if not b.text:
            self.selected +=1
            b.disabled = True
            b.text=str(self.selected)
            if self.selected == self.num:
                self.counted_all()

    def counted_all(self):
        ShowPopup("You counted " + str(self.num) +"!")
        self.restart()

class ShowPopup(Popup):
    def __init__(self, message, **kwargs):
        self.size=(300,300)
        self.title=message
        self.content=Button(text="Close")
        self.content.bind(on_press=self.dismiss)
        super(ShowPopup, self).__init__(**kwargs)
        self.open()

if __name__ == '__main__':
    MainApp().run()

Version 3: I've Got 99 Problems and GUI is All of Them

Screenshot

This version of the game is fully skinned. It has 12 fruits, randomly-selected and non-repeating, and it displays the number counted and the final number. The code is refactored. Wrangling with the layouts taught me a lot, but here are the pertinents:

Buttons always scale their backgrounds, so to maintain a square apect ratio for the images on the fruit grid I had to make a custom Image class that had button behavior.

Font outlines are listed in the documentation, but they're in 1.9.2 and this public release is 1.9.1.

I ended up converting some of the widgets to kivylanguage. kv does make it easier to read and organize, so it was well worth figuring out the kv syntax and how it interacts with the Python side of the script.

Last, but not least, ModalView is your friend.

import kivy
kivy.require('1.9.1')
from kivy.app import App
from kivy.graphics import Rectangle
from kivy.uix.gridlayout import GridLayout
from kivy.uix.modalview import ModalView
from kivy.uix.floatlayout import FloatLayout
from kivy.uix.screenmanager import Screen, ScreenManager
from kivy.core.audio import SoundLoader
from kivy.uix.label import Label
from kivy.uix.image import Image
from kivy.uix.button import Button
from kivy.uix.behaviors import ButtonBehavior
from functools import partial
import random

FRUIT_LIST = ["apple","banana","watermelon","orange","lemon","pomegranate","pear","mango","kiwi","pineapple","coconut","peach"]
NUMBER_IMAGES = ["one","two","three","four","five","six","seven","eight","nine","ten"]

class MainApp(App):
    def build(self):
        app_screenmanager = ScreenManager()
        screen1 = MainMenu(name='main_menu')
        screen2 = GameScreen(name='game_screen')
        app_screenmanager.add_widget(screen1)
        app_screenmanager.add_widget(screen2)
        upbeat = SoundLoader.load('sound/upbeat.mp3')
        upbeat.play()
        return app_screenmanager

class MenuButton(Button):
    def __init__(self, **kwargs):
        self.size_hint=(.25,.15)
        self.border=(0,0,0,0)
        self.text=""
        self.valign='middle'
        self.halign='center'
        self.background_normal = "images/button.png"
        self.font_name="HirukoBlackAlternate.ttf"
        self.font_size=40
        self.padding=(0.15,.15)
        self.color=(0,0,0,1)
        super (MenuButton, self).__init__(**kwargs)

class MainMenu(Screen):
    def switch(self, *args):
        self.manager.current = "game_screen"

    def quit(self, *args):
        MainApp.get_running_app().stop()

class GameScreen(Screen):
    def switch(self,*args):
        self.manager.current = 'main_menu'

class FruitBoard(GridLayout):
    def __init__(self, **kwargs):
        self.selected = 0
        self.rounds = 0
        self.num = 1
        super (FruitBoard, self).__init__(**kwargs)
        self.build_board()

    def build_board(self):
        GRID_ORDER = [(1,1),(1,2),(1,3),(2,2),(3,2),(3,2),(3,3),(3,3),(3,3),(4,3)]
        self.fruit_list = FRUIT_LIST[:]
        self.fruit = self.next_fruit()
        self.rows, self.cols = GRID_ORDER[self.num-1]
        for f in xrange(self.num):
            b = FruitButton(fruit=self.fruit)
            self.add_widget(b)

    def next_fruit(self):
        i = random.randint(0, len(self.fruit_list)-1)
        return self.fruit_list.pop(i)

    def reset(self):
        self.clear_widgets()
        self.selected = 0
        self.build_board()

    def restart(self):
        self.rounds = 0
        self.num = 1
        self.reset()

    def start_next_round(self, pop, but):
        pop.dismiss()
        self.rounds +=1
        if self.rounds < 10:
            self.num+=1
            self.fruit = self.next_fruit()
            self.reset()
        else:
            self.restart()
            self.parent.parent.switch()

class FruitButton(ButtonBehavior, Image):
    def __init__(self, **kwargs):
        self.source = "images/%s.png" % kwargs["fruit"]
        self.fruit = kwargs["fruit"]
        self.border = (0,0,0,0)
        self.selected = False
        super (FruitButton, self).__init__(**kwargs)

    def on_press(self):
        if not self.selected:
            self.source = "images/%s_clicked.png" % self.fruit
            self.selected = True
            mama = self.parent
            mama.selected +=1
            if mama.rounds > 1:
                self.parent.parent.parent.ids.number_label.text = str(mama.selected)  #probably indicates an organizational problem

            if mama.selected == mama.num:
                self.parent.parent.parent.ids.number_label.text = ""
                if mama.num > 1:
                    if mama.fruit == "peach" or mama.fruit == "mango":
                        fruit = mama.fruit.title() + "es!"
                    else:
                        fruit = mama.fruit.title() + "s!"
                else:
                    fruit = mama.fruit.title() + "!"
                num_img = "images/%s.png" % NUMBER_IMAGES[mama.num-1]
                roundmodal = ModalView(size_hint=(0.75, 0.75),auto_dismiss=False,background=num_img)
                message = FloatLayout()
                b = Button(border=(0,0,0,0),background_color=(1,1,1,0))
                b.bind(on_release=partial(mama.start_next_round,roundmodal))
                l = RoundLabel(text=fruit)
                message.add_widget(b)
                message.add_widget(l)
                roundmodal.add_widget(message)
                roundmodal.open()

class RoundLabel(Label):
    pass

if __name__ == '__main__':
    MainApp().run()

# main.kv
<MainMenu>:
  canvas.before:
    Rectangle:
      pos: self.pos
      size: self.size
      source: "images/leaf.png"
  FloatLayout:
    Image:
      source: "images/title.png"
      pos_hint: {'center_x':0.5,'center_y':.8}
      size_hint: .9,.5
    MenuButton:
      text: "Play"
      on_press: root.switch()
      pos_hint: {'center_x':0.5,'y':0.3}
    MenuButton:
      text: "Quit"
      on_press: root.quit()
      pos_hint: {'center_x':0.5,'y':0}

<GameScreen>:
  number_label: number_label
  canvas.before:
    Rectangle:
      pos: self.pos
      size: self.size
      source: "images/leaf.png"
  FloatLayout:
    id: flayout
    RoundLabel:
      id: number_label
      text: ""
      size_hint: .25,.25
      pos_hint: {'center_x':0.5,'y':0}
    FruitBoard:
      cols: 1
      rows: 1
      spacing: 20
      padding_top: 100
      size_hint: 1,.75
      pos_hint: {'x':0,'y':.2}
    Button:
      size_hint: .1, .1
      pos_hint: {'x':.01,'y':.025}
      background_normal: "images/return_button.png"
      background_down: "images/return_button.png"
      border: 0,0,0,0
      on_press: root.switch()

<RoundLabel>:
  border: 0,0,0,0
  background_color: 0,0,0,0
  text_size: self.width - 50, self.height - 50
  halign: 'center'
  valign: 'bottom'
  font_name: "HirukoBlackAlternate.ttf"
  font_size: 80
  color: 1,1,1,1
  pos_hint: {'center_x':0.5,'y':-0.1}

Version 4: Ren'Py to the Rescue

Screenshot After extensive difficulties building a Kivy distribution I decided to port Let’s Count! to Ren/Py. Ren’Py is a visual novel engine, however, it has a really excellent system for screens that makes designing button-based mini-games a snap. I’d never used the Ren’Py Android Packaging Tool (RAPT) before so I thought this would be a good starting application.

I made some minor adjustments to screens.rpy and gui.rpy, but this essentially the source. This version incorporates voice audio and basic animations. A monkey randomly pops up during the game and says, “Yum yum yum!”

init python:
    from random import shuffle

    BASE_FRUIT_LIST = ["apple","banana","watermelon","orange","lemon","pomegranate","pear","mango","kiwi","pineapple","coconut","peach"]
    NUMBERS = ["one","two","three","four","five","six","seven","eight","nine","ten"]    
    ROUNDS = 10   

    renpy.music.register_channel("yum", mixer="sfx",loop=False, tight=True, file_prefix='sfx/', file_suffix='.mp3')    
    renpy.music.register_channel("numbers",mixer="sfx",loop=False, tight=True, file_prefix="sfx/")

    def clicked(selected,num,fruit):
        total = len(selected)        
        number_sfx = NUMBERS[total-1] + ".mp3"

        if renpy.get_screen("counter"):
            renpy.hide_screen("counter")

        if total == num:
            if num > 1:
                if fruit == "mango" or fruit == "peach":
                    plural = "es"  
                else:
                    plural = "s"
            else:
                plural = ""

            final_fruit = fruit.title() + plural
            fruit_sfx = final_fruit+".mp3"

            if total == ROUNDS:
                renpy.music.play([number_sfx,fruit_sfx,"fanfare.wav"],channel='numbers')
                renpy.show_screen("monkey","laugh")
            else:
                renpy.music.play([number_sfx,fruit_sfx,"yeah.mp3"],channel='numbers')
            renpy.show_screen("number_display",num,total,final_fruit)            
        else:            
            renpy.show_screen("counter",total)  
            if COMPLETED_ROUNDS > 2 and COMPLETED_ROUNDS < ROUNDS-1:            
                if not MONKEY_SHOWN:
                    monkey_chance = renpy.random.randint(0,4)
                    if not monkey_chance:
                        sfx = renpy.random.choice(["yumyum1","yumyum2","yumyum3"])
                        renpy.show_screen("monkey",sfx)        

    def reset_board(num):
        global COMPLETED_ROUNDS, MONKEY_SHOWN
        MONKEY_SHOWN = False
        COMPLETED_ROUNDS +=1
        renpy.hide_screen("fruitboard")        
        if COMPLETED_ROUNDS < ROUNDS:        
            fruit = random_fruit()       
            renpy.show_screen("fruitboard",fruit=fruit,num=num+1)       
        else:
            start_game()

    def start_game():
        global FRUIT_LIST, COMPLETED_ROUNDS, MONKEY_SHOWN
        COMPLETED_ROUNDS = 0
        MONKEY_SHOWN = False        
        FRUIT_LIST = BASE_FRUIT_LIST[:]   
        next_fruit = random_fruit()
        renpy.show_screen("fruitboard",next_fruit, 1)

    def random_fruit():
        global FRUIT_LIST
        if not FRUIT_LIST:
            FRUIT_LIST = BASE_FRUIT_LIST[:]
        shuffle(FRUIT_LIST)
        i = renpy.random.randint(0, len(FRUIT_LIST)-1)
        return FRUIT_LIST.pop(i)               

label start:
    play music "music/upbeat.mp3" fadeout 1.0 fadein 1.0
    $ start_game()      

label gameloop:
    pause
    jump gameloop    

transform button_zoom(x):
    zoom x

transform waggle:
    on show:
        zoom 0.0
        linear 0.25 zoom 1.25
        linear 0.5 zoom 1.0
    on hide:
        linear 0.5 alpha 0.0

transform custom_fade(st,ht):
    on show:
        alpha 0.0
        linear st alpha 1.0
    on hide:
        linear ht alpha 0.0

transform monkey_dance:
    choice:
        parallel:
            choice:
                xalign 0.0
            choice:
                xalign 0.5
            choice:
                xalign 1.0    

        parallel:
            choice:                   
                yalign 1.8
                linear 0.5 yalign 1.0
                pause 1.0
                linear 0.5 yalign 1.8
            choice:            
                yzoom -1.0 yalign -0.8
                linear 0.5 yalign 0.0
                pause 1.0
                linear 0.5 yalign -0.8

    choice:
        parallel:
            choice:
                yalign 0.0
            choice:
                yalign 0.5
            choice:
                yalign 1.0

        parallel:
            choice:
                rotate 90 xpos -500
                linear 0.5 xpos -175
                pause 1.0
                linear 0.5 xpos -500

            choice:
                rotate -90 xpos 1200                
                linear 0.5 xpos 800
                pause 1.0
                linear 0.5 xpos 1200    

style mystyle_text:
    xalign 0.5
    size 100
    color "#fff"
    outlines [(10,"#000",0,0)]

screen fruitboard(fruit, num):
    default selected = set()
    $ GRID_ORDER = [(1,1,500),(2,1,400),(3,1,300),(2,2,300),(3,2,300),(3,2,300),(4,2,300),(4,2,300),(5,2,250),(5,2,250)]
    $ cols, rows, x = GRID_ORDER[num-1]    
    frame:
        xalign 0.5 yalign 0.5
        background "leaf"
        vbox:
            xfill True yfill True
            at custom_fade(0.25,0.25)
            grid cols rows:
                xalign 0.5 yalign 0.5 spacing 20              
                for item in range(0,num):                      
                    imagebutton:
                        idle ProportionalScale("images/"+fruit+".png",x,x)
                        insensitive ProportionalScale("images/"+fruit+"_clicked.png",x,x)
                        action [Play("audio","sfx/pop.ogg"),AddToSet(selected,item),Function(clicked,selected,num,fruit)]
                for i in range(num, rows*cols):
                    add Null()

    imagebutton:
        idle "return_button" at button_zoom(0.25)
        action [Play("audio","sfx/chime.ogg"),MainMenu(confirm=False)]
        xalign 0.02 yalign 0.98

screen number_display(num, total, fruit):   
    modal True   
    frame:
        xalign 0.5 ypos 50 background None
        at custom_fade(1.0,0.5)
        vbox:            
            imagebutton:
                idle NUMBERS[total-1] at button_zoom(0.75)               
                action [Function(reset_board,num), Hide("number_display")]
            if num==total:
                text fruit style_group "mystyle" ypos -100

screen counter(total):    
    vbox:
        xalign 0.98 yalign 0.98 at waggle
        add NUMBERS[total-1] at button_zoom(0.25)

screen monkey(sfx):          
    add "monkey" at monkey_dance    
    on "show" action [Play("yum",sfx),ToggleVariable("MONKEY_SHOWN")]
    timer 4.0 action [Hide("monkey")]

## handy scaling function from Cironian https://lemmasoft.renai.us/forums/viewtopic.php?f=32&t=8011    
init python:
    class ProportionalScale(im.ImageBase):
        def __init__(self, imgname, maxwidth, maxheight, bilinear=True, **properties):
            img = im.image(imgname)
            super(ProportionalScale, self).__init__(img, maxwidth, maxheight, bilinear, **properties)
            self.imgname = imgname
            self.image = img
            self.maxwidth = int(maxwidth)
            self.maxheight = int(maxheight)
            self.bilinear = bilinear

        def load(self):
            child = im.cache.get(self.image)
            currentwidth, currentheight = child.get_size()
            xscale = 1.0
            yscale = 1.0
            if (currentwidth > self.maxwidth):
                xscale = float(self.maxwidth) / float(currentwidth)
            if (currentheight > self.maxheight):
                yscale = float(self.maxheight) / float(currentheight)
            if (xscale < yscale):
                minscale = xscale
            else:
                minscale = yscale
            newwidth = currentwidth * minscale
            newheight = currentheight * minscale
            #Debug code to see when the loading really happens
            #renpy.log("Loading image %s from %f x %f to %f x %f" % (self.imgname, currentwidth , currentheight , newwidth, newheight))

            if self.bilinear:
                try:
                    renpy.display.render.blit_lock.acquire()
                    rv = renpy.display.scale.smoothscale(child, (newwidth, newheight))
                finally:
                    renpy.display.render.blit_lock.release()
            else:
                try:
                    renpy.display.render.blit_lock.acquire()
                    rv = renpy.display.pgrender.transform_scale(child, (newwidth, newheight))
                finally:
                    renpy.display.render.blit_lock.release()
            return rv

        def predict_files(self):
            return self.image.predict_files()   

I was pleased to find using RAPT is user-friendly.

The build process took a few minutes and the app works perfectly on my tablet. I didn’t have any issues with button transparencies, which was a limitation mentioned in the documentation.

So there you have it: baby’s first app.

Version 5: Flappy Fruit

Screenshot

I decided to add a new level called Flappy Fruit. Flappy birds fly back and forth with fruit. The player counts to 10 by touching the birds. The code for this level is simpler than the previous one, even though it has moving parts.

Ren’Py’s “use” screen action allows me to make four flappy bird screens that utilize a base birdie screen. Ren’Py allows the same image to be shown multiple times at once using the “as” command, but screens don’t work that way. You can only show a screen with a specific name once. If you want to reuse a screen you’ll need use or transclude.

Kids handle touch input differently than adults. I noticed Bookworm would always double or triple-tap and generally doesn’t wait for popups. So if you’re making a game for kids you may need to include mechanisms account for buttons being clicked in rapid succession, or clicks earlier than you would generally anticipate.

A future version of Flappy Fruit should allow counting up to 20. If you’ve looked at my code, you already know

NUMBERS = ["one","two","three","four","five","six","seven","eight","nine","ten"]

mama done a bad thing. Yes, I did that, even knowing I would probably expand the counting range. A better naming scheme for the number images would have been no1, no2, no3… So when I need 11, I can easily split a string and know I need two no1 side-by-side.

init python:

    BASE_FRUIT_LIST = ["apple","banana","watermelon","orange","lemon","pomegranate","pear","mango","kiwi","pineapple","coconut","peach"]
    NUMBERS = ["one","two","three","four","five","six","seven","eight","nine","ten"]    
    ROUNDS = 10  
    SPEED = 5.0
    COMPLETED_ROUNDS = 0
    YLIST = [0.0,0.5,1.0]    
    BIRDS = set(["yellowbirdie","pinkbirdie","greenbirdie","chicky"])

    renpy.music.register_channel("numbers",mixer="sfx",loop=False, tight=True, file_prefix="sfx/")

    def random_fruit():
        global FRUIT_LIST
        if not FRUIT_LIST:
            FRUIT_LIST = BASE_FRUIT_LIST[:]
        shuffle(FRUIT_LIST)
        i = renpy.random.randint(0, len(FRUIT_LIST)-1)
        return FRUIT_LIST.pop(i)         

    def start_flappy():
        global FRUIT_LIST, COMPLETED_ROUNDS, AVAILABLE_BIRDS, YS
        COMPLETED_ROUNDS = 0
        FRUIT_LIST = BASE_FRUIT_LIST[:]
        AVAILABLE_BIRDS = set(BIRDS)
        YS = set(YLIST)
        if not renpy.get_screen("sky"):
            renpy.show_screen("sky")    
        new_flappy()

    def new_flappy():
        global AVAILABLE_BIRDS, YS
        if COMPLETED_ROUNDS == ROUNDS:
            reset_flappy()
        else:
            if AVAILABLE_BIRDS:
                fruit = random_fruit()
                bird = AVAILABLE_BIRDS.pop()
                if not YS:
                    YS = set(YLIST)
                y = YS.pop()
                renpy.show_screen(bird, fruit=fruit,y=y)   

    def reset_flappy():    
        renpy.music.play("fanfare.wav",channel='numbers')
        start_flappy()        

label flappy:
    play music "music/upbeat.mp3" fadeout 1.0 fadein 1.0   
    $ start_flappy()  

label gameloop:
    pause
    jump gameloop       

image yellowbird:
    "images/yellowbird1.png"
    0.25
    "images/yellowbird2.png"
    0.25
    repeat

image pinkbird:
    "images/pinkbird1.png"
    0.25
    "images/pinkbird2.png"
    0.25
    repeat       

image greenbird:
    "images/greenbird1.png"
    0.25
    "images/greenbird2.png"
    0.25
    repeat      

image chicken:
    "images/chicken1.png"
    0.15
    "images/chicken2.png"
    0.15
    "images/chicken3.png"
    0.15
    "images/chicken4.png"
    0.15
    repeat    

transform flying(s,y):
    on show:
        parallel:
            yalign y           
        parallel:
            choice:
                xpos -500
                linear s xpos 1780      
                repeat
            choice:
                xzoom -1.0
                xpos 1780
                linear s xpos -700
                repeat
    on hide:
        linear 1.0 yalign 2.5

transform bird_number:
    zoom 0.20

transform bird_dissolve:
    on hide:
        alpha 1.0
        linear 0.5 alpha 0.0

screen sky():
    modal True
    frame:
        background Fixed(Solid("#0FF"),"cloud_scene")
        xfill True yfill True

    vbox:
        style_group "sky"
        textbutton "Fast" action SetVariable("SPEED",3.0)
        textbutton "Medium" action SetVariable("SPEED",5.0)
        textbutton "Slow" action SetVariable("SPEED",7.0)

    use returnbutton

    timer 3.0 action Function(new_flappy) repeat True     

    if COMPLETED_ROUNDS == 10:
        timer 0.5 action Return()   

style sky_button_text:    
    size 30
    color "#0FF"
    selected_color "#000"

style sky_button:
    xalign 1.0

style sky_vbox:
    xalign 0.95 yalign 0.95 spacing 5

screen chicky(fruit,y):
    zorder 2
    use birdie("chicken",fruit,"chicky",y)        

screen greenbirdie(fruit,y):
    zorder 2
    use birdie("greenbird",fruit,"greenbirdie",y)

screen yellowbirdie(fruit,y):
    zorder 2
    use birdie("yellowbird",fruit,"yellowbirdie",y)

screen pinkbirdie(fruit,y):
    zorder 2
    use birdie("pinkbird",fruit,"pinkbirdie",y)

screen birdie(bird,fruit,parent,y):
    default selected = False
    $ hit = bird + "ouch"
    frame:  
        background None xmaximum 400 ymaximum 800
        at flying(SPEED,y)
        if COMPLETED_ROUNDS < ROUNDS:
            imagebutton:
                at button_zoom(0.5)
                idle Fixed(Transform(bird, ypos=0),Transform(fruit,ypos=240))
                insensitive Fixed(Transform(hit, ypos=0),Transform(fruit,ypos=240))         
                action [Hide("flappy_number"),SetScreenVariable("selected",True),SetVariable("COMPLETED_ROUNDS",COMPLETED_ROUNDS+1),AddToSet(AVAILABLE_BIRDS,parent),Play("audio","sfx/coin.ogg"), Show("flappy_number",num=COMPLETED_ROUNDS),Hide(parent)]
        else:

            ## this keeps the birds from being triggered before game is reset
            add Fixed(Transform(bird, ypos=0),Transform(fruit,ypos=240)) at button_zoom(0.5)           
            timer 0.25 action Hide(parent,transition=dissolve)

screen flappy_number(num):   
    zorder 1
    if num <= 10:
        $ num_sfx = NUMBERS[num] + ".mp3"
        vbox:
            xalign 0.5 yalign 0.5 at waggle
            add NUMBERS[num] at button_zoom(0.75)      
        timer 2.0 action Hide("flappy_number")  

        on "show" action Play("numbers", num_sfx)

transform button_zoom(x):
    zoom x

transform waggle:
    on show:
        zoom 0.0
        linear 0.25 zoom 1.25
        linear 0.5 zoom 1.0
    on hide:
        linear 0.5 alpha 0.0

screen returnbutton():    
    imagebutton:
        idle "return_button" at button_zoom(0.5)
        action [Play("audio","sfx/chime.ogg"),MainMenu(confirm=False)]
        xalign 0.02 yalign 0.98

Version 6: Flappy Foo

It turned out Flappy Fruit was a little too simple. Bookworm wasn’t as engaged with it. Bard suggested it be structured more like Let’s Count, with x fruits needing to be tapped per round. He wanted the fruit to remain visible at the bottom of the screen until the round was over.

So, I settled on a button that dropped when clicked, with the fruit half remaining at the bottom of the screen and the bird half flying away. The simplest solution I could think of was to use a Python show statements to show a new fruit and bird images on a custom layer over the button before it is hidden, then animate the bird to fly away. We know the button’s xpos, so we pass that down and use image_size to get the fruit’s dimensions and figure out where the bird should be.

At this stage I don’t think Flappy Fruit adds real value to the base game, it isn’t engaging enough. I wanted to add more variety to the birds’ flight patterns, for instance have horizontal as well as vertical movement, but the challenge is figuring out the button’s xpos when I haven’t explicitly set it.

There is no real documentation on renpy.get_widget_properties, which seems like the only way to get a screen widget’s xpos after the fact, and I could only ever get it to return empty tuples. renpy.get_placement also returns empty tuples. Short of creating the screen in Python, I’m not sure what to do. I thought I could use renpy.get_mouse_pos, but there was some issue with timing so I was not getting the coordinates from the moment of click. I think it would work better with touch input, but at this point I suspect I need to take a break from this project and completely rethink how the buttons are being handled. A proper solution shouldn’t be so messy.

## FLAPPY   
init:
    define config.layers = [ 'master', 'transient', 'screens', 'fruits', 'overlay' ]

init python:
    from random import shuffle

    BASE_FRUIT_LIST = ["apple","banana","watermelon","orange","lemon","pomegranate","pear","mango","kiwi","pineapple","coconut","peach"]
    NUMBERS = ["no"+str(n) for n in range(1,11)]
    BIRDS = set(["yellowbirdie","pinkbirdie","greenbirdie","chicky"])    
    ROUNDS = 10  
    SPEED = 3.0
    COMPLETED_ROUNDS = 0
    FLAPPY_COUNT = 0   

    def start_flappy():
        global CURRENT_ROUND, FRUIT_LIST, FLAPPY_COUNT, AVAILABLE_BIRDS, CURRENT_FRUIT, XS
        CURRENT_ROUND = 1
        FLAPPY_COUNT = 0
        FRUIT_LIST = BASE_FRUIT_LIST[:]
        AVAILABLE_BIRDS = set(BIRDS)
        CURRENT_FRUIT = random_fruit()
        XS = [n*96 for n in range(2,12)]
        renpy.show_screen("sky")
        create_flappy()

    def create_flappy():
        global AVAILABLE_BIRDS, XS
        if not AVAILABLE_BIRDS:
            AVAILABLE_BIRDS = set(BIRDS)
        bird = renpy.random.choice(list(AVAILABLE_BIRDS))
        AVAILABLE_BIRDS.remove(bird)
        shuffle(XS)
        x = XS.pop()            
        renpy.show_screen(bird, fruit=CURRENT_FRUIT,x=x)          

    def flappy_click(x,bird):
        global FLAPPY_COUNT, CURRENT_ROUND, CURRENT_FRUIT
        FLAPPY_COUNT +=1     

        prev_fruits = ["f"+str(n) for n in range(1,FLAPPY_COUNT)]

        renpy.show(CURRENT_FRUIT+"_clicked",at_list=[fruit_drop(x),button_zoom(0.5)],layer='fruits',tag=("f"+str(FLAPPY_COUNT)),behind=prev_fruits)

        stat = renpy.image_size(CURRENT_FRUIT+".png")
        renpy.show(bird,at_list=[flit(x,stat[0]),button_zoom(0.5)],layer="screens",tag=("b"+str(FLAPPY_COUNT)))

        if FLAPPY_COUNT == CURRENT_ROUND:                        
            if CURRENT_ROUND == ROUNDS:
                renpy.music.play("yeah.mp3",channel='numbers')
                renpy.full_restart()               
            else:                
                CURRENT_ROUND+=1     
        else:            
            create_flappy()

    def flappy_reset():
        global FLAPPY_COUNT, AVAILABLE_BIRDS,CURRENT_FRUIT, XS
        FLAPPY_COUNT = 0
        XS = [n*96 for n in range(2,12)]
        for s in BIRDS:
            if renpy.get_screen(s):
                renpy.hide(s)         
        renpy.scene("fruits")
        CURRENT_FRUIT = random_fruit()
        create_flappy()

transform flying(s,x):
    on show:
        yanchor 1.0
        ypos 0 xpos x
        linear s ypos 720
        block:           
            linear s ypos 0
            linear s ypos 720
            repeat
    on hide:
        linear 1.0 ypos 720

transform flit(x,y):
    ypos 720-y xpos x
    linear 1.0 ypos -400

transform fruit_drop(x):
    yanchor 1.0 ypos 720 xpos x

transform drop:
    linear 1.0 yanchor 1.0 ypos 720

screen sky():
    modal True
    frame:
        background Fixed(Solid("#ADD8E6"),"cloud_scene")
        xfill True yfill True        
    vbox:
        style_group "sky"
        textbutton "Fast" action SetVariable("SPEED",3.0)
        textbutton "Medium" action SetVariable("SPEED",5.0)
        textbutton "Slow" action SetVariable("SPEED",7.0)
    use returnbutton  

screen flappy_number(x,bird):   
    default num = FLAPPY_COUNT
    default num_sfx = NUMBERS[num] + ".mp3"
    zorder 1
    if FLAPPY_COUNT <= 10:

        if FLAPPY_COUNT == CURRENT_ROUND-1:
            vbox:
                xalign 0.5 yalign 0.5 at waggle
                imagebutton:
                    idle NUMBERS[num] at button_zoom(0.75)
                    action [Hide("flappy_number"),Function(flappy_reset)]

            timer 2.0 action [Function(flappy_click,x,bird)]
        else:
            vbox:
                xalign 0.5 yalign 0.25 at waggle
                add NUMBERS[num] at button_zoom(0.25)
            timer 2.0 action [Hide("flappy_number"), Function(flappy_click,x,bird)]       
        on "show" action Play("numbers", num_sfx)

screen var(s):
    text str(s)

screen birdie(bird,fruit,parent,x):
    default selected = False
    vbox:  
        at flying(SPEED,x)
        if FLAPPY_COUNT == CURRENT_ROUND - 1:
            imagebutton:            
                at button_zoom(0.5)
                idle VBox(Transform(bird,ypos=100),fruit,order_reverse=True)
                insensitive VBox(Transform(bird+"ouch",ypos=100),fruit+"_clicked",order_reverse=True)     
                action [SetScreenVariable("selected",True),AddToSet(AVAILABLE_BIRDS,parent),Play("audio","sfx/fanfare.wav"),Show("flappy_number",x=x,bird=bird),Hide(parent)]  
        else:
            imagebutton:            
                at button_zoom(0.5)
                idle VBox(Transform(bird,ypos=100),fruit,order_reverse=True)
                insensitive VBox(Transform(bird+"ouch",ypos=100),fruit+"_clicked",order_reverse=True)
                action [SetScreenVariable("selected",True),AddToSet(AVAILABLE_BIRDS,parent),Play("audio","sfx/coin.ogg"),Show("flappy_number",x=x,bird=bird),Hide(parent)]   

screen chicky(fruit,x):
    zorder 2
    use birdie("chicken",fruit,"chicky",x)        

screen greenbirdie(fruit,x):
    zorder 2
    use birdie("greenbird",fruit,"greenbirdie",x)

screen yellowbirdie(fruit,x):
    zorder 2
    use birdie("yellowbird",fruit,"yellowbirdie",x)

screen pinkbirdie(fruit,x):
    zorder 2
    use birdie("pinkbird",fruit,"pinkbirdie",x)

image yellowbird:
    "images/yellowbird1.png"
    0.25
    "images/yellowbird2.png"
    0.25
    repeat

image pinkbird:
    "images/pinkbird1.png"
    0.25
    "images/pinkbird2.png"
    0.25
    repeat       

image greenbird:
    "images/greenbird1.png"
    0.25
    "images/greenbird2.png"
    0.25
    repeat      

image chicken:
    "images/chicken1.png"
    0.15
    "images/chicken2.png"
    0.15
    "images/chicken3.png"
    0.15
    "images/chicken4.png"
    0.15
    repeat             

style sky_button_text:    
    size 30
    color "#0FF"
    selected_color "#000"

style sky_button:
    xalign 1.0

style sky_vbox:
    xalign 0.95 yalign 0.05 spacing 5     
CC0: Public Domain
The content on this page is Public Domain