0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【学習記録】Python × Kivyでアプリ開発④【実装編・後半】

Posted at

はじめに

お疲れ様でございます。ハム二郎です:bow_tone1:

前回に引き続き、短歌対戦アプリの開発記録の後半部分をまとめます。

短歌対戦アプリの後半開発を進める中で、
UI構造・責務分離・設計原則の理解が不十分な状態のまま進行していたことに気づき、いくつかの重要な構造的課題が浮き彫りになりました。

本記事では、短歌対戦アプリのコード学習ログとともに以下の内容をまとめています。

  • 発生した構造的問題
  • その原因と設計上の解釈
  • 改善のために必要なアーキテクチャの方向性

もし気づいた点があれば、コメントでご指摘していただけますと幸いです。

目次

コード内容

まとめ

:mag_right: 設計上の気づき

短歌アプリの後半コードを整理する過程で、前回の記事で触れていた UI 崩れやスコアロジック不具合の原因が、単なる記述ミスではなく 設計原則の理解不足による構造破壊 であることが明確になりました。

主に以下の2点が、UI とロジックの両層に深刻な影響を与えていました。

① Slot クラスの二重定義

前回の記事で既に Slot クラスを定義していたにも関わらず、「個別スロット(札1枚分の置き場)」の実装時に同名の Slot クラスを再定義していました。

Python では 後に定義された同名クラスが完全に上書きされる ため、SlotColumn が保持していた Slot オブジェクトが別物へと置き換わり、UI とロジックが前提としていた構造が破壊されました。

:x: 発生した事象

  • 本来の Slot に含まれていた以下の機能がすべて失われ、UI の視覚構造が崩壊。
    • 角丸背景
    • 枠線
    • pos/size の追従処理
    • card 属性
  • SlotColumn 内の Slot が「想定外の型」になり、slot.card の更新が正しく行われず短歌文字列が生成されない。

結果として スコアロジック・役演出に到達しない形となりました。

:warning: 根本原因

  • UI要素を役割ごとに命名するという基本原則の欠如
  • 「句スロット」と「個別スロット」に対する責務の境界が曖昧
  • 名前空間衝突の影響範囲に対する理解不足

得た気づき

  • クラス名は「責務単位」で分ける(例:BoardSlot / HandSlot)
  • Pythonは同名クラスを厳密に上書きするため、名前衝突は構造破壊につながる
  • UIロジックはクラス構造への依存度が高く、名前空間の管理が重要

__init__ のダブルアンダースコアや **kwargs のダブルアスタリスクの抜けによる初期化プロセスの破綻

一部のウィジェットで super().__init__(**kwargs) が呼ばれておらず、Kivy が内部で必要とする以下の初期化プロセスが実行されていませんでした。

  • レイアウトツリーへの登録
  • pos/size の初期化
  • EventDispatcher の構築
  • Canvas の準備
  • バインディングの設定

:x: 発生した事象

  • pos/size の更新が描画へ反映されない
  • bind が発火せず update_rect が呼ばれない
  • スロット座標が正しく管理されず、札が配置されても slot.card が正しく更新されない
  • その結果、短歌文字列が形成されず score 計算が行われない

:warning: 根本原因

  • Kivy の「ウィジェット初期化は階層構造の接続点」という理解不足
  • **kwargs がプロパティ伝搬に不可欠である点の見落とし

得た気づき

  • UIクラスの初期化処理は、レイアウト・イベント・描画すべての基盤になる
  • super().__init__(**kwargs) を抜くと、ウィジェットが「見た目だけ存在する壊れたオブジェクト」になる
  • 初期化は継承構造の最重要ポイントであり、省略は致命的

課題点でもまとめていますが、当記事を読んでくださる方に向けて、予め留意点としてお伝えさせていただきます。

カードボタンクラス

class CardButton(Button):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.color = (0, 0, 0, 1)  
        self.font_size = 20
        self.background_color = (0, 0, 0, 0) 
        self.background_normal = ''  # デフォルト背景を無効化

class CardButton(Button):
def __init__(self, **kwargs):
super().__init__(**kwargs)

Kivyの標準ボタン(Button)を継承し、自作のカスタムボタン CardButton を定義しつつ、ボタンの初期化処理を行い初期設定をセットアップしています。

