見出し画像

【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

もし、うまく動かない場合はこちらを参考にしてみてください。

この記事が気に入ったらサポートをしてみませんか?