In-VR menu: replace chunky prop-knob panel with Godot Control UI in a SubViewport

WorldPanel is now the project's one SubViewport screen: MainMenu (tabs Player /
World / System; Return to spawn, Recalibrate body, Reset props, Ping, click
volume slider, scrollable log, FPS, Close) rendered by MenuScreen into a
960x640 SubViewport on a thin 0.36x0.24 m panel.

Input = touch-first hybrid cursor injected as mouse events: both controller
rays are projected analytically onto the panel plane, converted to viewport
pixels and pushed via Viewport.PushInput (MouseMotion/MouseButton, wheel from
thumbstick). Stylus tip within 6 cm = touch cursor (press crossing 0.6 cm from
the front, release past 1.8 cm); farther = laser cursor with trigger edge. One
owning hand per viewport. Haptic tick + synthesized click on press.

Kept: ax_button summon/dismiss, XROrigin3D parenting, grip drag (direct/laser)
with head re-orient, IsBlocking/IsPanelCollider. Locomotion's beam now takes
its length from WorldPanel.TryGetLaserDistance (no beam while touching) and
starts at the stylus tip. Recalibrate calls Recalibrate() on the first node in
group "calibration" (disabled when absent).

New headless regression scenes/menu_spike.tscn ("MENU: overall PASS").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FoxyzandClaude Opus 5.5 committed 2026-10-02 05:49:13 +03:00
1 parent 3a990c6dbe
commit 661fe1089a
11 files changed
+1281 -316

No files matched your search