さらに、その下では属性追加を行い、「背景を透明にし、黒文字で、自分でデザインできるカード状ボタン」を作っています。
なお、RGBAでの色の指定は以下の通りです。

意味 範囲
R 0〜1
G 0〜1
B 0〜1
A 透明度 0(透明)〜1(不透明)
        with self.canvas.before:
            Color(0, 0, 0, 1) 
            self.border_rect = Rectangle(pos=self.pos, size=self.size)
        self.bind(pos=self.update_rect, size=self.update_rect) 
        self.padding = (10, 10)
        self.border_radius = 15
        self.elevated = False

with self.canvas.before:
Color(0, 0, 0, 1)

前述の通り、「このウィジェットの背景レイヤーに、図形や色を描く命令を書きます」という宣言をしています。


self.border_rect = Rectangle(pos=self.pos, size=self.size)
Rectangle(pos=self.pos, size=self.size) により CardButton とまったく同じ位置・サイズの枠線矩形を描画した Rectangle オブジェクトを self.border_rect という属性として保存します。
なお、保存する理由としては、カードが動いたとき四角形も動かす必要があり、その際に更新を行うためです。


self.bind(pos=self.update_rect, size=self.update_rect)
前述の通り、ボタンの変化に図形の動きを追従させています。


self.padding = (10, 10)
ボタン内の文字(text)の余白を左右上下に10pxずつ付けています。


self.border_radius = 15
ボタンの角を丸める“半径”を15に設定します。


self.elevated = False
カードが押された時のアニメーションを管理しています。通常状態は浮き上がっていなよう設定し、= Trueとなれば浮き上がる状態に設定しています。

        with self.canvas.before:
            Color(0.98, 0.96, 0.92, 1) 
            self.bg = RoundedRectangle(radius=[15], pos=self.pos, size=self.size)
            Color(0, 0, 0, 1)
            self.border = RoundedRectangle(radius=[15], pos=self.pos, size=self.size)

with self.canvas.before:
前述の通り、このウィジェットの 背景レイヤー に図形を描く宣言をしています。


Color(0.98, 0.96, 0.92, 1)
図形をほぼ白に近い淡いベージュ(和紙・古文書っぽい色)に設定します。


self.bg = RoundedRectangle(radius=[15], pos=self.pos, size=self.size)
角丸の背景(bg = background)を描きます。角丸半径 15、カスタム色、位置・サイズはボタン本体と同じに設定しています。

RoundedRectangle … Rectangle の角を丸くした図形。

Color(0, 0, 0, 1)
Kivy は必ず Color → 図形 の順で描くため、色を黒色に再指定しています。


self.border = RoundedRectangle(radius=[15], pos=self.pos, size=self.size)
背景と同様に枠線を描きます。
色が黒なので周囲に黒枠ができるようにしています。

        self.bind(pos=self.update_graphics, size=self.update_graphics)
        self.bind(on_press=self.on_press_effect, on_release=self.on_release_effect)

前半部分は前述の通りです。
self.bind(on_press=self.on_press_effect, on_release=self.on_release_effect)
この後半部分では、「押す=強調 / 離す=元に戻す」という UI 演出を実行するためのイベントを登録しています。

bind()bind(イベント名 = イベント時に呼ぶ関数)

    def update_rect(self, *args):
        self.border_rect.pos = self.pos
        self.border_rect.size = self.size

前述の通り、ボタンの動きにあわせて実際の枠線(border_rect)を追従させます。

    def update_graphics(self, *args):
        self.bg.pos = self.pos
        self.bg.size = self.size
        self.border.pos = self.pos
        self.border.size = self.size

前述と同様に、自身(Widget)の pos/size が変更された場合、背景(bg) と枠線(border) の pos/size を同期させるよう設定しています。見た目のズレを防いでいます。

    def on_press_effect(self,*args):
        self.elevated = True
        self.canvas.before.clear()
        with self.canvas.before:
            Color(0.9, 0.88, 0.85, 1)
            self.bg = RoundedRectangle(radius=[15], pos=self.pos, size=self.size)
            Color(0, 0, 0, 1)
            self.border = RoundedRectangle(radius=[15], pos=self.pos, size=self.size)

def on_press_effect(self,args):
先に self.bind(on_press=self.on_press_effect) を記述していたため、ボタンが押された瞬間に自動でこの関数が呼ばれ、以下の通りUI演出が行われます。

