個人開発者日記[6日目] 図形同士の当たり判定 その③ 四角形(矩形) の当たり判定

一週間ほどあいてしまいました。

今回も、前回までに引き続いてゲームの実装において重要な当たり判定を学んでいきます。

今回は当たり判定で円の次に重要な図形、四角形(矩形)を考えます。

矩形同士で当たり判定を行うプログラム

まずは、前々回に作ったものを参考に、矩形を画面に描画する簡単なプログラムをつくります。

import pyxel
import math


class Rectangle:    
    def __init__(self, x, y, w, h, col):
        self.x = x
        self.y = y
        self.w = w
        self.h = h
        self.col = col

    def draw(self):
        pyxel.rect(self.x, self.y, self.w, self.h, self.col)
        pyxel.pset(self.x, self.y, 0)
  


class App:
    def __init__(self):
        pyxel.init(160, 120)
        self.player = Rectangle(x=30, y=40, w=20, h=15, col=9)
        self.obstacle = Rectangle(x=80, y=60, w=30, h=20, col=8)

        pyxel.run(self.update, self.draw)

    def update(self):
        if pyxel.btn(pyxel.KEY_LEFT) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_LEFT):
            self.player.x -= 1
        if pyxel.btn(pyxel.KEY_RIGHT) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_RIGHT):
            self.player.x += 1
        if pyxel.btn(pyxel.KEY_UP) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_UP):
            self.player.y -= 1
        if pyxel.btn(pyxel.KEY_DOWN) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_DOWN):
            self.player.y += 1
        

    def draw(self):
        pyxel.cls(6)

        # 矩形の描画
        self.obstacle.draw()
        self.player.draw()


App()

これで、二つの矩形が画面に描画されるようになりました。

次に、二つの矩形が衝突しているかどうかを調べる方法を考えます。

pyxelでは、矩形が指定した座標から右方向の幅、下方向の高さをもって描画されます。

まずは、絶対に衝突していないのはどういう場合かを考えます。

自機を矩形としたとき、その辺のいずれかが他の矩形と明らかに衝突していない場合をそれぞれ列挙すると

矩形の右辺が、右側にある矩形の左辺よりも左にある

矩形の左辺が、左側にある矩形の右辺よりも右にある

矩形の下辺が、下側にある矩形の上辺よりも上にある

矩形の上辺が、上にある矩形の下辺よりも下にある

上記のいずかを満たす場合、自機と判定対象となるもう一方の矩形とは衝突していないといえます。

これをそのまま実装に組み込むと以下のようになります。

import pyxel
import math


class Rectangle:    
# 略
    def intersects(self, other):
        if isinstance(other, Rectangle):
            return (
                        self.x + self.w < other.x or
                        self.x > other.x + other.w or
                        self.y + self.h < other.y or
                        self.y > other.y + other.h
                    )
        return False

# 略

        if self.player.intersects(self.obstacle):
            pyxel.text(50, 10, "not collide", 0)
App()

これを実行すると、衝突していないときだけ「not collide」という文字が画面に表示されます。

この条件を裏返すと、衝突していることを判定できます。

            return not  (
                        self.x + self.w < other.x or
                        self.x > other.x + other.w or
                        self.y + self.h < other.y or
                        self.y > other.y + other.h
                    )

見やすく変形させます。

            return (
                        self.x + self.w >= other.x and
                        self.x <= other.x + other.w and
                        self.y + self.h >= other.y and
                        self.y <= other.y + other.h
                    )

実行してみましょう。

うまくいってますね。

この辺同士の位置関係を使って矩形同士の衝突を判定するやり方を

Axis-Aligned Bounding Box (AABB)   というそうです。

これで、矩形同士の当たり判定、円同士の当たり判定はわかりましたね。

今回はちょっと短くなってしまいましたが、次回は円と矩形の当たり判定について見ていきたいと思います。

参考記事など

yttm-work.jp

developer.mozilla.org

個人開発者日記[5日目] 図形同士の当たり判定 その② 円だけでシューティングを実装してみる

前回の記事では、円同士の当たり判定を実装しました。

aethergenesis.hatenablog.com

この知識があれば、円を使ってゲームが作れますね。今回は、2Dシューティングゲームを作ってみようと思います。

ゲームを作る前に、前回作ったサンプルをリファクタリングしていきます。

前回作ったサンプルだと、以下のようにいろんな変数をすべてAppクラスのメンバ変数に設定していました。

        self.x = 0
        self.y = 0
        self.circ1_r = 8
        self.circ1_col = 8
        self.circ2_x = 80
        self.circ2_y = 60
        self.circ2_r = 16
        self.circ2_col = 9

しかし、よく見てみるとcirc1とcirc2という名前違いの同じような変数が並んでいます。いまは円が2つなのでいいですが、 シューティングゲームなどになってくると、プレイヤー、敵機、弾など画面上に大量の円が必要です。それらすべてをメンバ変数に保持すると 同じような変数が大量に増えて、管理が非常に煩雑になってきます。