+123 -260
View File
@@ -1,16 +1,18 @@
using Godot;
using System.Collections.Generic;
// 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 TipHoverRadius = 0.07f;
private const float PressContactDistance = 0.045f;
private const ulong PressCooldownMs = 400;
private const float TipLength = 0.12f;
private const float RayLength = 50f;
private const float SummonDistance = 0.3f;
private const float HeadRecallDistance = 0.45f;
private const float HeadRecallDrop = 0.12f;
private static WorldPanel _instance;
@@ -20,29 +22,17 @@ public partial class WorldPanel : Node3D
private Locomotion _locomotion;
private PropShop _propShop;
private AudioStreamPlayer3D _click;
private AudioStreamPlayer3D _ping;
private StaticBody3D _panelBody;
private struct Knob
{
public string Name;
public Node3D Root;
public MeshInstance3D Mesh;
public StandardMaterial3D Material;
public Callable Action;
public ulong LastPressMs;
}
private readonly List<Knob> _knobs = new();
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[] _triggerPrev = new bool[2];
private readonly bool[] _summonPrev = new bool[2];
private readonly int[] _hover = new int[2] { -1, -1 };
private readonly bool[] _hoverPanel = new bool[2];
private readonly MenuScreen.PointerInput[] _inputs = new MenuScreen.PointerInput[2];
public override void _Ready()
{
@@ -52,9 +42,38 @@ public partial class WorldPanel : Node3D
_camera = GetNode<XRCamera3D>("../XRCamera3D");
_locomotion = GetNode<Locomotion>("../../Locomotion");
_propShop = GetNode<PropShop>("../../PropShop");
Visible = false;
BuildPanel();
BuildClickSound();
_screen = new MenuScreen { Name = "Screen" };
AddChild(_screen);
_menu = new MainMenu { Name = "MainMenu" };
_screen.SetContent(_menu);
_screen.PointerPressed += OnPointerPressed;
_menu.ReturnToSpawnRequested += () =>
{
_locomotion.ReturnToSpawn();
ResetInteractionState();
PlaceInFrontOfHead();
_menu.Log("Returned to spawn");
};
_menu.ResetPropsRequested += () =>
{
_propShop.Respawn();
_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);
SetPanelActive(false);
}
@@ -64,16 +83,48 @@ public partial class WorldPanel : Node3D
public static bool IsPanelCollider(Node node) =>
_instance != null && _instance.Visible && node != null &&
(node.HasMeta("knob_index") || node == _instance._panelBody || node.GetParent() == _instance._panelBody);
(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)
return;
UpdateHand(_left, 0);
UpdateHand(_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)
@@ -91,173 +142,68 @@ public partial class WorldPanel : Node3D
private void SetPanelActive(bool active)
{
if (!active)
ResetInteractionState();
ResetInteractionState();
Visible = active;
SetShapesEnabled(this, active);
}
private static void SetShapesEnabled(Node root, bool enabled)
{
foreach (Node child in root.GetChildren())
{
if (child is CollisionShape3D shape)
shape.SetDeferred(CollisionShape3D.PropertyName.Disabled, !enabled);
SetShapesEnabled(child, enabled);
}
_screen.SetActive(active);
if (active)
_menu.RefreshCalibration();
}
private void PlaceInFrontOf(XRController3D controller) =>
OrientTo(controller.GlobalPosition - controller.GlobalTransform.Basis.Z * 0.5f);
OrientTo(controller.GlobalPosition - controller.GlobalTransform.Basis.Z * SummonDistance);
private void PlaceInFrontOfHead() =>
OrientTo(_camera.GlobalPosition - _camera.GlobalTransform.Basis.Z * 0.5f);
private void OrientTo(Vector3 pos)
private void PlaceInFrontOfHead()
{
Vector3 look = _camera.GlobalPosition - pos;
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;
return Basis.Identity;
Vector3 zAxis = look.Normalized();
Vector3 xAxis = Vector3.Up.Cross(zAxis);
if (xAxis.LengthSquared() < 0.0001f)
return;
return Basis.Identity;
xAxis = xAxis.Normalized();
Vector3 yAxis = zAxis.Cross(xAxis).Normalized();
GlobalTransform = new Transform3D(new Basis(xAxis, yAxis, zAxis), pos);
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()
{
Vector3 look = _camera.GlobalPosition - GlobalPosition;
if (look.LengthSquared() < 0.0001f)
return;
Vector3 zAxis = look.Normalized();
Vector3 xAxis = Vector3.Up.Cross(zAxis);
if (xAxis.LengthSquared() < 0.0001f)
return;
xAxis = xAxis.Normalized();
Vector3 yAxis = zAxis.Cross(xAxis).Normalized();
GlobalBasis = GlobalBasis.Slerp(new Basis(xAxis, yAxis, zAxis), 0.35f).Orthonormalized();
Basis basis = FacingBasis(GlobalPosition, _camera.GlobalPosition, out bool ok);
if (ok)
GlobalBasis = GlobalBasis.Slerp(basis, 0.35f).Orthonormalized();
}
private void BuildPanel()
{
Node3D plate = new() { Name = "Plate" };
AddChild(plate);
StandardMaterial3D plateMaterial = new()
{
AlbedoColor = new Color(0.08f, 0.09f, 0.12f),
EmissionEnabled = true,
Emission = new Color(0.1f, 0.2f, 0.35f),
EmissionEnergyMultiplier = 0.4f,
};
MeshInstance3D plateMesh = new() { Mesh = new BoxMesh { Size = new Vector3(0.6f, 0.34f, 0.02f) } };
plateMesh.SetSurfaceOverrideMaterial(0, plateMaterial);
plate.AddChild(plateMesh);
_panelBody = new StaticBody3D
{
Name = "PanelBody",
CollisionLayer = 2,
CollisionMask = 0,
};
_panelBody.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(0.6f, 0.34f, 0.06f) } });
plate.AddChild(_panelBody);
Vector3[] positions =
{
new(-0.21f, 0.06f, 0.03f),
new(-0.07f, 0.06f, 0.03f),
new(0.07f, 0.06f, 0.03f),
new(0.21f, 0.06f, 0.03f),
};
string[] names = { "Spawn", "Ping", "Reset", "Close" };
Color[] colors =
{
new(0.25f, 0.6f, 1f),
new(0.95f, 0.75f, 0.25f),
new(0.2f, 0.8f, 0.35f),
new(0.9f, 0.3f, 0.3f),
};
Callable[] actions =
{
Callable.From(() =>
{
_locomotion.ReturnToSpawn();
ResetInteractionState();
PlaceInFrontOfHead();
}),
Callable.From(Ping),
Callable.From(() => _propShop.Respawn()),
Callable.From(Close),
};
for (int i = 0; i < names.Length; i++)
{
StandardMaterial3D material = new()
{
AlbedoColor = colors[i],
EmissionEnabled = true,
Emission = colors[i],
EmissionEnergyMultiplier = 1.2f,
};
MeshInstance3D mesh = new()
{
Mesh = new CylinderMesh { TopRadius = 0.032f, BottomRadius = 0.032f, Height = 0.025f },
RotationDegrees = new Vector3(90, 0, 0),
};
mesh.SetSurfaceOverrideMaterial(0, material);
StaticBody3D body = new()
{
CollisionLayer = 2,
CollisionMask = 0,
};
body.SetMeta("knob_index", i);
body.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(0.09f, 0.09f, 0.06f) } });
Node3D root = new() { Position = positions[i] };
root.AddChild(mesh);
root.AddChild(body);
Label3D label = new()
{
Text = names[i],
FontSize = 96,
PixelSize = 0.00025f,
OutlineSize = 6,
Position = new Vector3(0, -0.08f, 0.015f),
};
root.AddChild(label);
plate.AddChild(root);
_knobs.Add(new Knob
{
Name = names[i],
Root = root,
Mesh = mesh,
Material = material,
Action = actions[i],
});
}
}
private void BuildClickSound()
private AudioStreamPlayer3D MakeTone(float frequency, int durationDivisor, float amplitude, float decayPower)
{
const int rate = 44100;
const int samples = rate / 25;
int samples = rate / durationDivisor;
byte[] data = new byte[samples * 2];
for (int i = 0; i < samples; i++)
{
float envelope = 1f - (float)i / samples;
float value = Mathf.Sin(Mathf.Tau * 900f * i / rate) * envelope * 0.35f;
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);
}
_click = new AudioStreamPlayer3D
AudioStreamPlayer3D player = new()
{
Stream = new AudioStreamWav
{
@@ -267,23 +213,22 @@ public partial class WorldPanel : Node3D
Data = data,
},
};
AddChild(_click);
AddChild(player);
return player;
}
private void UpdateHand(XRController3D controller, int hand)
private void UpdateDrag(XRController3D controller, int hand)
{
Vector3 from = controller.GlobalPosition;
Vector3 dir = -controller.GlobalTransform.Basis.Z;
Vector3 tip = from + dir * TipLength;
UpdateHover(controller, hand, from, dir, tip);
bool laserOnPanel = _screen.GetMode(hand) == MenuScreen.PointerMode.Laser;
bool grip = controller.GetFloat("grip") > GripThreshold
&& !PropGrabber.IsGrabbing(controller);
if (grip && !_dragging[hand] && (_hoverPanel[hand] || GlobalPosition.DistanceTo(from) < DirectGrabRadius))
if (grip && !_dragging[hand] && (laserOnPanel || GlobalPosition.DistanceTo(from) < DirectGrabRadius))
{
_dragging[hand] = true;
_directDrag[hand] = !_hoverPanel[hand];
_directDrag[hand] = !laserOnPanel;
if (_directDrag[hand])
_directOffset[hand] = GlobalPosition - from;
else
@@ -303,94 +248,12 @@ public partial class WorldPanel : Node3D
: from + dir * _laserDist[hand];
OrientTowardCamera();
}
bool trigger = controller.GetFloat("trigger") > TriggerThreshold;
if (trigger && !_triggerPrev[hand] && _hover[hand] >= 0)
Press(_hover[hand]);
_triggerPrev[hand] = trigger;
if (_hover[hand] >= 0)
{
int hovered = _hover[hand];
Knob knob = _knobs[hovered];
if (knob.Root.GlobalPosition.DistanceTo(tip) < PressContactDistance)
{
ulong now = Time.GetTicksMsec();
if (now - knob.LastPressMs > PressCooldownMs)
{
knob.LastPressMs = now;
_knobs[hovered] = knob;
Press(hovered);
}
}
}
}
private void UpdateHover(XRController3D controller, int hand, Vector3 from, Vector3 dir, Vector3 tip)
{
int knob = -1;
bool panel = false;
var query = PhysicsRayQueryParameters3D.Create(from, from + dir * RayLength);
var hit = GetViewport().GetWorld3D().DirectSpaceState.IntersectRay(query);
if (hit.TryGetValue("collider", out Variant collider) && collider.As<Node>() is Node node)
{
if (node.HasMeta("knob_index"))
knob = (int)node.GetMeta("knob_index");
else
panel = IsPanelCollider(node) || node.GetParent() == _panelBody;
}
if (knob < 0)
{
for (int i = 0; i < _knobs.Count; i++)
{
if (_knobs[i].Root.GlobalPosition.DistanceTo(tip) < TipHoverRadius)
{
knob = i;
break;
}
}
}
if (knob != _hover[hand])
{
if (_hover[hand] >= 0)
_knobs[_hover[hand]].Material.EmissionEnergyMultiplier = 1.2f;
_hover[hand] = knob;
if (knob >= 0)
_knobs[knob].Material.EmissionEnergyMultiplier = 2.8f;
}
_hoverPanel[hand] = panel;
}
private void Press(int index)
{
Knob knob = _knobs[index];
GD.Print($"WorldPanel: pressed '{knob.Name}'");
_click.Play();
Tween tween = CreateTween();
tween.TweenProperty(knob.Mesh, "position:z", -0.014f, 0.05);
tween.Parallel().TweenProperty(knob.Material, "emission_energy_multiplier", 4.0f, 0.05);
tween.TweenProperty(knob.Mesh, "position:z", 0f, 0.12);
tween.Parallel().TweenProperty(knob.Material, "emission_energy_multiplier", 1.2f, 0.3);
knob.Action.Call();
}
private void Ping() => GD.Print("WorldPanel: ping");
private void Close() => SetPanelActive(false);
private void ResetInteractionState()
{
for (int hand = 0; hand < 2; hand++)
{
_dragging[hand] = false;
_hoverPanel[hand] = false;
_triggerPrev[hand] = false;
if (_hover[hand] >= 0)
_knobs[_hover[hand]].Material.EmissionEnergyMultiplier = 1.2f;
_hover[hand] = -1;
}
_screen?.ReleaseAll();
}
}