self.elevated = True
前述した通り、ボタンが押されて浮き上がっている状態に設定しています。

self.canvas.before.clear()
Canvas の図形は「上書き」できないため、背景レイヤーの描画内容を一度すべて消します。

:bulb: clear に()を付ける理由
Python では、関数(メソッド)は オブジェクトとして存在するため、ただの「値」として扱うことができます。
よって、clear のみでは clear という関数オブジェクトとして扱われますが、()を付け clear() とすることで「clear を実行する」という意味になります。

以降は前述した内容と同様に、ボタンの位置やサイズに合わせ、影を帯びた押下用の背景(bg)及び、押下後の黒い角丸枠(border)を描いています。

スロットカラムクラス

class SlotColumn(BoxLayout):
    def __init__(self, length, color, label_text, **kwargs):
        super().__init__(orientation="vertical", **kwargs)
        self.length = length
        self.color = color
        self.slots = []
        self.enabled = True

class SlotColumn(BoxLayout):
def __init__(self, length, color, label_text, **kwargs):
super().__init__(orientation="vertical", **kwargs)

Kivyの BoxLayout を継承し、専用のスロットの縦列 SlotColumn を定義しつつ、レイアウトの初期化処理を行い初期設定をセットアップしています。
また、セットアップ時に呼び出し側から length(スロット数)color(色)label_text(句のラベル:上の句など)を受け取ります。
さらに、BoxLayout のデフォルトは horizontal(横並び) であるため、親クラスの初期化を縦方向レイアウト(orientation="vertical") で呼んでいます。

以降はこの SlotColumn の「文字数」「色」「空リスト」を属性追加し、self.enabled = True にて有効状態に設定しています。

        self.add_widget(Label(text=label_text, size_hint=(1, None), height=30))
        for i in range(length):
            slot = Slot(color)
            self.add_widget(slot)
            self.slots.append(slot)

self.add_widget(Label(text=label_text, size_hint=(1, None), height=30))
SlotColumn はウィジェットを上から順に縦に並べるため、 [ Label ] が一番上に配置されることを利用し、「上の句」などのタイトルをラベルとして追加しています。

Label()()内は、Label クラスのインスタンスを作るための構成(初期設定)であり、これらの引数は __int__ を通し最終的にラベルウィジェットの属性として保存されます。
ここでは、「表示する文字列」「横幅は親に追従 / 高さは固定」「高さ30px」を追加しています。

なお、Label はクラス名そのものです。よってここでは、()の付加によるメソッドの実行ではなく、Label()Label クラスのインスタンス(=ラベルウィジェット)を作っています。


for i in range(length):
Python のループ構文は for 変数(処理) in 集合: なので、range(length) の中の数を 0 から length−1 までを「i」に順番に入れてそのたびに処理を実行するという意味です。


slot = Slot(color)
self.add_widget(slot)
self.slots.append(slot)

Slot クラスから背景色に color を使う新しいスロットオブジェクトを1つ生成し、変数 slot に入れています。
そして、今作った slot を SlotColumn(縦レイアウト)の中に追加し、画面に表示します。
その後、追加した slot を SlotColumn が持つスロット一覧リストに保存しロジック面で管理しています。

append は Python のリストの基本メソッドであり、「リストの末尾に要素を追加する」という動作を示します。

    def enable(self):
        self.enabled = True
        for s in self.slots:
            s.opacity = 1.0

def enable(self):
SlotColumn を「有効化する」動作を定義しています。
self.slots に入っている slot オブジェクトを 1つずつ取り出して、 s として取り扱い、すべてのスロットに対して同じ処理を繰り返すことを可能にします。

スロットは [s1, s2, s3, s4, s5] のように複数あり、一つずつ書くのは不可能です。そこで、まとめて enable / disable により全体に対する UI 操作を行うため、スロットをリスト(self.slots)にて管理し、ループで一括処理できるようにしています。


s.opacity = 1.0
全てのスロットの透明度(opacity)を 1.0(完全に見える状態)にします。

    def disable(self):
        self.enabled = False
        for s in self.slots:
            s.opacity = 0.3

def disable(self):
SlotColumn を「有効化する」動作を定義しています。


