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

+258
View File
@@ -0,0 +1,258 @@
using Godot;
using System;
using System.Threading.Tasks;
// Headless regression for the in-VR menu (MenuScreen + MainMenu), no XR: drives simulated
// controller rays at known controls and asserts the Godot UI reacted. Run:
// godot-mono --headless --fixed-fps 90 --path . scenes/menu_spike.tscn
// Must print "MENU: overall PASS".
public partial class MenuSpike : Node3D
{
private partial class CalibrationStub : Node
{
public int Calls;
public void Recalibrate() => Calls++;
}
private MenuScreen _screen;
private MainMenu _menu;
private readonly MenuScreen.PointerInput[] _inputs = new MenuScreen.PointerInput[2];
private int _spawn, _reset, _ping, _close, _clicks, _pressedEvents;
private bool _allPass = true;
public override void _Ready()
{
_screen = new MenuScreen();
AddChild(_screen);
// Arbitrary, non-axis-aligned pose to exercise the plane math.
_screen.GlobalTransform = new Transform3D(
Basis.FromEuler(new Vector3(Mathf.DegToRad(-15f), Mathf.DegToRad(30f), 0f)),
new Vector3(0.3f, 1.3f, -0.5f));
_menu = new MainMenu();
_screen.SetContent(_menu);
_screen.SetActive(true);
_screen.PointerPressed += (_, _) => _pressedEvents++;
_menu.ReturnToSpawnRequested += () => _spawn++;
_menu.ResetPropsRequested += () => _reset++;
_menu.PingRequested += () => _ping++;
_menu.CloseRequested += () => _close++;
_menu.Clicked += () => _clicks++;
GetTree().CreateTimer(30.0).Timeout += () =>
{
GD.Print("MENU: timeout — overall FAIL");
GetTree().Quit(1);
};
Run();
}
private void Check(string name, bool ok, string detail = "")
{
GD.Print($"MENU: {name} {detail} → {(ok ? "PASS" : "FAIL")}");
_allPass &= ok;
}
private async Task Frame(int count = 1)
{
for (int i = 0; i < count; i++)
{
_screen.UpdatePointers(_inputs, 1.0 / 90.0);
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
}
}
private Vector2 Center(Control c) => c.GetGlobalRect().GetCenter();
private Vector3 Normal => _screen.GlobalBasis.Z.Normalized();
private void SetHand(int hand, Vector3 origin, Vector3 dir, bool trigger = false, Vector2 stick = default) =>
_inputs[hand] = new MenuScreen.PointerInput { Valid = true, Origin = origin, Direction = dir.Normalized(), Trigger = trigger, Stick = stick };
private void ClearHand(int hand) => _inputs[hand] = default;
// Stylus straight at the panel: tip depth d in front of the pixel (negative = pushed through).
private void TipAt(int hand, Vector2 pixel, float depth)
{
Vector3 p = _screen.PixelToGlobal(pixel);
SetHand(hand, p + Normal * (MenuScreen.TipLength + depth), -Normal);
}
private async Task TouchPress(int hand, Vector2 pixel)
{
for (float d = 0.10f; d > -0.012f; d -= 0.004f)
{
TipAt(hand, pixel, d);
await Frame();
}
for (float d = -0.012f; d < 0.08f; d += 0.006f)
{
TipAt(hand, pixel, d);
await Frame();
}
ClearHand(hand);
await Frame(3);
}
// Laser from 0.8 m, off-axis, aimed at the pixel.
private void LaserAt(int hand, Vector2 pixel, bool trigger, Vector2 stick = default)
{
Vector3 p = _screen.PixelToGlobal(pixel);
Vector3 origin = p + Normal * 0.7f + _screen.GlobalBasis.X * 0.25f + Vector3.Up * 0.2f;
SetHand(hand, origin, p - origin, trigger, stick);
}
private async Task LaserClick(int hand, Vector2 pixel)
{
LaserAt(hand, pixel, false);
await Frame(4);
LaserAt(hand, pixel, true);
await Frame(4);
LaserAt(hand, pixel, false);
await Frame(4);
ClearHand(hand);
await Frame(3);
}
private Vector2 TabCenter(int index)
{
TabBar bar = _menu.Tabs.GetTabBar();
Rect2 r = bar.GetTabRect(index);
return bar.GetGlobalRect().Position + r.GetCenter();
}
private async void Run()
{
await Frame(10); // layout
// Mapping sanity: pixel → world → local → pixel round trip.
Vector2 probe = new(123f, 456f);
Vector2 back = _screen.LocalToPixel(_screen.GlobalTransform.AffineInverse() * _screen.PixelToGlobal(probe));
Check("pixel mapping round trip", back.DistanceTo(probe) < 0.01f, $"({back})");
// 1) Touch path, with the other hand idling as a laser on the panel (arbitration:
// touch beats laser).
Vector2 spawnPx = Center(_menu.ReturnToSpawnButton);
LaserAt(0, new Vector2(800, 600), false);
await Frame(3);
Check("idle laser is laser mode", _screen.GetMode(0) == MenuScreen.PointerMode.Laser);
bool sawHover = false, sawTouch = false;
for (float d = 0.10f; d > -0.012f; d -= 0.004f)
{
TipAt(1, spawnPx, d);
await Frame();
sawTouch |= _screen.GetMode(1) == MenuScreen.PointerMode.Touch;
sawHover |= _menu.ReturnToSpawnButton.IsHovered();
}
bool pressedWhileIn = _screen.IsPressing(1);
for (float d = -0.012f; d < 0.08f; d += 0.006f)
{
TipAt(1, spawnPx, d);
await Frame();
}
ClearHand(0);
ClearHand(1);
await Frame(3);
Check("touch: tip mode + hover", sawTouch && sawHover, $"(touch={sawTouch}, hover={sawHover})");
Check("touch: contact holds press", pressedWhileIn);
Check("touch: Return to spawn fired once", _spawn == 1, $"(count={_spawn}, pressedEvents={_pressedEvents})");
// 2) Tip sweeping in sideways already behind the surface must NOT press.
int before = _spawn;
Vector2 offPanel = new(-60f, spawnPx.Y);
for (int i = 0; i <= 30; i++)
{
Vector2 px = offPanel.Lerp(spawnPx, i / 30f);
Vector3 p = _screen.GlobalTransform * new Vector3(
(px.X / _screen.SizePixels.X - 0.5f) * _screen.SizeMeters.X,
(0.5f - px.Y / _screen.SizePixels.Y) * _screen.SizeMeters.Y, 0f);
SetHand(1, p + Normal * (MenuScreen.TipLength - 0.01f), -Normal);
await Frame();
}
TipAt(1, spawnPx, 0.08f);
await Frame(3);
ClearHand(1);
await Frame(3);
Check("touch: side entry behind surface does not press", _spawn == before, $"(count={_spawn})");
// 3) Laser + trigger: switch to the World tab, then Reset props.
await LaserClick(0, TabCenter(1));
Check("laser: World tab selected", _menu.Tabs.CurrentTab == 1, $"(tab={_menu.Tabs.CurrentTab})");
await Frame(2);
Vector2 resetPx = Center(_menu.ResetPropsButton);
LaserAt(0, resetPx, false);
await Frame(2);
bool laserDist = _screen.TryGetLaserDistance(0, out float dist) && dist > 0.5f && dist < 1.0f;
Check("laser: distance reported", laserDist, $"({dist:F3} m)");
await LaserClick(0, resetPx);
Check("laser: Reset props fired once", _reset == 1, $"(count={_reset})");
// 4) Trigger already held when the laser arrives must not click.
LaserAt(0, new Vector2(-200, -200), true); // off panel
await Frame(3);
LaserAt(0, Center(_menu.PingButton), true);
await Frame(5);
LaserAt(0, Center(_menu.PingButton), false);
await Frame(3);
ClearHand(0);
await Frame(2);
Check("laser: held trigger arriving does not click", _ping == 0, $"(count={_ping})");
await LaserClick(0, Center(_menu.PingButton));
Check("laser: Ping fired once", _ping == 1, $"(count={_ping})");
// 5) Touch a tab, then drag the slider with the laser (trigger held).
await TouchPress(1, TabCenter(2));
Check("touch: System tab selected", _menu.Tabs.CurrentTab == 2, $"(tab={_menu.Tabs.CurrentTab})");
await Frame(2);
Rect2 sr = _menu.ClickVolumeSlider.GetGlobalRect();
double v0 = _menu.ClickVolumeSlider.Value;
Vector2 grab = new(sr.Position.X + sr.Size.X * (float)(v0 / 100.0), sr.GetCenter().Y);
LaserAt(0, grab, false);
await Frame(3);
LaserAt(0, grab, true);
await Frame(3);
for (int i = 1; i <= 20; i++)
{
LaserAt(0, grab.Lerp(new Vector2(sr.Position.X + 4f, grab.Y), i / 20f), true);
await Frame();
}
LaserAt(0, new Vector2(sr.Position.X + 4f, grab.Y), false);
await Frame(3);
ClearHand(0);
await Frame(2);
double v1 = _menu.ClickVolumeSlider.Value;
Check("laser: slider drag", v1 < v0 - 30, $"({v0} → {v1})");
// 6) Thumbstick scrolls the log while hovering it.
for (int i = 0; i < 60; i++)
_menu.Log($"log line {i}");
await Frame(5);
Vector2 logPx = Center(_menu.LogScroll);
LaserAt(0, logPx, false, new Vector2(0, -1));
await Frame(30);
int scrolled = _menu.LogScroll.ScrollVertical;
LaserAt(0, logPx, false, new Vector2(0, 1));
await Frame(60);
int scrolledBack = _menu.LogScroll.ScrollVertical;
ClearHand(0);
await Frame(2);
Check("stick: scroll down then up", scrolled > 50 && scrolledBack < scrolled, $"({scrolled} → {scrolledBack})");
// 7) Recalibrate: disabled without a calibration node, enabled + calls it when present.
await TouchPress(1, TabCenter(0));
Check("Recalibrate disabled without group node", _menu.RecalibrateButton.Disabled);
await TouchPress(1, Center(_menu.RecalibrateButton));
CalibrationStub stub = new();
AddChild(stub);
stub.AddToGroup(MainMenu.CalibrationGroup);
await Frame(40); // refresh runs at 4 Hz
Check("Recalibrate enabled with group node", !_menu.RecalibrateButton.Disabled);
await TouchPress(1, Center(_menu.RecalibrateButton));
Check("touch: Recalibrate called once", stub.Calls == 1, $"(calls={stub.Calls})");
// 8) Header Close via touch.
await TouchPress(1, Center(_menu.HeaderCloseButton));
Check("touch: Close fired once", _close == 1, $"(count={_close})");
Check("click feedback fired", _clicks >= 8, $"(clicks={_clicks})");
GD.Print($"MENU: overall {(_allPass ? "PASS" : "FAIL")}");
GetTree().Quit(_allPass ? 0 : 1);
}
}