そこで、円という概念に必要な変数を一つのクラスにまとめて、オブジェクトとして汎用的に使いまわせるようにします。

まず、Circleクラスをつくります

class Circle:
    def __init__(self, x, y, r, col):
        self.x = x
        self.y = y
        self.r = r
        self.col = col

    def draw(self):
        pyxel.circ(self.x, self.y, self.r, self.col)
        pyxel.pset(self.x, self.y, 0)

次に、Appのメンバ変数を削除し、代わりにCircleクラスのインスタンスを生成します。 各処理では、各インスタンスの変数を参照・更新するように変更します。

class App:
    def __init__(self):
        pyxel.init(160, 120)
        self.player_circle = Circle(0, 0, 8, 8)
        self.static_circle = Circle(80, 60, 16, 9)

        pyxel.run(self.update, self.draw)

    def update(self):
        if pyxel.btn(pyxel.KEY_LEFT) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_LEFT):
            self.player_circle.x -= 1
        elif pyxel.btn(pyxel.KEY_RIGHT) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_RIGHT):
            self.player_circle.x += 1
        elif pyxel.btn(pyxel.KEY_UP) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_UP):
            self.player_circle.y -= 1
        elif pyxel.btn(pyxel.KEY_DOWN) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_DOWN):
            self.player_circle.y += 1
        
        dx = self.static_circle.x - self.player_circle.x
        dy = self.static_circle.y - self.player_circle.y
        dist_square = dx * dx + dy * dy

        if dist_square <= (self.player_circle.r + self.static_circle.r) ** 2 :
            self.collision = True
        else:
            self.collision = False

    def draw(self):
        pyxel.cls(6)

        self.static_circle.draw()
        self.player_circle.draw()

        if hasattr(self, 'collision') and self.collision:
            pyxel.text(0, 0, "Collision!", 8)

さらに、衝突判定も汎用的なメソッドに変えます。

class Circle:
# 略

    def intersects(self, other):
        if isinstance(other, Circle):
            dx = self.x - other.x
            dy = self.y - other.y
            dist_square = dx * dx + dy * dy
            return dist_square <= (self.r + other.r) ** 2
        return False
# 略
    def update(self):
#略
        if self.player_circle.intersects(self.static_circle):
            self.player_circle.col = 11
        else:
            self.player_circle.col = 8

文字表示ではなく、接触時にプレイヤー側の円の色を変えるようにしました。 これでだいぶ記述が簡潔になり、尚且つ円の数も簡単に増やせるようになりました。

これをベースに、簡単なシューティングゲームを作っていきます。

完成したゲームはこんな感じ。

ソースコード全文はGithubで。

github.com

長くなったので、要点だけ解説します。

class Circle:
    def __init__(self, x, y, r, col):
        self.x = x
        self.y = y
        self.r = r
        self.col = col

    def intersects(self, other):
        if isinstance(other, Circle):
            dx = self.x - other.x
            dy = self.y - other.y
            dist_square = dx * dx + dy * dy
            return dist_square <= (self.r + other.r) ** 2
        return False

    def draw(self):
        pyxel.circ(self.x, self.y, self.r, self.col)

class Player(Circle):
    def __init__(self, x, y, r, col,speed):
        super().__init__(x, y, r, col)
        self.speed = speed

class Enemy(Circle):
    def __init__(self, x, y, r, col,speed,score=10):
        super().__init__(x, y, r, col)
        self.speed = speed
        self.score = score
        self.initial_y = y

class Bullet(Circle):
    def __init__(self, x, y, r, col,speed):
        super().__init__(x, y, r, col)
        self.speed = speed

基底クラスCircleに、円に必要な変数(x/y座標、半径、色)と初期設定できるようにします。また、 intersectsメソッドで、他の円との接触判定を行えるようにします。 Player, Enemy, BulletはCircleを継承した子クラスです。拡張性を考えてこのように分けましたが、あまり差がなくなってしまいました。

class App:
    def __init__(self):
        pyxel.init(120, 160)
        self.player = Player(x=10, y=pyxel.height - 10, r=5, col=2,speed=2)
        self.enemy_list = [
            Enemy(x=0, y=10, r=5, col=3,speed=1),
            Enemy(x=0, y=40, r=5, col=4,speed=1.5),
            Enemy(x=0, y=70, r=5, col=5,speed=2),
        ]
        self.bullet_list = [] 
# 略

Appクラスのコンストラクタでは、クラスのインスタンスを生成します。 Playerは一つしか生成しませんが、Enemyは初期段階で3体分生成します。Bulletは発射時に生成されるので、格納するための空のリストを設定します。

    def update(self):
        self.player_update()
        self.bullet_update()
        self.enemy_update()
    
    def player_update(self):
