Ren'Pyで2D RPGを作る(3)―アイテムと道具屋

# 概要
こんにちは、最近ゲーム制作にかまけてエロゲができていないエロゲーマー蟹丸です。Ren'Pyを使った2D RPG制作第三回です。
今回はアイテムと道具屋を作っていきます。

前回：[Ren'Pyで2D RPGを作る(2)―パーティーとステータス](https://qiita.com/salt_crab/items/6d9db298f9bd457ed412)

**本記事の範囲**
アイテムを実装し、メニュー画面からの使用、道具屋での売買を可能にする

# 背景
RPGにどうしてアイテムが必要なんだ！という思いを抱く人はいないと思います。
バトルを作る前にアイテムを作っておけば戦闘中も回復できるようになるので、先にアイテムを作ってしまいます。

# 1. アイテムを作る
まずはアイテムを作る～メニュー画面から使用する部分を実装していきます。

## システム実装
前回と同様こちらはRen'PyではなくただのPythonなので、Pythonがわかる人は自分の好きなようにやってしまえると思います。

※この部分はitem.rpyというファイルのinit pythonステートメント内に書いているものとして読んでください。

まずは`inventory`をdict型で作ります。keyをアイテムのインスタンス、valueを所持アイテム数として使います。ユーザ定義型のインスタンスはhashableなのでdict型のkeyとして使えるんですね。便利です。
普通にPythonコードを書くときはあんまりグローバル変数は使いたくないですが、Ren'Pyはグローバル変数を使ってなんぼなところがあるので容赦なく使います。

```python
inventory = {}
```

次にアイテムの基本となる`Item`クラスを作ります。
インスタンス変数として値段、使用可能フラグなどを定義しておきます。
メソッドは使用時のメッセージを返すgetter、取得・売却時の処理などを定義しています。

```python
class Item():
    def __init__(self, name, desc, price):
        self.name = name
        self.desc = desc
        self.price = price
        self.sell_price = int(price / 2.0)
        self.marketable_flag = True # Falseの場合売ることができない
        self.usable_flag = True # Falseの場合通常時使うことができない
        self.battle_flag = True # Falseの場合戦闘中に使うことができない
        self.icon_image = "images/icons/medical_item.png"

    ### 中略：基本のgetter ###

    def get_confirm_msg(self):
        return self.name + "を使いますか？"

    def get_result_msg(self):
        return self.name + "を使いました"

    # お店での購入
    def buy_item(self, buy_amount = 1):
        party_money.add_value(-self.price * buy_amount)
        inventory[self] += buy_amount

    # お店での売却
    def sell_item(self, sell_amount = 1):
        party_money.add_value(self.sell_price * sell_amount)
        inventory[self] -= sell_amount

    # 拾ったりもらったりしたとき
    def find_item(self, find_amount = 1):
        inventory[self] += find_amount 
```
アイテム種別ごとに`Item`クラスを継承したクラスを作ります。回復アイテム、ステータスアップアイテム、素材アイテム、レシピアイテムなどなど。自分のゲームで必要になるものを実装します。
以下には例として回復アイテムのクラスのコードを載せます。

- 回復アイテムの対象ステータス
- 回復量
- アイテム使用時のメソッド

を新たに定義しています。
アイテム使用時は対象ステータスのインスタンスの`add_current_value(diff)`メソッドを呼び出して回復し、先ほど定義した`inventory`からそのアイテムのvalueを一つ減らします。

```python
# 回復アイテム
class MedicalItem(Item):
    def __init__(self, name, desc, price, target_stat, amount):
        super(MedicalItem, self).__init__(name, desc, price)
        self.target_stat = target_stat
        self.amount = amount

    def get_target_stat(self):
        return self.target_stat

    def get_amount(self):
        return self.amount

    def get_usable_flag(self):
        if self.target_stat.get_current_value() == self.target_stat.get_max_value():
            return False
        else:
            return True

    # 現在値を上げる
    def use_item(self):
        self.target_stat.add_current_value(self.amount)
        inventory[self] -= 1
```

ここまでできたら、以下のようにして`inventory`に所持アイテムを追加します。

```python
medical_hp_small = MedicalItem(name = "すこし苦い薬", desc = "体力を小回復する。", price = 50, target_stat = stat_hp, amount = 40)
inventory[medical_hp_small] = 3
```

これで所持アイテムを管理できるようになりました。

## UI実装
こちらがRen'Py独自のもの。今回実装した結果を先に図で示しておきます。
![SnapCrab_griseo_2020-8-1_18-12-17_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679910/c2b0507e-3afb-75e4-a106-391260f3709a.png)
主だった機能と利用した変数などは以下のようになります。

- メニュー画面にアイテム一覧を表示(`inventory`からvalueが0より大きいkeyを表示)
- 現在使用可能なアイテムと使用不可能なアイテムで色分け(`usable_flag`を利用)
- アイテム名にhoverすると右にアイテム説明が表示される(Ren'Pyのtooltipを利用)
- 使用可能なアイテムをクリックすると道具を使用する(`use_item()`を使用)

それではコードを載せていきます。

```python:screens.rpy
# アイテム画面の設定
screen inventory():

    tag menu

    use game_menu(_("アイテム"), scroll="None"):

        style_prefix "inventory"

        hbox: # ステータス、アイテム、アイテム一覧を横に並べる
            spacing 40
            vbox: 
                ### 中略：ステータススクリーン（前回参照） ###
            
            viewport: # アイテムが多いときにスクロールできるようviewportを使用する
                scrollbars "vertical"
                mousewheel True
                draggable True
                pagekeys True
                xsize 350
                ysize 500
                vbox:
                    label _("アイテム")
                    null height (2 * gui.pref_spacing)
                    for item, amount in inventory.items():
                        if amount > 0:
                            hbox:
                                $item_name = item.get_name()
                                $usable_flag = item.get_usable_flag()
                                $confirm_msg = item.get_confirm_msg()
                                $desc = item.get_desc()
                                $icon_image = item.get_icon_image()
                                add icon_image
                                if usable_flag and amount > 0: # アイテムが使用可能なとき
                                    textbutton _(item_name + " " + str(amount)):
                                        tooltip desc 
                                        action Confirm(confirm_msg, UseItem(item))
                                else: # 使用不可能なとき。insensitiveにするとtooltipが使えないので注意
                                    textbutton _(item_name + " " + str(amount)):
                                        tooltip desc 
                                        action NullAction()
                                        text_style "unusable_text" # 色を変える
                                null height (gui.pref_spacing)

            vbox:
                xsize 350

                label _("アイテム詳細")
                $ tooltip = GetTooltip()
                if tooltip:
                    null height (4 * gui.pref_spacing)
                    text "[tooltip]"

### style省略 ###

style unusable_text:
    color gui.insensitive_color
```

このコードでは使用可能なアイテムがクリックされたとき、`confirm_msg`を出す`Confirm`スクリーンを表示し、「はい」が選択された場合`UseItem`アクションを呼びます。`UseItem`は独自に実装したActionです。

```python:screens.rpy
init:
    python:
        class UseItem(Action):
            def __init__(self, item):
                self.item = item

            def predict(self):
                renpy.predict_screen("notify")

            def __call__(self):
                renpy.notify(self.item.get_result_msg())
                self.item.use_item()
```
Ren'Pyでは`Action`クラスを継承したクラスはActionとして呼ぶことができ、呼ばれたとき`__call__()`内の処理が行われます。
ここでは`UseItem(item)`が呼ばれたとき、`item`の`result_msg`を取得してそれをnotifyに表示したのち、`item`の`use_item()`メソッドを呼ぶようにしています。

最後にここで作った`inventory()`スクリーンをゲームメニューに表示します。ステータスの場合と同様ですね。

```python:screens.rpy
screen navigation():

    ### 中略 ###

        if main_menu:

            ### 中略 ###

        else:

            textbutton _("ステータス") action ShowMenu("stats")

            textbutton _("アイテム") action ShowMenu("inventory") # この行を追加
```
これでゲームメニューからアイテムを使えるようになりました。

# 2. 道具屋での売買
アイテムを使えるようになったので、道具屋で売買できるようにしましょう。`Item`クラスで定義していた`price`や`buy_item()`メソッドの出番です。

## システム実装
こちらはあまりやることがなく、道具屋さんにおいてあるアイテムを定義するくらいです。今回はlistにしましたが、在庫の概念がある場合はdictで作れば対応できます。

```python:store.rpy
# 道具屋においてあるアイテムを登録する
init python:
    store = [medical_hp_small, medical_hp_medium, enhancing_hp]
```

## UI実装
こちらも実装の結果を載せておきます。
![SnapCrab_griseo_2020-8-1_18-50-15_No-00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/679910/a7c1ce3f-a62d-b35e-1e3b-dd20d56e7a9d.png)
いったんありものの画像で作っているので若干不自然ですが(スクロールバーなど特に)、機能的には完全に道具屋さんです。しかも画像では伝わりませんが購入・売却時にチャリーンと音が鳴ります。ゲームっぽいですね！
※使用した素材は記事の最後に載せています。

以下コードです。クラス定義も一気に載せます。

```python:store.rpy
init 1 python:
    # 購入imagemapのtextbuttonが押されたとき
    class BuyItem(Action):
        def __init__(self, item):
            self.item = item

        def __call__(self):
            self.item.buy_item()
            renpy.restart_interaction()
            renpy.play("audio/sounds/coins.mp3")

    # 売却imagemapのtextbuttonが押されたとき
    class SellItem(Action):
        def __init__(self, item):
            self.item = item

        def __call__(self):
            self.item.sell_item()
            renpy.restart_interaction()
            renpy.play("audio/sounds/coins.mp3")

screen store_screen(store_instance):

    style_prefix "store"
    $current_money = party_money.get_value() # グローバル変数party_money(適当に作ったmoney用クラスのインスタンスから現在値を取得)

    frame:
        background "gui/store/blank.png"
        style_group "mm"
        xalign 0
        yalign 0
    hbox:
        xalign 0.05 yalign 0.03
        add "images/icons/money.png"
        text str(current_money)

    null height (4 * gui.pref_spacing)
    hbox: # 購入imagemapと売却imagemapを横に並べる
        xalign 0.05
        yalign 0.4
        imagemap:
            ground "gui/store/background.png"
            label _("購入")   
            viewport:
                #yinitial yinitial
                scrollbars "vertical"
                mousewheel True
                draggable True
                pagekeys True

                side_yfill True
                vbox:
                    for item in store_instance: # 道具屋の在庫を金額とともに表示
                        $item_name = item.get_name()
                        $item_price = item.get_price()
                        hbox:
                            if item_price > current_money: # 所持金よりも高いアイテム
                                textbutton item_name sensitive False
                                add "images/icons/money.png" yalign 1.0
                                text str(item_price)
                            else: # 買えるアイテム
                                textbutton item_name action BuyItem(item)
                                add "images/icons/money.png" yalign 1.0
                                text str(item_price)

        null width (2 * gui.pref_spacing)
        imagemap:
            ground "gui/store/background.png"
            label _("売却")
            viewport:
                #yinitial yinitial
                scrollbars "vertical"
                mousewheel True
                draggable True
                pagekeys True

                side_yfill True

                vbox:
                    for item, amount in inventory.items(): # inventoryのアイテムを売値と数量とともに表示
                        if amount > 0:
                            $item_name = item.get_name()
                            $item_sell_price = item.get_sell_price()
                            $marketable_flag = item.get_marketable_flag()
                            hbox:
                                if marketable_flag: # 売れるアイテム
                                    textbutton item_name action SellItem(item)
                                    add "images/icons/money.png" yalign 1.0
                                    text str(item_sell_price)
                                    text "×" + str(amount)
                                else: # 売れないアイテム
                                    textbutton item_name sensitive False

    button: # 戻るボタン
        xalign 0.9
        yalign 1.0
        xsize 145
        ysize 100
        text "戻る" yalign 0.35
        background "gui/store/blank.png"
        
        action Return(True) # 呼び出し元のlabelに戻る

### style省略 ###
```

これだけのコードで道具屋さんが作れるのはとても嬉しいですね！

# 結論と今後
今回はアイテムを実装し、メニュー画面からの使用と道具屋での購入・売却ができるようになりました。
そろそろバトルができそうなので、次回は戦闘システムを作っていこうと思います。

# 参考にしたコードや素材(敬称略)
コード参照＆GUIを拝借：
Dragonaqua on Lemmasoft(https://lemmasoft.renai.us/forums/viewtopic.php?f=51&t=57105)
背景：
成瀬直瑚
立ち絵：
わたおきば(https://wataokiba.net/)
アイコン：
素材屋Rosa(http://sozairosa.blog.fc2.com/)