self.enabled = False
SlotColumn の状態フラグ(enabled)を False にし、「この列は今は操作不可である」と設定しています。


for s in self.slots: s.opacity = 0.3
self.slots に入っている slot オブジェクトを 1つずつ取り出して、 s として取り扱い、全ての Slot を半透明(薄く表示)にして「使えない」状態の見た目にします。
短歌アプリでは順番に句を置いていくため、下記のようにUI操作にてフェーズ管理をしています。

状態
上の句(5音) enable(置ける)
中の句(7音) disable(まだ置けない)
下の句(5音) disable(さらに後)

:warning: 個別スロット(札1枚分の置き場)

class Slot(Widget):
    def __init__(self, color, **kwargs):
        super().__init__(**kwargs)
        self.card = None
        with self.canvas:
            Color(*color)
            self.rect = Rectangle(pos=self.pos, size=(80, 80))
        self.bind(pos=self.update_rect, size=self.update_rect)

    def update_rect(self, *args):
        self.rect.pos = self.pos
        self.rect.size = self.size

判明したミスでもお伝えしましたが、スロット1列分(句単位)にて既に Slot クラスを作成しているのに、個別スロット(札1枚分の置き場)でも重複して Slot クラスを作成してしまっています。

見出しの通り、個別スロット(札1枚分の置き場)を作成するために上記コードを記述しましたが、上書きされることに気が付かず進めてしまっていました。

前回の記事で進捗をまとめる際にGIF画像で動作中の画面を記載していましたが、私が予想していた内容とは全く異なり、「選択札が枠の中央に収まらない」「枠の角が角ばっている」などしていました。これらは全て、不完全なコード内容である2回目の Slot クラスで上書きしてしまっているためでした。

スクリーンクラス

class TankaScreen(Screen):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.layout = FloatLayout()
    self.add_widget(self.layout)
        self.active_index = 0 
        self.colors = {
            1: [1, 1, 1, 1],
            2: [0.7, 1, 0.7, 1],
            3: [0.7, 0.7, 1, 1],
            4: [1, 1, 0.6, 1],
            5: [1, 0.6, 0.6, 1]
        }# 山札
        self.deck = {
            1: [""],
            2: ["〇〇"],
            3: ["〇〇〇"],
            4: ["〇〇〇〇"],
            5: ["〇〇〇〇〇"]
        }

class TankaScreen(Screen): def __init__(self, **kwargs): super().__init__(**kwargs)
Kivyの標準画面(Screen)を継承し、自作のメイン画面 TankaScreen を定義しつつ、画面の初期化処理を行い初期設定をセットアップしています。


self.layout = FloatLayout() self.add_widget(self.layout)
Screen は単なる画面枠であり、
「中に自由に UI を置くための入れ物(レイアウト)」が必要です。

UI を置くための「入れ物」を作成するため、 FloatLayout クラスをオブジェクト化し、self.layout に保存することで入れ物として使用できます。


self.active_index = 0
今どの句(5・7・5・7・7)の列にカードを置くかを示すための番号(インデックス)を =0 によって初期化しています。
index 0 → 5文字句
index 1 → 7文字句
index 2 → 5文字句
index 3 → 7文字句
index 4 → 7文字句


self.colors = { … }
カードの文字数に応じて色を決めるための辞書を作っています。
左側の数字(1, 2, 3, 4, 5)はキー(key)であり、数に応じた文字数のカードを示します。
右側のリストは RGBA の色データです。
background_color=self.colors[length] にて length に代入する形で使用します。


self.deck = { … }
文字数(1~5)をキーとして、その文字数に対応する札の言葉リストをまとめた辞書を作り、TankaScreen の属性として保存しています。つまり、山札の役割を果たします。

これは後述する length = random.randint(1, 5)
text = random.choice(self.deck[length]) にて、使用します。具体的には、length の値に応じて self.deck を参照し、そこからランダムに選んで引きます。

俳句盤面

        self.slot_layout = BoxLayout(orientation='horizontal', size_hint=(None, None),
                                     pos=(60, 250), size=(700, 300), spacing=10)
        self.layout.add_widget(self.slot_layout)

        tanka_pattern = [5, 7, 5, 7, 7]
        self.slot_columns = []

        for i, n in enumerate(tanka_pattern):
            col = SlotColumn(n, self.colors[min(n, 5)], f"{n}文字句")
            self.slot_layout.add_widget(col)
            self.slot_columns.append(col)

