Python
GUI
Kivy
PythonDay 5

Python Kivyの使い方⑤ ~公式チュートリアルの拡張~

Kivyとは

Python のGUIライブラリーKivyは、PythonのGUIライブラリーの中では最も勢いのあるライブラリーと言われており、海外ではpyQt、pySideなどのQt系のあるライブラリーよりも勢いがあります。

特徴を簡単に述べると以下の通りです。

  • PythonでGUIを作成するツール
  • Python2/3対応
  • マルチプラットフォーム(win,Mac,Linux,RaspberryPi.Android,ios)
  • Kv LanguageというUIを記述するメタ言語がある
  • MITライセンス

今回は公式チュートリアルの「A Simple Paint App」」と「pon game」を拡張したものを作ります。
※今回の記事は 2018/04/22に行われた技術書典4で出店した本からの抜粋になります

「A Simple Paint App」」の拡張したもの
s.jpg

「pon game」を完成したもの
s2.jpg

A Simple Paint Appの拡張

概要

Kivyの公式チュートリアルに紹介している、A Simple Paint App( https://kivy.org/docs/tutorials/firstwidget.html )を拡張します。

日本語の翻訳記事は以下( https://pyky.github.io/kivy-doc-ja/tutorials/firstwidget.html )になります。

もとの内容の説明

kivy の公式チュートリアルは以下の通りです

1_guide-6.jpg

機能としては

  • 画面をクリック(ドラッグ)するたびに始点が〇で描かれて線が引かれる
  • 線の色は毎回ランダムな色
  • 「clear」ボタンを押すと、線が削除されて真っ黒な画面が表示される

改造して最終的に完成するアプリ

最終的に完成するのは以下のアプリです。

2_キャプチャ.PNG

機能としては以下の通りです

  • 画面をクリックしながらドラッグすることで色をぬる
  • Clearボタンで画面を削除する
  • 各色のボタンを押すことでぬる色が変化する
  • Line width(スライダー)を変更することで線の太さを変更する
  • saveボタンをクリックすることで画面を画像保存する

ファイルについて

Githubに配置しています

https://github.com/okajun35/Kivy_paint_expansion

ファイルの構造

    android.txt ・・・Kivy Lancher で実行時の設定
    color_button_down.png ・・・カラーボタンのクリック時の画像ファイル
    color_button_normal.png ・・・カラーボタンのクリックしていないときの画像ファイル
    icon.png ・・・ Kivy Lancher で実行時のアイコン
    main.py ・・・  実際のロジックが書かれているファイル
    mypaint.kv ・・・ レイアウトが書かれたkv ファイル 

最終的に完成するコード

main.py
# -*- coding: utf-8 -*-

from random import random
from kivy.app import App
from kivy.config import Config

# 起動時の解像度の設定
Config.set('graphics', 'width', '1024')
Config.set('graphics', 'height', '768')  # 16:9
Config.set('graphics', 'resizable', False)  # ウインドウリサイズ禁止



from kivy.uix.widget import Widget
from kivy.uix.button import Button
from kivy.graphics import Color, Ellipse, Line
from kivy.properties import ObjectProperty
from kivy.uix.behaviors import ToggleButtonBehavior
from kivy.uix.togglebutton import ToggleButton

from kivy.utils import get_color_from_hex   # 色の16進数表示を可能にする
from kivy.core.window import Window


class MyPaintWidget(Widget):
    #pass
    last_color = '' # 画面クリアを押された場合の最後の色
    line_width = 3  # 線の太さ

    def on_touch_down(self, touch):
        if Widget.on_touch_down(self, touch):
            return


        color = (random(), 1, 1)
        with self.canvas:
            #Color(*color, mode='hsv')
            #d = 30.
            #Ellipse(pos=(touch.x - d / 2, touch.y - d / 2), size=(d, d))
            touch.ud['line'] = Line(points=(touch.x, touch.y), width=self.line_width)

    def set_line_width(self, line_width=3):
        self.line_width = line_width

    def on_touch_move(self, touch):
        if touch.ud:    # スライダーを動かす際のエラーを解除するため
            touch.ud['line'].points += [touch.x, touch.y]

    def set_color(self, new_color):
        ''' 塗る色を変更する '''
        self.last_color = new_color
        self.canvas.add(Color(*new_color))


class MyCanvasWidget(Widget):

    def clear_canvas(self):
        MyPaintWidget.clear_canvas(self)


class MyPaintApp(App):
    #paint_id = ObjectProperty(None)
    #self.painter.test # これでClearボタンにアクセス可能

    def __init__(self, **kwargs):
        super(MyPaintApp, self).__init__(**kwargs)
        self.title = '画像表示'

    def build(self):
        parent = Widget()
        self.painter = MyCanvasWidget()

        # 起動時の色の設定を行う
        self.painter.ids['paint_area'].set_color(
            get_color_from_hex('#000000'))  #黒色を設定


        #clearbtn = Button(text='Clear')
        #clearbtn.bind(on_release=self.clear_canvas)

        #parent.add_widget(self.painter)

        #parent.add_widget(clearbtn)

        #return parent
        return self.painter

    #def clear_canvas(self, obj):
    def clear_canvas(self):
        '''
        画面をきれいにする。行うことは以下の2点
        1:画面をクリアーにする
        2:最後にセットしていた色をセットしなおす
        ''' 
        self.painter.ids['paint_area'].canvas.clear()
        self.painter.ids['paint_area'].set_color(self.painter.ids['paint_area'].last_color)

    def save_canvas(self):
        # 時間があるときに一時的にcanvas.beforeに背景を塗り潰す処理を加えるの
        # https://kivy.org/docs/api-kivy.core.window.html?highlight=screenshot#kivy.core.window.WindowBase.screenshot
        Window.screenshot();    # スクリーンショットを保存する
        #self.painter.export_to_png('a.png')    # 画像を保存する ただしこのやり方だとウィンドウカラーが適用されないので描いていない部分が透明になる

class ColorButton(ToggleButton):
    def _do_press(self):
        '''
        何も押されていない状態で設定が解除されるのを防ぐためToggleButtonの関数を継承して変更する 
        Source code for kivy.uix.behaviors.button
        https://kivy.org/docs/_modules/kivy/uix/behaviors/button.html
        '''

        if self.state == 'normal':
            # ボタンを押されてない場合は状態を変更する
            ToggleButtonBehavior._do_press(self)

if __name__ == '__main__':
    Window.clearcolor = get_color_from_hex('#ffffff')   # ウィンドウの色を白色に変更する
    MyPaintApp().run()

kvファイル

mypaint.kv
#:import hex_color kivy.utils.get_color_from_hex

<ColorButton>:
    background_normal: 'color_button_normal.png'
    background_down: 'color_button_down.png'
    group: 'color'
    border: (5, 5, 5, 5)
    on_release: app.painter.ids['paint_area'].set_color(self.background_color)
    #on_release: app.painter.paint_id.set_color(self.background_color) # この方法でもset_corlorにアクセス可能
<MyCanvasWidget>:
    paint_id:paint_area
    id: canvas_area

    test:button1
    Button:
        text: 'save'
        color: 1, 1, 1 , 1
        font_size: 20
        on_release: app.save_canvas()
        border: (2, 2, 2, 2)
        x: 0
        top: root.top
        width: 80
        height: 40

    BoxLayout:
        orientation: 'vertical'
        height: root.height

        width: root.width
        MyPaintWidget:
            id: paint_area
            size_hint_y: 0.8

        BoxLayout:
            orientation: 'horizontal'
            size_hint_y: 0.1
            Label:
                size_hint_x: 0.1
                text: 'Line width %s' % int(s1.value) if s1.value else 'Line width not set'
                color: 0,0,0,1
            Slider:
                id: s1
                size_hint_x: 0.9
                value: 3
                range: (1,100)
                step: 1
                on_touch_down:app.painter.ids['paint_area'].set_line_width(self.value)

        BoxLayout:
            orientation: 'horizontal'
            size_hint_y: 0.1
            clear_btn:button1
            Button:
                id: button1
                text: "Clear"
                ont_size: 30
                on_release: app.clear_canvas()


            ColorButton:
                text: "white "
                color: 0, 0, 0 , 1
                background_color: hex_color('#ffffff')

            ColorButton:
                text: "black "
                state: 'down'
                background_color: hex_color('#000000')

            ColorButton:
                text: "red "
                background_color: hex_color('#ff0000')

            ColorButton:
                text: "biue "
                background_color: hex_color('#0000ff')

            ColorButton:
                text: "green "
                background_color: hex_color('#008000')

            ColorButton:
                text: "orange"
                background_color: hex_color('#ff4500')

            ColorButton:
                text: "purple"
                background_color: hex_color('#800080')

ファイル形式について

main.pyはUTF-8形式で保存してください。kvファイルはshift-jis形式で保存してください。

レイアウト全体

2_説明1.jpg

レイアウトは大きく縦に3つに分かれています
①描画部分
②線の太さを設定するスライダー部分
③削除ボタンと線の描画する色を設定する

mypaint kv のMyCanvasWidget(Widgetクラス)が今回のレイアウトを行っているクラスです。この中でBoxLayoutを用いて縦3つのクラスを形成しています。以下はkvファイルで該当部分の抜粋になります。

<MyCanvasWidget>:
    BoxLayout:
        orientation: 'vertical'
        height: root.height

        width: root.width
        MyPaintWidget:
            id: paint_area
            size_hint_y: 0.8

        BoxLayout:
            size_hint_y: 0.1

        BoxLayout:
            size_hint_y: 0.1

BoxLayoutは縦、または横にwidgetを並べることができます。
今回はプロパティorientationを行うことで縦に3つ並べています。
MyPaintWidgetwidgetはチュートリアルのころからあったクラスでこれが①描画部分になります。

次に上から2つ目のBoxLayoutですが、ここが「②線の太さを設定するスライダー部分」になります。

最後の3つ目のBoxLayoutですがここが「③削除ボタンと線の描画する色を設定する」となっています。

画面全体を作るBoxLayoutの設定ですが以下の部分を注目してください。

BoxLayout:
    orientation: 'vertical'
    height: root.height
    width: root.width

高さの「height」に「root.height」、幅のプロパティ「width」に「root.width」を設定しています。ここでrootはKv Languageの予約語で対象のWidgetの大元のインスタンスを指します。ここではMyCanvasWidgetを指しています。
つまりここでは BoxLayoutの幅と高さを大元のMyCanvasWidgetの幅と高さに設定 しています。

なぜ、このようなことをしているかというと、ここでの内容はPythonファイルではadd_widget(BoxLayout())と同じです。すなわちwidetクラスのBoxLayoutを足しています。ここで注意したいのはwidetクラスの「width」、「height」はともに100(単位:ピクセル)が初期値だからです。

これはよくkivyを始めたばかりの人が良く誤解する一つで、widgetを追加しただけだと初期値のサイズ(100×100)が適用されてしまい画面サイズに対し小さいwidgetが表示されてしまいます。
公式チュートリアルのPaint Appではこのことを利用して、サイズを指定せずにparent.add_widget(clearbtn)でクリアボタンを追加しています。
しかし初期のサイズについて説明がないので、実際にKivyで一からレイアウトを設定した場合にうまくいかずに 「部品サイズの初期値も意味不明」 ということで挫折される方が一定数いるようです。

Kivyの公式チュートリアルとプログラミングガイドを翻訳した感想ですと、両者には記載がないですが知っておくとよいことがいくつかあり、この内容もその1つです。

縦のサイズですが各widgetのsize_hint_yで設定しており、0.8,0.1,0.1がそれぞれの比率です。この内容を合計した値の比率を全体とした場合の比率になります。
今回は、0.8+0.1+0.1 = 1なので全体を1とした場合のそれぞれの割合で高さを設定しています。

削除ボタンと線の描画する色を設定する

3_説明4.jpg

kvファイルで削除ボタンを分離する

もとのチュートリアルではpythonファイルに削除ボタンを記載していましたが、
今回はKv Languageファイルに記載しています。

Button:
    id: button1
    text: "Clear"
    ont_size: 30
    on_release: app.clear_canvas()

ボタンをクリックすると、appはKv Languageの予約語でAppクラスを指します。ここではPytthonファイルのMyPaintApp() クラスを指します。その中のclear_canvas()を使用して画面を削除します。

参考:Kivy Language(翻訳済み) ( https://pyky.github.io/kivy-doc-ja/api-kivy.lang.html )

class MyPaintApp(App):
    def clear_canvas(self):
        '''
        画面をきれいにする。行うことは以下の2点
        1:画面をクリアーにする
        2:最後にセットしていた色をセットしなおす
        ''' 
        self.painter.ids['paint_area'].canvas.clear() ・・・①
        self.painter.ids['paint_area'].set_color(self.painter.ids['paint_area'].last_color)・・・②

①はidsを使用してMyPaintWidget()canvas(描画部分を)クリアしています。チュートリアルでは、rootを使用して描画部分をクリアーしていましたが、チュートリアルでは同じ描画していたcanvasと同じwidget内にいたのでcanavasをクリアしていましたが今回は、描画するwidgetとボタンを配置するwidgetが違うので、Appクラスからkvファイルないで設定したid(paint_area)でアクセスしてcanvasを削除しています。
②に関してはコードの説明は後でしますが、canvasは画面の表示をクリア(初期状態)に戻すだけでなく描画する色も初期値(黒)に戻します。そのため最後に記録していた色をセットしなおします。

トグルボタンで削除ボタン、色を変更するボタンを追加

3_説明4.jpg

描画する色を選択するのに今回はToggle button(トグルボタン) ( https://kivy.org/docs/api-kivy.uix.togglebutton.html )を使用しています。
関係するコードは以下の通りです。

mypaint.kv

mypaint.kv
#:import hex_color kivy.utils.get_color_from_hex
<ColorButton>:
    background_normal: 'color_button_normal.png'
    background_down: 'color_button_down.png'
    group: 'color'
    border: (5, 5, 5, 5)
    on_release: app.painter.ids['paint_area'].set_color(self.background_color)
    #on_release: app.painter.paint_id.set_color(self.background_color) # この方法でもset_corlorにアクセス可能

<MyCanvasWidget>:
        BoxLayout:
            orientation: 'horizontal'
            size_hint_y: 0.1
            clear_btn:button1

            ColorButton:
                text: "white "
                color: 0, 0, 0 , 1
                background_color: hex_color('#ffffff')

            ColorButton:
                text: "black "
                state: 'down'
                background_color: hex_color('#000000')

            ColorButton:
                text: "red "
                background_color: hex_color('#ff0000')

            ColorButton:
                text: "biue "
                background_color: hex_color('#0000ff')

            ColorButton:
                text: "green "
                background_color: hex_color('#008000')

            ColorButton:
                text: "orange"
                background_color: hex_color('#ff4500')

            ColorButton:
                text: "purple"
                background_color: hex_color('#800080')

main.py

main.py
class ColorButton(ToggleButton):
    def _do_press(self):
        '''
        何も押されていない状態で設定が解除されるのを防ぐためToggleButtonの関数を継承して変更する 
        Source code for kivy.uix.behaviors.button
        https://kivy.org/docs/_modules/kivy/uix/behaviors/button.html
        '''

        if self.state == 'normal':
            # ボタンを押されてない場合は状態を変更する
            ToggleButtonBehavior._do_press(self)

トグルボタンですが、ToggleButtonを継承してColorButtonを作成しております。機共通の内容についてはkvファイル内のColorButtonで定義しています。個別の機能については<MyCanvasWidget>ないで配置する際に設定しています。

またここでは2つの事を行っています。
①ボタンのデザインを行う
②トグルボタンのgroupとstateを用いてボタンの制御を行う
③ボタンを押すと各色に描画色を設定する

①ボタンのデザインを行う

5_togglebutton.jpg

トグルボタン自体のレイアウトは公式でも用意されていますが、色ごとに用意したかったので自前で設定をすることにしています。

<ColorButton>:
    background_normal: 'color_button_normal.png'
    background_down: 'color_button_down.png'
    border: (5, 5, 5, 5)

background_normalで通常のボタン時の画像を、background_downでクリック時の画像を設定しています。
設定している「color_button_normal.png」と「color_button_down.png」ですが、ファイルは幅と高さが10pxと白い画像ファイルとしています。
またborderを設定していることで角丸のデザインを設定しています。

各色に関しては、MyCanvasWidget内でそれぞれの色を設定しています。

#:import hex_color kivy.utils.get_color_from_hex

<MyCanvasWidget>:
        BoxLayout:
            orientation: 'horizontal'
            size_hint_y: 0.1
            clear_btn:button1

            ColorButton:
                text: "white "
                color: 0, 0, 0 , 1
                background_color: hex_color('#ffffff')

            ColorButton:
                text: "black "
                state: 'down'
                background_color: hex_color('#000000')

            ColorButton:
                text: "red "
                background_color: hex_color('#ff0000')

            ColorButton:
                text: "biue "
                background_color: hex_color('#0000ff')

            ColorButton:
                text: "green "
                background_color: hex_color('#008000')

            ColorButton:
                text: "orange"
                background_color: hex_color('#ff4500')

            ColorButton:
                text: "purple"
                background_color: hex_color('#800080')

#:importを用いることでkvファイル内でPythonのライブラリをimportできます。今回は hex_color kivy.utils.get_color()を用いることで16進数カラーに設定できるようにしています。あとはMyCanvasWidget内のbackground_colorでボタンの背景色を変更しています。

②トグルボタンのgroupとstateを用いてボタンの制御を行う

ボタンをクリックして色を設定していますが、この時に条件としては以下の2つがあります

1.ある色のボタンを押した場合に、ある色のボタンがONになり、それまで設定していた他の色のボタンがoffになる。(複数のボタンの状態を連動させる)
2.onになっているボタンをクリックしてもoffにさせない

1.に関してはkivyのトグルボタンにはgroupという仕組みがあり、groupの値が同一のトグルボタンは状態が連動されるという仕組みがあります。今回はgroupcolorという値を設定することで、色を設定するボタンの仕組みを連動しています。

2.に関してはToggleButtonstateがボタンの状態を設定しています。
以下のコードのようにPythonファイル内でボタンの状態(state)がOFF(nomarl)の場合のみ状態が変更するようにしています。

class ColorButton(ToggleButton):
    def _do_press(self):

        if self.state == 'normal':
            # ボタンを押されてない場合は状態を変更する
            ToggleButtonBehavior._do_press(self)

③ボタンを押すと各色に描画色を設定する

pythonないで各色に変更しています。
コードとしては描画色に設定するさいにlast_colorにセットする描画色
を格納します。
こうすることでcanvasをクリアした際に描画する色をセットすることができます。

class MyPaintWidget(Widget):
    last_color = '' # 画面クリアを押された場合の最後の色

    def set_color(self, new_color):
        ''' 塗る色を変更する '''
        self.last_color = new_color
        self.canvas.add(Color(*new_color))

スライダーで線の太さを変更する

6_説明3.jpg

kvファイル

<MyCanvasWidget>:
        BoxLayout:
            orientation: 'horizontal'
            size_hint_y: 0.1
            Label:
                size_hint_x: 0.1
                text: 'Line width %s' % int(s1.value) if s1.value else 'Line width not set'
                color: 0,0,0,1
            Slider:
                id: s1
                size_hint_x: 0.9
                value: 3
                range: (1,100)
                step: 1
                on_touch_down:app.painter.ids['paint_area'].set_line_width(self.value)

pythonファイル

class MyPaintWidget(Widget):
    line_width = 3  # 線の太さ

    def set_line_width(self, line_width=3):
        self.line_width = line_width

スライダーの値と線の太さを連動しています。

画面の色について

    Window.clearcolor = get_color_from_hex('#ffffff')   # ウィンドウの色を白色に変更

画面の色に関してはmain.py内でウィンドウの色を白色に変更しています。

保存ボタンでスクリーンショットをとる

class MyPaintApp(App):

    def save_canvas(self):
        # 時間があるときに一時的にcanvas.beforeに背景を塗り潰す処理を加える
        # https://kivy.org/docs/api-kivy.core.window.html?highlight=screenshot#kivy.core.window.WindowBase.screenshot
        Window.screenshot();    # スクリーンショットを保存する
        #self.painter.export_to_png('a.png')    # 画像を保存する ただしこのやり方だとウィンドウカラーが適用されないので描いていない部分が透明になる

saveボタンでウィンドウのスクリーンショットを取得しています。
export_to_png()では描かれていない部分が透明になるため今回は使用していないです。
export_to_png()の実行前で一時的に色を塗りつぶす処理を追加するか、起動時にwidget全体を背景色で塗りつぶす処理を加えると状況は改善すると思いますが今回は試していません。

Kivy Lancherでアンドロイド端末で動かす

Google PlayからKivy Lancher ( https://play.google.com/store/apps/details?id=org.kivy.pygame&hl=ja )をインストールします。

android.txt に以下を記載して アンドロイド端末内にファイルを転送します

title=Paint app
author=<auther name>
orientation=landscape

詳しくは以下の使い方を参考にしてください。

Python Kivyの使い方④ ~Androidでの実行~(
https://qiita.com/dario_okazaki/items/4f6373051afb70b794d9 )

まとめ

今回の内容で、ある程度ペイントソフトとしての機能が実装できたかと思います。
機能として追加するとしたら以下のようになるかと思います。

  • 背景を画像に読み込む
  • カスタマイズした色を加える
  • アンドウ、リドゥの機能を加える etc

Kivyでどの程度のペイントソフトができるかは以下のアプリがGoogle Play Storeに以下のアプリがありお勧めです。

Spring Paint( https://play.google.com/store/apps/details?id=ek.myspaint )

また今回のコードですが、公式のチュートリアルにかなり手を加えたのでコードでしたらほぼ別物に近いです。
公式のPaint Appですがpythonコードのみで作成されており、少ないコードでマルチタッチのペイントソフトができるということを売りにしているみたいですがそれにしても機能が少なすぎます。またkivyのcanvasはAPIリファレンスの完成度が低いのもあり個人的には公式のチュートリアルとしてはあまり出来が良くないと思っています。

参考:
APIリファレンス Canvas( https://kivy.org/docs/api-kivy.graphics.instructions.html )
(翻訳,要約) Kivyのcanvasに関して知っておくべき10の事 (https://qiita.com/gotta_dive_into_python/items/edf5574f83f422de657c)

Pong Gameの拡張

概要

Kivyの公式チュートリアルに紹介している、Pong Game( https://kivy.org/docs/tutorials/pong.html )を拡張します。

日本語の翻訳記事は以下( https://pyky.github.io/kivy-doc-ja/tutorials/pong.html )になります。

もとの内容の説明

1_pong.jpg

もとのアプリは起動するとすぐに、ゲームが開始されます。
ボールが動きだして左右のラケットを上下に動かしてボールを打ち返します。
打ち返しに失敗して左右の画面端にボールが行くと点数が加算されて表示されます。
ゲームオーバなどはありません。ひたすら左右のラケットを自分で動かしてボールが打ち返すだけです。

改造して最終的に完成するアプリ

改造して、完成するアプリは以下の通りです

スタート画面
2_game_start.jpg

起動直後にスタート画面が表示されます。STARTボタンをクリックするとゲーム画面が表示されます。ENDボタンをクリックするとゲームを終了します。

ゲーム画面
3_game_window1.jpg

「START GAME」ボタンをクリックするとゲームが開始します。

4_game_window2.jpg

ゲームが進めます。
どちらかが5点先取すると勝利画面に自動で遷移します

勝利画面
5_game_window3.jpg3_game_window1.jpg

追加した内容は以下になります。

① ゲーム画面に画像を追加する
②ゲーム画面にスタートボタン/リセットボタンを追加する
③ゲーム画面にスタート画面を追加する
④勝利画面を追加して5点とったら自動で移動する
⑥config.iniを使用して起動直後の画面サイズを設定する

ファイルについて

Githubに配置しています

https://github.com/okajun35/Kivy_pongame_expansion

ファイルの構造

最終的に完成するコード

│  config.ini
│  main.py
│  pong.kv
│
└─image
        background.jpg
        ball01_01.png
        paddle1.png
        paddle2.png
        player1_win.jpg
        player2_win.jpg
        start_background.jpg

main.py

import kivy
kivy.require('1.1.1')

from kivy.app import App
from kivy.uix.widget import Widget
from kivy.properties import NumericProperty, ReferenceListProperty,\
    ObjectProperty
from kivy.vector import Vector
from kivy.clock import Clock

from kivy.config import Config
Config.read("config.ini")

from kivy.uix.screenmanager import ScreenManager, Screen

screen_manager = ScreenManager()



class StartMenu(Screen):
    ''' スタートメニュー '''
    pass


class PonGameWindow(Screen):
    ''' ゲーム画面 '''    
    pass


class Player1Win(Screen):
    pass

class Player2Win(Screen):
    pass


class PongPaddle(Widget):
    score = NumericProperty(0)

    def bounce_ball(self, ball):
        if self.collide_widget(ball):
            vx, vy = ball.velocity
            offset = (ball.center_y - self.center_y) / (self.height / 2)
            bounced = Vector(-1 * vx, vy)
            vel = bounced * 1.1
            ball.velocity = vel.x, vel.y + offset


class PongBall(Widget):
    velocity_x = NumericProperty(0)
    velocity_y = NumericProperty(0)
    velocity = ReferenceListProperty(velocity_x, velocity_y)

    def move(self):
        self.pos = Vector(*self.velocity) + self.pos

class PongGame(Widget):

    def __init__(self, screen_manager=None):
        super(PongGame, self).__init__()
        self.screen_manager = screen_manager


    ball = ObjectProperty(None)
    player1 = ObjectProperty(None)
    player2 = ObjectProperty(None)

    def serve_ball(self, vel=(4, 0)):
        self.ball.center = self.center
        self.ball.velocity = vel

    def update(self, dt):
        self.ball.move()

        # bounce ball off paddles
        self.player1.bounce_ball(self.ball)
        self.player2.bounce_ball(self.ball)

        # bounce ball off bottom or top
        if (self.ball.y < self.y) or (self.ball.top > self.top):
            self.ball.velocity_y *= -1

        # went off a side to score point?
        if self.ball.x < self.x:
            self.player2.score += 1
            self.serve_ball(vel=(4, 0))
        if self.ball.x > self.width:
            self.player1.score += 1
            self.serve_ball(vel=(-4, 0))

        # 得点チェックして5点を超えたらゲーム終了
        pong_score = 5
        if self.player1.score >= pong_score:
            self.serve_ball(vel=(0, 0))
            self.player1.score = 0
            self.player2.score = 0
            self.screen_manager.current = "winner_player_1"
            return
        elif self.player2.score >= pong_score:
            self.serve_ball(vel=(0, 0))
            self.player1.score = 0
            self.player2.score = 0
            self.screen_manager.current = "winner_player_1"
            return

    def on_touch_move(self, touch):
        if touch.x < self.width / 3:
            self.player1.center_y = touch.y
        if touch.x > self.width - self.width / 3:
            self.player2.center_y = touch.y

    def start_pong(self):
        #self.remove_widget(self.ids['start_button'])
        self.remove_widget(self.ids.start_button)
        self.ids.reset_button.center_y = 20

        self.serve_ball()
        Clock.schedule_interval(self.update, 1.0 / 60.0)

    def reset_pong(self):
        self.serve_ball()

        self.player1.score = 0
        self.player2.score = 0

class PongApp(App):
    def build(self):
        #game = PongGame()
        #game.serve_ball()
        #Clock.schedule_interval(game.update, 1.0 / 60.0)

        pong = PongGame(screen_manager=screen_manager)
        pon_game = PonGameWindow(name="pon")

        pon_game.add_widget(pong)


        screen_manager.add_widget(StartMenu(name='start'))
        screen_manager.add_widget(pon_game)
        screen_manager.add_widget(Player1Win(name='winner_player_1'))
        screen_manager.add_widget(Player2Win(name='winner_player_2'))


        return screen_manager
       # return game

    def reset_game(self):
        print("reset")

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

pong.kv

#:kivy 1.0.9

<PongBall>:
    size: 50, 50 
    canvas:
        Ellipse:
            source: 'image/ball01_01.png'
            pos: self.pos
            size: self.size          

<PongPaddle>:
    size: 25, 200
    canvas:
        Rectangle:
            source: 'image/paddle1.png'
            pos:self.pos
            size:self.size

<PongGame>: #ゲーム画面
    ball: pong_ball
    player1: player_left
    player2: player_right

    canvas:
        Rectangle:
            source: 'image/background.jpg' #背景を変更 
            pos: 0, 0
            size: self.width, self.height

    Label:
        font_size: 20  
        center_x: root.width / 4
        top: root.top
        text: "Player1"

    Label:
        font_size: 70  
        center_x: root.width / 4
        top: root.top - 50
        text: str(root.player1.score)

    Label:
        font_size: 20  
        center_x: root.width * 3 / 4
        top: root.top

        text: "Player2"

    Label:
        font_size: 70  
        center_x: root.width * 3 / 4
        top: root.top - 50
        text: str(root.player2.score)

    PongBall:
        id: pong_ball
        center: self.parent.center

    PongPaddle:
        id: player_left
        x: root.x
        center_y: root.center_y

    PongPaddle:
        id: player_right
        canvas:
            Rectangle:
                source: 'image/paddle2.png'
                pos:self.pos
                size:self.size
        x: root.width-self.width
        center_y: root.center_y

    Button:
        id: start_button
        size: 200, 50
        #background_normal: 'img/iniciar-btn-normal.png'
        #background_down: 'img/iniciar-btn-pressed.png'
        text: 'START GAME'
        center_x: root.width/2
        center_y: root.height/2
        on_press:
            #root.remove_btn(self)
            root.start_pong()
            #reinicia_jogo_btn.center_x = 1 * root.width/4
            #menu_btn.center_x = 3 * root.width/4

    Button:
        id: reset_button
        size: 200, 50
        text: 'RESET'
        center_x: root.width/2
        center_y: root.height + 50
        on_press:root.reset_pong()
        size_hint_y: None

<StartMenu>: # メニュー(スタート画面)
    canvas:
        Rectangle:
            source: 'image/start_background.jpg' # スタート画面を出す
            size: self.width, self.height   # 画面サイズに合わす

    Button: #スタートボタン
        size_hint: 0.2, 0.1
        center_x: root.center_x - self.width # 真ん中 -画像の幅
        center_y: root.center_y # 画面真ん中
        text: 'START'
        on_release:
            root.manager.transition.direction = 'left' 
            root.manager.current = 'pon'

    Button: # 終了ボタン
        size_hint: 0.2, 0.1
        center_x: root.center_x + self.width
        center_y: root.center_y
        text: 'END'
        on_press: app.stop() # アプリ終了

<Player1Win>:
    canvas:
        Rectangle:
            source: 'image/player1_win.jpg'
            size: self.width, self.height
    Button:
        size_hint: 0.2, 0.1
        center_x: root.center_x
        center_y: root.center_y -root.height/10 * 4
        text: 'GO START'
        on_press:
            root.manager.current = "start"

<Player2Win>:
    canvas:
        Rectangle:
            source: 'image/player2_win.jpg'
            size: self.width, self.height
    Button:
        size_hint: 0.2, 0.1
        center_x: root.center_x
        center_y: root.center_y -root.height/10 * 4
        text: 'GO START'
        on_press:
            root.manager.current = "start"

config.ini

[graphics]
resizable = 0
width = 1024
height = 768

ゲーム画面に画像を追加する

今回は、ボール、ラケット、配置します。
基本的な方法は、どれも同じで、canvasで各widgetと同じ大きさの矩形(ボールの場合は円)を描き、その矩形のに画像を配置します。

6_paddle1.png

ラケットを配置する例は以下の通りです。

pong.kv
<PongPaddle>:
    size: 25, 200
    canvas:
        Rectangle: ・・・矩形を描きます
            source: 'image/paddle1.png' ・・・画像を配置します。
            pos:self.pos ・・・描画の開始位置をwidgetと同じにします
            size:self.size ・・・描画する図形の大きさをwidgetと同じにします

ゲーム画面にスタートボタン/リセットボタンを追加する

3_game_window1.jpg
スタートボタン

4_game_window2.jpg
リセットボタン

該当のコードは以下の通りです

pong.kv
<PongGame>: #ゲーム画面
    ball: pong_ball
    player1: player_left
    player2: player_right

    Button: スタートボタン
        id: start_button
        size: 200, 50
        text: 'START GAME'
        center_x: root.width/2
        center_y: root.height/2
        on_press:
            root.start_pong()

    Button: リセットボタン
        id: reset_button
        size: 200, 50
        text: 'RESET'
        center_x: root.width/2
        center_y: root.height + 50  ・・・画面外にボタンを配置する
        on_press:root.reset_pong()

main.py
class PongGame(Widget):

    def start_pong(self):
        #self.remove_widget(self.ids['start_button'])
        self.remove_widget(self.ids.start_button) ・・・ スタートボタンを画面から削除する
            self.ids.reset_button.center_y = 20 ・・・リセットボタンを画面内に移動する

        self.serve_ball()
        Clock.schedule_interval(self.update, 1.0 / 60.0) ・・・画面を更新する

    def reset_pong(self):
        self.serve_ball()

        self.player1.score = 0
        self.player2.score = 0

ここで注目するべきは2点あります。

①リセットボタンの位置について
schedule_interval()start_pong()内に記述する

①リセットボタンの位置について

リセットボタンの座標ですがcenter_yroot.height + 50を設定しています。root.heightは画面の高さです。これに50を足して画面外にボタンを設定して画面内に表示されないようにしています。スタートボタンを押してstart_pong()を実行したした際に、remove_widget()でスタートボタンを削除して、リセットボタンの座標ですが center_yに20を設定して画面右下に表示しています。
なぜこのような動作にしたかというとkivyにはadd_widget()を使用してwidetを使用できますが add_widget()を使うとボタンなどの挙動が必要なものは同時にイベントをバインドしないと動作しないのですが、その手間を省きたかったためです。
画面外にボタンを表示せずに画面外に逃がして、必要な場合だけボタンを画面に表示するようにしました。

②「schedule_interval()」をstart_pong()内に記述する

公式のチュートリアルだと画面を更新するschedule_interval()PongApp()クラスのbuild()関数に記述しています。この内容ですとアプリが起動した直後にゲームが始まってしまいます。そのために今回はstart_pong()内に記述してゲーム画面に遷移した直後はゲームが開始されていない状態を作っています。

ゲーム画面にスタート画面を追加する

Kivyのscreen manegerを使用して画面を複数作成しています。

該当のコードは以下の通りです

pong.kv
<PongGame>: #ゲーム画面


<StartMenu>: # メニュー(スタート画面)
    Button: #スタートボタン
        size_hint: 0.2, 0.1
        center_x: root.center_x - self.width # 真ん中 -画像の幅
        center_y: root.center_y # 画面真ん中
        text: 'START'
        on_release:
            root.manager.transition.direction = 'left'  ・・・移動時に左に移動するように設定する。
            root.manager.current = 'pon'      ・・・ponの画面に移動する

<Player1Win>:


<Player2Win>:

main.py
from kivy.uix.screenmanager import ScreenManager, Screen

screen_manager = ScreenManager()

class StartMenu(Screen):
    ''' スタートメニュー '''
    pass


class PonGameWindow(Screen):
    ''' ゲーム画面 '''    
    pass

class Player1Win(Screen):
    pass

class Player2Win(Screen):
    pass

class PongGame(Widget):

    def __init__(self, screen_manager=None):
        super(PongGame, self).__init__()
        self.screen_manager = screen_manager

class PongApp(App):
    def build(self):

        pong = PongGame(screen_manager=screen_manager)
        pon_game = PonGameWindow(name="pon") ・・・nameを設定する

        pon_game.add_widget(pong)・・PonGameWindowpon gameを設定します。


        screen_manager.add_widget(StartMenu(name='start'))
        screen_manager.add_widget(pon_game)
        screen_manager.add_widget(Player1Win(name='winner_player_1'))
        screen_manager.add_widget(Player2Win(name='winner_player_2'))

screen manegerですが、複数の画面(スライド)を作れますが。追加できるのはscreenクラスのみとなります。そのため、PongGameクラスをscrrenクラスの子クラスであるpon_game()クラスにadd_widget()で追加しています。
また、

screen_manager.add_widget(StartMenu(name='start'))

StartMenuで起動時のスライドを指定できます。今回はstart=StartMenu()を指定できます。

参考:screen manager
(公式リファレンス) https://kivy.org/docs/api-kivy.uix.screenmanager.html
(翻訳(途中)) https://pyky.github.io/kivy-doc-ja/api-kivy.uix.screenmanager.html

勝利画面を追加して5点とったら自動で移動する

該当のコードは以下の通りです

main.py
class PongGame(Widget):
    def update(self, dt):
        # 得点チェックして5点を超えたらゲーム終了
        pong_score = 5
        if self.player1.score >= pong_score:
            self.serve_ball(vel=(0, 0))
            self.player1.score = 0
            self.player2.score = 0
            self.screen_manager.current = "winner_player_1"
            return
        elif self.player2.score >= pong_score:
            self.serve_ball(vel=(0, 0))
            self.player1.score = 0
            self.player2.score = 0
            self.screen_manager.current = "winner_player_1"

画面を更新するごとに、player1とplayer2の得点を判定して5点以上になった場合は以下の処理を実行します。
ボールの位置を画面真ん中に戻します
player1とplayer2のscore(点数)を0に設定します。
勝利画面(winner_player_1 or winner_player_2)に遷移します。

config.iniを使用して起動直後の画面サイズを設定する

該当のコードは以下の通りです。

main.py
from kivy.config import Config
Config.read("config.ini")
config.ini
[graphics]
resizable = 0
width = 1024
height = 768

詳しくは参考のリンク先を見ていただきたいですが、Kivyは設定を行う画面の解像度など初期値を設定設定をconfig.iniに記載して管理できます。本来はインストール先( "C:\Users<ユーザ名.kivy *" )にありますが、そこにあるファイルの内容を変えてしまうと、次回起動時の設定のすべてがデフォルトと変わってしまうので、今回用のconfig.iniを作成して、それを読み込むことで実行を行っています。なおconfig.iniの読み込みは極力コードの最初の方で行ってください。
if __name__ == '__main__':の中で行ったりすると既に設定値がデフォルトのconfig.iniを読み込んでいるので反映されません。

参考:Python Kivyの使い方① ~Kv Languageの基本~ 15.起動時の解像度を変更およびパラメータの使いまわしについて: https://qiita.com/dario_okazaki/items/7892b24fcfa787faface#15%E8%B5%B7%E5%8B%95%E6%99%82%E3%81%AE%E8%A7%A3%E5%83%8F%E5%BA%A6%E3%82%92%E5%A4%89%E6%9B%B4%E3%81%8A%E3%82%88%E3%81%B3%E3%83%91%E3%83%A9%E3%83%A1%E3%83%BC%E3%82%BF%E3%81%AE%E4%BD%BF%E3%81%84%E3%81%BE%E3%82%8F%E3%81%97%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6

まとめ

今回の内容で、見た目に関しては通常のゲームに近いものができたかと思います。
もう少し改良して通常のゲームのようにするには以下の機能の追加が必要かと思います。

  • 音楽を鳴らす 
  • ラケットを打ち返したさい、壁にボールが当たった際に効果音を追加する
  • 打ち返したさいに加速度をつける
  • ラケット1のみユーザーが操作してラケット2は自動で制御して打ち返す

また、今回直していないですが、「勝利画面に行った後にスタート画面にもどってスタートボタンを押したときにラケットの位置が元に戻っていないなど完全なスタート画面に戻っていない」
という内容があります。
完全なゲームを作るのには細かい機能追加とバグの改修が必要かと思いますが、今回の内容の延長にある内容なので今回の内容が理解できればさほど苦労しないかと思います。

また今回のコードはチュートリアルのコードに基本は追加という形になっています。Pong Gameはチュートリアルとしては拡張性のあるよいコードだと個人的には思いますが、もう少し踏み込んで今回の内容あたりまでを紹介すればと思っています。