PYXEL GUIDE / COLLISION

Pyxelの当たり判定の作り方

公開日: / Pyxel 2.9.9 の公式ドキュメントとソースで確認

「プレイヤーがコインに触れたら取る」「敵にぶつかったらミス」「壁の中には入れない」。こうした処理をまとめて当たり判定と呼びます。Pyxelでは当たり判定を自分で書きますが、基本の形を4つ覚えれば、たいていのゲームに対応できます。

わかること
四角・円・マス目・タイルマップの当たり判定と、すり抜けを防ぐコツ
前提
変数・if・関数と、Pyxelの update と draw の役割
確認したバージョン
Pyxel 2.9.9
円の形のキャラクターがタイルの道を進むPyxelのゲーム画面
このサイトの講座「ころがる切手」の元の完成版。丸い切手と、タイルでできた壁との当たり判定を使っています(元の完成版を自動操作したPyxel描画)。

まずは形を決める

当たり判定は、2つの物がぶつかったか(重なったか)を調べる処理です。「コインに触れたら取る」「敵に当たったらミス」「壁の中には入れない」など、ほとんどのゲームで使います。

Pyxelには、キャラクターどうしの当たり判定を自動でしてくれる機能はありません。そのかわり、キャラクターを「四角」や「円」のような単純な形とみなして、数字で調べます。見た目の絵より少し小さめの形にすると、「当たっていないのにミスになった」と感じにくくなります。

当たり判定の形の選び方
形調べ方向いているもの
四角横の範囲と縦の範囲が、両方重なっているかプレイヤー・アイテム・ブロック
円中心どうしの距離が、半径の合計より短いか弾・丸い敵・爆発の範囲
マス目行き先のマスが壁かどうか迷路・パズル・RPGの移動
タイルマップTilemap.collide に任せるPyxel Editor で作ったステージ

四角と四角

pyxel.rect(x, y, w, h, col) は、左上が (x, y)、幅 w・高さ h の四角を描きます。当たり判定でも同じ4つの数で四角を表すと、描く四角と判定する四角をそろえやすくなります。

四角どうしの判定
def rects_overlap(ax, ay, aw, ah, bx, by, bw, bh):
    return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah

A の左はしが B の右はしより左にあり、B の左はしが A の右はしより左にある。これで「横の範囲が重なっている」ことがわかります。縦も同じように調べて、両方とも重なっているときだけ True を返します。どちらか1つでも離れていれば、当たっていません。

数字で確かめる

rects_overlap(0, 0, 4, 4, 3, 3, 4, 4) は True(右下の1ドットが重なる)、rects_overlap(0, 0, 4, 4, 4, 0, 4, 4) は False です。2つ目は、幅4の四角が x=0 と x=4 にすき間なく並んでいるだけで、重なってはいません。

「接しただけ」を当たりにするなら

< を <= に変えると、辺がぴったり接しただけでも当たりになります。どちらにするかは、ゲームに合わせて決めましょう。

円と円

2つの円は、中心どうしの距離が「2つの半径の合計」より短ければ重なっています。距離を求めるには平方根(√)の計算が必要ですが、両方を2乗して比べれば、平方根を使わずにすみます。

円どうしの判定
def circles_overlap(ax, ay, ar, bx, by, br):
    dx = ax - bx
    dy = ay - by
    return dx * dx + dy * dy < (ar + br) ** 2

import math をして math.hypot(dx, dy) < ar + br と書いても、同じ意味です。読みやすいほうを選んでかまいません。

円と四角を比べるとき

丸いボールと四角いブロックのように、円と四角を比べるときは、四角の中で円の中心にいちばん近い点を求めて、その点までの距離を調べます。講座「ころがる切手」で、切手(円)とタイルの壁を判定している方法です。

円と四角の判定
def circle_hits_rect(cx, cy, r, x, y, w, h):
    nearest_x = max(x, min(cx, x + w))
    nearest_y = max(y, min(cy, y + h))
    dx = cx - nearest_x
    dy = cy - nearest_y
    return dx * dx + dy * dy < r * r

マス目の壁

迷路やパズルのように、マップをマス目で作るときは、「動いたあとで壁から戻す」より「動く前に、行き先が壁でないかを調べる」ほうがかんたんです。マップは、文字を並べたリストで書けます。# が壁、. が床です。

