Pythonを使って仮想ジョイスティックのGUIを作る

# はじめに
この記事はPython アドベントカレンダー2日目の記事です。

https://qiita.com/advent-calendar/2024/python

PythonのTkinterでマウスで操作することができる仮想ジョイスティックを作りました。
ゲームパッド本体が手元にない場合でもこれを使用することでロボットの操作などをすることができます。

https://x.com/ko1_baseba11/status/1861030126361919886?s=46&t=bgT0nT1kL3s1j6o4lYNA5Q

# 環境
以下の環境で動作を確認しています。

| 項目 | バージョン |
|:-: | :-:|
| Windows |  11 |
| WSL | 2 |
| Ubuntu | 22.04 |
| Python | 3.10.12 |

# 実装
まずソースコード全体を以下に貼ります。

```python
import tkinter
import dataclasses


@dataclasses.dataclass
class WindowSize:
    width: int
    height: int


@dataclasses.dataclass
class Position:
    x: float
    y: float


_WINDOS_SIZE = WindowSize(200, 300)
_CANVAS_SIZE = WindowSize(200, 200)
_CIRCLE_SIZE = 20


class MousePosition(tkinter.Frame):

    def __init__(self, master):
        super().__init__(master, height=_WINDOS_SIZE.height, width=_WINDOS_SIZE.width)

        canvas = tkinter.Canvas(
            master,
            background="white",
            height=_CANVAS_SIZE.height,
            width=_CANVAS_SIZE.width,
        )
        self._canvas = canvas
        self._virtual_stick_circle = canvas.create_oval(
            _CANVAS_SIZE.width / 2 - _CIRCLE_SIZE,
            _CANVAS_SIZE.height / 2 - _CIRCLE_SIZE,
            _CANVAS_SIZE.width / 2 + _CIRCLE_SIZE,
            _CANVAS_SIZE.height / 2 + _CIRCLE_SIZE,
            tag="oval",
        )
        canvas.create_line(
            _CANVAS_SIZE.width / 2, 0, _CANVAS_SIZE.width / 2, _CANVAS_SIZE.height
        )
        canvas.create_line(
            0, _CANVAS_SIZE.height / 2, _CANVAS_SIZE.width, _CANVAS_SIZE.height / 2
        )
        canvas.grid(row=0, column=0)
        canvas.bind("<Motion>", self._mouse_callback)
        canvas.bind("<Button-1>", self._click_callback)

        self._position_label = tkinter.Label(master)
        self._position_label.grid(row=1, column=0)

        self._last_position = Position(0.0, 0.0)
        self._is_clicked = False

    def _mouse_callback(self, event):
        position_from_center = Position(
            event.x - 1 / 2 * _CANVAS_SIZE.width,
            1 / 2 * _CANVAS_SIZE.height - event.y,
        )

        self._position_label["text"] = (
            str(position_from_center.x) + ", " + str(position_from_center.y)
        )

        if self._is_clicked:
            self._canvas.move(
                self._virtual_stick_circle,
                -1 * (self._last_position.x - position_from_center.x),
                self._last_position.y - position_from_center.y,
            )
            self._last_position.x = position_from_center.x
            self._last_position.y = position_from_center.y

    def _click_callback(self, event):
        if self._is_clicked:
            self._is_clicked = False
        else:
            self._is_clicked = True


if __name__ == "__main__":
    root = tkinter.Tk()
    MousePosition(root)
    root.mainloop()
```


## GUIの作成
図形描画用のCanvasと数値表示用のLabelを用意します。

```python:Canvasの作成
        canvas = tkinter.Canvas(
            master,
            background="white",
            height=_CANVAS_SIZE.height,
            width=_CANVAS_SIZE.width,
        )
```
```python:Labelの作成
        self._position_label = tkinter.Label(master)
```


TkinterのCanvas上に`create_oval`を使用して円を描画します。
`create_oval`の返り値を、`canvas.move`の引数に入れることでその丸を移動させることができます。

```python:Canvas上に円を描画
        self._virtual_stick_circle = canvas.create_oval(
            _CANVAS_SIZE.width / 2 - _CIRCLE_SIZE,
            _CANVAS_SIZE.height / 2 - _CIRCLE_SIZE,
            _CANVAS_SIZE.width / 2 + _CIRCLE_SIZE,
            _CANVAS_SIZE.height / 2 + _CIRCLE_SIZE,
            tag="oval",
        )
```
```python:円を動かす
            self._canvas.move(
                self._virtual_stick_circle,
                -1 * (self._last_position.x - position_from_center.x),
                self._last_position.y - position_from_center.y,
            )
```

## 動きをつける
bindでCanvas上の上をマウスカーソルが動いた時のイベントを取得してcallback関数を呼び出します。

```python:マウスカーソル移動のバインド
        canvas.bind("<Motion>", self._mouse_callback)
```

```python:マウスカーソル移動のコールバック関数
    def _mouse_callback(self, event):
        position_from_center = Position(
            event.x - 1 / 2 * _CANVAS_SIZE.width,
            1 / 2 * _CANVAS_SIZE.height - event.y,
        )

        self._position_label["text"] = (
            str(position_from_center.x) + ", " + str(position_from_center.y)
        )

        if self._is_clicked:
            self._canvas.move(
                self._virtual_stick_circle,
                -1 * (self._last_position.x - position_from_center.x),
                self._last_position.y - position_from_center.y,
            )
            self._last_position.x = position_from_center.x
            self._last_position.y = position_from_center.y
```

また、このままだとON/OFFのような機能がないので、マウスクリックのイベントを取得してON/OFFのフラグを設定するようにします。

```python:クリックのバインド
        canvas.bind("<Button-1>", self._click_callback)
```

```python:クリックのコールバック関数
    def _click_callback(self, event):
        if self._is_clicked:
            self._is_clicked = False
        else:
            self._is_clicked = True
```

## データクラスを使う
widthとheight、xとyを扱う必要があり、ややこしくなると思ったためdataclassを使用しています。
C言語などの構造体と同様に使用することができただのタプルやリストに比べて意味のあるデータとすることができるので可読性や拡張性が広がります。

```python
@dataclasses.dataclass
class WindowSize:
    width: int
    height: int


@dataclasses.dataclass
class Position:
    x: float
    y: float
```

# おわりに
今回は簡単なGUIを作成し、その中の図形をマウスカーソルの動きに沿って動かしてみました。
これで取得できたX,Y座標を使用してロボットなどの操作に使っていきたいです。