self.slot_layout = BoxLayout(...)
俳句盤面(5列構成)を横に並べるレイアウト BoxLayout を作っています。() の中では、「横並び、画面左から60pxで下から250pxの位置、幅700で高さ300の大きさ、10px の余白」を設定しています。
なお、size_hint=(None, None) では親レイアウトにサイズを任せず、自前でサイズを持つよう設定しています。size=(700, 300) を使うために必要です。


self.layout.add_widget(self.slot_layout)
先ほど作った BoxLayout を、画面の下地レイアウト(FloatLayout)に追加します。


tanka_pattern = [5, 7, 5, 7, 7]
短歌の構造(5文字・7文字・5文字・7文字・7文字)を表す一覧です。ここで後のループにて「各列に何スロット置くか」の基準を作っています。


self.slot_columns = []
句の順番制御や active_column の管理を行うため、後で SlotColumn を全て保存する用のリストです。


for i, n in enumerate(tanka_pattern):
先程作成した tanka_pattern = [5, 7, 5, 7, 7]

  • i → インデックス(0,1,2,3,4)
  • n → 値(5,7,5,7,7)

という形で1つずつ取り出します。


col = SlotColumn(n, self.colors[min(n, 5)], f"{n}文字句")
SlotColumn を1つ作り、そのインスタンスを col という変数に入れています。
その SlotColumn は n 個のスロットを持ち、色は文字数に応じた色で、ラベルは「5文字句/7文字句」のように付けられるよう設定しています。

n はfor文で tanka_pattern = [5, 7, 5, 7, 7] の中の値を1つずつ取り出したものであり、n は 5 → 7 → 5 → 7 → 7 と変化します。つまり、列(SlotColumn)が何スロット必要かを示しています。
具体的に、col = SlotColumn(n, 色, ラベル) で SlotColumn を呼ぶと、__init__(self, length, color, label_text) へ順番通りに値が渡されます。つまり、length ← n となります。

Python は「位置引数」を順番に渡す関数やクラスの引数は、左から順に対応します。
これを「位置引数(positional arguments)」と呼びます。

同様に self.colors[min(n, 5)] color ← self.colors[min(n,5)] となり、n の文字数に応じて色を決めます。

また、f"{n}文字句label_text ← f"{n}文字句" となり、ラベル文字列を動的に作っています。前回も登場しましたが、f"文字列 {変数} 文字列"f は「f-string(フォーマット文字列)」を使うための記号であり、Python が提供する「文字列を簡単に埋め込むための機能」です。変数を {} の中に直接書くだけで変数nが展開され、その値が埋め込まれた文字列を作ることができます。


self.slot_layout.add_widget(col)
col(= SlotColumn のインスタンス)を slot_layout(= BoxLayout)の子ウィジェットとして追加しています。colcol = SlotColumn(n, self.colors[min(n, 5)], f"{n}文字句") によって作られた SlotColumn クラスから作られたオブジェクトです。

slot_layout は self.slot_layout = BoxLayout(orientation='horizontal', ...) であり、横並びのレイアウトですので、中に入れたものを横に並べて配置します。これにより、ここに col が追加されることにより、画面上に新しい SlotColumn(縦のスロット群)が表示され、slot_layout 内に「横並びの 1 列」として配置されます。


self.slot_columns.append(col)
先ほど self.slot_columns = [] で作成した slot_columns というリストにSlotColumn(=1句分のスロット群)を保存しています。

手札(下段)

        self.hand = []
        x = 100
        for i in range(5):
            length = random.randint(1, 5)
            text = random.choice(self.deck[length])
            card = Button(text=f"{text}\n({length})",
                          background_color=self.colors[length],
                          pos=(x, 100), size_hint=(None, None), size=(100, 120))
            card.bind(on_press=self.on_card_select)
            self.layout.add_widget(card)
            self.hand.append(card)
            x += 120

self.hand = []
 x = 100

後から手札管理やカード操作を行うため、手札カードを保存するための空リストを作ります。また、カードを横に並べるための初期位置(X座標)を 100px にセットします。


for i in range(5):
length = random.randint(1, 5)
text = random.choice(self.deck[length])

