torinaブログ

DjangoとBootstrap4で作成したブログ
Python, Django, Kivy, Bootstrap, Apache等のメモです
ソースコード

Kivyで、シンプルなシューティングゲーム④

Python Kivy Kivy
2016年11月18日22:14
Kivyで、シンプルなシューティングゲーム③
https://torina.top/main/311/

の続きです。

起動すると、まずこのようなスタート画面です。Stage01ボタンと、Stage02ボタンがあります。


ボタンを押すと、以前のシューティング画面に移ります。
Stage01もStage02も、とりあえず同じゲームです。


ディレクトリの構成はこのようになりました。


libパッケージ...
前に作ったwidget.pyです。


前からほとんど変更はありません。
from kivy.clock import Clock
from kivy.properties import (
    NumericProperty,
    ObjectProperty,
    ReferenceListProperty,
    StringProperty,
)
from kivy.uix.image import Image
from kivy.uix.widget import Widget
from kivy.vector import Vector


class ShootingWidget(Image):
    """シューティングゲームのウィジェット、基底クラス"""

    # updateが何回呼ばれたか
    update_count = NumericProperty(0)

    # ヒットポイント
    hitpoint = NumericProperty(100)

    # キャラ画像
    source = StringProperty('')

    # 与える経験値
    point = NumericProperty(0)

    # 獲得したスコア
    score = NumericProperty(0)

    # 幅、高さ
    width = NumericProperty(100)
    height = NumericProperty(100)

    def update(self):
        """1秒間に60回呼ばれる更新処理。必要があればサブクラスで上書きすること"""

        pass

    def attack(self, target):
        """攻撃処理

        対象のヒットポイントを減らし、0以下になれば対象のdestroyを呼びます。
        また、自分のスコアに対象の経験値を加算します
        """

        target.hitpoint -= self.hitpoint
        if target.hitpoint <= 0:
            self.score = target.point
            target.destroy()

    def destroy(self):
        """ウィジェットの死亡処理

        create_fireで燃えカスを同じ場所に生成し、
        removeを呼び出し自分を画面から消去する
        """

        self.create_fire()
        self.remove()

    def create_fire(self):
        """燃えカスを生成する"""

        fire = Fire(pos=self.center)
        self.parent.add_widget(fire)    

    def remove(self):
        """自分を画面から消去する

        燃えカスを生成したくない場合や、ウィジェットが削除される前に何かの処理を挟みたい場合に
        上書きしてください。
        self.parent.remove_widgetの後に、
        self.parentを参照しないようにしてください。self.parentはNoneになっています
        """

        self.parent.remove_widget(self)


class Shot(ShootingWidget):
    """弾"""

    source = StringProperty('shot.png')
    point = NumericProperty(50)

    # 自分を発射したウィジェット
    shooter = ObjectProperty(None)

    # 速度
    velocity_x = NumericProperty(0)
    velocity_y = NumericProperty(10)
    velocity = ReferenceListProperty(velocity_x, velocity_y)

    def update(self):

        self.pos = Vector(*self.velocity) + self.pos

        # 弾が画面の上まで達したら消す
        if self.top > self.parent.top:
            self.remove()

        # 弾が画面の下まで達したら消す
        elif self.y < self.parent.y:
            self.remove()

        # 弾の通常処理
        else:
            # 画面内のウィジェットを取得する
            for widget in self.parent.children:

                # 重なっていればダメージを与え、自分を消去
                if self.is_collide(widget):
                    self.attack(widget)
                    self.remove()
                    break

    def is_collide(self, widget):
        """他のウィジェットと重なっていればTrue

        自分自身や、自分を発射したウィジェット、Fire(燃え後)ウィジェットを除き
        重なっていればTrueを返す
        """

        if widget is self:
            return False
        elif widget is self.shooter:
            return False
        elif isinstance(widget, Fire):
            return False
        elif not self.collide_widget(widget):
            return False
        return True

    def remove(self):
        """自分を画面から消去

        弾に溜まったスコアをシューターに還元する処理を追加するため、オーバーライド
        """

        self.shooter.score += self.score
        self.parent.remove_widget(self)


class Player(ShootingWidget):
    """プレイヤー"""

    source = StringProperty('player.png')
    hitpoint = NumericProperty(1000)
    point = NumericProperty(1000)
    width = NumericProperty(100)
    height = NumericProperty(100)

    def on_touch_move(self, touch):
        """タッチしたまま移動でプレイヤー移動"""

        if 0 < touch.x < self.parent.width:
            self.center_x = touch.x

    def on_touch_down(self, touch):
        """画面タッチで弾発射"""

        shot = Shot(shooter=self)

        # 弾をウィジェットの中央に配置
        x = self.center_x - shot.width / 2
        y = self.center_y
        shot.pos = x, y
        self.parent.add_widget(shot)


