ここでは、Windows11で、Sony toio を活用し、PhysicalAIを目指したロボットシミュレーションの検証結果を報告します。
最近では PhysicalAIが注目されており、自分もロボットシミュレーションを試したいと思いました。ここでは、Windows11で、Sony製ロボット「toio:トイオ」を活用する手順を記録してゆきます。
まず、この記事はPythonによるプログラミングの、以下の手順の続きです。
Pythonプログラミングで活用する情報は、以下のウエブ「github - toio.py」が基本情報です。
https://github.com/toio/toio.py/blob/main/README.ja.md
ここでは、ChatGPTの支援を受けながら、toioプログラミングの例題として、Pythonでマット上のtoio 2台で鬼ごっこをするプログラムを作ることを目標に、asyncioによる同時制御を検証してゆきます。
なお、用いるマットは、toioコアキューブの箱の中にある「方眼状の簡易プレイマット」です。
asyncio とは、Pythonで「非同期処理(並行処理)」を書くための標準ライブラリです。async/await 構文を使い、通信やファイルの読み書きなど「待ち時間」が発生する処理を効率よく同時に進めることができます。
つまり、2台のtoioに、それぞれの機能をプログラミングして、2台が並行処理するようにしています。
はじめに
これまで色々と検証してきたように、Sonyのtoioは、Bluetoothを使ってPythonから制御できる小型ロボットです。toioの特徴の一つは、専用マット上で自分の位置や向きを取得できることです。
今回は、このロボットの位置を簡単に取得できる機能を用いて、toioを2台使って簡単な「鬼ごっこプログラム」を作ってみます。
ここでは、2台のロボットとして、赤色のtoioを逃走役、青色のtoioを鬼として動かします。いろいろと試してみると、2台のtoioで逃走役と鬼の左右は自由ですが、マットの左右の端の中央に置くと良い感じです。
また、マットの折り目でtoioが引っ掛かり、動けなくなる時があります。マットは平らな状態にしてください。
プレイマット全体
┌─────────────────────────────────┐
│ │
│ 🔴RUNNER:逃走役 CHASER:鬼🔵 │
│ │
└─────────────────────────────────┘
- 赤いtoioは、プレイマットの外周を移動する
- 一定時間ごとに、ランダムに移動方向を変える
- 青いtoioは、赤いtoioの現在位置を取得して追いかける
- 青いtoioは、時間とともに少しずつ加速する
- 2台の距離が一定以下になると「CATCH!」としてゲーム終了
この例題では、toioの位置情報を利用した移動だけでなく、Pythonのasyncioを使って2台のtoioを同時に制御します。
この記事で使用するプログラムは「tag-game-toio-qiita.py」です。このプログラムについて解説してゆきます。
以下のリンクから、プログラム全体を表示して、それをVScodeなどの、エディタにコピーして、ファイルを作成してください。
プログラムには、コメント内に「【Qiita 1】」の記述があり、これは本Qiita記事の内容に、対応しています。解説を読みながら、プログラムの該当する部分を確認してください。
「tag-game-toio-qiita.py」のソースコードを見る(ここをクリック)
# ============================================================
# 【Qiita 1】今回作る鬼ごっこ
#
# toio 2台による鬼ごっこ
#
# 赤色のtoio:逃走役
# 青色のtoio:追跡役(鬼)
#
# asyncioを使って、2台を同時に動かします。
# ============================================================
import asyncio # 非同期処理を使うための標準ライブラリ
import math # 距離や平方根を計算するための標準ライブラリ
import random # ランダムな方向転換を行うための標準ライブラリ
import time # 経過時間を測定するための標準ライブラリ
from toio import * # toioを制御するためのクラスを読み込む
# ============================================================
# 【Qiita 2】プログラム全体の構成
#
# このプログラムは、次の部分で構成されています。
#
# ・プレイマットの設定
# ・ゲーム設定
# ・位置や距離を計算する共通関数
# ・逃走役 runner()
# ・鬼 chaser()
# ・停止処理 stop_all()
# ・メイン処理 main()
# ============================================================
# ============================================================
# 【Qiita 3】プレイマットの移動範囲を決める
# ============================================================
MAT_X_MIN = 98 # プレイマットの最小X座標
MAT_X_MAX = 402 # プレイマットの最大X座標
MAT_Y_MIN = 142 # プレイマットの最小Y座標
MAT_Y_MAX = 358 # プレイマットの最大Y座標
# マットの端ぎりぎりまで移動しないための余白
MARGIN = 35
# 余白を考慮した実際の移動範囲
X_MIN = MAT_X_MIN + MARGIN
X_MAX = MAT_X_MAX - MARGIN
Y_MIN = MAT_Y_MIN + MARGIN
Y_MAX = MAT_Y_MAX - MARGIN
# ============================================================
# 【Qiita 17】プログラムを変更して遊んでみる
#
# 以下の値を変更することで、鬼ごっこの動きを変えられます。
# ============================================================
RUNNER_SPEED = 75 # 逃走役の移動速度
ESCAPE_STEP = 55 # 逃走役が1回に進む距離
DIRECTION_CHANGE_INTERVAL = 5.0 # 方向転換を判定する時間間隔[秒]
DIRECTION_CHANGE_PROBABILITY = 0.35 # 方向転換する確率
CHASER_START_SPEED = 20 # 鬼の開始時の速度
CHASER_MAX_SPEED = 100 # 鬼の最大速度
SPEED_UP_INTERVAL = 2 # 鬼が加速する時間間隔[秒]
SPEED_UP_AMOUNT = 10 # 1回の加速で増える速度
CHASE_STEP = 30 # 鬼が1回に進む距離
CATCH_DISTANCE = 40 # この距離以下で捕獲
UPDATE_INTERVAL = 0.4 # 各処理を繰り返す間隔[秒]
# ============================================================
# 【Qiita 4】toioの現在位置を取得する
# ============================================================
async def get_position(cube):
# toioから現在のID情報を非同期で読み取る
pos = await cube.api.id_information.read()
# 取得した情報がプレイマット上の位置情報か確認する
if isinstance(pos, PositionId):
# X座標、Y座標、現在の角度を返す
return pos.center.point.x, pos.center.point.y, pos.center.angle
# 位置情報を取得できなかった場合はNoneを返す
return None
# ============================================================
# 【Qiita 5】2点間の距離を計算する
# ============================================================
def distance(x1, y1, x2, y2):
# 三平方の定理を使って2点間の直線距離を計算する
return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
# ============================================================
# 【Qiita 8】鬼は逃走役の方向を計算する
#
# ベクトルの長さを1にして、方向だけを取り出す。
# ============================================================
def normalize(dx, dy):
# X方向とY方向のベクトルの長さを計算する
length = math.sqrt(dx * dx + dy * dy)
# 同じ位置にいる場合に0で割ることを防ぐ
if length < 0.001:
return 0.0, 0.0
# ベクトルの長さを1にして方向だけを返す
return dx / length, dy / length
# ============================================================
# 【Qiita 6】逃走役はプレイマットの外周を移動する
#
# 【Qiita 7】時計回りと反時計回りを切り替える
#
# direction = 1:時計回り
# direction = -1:反時計回り
# ============================================================
def get_next_perimeter_target(x, y, direction):
# 現在位置から上辺までの距離を計算する
dist_top = abs(y - Y_MIN)
# 現在位置から下辺までの距離を計算する
dist_bottom = abs(y - Y_MAX)
# 現在位置から左辺までの距離を計算する
dist_left = abs(x - X_MIN)
# 現在位置から右辺までの距離を計算する
dist_right = abs(x - X_MAX)
# 現在位置から最も近い辺までの距離を求める
min_dist = min(dist_top, dist_bottom, dist_left, dist_right)
# directionが1なら時計回りに移動する
if direction == 1:
# 上辺にいる場合
if min_dist == dist_top:
target_x = x + ESCAPE_STEP
target_y = Y_MIN
# 右端まで到達したら、次は下方向へ進む
if target_x >= X_MAX:
target_x = X_MAX
target_y = y + ESCAPE_STEP
# 右辺にいる場合
elif min_dist == dist_right:
target_x = X_MAX
target_y = y + ESCAPE_STEP
# 下端まで到達したら、次は左方向へ進む
if target_y >= Y_MAX:
target_y = Y_MAX
target_x = x - ESCAPE_STEP
# 下辺にいる場合
elif min_dist == dist_bottom:
target_x = x - ESCAPE_STEP
target_y = Y_MAX
# 左端まで到達したら、次は上方向へ進む
if target_x <= X_MIN:
target_x = X_MIN
target_y = y - ESCAPE_STEP
# 左辺にいる場合
else:
target_x = X_MIN
target_y = y - ESCAPE_STEP
# 上端まで到達したら、次は右方向へ進む
if target_y <= Y_MIN:
target_y = Y_MIN
target_x = x + ESCAPE_STEP
# directionが-1なら反時計回りに移動する
else:
# 上辺にいる場合
if min_dist == dist_top:
target_x = x - ESCAPE_STEP
target_y = Y_MIN
# 左端まで到達したら、次は下方向へ進む
if target_x <= X_MIN:
target_x = X_MIN
target_y = y + ESCAPE_STEP
# 左辺にいる場合
elif min_dist == dist_left:
target_x = X_MIN
target_y = y + ESCAPE_STEP
# 下端まで到達したら、次は右方向へ進む
if target_y >= Y_MAX:
target_y = Y_MAX
target_x = x + ESCAPE_STEP
# 下辺にいる場合
elif min_dist == dist_bottom:
target_x = x + ESCAPE_STEP
target_y = Y_MAX
# 右端まで到達したら、次は上方向へ進む
if target_x >= X_MAX:
target_x = X_MAX
target_y = y - ESCAPE_STEP
# 右辺にいる場合
else:
target_x = X_MAX
target_y = y - ESCAPE_STEP
# 上端まで到達したら、次は左方向へ進む
if target_y <= Y_MIN:
target_y = Y_MIN
target_x = x - ESCAPE_STEP
# X座標が移動範囲を超えないように制限する
target_x = max(X_MIN, min(X_MAX, target_x))
# Y座標が移動範囲を超えないように制限する
target_y = max(Y_MIN, min(Y_MAX, target_y))
# 計算した次の目標位置を整数で返す
return int(target_x), int(target_y)
# ============================================================
# 【Qiita 6】逃走役を動かす
# 【Qiita 7】一定時間ごとに方向転換する
# ============================================================
async def runner(runner_cube, game_end):
print("\n================================")
print("Runner started")
print("Direction will change randomly")
print("================================")
# 最初は時計回りに設定する
direction = 1
# 最後に方向転換を判定した時刻を保存する
last_direction_check = time.monotonic()
# 【Qiita 13】game_endがセットされるまで逃走を続ける
while not game_end.is_set():
# 【Qiita 4】逃走役の現在位置を取得する
runner_pos = await get_position(runner_cube)
# 位置情報を取得できなかった場合
if runner_pos is None:
# 少し待ってから再び位置を取得する
await asyncio.sleep(UPDATE_INTERVAL)
continue
# 取得した位置情報をX、Y、角度に分ける
runner_x, runner_y, runner_angle = runner_pos
# 現在時刻を取得する
now = time.monotonic()
# 【Qiita 7】設定時間が経過したら方向転換するか判定する
if now - last_direction_check >= DIRECTION_CHANGE_INTERVAL:
# 今回の判定時刻を保存する
last_direction_check = now
# 指定した確率で時計回りと反時計回りを切り替える
if random.random() < DIRECTION_CHANGE_PROBABILITY:
direction = -direction
if direction == 1:
print("\n>>> RUNNER: CLOCKWISE")
else:
print("\n>>> RUNNER: COUNTER CLOCKWISE")
# 【Qiita 6】現在位置から次の外周上の目標位置を計算する
target_x, target_y = get_next_perimeter_target(
runner_x, runner_y, direction
)
# 【Qiita 18】実行時の状態を画面に表示する
print(
f"[RUNNER] direction={direction} "
f"pos=({runner_x}, {runner_y}) "
f"target=({target_x}, {target_y})"
)
# 計算した目標位置へ逃走役を移動させる
await runner_cube.api.motor.motor_control_target(
timeout=2,
movement_type=MovementType.Linear,
speed=Speed(
max=RUNNER_SPEED,
speed_change_type=SpeedChangeType.Constant
),
target=TargetPosition(
cube_location=CubeLocation(
point=Point(x=target_x, y=target_y),
angle=runner_angle
),
rotation_option=RotationOption.AbsoluteOptimal
)
)
# 次の処理まで少し待つ
await asyncio.sleep(UPDATE_INTERVAL)
# ============================================================
# 【Qiita 8】鬼は逃走役の方向を計算する
# 【Qiita 9】鬼の次の目標位置を決める
# 【Qiita 10】鬼は時間とともに加速する
# 【Qiita 11】捕獲判定を行う
# ============================================================
async def chaser(chaser_cube, runner_cube, game_end):
print("\n================================")
print("Chaser started")
print("Fast acceleration mode")
print("================================")
# 鬼が動き始めた時刻を記録する
start_time = time.monotonic()
# 【Qiita 13】ゲーム終了まで追跡を繰り返す
while not game_end.is_set():
# 【Qiita 10】ゲーム開始からの経過時間を計算する
elapsed_time = time.monotonic() - start_time
# 何回加速する時間が経過したかを計算する
speed_level = int(elapsed_time / SPEED_UP_INTERVAL)
# 開始速度に加速回数分の速度を加える
chaser_speed = CHASER_START_SPEED + speed_level * SPEED_UP_AMOUNT
# 最大速度を超えないように制限する
chaser_speed = min(CHASER_MAX_SPEED, chaser_speed)
# 【Qiita 4】逃走役と鬼の現在位置を取得する
runner_pos = await get_position(runner_cube)
chaser_pos = await get_position(chaser_cube)
# どちらかの位置情報を取得できなかった場合
if runner_pos is None or chaser_pos is None:
await asyncio.sleep(UPDATE_INTERVAL)
continue
# 逃走役のX座標とY座標を取り出す
runner_x, runner_y, _ = runner_pos
# 鬼のX座標、Y座標、角度を取り出す
chaser_x, chaser_y, chaser_angle = chaser_pos
# 【Qiita 5】鬼と逃走役の距離を計算する
dist = distance(chaser_x, chaser_y, runner_x, runner_y)
# 【Qiita 11】距離が捕獲距離以下ならゲームを終了する
if dist <= CATCH_DISTANCE:
print("\n********************************")
print(" CATCH!")
print("********************************\n")
# 【Qiita 13】全ての処理にゲーム終了を知らせる
game_end.set()
# 鬼の追跡処理を終了する
break
# 【Qiita 8】鬼から逃走役へのX方向の差を計算する
dx = runner_x - chaser_x
# 鬼から逃走役へのY方向の差を計算する
dy = runner_y - chaser_y
# ベクトルを正規化して方向だけを取り出す
dx, dy = normalize(dx, dy)
# 【Qiita 9】1回に進む距離を決める
step = min(CHASE_STEP, dist)
# 鬼の次の目標X座標を計算する
target_x = int(chaser_x + dx * step)
# 鬼の次の目標Y座標を計算する
target_y = int(chaser_y + dy * step)
# 【Qiita 18】現在の追跡状態を画面に表示する
print(
f"[CHASER] "
f"time={elapsed_time:.1f}s "
f"speed={chaser_speed} "
f"distance={dist:.1f}"
)
# 【Qiita 9】計算した次の目標位置へ鬼を移動させる
await chaser_cube.api.motor.motor_control_target(
timeout=2,
movement_type=MovementType.Linear,
speed=Speed(
max=chaser_speed,
speed_change_type=SpeedChangeType.Constant
),
target=TargetPosition(
cube_location=CubeLocation(
point=Point(x=target_x, y=target_y),
angle=chaser_angle
),
rotation_option=RotationOption.AbsoluteOptimal
)
)
# 次の追跡計算まで少し待つ
await asyncio.sleep(UPDATE_INTERVAL)
# ============================================================
# 【Qiita 14】ゲーム終了時にtoioを停止する
# ============================================================
async def stop_all(cubes):
# 停止処理を開始したことを表示する
print("\nStopping cubes...")
# 接続されている全てのtoioを停止する
for cube in cubes:
try:
# 左右のモーター速度を0にする
await cube.api.motor.motor_control(0, 0)
# 停止時のエラーを表示する
except Exception as e:
print(f"Stop error: {e}")
# ============================================================
# 【Qiita 12】asyncioで2台のtoioを同時に動かす
#
# 【Qiita 13】asyncio.Event()でゲーム終了を共有する
#
# 【Qiita 15】2台のtoioに接続する
#
# 【Qiita 16】LEDで役割を分かりやすくする
#
# 【Qiita 18】実行時の流れ
# ============================================================
async def main():
# 【Qiita 18】プログラム開始時のタイトルを表示する
print("\n================================")
print(" TOIO TAG GAME")
print("================================\n")
print("Connecting 2 cubes...\n")
# 【Qiita 15】2台のtoioに接続する
async with MultipleToioCoreCubes(cubes=2) as cubes:
# 実際に接続できた台数を表示する
print(f"Connected cubes: {len(cubes)}")
# 2台接続できなかった場合は終了する
if len(cubes) < 2:
print("\nERROR: 2台接続できませんでした。")
return
# 1台目を逃走役に設定する
runner_cube = cubes[0]
# 2台目を鬼に設定する
chaser_cube = cubes[1]
# ----------------------------------------------------
# 【Qiita 16】LEDで役割を分かりやすくする
# ----------------------------------------------------
# 逃走役のLEDを赤色にする
await runner_cube.api.indicator.turn_on(
IndicatorParam(
duration_ms=0,
color=Color(r=255, g=0, b=0)
)
)
# 鬼のLEDを青色にする
await chaser_cube.api.indicator.turn_on(
IndicatorParam(
duration_ms=0,
color=Color(r=0, g=0, b=255)
)
)
# 【Qiita 18】ゲーム開始前の役割を表示する
print("\n==============================")
print("RED = RUNNER")
print("BLUE = CHASER")
print("==============================")
# ゲーム開始まで3秒待つ
print("\nGame starts in 3 seconds...")
await asyncio.sleep(3)
# 【Qiita 13】ゲーム終了を共有するEventを作成する
game_end = asyncio.Event()
# 【Qiita 12】逃走役を非同期タスクとして開始する
runner_task = asyncio.create_task(
runner(runner_cube, game_end)
)
# 【Qiita 12】鬼を非同期タスクとして開始する
chaser_task = asyncio.create_task(
chaser(chaser_cube, runner_cube, game_end)
)
try:
# 【Qiita 12】2つのタスクを同時に実行する
await asyncio.gather(runner_task, chaser_task)
# 【Qiita 14】ゲーム終了時やキャンセル時にも必ず停止処理を行う
finally:
# 【Qiita 13】ゲーム終了を全てのタスクに通知する
game_end.set()
# 逃走役のタスクをキャンセルする
runner_task.cancel()
# 鬼のタスクをキャンセルする
chaser_task.cancel()
try:
# 逃走役のタスク終了を待つ
await runner_task
except asyncio.CancelledError:
pass
try:
# 鬼のタスク終了を待つ
await chaser_task
except asyncio.CancelledError:
pass
# 【Qiita 14】最後に2台のtoioを確実に停止する
await stop_all(cubes)
# 【Qiita 18】ゲーム終了を表示する
print("\n================================")
print(" GAME OVER")
print("================================")
# ============================================================
# 【Qiita 19】今回学んだこと
#
# ・2台のtoioへの同時接続
# ・toioの現在位置の取得
# ・座標を使った移動
# ・2点間の距離計算
# ・外周に沿った移動
# ・ランダムな方向転換
# ・時間による速度変更
# ・距離による捕獲判定
# ・asyncioによる同時実行
# ・asyncio.Event()によるゲーム終了通知
# ・finallyによる安全な停止処理
# ============================================================
if __name__ == "__main__":
try:
# asyncioイベントループを作成してmain()を実行する
asyncio.run(main())
# Ctrl+Cで停止された場合
except KeyboardInterrupt:
print("\nProgram stopped.")
【Qiita 1】 今回作る鬼ごっこ
今回のプログラムでは、2台のtoioに次の役割を与えます。
| toio | LED | 役割 |
|---|---|---|
| 1台目 | 赤 | 逃走役:プレイマットの外周を移動し、ときどき方向転換 |
| 2台目 | 青 | 鬼:逃走役の現在位置を取得して追跡し、徐々に加速 |
最終的には、鬼と逃走役の距離が一定以下になったらゲーム終了です。ゲームの流れは次の通りです。
ゲーム開始
├── 赤:外周を時計方向に移動して逃げ回る
│ └── ときどき逆方向に転換
├── 青:赤の位置を取得
│ └── 赤に向かって移動
│ └── 時間とともに加速
│ 2つのtoioが、鬼ごっこで動き回る
├── 距離が一定以下?
│ No Yes
└───┘ │
CATCH!
│
ゲーム終了
図で表現すると、このように、2台のtoioが、それぞれ別の動作を、同時に行います。
┌──────────────────────────┐
│ │ toioマット
│ 🔴 逃走役 ───────→ │
│ ↑ │
│ │ 追跡 │
│ │ │
│ 🔵 鬼 │
└──────────────────────────┘
色によって、以下の関係を一目で分かるようにします。
- 赤色のtoioは外周を移動
- 青色のtoioは赤色のtoioを追跡
【Qiita 2】 プログラム全体の構成
プログラムは、大きく次の部分に分かれています。
tag-game-toio-qiita.py
│
├── プレイマットの設定
├── ゲーム設定
│ ├── 逃走役の速度
│ ├── 方向転換
│ ├── 鬼の速度
│ └── 捕獲距離
├── 共通関数
│ ├── get_position()
│ ├── distance()
│ └── normalize()
├── 逃走役
│ ├── get_next_perimeter_target()
│ └── runner()
├── 鬼
│ └── chaser()
├── 停止処理
│ └── stop_all()
└── メイン処理
└── main()
まず、ゲーム全体で使用する設定を確認します。役割ごとに関数を分けることで、プログラム全体が理解しやすくなります。
main()
┌───────┴───────┐
runner() chaser()
逃走役を移動 鬼を追跡
└───────┬───────┘
game_end
GAME OVER
この図によって、main() が2つの処理を同時に開始していることが分かります。
【Qiita 3】 プレイマットの移動範囲を決める
toioはプレイマット上に印刷されたパターンを、toioキューブの底面にある読み取りセンサー(赤外線カメラ)により座標を利用して位置を認識します。
まず、プレイマットの範囲を設定します。このプログラムでは、簡易プレイマットの座標範囲を設定しています。マットの種類によって、座標範囲は異なります。