マップの書き方(一部)
TILE = 8  # 1マスの大きさ(ドット)
SIZE = 6  # プレイヤーの四角の大きさ
MAP = [
    "####################",
    "#..................#",
    "#..####......####..#",
    # …(全部で14行。サンプルの全文を参照)
]
壁の判定
def is_wall(px, py):
    return MAP[py // TILE][px // TILE] == "#"


def hits_wall(x, y, w, h):
    return (is_wall(x, y) or is_wall(x + w - 1, y)
            or is_wall(x, y + h - 1) or is_wall(x + w - 1, y + h - 1))

is_wall は、ピクセルの位置をマス目の位置に変えて(// は割り算の答えの整数部分)、そのマスが # かどうかを調べます。hits_wall は、四角の4つの角のどれかが壁のマスに入っていないかを調べます。

4つの角で調べられる大きさ

4つの角だけを調べる方法は、キャラクターの四角が1マス(ここでは8ドット)以下のときに使えます。1マスより大きいキャラクターは、辺の途中が壁に入ることがあるので、調べる点を増やします。

横と縦を別々に動かす

update の中の移動の部分
dx = pyxel.btn(pyxel.KEY_RIGHT) - pyxel.btn(pyxel.KEY_LEFT)
dy = pyxel.btn(pyxel.KEY_DOWN) - pyxel.btn(pyxel.KEY_UP)
if not hits_wall(self.x + dx, self.y, SIZE, SIZE):
    self.x += dx
if not hits_wall(self.x, self.y + dy, SIZE, SIZE):
    self.y += dy

横の移動と縦の移動を別々に調べると、ななめに進んで壁に当たったときも、壁にそってすべるように動けます。Pythonでは True を 1、False を 0 として計算できるので、右キーだけなら dx は 1、左キーだけなら -1、両方押すと 0 になります。

マス目の番号と画面のピクセルの変換は、共通入門の画面とマップの座標でくわしく練習できます。

タイルマップの壁(Tilemap.collide)

タイルマップ(8×8ドットのタイルを並べたステージ。Pyxel Editor などで作ります)を使っている場合は、Pyxel 2.6.5 で追加された Tilemap.collide を使えます。動かしたい量(dx, dy)を渡すと、壁にめりこまないように調整した量を返してくれます。

タイルマップの壁から押し戻す
WALLS = [(1, 0), (2, 0)]  # 壁として扱うタイルの種類(イメージバンク上の列, 行)


def push_back(x, y, dx, dy):
    dx, dy = pyxel.tilemaps[0].collide(x, y, 8, 8, dx, dy, WALLS)
    return x + dx, y + dy
  • x, y, 8, 8 は、キャラクターの四角(ピクセル)です。
  • WALLS には、壁として扱うタイルの種類を、イメージバンクの上での位置(何列目, 何行目)で並べます。マップ上の場所ではない点に注意しましょう。
  • 公式サンプルの 10_platformer.py でも、この形でプレイヤーや敵を壁から押し戻しています。

ある場所に置かれたタイルを調べるだけなら、pget を使います。ピクセルの位置から調べるときは、8で割ってタイルの位置に変えます。

ある場所のタイルを調べる
tile = pyxel.tilemaps[0].pget(x // 8, y // 8)
if tile in WALLS:
    ...

collide と pget は、タイルマップを用意してから使う機能です。タイルマップは、Pyxel Editor(pyxel edit)で作るほか、文字列から作ったり、Tiled というツールのTMXファイルを読み込んだりして用意できます。この記事のサンプルは、追加のファイルなしで動くように、文字のリストのマップ(ひとつ前の方法)を使っています。

3つをまとめたサンプル

四角・円・マス目の3つの判定を、1本にまとめたサンプルです。矢印キーで黄緑の四角を動かし、黄色のコインを5枚集めるとクリアです。左右に動く赤い丸に当たると、スタート位置に戻ります。R キーで最初からやり直せます。

collision_demo.py / 全文
import pyxel

TILE = 8
SIZE = 6
MAP = [
    "####################",
    "#..................#",
    "#..####......####..#",
    "#..................#",
    "#.....#......#.....#",
    "#.....#......#.....#",
    "#..................#",
    "#..####......####..#",
    "#..................#",
    "#..................#",
    "#..####......####..#",
    "#..................#",
    "#..................#",
    "####################",
]
START = (12, 12)
COINS = [(28, 28), (124, 28), (76, 44), (28, 92), (124, 92)]


def is_wall(px, py):
    return MAP[py // TILE][px // TILE] == "#"


def hits_wall(x, y, w, h):
    return (is_wall(x, y) or is_wall(x + w - 1, y)
            or is_wall(x, y + h - 1) or is_wall(x + w - 1, y + h - 1))


def rects_overlap(ax, ay, aw, ah, bx, by, bw, bh):
    return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah


def circles_overlap(ax, ay, ar, bx, by, br):
    dx = ax - bx
    dy = ay - by
    return dx * dx + dy * dy < (ar + br) ** 2


class App:
    def __init__(self):
        pyxel.init(160, 120, title="Collision Demo")
        self.reset()
        pyxel.run(self.update, self.draw)

    def reset(self):
        self.x, self.y = START
        self.coins = list(COINS)
        self.enemy_x = 40
        self.enemy_vx = 1
        self.misses = 0

    def update(self):
        if pyxel.btnp(pyxel.KEY_R):
            self.reset()
        if not self.coins:
            return

        dx = pyxel.btn(pyxel.KEY_RIGHT) - pyxel.btn(pyxel.KEY_LEFT)
        dy = pyxel.btn(pyxel.KEY_DOWN) - pyxel.btn(pyxel.KEY_UP)
        if not hits_wall(self.x + dx, self.y, SIZE, SIZE):
            self.x += dx
        if not hits_wall(self.x, self.y + dy, SIZE, SIZE):
            self.y += dy

        for coin in self.coins[:]:
            if rects_overlap(self.x, self.y, SIZE, SIZE, coin[0], coin[1], 4, 4):
                self.coins.remove(coin)

        self.enemy_x += self.enemy_vx
        if self.enemy_x < 20 or self.enemy_x > 140:
            self.enemy_vx = -self.enemy_vx
        center_x = self.x + SIZE / 2
        center_y = self.y + SIZE / 2
        if circles_overlap(center_x, center_y, 3, self.enemy_x, 68, 4):
            self.x, self.y = START
            self.misses += 1

    def draw(self):
        pyxel.cls(0)
        for row, line in enumerate(MAP):
            for col, cell in enumerate(line):
                if cell == "#":
                    pyxel.rect(col * TILE, row * TILE, TILE, TILE, 5)
        for cx, cy in self.coins:
            pyxel.rect(cx, cy, 4, 4, 10)
        pyxel.circ(self.enemy_x, 68, 4, 8)
        pyxel.rect(self.x, self.y, SIZE, SIZE, 11)
        pyxel.text(4, 113, f"COINS:{len(self.coins)} MISS:{self.misses}", 7)
        if not self.coins:
            pyxel.text(60, 56, "CLEAR!", 7)


App()
Windows
.venv\Scripts\python.exe collision_demo.py
macOS / Linux
.venv/bin/python collision_demo.py
サンプルで使っている当たり判定
判定使っている関数どうなる?
壁hits_wall行き先が壁なら、その方向には動かない
コインrects_overlap重なったコインをリストから消す
赤い丸circles_overlapプレイヤーを半径3の円とみなして調べ、当たったらスタートへ戻す

for coin in self.coins[:]: の [:] は、リストのコピーを作ってから順番に見ています。ループの途中で remove しても、コインの取りこぼしが起きないようにするためです。

1か所だけ変えてみよう

赤い丸の半径を4から8にすると、当たりやすくなります。pyxel.circ の 4 と、circles_overlap に渡している 4 を両方変えて、見た目と判定をそろえましょう。片方だけ変えると、見た目と当たる範囲がずれることも確かめられます。

すり抜け・めりこみを防ぐコツ

速い物は、すり抜けることがある

1フレームで動く量が、壁や相手の大きさより大きいと、前のフレームでは手前、次のフレームでは向こう側にいて、一度も重ならないことがあります。これを「すり抜け」といいます。速く動く物は、1フレームの移動を何回かに分けて調べるか、通った線全体を調べます。講座「反射便」では、まっすぐ進む物が最初に壁に当たる時刻を求めて、すり抜けを防いでいます。

動く前に調べると、めりこまない

先に動かしてから調べると、壁の中に入った状態が1フレームだけ生まれます。このページのように「動く前に行き先を調べる」か、collide のように「動く量を調整する」と防げます。

判定は update で、draw では描くだけ

当たり判定は update の中で行い、draw では結果を描くだけにします。Pyxelは、処理が重いときに draw をとばすことがあります。draw の中で判定すると、当たったのに見逃すことがあるので注意しましょう。

講座で当たり判定を読む

このサイトの講座では、ゲームに合わせていろいろな当たり判定を使っています。やさしい順に読んでみましょう。

この記事で確認したこと

Pyxel 2.9.9 のAPIリファレンス(rect・circ・Tilemap.collide・Tilemap.pget)、変更履歴(collide は 2.6.5 で追加)、collide のソースコード(タイルは8ドット)、公式サンプル 10_platformer.py、よくある質問(draw がとばされることがある)を照合しました。

collision_demo.py は、Pyxelの代わりに入力と描画命令を記録する模擬モジュールで動かし、壁を通り抜けずに5枚すべてのコインを集められること、赤い丸に当たるとスタートに戻ること、判定の関数の境目(接しただけは False)を確かめています。実際のPyxelのウィンドウでの表示確認と、collide を使うタイルマップの例の実行は、この記事の作成時には行っていません。