見出し画像

Pythonでマイクラを作る ⑮物理シミュレーションの実装

Pythonでマイクラを作る 第15回目です。
今回はプレイヤーのジャンプを実装します。重力や衝突判定のある物理世界を作るには物理エンジンを使用することが多いのですが、今回は物理シミュレーションをプログラミングで行います。
これまでプレイヤーはブロックをすり抜けできる状態でした。衝突判定を行なって、プレイヤーがブロックをすり抜けできるバグを修正します。

放物線の運動方程式

$$
x = v_x t\\
y = v_y t - \frac{1}{2}g t^2\\
$$

放物線のグラフ

XY平面において、重力加速度 g、速度 $${v = (v_x, v_y)}$$ で投げ上げた物体の軌跡は上記の式で与えられます。プレイヤーが斜めにジャンプした時の軌跡は、この方程式で表されます。初等の物理学で習う放物線の運動方程式をプログラムで再現します。

ジャンプアクションの実装

修正
"""src/player.py"""
from math import *
from panda3d.core import *
from direct.showbase.ShowBaseGlobal import globalClock
from .player_model import PlayerModel
from .camera import Camera
from .target import Target


class Player(PlayerModel, Camera, Target):
    heading_angular_velocity = 15000
    pitch_angular_velocity = 5000
    max_pitch_angle = 60
    speed = 10
    eye_height = 1.6
    gravity_force = 9.8  # 追記
    jump_speed = 10  # 追記

    # コンストラクタ
    def __init__(self, base):
        self.base = base
        PlayerModel.__init__(self)
        Camera.__init__(self)
        Target.__init__(self)

        self.position = Point3(0, 0, 0)
        self.direction = VBase3(0, 0, 0)
        self.velocity = Vec3(0, 0, 0)
        self.mouse_pos_x = 0
        self.mouse_pos_y = 0
        self.target_position = None
        self.is_on_ground = True  # 追記

 Playerクラスを修正します。
クラス変数gravity_force は重力加速度を表します。重力加速度は $${9.8 m / s^2}$$ で近似されます。クラス変数jump_speed は上向きのジャンプ速度を表し、$${10 m / s}$$ に設定しました。
インスタンス変数is_on_ground はプレイヤーが地面に接地しているかどうかを表します。ジャンプ中はキー操作を受け付けないように、is_on_ground の値で判定します。

修正
"""src/player.py"""

# キー操作を保存
self.key_map = {
    'w': 0,
    'a': 0,
    's': 0,
    'd': 0,
    'space': 0,  # 追記
}

# ユーザーのキー操作
base.accept('w', self.update_key_map, ["w", 1])
base.accept('w-up', self.update_key_map, ["w", 0])
base.accept('a', self.update_key_map, ["a", 1])
base.accept('a-up', self.update_key_map, ["a", 0])
base.accept('s', self.update_key_map, ["s", 1])
base.accept('s-up', self.update_key_map, ["s", 0])
base.accept('d', self.update_key_map, ["d", 1])
base.accept('d-up', self.update_key_map, ["d", 0])
base.accept('mouse1', self.player_remove_block)
base.accept('mouse3', self.player_add_block)
base.accept('space', self.update_key_map, ["space", 1])  # 追記
base.accept('space-up', self.update_key_map, ["space", 0])  # 追記

ユーザーが「スペース」キーを押したとき、ジャンプアクションを実行します。ユーザー操作を記録するkey_map にキーspace を追記してください。acceptメソッドはユーザーのキー操作を受け付けて、第2引数のメソッドを実行します。第3引数はメソッドに渡す引数をリスト形式で指定します。
これで、ユーザーが「スペース」キーを押すと、key_map['space'] が 1 に、離すと 0 になるように設定できました。