# 略
        if (pyxel.btnp(pyxel.KEY_SPACE) or pyxel.btnp(pyxel.GAMEPAD1_BUTTON_B)) and len(self.bullet_list) < 3:
            self.bullet_list.append(Bullet(self.player.x, self.player.y - self.player.r, 2, 9,3))
        
        for enemy in self.enemy_list:
            if self.player.intersects(enemy):
                self.game_over = True
                break

updateでは、それぞれの毎フレーム処理を呼び出します。 プレイヤーは毎フレーム移動判定、弾発射判定、敵との接触によるゲームオーバー判定を行います。 弾は画面内に3発までしか存在できないよう制御します。

    def enemy_update(self):
        for enemy in self.enemy_list:
            enemy.x = (enemy.x + enemy.speed) % (pyxel.width + enemy.r * 2)
            enemy.y = ( (math.sin(pyxel.frame_count * 0.1))  * 20  ) + enemy.initial_y 

敵は、毎フレームmath.sin関数により上下に揺れながら左から右へ動きます。画面右端に到達したら、また左端から出てきます。

    def bullet_update(self):
        for bullet in self.bullet_list:
            bullet.y -= bullet.speed

            for enemy in self.enemy_list:
                if bullet.intersects(enemy):
                    self.enemy_list.remove(enemy)
                    self.score += enemy.score
                    self.enemy_list.append(Enemy(x=0, y=pyxel.rndi(30, pyxel.height - 50), r=5, col=pyxel.rndi(1,15) ,speed=pyxel.rndf(1,3)))
                    self.bullet_list.remove(bullet)
                    break

            if bullet in self.bullet_list and bullet.y + bullet.r < 0:
                self.bullet_list.remove(bullet)

弾は、毎フレーム上方向に動きます。弾と敵が衝突すると両者が消え、スコアが加算されます。 また、弾が画面外に出ると、弾が消えます。

    def draw(self):
# 略
            pyxel.cls(0)

            self.player.draw()

            for enemy in self.enemy_list:
                enemy.draw()
            for bullet in self.bullet_list:
                bullet.draw()

毎フレームごとにプレイヤー、敵、弾を順番にすべて描画します。

今回作ったゲームも、Github Pagesから遊べるようにしてみました。

ゲームというにはまだまだ足りていない要素が多いですが、円の基礎的な当たり判定ができるようになりました。

https://naritsan.github.io/pyxel_firstgame/pages/circle_shooting.html

次回は、円と同じくらいよく使われる、矩形の当たり判定について学習していきたいと思います。

個人開発者日記[4日目] 図形同士の当たり判定 その① 円の当たり判定

またも体調をくずしてしまい、更新が滞ってしまいました。

前回の記事では、1ピクセルのドットを使って当たり判定を実装しました。

aethergenesis.hatenablog.com

しかし、1ピクセルが視認できるレベルの拡大倍率だと、表現の幅が極端に狭まります。文字だけで画面の大半が埋まってしまうレベルです。

これではゲームらしいゲームにはならないので、もっと進化させてファミコンレベルを目指していきましょう。ゲームの当たり判定では、円と四角形(矩形)がもっともよく使われるようです。今回はこの2種類の組み合わせで当たり判定の基礎を学びつつ、実践的なゲームを作っていきます。すでに色々なブログや記事で私より詳しい方々が参考になる記事を書いていらっしゃっていますので、それらを参考にしながら私なりに理解を深めて身につけていくことを目的とします。

円同士の当たり判定

2Dゲームにおいて最もよく使われる当たり判定といえば円ですね。特にプレイアブルキャラクターや敵キャラ、飛び道具などは円系の当たり判定が多い印象です。

円と円がぶつかっているかどうかを判定するには、どうすればいいでしょうか。それは、円の中心となる2点間の距離が、2つの円の半径を足した距離の和よりも短いかどうかを判定することです。接触していない場合は2つの円の半径にプラスして円の隙間に距離があるので、必然的に2つの円の和よりも距離が長くなります。

半径の和を求めるには単純な足し算で事足りますが、2点間の距離を求めるにはどうすればよいでしょうか。これには、ピタゴラスの定理を用います。

ピタゴラスの定理とは、 直角三角形の3辺において、斜辺の長さ(c)とはそれ以外の辺の(a,b)の間で、 a2 + b2 = c2 の関係が成り立つ、という定理です。

Googleスライドで作ったイメージ図)

これをゲームに応用すると、2つの円の中心となる点同士のx座標の距離(a)、y座標の距離(b)がわかれば、それらを2乗した後に足して平方根を求めれば2点間の距離(c)が測れるということです。

Googleスライドで作ったイメージ図2)

では、これを早速pyxelで実装してみます。

import pyxel
import math

