Files
VR-Mess/scripts/WorldPanel.cs
T
FoxyzandClaude Opus 5.5 ca9a299fe8 The crag: a 26 m box-rock cliff with a graded climbing route
North of the playground, facing spawn: bouldering blocks, a ladder to an 8 m rest ledge,
a jug wall, a 0.4 m traverse ledge, an overhang with monkey bars and a lip bar onto a
16 m rest ledge, a chimney to the 26 m summit with throwable props. A 'Crag' teleport
marker at the base is the way down; teleports now land the floor on the marker's height.
Floor widened to 80 m. Regression: scenes/crag_spike.tscn (reach, free space, overlaps,
height, line of sight).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 17:24:28 +03:00

277 lines
10 KiB
C#

using Godot;
// The in-VR menu: a MenuScreen (Godot Control UI in a SubViewport on a thin 0.36x0.24 m
// panel) showing MainMenu. This node is the XR glue: summon/dismiss with ax_button (A/X),
// grip-drag (direct or along the laser), re-orient toward the head, feed both controllers'
// pointer rays into the screen, click sound + haptics. PARENTED UNDER XROrigin3D (fixed in the
// player's room; game locomotion carries it).
public partial class WorldPanel : Node3D
{
private const float GripThreshold = 0.6f;
private const float TriggerThreshold = 0.6f;
private const float DirectGrabRadius = 0.45f;
private const float SummonDistance = 0.3f;
private const float HeadRecallDistance = 0.45f;
private const float HeadRecallDrop = 0.12f;
private static WorldPanel _instance;
private XRController3D _left;
private XRController3D _right;
private XRCamera3D _camera;
private Locomotion _locomotion;
private PropShop _propShop;
private AudioStreamPlayer3D _click;
private AudioStreamPlayer3D _ping;
private MenuScreen _screen;
private MainMenu _menu;
private readonly bool[] _dragging = new bool[2];
private readonly bool[] _directDrag = new bool[2];
private readonly float[] _laserDist = new float[2];
private readonly Vector3[] _directOffset = new Vector3[2];
private readonly bool[] _summonPrev = new bool[2];
private readonly MenuScreen.PointerInput[] _inputs = new MenuScreen.PointerInput[2];
public override void _Ready()
{
_instance = this;
_left = GetNode<XRController3D>("../LeftHand");
_right = GetNode<XRController3D>("../RightHand");
_camera = GetNode<XRCamera3D>("../XRCamera3D");
_locomotion = GetNode<Locomotion>("../../Locomotion");
_propShop = GetNode<PropShop>("../../PropShop");
_screen = new MenuScreen { Name = "Screen" };
AddChild(_screen);
_menu = new MainMenu { Name = "MainMenu" };
_screen.SetContent(_menu);
_screen.PointerPressed += OnPointerPressed;
_screen.Haptic += (hand, amplitude, seconds) =>
(hand == 0 ? _left : _right).TriggerHapticPulse("haptic", 0.0, amplitude, seconds, 0.0);
_menu.ReturnToSpawnRequested += () =>
{
_locomotion.ReturnToSpawn();
ResetInteractionState();
PlaceInFrontOfHead();
_menu.Log("Returned to spawn");
};
_menu.ResetPropsRequested += () =>
{
_propShop.Respawn();
GetNodeOrNull<Playground>("../../Playground")?.Reset();
GetNodeOrNull<Crag>("../../Crag")?.Reset();
_menu.Log("Props reset");
};
_menu.PingRequested += () =>
{
GD.Print("WorldPanel: ping");
_ping.Play();
_menu.Log("Ping");
};
_menu.CloseRequested += () => SetPanelActive(false);
_menu.Clicked += () => _click.Play();
_click = MakeTone(900f, 25, 0.35f, 1f);
_ping = MakeTone(1320f, 4, 0.3f, 3f);
_menu.ClickVolumeSlider.ValueChanged += v => _click.VolumeDb = Mathf.LinearToDb((float)v / 70f);
_menu.StrengthChanged += v => PlayerRagdoll.Strength = v;
_menu.SmoothTurnToggled += on => PlayerRig.SmoothTurn = on;
_menu.SpectatorToggled += PlayerRig.SetSpectator;
SetPanelActive(false);
}
// This controller currently points at (or touches) the menu — its stick scrolls the menu,
// so locomotion must ignore it.
public static bool IsPointing(XRController3D controller)
{
if (_instance == null || !_instance.Visible)
return false;
int hand = controller == _instance._left ? 0 : controller == _instance._right ? 1 : -1;
return hand >= 0 && _instance._screen.GetMode(hand) != MenuScreen.PointerMode.None;
}
public static bool IsBlocking(XRController3D controller) =>
_instance != null && _instance.Visible &&
_instance.GlobalPosition.DistanceTo(controller.GlobalPosition) < DirectGrabRadius;
public static bool IsPanelCollider(Node node) =>
_instance != null && _instance.Visible && node != null &&
(node == _instance._screen.Body || node.GetParent() == _instance._screen.Body);
// Laser length to the menu for this controller, if the controller is currently using its
// laser (not touch) on the menu. Locomotion.UpdatePointer draws the beam from this.
public static bool TryGetLaserDistance(XRController3D controller, out float distance)
{
distance = 0f;
if (_instance == null || !_instance.Visible)
return false;
int hand = controller == _instance._left ? 0 : controller == _instance._right ? 1 : -1;
return hand >= 0 && _instance._screen.TryGetLaserDistance(hand, out distance);
}
public override void _Process(double delta)
{
PollSummon(_left, 0);
PollSummon(_right, 1);
if (Visible)
{
UpdateDrag(_left, 0);
UpdateDrag(_right, 1);
}
_inputs[0] = ReadPointer(_left, 0);
_inputs[1] = ReadPointer(_right, 1);
_screen.UpdatePointers(_inputs, delta);
}
private MenuScreen.PointerInput ReadPointer(XRController3D controller, int hand) => new()
{
// A hand dragging the panel doesn't also point at it.
Valid = !_dragging[hand] && controller.GetIsActive(),
Origin = controller.GlobalPosition,
Direction = -controller.GlobalTransform.Basis.Z,
Trigger = controller.GetFloat("trigger") > TriggerThreshold,
Stick = controller.GetVector2("primary"),
};
private void OnPointerPressed(int hand, MenuScreen.PointerMode mode)
{
XRController3D controller = hand == 0 ? _left : _right;
// Short tick on contact: the "physical press" feel for the touch cursor.
controller.TriggerHapticPulse("haptic", 0.0, mode == MenuScreen.PointerMode.Touch ? 0.35 : 0.2, 0.02, 0.0);
}
private void PollSummon(XRController3D controller, int hand)
{
bool summon = controller.IsButtonPressed("ax_button");
if (summon && !_summonPrev[hand])
{
if (!Visible)
PlaceInFrontOf(controller);
SetPanelActive(!Visible);
GD.Print($"WorldPanel: {(Visible ? "summoned" : "dismissed")} via ax_button ({(hand == 0 ? "left" : "right")})");
}
_summonPrev[hand] = summon;
}
private void SetPanelActive(bool active)
{
ResetInteractionState();
Visible = active;
_screen.SetActive(active);
if (active)
_menu.RefreshCalibration();
}
private void PlaceInFrontOf(XRController3D controller) =>
OrientTo(controller.GlobalPosition - controller.GlobalTransform.Basis.Z * SummonDistance);
private void PlaceInFrontOfHead()
{
Vector3 forward = -_camera.GlobalTransform.Basis.Z;
forward.Y = 0f;
forward = forward.LengthSquared() < 0.0001f ? Vector3.Forward : forward.Normalized();
OrientTo(_camera.GlobalPosition + forward * HeadRecallDistance + Vector3.Down * HeadRecallDrop);
}
private static Basis FacingBasis(Vector3 from, Vector3 head, out bool ok)
{
ok = false;
Vector3 look = head - from;
if (look.LengthSquared() < 0.0001f)
return Basis.Identity;
Vector3 zAxis = look.Normalized();
Vector3 xAxis = Vector3.Up.Cross(zAxis);
if (xAxis.LengthSquared() < 0.0001f)
return Basis.Identity;
xAxis = xAxis.Normalized();
Vector3 yAxis = zAxis.Cross(xAxis).Normalized();
ok = true;
return new Basis(xAxis, yAxis, zAxis);
}
private void OrientTo(Vector3 pos)
{
Basis basis = FacingBasis(pos, _camera.GlobalPosition, out bool ok);
if (ok)
GlobalTransform = new Transform3D(basis, pos);
}
private void OrientTowardCamera()
{
Basis basis = FacingBasis(GlobalPosition, _camera.GlobalPosition, out bool ok);
if (ok)
GlobalBasis = GlobalBasis.Slerp(basis, 0.35f).Orthonormalized();
}
private AudioStreamPlayer3D MakeTone(float frequency, int durationDivisor, float amplitude, float decayPower)
{
const int rate = 44100;
int samples = rate / durationDivisor;
byte[] data = new byte[samples * 2];
for (int i = 0; i < samples; i++)
{
float envelope = Mathf.Pow(1f - (float)i / samples, decayPower);
float value = Mathf.Sin(Mathf.Tau * frequency * i / rate) * envelope * amplitude;
short sample = (short)(value * short.MaxValue);
data[i * 2] = (byte)sample;
data[i * 2 + 1] = (byte)(sample >> 8);
}
AudioStreamPlayer3D player = new()
{
Stream = new AudioStreamWav
{
Format = AudioStreamWav.FormatEnum.Format16Bits,
MixRate = rate,
Stereo = false,
Data = data,
},
};
AddChild(player);
return player;
}
private void UpdateDrag(XRController3D controller, int hand)
{
Vector3 from = controller.GlobalPosition;
Vector3 dir = -controller.GlobalTransform.Basis.Z;
bool laserOnPanel = _screen.GetMode(hand) == MenuScreen.PointerMode.Laser;
bool grip = controller.GetFloat("grip") > GripThreshold
&& !PropGrabber.IsGrabbing(controller);
if (grip && !_dragging[hand] && (laserOnPanel || GlobalPosition.DistanceTo(from) < DirectGrabRadius))
{
_dragging[hand] = true;
_directDrag[hand] = !laserOnPanel;
if (_directDrag[hand])
_directOffset[hand] = GlobalPosition - from;
else
_laserDist[hand] = Mathf.Max(0.2f, (GlobalPosition - from).Dot(dir));
GD.Print($"WorldPanel: grab started ({(_directDrag[hand] ? "direct" : "laser")}, {(hand == 0 ? "left" : "right")})");
}
if (!grip && _dragging[hand])
{
_dragging[hand] = false;
GD.Print("WorldPanel: grab released");
}
if (_dragging[hand])
{
GlobalPosition = _directDrag[hand]
? from + _directOffset[hand]
: from + dir * _laserDist[hand];
OrientTowardCamera();
}
}
private void ResetInteractionState()
{
for (int hand = 0; hand < 2; hand++)
_dragging[hand] = false;
_screen?.ReleaseAll();
}
}