まず、for構文でのループにより手札を5枚作ります。
次に、1〜5 の整数からランダムに1つ選んで返し、その結果を length(カードの文字数) に代入します。

random.randint(a, b) は Python 標準ライブラリの random モジュールの関数であり、a 以上 b 以下の整数を完全ランダムで1つ返します。

そして、text = random.choice(self.deck[length]) にて代入された length の値に応じて山札である self.deck を参照し、そこからランダムに選んで引きます。


card = Button(...)
手札として画面に表示するボタンを作っています。
各引数では、以下の通りボタンの内容を設定しています。
text=f"{text}\n({length})" では上段に単語、下段に文字数を表示します。\n は 「ここで改行せよ」という意味の特別な記号であり、これにより上下に分けられています。
background_color=self.colors[length] では文字数別の色でカード背景を決定します。TankaScreeninit 内で1〜5 の整数に対応した色を self.colors にて設定しており、ここが対応付けられます。
pos=(x, 100) では画面内の位置(絶対座標)を設定しています。x は横位置(後述のコードで毎回 120 足されて横並びになる)、y=100 は固定で下段に配置しています。
size_hint=(None, None) は前述の通り自動サイズ調整を無効かしています。
size=(100, 120) はカードの大きさを設定しています。


card.bind(on_press=self.on_card_select)
bind は「プロパティの変化を監視する関数」のため、カードが押された時に on_card_select() が呼び出されるよう設定します。


self.layout.add_widget(card)
作ったカードを画面(FloatLayout)に追加して表示しています。


self.hand.append(card)
生成したカードを作成済の手札リストである self.hand に保存します。


x += 120
次のカードの横位置を 120px 右にずらし、カード1枚ずつ間隔をつけて横に並べるようにしています。

:bulb: () の意味(おさらい)
上記における () は 関数(メソッド)を実行するためのカッコであり、add_widget は関数の名前、(card) は関数に渡す引数を示します。これにより、card を追加するという意味になります。() が無ければ関数そのものを指すだけで実行されません。
なお、引数を渡さない場合、例えば self.canvas.before.clear() では canvas.beforeclear メソッドを実行するという意味になります。add_widget の場合は必ずウィジェット(Widget オブジェクト)を引数に要求する関数のため、引数を渡さない場合はエラーになります。

札を出す動作

    def on_card_select(self, instance):
        active_col = self.slot_columns[self.active_index]
        for slot in active_col.slots:
            if not slot.card:
                slot.card = instance
                instance.pos = slot.pos
                break
        self.active_index = (self.active_index + 1) % len(self.slot_columns)
        self.update_active_column()

def on_card_select(self, instance):
先に card.bind(on_press=self.on_card_select) にてバインドしていたため、カードが押されたとき、押されたそのカードが instance として渡ってきます。


active_col = self.slot_columns[self.active_index]
先ほど作成した self.slot_columns.append(col) により slot_columns では、[ SlotColumn(5), SlotColumn(7), SlotColumn(5), SlotColumn(7), SlotColumn(7) ] のようにリスト化されています。

リスト[インデックス] … Python のリストの基本構文であり、「その番号の要素」を取り出す処理を行う。

active_index は現在操作中の句を表す番号のため、上記コードでは今選択中の句(SlotColumn)を active_col として取り出す処理を行っています。


for slot in active_col.slots:
for 変数 in リスト: はリストの中身を1つずつ取り出して変数(ここでは slot)に入れ、その都度処理を行います。つまり、active_col(=現在の句)の中にある全ての Slot を、1つずつ取り出すループを行います。
なお、句の中のすべての Slot はSlotColumn にて slots にまとめて保持しているため、その句の中のスロットにアクセスするには active_col.slots である必要があります。


if not slot.card:
slot.card はそのスロットに既にカードが置かれているかどうかを表すため、空いているスロットを探す条件となります。


slot.card = instance
Slot クラスの冒頭にて、self.card = None と定義しているため、ここでスロットはまだ何も置かれていない状態です。
また、先に def on_card_select(self, instance): と定義しているため、instance は「押されたカード Button」を示します。
つまり、「このスロットは、このカードを保持している」という情報を Slot オブジェクトに保存しています。


instance.pos = slot.pos
押されたカードの位置を、スロットの位置に移動させています。