class Enemy(ShootingWidget):
    """敵キャラ"""

    source = StringProperty('enemy.png')
    point = NumericProperty(100)
    width = NumericProperty(50)
    height = NumericProperty(50)
    
    # 速度
    velocity_x = NumericProperty(0)
    velocity_y = NumericProperty(-1)
    velocity = ReferenceListProperty(velocity_x, velocity_y)

    def update(self):
        """敵キャラの移動処理"""

        self.update_count += 1
        self.pos = Vector(*self.velocity) + self.pos

        # 1秒ごとに弾発射
        if self.update_count % 60 == 0:
            self.shoot()

        # 画面の下まで到達したら、消える
        if self.y < self.parent.y:
            self.remove()

    def shoot(self):
        """弾を発射する"""

        # velocityを-にし、下方向へ発射
        shot = Shot(velocity_y=-5, shooter=self)

        # 弾をウィジェットの中央に配置
        x = self.center_x - shot.width / 2
        y = self.center_y - shot.height
        shot.pos = x, y
        self.parent.add_widget(shot)


class Fire(ShootingWidget):
    """燃え跡"""

    source = StringProperty('fire.png')
    width = NumericProperty(50)
    height = NumericProperty(50)

    def update(self):
        self.update_count += 1
        # 1秒経ったら、消える
        if self.update_count >= 60:
            return self.remove()


stage01ディレクトリ。このように、ステージ毎にディレクトリを用意するようにしました。
制約として、pyファイルとkvファイルの名前は「main」を使うようにします。
ここにassetsだとかstaticというディレクトリを作り、ステージ毎に管理したかったのですが、今回はやめました。


main.py
これも前と大体同じです。
違いは、build関数内で行っていたClock.schedule...をstartというメソッドにまとめ呼び出すようにし
これを解除するためのendというメソッドも作成しました。
updateでは、ある条件..今回だとスコアが100越えたらendを呼び出し終了する、という流れです。
endの最後の
self.parent.parent.parent.screen_manager.current = 'top'
は、トップ画面に戻すという意味です。後で説明します。
制約があり、クラス名をStage01としています。ディレクトリ名と対応している形です。
from random import randint
from kivy.app import App
from kivy.clock import Clock
from kivy.properties import ObjectProperty
from kivy.uix.boxlayout import BoxLayout
from lib.widget import Player, Enemy


class Stage01(BoxLayout):
    """ルートウィジェット"""

    main = ObjectProperty(None)
    menu = ObjectProperty(None)

    def update(self, dt):
        """1秒に60回呼ばれるゲーム更新処理"""

        for widget in self.main.children:
            widget.update()
        # print(len(self.main.children))

        if self.player.score >= 100:
            self.end()

    def create_enemy(self, dt):
        """1秒毎に呼ばれる、敵キャラ作成処理"""

        # 敵キャラのx座標をランダムに。y座標は固定、一番上
        x = randint(0, self.main.width)
        y = self.main.top

        # 敵の生成処理
        enemy = Enemy(pos=(x, y))
        self.main.add_widget(enemy)

    def start(self):
        Clock.schedule_interval(self.update, 1.0 / 60.0)
        Clock.schedule_interval(self.create_enemy, 1.0)

    def end(self):
        Clock.unschedule(self.update)
        Clock.unschedule(self.create_enemy)
        self.parent.parent.parent.screen_manager.current = 'top'


class ShootingApp(App):

    def build(self):
        game = Stage01()
        game.start()
        return game


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


main.kv
ほぼ変更なしです。こちらも<Stage01>のようにしました。
<Stage01>:
    player: player
    main: main
    menu: menu
    orientation: 'horizontal'
 
    Widget:
        id: main
        size_hint: .7, 1
 
        canvas:
            Rectangle:
                pos: self.pos
                size: self.size
                source: 'background.jpg'
 
        Player:
            id: player
            pos: main.center_x - self.width/2, 0
 
    BoxLayout:
        id: menu
        size_hint: .3, 1
        orientation: 'vertical'
 
        canvas:
            Color:
                rgba: 0, 0, .4, 1
            Rectangle:
                pos: self.pos
                size: self.size
 
        Label:
            text: 'HITPOINT {}'.format(str(player.hitpoint))
 
        Label:
            text: 'SCORE {}'.format(str(player.score))


stage02ディレクトリも、Stage01がStage02に変わっただけの同じものです。

プロジェクト直下のmain.py
import importlib
import os
from kivy.app import App
from kivy.lang import Builder
from kivy.properties import ObjectProperty
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.screenmanager import Screen
from kivy.uix.button import Button


# このファイルがあるディレクトリのパスを取得
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))

# stage01 stage02 などのディレクトリ名が入っていく
STAGE_DIRS = []

