初心者向け No.073

都道府県クイズ

都道府県と県庁所在地を答えるクイズアプリ。ランダム出題・正解率・苦手問題の重点出題機能付き。

🎯 難易度: ★☆☆ 📦 ライブラリ: tkinter(標準ライブラリ) ⏱️ 制作時間: 30〜90分

1. アプリ概要

都道府県と県庁所在地を答えるクイズアプリ。ランダム出題・正解率・苦手問題の重点出題機能付き。

このアプリはgamesカテゴリの実践的なPythonアプリです。使用ライブラリは tkinter(標準ライブラリ)、難易度は ★☆☆ です。

このアプリは「ゲーム」カテゴリです。ゲーム実装は GUI イベント処理・状態管理・描画ループを同時に学べる教材で、画面・入力・ロジックの三層分離の感覚が掴めます。tkinter(標準ライブラリ) を活かして実装するこの構造は、他のアプリにも応用が効きます。

コードを読む前に実行することをおすすめします。動いている挙動を先に把握しておくと、解説で出てくる関数や処理がどこに対応するかが頭に入りやすくなります。

応用のヒントは、機能を 1 つ増やす・見た目を整える・例外を一つでも丁寧に扱う、のいずれかから始めるのがおすすめです。

都道府県クイズ 実行画面(Windows)
実行画面(Windows)
都道府県クイズ 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • ループで生成するボタン群を _answer() に接続
  • 「次の問題」ボタン(next_question())で操作
  • イベント <<ComboboxSelected>> から _on_mode_change() を実行
  • ウィンドウはタイトル「都道府県クイズ」・サイズ 620x480 で起動

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)

Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は標準ライブラリの範囲で動作する想定ですが、未検証です。

  • Python 3.10 以上
  • OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)

4. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

追加インストール不要(標準ライブラリのみ使用)
app073.py
import random
import tkinter as tk
from tkinter import messagebox, ttk


# 都道府県と県庁所在地
PREFECTURES = {
    "北海道": "札幌市", "青森県": "青森市", "岩手県": "盛岡市", "宮城県": "仙台市",
    "秋田県": "秋田市", "山形県": "山形市", "福島県": "福島市", "茨城県": "水戸市",
    "栃木県": "宇都宮市", "群馬県": "前橋市", "埼玉県": "さいたま市", "千葉県": "千葉市",
    "東京都": "新宿区", "神奈川県": "横浜市", "新潟県": "新潟市", "富山県": "富山市",
    "石川県": "金沢市", "福井県": "福井市", "山梨県": "甲府市", "長野県": "長野市",
    "岐阜県": "岐阜市", "静岡県": "静岡市", "愛知県": "名古屋市", "三重県": "津市",
    "滋賀県": "大津市", "京都府": "京都市", "大阪府": "大阪市", "兵庫県": "神戸市",
    "奈良県": "奈良市", "和歌山県": "和歌山市", "鳥取県": "鳥取市", "島根県": "松江市",
    "岡山県": "岡山市", "広島県": "広島市", "山口県": "山口市", "徳島県": "徳島市",
    "香川県": "高松市", "愛媛県": "松山市", "高知県": "高知市", "福岡県": "福岡市",
    "佐賀県": "佐賀市", "長崎県": "長崎市", "熊本県": "熊本市", "大分県": "大分市",
    "宮崎県": "宮崎市", "鹿児島県": "鹿児島市", "沖縄県": "那覇市",
}