break
「今いるループ(for / while)を即座に終了して抜ける」 という命令です。
これをしなければ、空きスロットを見つけてカードを置いた後も残りの slot すべてをループし続けてしまいます。


self.active_index = (self.active_index + 1) % len(self.slot_columns)
次の句へ進むために active_index を1つ増やし、最後まで行ったら0に戻します。

% は剰余演算子であり、a % b ではa を b で割った “余り” を返します。

len() は「要素数を返す関数」であり、self.slot_columns には短歌の5つの句(SlotColumn)が入っているため、5を返します。


self.update_active_column()
現在アクティブな句の UI 状態を更新するためのメソッドを呼び出しています。

短歌完成処理

    def complete_tanka(self, instance):
        tanka = ""
        for col in self.slot_columns:
            for s in col.slots:
                if s.card:
                    tanka += s.card.text.split("\n")[0]
        if not tanka:
            self.result_label.text = "札を出してください。"
            return

        score, yaku = self.evaluate_tanka(tanka)
        self.result_label.text = f"短歌『{tanka}』 得点:{score}\n役:{yaku or 'なし'}"


def complete_tanka(self, instance):


tanka = ""
最初は空文字の短歌にし、短歌を作るための文字列を初期化します。


for col in self.slot_columns:
短歌を構成する5つの句のリストである self.slot_columns から1つずつ句を取り出して col に代入していくループです。このループで句(columns)を 1句目 → 2句目 → … →5句目 と順番に見ます。


for s in col.slots:
その句に含まれるスロット一覧である col.slots から1つずつ取り出して s に代入していくループです。このループで各句の中のスロットを1つずつ見ます。


if s.card:
s.card は「その Slot(s)に置かれているカード(札)」を示します。
Python では None は “偽 (False)” として扱われる値のため、このコードは「札が置いてあるスロットだけ処理する」という意味になります。


tanka += s.card.text.split("\n")[0]
tanka += Xtanka = tanka + X の省略形であり、今の tanka の末尾に X を追加(結合)します。