# 同じ階層にあるstage01、stage02等のディレクトリ名を取得する
for entry in os.listdir(PROJECT_ROOT):
    path = os.path.join(PROJECT_ROOT, entry)

    # stage から始まっている名前で、ディレクトリならば...
    if entry.startswith('stage') and os.path.isdir(path):
        STAGE_DIRS.append(entry)

"""ワンライナー
STAGE_DIRS = [dr for dr in os.listdir(PROJECT_ROOT)
              if os.path.isdir(os.path.join(PROJECT_ROOT, dr))
              if dr.startswith('stage')]
"""

# ソートし、stage01, stage02, stage03...のように入れ替える
STAGE_DIRS.sort()


# 各stageディレクトリの、main.kvとmain.pyを読み込む
for stage in STAGE_DIRS:

    # kvファイルロード
    kvfile_path = os.path.join(PROJECT_ROOT, stage, 'main.kv')
    Builder.load_file(kvfile_path)

    # stage01 → Stage01に変換。クラス名の形式に
    stage_class_name = stage.title()

    # stage01/main モジュールを読み込む
    module = importlib.import_module(stage + '.main')

    # このモジュールのグローバルなスコープに、上のモジュールからクラスをロード
    globals()[stage_class_name] = getattr(module, stage_class_name)


class Top(BoxLayout):
    """最初に表示されるトップ画面"""

    pass


class ShootingGame(BoxLayout):
    """ルートウィジェット"""

    stage_buttons = ObjectProperty()
    screen_manager = ObjectProperty()

    def __init__(self, **kwargs):
        super().__init__(**kwargs)

        # とりあえずScreenManagerのカレントをトップに
        self.screen_manager.current = 'top'

        for stage in STAGE_DIRS:
            # stage01 等のボタンを作成
            button = Button(text=stage, size_hint=(.2, 1))
            button.bind(on_press=self.select_stage)
            self.stage_buttons.add_widget(button)

            # ステージをScreenにadd_widgetする。
            # 具体的にはstage01のmain.pyのStage01クラス等
            screen = Screen(name=stage)
            stage_class = globals()[stage.title()]()
            screen.add_widget(stage_class) 
            self.screen_manager.add_widget(screen)

    def select_stage(self, button):
        """ステージ選択ボタン押下時の、ステージ変更処理"""

        # stage01 等の文字列が入る
        stage = button.text

        # カレントスクリーンを変更
        self.screen_manager.current = stage

        # gameにはstage01のmain.pyのStage01クラスインスタンス等が入る
        screen = self.screen_manager.current_screen
        game = screen.children[0]
        game.start()


class ShootingApp(App):

    def build(self):
        return ShootingGame()


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


shooting.kv
<ShootingGame>:
    screen_manager: screen_manager
    stage_buttons: stage_buttons

    orientation: 'vertical'

    StackLayout:
        id: stage_buttons
        orientation: 'lr-tb'
        size_hint: 1, .1


    ScreenManager:
        size_hint: 1, .9
        id: screen_manager
        Screen:
            name: 'top'
            Top:

<Top>:
    Label:
        text: 'Push Stage Button'


まずkvファイルです。
StackLayoutを使い、ステージの数だけボタンが詰め込まれていきます。
動的に、数がいくつになるかわからないような、ボタン等の小さめのウィジェットを追加していくのに便利です。
    StackLayout:
        id: stage_buttons
        orientation: 'lr-tb'
        size_hint: 1, .1


今回はスクリーンマネージャを利用し、各画面に作ったステージを詰め込んでいます。
Screenの中に、Topがあります。Topはpyファイルに書いていますが、BoxLayoutです。
Screenではレイアウトを整えるような機能は難しいですが、BoxLayoutやFloatLayoutといったレイアウトをネストさせることで、柔軟に調節できます。
    ScreenManager:
        size_hint: 1, .9
        id: screen_manager
        Screen:
            name: 'top'
            Top:


これがそのTopです。とりあえずラベルがあるだけです。
<Top>:
    Label:
        text: 'Push Stage Button


次にmain.pyです。
流れとしては、
・stage01、stage02ディレクトリを取得
・その中の、main.kvファイルをロード
・main.pyの中のStage01等のルートウィジェットとなるクラスも取得
・stage01等のボタンを生成
・screenも生成。中身はStage01()やStage02()を入れていく
という流れになります。

これはよく見る、ファイルがあるディレクトリのパスを取得しています。
# このファイルがあるディレクトリのパスを取得
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))


コメントのとおりです。
# stage01 stage02 などのディレクトリ名が入っていく
STAGE_DIRS = []

# 同じ階層にあるstage01、stage02等のディレクトリ名を取得する
for entry in os.listdir(PROJECT_ROOT):
    path = os.path.join(PROJECT_ROOT, entry)

    # stage から始まっている名前で、ディレクトリならば...
    if entry.startswith('stage') and os.path.isdir(path):
        STAGE_DIRS.append(entry)