修正
"""src/player.py"""

    def update_velocity(self):
        key_map = self.key_map

        if self.is_on_ground:  # 追記
            if key_map['w'] or key_map['a'] or key_map['s'] or key_map['d']:
                heading = self.direction.x
                if key_map['w'] and key_map['a']:
                    angle = 135
                elif key_map['a'] and key_map['s']:
                    angle = 225
                elif key_map['s'] and key_map['d']:
                    angle = 315
                elif key_map['d'] and key_map['w']:
                    angle = 45
                elif key_map['w']:
                    angle = 90
                elif key_map['a']:
                    angle = 180
                elif key_map['s']:
                    angle = 270
                else:  # key_map['d']
                    angle = 0
                self.velocity = \
                    Vec3(
                        cos(radians(angle + heading)),
                        sin(radians(angle + heading)),
                        0
                    ) * Player.speed
            else:
                self.velocity = Vec3(0, 0, 0)

            if key_map['space']:  # 追記
                self.is_on_ground = False  # 追記
                self.velocity.setZ(Player.jump_speed)  # 追記

update_velocityメソッドを修正します。
ジャンプ中は足が空中に浮いているので、WASD キーによる平行移動やスペースによる再ジャンプは受けてないようにします。条件式 if self.is_on_ground: により、プレイヤーが地面に設置しているか判定しています。
スペースキーが押されたとき、インスタンス変数is_on_ground を False にします(つまり地面に接していない)。そして上向きの速度を self.velocity.setZ(Player.jump_speed)により設定します。これでジャンプすることができます。

修正
"""src/player.py"""

def update_position(self):
    self.update_velocity()
    dt = globalClock.getDt()
    self.position = self.position + self.velocity * dt

    floor_height = 0  # 追記

    if not self.is_on_ground:  # 追記
        if self.position.z <= floor_height:  # 追記
            self.position.z = floor_height  # 追記
            self.is_on_ground = True  # 追記
        else:  # 追記
            self.velocity.setZ(self.velocity.getZ() - Player.gravity_force * dt)  # 追記
   

update_positionメソッドを修正します。
変数floor_height は地面の高さを表します。フラットワールドを想定して、地面の高さに 0 を代入します。
条件式 if not self.is_on_ground: はジャンプ中であることを判定しています。ジャンプ中であるとき、条件式 if self.position.z <= floor_height:により、プレイヤーが接地したか判定します。接地したら、インスタンス変数is_on_ground は True に戻ります。プレイヤーの位置Z は地面の高さと同じにします。
プレイヤーがジャンプ中であれば、上向きの速度を self.velocity.getZ() - Player.gravity_force * dt により計算します。重力加速度により徐々に上向きの速度が遅くなり、下向きの速度に変わっていくことをコードで表現できました。(このコードには誤差があります。最後の節で考察します。)

"""15_01_main.py"""
from math import *
from src import MC


class Game(MC):
    def __init__(self):
        # MCを継承する
        MC.__init__(self, ground_size=16, mode='debug')

        # 座標軸
        self.axis = self.loader.loadModel('models/zup-axis')
        self.axis.setPos(0, 0, 0)
        self.axis.setScale(1.5)
        self.axis.reparentTo(self.render)

        # 階段
        block_id = 'gold_block'
        for i in range(-7, 9):
            for j in range(-7, 9):
                for k in range(6):
                    if -2 <= j < 3:
                        if i < -2:
                            if k - i == 7:
                                self.block.add_block(i, j, k, block_id)
                        if -2 <= i < 3:
                            if k == 5:
                                self.block.add_block(i, j, k, block_id)
                        if 3 <= i:
                            if i + k == 7:
                                self.block.add_block(i, j, k, block_id)
                    if -2 <= i < 3:
                        if j < -2:
                            if k - j == 7:
                                self.block.add_block(i, j, k, block_id)
                        if -2 <= j < 3:
                            if k == 5:
                                self.block.add_block(i, j, k, block_id)
                        if 3 <= j:
                            if j + k == 7:
                                self.block.add_block(i, j, k, block_id)


game = Game()
game.run()
プレイヤーのジャンプアクション

15_01_main.py を新規作成します。
このワールドは中央に階段のある世界です。実行して、プレイヤーがジャンプできるか、「スペース」キーを押して試してください。垂直ジャンプと動きながらジャンプする斜めジャンプの両方が実装されました。

まだ衝突判定がされていないので、階段をすり抜けてしまうバグがあります。次に地面への着地を実装します。

着地の実装

"""src/block.py"""
import re
from math import *
from panda3d.core import *