class App:
    def __init__(self):
        pyxel.init(160, 120)
        self.x = 0
        self.y = 0
        self.circ1_r = 8
        self.circ1_col = 8
        self.circ2_x = 80
        self.circ2_y = 60
        self.circ2_r = 16
        self.circ2_col = 9
        pyxel.run(self.update, self.draw)

    def update(self):
        if pyxel.btn(pyxel.KEY_LEFT) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_LEFT):
            self.x -= 1
        elif pyxel.btn(pyxel.KEY_RIGHT) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_RIGHT):
            self.x += 1
        elif pyxel.btn(pyxel.KEY_UP) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_UP):
            self.y -= 1
        elif pyxel.btn(pyxel.KEY_DOWN) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_DOWN):
            self.y += 1
        
        dx = self.circ2_x - self.x
        dy = self.circ2_y - self.y
        dist = math.sqrt(dx * dx + dy * dy)

        if dist < self.circ1_r + self.circ2_r:
            self.collision = True
        else:
            self.collision = False

    def draw(self):
        pyxel.cls(6)

        # circ2
        pyxel.circ(self.circ2_x, self.circ2_y, self.circ2_r, self.circ2_col)
        pyxel.pset(self.circ2_x, self.circ2_y, 0)

        # circ1
        pyxel.circ(self.x, self.y, self.circ1_r, self.circ1_col)
        pyxel.pset(self.x, self.y, 0)


        if hasattr(self, 'collision') and self.collision:
            pyxel.text(0, 0, "Collision!", 8)
App()

実行してみるとこんな感じになります

ちゃんと判定できていますね。

ここで問題になってくるのが、平方根を求める計算の処理負荷です。ゲームというのは30fpsの場合でも 一秒間に30回もの処理をすることになります。平方根を求めるmath.sqrt関数は比較的負荷のかかる処理なので、 これが複数箇所で何度も呼び出されるとマシンリソースが無駄に消費されてしまいます。

そこで、今回行った計算に工夫をこらします。

math.sqrt(dx * dx + dy * dy) < self.circ1_r + self.circ2_rの比較は、両辺を二乗しても結果が変わりません。 そこで、半径同士を足している右辺を二乗することで、平方根の計算をなくします。

# 略
    def update(self):
# 略
        dx = self.circ2_x - self.x
        dy = self.circ2_y - self.y
        dist_square = dx * dx + dy * dy

        if dist_square <= (self.circ1_r + self.circ2_r) ** 2:
            self.collision = True
        else:
            self.collision = False

実行結果は先ほどと同じです。

ちょっと今回は中身が薄めになってしまいましたが、一旦この辺りにします。次回は、円の当たり判定だけで簡単なシューティングゲームを作ってみようと思います。

参考にした書籍、資料・記事

翻訳本ですが、ゲーム開発者にとってのバイブルのようなので買ってみました。文系の私でも理解できるようなゲームにおける実例を交えながら書いてあり、とても参考になります。

実装にあたっては、MDNのサンプルコード(JavaScript)が参考になりました。

developer.mozilla.org

個人開発者日記[3日目] ゲーム性を付加していく

2日目の記事では、Pyxelを使ってドット絵を描画し、プレイヤーの入力で操作ができるようにしました。

これに手を加えて、「遊べる」ゲームとして形にしていきます。サンプルゲームの「スネークゲーム」を参考にしています。

当たり判定

ゲームといえば、当たり判定が必要です。基本的に、何かと何かがぶつかったらイベントが起きるのがゲームです。

図形の場合、描画の起点となる座標から幅・高さをもって描画されるので、当たり判定を実装するには衝突する二つの図形の幅・高さ同士が接触しているかどうかを計算する必要があります。また、実際には描画する図形よりも当たり判定が小さかったり、大きかったりすることもあります。ちょっとややこしいですね。

最も単純な当たり判定

当たり判定ロジックを単純化させるために、衝突するオブジェクトとして描画する図形を1ピクセルの点(ドット)にしてみます。こうすると、衝突しているかどうかはその点の座標が一致しているかどうかを見ればいいので、当たり判定が非常に簡単です。サンプルゲームの07_snake.pyが参考になります。

デフォルトだと非常に小さい点になってしまうので、画面を拡大表示させることで1ドットがそれなりの大きさになるマス目にします。

    def __init__(self):
        pyxel.init(
            40, 30, title="secondgame", fps=20, display_scale=20
        )
# 以下略

これで、ある程度大きな1ドットを画面上で動かせるようになりました。

次に、衝突の対象となるドットも画面に配置してみます。原理理解が目的なので、オブジェクトにせず手続き型っぽく作っていきます。

    def __init__(self):
# 略
        self.apple_x = 20
        self.apple_y = 15 

# 略

    def draw(self):
# 略
        pyxel.pset(self.apple_x, self.apple_y,8)

これで、appleを示す赤いドットが20,15の位置に表示されました。 次に、自機のドットとappleの赤いドットが衝突したら、appleのドットが消えるようにします。

    def __init__(self):
# 略
        self.apple_eaten = False
