🤸♂️
体感ゲームを自作しよう
💡やること
micro:bit + ラズパイ + pyxel を組み合わせて、体感ゲームを自作します。
ゲームを自体は単純で、ジャンプして障害物をよけるものです。
🏁デモ
つくるもの
- ゲームを開始する
- この時点でマイクロビットは加速度データをラズパイに定期送信
- 障害物(サボテン)がプレイヤー(キャラクタ)に迫ってくる
- 操作者(Actor)は、タイミングよくリアルな空間でジャンプする
- ラズパイは、加速度データの変化をみてジャンプを認識する
- ジャンプを認識したら、プレイヤー(キャラクタ)をジャンプさせる
- 障害物を回避する
- 3回ぶつかったら、ゲームオーバー
🔧パーツ一覧
no | 部品名 | 個数 | 備考 |
---|---|---|---|
1 | ラズベリーパイ | 1 | 今回は4Bで確認 |
2 | マイクロビットV2 | 1 | 秋月電子通商 |
💻環境
開発環境
64bit版 - Linux rpi 5.15.32
- ラズベリーパイ
- Linux raspberrypi 5.15.32-v8+ #1538 SMP PREEMPT Thu Mar 31 19:40:39 BST 2022 aarch64
- Python
- Python 3.9.2 (default, Feb 28 2021, 17:03:44)
📝手順
- 事前準備(前記事の参照)
- micro:bitとラズパイをつなぐ
- Pyxelのインストール
- ゲームプログラムの全体像
- 動作確認
- ジャンプの検出
- ゲーム実体
事前準備 - micro:bitとラズパイをつなぐ
下記の記事にまとめてあります。
事前準備 - Pyxelのインストール
下記の記事にまとめてあります。
ゲームプログラムの全体像
下記の図では、主要クラスのみ記載しています。
各クラスの役割は以下となります。
- jump_mbit.py
- Gameクラス
- ゲーム全体の管理
- 本クラスの中で、プレイヤー/障害物などを制御する
- ジャンプ情報キューの受信も本クラス内で行います
- キューのデータはそのままPlayerクラスに渡す
- Playerクラス
- プレイヤーの制御、描画を行う
- Hurdleクラス
- 障害物の制御、管理を行う
- プレイヤーとの衝突判定は、本クラスで計算する
- その他のクラス
- Scoreクラス : 点数の管理(16フレーム経過毎に+10ポイント)
- Sandクラス : 背景 - 砂漠の砂を描画
- Soundクラス : 衝突時の効果音
- Effectクラス : 衝突時の効果
- Gameクラス
- mbit.py
- Mbitクラス
- bluetooth通信経由のデータ受信
- ジャンプイベントの判定
- Gameクラスへのイベント送信
- Mbitクラス
ジャンプの検出
ジャンプの検出は、mbit.py - Mbitクラスで行います。
コード
以下がコードとなります。
mbit.py
from bluepy.btle import DefaultDelegate, Peripheral,ADDR_TYPE_RANDOM
from queue import Queue
import threading
import time
import datetime
# 自身の環境に合わせて設定してください
MAC_ADDRESSs = [
'XX:XX:XX:XX:XX:XX',
]
#ACCELEROMETER SERVICE/CHARACTERISTICS UUID
ACC_SERVICE_UUID = 'E95D0753251D470AA062FA1922DFA9A8'
ACC_CHARACTERISTICS_UUID = 'E95DCA4B251D470AA062FA1922DFA9A8'
#加速度センサーの動作判定しきい値
JUMP_TH_MIN = 1500 #ジャンプ最小値
JUMP_TH_MAX = 3000 #ジャンプ最大値
class Mbit(threading.Thread):
"""
マイクロビット管理
"""
def __init__(self, macadr, snd_que=None, s_min=8, s_max=20):
threading.Thread.__init__(self)
self.stop_event = threading.Event()
self.setDaemon(True)
self._macadr = macadr
self._snd_que = snd_que
self._peripheral = None
self._s_min = s_min
self._s_max = s_max
return
def stop(self):
self.stop_event.set()
if self._peripheral is not None:
self._peripheral.disconnect()
return
def run(self):
def is_running(strength):
return True if strength > RUN_TH else False
def cal_strength(x0, y0, x1, y1, x):
return y0 + (y1 - y0) * (x - x0) // (x1 - x0)
def is_jumping(strength):
if strength > JUMP_TH_MAX:
return True, self._s_max
elif strength > JUMP_TH_MIN:
return True, cal_strength(JUMP_TH_MIN, self._s_min, JUMP_TH_MAX, self._s_max, strength)
return False, None
def service_mb():
# 接続設定
peripheral = Peripheral(self._macadr, ADDR_TYPE_RANDOM)
acc_service = peripheral.getServiceByUUID(ACC_SERVICE_UUID)
acc_characteristic = peripheral.getCharacteristics(uuid=ACC_CHARACTERISTICS_UUID)
while True:
# 値の読み取り
acc_read_data = acc_characteristic[0].read()
# 加速度センサー
x = int.from_bytes(acc_read_data[0:2], byteorder='little', signed=True)
y = int.from_bytes(acc_read_data[2:4], byteorder='little', signed=True)
z = int.from_bytes(acc_read_data[4:6], byteorder='little', signed=True)
s = (x**2 + y**2 + z**2)**0.5
ret, s_conv = is_jumping(s)
if ret:
self._send_msg(self._macadr, "jump", s_conv)
while True:
try:
service_mb()
except:
print("error : except")
time.sleep(3)
return
def _send_msg(self, name, action, strength=10):
if self._snd_que is None:
return
print(f"action: {action}, strength: {strength}")
self._snd_que.put({"type": "mbit", "name": name, "action": action, "strength": int(strength)})
return
def main():
q = Queue()
for ma in MAC_ADDRESSs:
mbit_th = Mbit(ma, q)
mbit_th.start()
time.sleep(60)
mbit_th.stop()
while True:
if q.empty():
print("!!! q.empty !!!")
break
print(q.get())
return
if __name__ == "__main__":
main()
実行結果
以下がコードとなります。
(env) $ python mbit.py
# micro:bitを揺らす
action: jump, strength: 17.0
action: jump, strength: 18.0
action: jump, strength: 15.0
action: jump, strength: 19.0
action: jump, strength: 20
# micro:bitを揺らす
action: jump, strength: 10.0
action: jump, strength: 17.0
action: jump, strength: 18.0
action: jump, strength: 12.0
action: jump, strength: 13.0
# micro:bitを揺らす
action: jump, strength: 19.0
action: jump, strength: 19.0
action: jump, strength: 15.0
action: jump, strength: 19.0
action: jump, strength: 8.0
# micro:bitの電源を切る
error : except
micro:bitが揺れると、ジャンプとして認識していることがわかります。
補足
- 加速度の大きさの計算
- この計算は、"s = (x2 + y2 + z**2)**0.5"としています
- micro:bitの向きによりマイナス方向となるため、2乗しています
- また、ジャンプの検出は単純にその大きさとしています
- 使用する環境によって、この計算式やJUMP_TH_MIN, JUMP_TH_MAXのチューニングが必要です
- ジャンプの大きさ
- ジャンプの大きさは、self._s_min, self._s_maxの間で強さが決定されます
- 本値は、キャラクタのジャンプの大きさを変更できるように設けています
これで、ジャンプの検出ができるようになりました。
ゲーム実体
公式サイト - exmples - 02_jump_game.pyを参考に作成しました。
リソースファイルも参考にしています。
コード
コメントに説明を記載しています。
pyxel自体の説明は、公式を参照願います。
jump_mbit.py
import pyxel
import random
from enum import Enum, IntEnum
from queue import Queue
import threading
import time
from mbit import Mbit, MAC_ADDRESSs
# デバッグ用
DEBUG_GAME = False # False:通常, True:当たり判定表示
CAP_GAME = False # False:通常, True:キャプチャ実施
# ゲーム定義
OUTER_SIZE = (240, 134) # 画面の大きさ
# プレイヤー定義
# - プレイヤー大きさ
PLAYER_WIDTH = 16
PLAYER_HEIGHT = 16
# - プレイヤー位置
PLAYER_BASE_X = 10
PLAYER_BASE_Y = OUTER_SIZE[1] - PLAYER_HEIGHT - 4
# プレイヤー状態
class State(Enum):
STANDING = 1 # 地面に立っている
JUMPING = 2 # ジャンプ中
# カラーパレット
class Plt(IntEnum):
BLACK = 0 # 000000
NAVY = 1 # 2B335F
PLUM = 2 # 7E2072 (ラズベリー、ロイヤルパープル)
TURQUOISE = 3 # 19959C (ピーコックブルー、ナイトブルー)
WINE_RED = 4 # 8B4852
COBALT_BLUE = 5 # 395C98
BABY_BLUE = 6 # A9C1FF
SNOW_WHITE = 7 # EEEEEE
MAGENTA = 8 # D4186C
CAMEL_COLOR = 9 # D38441
HONEY = 10 # E9C35B
COBALT_GREEN = 11 # 70C6A9
HYACINTH = 12 # 7696DE
PEARL_GRAY = 13 # A3A3A3
PINK = 14 # FF9798
ASH_ROSE = 15 # EDC7B0
TRANS = 0 # 透過色
BG = 12 # 背景色
# 障害物定義
HurdleType = {
"type1":
{
"u": 0,
"v": 152,
"w": 16,
"h": 16,
},
"type2":
{
"u": 0,
"v": 168,
"w": 16,
"h": 16,
},
"type3":
{
"u": 16,
"v": 152,
"w": 16,
"h": 32,
},
}
class Effect:
"""エフェクト : プレイヤーが障害物にぶつかった場合のエフェクト
"""
def __init__(self):
self.underEffect = []
def start(self, fcount=20):
self.underEffect.append([fcount])
def move(self):
for i in range(len(self.underEffect)-1, -1, -1):
t = self.underEffect[i][0]
if t == 1:
self.underEffect.pop(i)
else:
self.underEffect[i][0] -= 1
def draw(self, pos):
img_x = 0
for exp in self.underEffect:
img_y = 120 if exp[0] % 2 == 0 else 136
pyxel.blt(
*pos,
0,
img_x, img_y,
16, 16,
Plt.TRANS
)
class Player:
"""プレイヤーの管理
"""
def __init__(self):
self.init()
def init(self):
self.count = 3 # 残機
self.x = PLAYER_BASE_X # プレイヤー表示水平位置
self.y = PLAYER_BASE_Y # プレイヤー表示垂直位置
self.w = PLAYER_WIDTH # プレイヤー表示水平幅
self.h = PLAYER_HEIGHT # プレイヤー表示垂直幅
self.state = State.STANDING
self.vel = 0 # y方向の速度
self.acc = 1 # 重力加速度
self.vel_base = -10 # y方向の速度(初期値)
self.acc_base = 1 # 重力加速度(初期値)
def pos(self):
return (self.x, self.y)
def pos_for_hit(self):
return ((self.x + self.w//2), (self.y + self.h//2))
def dec(self):
if self.count > 0:
self.count -= 1
def left(self):
return self.count
def move(self, ev_mbit=None):
# キーボードイベント
if pyxel.btnp(pyxel.KEY_SPACE) and self.state == State.STANDING:
self.acc = self.acc_base
self.vel = self.vel_base # 初速を与える
# ジャンプする
self.state = State.JUMPING
# キーボードイベント(for debug)
if pyxel.btnp(pyxel.KEY_UP):
self.vel_base -= 1
print(f"vel_base:{self.vel_base}")
# キーボードイベント(for debug)
if pyxel.btnp(pyxel.KEY_DOWN):
self.vel_base += 1
print(f"vel_base:{self.vel_base}")
# micro:bit event
if ev_mbit is not None:
print("ev_mbit is not None")
if ev_mbit["action"] in "jump" and self.state == State.STANDING:
print("ev_mbit : jump")
self.vel_base = (-1) * ev_mbit["strength"]
self.acc = self.acc_base
self.vel = self.vel_base # 初速を与える
self.state = State.JUMPING
# 更新
self.vel += self.acc
self.y += self.vel
# 着地判定
if self.y > PLAYER_BASE_Y:
self.y = PLAYER_BASE_Y
if self.state == State.JUMPING:
self.state = State.STANDING
def draw(self):
# ジャンプ中の画柄
if self.state == State.JUMPING:
pyxel.blt(
self.x, self.y,
0,
16, 0,
PLAYER_WIDTH, PLAYER_HEIGHT,
Plt.TRANS
)
# 走行中の画柄
elif self.state == State.STANDING:
pyxel.blt(
self.x, self.y,
0,
0, 0,
PLAYER_WIDTH, PLAYER_HEIGHT,
Plt.TRANS
)
# デバッグ用 - 当たり判定位置の表示
if DEBUG_GAME:
pyxel.pset(self.x, self.y, Plt.MAGENTA)
hit_pos = self.pos_for_hit()
pyxel.pset(*hit_pos, Plt.MAGENTA)
class Hurdle:
"""障害物の管理
"""
def __init__(self):
self.init()
def init(self):
self.hurdles = [] # x, y, u, v, w, h
self.speed = 2
self.max_count = 2
def create(self):
"""障害物の生成
"""
# 1画面中の最大障害数
if len(self.hurdles) == self.max_count:
return
if pyxel.frame_count % 128 != 0:
if random.randint(1, 10) != 1:
return
typ = "type" + str( random.randint(1, 3) )
u, v = HurdleType[typ]["u"], HurdleType[typ]["v"]
w, h = HurdleType[typ]["w"], HurdleType[typ]["h"]
self.add(
OUTER_SIZE[0] + 10, OUTER_SIZE[1] - h - 4, \
u, v, w, h)
return
def add(self, x, y, u, v, w, h):
self.hurdles.append([x, y, u, v, w, h])
def cal_hitrange(self, hardle):
"""衝突判定範囲の算出
"""
off_x = 4
off_y = 0
return (hardle[0] + off_x), hardle[1] + off_y, \
(hardle[4] - off_x*2), (hardle[5] - off_y*2)
def hit_player(self, pos):
"""プレイヤーとの衝突判定
"""
player_x, player_y = pos
for i in range(len(self.hurdles)-1, -1, -1):
h_x, h_y, h_w, h_h = self.cal_hitrange(self.hurdles[i])
if h_x <= player_x and player_x <= (h_x + h_w) and \
h_y <= player_y and player_y <= (h_y + h_h):
self.hurdles.pop(i)
return True
return False
def move(self):
"""障害物の移動
"""
for i in range(len(self.hurdles)-1, -1, -1):
if self.hurdles[i][0] < -10:
self.hurdles.pop(i)
else:
self.hurdles[i][0] -= self.speed
def draw(self):
"""障害物の描画
"""
for hurdle in self.hurdles:
pyxel.blt(
hurdle[0], hurdle[1], # x, y
0,
hurdle[2], hurdle[3], # u, v
hurdle[4], hurdle[5], # w, h
Plt.TRANS
)
# 障害物の
if DEBUG_GAME:
x, y, w, h = self.cal_hitrange(hurdle)
pyxel.rectb(x, y, w, h, Plt.MAGENTA)
class Sand:
"""砂の装飾管理
"""
def __init__(self):
self.num_of_stars = 80
self.sands = []
for i in range(self.num_of_stars):
self.sands.append([
random.randint(0, OUTER_SIZE[0]),
random.randint(OUTER_SIZE[1] - 16, OUTER_SIZE[1] - 8),
random.randint(2, 15)
])
def move(self):
for i in range(self.num_of_stars):
self.sands[i][0] -= 1
if self.sands[i][0] < 0:
self.sands[i][0] = OUTER_SIZE[0]
self.sands[i][1] = random.randint(OUTER_SIZE[1] - 16, OUTER_SIZE[1] - 8)
def draw(self):
for i in range(self.num_of_stars):
pyxel.pset(self.sands[i][0], self.sands[i][1], self.sands[i][2])
class Score:
"""点数管理
"""
def __init__(self):
self.init()
self.hi_score = 0
def init(self):
self.score = 0
def add(self, n):
self.score += n
self.hi_score = max(self.score, self.hi_score)
def value(self):
return self.score
def hi_value(self):
return self.hi_score
class Sound:
"""音声の管理
"""
def __init__(self):
# プレイヤーの衝突音
pyxel.sound(1).set("f0f1", "n", "7", "s", 15)
def playerBomb(self):
pyxel.play(1, 1)
class Game:
"""ゲーム全体の管理
"""
def __init__(self, rcv_que):
# pyxelの初期化
if CAP_GAME:
pyxel.init(*OUTER_SIZE, title="JUMP GAME", display_scale=8, capture_scale=1, capture_sec=10)
else:
pyxel.init(*OUTER_SIZE, title="JUMP GAME", display_scale=8)
# 各キャラクタの準備
pyxel.load("jump_game.pyxres")
self._rcv_que = rcv_que
self.hurdle = Hurdle()
self.sand = Sand()
self.player = Player()
self.effect = Effect()
self.score = Score()
self.sound = Sound()
self.demoMode = True
self.scene = 0
self.far_cloud = [(-10, 75), (40, 65), (90, 60)]
self.near_cloud = [(10, 25), (70, 35), (120, 15)]
pyxel.run(self.update, self.draw)
def init(self):
self.hurdle.init()
self.scene += 1
def demoPlay(self):
if self.player.left() == 0:
pyxel.text(40+58, 40, "GAME OVER", 8)
pyxel.text(40+50, 60, "Pyxel-JUMP GAME", 7)
pyxel.text(40+45, 76, "START : SPACE KEY", 7)
pyxel.text(40+50, 92, f"HIGH SCORE {self.score.hi_value()}", 7)
if pyxel.btnp(pyxel.KEY_SPACE):
self.demoMode = False
self.score.init()
self.player.init()
self.scene = 0
self.init()
def update(self):
"""フレーム更新時の処理
"""
# キー入力チェック
# 各キャラクタの移動
self.hurdle.create()
self.hurdle.move()
self.sand.move()
self.effect.move()
if self.demoMode:
return
if self._rcv_que.empty():
m_ev = None
else:
m_ev = self._rcv_que.get()
self.player.move(m_ev)
if pyxel.frame_count % 16 == 0:
self.score.add(10)
# 衝突判定
if self.hurdle.hit_player(self.player.pos_for_hit()):
self.effect.start()
self.sound.playerBomb()
self.player.dec()
self.hurdle.init()
# 終了判定
if self.player.left() == 0:
self.demoMode = True
def draw(self):
"""描画処理"""
# 画面クリア
pyxel.cls(Plt.BG)
# 床の表示
pyxel.rect(0, OUTER_SIZE[1] - 16, *OUTER_SIZE, Plt.ASH_ROSE)
# 雲の表示
offset = (pyxel.frame_count // 16) % 160
for i in range(2):
for x, y in self.far_cloud:
pyxel.blt(x + i * 160 - offset, y, 0, 64, 32, 32, 8, 12)
offset = (pyxel.frame_count // 8) % 160
for i in range(2):
for x, y in self.near_cloud:
pyxel.blt(x + i * 160 - offset, y, 0, 0, 32, 56, 8, 12)
# 各キャラクタの描画処理
self.sand.draw()
pyxel.text(10, 0,
" SCENE:{0:2d} HI:{1:05d} SCORE:{2:05d} LEFT:{3:1d}".format(
self.scene,
self.score.hi_value(),
self.score.value(),
self.player.left()
), 7)
if self.demoMode:
self.demoPlay()
self.hurdle.draw()
self.player.draw()
self.effect.draw(self.player.pos())
if __name__ == "__main__":
que_mbit = Queue()
for ma in MAC_ADDRESSs:
mbit_th = Mbit(ma, que_mbit)
mbit_th.start()
mbit_th = Mbit(que_mbit)
mbit_th.start()
Game(que_mbit)
実行結果
デモの結果となります。
補足
- ゲームの開始/終了
- ゲームの開始は、キーボードより、SPACEキーで開始する
- ゲームの数量は、キーボードより、ESCキーで終了する
- リソースファイルの参照
- リソースファイルの中身は下記となっており、黄色線部分を使用している
- Mbitスレッドからのイベントの受信
- " m_ev = self._rcv_que.get()"にてイベントの受信を行っている
- そのあとの"self.player.move(m_ev)"にて、プレイヤーに渡している
😕気になったこと
- [ゲーム]ジャンプイベントの反応が気持ち遅い
- おそらくbluetoothの部分がネック
- micro:bit v1だと、1秒あたりの加速度センサーの受信数が少なくなってしまい、より遅く感じる
- このシステムは、micro:bit v2でないとダメ
- [ゲーム]地味である
- エフェクト、サウンドにもう少し力を入れてもよかった
- [技術]micro:bit v1の不満
- 起動の順番によって、メモリエラーになってしまう
さいごに
思っていたよりゲームになってよかったです。
また、pyxelを初めて触りましたが、使いやすく勉強になりました。
micro:bitには他にもセンサーがあるため、より体感の強いものもできそうです。
この記事以外に、ラズパイの活用方法を
としてまとめ中です。
Discussion