追記

def get_floor_height(self):
    x, y, z = [floor(value) for value in self.base.player.position]
    s = re.compile(f'{x}_{y}_.+')
    floor_height = -1
    for key in self.block_dictionary:
        if s.search(key):
            _, _, block_z = [int(value) for value in key.split('_')]
            if floor_height < block_z <= z:
                floor_height = block_z
    return floor_height

地面に着地するには、プレイヤーの位置座標 x, y における地面の高さを計算する必要があります。Blockクラスに get_floor_heightメソッドを追加します。
get_floor_heightメソッドは、ブロックの情報 self.block_positions を検索して、プレイヤーがいる場所の地面の高さを求めます。s = re.compile(f'{x}_{y}_.+') は正規表現(Regular Expression)を表しています。正規表現は曖昧な検索とも言えますが、辞書のキーが x_y_(任意の数字) である全てのキーを検索できます。
見つかったキーのうち、Zの値がプレイヤーの位置より低く、かつ最大のものが求める地面の高さになります。

"""src/player.py"""

修正

    def update_position(self):
        self.update_velocity()
        dt = globalClock.getDt()
        self.position = self.position + self.velocity * dt

        floor_height = self.base.block.get_floor_height() + 1  # 修正

        if not self.is_on_ground:
            if self.position.z <= floor_height:
                self.position.z = floor_height
                self.is_on_ground = True
            else:
                self.velocity.setZ(self.velocity.getZ() - Player.gravity_force * dt)
        else:  # 追記
            if floor_height < self.position.z:  # 追記
                self.is_on_ground = False  # 追記
                self.velocity.setZ(-Player.gravity_force * dt)  # 追記

Playerクラスを修正します。
update_positionメソッドにおいて、変数floor_height の値を self.base.block.get_floor_height() + 1 に変更して、地面の高さを get_floor_heightメソッドにより求めます。
追加した4行の elseブロックは、階段から降りるときに空中に浮いてしまうバグを修正するコードです。プレイヤーが地面の高さより上にいるときは、下向きの重力で落下するように変更しました。

15_01_main.py を実行してください。プレイヤーをジャンプさせて、階段に乗せることができます。階段から飛び降りて地面に着地できれば成功です。
次に、衝突判定によりプレイヤーがブロックをすり抜けるバグを修正します。

衝突判定について

衝突判定

プレイヤーとブロックの衝突判定について考えます。上図はプレイヤーとブロックを上から見た図です。
計算を簡単にするためプレイヤーは縦横1メートル高さ2メートルのブロックと考えます。プレイヤーの位置 Point3(px, py, pz) はブロック底面の中心にあります。
設置されているブロックのサイズは1メートル角の立方体(キューブ)で、ブロックの位置 Point3(bx, by, bz) は底面左下の角にあります。

プレイヤーがX軸方向に移動しているとき、プレイヤーの位置から X方向に 
0.5 進んだ点(px + 0.5)にブロックがあれば衝突したと判定できます。衝突したとき、プレイヤーのX座標をブロックのX座標マイナス1(bx - 1)に跳ね返らせます。
逆に -X方向に移動しているとき、プレイヤーの位置から -X方向に 0.5(px - 0.5)進んだ点にブロックがあれば衝突したと判定できます。衝突したとき、プレイヤーのX座標をブロックのX座標プラス2(bx + 2)に跳ね返らせます。

Y軸方向の移動も同様に考えます。
Z方向はプラスのみ考えればよく、プレイヤーの位置からZ方向に 2 進んだ点(pz + 2)にブロックがあれば衝突と判定し、ブロックの座標マイナス2(bz - 2)に跳ね返らせます。
以上の考察を踏まえて、衝突判定をコードで表してみましょう。

衝突判定の実装