水色の「toio」が左下に来るように、1枚を置いて利用します。
MAT_X_MIN = 98
MAT_X_MAX = 402
MAT_Y_MIN = 142
MAT_Y_MAX = 358
しかし、toioをマットの端ぎりぎりまで移動させると、動作が不安定になる場合があります。
そこで、少し内側を実際の移動範囲として設定します。
MARGIN = 35
X_MIN = MAT_X_MIN + MARGIN
X_MAX = MAT_X_MAX - MARGIN
Y_MIN = MAT_Y_MIN + MARGIN
Y_MAX = MAT_Y_MAX - MARGIN
イメージは次のようになります。
プレイマット全体
┌──────────────────────────────┐
│← MARGIN → │
│ ┌────────┐ │
│ │実際に │ │
│ │移動す │ │
│ │る範囲 │ │
│ └────────┘ │
│ │
└──────────────────────────────┘
このように少し余白を作ることで、toioがマットの端に、近づきすぎないようにしています。
【Qiita 4】 toioの現在位置を取得する
鬼ごっこでは、2台のtoioの現在位置を取得する必要があります。そのため、toioの現在位置を取得する次の関数を用います。
async def get_position(cube):
pos = await cube.api.id_information.read()
if isinstance(pos, PositionId):
return pos.center.point.x, pos.center.point.y, pos.center.angle
return None
この関数では、取得した位置情報は、逃走役の移動や、鬼の追跡に利用します。
- X座標
- Y座標
- 角度
例えば、
runner_x, runner_y, runner_angle = runner_pos
とすると、逃走役の現在位置を取得できます。
今回のプログラムでは、この位置情報を使って、以下の処理を行います。
- 逃走役が次に向かう位置を決める
- 鬼が逃走役を追いかける
- 2台の距離を計算する
【Qiita 5】 2点間の距離を計算する
鬼が逃走役に近づいたかどうかを調べるために、2台の距離を計算します。
def distance(x1, y1, x2, y2):
return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
これは三平方の定理を利用しています。
距離 = √((x2 - x1)² + (y2 - y1)²)
例えば、以下のような2点の距離を求めます。
鬼 ●──────────● 逃走役
この距離は、捕獲判定にも使用します。
【Qiita 6】 逃走役はプレイマットの外周を移動する
逃走役は、自由にランダムな位置へ移動するのではありません。現在位置から最も近い辺を判断し、プレイマットの外周に沿って移動します。
鬼から逃げる方法は、色々な作戦があると思いますが、ここでは単純に、鬼からの距離を稼ぐために、外周を時計回りに、移動する作戦にしました。
イメージは次のようになります。
→ → → → →
┌─────────┐
↑ ↓
↑ ↓
↑ ↓
└─────────┘
← ← ← ← ←
次の目標位置を計算する関数を、以下のように定義します。
def get_next_perimeter_target(x, y, direction):
この関数では、現在位置が、
- 上辺
- 下辺
- 左辺
- 右辺
のどこに近いかを調べます。
dist_top = abs(y - Y_MIN)
dist_bottom = abs(y - Y_MAX)
dist_left = abs(x - X_MIN)
dist_right = abs(x - X_MAX)
そして、最も近い辺を求めます。
min_dist = min(
dist_top,
dist_bottom,
dist_left,
dist_right
)
例えば、上辺にいる場合、時計回りなら右方向へ移動します。
┌────────────────→
│
│
│
右端まで到達すると、次は下方向へ移動します。
┌────────────────┐
↓
↓
↓
この処理を繰り返すことで、外周を回る動作を実現しています。
時計回りの場合は、
上辺 → 右辺 → 下辺 → 左辺
の順番で移動します。
反時計回りの場合は、
上辺 → 左辺 → 下辺 → 右辺
の順番になります。
図で表すと、以下のようになります。
┌────────────→ ←────────────┐
↑ │ │ ↑
│ ↓ ↓ │
└────────────┘ └────────────┘
時計回り 反時計回り
外周を移動していることが視覚的に分かります。
各辺で次の目標位置を計算することで、逃走役は外周を周回します。
【Qiita 7】 時計回りと反時計回りを切り替える
逃走役が常に同じ方向へ移動すると、動きが単調になります。
そこで、このプログラムでは一定時間ごとに方向を変更するかどうかを判定しています。
DIRECTION_CHANGE_INTERVAL = 5.0
DIRECTION_CHANGE_PROBABILITY = 0.35
これは、「5秒ごとに35%の確率で方向を変更する」、という意味です。なお、最初は時計回りに設定しています。
移動方向は direction で管理します。
direction = 1
ここでは、
direction = 1 → 時計回り
direction = -1 → 反時計回り
としています。
一定時間ごとに方向転換するかを判定します。ここでは、乱数を使って方向を変更するかを決めます。
if now - last_direction_check >= DIRECTION_CHANGE_INTERVAL:
last_direction_check = now
if random.random() < DIRECTION_CHANGE_PROBABILITY:
direction = -direction
direction = -direction とするだけで、
1 → -1
-1 → 1
と簡単に方向を反転できます。
そのため、逃走役は次のような動きをします。
時計回り
↓
5秒経過
↓
35%の確率で判定
├── 方向を維持
└── 反時計回りへ変更
完全なランダム移動ではなく、「基本的には外周を移動しながら、ときどき逃げる方向を変える」という動作にしています。
そのため、逃走役がいつも同じ方向に回り続けるのを防げます。ただ実際には、この反転の時に速度が遅くなり、鬼に捕まることが多いです。
【Qiita 8】 鬼は逃走役の方向を計算する
鬼は、逃走役の現在位置を取得して、その方向へ移動します。
鬼の位置を、「(cx, cy)」、逃走役の位置を、「(rx, ry)」、とします。
まず、鬼から逃走役への差を計算します。
dx = runner_x - chaser_x
dy = runner_y - chaser_y
これは、
鬼 ●
↘ dx, dy
● 逃走役
のように、鬼から逃走役へ向かう方向を表しています。
ただし、このままでは距離によってベクトルの長さが変わります。そこで、次の関数で、このベクトルを正規化します。
dx, dy = normalize(dx, dy)
normalize() では、ベクトルの長さを1にします。
def normalize(dx, dy):
length = math.sqrt(dx * dx + dy * dy)
if length < 0.001:
return 0.0, 0.0
return dx / length, dy / length
これにより、距離の大きさではなく、「逃走役がどちらの方向にいるか」だけを取り出せます。
図で説明すると、以下のようになります。
鬼の現在位置
●
\
\
\ dx, dy
\
● 逃走役
鬼 → 逃走役の方向を計算 ⇒ ベクトルを正規化 ⇒ CHASE_STEPだけ進む ⇒ 次の位置を決定
座標やベクトルに慣れていない読者にも、鬼が少しずつ近づく仕組みを、下図より確認してください。
【Qiita 9】 鬼の次の目標位置を決める
鬼は逃走役まで一気に移動するのではなく、少しずつ追跡します。
1回に進む距離は、以下で設定しています。
CHASE_STEP = 30
実際に進む距離は、以下で決めます。
step = min(CHASE_STEP, dist)
鬼の次の目標位置は、以下で計算します。
target_x = int(chaser_x + dx * step)
target_y = int(chaser_y + dy * step)
イメージは次のようになります。
現在位置
鬼 ● ─────→ ● 次の目標
● 逃走役
移動したら、再び逃走役の位置を取得します。
つまり、以下の処理を繰り返すことで、逃走役を継続的に追跡します。
逃走役と鬼の現在位置
↓
逃走役の方向を計算
↓
CHASE_STEPだけ進む
↓
新しい位置を取得
↓
再び逃走役を追跡
【Qiita 10】 鬼は時間とともに加速する
ゲーム開始直後から鬼の速度が速すぎると、すぐに捕まってしまいます。そこで、最初は遅くして、時間とともに加速するようにしています。
まずは、toioの速度について確認します。以下のtoioのモーターの、速度指示の設定を調べて見ます。
https://toio.github.io/toio-spec/docs/ble_motor/
モーターに対して回転の速度を指定します。 モーターの速度指示値は0以上255以下の範囲で指定できますが、実際にタイヤの回転速度が変化する有効範囲は8以上115以下です。
速度指示値が8未満の場合、タイヤの回転速度は0 rpmになります。速度指示値が115以上の場合、タイヤの回転速度は494 rpmで一定になります。
速度指示値が8以上115以下の範囲では、速度指示値と実際のタイヤの回転速度は以下のグラフのとおり線形の関係になります。