# 略
    def update(self):
# 略
        if self.x == self.apple_x and self.y == self.apple_y:
            self.apple_eaten = True

    def draw(self):
#略
        if not self.apple_eaten:
            pyxel.pset(self.apple_x, self.apple_y,8)

x座標とy座標が自機・りんごで一致したらフラグを立てて表示処理を実行しない、という単純な処理です。

これで、非常に単純な当たり判定を実装することができました。

ゲーム性を追加する

当たり判定の実装はできましたが、これではゲームとして成立していません。

「スネークゲーム」を参考に、ゲーム性を付加していきましょう。

スコア表示を追加する

古いアーケードゲームファミコンのゲームでは、アイテムを集めたり敵を倒したりしてスコアをどれだけ集められるかを競う、というものが多いです。

そこで、このゲームにもスコアを導入します。

その前に、今はappleが一回取ったら消えるようになっているので、取ったらまた別の場所に再出現するようにしてみます。こちらも、サンプルのスネークゲームのロジックを参考にします。

    def __init__(self):
        pyxel.init(
#略
        # このフラグは使わないのでコメントアウトしておきます
        # self.apple_eaten = False

        pyxel.run(self.update, self.draw)
#略
    def update(self):
        if self.x == self.apple_x and self.y == self.apple_y:
            # 座標が一致したら、appleの座標を移動(再出現)させるように
            self.apple_x = pyxel.rndi(0, pyxel.width - 1)
            self.apple_y = pyxel.rndi(0, pyxel.height - 1)

    def draw(self):
        pyxel.cls(6)
        pyxel.pset(self.x, self.y,1)
        # 以下の条件分岐も不要なのでコメントアウトします
        # if not self.apple_eaten:
        pyxel.pset(self.apple_x, self.apple_y,8)

これだけでもだいぶゲームらしくなりましたね。 次に、appleと衝突したら加算するスコアを加算し、画面に表示する処理を追加します。

    def __init__(self):
#略
        # スコア領域と被らないように右にずらす
        self.x = 30
        self.score = 0
#略
    def update(self):
#略
        if self.x == self.apple_x and self.y == self.apple_y:
            self.score += 100
#略

    def draw(self):
#略
        pyxel.rect(0, 0, 18, 8, 0)
        pyxel.text(1, 1, f"{self.score}", 7)
        # スコア表示で見えなくならないように、自機を最後に描画させる
        pyxel.pset(self.x, self.y,1)

これで、appleと衝突するたびにスコアが100加算されるようになりました。

ゲームの終了条件

これで最低限ゲームとしての体を成したわけですが、このままでは目的もなければ終わりもありません。

ゲームには、ゲームオーバーやクリアなど、終わりになる条件が必要です。今回は、ゲームオーバーとタイムアップによるゲーム終了を実装してよりゲームらしさをつけくわえてみます。

まずは、ゲームオーバーの条件を設定します。単純さのため、林檎と同時に生成される毒林檎を間違えて食べたらゲームオーバー、としてみます。

    def __init__(self):
# 腐った林檎の座標
        self.rotten_apple_x = pyxel.rndi(19, pyxel.width - 1)
        self.rotten_apple_y = pyxel.rndi(9, pyxel.height - 1)
        self.score = 0
        self.game_over = False
# 略
    def update(self):
        if self.x == self.apple_x and self.y == self.apple_y:
 # 略
# 林檎を取ったら腐った林檎も再配置する
            self.rotten_apple_x = pyxel.rndi(19, pyxel.width - 1)
            self.rotten_apple_y = pyxel.rndi(9, pyxel.height - 1)
# 腐った林檎とぶつかったらゲームオーバーフラグを立てる
        if self.x == self.rotten_apple_x and self.y == self.rotten_apple_y:
            self.game_over = True
# 略
    def draw(self):
# ゲームオーバーフラグが立っていたら、画面表示を変える
        if self.game_over:
            pyxel.cls(0)
            pyxel.text(4, 11, "GAMEOVER", 8)
        else:
# 元々の処理を記載するので省略

これで、緑の腐った林檎を食べたらゲームオーバーになり、ゲーム性が増しましたね。

最後に、時間制限を設けて、ゲームオーバーせずにタイムアップしたらスコアが表示されるようにしてみましょう。

    def __init__(self):
        pyxel.init(
# 略
# 秒数カウント用の変数 初期値十秒
        self.time_up = False
        self.timer = 10

# 略
    def update(self):
# ゲームオーバーまたはタイムアップ後は処理をしないように
        if self.game_over or self.time_up:
            return

# 略
# 20フレームでカウントしたら一秒経ったとみなしてタイマーのカウントを1減らす
        if pyxel.frame_count != 0 and pyxel.frame_count % 20 == 0 and not self.game_over:
            self.timer -= 1
            if self.timer <= 0:
                self.time_up = True
# 略
    def draw(self):
        if self.game_over:
            pyxel.cls(0)
            pyxel.text(4, 11, "GAMEOVER", 8)
# タイムアップ後のスコア表示
        elif self.time_up:
            pyxel.cls(0)
            pyxel.text(0, 11, f"SCORE:{self.score} ", 7)
# 略

これで、ゲームオーバーと、時間切れによるゲーム終了の処理ができて、だいぶゲームらしくなりました。 実はこのゲームには潜在的なバグがいくつか残されていますが、サンプルゲームなのであえてそのままにしておきます。

今回作ったゲームもGithubリポジトリにアップロードして、ブラウザ上でプレイできるように公開しておきました。

github.com

https://naritsan.github.io/pyxel_firstgame/pages/firstgame_v2.html

まとめ

昨日のただ動くだけだったものから、遊べるゲームを作るところまで勧められました。 明日は、ドットではなく図形による当たり判定を実装していきたいと思います。

個人開発者日記[2日目] Pyxelに入門しよう

体調を崩してしまい、1日空いてしまいました。

今回は表題の通り、ゲーム開発エンジンの一つであるPyxelの入門編ということで、ゲームエンジンの紹介と簡単なサンプルコードの実装をしてみることにします。

Pyxelって?

Pyxel(ピクセル)は、 Python 向けのレトロゲームエンジンです。 github.com

UnityやUnreal Engineのようにリッチな3Dゲームではなく、 ファミコン / スーパーファミコンのようなレトロ風ゲームを作ることに特化したゲームエンジンです。上に紹介したGithubや、作者の方のQiita記事などが参考になります。 qiita.com

なぜPyxelを選んだか?

現代では、高性能・高機能なゲームエンジンが溢れています。有名どころではUnityがありますね。

ただ、Unityは複雑な3Dゲームを作るのに特化しており、ゲームロジック本体を構築することのほかに覚えることがかなり多いです。

自分としては、当面は小規模の2Dミニゲームを作っていきたいと考えていて、Unityだとオーバースペック感があるので、まさにこういうのを求めていました、って感じです。 作者が日本の方ということで、日本語の情報が充実しているのもありがたいです。

Unityだと、簡単なチュートリアルが3Dの物理演算を利用したゲームとかなので、ちょっと違うなと。※Unity2Dもありますが。

あと、しばらくはMacbook Airで開発しようと思っていて、スペックがそれほど求められないエンジンがよかったというのもあります。 それなりのスペックのデスクトップPCも保有してますが、リビングで座ってテレビとかみながらのんびりやりたいというわがままもあり。

また、簡単にゲームを作れるというコンセプトだとRPGツクールも挙げられますが、これもRPG特化でアクションなど別ジャンルになると途端に構築難易度が跳ね上がります。実際、Switch2でRPG MAKER WITHを使ってゲーム作りをしていたのですが、込み入った処理をしようとするとスパゲティな処理をゴリゴリ記述することになってしまい、もはや普通にコードしたほうがはやいかな、という感覚になりました。

2Dゲーム界隈だと、Hot soup Processorプチコンなども挙げられます。ただ、これらはいずれも環境が特殊で、現代の一般的なプログラミング環境の恩恵を受けづらいと感じています。その意味で、一般的なPythonプログラミングの延長線上でゲーム開発ができるPyxelを選びました。

もちろん、今回選定しなかったツール群もそれぞれすべて良いものです。単純に、私の個人的な好みと指向との兼ね合いであることを最後に申し添えておきます。

ということで、次の章からはPyxelを実際に使い始めていきます

導入

まずは導入です。とはいっても、導入は非常に簡単です。Githubから引用しますが、最新情報は都度公式を確認してください。

Windows

Python3 (バージョン 3.8 以上) をインストールした後、次のコマンドを実行します。

pip install -U pyxel

公式インストーラーで Python をインストールする場合は、pyxelコマンドを有効にするために、Add Python 3.x to PATHにチェックを入れて Python のインストールを行ってください。

Mac

Homebrew をインストールした後、次のコマンドを実行します。

brew install pipx
pipx ensurepath
pipx install py

Pyxel をインストールした後にバージョンを更新する場合は、pipx upgrade pyxelを実行してください。

今回私はMacなので、ターミナルで上記コマンドを実行しました。

開発環境

ターミナル(Windowsだとコマンドプロンプト)から実行するだけなら上記だけでもいいですが、 もっとリッチで快適な開発体験を享受したいので、色々整えていきます。

エディタ

ここはお好みで、というところですが、 エディタは Visual Studio Codeを使います。

Python関係の拡張機能も入れていきます。 marketplace.visualstudio.com marketplace.visualstudio.com

AIエージェントとして、Gemini CLIを導入します。お仕事ではCursor / Claude Codeがメインですが、個人で使うにはちょっと高いので、無料枠でそこそこ使えるものを。 cloud.google.com marketplace.visualstudio.com

バージョン管理

必須ではないのですが、 ローカル環境だけで開発していると故障でデータが消えたり、 手を加える前の状態に戻したい、みたいなときに大変なので、Git(Github)によるバージョン管理を導入します。

GitやGithubの使い方に関して細かく説明しているとそれだけで一冊本が書けちゃうレベルなので、それはインターネットの各種記事や書籍にお譲りすることにします。

今回は私一人で開発するので、ゲーム一本ごとにリポジトリを作り、ある程度のまとまりごとにブランチを切ってmainにマージしていくようにしようかと。mainにプッシュし続けるのでも別にいいかなと思いますが。

注意点としては、外部公開したくないリポジトリはpublicではなくprivateで作りましょう。このブログで実装するプログラムは基本的にpublicにしようと思いますが、著作権的に問題がありそうなものについてはprivateで作りソースコードの公開はしないことにします。

サンプルからはじめよう

環境が整ったところで、まずはサンプルゲームを見てみましょう。

任意のフォルダで

pyxel copy_examples

を実行することで、サンプルゲームが自動で配置されます。

色々なサンプルがあってこれだけでもすごく勉強になるいい教材なのですが、 初歩の初歩である01のサンプルから、02や09のようなプレイヤー操作を含むアクションゲームのサンプルの間でレベルがかなり上がる印象を受けました。

まずは動作原理を理解したいので、簡単なものに徐々に手を加えていって、本格的なものに仕上げていくという工程を踏むことにします。

https://github.com/kitao/pyxel/blob/main/docs/README.ja.md

今回、教材としてreadmeの「使い方」に記載されているスクリプトをみてみます。

import pyxel

class App:
    def __init__(self):
        pyxel.init(160, 120)
        self.x = 0
        pyxel.run(self.update, self.draw)

    def update(self):
        self.x = (self.x + 1) % pyxel.width

    def draw(self):
        pyxel.cls(0)
        pyxel.rect(self.x, 0, 8, 8, 9)

App()

ゲームとして最低限の要素が詰まっているスクリプトなので、まずはこの仕組みを見ていきましょう。

def init(self)

ゲームの起動時に1回だけ実行される処理を記載する初期化関数。オブジェクト指向的には「コンストラクタ」ですね。

pyxel.initでウィンドウのサイズ(横・縦)を指定します。self.xはメンバ変数ですね。

pyxel.runにupdate,drawの関数をわたすことで、1フレームごとにupdate,drawメソッドが呼び出される仕組みのようです。

1フレームというのは、パラパラ漫画の一コマのようなものです。フレームが高速で移り変わることで、ゲームとしての動きを表現しています。

pyxelでは、デフォルトは30FPSになっているので一秒間に30フレーム、つまりupdate,drawが30回呼ばれることになりますね。

Alt(Macの場合はOption)と0を同時押しすると、パフォーマンスモニタ (FPS/update時間/draw時間) を表示できます。

wait処理を明示的に書かなくてもいいのはありがたいですね。

def update(self)

毎フレームの更新処理です。 self.xに1を足してウィンドウの幅(width)で割っているので、xが0〜159まで順番に増えていったあと、160まで増えるとxが再度0に戻る、という仕組みです。これを後述の描画処理で使うことで、x座標が常にウィンドウ枠内の収まっています。

def draw(self)

毎フレームの描画処理です。

pyxel.clsで画面を単一色で塗りつぶしたあと、pyxel.rectで四角形を描画します。

clsやrectに指定するcol引数は「カラーパレット」といって、0〜15の16色の中から選べます。

デフォルトのカラーパレット値はShift+Alt(Option)+0でDesktopに画像として保存されます。0は黒、9はオレンジです。

サンプルゲームの05_color_palette.py を実行するとよりわかりやすいですね。

描画される四角形のx座標が前述のupdateで毎フレーム1ずつ増えているので、結果として毎フレーム右に動いています。

入力を反映させよう

前の章で、アニメーションとして動くものができあがりました。ただ、これではゲームとは言えません。

ゲームというには、プレイヤーの操作が画面に反映される必要があります。

そこで、x座標の変化を自動ではなく、プレイヤーの入力によって増減させられるようにします。

updateメソッドを以下のように書き換えましょう

    def update(self):
        if pyxel.btn(pyxel.KEY_LEFT) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_LEFT):
            self.x -= 1
        elif pyxel.btn(pyxel.KEY_RIGHT) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_RIGHT):
            self.x += 1

