Pyxelでスマホ向けにタップだけでサクサク遊べるフリーセルを作った。
一度遊ぶと他のフリーセルアプリには戻れないくらいの快適さがあります!
こちらで遊べます。
遊び方:
操作は全てタップのみで実現しています。
- カードをタップすると自動でいい感じに移動する
- 連結したカードをタップすると、配下のカードも同時に移動する。(スーパームーブ)
- ホームセル(上部右側の青いセル)をタップするとカードが回収される
ソースはこちら。
本稿は、開発における仕様や実装の工夫を中心に説明する。
仕様編
自動移動ロジック
本作はスマホやタブレットでのプレイを想定しているが、もともとレトロゲー開発用のPyxelをエンジンにしているため、フリック操作は実現が難しいと判断した。そこで、タップ操作のみでプレイできたら快適だろうということで、実装の方針が定まった。
- 列の一番下の1枚のカード移動の優先順位
- 別の列の下に移動(右側の近い列から探索、端までいくと左端にループ)
- フリーセルに移動
- 空き列に移動
- ホームセルに移動
- 複数枚カード移動の優先順位
複数枚はフリーセルやホームセルには行けないので、以下の2択になる- 別の列の下に移動(右側の近い列から探索、端までいくと左端にループ)
- 空き列に移動
- フリーセルのカードの移動の優先順位
- 列の一番下に移動(左端から順に探索)
- 空き列に移動
- ホームセルに移動
※操作感はこちらの動きを大いに参考にしている。
スーパームーブ
複数の連結したカードを同時に移動する操作を「スーパームーブ」と呼ぶ。(本来は空きセルを使って1枚ずつ移動するところを、その過程を省略して一気に移動する。)
複数の連結したカードを一気に移動する場合、最大何枚まで移動できるかの計算式は、
同時移動可能カード枚数 = 2^空列の数 * (空フリーセルの数 + 1)
となる。これはWikipediaから拝借した。
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に記載されているMicroSoft Freecellルールを採用した:
- 2以下のカード:常に回収
- 3以上のカード:数が1つ低い反対の色カードが2枚とも回収されている場合
フリーセルへの移動の特殊操作
一旦ホームセルに移動したカードは戻せないルールのため、ホームセル移動は優先順位が最低になっている。
このままでは盤面が飽和状態のときしかホームセルへの移動ができなくなるため、特殊なタップルールを設定した。
ホームセルをタップすると次のカードがあれば回収する。
移動先のカードをタップするのは、直感的ではなく、フリーセルに慣れてる人ほど気づきづらいルールなので、ヘルプで説明することにした。
私が最初の300問を解いた経験では以上のロジックで支障なくプレイできている。
当初はマウスの右クリック、トラックパッドの二本指タップをホームセル移動に充てていたが、私の使っているiPhoneでは右クリック相当の操作ができなかったため、このような実装とした。
ちなみに、スマホでプレイする際にマウスカーソルを表示させない方法はこちらの記事のコードをそっくりそのまま拝借させていただきました。
「解けない問題」問題
フリーセルは99.999%の問題は解けると見積もられているが、ランダム生成では10万問に1問の割合で解けない問題が生成されてしまうようだ。そこでrosettacodeにあったWindows版フリーセルの盤面生成アルゴリズムを拝借することにした。
本アプリの盤面は完全にWindows版準拠である。少なくとも最初の32000問では、有名な#11982以外は解けることが証明されてるので、安心してプレイできる。
Window版フリーセルのヘルプには「フリーセルでは、どのゲームでも勝つ可能性があると信じられています (証明されてはいません)」というロマンあふれる名言があった。しかし当時の開発陣は解けない問題があることを知っていたらしく、20数名の人海戦術で全32000問クリアできるかテストプレイしたつもりだったが、#11982は検査をすり抜けてしまったようだ(笑)。今でこそソルバが解けるかを自動判定してくれるので、「信じられています」というようなロマンは無くなってしまった。
実装編
状態のもたせ方
ゲームを作るときは、状態は全てグローバル変数にしている。ダサいけど、オブジェクトに持たせると、引数でやりとりすることになり、コードが煩雑になるからだ。(この辺は自己流)
DECK = [] #列の状態(2次元配列)
HOME = [] #ホームセル
FREE = [] #フリーセル
MOVE = [] #移動中のカードを一時的に格納
UNDO = [] #一手前の状態をdeepcopyで格納
STATE = {
'isGameOver': False,
'isNewGame': True,
'time': 0,
'id': 0,
'newId': '',
'idSelection': False, #ゲームIDを選択画面表示中か
'help': False, # ヘルプ画面表示中か
}
思ったよりも状態が多くて、状態制御に苦労した。
カードだけはオブジェクトにした。
カードの数字、スーツ、位置、移動関連情報(移動元、移動先、移動中のフレーム経過時間)、そして自身を描画する関数だけを持っている。
通常は上記のDECKなどのリストに格納されていて、全体のdraw()関数の中で、位置を算出してから描画している。
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秒なので、これならプレイに支障はないと判断した。
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枚のカードを描画している。
♠️❤️♣️♦️と赤黒交互に置くことで、フリーセルの連結条件の演算がしやすくなる(足した数が奇数なら連結)。
開発を終えて
試遊してるうちに、他のフリーセルアプリにはない快適さが実現できつつあることに気づいて、開発にも熱が入った。直感的さと快適性はかなり追求したつもりだ。
自分自身がとことんやりつづけられるゲームを目指して作ったし、幸いそれが実現できている。同じくらいハマってくれる方がいたらうれしい。
ゲームには、シナリオ、キャラデザ、音楽、システム設計、プログラムなど多種な役割があるが、プログラマは創造性がいちばん低い職種と思われがちだ。しかし、できた試作を最初に触るのはプログラマ。そこでの試行錯誤、仕様書に書ききれない微細な部分の調整はプログラマのセンスが介在する。プログラマの審美眼を通過したものが他のメンバに共有される。特に快適性の面においてゲームプログラマが果たすウェイトは小さくないと感じた。