追加
"""src/player.py"""

    def change_position_when_interfering_with_block(self):
        velocity_x, velocity_y, velocity_z = self.velocity
        x, y, z = self.position
        # X方向の干渉チェック
        if 0 < velocity_x:
            x_to_check = x + 0.5
            if self.base.block.is_block_at(Point3(x_to_check, y, z)) or \
                    self.base.block.is_block_at(Point3(x_to_check, y, z + 1)):
                x = floor(x_to_check) - 1
        elif velocity_x < 0:
            x_to_check = x - 0.5
            if self.base.block.is_block_at(Point3(x_to_check, y, z)) or \
                    self.base.block.is_block_at(Point3(x_to_check, y, z + 1)):
                x = floor(x_to_check) + 2
        # Y方向の干渉チェック
        if 0 < velocity_y:
            y_to_check = y + 0.5
            if self.base.block.is_block_at(Point3(x, y_to_check, z)) or \
                    self.base.block.is_block_at(Point3(x, y_to_check, z + 1)):
                y = floor(y_to_check) - 1
        elif velocity_y < 0:
            y_to_check = y - 0.5
            if self.base.block.is_block_at(Point3(x, y_to_check, z)) or \
                    self.base.block.is_block_at(Point3(x, y_to_check, z + 1)):
                y = floor(y_to_check) + 2
        # Z方向の干渉チェック
        if 0 < velocity_z:
            z_to_check = z + 2
            if self.base.block.is_block_at(Point3(x, y, z_to_check)):
                z = floor(z_to_check) - 2
                self.velocity.setZ(0)
        self.position = Point3(x, y, z)

Playerクラスに change_position_when_interfering_with_blockメソッドを追加します。プレイヤーとブロックが衝突したら、プレイヤーの位置を変更します。
プレイヤーの移動方向 self.velocity により、X方向、Y方向、Z方向それぞれの衝突判定を行います。
X方向に移動しているときは x + 0.5 の位置(x_to_check = x + 0.5)にブロックがあるか判定することで衝突判定を行います。衝突したらブロックの位置からX方向に - 1(x = floor(x_to_check) - 1)にプレイヤーを移動させます。
-X方向に移動しているときは x - 0.5 の位置(x_to_check = x - 0.5)にブロックがあるか判定することで衝突判定を行います。衝突したらブロックの位置からX方向に2(x = floor(x_to_check) + 2)にプレイヤーを移動させます。
Y方向の移動、Z方向の移動も同様に考えます。

修正

def draw(self):
    self.base.player_node.setH(self.direction.x)
    self.base.player_head_node.setP(self.direction.y)
    # # ブロックと干渉したとき位置を修正
    self.change_position_when_interfering_with_block()
    self.base.player_node.setPos(self.position)

Playerクラスの drawメソッドを修正します。
player_nodeノードの位置を決める直前に、先ほど追加した change_position_when_interfering_with_blockメソッドを追記します。これで、ブロックと衝突したらプレイヤーの位置が修正されるようになりました。

衝突判定

15_01_main.py を実行して、プレイヤーとブロックの衝突判定を実験してみましょう。X方向、-X方向、Y方向、-Y方向、Z方向に移動して、ブロックとぶつかったときに進めなくなれば成功です。
これで物理シミュレーションの実装は完了です。

ジャンプアクションの誤差について

勘の良い方ならお気づきと思いますが、今回実装したジャンプアクションには誤差があります。誤差の大きさとその原因について考察します。

"""15_02_graph_of_jump.py"""
import numpy as np
import matplotlib.pyplot as plt

fig, ax = plt.subplots()

v0, g = 10, 9.8
t0 = np.linspace(0, 2.25, 100)
z0 = v0 * t0 - (g * t0 ** 2) / 2