では、この速度設定値が、実際の移動速度ではどうなるか調べて見ます。
https://toio.github.io/toio-spec/docs/hardware_other/
この情報では、何も載せていない状態での最高速度は「350 mm/秒(秒速35cm)」と定められています。これを設定値の上限である115で割ることで、toioの設定値「1」あたり、実際の速度は約3mm/秒(秒速3mm)に換算できます。
よって、以下の初期速度の20では、「約60mm/秒(秒速6cm)」で、最大速度は、「約300mm/秒(秒速30cm)」になります。
追い掛ける鬼は、最初から高速ではありません。開始時の速度は以下の20で、最大の速度が100と設定しています。
CHASER_START_SPEED = 20
CHASER_MAX_SPEED = 100
その後、以下の設定としているため、2秒ごとに速度が10増加します。
SPEED_UP_INTERVAL = 2
SPEED_UP_AMOUNT = 10
計算は次のようになります。まず、ゲーム開始からの経過時間を求めます。次に、何回加速したかを計算します。そして、現在の速度を求めます。
elapsed_time = time.monotonic() - start_time
speed_level = int(elapsed_time / SPEED_UP_INTERVAL)
chaser_speed = (
CHASER_START_SPEED
+ speed_level * SPEED_UP_AMOUNT
)
ただし、最大速度を超えないようにしています。
chaser_speed = min(CHASER_MAX_SPEED, chaser_speed)
例えば、以下のように加速してゆきます。
開始 20
2秒後 30
4秒後 40
6秒後 50
...
最大速度 100
この仕組みにより、ゲーム開始直後は逃げやすく、時間が経つと速度が速くなり、捕まりやすくなり、徐々に緊張感が高まります。
なかなか捕まらないときは、「SPEED_UP_INTERVALを小さくする」「SPEED_UP_AMOUNTを大きくする」ような、設定としてください。
グラフで示すと、鬼の速度は、以下のように段階的に、速くなります。
速度
80 | ─────
70 | ─────
60 | ─────
50 | ─────
40 | ─────
30 | ─────
20 |─────
+----------------------------→ 時間
0 2 4 6 8 10 秒
【Qiita 11】 捕獲判定を行う
鬼と逃走役の距離を計算します。
dist = distance(
chaser_x,
chaser_y,
runner_x,
runner_y
)
その距離が、
CATCH_DISTANCE = 40
以下になったら捕獲です。
if dist <= CATCH_DISTANCE:
print("CATCH!")
game_end.set()
break
このように、座標から計算した距離を利用することで、実際に接触していなくても「近づいたら捕獲」という判定を作れます。
【Qiita 11】 捕獲判定を行う
まずは、鬼と逃走役の距離を考えるために、toioのマット上の距離を調べて見ます。toioの座標(Position ID)はmm等の実寸単位ではなく独自のスケールです。
厳密には、公式の固定変換係数は存在せず、マットの種類や印刷誤差で変わるため、利用する範囲の最大距離と座標を実測してその都度「実測距離[mm] ÷ 座標の差」で変換係数を算出する必要があります。
「簡易プレイマット」の場合には、仕様値として、以下の設定です。
マットの座標範囲: [X軸] 98 ~ 412 / [Y軸] 142 ~ 364
座標の幅(カウント数): 横幅 314 カウント / 縦幅 222 カウント
実際の寸法: 横幅 420 mm / 縦幅 297 mm(A3サイズジャスト)
1カウントあたりの実寸(変換係数)簡易マットの印刷比率から計算すると、「1カウント≒約 1.3375 mm」になります。以下の捕獲判定のCATCH_DISTANCEの数値40は約5.3cmですので、
鬼と逃走役の距離を、以下の関数で計算し、捕獲判定をします。
dist = distance(
chaser_x,
chaser_y,
runner_x,
runner_y
)
その距離が、「CATCH_DISTANCE = 40」以下になったら捕獲で、この捕獲判定のCATCH_DISTANCEの数値40は約53mmとなります。
toioキューブの大きさが32mmで、中心間距離で判定すると、おおよそキューブの間が20mm近づいた時に捕獲としています。
if dist <= CATCH_DISTANCE:
print("CATCH!")
game_end.set()
break
このように、座標から計算した距離を利用することで、実際に接触していなくても「近づいたら捕獲」という判定を作れます。イメージは次のようになります。
鬼と逃走役の距離が大きい
鬼 ●──────────────● 逃走役
↓
ゲーム続行
鬼と逃走役の距離が小さい
鬼 ●──● 逃走役
↓
距離 ≤ CATCH_DISTANCE
↓
CATCH!:ゲーム終了!
この「鬼と逃走役の距離」は、20mmぐらい近づくと捕獲となり、ゲームが終了します。お互いが一瞬すれ違って離れても、捕獲判定されます。
この設定でも、捕獲判定してから停止となっても、処理が進む時間があって、toioキューブがすぐには止まれないのか、衝突することがあります。
【Qiita 12】 asyncioで2台のtoioを同時に動かす
今回のプログラムでは、「逃走役・鬼」を、同時に動かす必要があります。つまり、以下の2つの処理を同時に行う必要があります。
- 赤いtoioは逃げる
- 青いtoioは追いかける
もし、普通に順番に処理すると、以下のようになってしまいます。今の鬼の方が遅い状態では、いつまでも追いつかず、これでは鬼ごっこになりません。
逃げる処理
↓
終わるまで待つ
↓
追いかける処理
そこでPythonの非同期処理のasyncio.create_task() を使用します。
詳しく説明すると、toioを動かす際、1台の制御なら、命令(進む、止まるなど)を順番に出すだけでシンプルに動かせます。
しかし、2台を同時に動かす場合、通常の処理では、1台目の移動が終わるまで2台目が動けない、という「待ち時間」のズレが生まれてしまいます。
ここで活躍するのが「asyncio」です。asyncioを使えば、2台のキューブへ「同時に動け」という命令を、待ち時間なしでほぼ一斉に送り出せます。
その結果、2台がぶつからないようにシンクロして走らせたり、鬼ごっこしたりダンスをさせたりする「複雑な並行処理」がスムーズに実現できるようになります。
まず、逃走役の処理をタスクを、「asyncio.create_task」を用いて開始します。
runner_task = asyncio.create_task(
runner(runner_cube, game_end)
)
次に、鬼の処理を、同様に開始します。
chaser_task = asyncio.create_task(
chaser(chaser_cube, runner_cube, game_end)
)
最後に、以下の手順で2つの処理を同時に実行します。
await asyncio.gather(
runner_task,
chaser_task
)
イメージとしては、以下になります。
main()
├── runner_task:赤toioの逃走役を動かす
└── chaser_task:青toioの鬼を動かす
そして、2つの処理をまとめて実行します。
await asyncio.gather(
runner_task,
chaser_task
)
このように、1つのPythonプログラムの中で、2台のtoioをそれぞれ独立した処理として動かしています。
時間 →
逃走役 ──●────●────●────●──
移動 移動 移動
鬼 ────●────●────●────●
追跡 追跡 追跡
実際には完全に同時に1行ずつ実行しているのではなく、非同期処理によって複数の処理を効率よく進めているイメージになります。
【Qiita 13】 asyncio.Event()でゲーム終了を共有する
「逃走役・鬼」この2つの処理は同時に動いています。そのため、「鬼が逃走役を捕まえた」という情報を、逃走役の処理にも伝える必要があります。
つまり、鬼が逃走役を捕まえたとき、両方の処理を終了する必要があります。
そのために、以下の関数「asyncio.Event()」を利用します。
game_end = asyncio.Event()
逃走役と鬼は、それぞれ、以下のループで処理を繰り返します。
while not game_end.is_set():
このループは、「game_end がセットされていない」つまり、「ゲームを続ける」という意味です。
ついに、鬼が逃走役を捕まえると、以下を実行します。
game_end.set()
すると、以下の手順になります。複数の非同期処理を、まとめて終了する場合に便利です。
鬼が、逃走役を捕まえる:CATCH!
│
game_end.set()
├───────────┐
逃走役 鬼
runner() chaser()
終了する 終了する
鬼が逃走役に近づいてから、捕獲して、ゲームが終了するまでの流れは、以下に示します。
鬼が逃走役に近づく
↓
distanceを計算
↓
CATCH_DISTANCE以下?
│ │
いいえ はい
│ │
追跡継続 game_end.set()
│
┌──────┴──────┐
runner終了 chaser終了
└──────┬──────┘
stop_all()
│
GAME OVER
【Qiita 14】 ゲーム終了時にtoioを停止する
実機のロボットを制御する場合、プログラム終了時の停止処理は重要です。ゲーム終了時には、toioを停止します。
この処理では、すべてのtoioの左右モーターを0にします。
async def stop_all(cubes):
for cube in cubes:
try:
await cube.api.motor.motor_control(0, 0)
except Exception as e:
print(f"Stop error: {e}")
ゲーム終了後の処理は finally の中で実行しています。
finally:
game_end.set()
runner_task.cancel()
chaser_task.cancel()
await stop_all(cubes)
これにより、処理が終了するときにtoioを停止する流れをまとめています。finallyの中に停止処理を書くことで、ゲームが終了した場合でも、処理が途中でキャンセルされた場合でも、最後にtoioを停止するようにしています。
【Qiita 15】 2台のtoioに接続する
最後に、メイン処理を見てみます。「逃走役・鬼」となる2台のtoioに接続します。
async with MultipleToioCoreCubes(cubes=2) as cubes:
上記の関する「MultipleToioCoreCubes」で、接続できた台数を確認します。
print(f"Connected cubes: {len(cubes)}")
if len(cubes) < 2:
print("ERROR: 2台接続できませんでした。")
return
2台接続できたら、
runner_cube = cubes[0] → 逃走役
chaser_cube = cubes[1] → 鬼
として役割を決めます。
【Qiita 16】 LEDで役割を分かりやすくする
「逃走役・鬼」の2台の役割を確認しやすくするため、LEDの色を変更します。
逃走役は赤色🔴です。
Color(r=255, g=0, b=0)
鬼は青色🔵です。
Color(r=0, g=0, b=255)
プログラムでは、以下のように設定しています。
# 逃走役のLEDを赤色にする
await runner_cube.api.indicator.turn_on(
IndicatorParam(
duration_ms=0,
color=Color(r=255, g=0, b=0)
)
)
# 鬼のLEDを青色にする
await chaser_cube.api.indicator.turn_on(
IndicatorParam(
duration_ms=0,
color=Color(r=0, g=0, b=255)
)
)
そのため、実際に動かすと、
🔴 RUNNER:逃走役 対 🔵 CHASER:鬼
という関係になります。ゲームを開始する前に、どちらが鬼なのか確認できます。
【Qiita 17】 プログラムを変更して遊んでみる
このプログラムは、先頭にある以下の設定値を変更することで、鬼ごっこの難しさを変えられます。
この設定を見てみわかるように、鬼は徐々に加速「SPEED_UP_AMOUNT」しますが、逃走役は設定した速度「RUNNER_SPEED」で、逃げ続けます。
# ============================================================
# 【Qiita 17】プログラムを変更して遊んでみる
# 以下の値を変更することで、鬼ごっこの動きを変えられます。
# ============================================================
RUNNER_SPEED = 75 # 逃走役の移動速度
ESCAPE_STEP = 55 # 逃走役が1回に進む距離
DIRECTION_CHANGE_INTERVAL = 5.0 # 方向転換を判定する時間間隔[秒]
DIRECTION_CHANGE_PROBABILITY = 0.35 # 方向転換する確率
CHASER_START_SPEED = 20 # 鬼の開始時の速度
CHASER_MAX_SPEED = 100 # 鬼の最大速度
SPEED_UP_INTERVAL = 2 # 鬼が加速する時間間隔[秒]
SPEED_UP_AMOUNT = 10 # 1回の加速で増える速度
CHASE_STEP = 30 # 鬼が1回に進む距離
CATCH_DISTANCE = 40 # この距離以下で捕獲
UPDATE_INTERVAL = 0.4 # 各処理を繰り返す間隔[秒]
鬼を最初から速くする:大きいほど速度が大きい
CHASER_START_SPEED = 30
鬼の加速を大きくする:大きいほど加速が大きい
SPEED_UP_AMOUNT = 20
逃走役をもっと頻繁に方向転換させる:小さいほど頻繁に方向転換を判断する
DIRECTION_CHANGE_INTERVAL = 2.0
方向転換する確率を高くする:大きいほど方向転換の確率が上がる
DIRECTION_CHANGE_PROBABILITY = 0.6
捕獲を難しくする:小さいほど近づかないと捕獲できない
CATCH_DISTANCE = 25
これらの値を変更すると、プログラム本体を変更しなくてもゲームの動きを調整できます。
【Qiita 18】 実行時の流れ
プログラムを実行「python .\tag-game-toio-qiita.py」すると、まず2台のtoioに接続します。
PS C:\SonyToio\PythonWork> python .\tag-game-toio-qiita.py
================================
TOIO TAG GAME
================================
Connecting 2 cubes...
Connected cubes: 2
続いて、役割を表示します。3秒後に開始します。
==============================
RED = RUNNER (🔴逃走役)
BLUE = CHASER (🔵鬼)
==============================
Game starts in 3 seconds...
ゲームが開始されると、それぞれの処理が動き始めます。
================================
Runner started
Direction will change randomly
================================
================================
Chaser started
Fast acceleration mode
================================
逃走役と鬼の状態は、以下のように表示されます。
[RUNNER] direction=1 pos=(379, 257) target=(367, 312)
direction=1:時計回り(-1で反時計回り)
pos:逃走役の位置
target:逃走役の移動先
[CHASER] time=0.0s speed=20 distance=259.1
time:鬼が追いかけた時間
speed:鬼の追いかける速度
distance:追跡役と鬼との距離
そして、鬼と逃走役の距離が設定値以下になると、以下のように表示され、ゲーム終了となります。
********************************
CATCH!
********************************
Stopping cubes...
================================
GAME OVER
================================
【Qiita 19】 今回学んだこと
今回の鬼ごっこプログラムでは、次のような内容を組み合わせています。
・2台のtoioへの同時接続
・toioの現在位置の取得
・座標を使った移動
・2点間の距離計算
・外周に沿った移動
・ランダムな方向転換
・時間による速度変更
・距離による捕獲判定
・asyncioによる同時実行
・asyncio.Event()によるゲーム終了通知
・finallyによる安全な停止処理
鬼ごっこを実現する重要なポイントは、以下の3つです。
赤toio:自分のルールに従って逃げる
青toio:赤toioの位置を取得して追いかける
Python:2つの処理をasyncioで同時に実行する
さらに補足すると、以下も大切なポイントです。
・プレイマットの範囲設定
・2点間の方向ベクトルの計算
・LED色表示による役割表示
・パラメータによる動作変更
・複数の処理を組み合わせたロボット制御
toioを1台だけ動かす場合と比べると、複数台を使うことで、ロボット同士が相手の状態を利用して行動するプログラムを作ることができます。
まずは、速度や方向転換の確率を変更して、どのようにゲームの動きが変化するか試してみるのがおすすめです。
おわりに
このプログラムでは、2台のtoioを単純に動かすだけでなく、
- 現在位置を取得する
- 座標から次の移動位置を計算する
- 距離を計算する
- 相手を追跡する
- 時間によって速度を変える
- 非同期処理で複数台を同時に動かす
という処理を組み合わせています。
プログラム中の 【Qiita 1】 ~ 【Qiita 19】 とこの記事の節番号は対応しているため、記事を読みながら該当するコードを確認できます。
次のステップとして、逃走役の動きをさらに複雑にしたり、複数の鬼を追加したり、得点や制限時間を加えたりすることもできます。
初心者向け補足:このプログラムを理解するための基本
ここからは、以上で説明した内容を理解するために、Pythonやtoioのプログラムに慣れていない方のために、基本的な内容を追加します。
補足1:async と await とは
今回のプログラムには、2つのtoioに、逃走役と鬼の2つの役割を、同時に実行するために、以下のコードを用いています。
async def runner(runner_cube, game_end):
await asyncio.sleep(UPDATE_INTERVAL)
async は、「この関数は、途中で他の処理に切り替えながら実行できる」という意味です。
await は、「この処理が終わるまで待ち、その間は他の処理を進めてもよい」という意味です。
今回の場合、以下の2つの処理を交互に進めるために使っています。
逃走役を動かす
↕ 同時進行
鬼を動かす
補足2:while による繰り返し
逃走役の処理には、以下のループがあります。
while not game_end.is_set():
これは、「ゲームが終了していない間(not game_end.is_set)、繰り返す」という意味です。
流れは次のようになります。
ゲーム終了?
├─ いいえ────────────────────はい
│ ↓ 現在位置を取得 ↓ 終了
│ ↓ 次の位置を計算
│ ↓ 移動
└──── 繰り返す
補足3:関数に分ける理由
例えば、距離を計算する処理を、以下の関数にしています。
def distance(x1, y1, x2, y2):
関数にしておくと、以下のように、簡単に利用できます。
dist = distance(
chaser_x,
chaser_y,
runner_x,
runner_y
)
複雑な計算を何度も書かなくてよいことが、関数を使う大きな利点です。
補足4:変数の役割
例えば、以下では、RUNNER_SPEED という名前の変数に 75 を保存しています。
RUNNER_SPEED = 75
今回のプログラムでは、設定値を先頭にまとめています。
RUNNER_SPEED = 75
CHASER_START_SPEED = 20
CHASER_MAX_SPEED = 100
CATCH_DISTANCE = 40
このようにすると、ゲームの動きを変更するときに、プログラム全体を探し回る必要がありません。
補足5:if による条件分岐
プログラムでは、以下のような処理があります。
if dist <= CATCH_DISTANCE:
これは、「距離が捕獲距離以下なら」という意味です。条件が成立した場合だけ、その中の処理が実行されます。
if dist <= CATCH_DISTANCE:
print("CATCH!")
game_end.set()
今回の鬼ごっこでは、この条件がゲーム終了の判定になります。



