【Pyxel】一筆書きゲームの作り方
今回は、レトロゲームエンジン「Pyxel」を使って、一筆書きゲームの作り方を紹介します。
なお Pyxel のセットアップについては、以下のページにまとめていますので、まだインストールできていない場合は、こちらを参考にしてください。
【関連記事】:【Pyxel】セットアップ手順と基本の関数について
■今回作るゲーム
今回作るのはこのように、プレイヤーキャラクターが通った道を塗りつぶし、全ての道を塗りつぶすとゲームクリアとなるゲームです。
■使用する素材
ニャンコの画像を使用しますので、以下のリンクからダウンロードします。
http://syun777.sakura.ne.jp/tmp/pyxel/cat.png
ソースコードを配置するフォルダと同じ場所に配置するようにしてください。
■最小限のコードを実装する
まずはPyxelを動かすための最小限のコードを記述します。
import pyxel
class App:
def __init__(self):
pyxel.init(160, 120, fps=60)
pyxel.run(self.update, self.draw)
def update(self):
pass
def draw(self):
pass
App()
実行して真っ暗な画面が表示されることを確認します。
■ニャンコの描画
ダウンロードした "cat.png" をソースコードと同じフォルダに配置して、以下のコードで描画します。
import pyxel
class App:
def __init__(self):
pyxel.init(160, 120, fps=60)
pyxel.image(0).load(0, 0, "cat.png")
pyxel.run(self.update, self.draw)
def update(self):
pass
def draw(self):
pyxel.cls(0)
pyxel.blt(0, 0, 0, 0, 0, 16, 16, 5)
App()
実行すると左上にニャンコが描画されます。
■マップの線を描画する
マップ情報を描画する準備として、格子状の線を描画します。
左上を少し開けて(4px, 4px)、そこから線を描画します。マップのチップは、5x4 とします。
class Map:
OFS_X = 4 # マップ描画開始座標(X)
OFS_Y = 4 # マップ描画開始座標(Y)
WIDTH = 5 # マップチップの横の数
HEIGHT= 4 # マップチップの縦の数
SIZE = 16 # 1つのマップチップのサイズ
@classmethod
def to_screen(cls, x, y):
px = cls.OFS_X + (cls.SIZE * x)
py = cls.OFS_Y + (cls.SIZE * y)
return px, py
class App:
def __init__(self):
pyxel.init(160, 120, fps=60)
pyxel.image(0).load(0, 0, "cat.png")
pyxel.run(self.update, self.draw)
def update(self):
pass
def draw(self):
pyxel.cls(0)
self.draw_back()
self.draw_player()
def draw_back(self):
for i in range(Map.WIDTH):
x1, y1 = Map.to_screen(i, 0)
x2, y2 = Map.to_screen(i, Map.HEIGHT)
pyxel.line(x1, y1, x2, y2, 7)
for j in range(Map.HEIGHT):
x1, y1 = Map.to_screen(0, j)
x2, y2 = Map.to_screen(Map.WIDTH, j)
pyxel.line(x1, y1, x2, y2, 7)
def draw_player(self):
pyxel.blt(0, 0, 0, 0, 0, 16, 16, 5)
App()
Mapクラスを定義して、マップの描画に必要な定数を定義し、マップチップ座標系からスクリーン座標に変換する関数 to_screen() を追加しました。
線の描画は、draw_back() を新たに追加してそこで行うようにしました。ニャンコ画像の描画も draw_player() に移動しています。
では、実行して線の描画を確認します。
すると、右側と下側の最後の線が描かれていません。必要な線の数は「マップチップの数+1」となるためです。
マップチップの数は 5x4 ですが、線の数は 6本x5本 となります。そのためコードを以下のように修正します。
def draw_back(self):
for i in range(Map.WIDTH + 1): # ループを1回増やす
x1, y1 = Map.to_screen(i, 0)
x2, y2 = Map.to_screen(i, Map.HEIGHT)
pyxel.line(x1, y1, x2, y2, 7)
for j in range(Map.HEIGHT + 1): # ループを1回増やす
x1, y1 = Map.to_screen(0, j)
x2, y2 = Map.to_screen(Map.WIDTH, j)
pyxel.line(x1, y1, x2, y2, 7)
このようにループの回数を+1に修正すると、線が正しく表示されます。
■ニャンコを正しい位置に描画。キー操作で動かす
ニャンコの位置がずれているので、正しい位置に描画するようにします。また、キー操作で移動するようにします。__init__() に self.x / self.y を追加します。
def __init__(self):
pyxel.init(160, 120, fps=60)
pyxel.image(0).load(0, 0, "cat.png")
# ニャンコの座標を初期化
self.x = 0
self.y = 0
pyxel.run(self.update, self.draw)
次に update() にニャンコを動かす処理を記述します。
def update(self):
# キー入力による移動
if pyxel.btnp(pyxel.KEY_LEFT):
self.x -= 1
if pyxel.btnp(pyxel.KEY_RIGHT):
self.x += 1
if pyxel.btnp(pyxel.KEY_UP):
self.y -= 1
if pyxel.btnp(pyxel.KEY_DOWN):
self.y += 1
# はみ出し判定
if self.x < 0:
self.x = 0
if self.x >= Map.WIDTH:
self.x = Map.WIDTH - 1
if self.y < 0:
self.y = 0
if self.y >= Map.HEIGHT:
self.y = Map.HEIGHT - 1
最後に描画位置の修正です。先ほど追加した draw_player() を修正します。
def draw_player(self):
px, py = Map.to_screen(self.x, self.y)
pyxel.blt(px, py, 0, 0, 0, 16, 16, 5)
実行して、ニャンコがカーソルキーの上下左右で動かせることを確認します。
枠の外に移動できないことも確認しておきます。
■マップ情報の作成
マップ情報を作成するために、Appクラスを修正します。
修正箇所は以下の通りです。
▼修正箇所
* __init__() に self.map を追加。"0" で初期化
* get_map() / set_map() を追加
* update() の最後で、プレイヤーがいる座標を塗りつぶす("1" を設定)
* draw() でマップ情報を描画する (塗りつぶされていたら矩形の灰色を描画)
class App:
def __init__(self):
pyxel.init(160, 120, fps=60)
pyxel.image(0).load(0, 0, "cat.png")
# ニャンコの座標を初期化
self.x = 0
self.y = 0
self.map = [0] * (Map.WIDTH * Map.HEIGHT)
pyxel.run(self.update, self.draw)
def get_map(self, x, y):
idx = x + (Map.WIDTH * y)
return self.map[idx]
def set_map(self, x, y, v):
idx = x + (Map.WIDTH * y)
self.map[idx] = v
def update(self):
# キー入力による移動
if pyxel.btnp(pyxel.KEY_LEFT):
self.x -= 1
if pyxel.btnp(pyxel.KEY_RIGHT):
self.x += 1
if pyxel.btnp(pyxel.KEY_UP):
self.y -= 1
if pyxel.btnp(pyxel.KEY_DOWN):
self.y += 1
# はみ出し判定
if self.x < 0:
self.x = 0
if self.x >= Map.WIDTH:
self.x = Map.WIDTH - 1
if self.y < 0:
self.y = 0
if self.y >= Map.HEIGHT:
self.y = Map.HEIGHT - 1
# 塗りつぶす
self.set_map(self.x, self.y, 1)
def draw_back(self):
for idx, v in enumerate(self.map):
if v == 0:
continue # 塗りつぶしていない
# インデックスからXY座標に変換
x = idx%Map.WIDTH
y = math.floor(idx/Map.WIDTH)
px, py = Map.to_screen(x, y)
pyxel.rect(px, py, px+Map.SIZE, py+Map.SIZE, 6)
for i in range(Map.WIDTH + 1):
x1, y1 = Map.to_screen(i, 0)
x2, y2 = Map.to_screen(i, Map.HEIGHT)
pyxel.line(x1, y1, x2, y2, 7)
for j in range(Map.HEIGHT + 1):
x1, y1 = Map.to_screen(0, j)
x2, y2 = Map.to_screen(Map.WIDTH, j)
pyxel.line(x1, y1, x2, y2, 7)
なお、マップ情報は "0" が塗りつぶされていない、"1" が塗りつぶしたもの、となります。
また、マップ情報は2次元配列ではなく、1次元配列としました。これはPythonでの記述がわかりやすいのでこのようにしました。その代わり、マップ情報にアクセスする場合、インデックス座標系(通し番号)に変換して取り出すようにしています。
ちなみに2次元配列を定義するときは、リスト内包表記を使用した以下の書き方となります。
self.map = [[0 for i in range(Map.WIDTH)] for j in range(Map.HEIGHT)]
では、実行してニャンコが通った道が塗りつぶされるのを確認します。
■リトライの実装
ゲームクリア判定を作る前に、簡単にやり直しできるようにします。まずは初期化処理をまとめるために、新たに init() を作成します。
def init(self):
# ニャンコの座標を初期化
self.x = 0
self.y = 0
self.map = [0] * (Map.WIDTH * Map.HEIGHT)
__init__() に書いた処理を移動させました。次に、__init__() で init() を呼び出すようにします。
def __init__(self):
pyxel.init(160, 120, fps=60)
pyxel.image(0).load(0, 0, "cat.png")
# 初期化
self.init()
pyxel.run(self.update, self.draw)
update() にリトライ処理を書きます。 "R" キーを押した時にやり直しができるようにしました。
def update(self):
# リトライ判定
if pyxel.btnp(pyxel.KEY_R):
self.init() # 初期化
# キー入力による移動
# ...
では、実行して "R" キーでやり直しができることを確認します。
■ゲームクリア判定の実装
全て塗りつぶしたらゲームクリアとします。ゲームの状態を判定するための定数をAppクラスの先頭に追加します。
class App:
MAIN = 0 # メイン
GAMECLEAR = 1 # ゲームクリア
次に、init() で状態変数 self.state を初期化します。
def init(self):
# ニャンコの座標を初期化
self.x = 0
self.y = 0
self.map = [0] * (Map.WIDTH * Map.HEIGHT)
self.state = self.MAIN
そして、クリア判定用の関数 check_gameclear() を追加します。
def check_gameclear(self):
for v in self.map:
if v == 0:
return False # 塗り残しがある
return True # 全て塗りつぶした
このクリア判定関数を使って、update() でクリア判定を行います。(塗りつぶし処理の下の追加)
def update(self):
# リトライ判定
# ...
# 塗りつぶす
self.set_map(self.x, self.y, 1)
# クリア判定
if self.check_gameclear():
self.state = self.GAMECLEAR
最後に、クリア状態であれば "GAME CLEAR" の表示をします。(draw() に追加)
def draw(self):
pyxel.cls(0)
self.draw_back()
self.draw_player()
if self.state == self.GAMECLEAR:
# ゲームクリア表示
pyxel.text(24, 80, "GAME CLEAR", 7)
では、実行して全て塗りつぶしたら "GAME CLEAR" の表示がされることを確認します。
■ゲームオーバー判定の実装
一筆書きゲームなので、間違ってすでに塗りつぶした場所に移動したら、ゲームオーバーになるようにします。
まずは定数をAppクラスの先頭に追加します。
class App:
MAIN = 0 # メイン
GAMECLEAR = 1 # ゲームクリア
GAMEOVER = 2 # ゲームオーバー
update() に失敗判定を実装します。
def update(self):
# リトライ判定
# ...
# はみ出し判定
if self.x < 0:
self.x = 0
if self.x >= Map.WIDTH:
self.x = Map.WIDTH - 1
if self.y < 0:
self.y = 0
if self.y >= Map.HEIGHT:
self.y = Map.HEIGHT - 1
# ゲームオーバー判定
if self.get_map(self.x, self.y) == 1:
self.state = self.GAMEOVER
# 塗りつぶす
self.set_map(self.x, self.y, 1)
# クリア判定
if self.check_gameclear():
self.state = self.GAMECLEAR
塗りつぶし処理の前で行います。
draw() にゲームオーバーの場合は "GAME OVER" と表示する処理を実装します。
def draw(self):
pyxel.cls(0)
self.draw_back()
self.draw_player()
if self.state == self.GAMECLEAR:
# ゲームクリア表示
pyxel.text(24, 80, "GAME CLEAR", 7)
if self.state == self.GAMEOVER:
# ゲームオーバー表示
pyxel.text(24, 80, "GAME OVER", 7)
では、実行してみます。
するとなぜか、最初から "GAME OVER" の文字が表示されてしまいます。これは update() でのゲームオーバー判定が「移動していないのに移動したことになって、すでに塗りつぶしたと誤判定されてしまう」ということが原因です。
なので、移動していない場合は、ゲームオーバー判定しないようにします。update() を以下のように書き換えます。
def update(self):
# リトライ判定
if pyxel.btnp(pyxel.KEY_R):
self.init()
# キー入力による移動
previous_x = self.x
previous_y = self.y
if pyxel.btnp(pyxel.KEY_LEFT):
self.x -= 1
if pyxel.btnp(pyxel.KEY_RIGHT):
self.x += 1
if pyxel.btnp(pyxel.KEY_UP):
self.y -= 1
if pyxel.btnp(pyxel.KEY_DOWN):
self.y += 1
# はみ出し判定
if self.x < 0:
self.x = 0
if self.x >= Map.WIDTH:
self.x = Map.WIDTH - 1
if self.y < 0:
self.y = 0
if self.y >= Map.HEIGHT:
self.y = Map.HEIGHT - 1
if previous_x == self.x and previous_y == self.y:
return # 動いていない
# ゲームオーバー判定
# ...
移動前に、"previous_x" / "previous_y" に移動前の座標を保存しておきます。そして移動処理後、移動前の座標と移動後の座標が一致していたら、動いていないと判定して、ゲームオーバー判定を行わないようにします。
これで完成! と思って実行して動きを確認すると……
開始地点が塗りつぶされなくなっています。これはゲーム開始時に塗りつぶしを行なっていないためとなります。
なので、init() で塗りつぶし処理を行います。
def init(self):
# ニャンコの座標を初期化
self.x = 0
self.y = 0
self.map = [0] * (Map.WIDTH * Map.HEIGHT)
self.set_map(self.x, self.y, 1) # スタート地点を塗りつぶす
self.state = self.MAIN
スタート地点を塗りつぶすようにしました。
あともう1つの問題として、「ゲームオーバー時」「ゲームクリア時」にも動かことができてしまいます。
これはみっともないので、update() 関数を修正します。
def update(self):
# リトライ判定
if pyxel.btnp(pyxel.KEY_R):
self.init()
if self.state != self.MAIN:
# メインゲーム中以外は動かせない
return
# キー入力による移動
# ...
これで完成です。実行して不具合がないことを確認してみてください。
■完成コード
一筆書きの完成コードはこちらにアップしています。
http://syun777.sakura.ne.jp/tmp/pyxel/OneStrokeSketch.zip
もし、うまく動かない場合はこちらを参考にしてみてください。