class App073:
    """都道府県クイズ"""

    def __init__(self, root):
        self.root = root
        self.root.title("都道府県クイズ")
        self.root.geometry("620x480")
        self.root.configure(bg="#f8f9fc")
        self.score = 0
        self.total = 0
        self.weights = {p: 1 for p in PREFECTURES}  # 苦手問題ほど重みが大きい
        self.current = None
        self.choices = []
        self.mode = "県庁→都道府県"  # クイズの方向
        self._build_ui()
        self.next_question()

    def _build_ui(self):
        """UI を構築する"""
        title = tk.Frame(self.root, bg="#3776ab", pady=12)
        title.pack(fill=tk.X)
        tk.Label(title, text="都道府県クイズ", font=("Noto Sans JP", 16, "bold"),
                 bg="#3776ab", fg="white").pack()

        top = tk.Frame(self.root, bg="#f8f9fc", pady=10)
        top.pack(fill=tk.X)
        ttk.Label(top, text="出題方向:").pack(side=tk.LEFT, padx=(20, 4))
        self.mode_var = tk.StringVar(value=self.mode)
        cb = ttk.Combobox(top, textvariable=self.mode_var, state="readonly", width=18,
                          values=["県庁→都道府県", "都道府県→県庁"])
        cb.pack(side=tk.LEFT)
        cb.bind("<<ComboboxSelected>>", lambda e: self._on_mode_change())

        self.score_label = tk.Label(top, text="正解 0 / 0  (0%)",
                                    font=("Noto Sans JP", 12, "bold"),
                                    bg="#f8f9fc", fg="#3776ab")
        self.score_label.pack(side=tk.RIGHT, padx=20)

        self.q_label = tk.Label(self.root, text="",
                                font=("Noto Sans JP", 18, "bold"),
                                bg="white", relief=tk.GROOVE, pady=18)
        self.q_label.pack(fill=tk.X, padx=20, pady=10)

        self.btn_frame = tk.Frame(self.root, bg="#f8f9fc")
        self.btn_frame.pack(pady=10)
        self.choice_btns = []
        for i in range(4):
            b = tk.Button(self.btn_frame, text="", width=18, height=2,
                          font=("Noto Sans JP", 11),
                          command=lambda idx=i: self._answer(idx))
            b.grid(row=i // 2, column=i % 2, padx=8, pady=6)
            self.choice_btns.append(b)
        self.default_btn_bg = self.choice_btns[0].cget("bg")  # OS 既定のボタン色

        self.fb = tk.Label(self.root, text="", bg="#f8f9fc",
                          font=("Noto Sans JP", 12))
        self.fb.pack(pady=8)

        ttk.Button(self.root, text="次の問題", command=self.next_question).pack(pady=4)

    def _on_mode_change(self):
        """モード変更時の処理"""
        self.mode = self.mode_var.get()
        self.next_question()

    def _weighted_choice(self):
        """重み付きで問題を選ぶ(苦手問題が出やすい)"""
        items = list(self.weights.items())
        total = sum(w for _, w in items)
        r = random.uniform(0, total)
        s = 0
        for k, w in items:
            s += w
            if r <= s:
                return k
        return items[-1][0]

    def next_question(self):
        """新しい問題を出題する"""
        self.current = self._weighted_choice()
        if self.mode == "県庁→都道府県":
            q_text = f"「{PREFECTURES[self.current]}」が県庁所在地の都道府県は?"
            correct = self.current
            pool = [p for p in PREFECTURES if p != self.current]
        else:
            q_text = f"「{self.current}」の県庁所在地は?"
            correct = PREFECTURES[self.current]
            pool = [PREFECTURES[p] for p in PREFECTURES if p != self.current]
        self.q_label.config(text=q_text)
        choices = random.sample(pool, 3) + [correct]
        random.shuffle(choices)
        self.choices = choices
        self._correct_answer = correct
        for i, b in enumerate(self.choice_btns):
            b.config(text=choices[i], bg=self.default_btn_bg, state=tk.NORMAL)
        self.fb.config(text="")

    def _answer(self, idx: int):
        """回答ボタン押下時の処理"""
        ans = self.choices[idx]
        self.total += 1
        if ans == self._correct_answer:
            self.score += 1
            self.fb.config(text="正解!", fg="green")
            self.choice_btns[idx].config(bg="#c8e6c9")
            self.weights[self.current] = max(1, self.weights[self.current] - 1)
        else:
            self.fb.config(text=f"不正解。正解: {self._correct_answer}", fg="red")
            self.choice_btns[idx].config(bg="#ffcdd2")
            self.weights[self.current] += 3
        for b in self.choice_btns:
            b.config(state=tk.DISABLED)
        rate = int(self.score / self.total * 100) if self.total else 0
        self.score_label.config(text=f"正解 {self.score} / {self.total}  ({rate}%)")


if __name__ == "__main__":
    root = tk.Tk()
    app = App073(root)
    root.mainloop()

5. コード解説

都道府県クイズのコードを、実際に書かれている実装に沿って解説します。

クラス設計とコンストラクタ

App073 クラスにアプリの全機能をまとめています(メソッド6個・全142行)。__init__ ではタイトル「都道府県クイズ」とウィンドウサイズ 620x480 を設定します。あわせて状態を保持する変数(self.scoreself.totalself.currentself.mode)を初期化し、そのあと _build_ui()next_question() の順に呼び出して初期状態を作ります。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

ウィジェット構成

画面は tk.Frame×3・tk.Label×4・ttk.Labelttk.Comboboxtk.Button×4(ループで生成)・ttk.Button で構成しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

イベント処理とボタンの接続

「次の問題」ボタン(next_question())を command= で接続しています。ボタンはループ内で command=lambda idx=i: self._answer(idx) の形で生成しています。既定引数でループ変数を固定するのがポイントで、これを書かないと全ボタンが最後の値で動いてしまいます。また bind("<<ComboboxSelected>>", ...) から _on_mode_change() を呼べるようにイベントも登録しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

例外処理を書いていない理由

このコードには try-except による例外処理がありません。テキスト入力欄がなくボタン操作だけで完結し、ファイルやネットワークへのアクセスも無い設計のため、例外を投げうる処理(型変換・ファイル/通信)がそもそも無いからです。入力欄やファイル保存を追加する改造をするときは、例外処理もセットで検討しましょう。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app073.py と保存します。使うのは randomtkinter だけなので、追加インストールは不要です。

  2. 2
    クラスの骨格を作る

    App073 クラスを定義し、__init__ と、末尾の root = tk.Tk()mainloop() の最小構成を書いて、まず空のウィンドウが出ることを確認します。

  3. 3
    ウィンドウを設定する

    title("都道府県クイズ")geometry("620x480")configure(bg="#f8f9fc") をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×3・tk.Label×4・ttk.Labelttk.Comboboxtk.Button×4(ループで生成)・ttk.Button を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  5. 5
    イベントを接続する

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(next_question())につなぎます。ループ生成のボタンは command=lambda idx=i: self._answer(idx) の形で接続します。bind("<<ComboboxSelected>>", ...) の登録も忘れずに。

  6. 6
    中心になるメソッドを実装する

    アプリの本体である next_question()(19行)・_answer()(17行)・_on_mode_change()(4行) を実装します。

  7. 7
    動作確認する

    python app073.py で起動し、各ボタンが反応することを確認します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。

💡 ダークモードを追加する

bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。

💡 結果をファイルに保存する

このコードに保存処理はないため、表示中の数値や結果はアプリを閉じると消えます。テキストファイルへ書き出して、次回起動時に読み込む機能を追加してみましょう。

💡 設定ダイアログ

フォントサイズや色などの設定をユーザーが変更できるオプションダイアログを追加しましょう。

8. よくある問題と解決法

❌ 文字のフォントが崩れる・見た目が違う

原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。

解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。

❌ 写経したら全部のボタンが同じ動きになる

原因:ループでボタンを作るとき、このコードは command=lambda idx=i: self._answer(idx) のように既定引数でループ変数を固定しています。これを省略してループ変数を直接書くと、ループ終了後の最後の値が全ボタンで共有されてしまいます(Python のクロージャの有名な罠です)。

解決法:掲載コードのとおり、lambda の既定引数でその時点の値を渡してください。

❌ ウィンドウの大きさが画面に合わない

原因:geometry("620x480") の固定サイズで起動するためです。

解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。出題されるのは7〜20行の PREFECTURES に入っている47件だけで、東京都の答えは都庁のある「新宿区」として登録されています。成績も苦手問題の重み(33行)もメモリの中だけにあり、ウィンドウを閉じると消えます。

  1. 課題1:不正解のときに正解のボタンも色で示す

    いま不正解を選ぶと、押したボタンだけが赤くなります(131行)。文字を読まなくても正解が分かるよう、正解のボタンにも色を付けましょう。

    期待結果:不正解のボタンを押すと、そのボタンが赤(#ffcdd2)になり、正解のボタンが緑(#c8e6c9)に変わる。下の文字は「不正解。正解: 〇〇」のまま

    合格条件

    • 不正解のとき、4つのうち正解のボタンだけが緑になる
    • 正解したときの見た目は今までどおり(押したボタンだけが緑)
    • 「次の問題」を押すと4つとも元の色に戻る

    ヒント①(どこを触るか): 触るのは1か所です。_answer() の不正解側、130〜132行のあたりです。
    ヒント②(使うもの): self.choices.index(self._correct_answer) で正解のボタン番号が分かります。色の付け方は127行の self.choice_btns[idx].config(bg="#c8e6c9") が手本です。
    つまずきやすい点: self.current は都道府県名なので、「都道府県→県庁」モードではボタンに出ている県庁所在地と一致しません。115行で保存した self._correct_answer を使えば、どちらのモードでも正しいボタンを指せます。色を戻す処理は自分で書かなくてよく、次の出題のときに117行が4つとも self.default_btn_bg(75行で読んだ OS 既定のボタン色)へ戻します。

  2. 課題2:出題方向を変えたときに成績を引き継ぐか選べるようにする

    いまは出題方向を変えても正解数がそのまま続きます(85〜86行)。方向ごとに成績を分けたい人のために、リセットするかたずねましょう。

    期待結果:出題方向のコンボボックスを切り替えると「成績もリセットしますか?」とたずねられ、「はい」を選ぶと右上が「正解 0 / 0 (0%)」に戻る

    合格条件

    • 「はい」で正解数と出題数がどちらも0に戻り、右上の表示も「正解 0 / 0 (0%)」になる
    • 「いいえ」では今までどおり成績が続く
    • どちらを選んでも新しい問題が1問出る(86行)

    ヒント①(どこを触るか): 触るのは1か所です。_on_mode_change() の85行と86行のあいだです。
    ヒント②(使うもの): messagebox.askyesno("確認", "成績もリセットしますか?") が真のとき、self.scoreself.total に0を入れます。messagebox は3行目で読み込み済みですが、このコードではまだ一度も使っていません。
    つまずきやすい点: 右上のラベルは136行の中でしか書き換わらないため、0を入れるだけでは前の数字が残ったままです。self.score_label.config(text="正解 0 / 0 (0%)") と自分で書き戻すと、56行の起動直後と同じ表示にそろいます。苦手問題の重み(33行の self.weights)は成績とは別に持っているので、0に戻しても出やすさの偏りは引き継がれます。

  3. 課題3:保存機能の追加

    処理結果をファイルに保存する機能を追加しましょう。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリに挑戦しましょう。

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
独学を1冊で体系化するなら

写経しながら「なぜこう書くのか」が気になり始めたら、入門書を1冊通して読むと断片的な知識がつながります。Python入門書のおすすめ2冊(当サイトの参考書ランキング総合1〜2位)で、独学者向けの最初の1冊を比較しています。