ドブル (Dobble / Spot it!) 自作 (2): カードのレイアウト

## はじめに

ボードゲーム [ドブル](https://amzn.to/45XW20t) (Dobble / Spot it!) を Python 実装した **[dobble-maker][dobble-maker]** の解説です。

https://github.com/youce23/dobble-maker

本記事では**カード内でのシンボルのレイアウト**について説明します。

- [ドブルデッキの構築][art1]
  - 任意の 2 枚のカードを選んだ時に、必ず共通するシンボルが 1 つだけ存在するような組み合わせを決定する
- **カード内でのシンボルのレイアウト**
  - シンボル間の重なり無く、程よくランダムに配置する
- [生成ツールの紹介][art3]
  - 画像ファイルから印刷用 PDF まで生成するツールの紹介
  - **仕組みはいいから作りたい！** という方はこちらです
- [2 枚に 2 つのシンボルが共通するデッキ][art4]
  - 通常ドブルとは異なる特性を持ったデッキを作ります
- [3 枚に 1 つのシンボルが共通するデッキ][art5]
  - また別の特性を持つデッキです
- [派生版のアイデア帖][art6]
  - デッキの解釈を変えて新しいデッキについて考えてみます

[dobble-maker]: https://github.com/youce23/dobble-maker
[art1]: https://qiita.com/youce/items/e67b0bf50d955a98c70e
[art2]: https://qiita.com/youce/items/0cf902357b183500fb45
[art3]: https://qiita.com/youce/items/8cb30104759c1fe5f10f
[art4]: https://qiita.com/youce/items/aebd114ab01a8d0d4292
[art5]: https://qiita.com/youce/items/96497bcf5db616268685
[art6]: https://qiita.com/youce/items/547d732995be7c8ef839

## 更新履歴

2023/10/22: [方法3](#方法-3-centroidal-power-diagrams) を追記

## やりたいこと

ドブルのカードにシンボルを描画するにあたって、やりたいことを整理しました。

- カードは任意サイズの円、または長方形
- カード内に形が異なる$N$個のシンボルを重ならないように配置する
  - 各シンボルのサイズ、形は異なる
- 各シンボルは程よくランダムに配置

## 方法 1: 完全ランダムに配置

最初に試したのがこの方法です。とにかくランダムに入れてみる。

結論からいうと、この方法は失敗なので、[方法 2](#方法-2-重心ボロノイ分割で位置決め)まで読み飛ばしていただいてもかまいません。

### 手順

1. カード型の空のキャンバスを準備
1. 1 つ目のシンボルをランダムにリサイズ、回転、位置決めして描画
1. 次のシンボルをランダムにリサイズ、回転、位置決めして描画
   1. 描画済みのシンボルと重なるなら、再度ランダムに描画
   1. 一定回数試しても入らないなら、キャンバスを空にして 1 つ目のシンボルからやり直し

### 結果

円形のカードに$N=5$個のシンボルを描画してみます。

![0_ol.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/e2aa55fd-23ac-010a-c579-8e028b523fc4.png)
1 枚目の 1 回目、入らないのでやり直し

![1_ol.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/eb231e53-72f0-466b-52b3-dde1593e4d54.png) ![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/5663563e-2528-3053-59d4-77800dcd39d1.png)
1 枚目の 2 回目、入ったので描画

![2_ol.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/a0d6341d-383a-225a-c735-e252a97567ce.png) ![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/f2de16c2-5f42-8bcf-9796-6edbfefaf26b.png)
2 枚目の 1 回目、入ったので描画

![3_ol.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/01d537ae-95fa-9958-6956-791a4581ec73.png)
3 枚目の 1 回目、入らないのでやり直し

・・・

![4_ol.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/df9157d1-624b-da05-16bf-f1c4a5620b50.png) ![4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/0e84a4cc-fd07-42de-1715-180095f0e4c7.png)
対象とするシンボルがすべて描画できたら、このカードは終わり。

[dobble-maker][dobble-maker]では、以下を`"random"`に設定することで、`layout_images_randomly_wo_overlap()`から`_layout_random()`が呼び出されます。

```python:dobble_maker.py
def main():
    ...
    layout_method: Literal["random", "voronoi"] = "random"
    ...
```

### 方法 1 の問題

さて、この方法はシンプルなんですが、問題がありました。

以下は生成された最初の 6 枚のカードです。
隙間が多くてスカスカで、小さくて見難いシンボルもありますね。
これを嫌ってシンボルのリサイズ率を大きくすると、処理がなかなか終わりません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/b11b9eb4-f347-6b06-2764-1d37dbbd386e.png)

## 方法 2: 重心ボロノイ分割で位置決め

次に試したのは、シンボルの描画位置を重心ボロノイ分割で決める方法です。

### 手順

1. カード型の閉領域内を$N$個の母点で重心ボロノイ分割する
1. 各母点の位置を各シンボルの中心位置として、シンボルを回転し、ボロノイ領域に収まったらそこで描画
   1. 入らなければシンボルを少し縮小して、再度回転して描画できるか確認、を描画できるまで繰り返す

以下、解説します。

### カード型の閉領域で重心ボロノイ分割

#### 重心ボロノイ分割 (CVT)

重心ボロノイ分割 (CVT: Centroidal Voronoi Tessellation) とは、任意の凸領域を、任意の個数の領域（ボロノイ領域）に分割する方法です。

1. 凸領域内にランダムに初期点を配置。この点を母点と呼ぶ。
1. 各母点に基づき、凸領域をボロノイ分割[^voronoi]する
1. 各母点をボロノイ領域の重心に移動する
1. 移動後の母点で再度ボロノイ分割する
1. これを指定の回数（または移動量が閾値以下になるまで）繰り返す

[^voronoi]: 隣接する 2 つの母点を結ぶ線分の垂直二等分線を境界とする領域（ボロノイ領域）を求める処理のこと

![cvt_circle_20.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/592e1896-ea1f-02c4-99c6-e4bda06eb591.gif)

この図は、円領域を 12 個の母点で重心ボロノイ分割（20 回反復）した図です。
何度か計算を繰り返すことで、母点が良い感じに均等に散らばる様子がわかります。
各領域の大きさも均一に揃っていますね。

これを使って、カード内におけるシンボルの配置位置とシンボルの描画範囲を決めます。

#### 実装

[dobble-maker][dobble-maker]では`voronoi.py`の`cvt()`で CVT を実装しています。

まず母点の初期値として、`_rand_pts_in_poly()`で所定の領域内に点群をランダム配置します。これは以下を参考にしました。

https://www.matecdev.com/posts/random-points-in-polygon.html

次に、`_bounded_voronoi()`で閉領域に対するボロノイ分割を行います。こちらは以下のほぼコピペです。

https://qiita.com/supbon2/items/30e0cb49c9338e721b8c

そのあとは母点を重心に移動し、再度ボロノイ分割、を繰り返しです。

### 各シンボルを母点の位置に描画

あとは母点の位置に、ボロノイ領域に収まる範囲でシンボルがなるべく大きく入るようにくるくる回しながら試していきます。

1 つ目のシンボルを描画します。

![card_0.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/f8ac485e-1901-42df-35f3-3e620de6c100.gif) ![card_0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/9c9e6d63-b895-ca4c-baeb-6107333d4210.png)

2 つ目のシンボルを描画します。

![card_1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/fe84666d-bcb2-6bfa-48d1-42ce3b4dfdf2.gif) ![card_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/74641233-c540-4db0-dd91-eaced8dfb9a1.png)

・・・

![card_4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/0e494b04-f1d0-819c-8d01-5b96c3a07d6c.png)

全部入ったら終わりです。

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/d4f92a0b-a65d-5369-809d-37e7fee8ffc6.png)

こちらが方法 2 で作った最初の 6 枚のカード（CVT を 10 回反復）です。

完全にランダムで作った[方法 1](#方法-1-の問題) に比べて、大きさが揃って、また配置にも偏りがありません。
ゲームなので好みで決めて良いのですが、ある程度大きさが揃っていた方が遊びやすいと思います。

またもっとランダムに配置したい場合には、CVT の反復回数を減らすことで調整ができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/d210ea83-ff14-e805-4665-57fa729ae334.png)
こちらは反復回数を 2 回にした場合です。シンボルの大きさに差が出てきていますね。

[dobble-maker][dobble-maker]では、以下を`"voronoi"`に設定することで、`layout_images_randomly_wo_overlap()`から`_layout_voronoi()`が呼び出されます。
反復回数を変更したい場合は`cvt()`の呼び出し部分に`n_iters`引数を追加してください。

```python:dobble_maker.py
def main():
    ...
    layout_method: Literal["random", "voronoi"] = "voronoi"
    ...
```

## 方法 3: Centroidal Power Diagrams

方法 2 で当初の狙いは達成できますが、「シンボルサイズの調整」を自由に行うことができませんでした（CVT の反復回数での調整は位置も影響を受けてしまう）。

そこで、各母点に重みを与えてボロノイ分割を行う方法の一種である Power Diagram（パワー図）を導入します。

### Power Diagram

Power Diagram は、ボロノイ分割を行う際に、各母点の重みとして「円の半径」を付与します。この方法で生成されるボロノイ領域は、簡単にいうと「各円の交点を結ぶ直線を境界とする領域」となります[^pd]。

[^pd]: 「ある母点の円の中に別の母点の円が内包される」場合や、「母点間の距離が円の距離の和より大きい」場合などはこうはならないので、上記は正確な説明ではありません

![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/b727c167-632f-4d04-0090-4266088fb4ee.png)
[Wikipedia](https://en.wikipedia.org/wiki/Power_diagram)より

全ての母点に同じ重みを与える（＝円の大きさが等しい）と、通常のボロノイ分割と同じ結果になりますが、与える重みを変えることで、ボロノイ領域の大きさをある程度制御することが可能です。

### Centroidal Power Diagram

Power Diagram はボロノイ分割の拡張手法であり、CVT の領域生成処理を Power Diagram に置き換えることが可能です。
これはCentroidal Power Diagram (CPD) と呼ばれています。

![cpd.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/8bb36fde-61c7-4f52-88ae-e799e7ef3d0f.gif)

この図は、円領域に対して 12 個の母点で CPD を適用し、20 回の反復計算をした結果です。
各母点を濃い青の点で、各母点の重みを薄い青の円で示しています。

||1 回目|20 回目|
|---|---|---|
|CPD|![cpd_00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/bc4bd60c-105e-fc4b-fb6f-024005069f13.png) |![cpd_19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/344c79de-3538-ca30-7ab3-b77046bd3a13.png)
|CVT|![cvt_00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/f47bcea4-99a2-c0aa-1c52-97bae939708b.png) |![voronoi_19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3562294/02512819-62a1-16b5-fd45-90458b9bbf01.png)|

こちらは同じ母点の初期配置で CPD と CVT を適用した場合の、 1 回目及び 20 回目の計算結果です（CPD の結果は前述の GIF から抜粋）。

CPD の 1 回目 と CVT の 1 回目は、母点の配置は同じながら得られる領域が異なっています。
また 20 回目の結果を比べると、重み（円の半径）が領域のサイズに影響を与えていることがよくわかりますね。

さらに CPD の 1 回目をよく見ると、母点のない領域や複数の母点を含む領域が存在します。このように Power diagram では、必ずしも母点が（自身の）ボロノイ領域内にあるとは限らない、という特徴があります。
そのため CPD の計算時には注意が必要です。

これを使って、カードに描画するシンボルサイズの大きさを制御します。

#### 実装

[dobble-maker][dobble-maker]では`laguerre_voronoi_2d.py`で Power Diagram の処理を実装し、これを`voronoi.py`から呼び出す実装になっています。

`laguerre_voronoi_2d.py`[^laguerre]はこちらの[GitHub Gist](https://gist.github.com/marmakoide/45d5389252683ae09c2df49d0548a627)で公開されている実装をベースとしています[^cpd_impl]。

[^laguerre]: Laguerre-Voronoi diagrams は Power diagrams の別名
[^cpd_impl]: 前述した CPD の特徴含む諸々の都合で、単純なコピペと関数差し替えでは動かなかったので、少し手を加えています

ボロノイ分割を Power diagram で置き換えたら、それ以外の処理はすべて方法 2 と同じです。

[dobble-maker][dobble-maker]では、母点の重みを`radius_p`で調整する仕様にしています。

```python:dobble_maker.py
def cvt(..., radius_p: float, ...):
    ...
    radius = None
    if radius_p > 0.0:
        poly = Polygon(bounding_points)
        area_per_site = poly.area / n_sites  # 外側の図形の面積を母点で均等に分割した場合の面積を radius の基準とする
        r_basis = np.sqrt(area_per_site / np.pi)  # 基準半径
        radius = r_basis / 2 + radius_p * r_basis / 2 * np.random.random(n_sites)
    ...

def main():
    ...
    # "voronoi"における各母点の半径を決めるパラメータ (0.0なら半径なし, つまり通常のボロノイ分割として処理)
    radius_p: float = 0.5
    ...
```

外側の図形（カード全体を表す図形）の面積 `poly.area` を基準に、各母点の基準の面積を求め、それに対する割合を`radius_p`で調整しています。

#### パラメータ調整

母点の重みとイテレーション回数を調整しないといけないので、少し調整が難しくなってしまいましたが、重み、及びイテレーション回数の片方を固定しながら、他方を少しずつ変える、というのを何度か試してみると、なかなか良い感じの結果が得られます。

## おわり