if文がやたらと長いのは、PCの方向キーとゲームパッドの両方に対応させるためです。 

この状態で実行すると、四角形を方向キーで左・右と自由に動かせるようになります。   

さらに、self.yというメンバ変数を追加し、方向キー上下でy座標を変更できるようにしましょう。

import pyxel

class App:
    def __init__(self):
        pyxel.init(160, 120)
        self.x = 0
        self.y = 0
        pyxel.run(self.update, self.draw)

    def update(self):
        if pyxel.btn(pyxel.KEY_LEFT) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_LEFT):
            self.x -= 1
        elif pyxel.btn(pyxel.KEY_RIGHT) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_RIGHT):
            self.x += 1
        elif pyxel.btn(pyxel.KEY_UP) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_UP):
            self.y -= 1
        elif pyxel.btn(pyxel.KEY_DOWN) or pyxel.btn(pyxel.GAMEPAD1_BUTTON_DPAD_DOWN):
            self.y += 1

    def draw(self):
        pyxel.cls(6)
        pyxel.rect(self.x, self.y, 8, 8, 9)

App()

self.yを追加し、方向キー上でyをマイナス、下でyをプラスします。 一般的にゲーム開発では原点が左上で、yが増えると座標は下方向に向かいます。

これにより、十字キーで四角形を上下左右自由に動かせるようになりました。                                                                                                                                                                                           