ワンライナーで書くと、こんな感じです。
ただ、明らかに長くてわかりづらいですし、内包表記の方が早いとは言え、パフォーマンスを気にする処理でもないでしょう。
forループで良いと思います。Effective Pythonにも、リスト内包表記には3つ以上の式を避けるってありましたし...
ちなみにですが、内包表記であればif文を複数書く場合、andと書かなくても暗黙的にandです。 x for x in my_list if x if y みたいな感じですね。
STAGE_DIRS = [dr for dr in os.listdir(PROJECT_ROOT)
              if os.path.isdir(os.path.join(PROJECT_ROOT, dr))
              if dr.startswith('stage')]


ソートしておきます。
# ソートし、stage01, stage02, stage03...のように入れ替える
STAGE_DIRS.sort()



これは説明が必要でしょう。
# 各stageディレクトリの、main.kvとmain.pyを読み込む
for stage in STAGE_DIRS:

    # kvファイルロード
    kvfile_path = os.path.join(PROJECT_ROOT, stage, 'main.kv')
    Builder.load_file(kvfile_path)

    # stage01 → Stage01に変換。クラス名の形式に
    stage_class_name = stage.title()

    # stage01/main モジュールを読み込む
    module = importlib.import_module(stage + '.main')

    # このモジュールのグローバルなスコープに、上のモジュールからクラスをロード
    globals()[stage_class_name] = getattr(module, stage_class_name)



stageには、stage01、stage02といった名前が渡されます。
os.path.joinでstage01などのディレクトリからmain.kvファイルのパスを取得し、
Builder.load_fileでkvファイルをロードします。複数のkvファイルのロードには、この関数が便利です。
    # kvファイルロード
    kvfile_path = os.path.join(PROJECT_ROOT, stage, 'main.kv')
    Builder.load_file(kvfile_path)


title()で先頭を大文字にします。これはstageディレクトリ内のmain.pyのルートウェジェットの名前に変換しています。
importlib.import_moduleで、文字列からモジュールをロードすることができます。
その後、グローバル空間にそのクラスを登録する、という流れです。
    # stage01 → Stage01に変換。クラス名の形式に
    stage_class_name = stage.title()

    # stage01/main モジュールを読み込む
    module = importlib.import_module(stage + '.main')

    # このモジュールのグローバルなスコープに、上のモジュールからクラスをロード
    globals()[stage_class_name] = getattr(module, stage_class_name)


これはトップ画面です。何もいう事はない
class Top(BoxLayout):
    """最初に表示されるトップ画面"""

    pass


ルートウィジェットのShootingGameです。
まず動的に生成するボタンとスクリーンの参照を定義します。
class ShootingGame(BoxLayout):
    """ルートウィジェット"""

    stage_buttons = ObjectProperty()
    screen_manager = ObjectProperty()


__init__内で、まずカレントスクリーンをtopにします。
その後、ボタンの生成とスクリーンの生成です。
ScreenManagerの中のScreenの中にStage01等のルートウィジェットが入るため、
Stage01からカレントスクリーンを変更するには
self.parent.parent.parent.screen_manager.current = 'top'
のようにする必要がありました。ここら辺も何とかしたいですね。
    def __init__(self, **kwargs):
        super().__init__(**kwargs)

        # とりあえずScreenManagerのカレントをトップに
        self.screen_manager.current = 'top'

        for stage in STAGE_DIRS:
            # stage01 等のボタンを作成
            button = Button(text=stage, size_hint=(.2, 1))
            button.bind(on_press=self.select_stage)
            self.stage_buttons.add_widget(button)

            # ステージをScreenにadd_widgetする。
            # 具体的にはstage01のmain.pyのStage01クラス等
            screen = Screen(name=stage)
            stage_class = globals()[stage.title()]()
            screen.add_widget(stage_class) 
            self.screen_manager.add_widget(screen)


そして、ボタンが押されたときの関数です。
カレントスクリーンを変更し、Stage01等のクラスのインスタンスを取得し、start()を呼び出します。これでゲームが開始されます。
ここも、Screenの中にStage01等がネストしているため、children[0]とする必要があります。
    def select_stage(self, button):
        """ステージ選択ボタン押下時の、ステージ変更処理"""

        # stage01 等の文字列が入る
        stage = button.text

        # カレントスクリーンを変更
        self.screen_manager.current = stage

        # gameにはstage01のmain.pyのStage01クラスインスタンス等が入る
        screen = self.screen_manager.current_screen
        game = screen.children[0]
        game.start()



https://bitbucket.org/toritoritorina/kvgames
にて、今後も開発していく予定です。kivyでゲームを作るためのミニフレームワークにしたいですね。