XR: движение стиком и шаговый поворот
Рабочая основа локомоции: коллайдер, origin, физический шаг, ввод и диагностика.
Этот пример добавляет движение левым стиком, шаговый поворот правым и коллизии игрока. Нужны рабочая XR-сессия и карта действий. Физическое перемещение головы обрабатывается отдельно от игрового: камера остаётся под управлением tracking.
Подготовьте проект
- Выполните настройку XR и добавьте XrStartup из обработки запуска.
- Скопируйте RoomScaleBody.cs из room scale. Его код здесь используется без изменений.
- Используйте вертикальный риг без масштаба и наклона тела: мировой +Y направлен вверх, XRServer.WorldScale=1. В этих условиях одна единица соответствует метру.
- Создайте структуру ниже. У Player должна быть CapsuleShape3D: Radius=0.2, Height=1.8, позиция CollisionShape3D=(0, 0.9, 0). Начальная позиция Player — на уровне пола.
Level
├── XrStartup (XrStartup.cs)
├── Player (CharacterBody3D, XrPlayer.cs)
│ ├── CollisionShape3D (CapsuleShape3D)
│ └── XROrigin3D
│ ├── XRCamera3D (head)
│ ├── XRController3D (left_hand, grip)
│ └── XRController3D (right_hand, grip)
└── Floor (StaticBody3D)
└── CollisionShape3D (BoxShape3D)У Floor задайте BoxShape3D.Size=(20, 1, 20), позицию тела (0, -0.5, 0). Верх коллайдера окажется на Y=0. Видимый пол создаётся отдельно: MeshInstance3D не заменяет физический коллайдер. У игрока и препятствий должны пересекаться collision layer/mask. Не добавляйте второй скрипт, двигающий Player или origin.
Настройте действия
В рабочей карте действий сохраните позы контроллеров и добавьте два действия Vector2. Одного select из минимального примера недостаточно. Конкретный пример профиля: /interaction_profiles/oculus/touch_controller.
| Действие | Тип | ToplevelPaths | BindingPath |
|---|---|---|---|
| grip | Pose | /user/hand/left, /user/hand/right | Отдельные привязки /user/hand/left/input/grip/pose и /user/hand/right/input/grip/pose |
| move_player | Vector2 | /user/hand/left | /user/hand/left/input/thumbstick |
| turn_player | Vector2 | /user/hand/right | /user/hand/right/input/thumbstick |
Это пример для профиля со стиками, а не обещание поддержки любого устройства. Для другого профиля используйте допустимые ему пути. Minimal Simple Controller не предоставляет стики. Сохраните карту до инициализации сеанса; изменение ресурса во время работы не перестраивает runtime-действия. В этом примере положительный Y движения означает вперёд, положительный X поворота — вправо.
У камеры оставьте Tracker=head. У контроллеров сначала задайте Tracker=left_hand/right_hand, затем Pose=grip. Не меняйте Pose динамически: в текущем API есть ограничение кэширования tracking.
Движение и поворот
Сохраните как XrWalk.cs. Это объект состояния: создаётся один экземпляр на игрока, а не каждый кадр. Step вызывается ровно один раз за физический тик.
using System;
using Reckfell;
public sealed class XrWalk
{
private bool turnLatched;
// Call once per physics tick. Upright, unscaled rig; one instance per player.
// true means physical head/body alignment is blocked, not successful tracking.
public bool Step(CharacterBody3D body, XROrigin3D origin, Entity3D head,
Vector2 move, float turn, bool allowInput, double delta)
{
if (!move.IsFinite() || !float.IsFinite(turn)
|| !double.IsFinite(delta) || delta <= 0 || delta > 1)
throw new ArgumentOutOfRangeException(nameof(delta), "Invalid input or physics delta.");
if (!allowInput)
{
body.Velocity = Vector3.Zero;
turnLatched = true; // Require neutral before the next snap after focus loss.
return false;
}
if (RoomScaleBody.FollowHead(body, origin, head))
{
body.Velocity = Vector3.Zero;
turnLatched = true;
return true;
}
if (Mathf.Abs(turn) <= 0.25f) turnLatched = false;
if (!turnLatched && Mathf.Abs(turn) >= 0.75f)
{
RoomScaleBody.TurnAroundHead(body, origin, head,
-Mathf.Sign(turn) * Mathf.Pi / 6);
turnLatched = true;
}
if (move.Length() <= 0.2f) move = Vector2.Zero;
if (move.LengthSquared() > 1) move = move.Normalized();
Vector3 forward = -head.GlobalBasis.Z;
forward.Y = 0;
if (forward.LengthSquared() < 0.000001f) forward = -body.GlobalBasis.Z;
forward = forward.Normalized();
Vector3 horizontal = (forward.Cross(Vector3.Up) * move.X + forward * move.Y) * 2;
Vector3 velocity = body.Velocity;
velocity.X = horizontal.X;
velocity.Z = horizontal.Z;
if (body.IsOnFloor() && velocity.Y < 0) velocity.Y = 0;
else velocity.Y -= 9.8f * (float)delta;
body.Velocity = velocity; // Units/second: MoveAndSlide applies its own frame delta.
body.MoveAndSlide(); // Carries the origin with the body; do not compensate it here.
return false;
}
}Скорость — 2 единицы/с; гравитация — 9.8 единицы/с². Velocity не умножается на delta: MoveAndSlide применяет физический шаг сам. Мёртвая зона движения — 0.2 без дополнительного растягивания диапазона. Диагональ ограничена общей скоростью. Направление берётся из горизонтальной проекции взгляда; при взгляде строго вверх/вниз используется направление тела.
Поворот — 30° при отклонении стика от 0.75. Для повторного шага верните стик до 0.25; удержание не повторяет поворот. После потери допуска к вводу также требуется нейтраль. Это параметры примера, не универсальная настройка комфорта.
Подключите к игроку
Сохраните как XrPlayer.cs и прикрепите к Player. Назначьте все пять ссылок: Origin, Head, MoveController=левый контроллер, TurnController=правый, Startup. При неполной структуре скрипт выводит понятную ошибку и отключает обработку.
using Reckfell;
public partial class XrPlayer : CharacterBody3D
{
[Export] public XROrigin3D Origin { get; set; } = null!;
[Export] public XRCamera3D Head { get; set; } = null!;
[Export] public XRController3D MoveController { get; set; } = null!;
[Export] public XRController3D TurnController { get; set; } = null!;
[Export] public XrStartup Startup { get; set; } = null!;
public bool PhysicalGap { get; private set; }
private readonly XrWalk walk = new();
public override void _Ready()
{
if (Origin is null || Head is null || MoveController is null
|| TurnController is null || Startup is null || !IsAncestorOf(Origin)
|| Head.GetParent() != Origin || MoveController.GetParent() != Origin
|| TurnController.GetParent() != Origin)
{
ReckfellApi.PushError("XrPlayer: assign the startup and complete body/origin rig.");
SetPhysicsProcess(false);
}
}
public override void _PhysicsProcess(double delta)
{
var tracker = XRServer.GetTracker(Head.Tracker) as XRPositionalTracker;
var pose = tracker?.GetPose("default"); // Borrowed state: do not Dispose.
var movePose = MoveController.GetPose();
var turnPose = TurnController.GetPose();
bool active = Startup.HasFocus && pose is not null && pose.HasTrackingData
&& movePose is not null && movePose.HasTrackingData
&& turnPose is not null && turnPose.HasTrackingData;
PhysicalGap = walk.Step(this, Origin, Head,
active ? MoveController.GetVector2("move_player") : Vector2.Zero,
active ? TurnController.GetVector2("turn_player").X : 0, active, delta);
}
}Допуск требует фокуса и актуальных pose головы и обоих контроллеров. Скрипт читает HasTrackingData самой pose; общие объекты не освобождает. Без допуска пример обнуляет скорость и останавливает всю свою локомоцию, включая гравитацию. Он не останавливает tracking камеры и не меняет паузу всего проекта. Для движущихся платформ и других правил паузы нужна отдельная политика.
RoomScaleBody сначала подводит коллайдер под физическую голову, сохраняя мировой origin. При игровом MoveAndSlide origin, наоборот, движется вместе с телом. Не компенсируйте его второй раз. PhysicalGap=true означает, что голова и тело остались разнесены из-за препятствия; виртуальное движение блокируется. Физическую голову это не останавливает: затемнение и запрет взаимодействий при проникновении — отдельная игровая логика, которой в примере нет.
Проверьте результат
| Ситуация | Ожидаемый результат / диагностика |
|---|---|
| Левый стик вперёд | Движение по полу в направлении взгляда; по диагонали скорость не растёт |
| Правый стик вправо и удержание | Один поворот на 30°; следующий только после нейтрали |
| Виртуальное движение в стену | CapsuleShape3D останавливает тело; камера движется вместе с телом |
| Физический шаг за стену | Возможен PhysicalGap; tracking головы продолжается, это не физическая защита |
| Нет движения | Проверьте Startup.FailureReason/HasFocus, pose обеих рук, имена действий и профиль; нулевой GetVector2 сам по себе не подтверждает подключение |
| Проваливание | Проверьте Shape, позицию пола, layer/mask и вертикальность рига |
| Двойное смещение / поворот | Уберите второго владельца движения; не умножайте Velocity на delta и не двигайте Head вручную |
На установленном движке выполнены 16 проверок XrWalk с настоящими коллайдерами пола/стены, искусственной головой и вводом. XrPlayer и XrStartup скомпилированы вместе; полный callback игрока с реальной XR-сессией не выполнялся. Привязки устройства, восстановление сеанса, комфорт, склоны и движущиеся платформы не подтверждены. Телепортации, прыжка и вращательного sweep произвольной формы здесь нет; риг рассчитан на вертикальную симметричную капсулу.