また、手札(self.card.text)は Button(text=f"{text}\n({length})") としており、これを .split("\n") で改行し分割します。(例:"〇〇\n(3)".split("\n") → ["〇〇", "(3)"]
そして [0] により分割したリストの0番目(=上の句の文字)だけを取り出します。
つまり、カードの文字そのものだけを取って、短歌に連結しています。


if not tanka:
self.result_label.text = "札を出してください。"
return

札が置かれていない場合、結果表示用のラベルである result_label により「札を出してください」と表示させます。
Pythonで return を書くとその場で関数の実行を終了し、それ以降の処理は一切実行されないことを利用し、tanka が空のまま評価することを防いでいます。


score, yaku = self.evaluate_tanka(tanka)
evaluate_tanka(tanka) を実行すると、後述の役演出から「スコア」と「文字列」の2つが返ってくるため、アンパック代入にて左辺の2つの変数に割り当てます。

Python の アンパック代入(tuple unpacking)とは「複数の値を、一度に複数の変数へ割り当てる構文」 のことです。
具体的には、右辺にある複数の値(タプル・リストなど)を、左辺の複数の変数にバラして(unpack して)代入します。


self.result_label.text = f"短歌『{tanka}』 得点:{score}点\n役:{yaku or 'なし'}"
結果表示用のラベルである result_label 及び f-string により「短歌『○○○○』 得点:○○点 役:○○」を表示させます。
なお、tanka は短歌文字列、scoreevaluate_tanka の戻り値、yaku は後述にて成立した役のリストです。

役演出

    def evaluate_tanka(self, tanka):
        roles = {
            "": 30, "": 20, "": 25, "": 15,
            "": 20, "": 30, "": 25, "": 20
        }
        score = random.randint(50, 80)
        yaku_hit = [k for k in roles if k in tanka]
        for k in yaku_hit:
            score += roles[k]
        return score, yaku_hit

def evaluate_tanka(self, tanka):
TankaScreen クラスの中に
「evaluate_tanka(短歌を評価する)」という名前のメソッドを定義しています。score, yaku = self.evaluate_tanka(tanka) からtankaを受け取り評価します。


roles = {…}
「役(やく)」の一覧を辞書(dict)として定義しています。


score = random.randint(50, 80)
最初に 基本点を50〜80点の乱数で決めています。ここは、後でAI評価を加えていけたらと思います。


yaku_hit = [k for k in roles if k in tanka]
リスト内包表記により、短歌に登場した役だけを集めたリストを作成しています。つまり「役の検出処理」を1行で行っています。

リスト内包表記とは、[new_item for item in collection if 条件] の形であり、今回の場合は下記の通りになります。

部分 意味
k 新しいリストに入れる値(=new_item)
for k in roles roles のキーを 1 つずつ取り出す(=for item in collection)
if k in tanka そのキーが tanka に含まれていれば入れる


for k in yaku_hit:
score += roles[k]

yaku_hit の中にある単語を1つずつ取り出してkに代入し、roles{…}の点数に応じた点数を加算します。
なお、score += roles[k]score = score + roles[k] の省略形です。


return score, yaku_hit
score(合計点)と yaku_hit(成立した役リスト)の2つを1セットとして呼び出し側に返します。つまり、score, yaku = self.evaluate_tanka(tanka) にタプルで返され、アンパック代入によりそれぞれ個別で受取ります。

最終実行

if __name__ == "__main__":
    TankaApp().run()    

このファイルが「メインの実行ファイル」として直接実行されたときだけ、TankaApp を起動するよう設定しています。

  • そのファイルが 直接実行された場合
    __name__= __main__
  • 他のファイルから import された場合
    __name__ = "ファイル名"

課題点

① ファイル肥大化

  • 500行超のmain.pyは、「理解不能な塊」 になりやすい。
  • Drag&Drop、スナップ、採点、配色、デッキ管理などの関心事が混在しているため、修正時にどこを触っても副作用が出る。

② クラス再定義 (Slot の二重定義)

  • 同名クラスが二度宣言されているため、上書きされており、意図しない描画となっている。

③ ランダムロジック

score = random.randint(50, 80)
  • 「運」で結果が変わってしまうため、基準に基づいた内容にする必要がある。

④ UI層にロジック直書き

def on_card_select(self, instance):
    ...
def evaluate_tanka(self, tanka):
    ...
  • 評価・進行・描画がすべてTankaScreenに埋め込まれているため、再利用不能。CPU対戦やオンライン化が不可能な設計となっている。
課題 対応すべき設計方針
ファイル肥大化 core/ ディレクトリを切り、責務別に分割する
ロジック混在 UI層とロジック層を分離(MVC/MVVM的構造)
ランダム依存 rules.py に純関数として評価ロジックを定義
テスト不能 pytest でユニットテスト可能な構成に変える
クラス再定義 命名規約・責務の明確化(Slot / BoardSlot / HandSlot

さいごに

ここまで読んでいただき、誠にありがとうございました。

今回の「大人の短歌」は多くの課題や反省点がありましたが、札と場の配置や選択した札の移動など、形式的にはそれなりに形となり有意義な経験となったと思います。

ですが、本質的には依然として設計されていない状態にあります。
具体的には、main.py に全機能を押し込み、ロジックとUIを混在させたままの状態であり、小さな修正でも全体の挙動に影響するような構造を持たない中身になってしまいました。

未だ開発途中ではありますが、「動く」ことと「設計されている」ことは全くの別物であり、未経験の私にとっては「構造化」を優先して学ばなければ成長できないと痛感しています。

今後について

現在作成中の短歌アプリは、以下の通りリファクタリングによる設計の再構築が必要です。

段階 行動 目的
Step 1 クラスや関数を抽出(共通化) 巨大化したTankaScreenを分解
Step 2 ファイル分割 論理構造を物理構造に反映
Step 3 依存関係を整理 Controller → Model → View の一方向化
Step 4 責務再定義 各層が明確に何を担当するか定義
Step 5 設計再構築完了 拡張・テスト・移植が容易になる

しかし、構造設計を理解していない状態で設計の再構築を行うと、論理構造が定まらないまま物理構造を切ってしまい、修正不能に陥る可能性があります。

したがって、今回の反省を踏まえ、次回からは構造設計を徹底的に理解するための簡素なミニアプリ「学習記録トラッカー」 を開発したいと思います。

上記アプリの開発を構造を鍛える修行場として活用し、データ構造・責務分離・永続化・テスト可能設計を一つずつ確立していく所存です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?