Pyxelでタップだけでサクサク遊べるフリーセルを作った。

[Pyxel](https://github.com/kitao/pyxel/blob/main/docs/README.ja.md)でスマホ向けにタップだけでサクサク遊べるフリーセルを作った。
一度遊ぶと他のフリーセルアプリには戻れないくらいの快適さがあります！

[こちら](https://tiquer17.github.io/pyxel/freecell/)で遊べます。


![pyxel-20250129-144035.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/445370/a57dd3c7-bff7-91fb-4598-a62150481286.gif)

# 遊び方：

4つのフリーセル（上部左側の緑セル）を駆使して、すべてのカードをホームセル（上部右側の青セル）に片付けるゲーム。

- カードは黒赤交互かつ降順に列の末尾に移動・連結できる
- ホームセルにはスーツごとにAから順番に片付ける
- 空きフリーセルや空き列には自由にカードを移動できる

# 操作方法

操作は全てタップのみで行う（本アプリ独自の操作方法）。

- カードをタップすると自動的に移動する
- 連結したカードをタップすると、十分な空きがあれば複数枚同時に移動する（スーパームーブ）
- ホームセルをタップすると次のカードが片付けられる

ソースは[こちら](https://github.com/tiquer17/pyxel/blob/main/freecell/)。


本稿は、開発における仕様や実装の工夫を中心に説明する。

# 仕様編

## 自動移動ロジック

本作はスマホやタブレットでのプレイを想定しているが、もともとレトロゲー開発用のPyxelをエンジンにしているため、フリック操作は実現が難しいと判断した。そこで、タップ操作のみでプレイできたら快適だろうということで、実装の方針が定まった。

- 列の一番下の1枚のカード移動の優先順位
  1. 別の列の下に移動（右側の近い列から探索、端までいくと左端にループ）
  1. フリーセルに移動
  1. 空き列に移動
  1. ホームセルに移動
- 複数枚カード移動の優先順位
  複数枚はフリーセルやホームセルには行けないので、以下の2択になる
  1. 別の列の下に移動（右側の近い列から探索、端までいくと左端にループ）
  1. 空き列に移動
- フリーセルのカードの移動の優先順位
  1. 列の一番下に移動（左端から順に探索）
  1. 空き列に移動
  1. ホームセルに移動

※操作感は[こちら](https://www.free-freecell-solitaire.com/freecell.html)の動きを大いに参考にしている。

## スーパームーブ

複数の連結したカードを同時に移動する操作を「スーパームーブ」と呼ぶ。（本来は空きセルを使って1枚ずつ移動するところを、その過程を省略して一気に移動する。）

複数の連結したカードを一気に移動する場合、最大何枚まで移動できるかの計算式は、

`同時移動可能カード枚数 = 2^空列の数 * (空フリーセルの数 + 1)`となる。これは[Wikipedia](https://en.wikipedia.org/wiki/FreeCell)から拝借した。

```python
num_free_cells = len([f for f in FREE if f is None])
num_empty_decks = len([d for d in DECK if len(d) == 0])
num_super_move = (2 ** num_empty_decks) * (num_free_cells + 1)
```

## オートムーブ

盤面のカードを自動回収する条件は、[素晴らしいFAQ](https://www.solitairelaboratory.com/fcfaq.html)に記載されているMicroSoft Freecellルールを採用した：
- 2以下のカード：常に回収
- 3以上のカード：数が1つ低い反対の色カードが2枚とも回収されている場合

## フリーセルへの移動の特殊操作

一旦ホームセルに移動したカードは戻せないルールのため、ホームセル移動は優先順位が最低になっている。
このままでは盤面が飽和状態のときしかホームセルへの移動ができなくなるため、特殊なタップルールを設定した。

> ホームセルをタップすると次のカードがあれば回収する。

移動先のカードをタップするのは、直感的ではなく、フリーセルに慣れてる人ほど気づきづらいルールなので、ヘルプで説明することにした。

![Screenshot 2025-01-29 at 14.42.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/445370/fd24c5be-6831-c5a5-29d1-dea9a5ff54bc.png)

私が最初の300問を解いた経験では以上のロジックで支障なくプレイできている。

当初はマウスの右クリック、トラックパッドの二本指タップをホームセル移動に充てていたが、私の使っているiPhoneでは右クリック相当の操作ができなかったため、このような実装とした。

ちなみに、スマホでプレイする際にマウスカーソルを表示させない方法は[こちら](https://qiita.com/igarashisan_t/items/6e5fc39dd5bafd195000)の記事のコードをそっくりそのまま拝借させていただきました。

## 「解けない問題」問題

フリーセルは99.999%の問題は解けると見積もられているが、ランダム生成では10万問に1問の割合で解けない問題が生成されてしまうようだ。そこでrosettacodeにあった[Windows版フリーセル盤面生成アルゴリズム](https://rosettacode.org/wiki/Deal_cards_for_FreeCell#Python)を拝借することにした。
本アプリの盤面は完全にWindows版準拠である。本作で選択できる99999問の中では有名な[#11982](https://freecellgamesolutions.com/stats.html)以外は解けることが証明されてるので、安心してプレイできる。

![Screenshot 2025-01-29 at 14.43.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/445370/08d41029-fa5d-36ad-d537-7d72c47158b5.png)
解けない#11982も忠実に再現（笑）

 > Window版フリーセルのヘルプには「フリーセルでは、どのゲームでも勝つ可能性があると信じられています (証明されてはいません)」というロマンあふれる名言があった。しかし当時の開発陣は解けない問題があることを知っていたらしく、20数名の人海戦術で全32000問クリアできるかテストプレイしたつもりだったが、#11982は検査をすり抜けてしまったようだ（笑）。今でこそソルバが解けるかを自動判定してくれるので、「信じられています」というようなロマンは無くなってしまった。

# 実装編

## 状態のもたせ方

ゲームを作るときは、状態は全てグローバル変数にしている。ダサいけど、オブジェクトに持たせると、引数でやりとりすることになり、コードが煩雑になるからだ。（この辺は自己流）

```python
DECK = [] #列の状態（2次元配列）
HOME = [] #ホームセル
FREE = [] #フリーセル
MOVE = [] #移動中のカードを一時的に格納
UNDO = [] #一手前の状態をdeepcopyで格納

STATE = {
    'isGameOver': False,
    'isNewGame': True,
    'time': 0,
    'id': 0,
    'newId': '', 
    'idSelection': False, #ゲームIDを選択画面表示中か
    'help': False, # ヘルプ画面表示中か
}
```

思ったよりも状態が多くて、状態制御に苦労した。

カードだけはオブジェクトにした。
カードの数字、スーツ、位置、移動関連情報（移動元、移動先、移動中のフレーム経過時間）、そして自身を描画する関数だけを持っている。
通常は上記のDECKなどのリストに格納されていて、全体のdraw()関数の中で、位置を算出してから描画している。

```python
class Card:
    def __init__(self, num, suit, x=0, y=0, fm=None, to=None, cnt=0):

    def draw(self):
```

## アニメーション

パズルゲームにおいて、エフェクトは邪魔になることが多いが、アニメーションが無いと、どこにカードが向かったか分からなくなることがあるので、ごく短時間のアニメーションを入れることとした。

移動中のカードはMOVE配列に入れ、移動元アドレスと移動先アドレスの間を4等分した地点を1フレームごとに移動する。移動時間は距離の遠近にかかわらず4フレーム≒0.07秒なので、これならプレイに支障はないと判断した。

```python
for m in MOVE:
  m.x = (m.fm[0] * (SPD - m.cnt) + m.to[0] * m.cnt) * T / SPD * 2
  m.y = (m.fm[1] * (SPD - m.cnt) + m.to[1] * m.cnt) * T / SPD + 40
  m.cnt += 1
```

## 画面リソース

Pyxelの反転機能を駆使して、数字や記号を判定させ、5つのパーツを組み合わせて1枚のカードを描画している。

♠️❤️♣️♦️と赤黒交互に置くことで、フリーセルの連結条件の演算がしやすくなる（足した数が奇数なら連結）。

![Screenshot 2025-01-29 at 15.28.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/445370/5cc62a41-999a-3400-27fc-5a3870d28667.png)

# 開発を終えて

試遊してるうちに、他のフリーセルアプリにはない快適さが実現できつつあることに気づいて、開発にも熱が入った。直感的さと快適性はかなり追求したつもりだ。

自分自身がとことんやりつづけられるゲームを目指して作ったし、幸いそれが実現できている。同じくらいハマってくれる方がいたらうれしい。

ゲームには、シナリオ、キャラデザ、音楽、システム設計、プログラムなど多種な役割があるが、プログラマは創造性がいちばん低い職種と思われがちだ。しかし、できた試作を最初に触るのはプログラマ。そこでの試行錯誤、仕様書に書ききれない微細な部分の調整はプログラマのセンスが介在する。プログラマの審美眼を通過したものが他のメンバに共有される。特に快適性の面においてゲームプログラマが果たすウェイトは小さくないと感じた。