作成したゲームを公開しよう

まだまだ「ゲーム」という遊びとはほど遠いですが、とりあえず動くものができました。

せっかくインターネットの時代なので、作ったゲームを公開して誰でも遊べるようにしたいです。

今回ははてなブログとの相性とバージョン管理方法を加味して、Github Pagesで公開する方法を使います。

こちらの記事が大変参考になりました。

qiita.com

※Web Launcherのほうが簡単なのですが、Github上で更新した内容がなかなか反映されず(サーバー側のキャッシュの問題?)、次点で簡単だったGithub Pagesの方法を選びました。

できあがったのが、こちら。

https://naritsan.github.io/pyxel_firstgame/

以下のリポジトリソースコードが見られます。

github.com

まだまだゲームというには色々なものが不足していますが、とりあえず最初に一歩を踏み出せました。

2日目は以上です。明日から、これをベースに色々と試していきたいと思います。

個人開発者日記 1日目

前段

IT業界に5年ほどいる私ですが、仕事で業務アプリケーションのプログラミングをすることはあっても、個人での開発はあまりしてきませんでした。

原因としては仕事が忙しくて余裕がなかったということのほかに、私の専門領域が少し特殊だから、ということもあります。

 

私は「Salesforceエンジニア」という職種で、企業が導入しているSalesforceアメリカ製の顧客管理用パッケージ)の設定、および一部開発をシステム管理者として行うのが主なお仕事になります。

