Curosor(AI)でGalton BoardシミュレーションをPythonで作ってみた

# Curosor(AI)でGalton BoardシミュレーションをPythonで作ってみた
Youtube動画でダルトン板のショートが回ってきたので、お？と思いPythonで作ってみた(作らせた)。僅か15分。いい時代ですね。どなたかの酒のつまみにどうぞｗ
(手抜き記事で済みません…)
![rapture_20250505142124.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/597849/0ce5d304-9eb2-4b5c-93d0-36997144c1cc.png)

~~~
# -------------------------------------------------------
# Galton Board Simulator(EMYH)
# Generated by Cursor
# -------------------------------------------------------
import tkinter as tk
import random
import time
import math
from tkinter import simpledialog

class GaltonBoard:
    def __init__(self, root, rows=8, balls=100):
        self.root = root
        self.rows = rows
        self.balls = balls
        self.canvas_width = 400
        self.canvas_height = 500
        self.pins = []
        self.bins = [0] * (rows + 1)
        self.ball_speed = 5  # ボールの落下速度

        self.canvas = tk.Canvas(root, width=self.canvas_width, height=self.canvas_height, bg="white")
        self.canvas.pack()

        self.create_pins()
        self.draw_bins()
        self.animate_balls()

    def create_pins(self):
        """ ピンを配置 """
        for row in range(self.rows):
            for col in range(row + 1):
                x = self.canvas_width // 2 + (col - row / 2) * 40
                y = 50 + row * 40
                self.pins.append((x, y))
                self.canvas.create_oval(x-5, y-5, x+5, y+5, fill="black")

    def get_pin_xs_for_row(self, row):
        # 指定した段のピンのx座標リストを返す
        return [self.canvas_width // 2 + (col - row / 2) * 40 for col in range(row + 1)]

    def animate_balls(self):
        """ ボールの落下をアニメーション化（ピンの位置で跳ねる, 加速度付き, 全体で約15秒, ピンの真下でのみ跳ねる, 時間制御, ボール数表示） """
        import time
        total_time = 15.0  # 全体の目標時間（秒）
        start_time = time.perf_counter()
        accel = max(0.5, 20 / self.rows)
        for ball_idx in range(self.balls):
            row = 0
            col = 0  # 最上段のピンは1つだけ
            x = self.get_pin_xs_for_row(0)[0]
            y = 50
            ball = self.canvas.create_oval(x-5, y-5, x+5, y+5, fill="red")

            for row in range(self.rows):
                pin_xs = self.get_pin_xs_for_row(row)
                x = pin_xs[col]
                target_y = 50 + row * 40
                velocity = 1
                while y < target_y:
                    elapsed = time.perf_counter() - start_time
                    remaining_balls = self.balls - ball_idx
                    remaining_time = max(total_time - elapsed, 0.001)
                    remaining_steps = remaining_balls * (self.rows * 2)
                    base_sleep = max(remaining_time / remaining_steps, 0.0005)

                    self.canvas.move(ball, 0, velocity)
                    y += velocity
                    velocity = min(velocity + accel, 20)
                    self.root.update()
                    time.sleep(base_sleep)
                if y != target_y:
                    self.canvas.move(ball, 0, target_y - y)
                    y = target_y
                    self.root.update()
                direction = random.choice([0, 1])  # 0:左, 1:右
                if direction == 1:
                    col += 1
                if row < self.rows - 1:
                    next_pin_xs = self.get_pin_xs_for_row(row + 1)
                    next_x = next_pin_xs[col]
                    dx = (next_x - x)
                    step = 1 if dx > 0 else -1
                    moved = 0
                    velocity = 1
                    while abs(moved) < abs(dx):
                        elapsed = time.perf_counter() - start_time
                        remaining_balls = self.balls - ball_idx
                        remaining_time = max(total_time - elapsed, 0.001)
                        remaining_steps = remaining_balls * (self.rows * 2)
                        base_sleep = max(remaining_time / remaining_steps, 0.0005)

                        self.canvas.move(ball, step * velocity, 0)
                        x += step * velocity
                        moved += abs(step * velocity)
                        velocity = min(velocity + accel, 20)
                        self.root.update()
                        time.sleep(base_sleep)
                    if x != next_x:
                        self.canvas.move(ball, next_x - x, 0)
                        x = next_x
                        self.root.update()
            bin_index = col
            self.bins[bin_index] += 1
            self.canvas.delete(ball)
            self.update_bins()
            self.update_info(ball_idx + 1)
        # シミュレーション完了後にメッセージ表示（画面中央、フォントサイズ12）
        self.canvas.create_text(self.canvas_width // 2, self.canvas_height // 2, text="Q:終了、R:再実行、P:パラメータ変更", font=("Arial", 12, "bold"), fill="black", tags="endmsg")

    def get_bottom_pin_xs(self):
        # 最下段のピンのx座標リストを返す
        bottom_row = self.rows - 1
        xs = []
        for col in range(self.rows + 1):
            x = self.canvas_width // 2 + (col - self.rows / 2) * 40
            xs.append(x)
        return xs

    def draw_normal_curve(self):
        # binの範囲を細かく分割して滑らかな正規分布曲線を描画
        xs = self.get_bottom_pin_xs()
        n_points = 100
        n = self.rows
        mu = n / 2
        sigma = (n / 4) ** 0.5
        min_x = xs[0]
        max_x = xs[-1]
        # x軸上で細かくサンプリング
        points = []
        for i in range(n_points + 1):
            frac = i / n_points
            # bin indexに対応する理論値
            bin_pos = frac * n
            # x座標を線形補間
            cx = min_x + frac * (max_x - min_x)
            # 正規分布値
            p = (1 / (sigma * (2 * math.pi) ** 0.5)) * math.exp(-((bin_pos - mu) ** 2) / (2 * sigma ** 2))
            points.append((cx, self.canvas_height - (p * 200 / max(1e-8, p))))
        # 正規化して最大値を200ピクセルに合わせる
        max_p = max((1 / (sigma * (2 * math.pi) ** 0.5)) * math.exp(-((i - mu) ** 2) / (2 * sigma ** 2)) for i in range(n+1))
        for i in range(len(points)):
            frac = i / n_points
            bin_pos = frac * n
            p = (1 / (sigma * (2 * math.pi) ** 0.5)) * math.exp(-((bin_pos - mu) ** 2) / (2 * sigma ** 2))
            points[i] = (points[i][0], self.canvas_height - (p / max_p) * 200)
        # 曲線を描画
        for i in range(len(points) - 1):
            self.canvas.create_line(points[i][0], points[i][1], points[i+1][0], points[i+1][1], fill="red", width=2, tags="normal_curve")

    def update_bins(self):
        self.canvas.delete("bin")
        self.canvas.delete("normal_curve")
        xs = self.get_bottom_pin_xs()
        max_count = max(self.bins) if max(self.bins) > 0 else 1
        for i, count in enumerate(self.bins):
            x1 = xs[i] - 15
            x2 = xs[i] + 15
            y1 = self.canvas_height
            y2 = self.canvas_height - (count / max_count) * 200
            self.canvas.create_rectangle(x1, y1, x2, y2, fill="blue", outline="black", tags="bin")
        self.draw_normal_curve()

    def update_info(self, dropped_balls=None):
        # 画面上部に情報を表示
        self.canvas.delete("info")
        if dropped_balls is None:
            dropped_balls = 0
        info_text1 = f"段数: {self.rows}    ボール数: {self.balls}    落としたボール: {dropped_balls}"
        info_text2 = f"bin: {self.bins}"
        self.canvas.create_text(self.canvas_width // 2, 20, text=info_text1, font=("Arial", 12), fill="black", tags="info")
        self.canvas.create_text(self.canvas_width // 2, 40, text=info_text2, font=("Arial", 12), fill="black", tags="info")

    def draw_bins(self):
        xs = self.get_bottom_pin_xs()
        for i in range(len(xs)):
            x1 = xs[i] - 15
            x2 = xs[i] + 15
            y1 = self.canvas_height
            y2 = self.canvas_height
            self.canvas.create_rectangle(x1, y1, x2, y2, fill="blue", outline="black", tags="bin")
        self.draw_normal_curve()
        self.update_info(0)

if __name__ == "__main__":
    import sys
    def restart_board(rows, balls):
        for widget in root.winfo_children():
            widget.destroy()
        board = GaltonBoard(root, rows=rows, balls=balls)
        root.board = board

    root = tk.Tk()
    root.title("ダルトン板シミュレーション")
    default_rows = 5
    default_balls = 100
    restart_board(default_rows, default_balls)

    def on_key(event):
        if event.char == 'q':
            root.destroy()
        elif event.char == 'r':
            restart_board(root.board.rows, root.board.balls)
        elif event.char == 'p':
            rows = simpledialog.askinteger("段数の入力", "段数を入力してください (2～12):", initialvalue=root.board.rows, minvalue=2, maxvalue=20)
            balls = simpledialog.askinteger("ボール数の入力", "ボール数を入力してください (1～2000):", initialvalue=root.board.balls, minvalue=1, maxvalue=2000)
            if rows and balls:
                restart_board(rows, balls)

    root.bind('<Key>', on_key)
    root.mainloop()
~~~
以下はrequirements.txtです。
~~~
ansicon==1.89.0
appdirs==1.4.4
blessed==1.21.0
flyingcircus==0.1.4.1
jinxed==1.3.0
packaging==25.0
pytk==0.0.2.1
setuptools==80.3.1
setuptools-scm==8.3.1
wcwidth==0.2.13
~~~