jump_positions_with_time = \
    [(0, 0.0, 0.0, 0.0), (0.028672000000000253, 0.0, 0.0, 0.28672000765800476),
     (0.05009499999999978, 0.0, 0.0, 0.4949304461479187), (0.06653399999999987, 0.0, 0.0, 0.6512500047683716),
     (0.11509000000000036, 0.0, 0.0, 1.1051498651504517), (0.13203399999999998, 0.0, 0.0, 1.2554789781570435),
     (0.2818200000000006, 0.0, 0.0, 2.559525728225708), (0.298381, 0.0, 0.0, 2.679396867752075),
     (0.3983780000000001, 0.0, 0.0, 3.3869621753692627), (0.41492400000000007, 0.0, 0.0, 3.4878249168395996),
     (0.7154069999999999, 0.0, 0.0, 5.270813941955566), (0.7321200000000001, 0.0, 0.0, 5.320769309997559),
     (0.8817370000000002, 0.0, 0.0, 5.743470668792725), (1.131679, 0.0, 0.0, 6.083136081695557),
     (1.2315620000000003, 0.0, 0.0, 5.974218368530273), (1.2488489999999999, 0.0, 0.0, 5.938446044921875),
     (1.3649300000000002, 0.0, 0.0, 5.678573131561279), (1.5317100000000003, 0.0, 0.0, 5.115471363067627),
     (1.7651240000000001, 0.0, 0.0, 3.945890188217163), (1.78247, 0.0, 0.0, 3.8192954063415527),
     (1.9150460000000002, 0.0, 0.0, 2.829190492630005), (2.0983590000000003, 0.0, 0.0, 1.2220027446746826),
     (2.3652560000000005, 0.0, 0.0, -1.597475528717041)]

t1 = [value[0] for value in jump_positions_with_time]
z1 = [value[3] for value in jump_positions_with_time]

c1, c2, c3, c4 = "blue", "green", "red", "black"  # 各プロットの色
l1, l2, l3, l4 = "parabola", "measured value", "label3", "label4"  # 各ラベル

ax.set_xlabel('t')  # x軸ラベル
ax.set_ylabel('z')  # y軸ラベル
ax.set_title(r'Jump Record')  # グラフタイトル
ax.grid()  # 罫線
ax.plot(t0, z0, color=c1, label=l1)
ax.plot(t1, z1, color=c2, label=l2)
ax.legend(loc=0)  # 凡例
fig.tight_layout()  # レイアウトの設定
plt.show()
ジャンプアクションの誤差

上図はジャンプアクションの誤差のグラフです。プレイヤーが垂直にジャンプした時の時間(t)と高さ(z)の関係を表しています。青線は運動方程式から得られる理論値です。緑線はプレイヤーの動きを実測して、その数値を座標にプロットして線で結んだものです。
実測値(緑)は理論値(青)より大きくなっています。実測値の最高点は約6.1メートル、理論値は$${\frac{10^2}{2 \times 9.8} = 5.102}$$ なので、20%ほど大きくなっています。

区分求積法の誤差

誤差の原因について考えます。
上図は時間 t と上向きの速度 $${v_z}$$ の関係を表したグラフです。階段の横幅は微小な時間(~0.1秒)と考えてください。運動方程式は等加速度運動なので、グラフの勾配は一定になっています。時間と速度のグラフでは、位置はグラフを積分した値で表されます。

今回のジャンプアクションの計算は、積分の近似計算である区分求積法を用いています。運動方程式の理論値は斜め下に引いた線と T軸で囲まれた台形の面積です。区分求積法で求められる面積は階段の面積で、誤差は斜め線からはみ出した部分です。
区分求積法において、階段の幅をどんどん小さくすると、誤差がどんどん小さくなり、階段の幅が 0 のときに誤差が 0 になることがグラフから読み取れます。これが積分法の原理です。

誤差の修正については今回は行わないことにしました。ジャンプアクションの実測値を見ると、ほぼ放物線を描いていますし、ゲームにおいては厳密に運動方程式を再現する必要性が少ないからです。
以上で誤算の考察を終了します。

今回は物理シミュレーションを実装しました。厳密に物理法則を再現するのは困難ですが、うまく近似を用いることで比較的簡単にプログラミングできることがわかりました。物理学(運動方程式)や数学(積分法)の勉強もできて、楽しかったですね? 興味が湧いたら、本格的に物理、数学を勉強してみましょう。

次回は、レンダリング(3DCG を画面上に描画すること)の高速化を行います。地面のサイズ(現在 16x16)を大きくすると、ブロックの数が多くなりすぎて、ゲームの進行がカクカクしてしまいます。レンダリングの高速化により、地面サイズを256x256 に広げることができるようになります。お楽しみに。


前の記事
Pythonでマイクラを作る ⑭ゲームをセーブする
次の記事
Pythonでマイクラを作る ⑯レンダリングの高速化

その他のタイトルはこちら


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