あまり一般的には知名度がないかもしれませんが、実は日本国内でもある程度大きな企業になってくるとかなりの割合でSalesforceを導入しています。Salesforceはノーコード開発ツールではあるものの、海外の製品であることや特殊な概念も多々あり込み入った設定になってくるとそこそこの専門知識が必要になってきます。そこで、専門資格や実務経験を持った「Salesforceエンジニア」が企業から発注を受けたり、または企業に専門人材として入社して開発保守運用を行う、ということが多いです。

 

Salesforceについてはまた別の機会で詳細を記すとして、なぜSalesforceエンジニアだと個人開発がしづらいのか、という話をします。単純にいえば、Salesforce基盤での開発は一般的なシステム開発とは全く違うからです。

たとえば、言語はApexという独自言語しか選択肢がありません。JavaPythonもNode.jsも使えません。当然、フレームワークも独自のものです。また、そもそもサーバーという概念が完全にパッケージ内部に隠蔽されているので、Linuxコマンドを触ることもありません。このように、Salesforceという閉じられた世界の中でしか通用しない専門知識だけを蓄えているので、個人開発をしようにも実務とはかけ離れた知識をほぼ0の状態から習得せねばならず、非常にハードルが高いのです。

 

個人開発者としてやりたいこと

フルスタックWebアプリの構築、運用

何を作るかはまだあんまり考えてませんが、家計簿アプリとかその辺りの実生活に役立つものを作るのがいいのかなと考えています。環境構築からこだわってみたいなと。

 

ゲーム開発

実は、ゲームづくりは子どもの頃からの憧れ、夢です。しかし、挑戦しては挫折を繰り返し、なかなか一本の完成品を世に出せるまでたどり着けずにいます。

小学生の頃…C言語の本を買ったものの、2進数が分からず挫折。

中学生の頃…Hot Soup Processorでゲーム開発に入門するが、アルゴリズムが分からず挫折。

大学生〜社会人…Unityの学習に手を出した。IT業界で働き始めてからプログラミングはできるようになったが、時間とゲーム作りのノウハウがなくチュートリアルや入門書止まり。

最終的には有料でインディーズゲームを販売してある程度の本数が売れることを目標としますが、まずは順を追って小さいものから形にすることを心がけようかと。

ゲームエンジンは最終Unityがいいかなと思いますが、しばらくは規模小さめのミニゲームを作ることを念頭に、pyxelでやってみようかと。

 

取り敢えず、1日目はこんなところで。明日から、毎日何かしらのアウトプットを出せるよう頑張ります。