ハコブくんと看護師の「距離感」をUnityで実装する|病院廊下のHRI設計
自動搬送ロボット「ハコブくん」が院内を縦横無尽に駆け巡る中、いくつか課題が浮上しました。それは、忙しく立ち働く看護師スタッフとの「衝突」と、目的地であるベッドへの「乗り上げ」です。
これまでは、スタッフはハコブくんを認識せず直進し、ハコブくんは目標地点であるベッドの「中心」を目指しすぎるあまり、物理的な接触を引き起こしていました。この振る舞いを解消し、互いを尊重する知的な自律移動へとアップデートします。
※薬剤搬送ロボットハコブくんは技術検証用の仮想設定です。 実用化目的ではなく、制御・UIデザイン・HRI(人間とロボットの相互作用)の本質を探求するコンセプトとして定義しています。
看護師への「回避知能」の実装:歩み寄るアルゴリズム
スタッフがハコブくんを押し退けて進んでしまう問題を解決するため、スタッフ側の制御スクリプト StaffPatrol に、周囲を察知する「目」と、速度を落とす「配慮」を組み込みます。
実装のポイント
回避精度の向上:
StartメソッドでobstacleAvoidanceTypeをHighQualityObstacleAvoidanceに設定。動的な前方検知:
Physics.OverlapSphereを使い、進行方向 0.6m 先に半径 1.0m の検知エリアを展開。減速ロジック: HakobuKunを検知した場合、歩行速度を 0.4倍 まで落とし、道を譲ります。
設計の意図: これまではロボット側だけが避ける「一方通行の回避」でしたが、スタッフ側にも減速ロジックを持たせることで「双方向の譲り合い」を生み出します。スタッフが足を緩めることで、NavMeshAgentが計算する回避スペースに余裕が生まれ、物理的な押し合いを根本から防ぎます。
C#
using UnityEngine;
using UnityEngine.AI;
public class HakobuKunSpawner : MonoBehaviour
{
[Header("Robot Settings")]
[SerializeField] private int robotCount = 4;
[SerializeField] private Color robotColor = Color.white;
void Start()
{
for (int i = 0; i < robotCount; i++)
{
SpawnRobot(i);
}
}
void SpawnRobot(int id)
{
GameObject robot = GameObject.CreatePrimitive(PrimitiveType.Cube);
robot.name = "HakobuKun_" + id;
// ★スリム化:横幅を0.5mに、高さは1.2mのまま
robot.transform.localScale = new Vector3(0.5f, 1.2f, 0.5f);
Renderer rend = robot.GetComponent<Renderer>();
rend.material = new Material(Shader.Find("Standard"));
rend.material.color = robotColor;
// 初期位置(少しずつずらして配置)
Vector3 spawnPos = new Vector3(id * 2.0f - 3f, 0.6f, 0f);
robot.transform.position = spawnPos;
NavMeshAgent agent = robot.AddComponent<NavMeshAgent>();
agent.speed = 1.8f;
agent.acceleration = 3.0f;
// ★重要:NavMesh上の当たり判定もスリム(0.5m)に設定
agent.radius = 0.5f;
agent.avoidancePriority = 10 + id;
robot.AddComponent<HakobuKunBrain>();
}
}ハコブくんの「スリム化」と優先順位の定義
多数のロボットを動的に配置する HakobuKunSpawner.cs では、単なる生成だけでなく、院内の狭い通路を考慮した形状の最適化を行います。
物理サイズ: Cubeの
localScaleを(0.5, 1.2, 0.5)に設定し、スリムな体躯に。占有半径の同期:
NavMeshAgent.radiusを0.5に設定し、計算上の当たり判定を視覚情報と一致させます。優先度の分散:
avoidancePriorityに個体IDを加算。ロボット同士が正面衝突した際、「どちらが譲るか」を瞬時に決定させます。
そろりそろりと避ける低速回避ロジック
これまでは障害物を検知すると「完全停止」していましたが、これでは渋滞の原因になります。新しい HakobuKunBrain.cs では、より人間らしい「徐行による回避」と「適切な停止距離」を導入します。
進化した回避行動
前方 45度 の視界:
Vector3.Dotを用いて、正面にスタッフがいるか判定。謙虚な振る舞い: スタッフ検知時、速度を 20% まで落とし、回避優先度(Priority)を 99(最低) に設定。
パーソナルスペースの確保:
stoppingDistanceを 1.8m に設定。ベッドの中心に突っ込まず、手前で礼儀正しく停止します。
C#
using UnityEngine;
using UnityEngine.AI;
using System.Collections;
using System.Collections.Generic;
public class HakobuKunBrain : MonoBehaviour
{
[Header("Delivery Settings")]
[SerializeField] private List<Transform> deliveryPoints = new List<Transform>();
[SerializeField] private float waitAtPointTime = 3.0f;
[Header("Safety & Obstacle Settings")]
[SerializeField] private float detectionRange = 1.5f;
// ★ここを調整:ベッドにぶつからないよう停止距離を広げます
[SerializeField] private float stopDistanceToBed = 1.8f;
private float defaultSpeed;
private NavMeshAgent agent;
private int currentPointIndex = 0;
private bool isWaiting = false;
private float stackTimer = 0f;
private bool isBackingUp = false;
void Start()
{
agent = GetComponent<NavMeshAgent>();
defaultSpeed = agent.speed;
// ★NavMeshAgent自体の停止距離も設定
agent.stoppingDistance = stopDistanceToBed;
agent.obstacleAvoidanceType = ObstacleAvoidanceType.HighQualityObstacleAvoidance;
float randomDelay = 1.5f + Random.Range(1.0f, 5.0f);
Invoke("DelayedStart", randomDelay);
}
void DelayedStart()
{
if (deliveryPoints.Count == 0) AutoAssignPoints();
SetNextDestination();
}
void Update()
{
if (isWaiting || isBackingUp || deliveryPoints.Count == 0) return;
HandleObstacles();
CheckStacking();
// ★到着判定のロジック
if (!agent.pathPending && agent.remainingDistance <= agent.stoppingDistance)
{
StartCoroutine(ArriveAtPoint());
}
}
// --- スタック・障害物回避ロジック(前回と同じ) ---
void CheckStacking()
{
if (!agent.isStopped && agent.velocity.sqrMagnitude < 0.1f)
{
stackTimer += Time.deltaTime;
if (stackTimer > 3.0f) StartCoroutine(BackupRoutine());
}
else { stackTimer = 0f; }
}
IEnumerator BackupRoutine()
{
isBackingUp = true;
stackTimer = 0f;
agent.ResetPath();
Debug.Log($"<color=orange>[{gameObject.name}]</color> 回避行動:バックします。");
float backupTime = 1.5f;
float randomTurn = Random.Range(-45f, 45f);
while (backupTime > 0)
{
transform.Translate(-Vector3.forward * Time.deltaTime * 0.8f);
transform.Rotate(Vector3.up * randomTurn * Time.deltaTime);
backupTime -= Time.deltaTime;
yield return null;
}
yield return new WaitForSeconds(0.5f);
isBackingUp = false;
SetNextDestination();
}
void HandleObstacles()
{
// 検知距離を少し短く(1.2m)し、横幅も絞ることで過剰な反応を抑える
Vector3 detectionCenter = transform.position + transform.forward * 0.8f;
Collider[] hits = Physics.OverlapSphere(detectionCenter, 1.2f);
bool nurseCloseInFront = false;
foreach (var hit in hits)
{
if (hit.gameObject == gameObject) continue;
if (hit.GetComponent<StaffPatrol>() != null)
{
// 真ん前にいるか判定
Vector3 directionToNurse = (hit.transform.position - transform.position).normalized;
float dot = Vector3.Dot(transform.forward, directionToNurse);
// 正面(角度が45度以内)にいて、かつ距離が近い場合のみ
if (dot > 0.7f)
{
nurseCloseInFront = true;
break;
}
}
}
if (nurseCloseInFront)
{
// ★改善:完全停止(isStopped)はさせず、極低速にしてNavMeshの自動回避に任せる
agent.isStopped = false;
agent.speed = defaultSpeed * 0.2f; // 20%の速度で「そろりそろり」と回避先を探す
// 障害物回避の優先度を一時的に下げる(相手に道を譲りやすくする)
agent.avoidancePriority = 99;
}
else if (!isBackingUp && !isWaiting)
{
agent.isStopped = false;
agent.speed = defaultSpeed;
agent.avoidancePriority = 80; // 通常の優先度に戻す
}
}
void AutoAssignPoints()
{
List<Transform> targetBeds = new List<Transform>();
foreach (var obj in GameObject.FindObjectsOfType<GameObject>())
{
if (obj.name.StartsWith("Bed_")) targetBeds.Add(obj.transform);
}
if (targetBeds.Count == 0) return;
for (int i = 0; i < 5; i++)
{
if (targetBeds.Count == 0) break;
int randomIndex = Random.Range(0, targetBeds.Count);
deliveryPoints.Add(targetBeds[randomIndex]);
targetBeds.RemoveAt(randomIndex);
}
}
IEnumerator ArriveAtPoint()
{
isWaiting = true;
Debug.Log($"<color=yellow>[{gameObject.name}]</color> {deliveryPoints[currentPointIndex].name} の手前に到着。荷下ろし中...");
yield return new WaitForSeconds(waitAtPointTime);
currentPointIndex = (currentPointIndex + 1) % deliveryPoints.Count;
isWaiting = false;
SetNextDestination();
}
void SetNextDestination()
{
if (deliveryPoints.Count > 0)
{
agent.SetDestination(deliveryPoints[currentPointIndex].position);
Debug.Log($"<color=cyan>[{gameObject.name}]</color> 次の目的地:{deliveryPoints[currentPointIndex].name}");
}
}
}