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:
1 parent
3a990c6dbe
commit
661fe1089a
11 files changed
+1281
-316
No files matched your search
@@ -0,0 +1,288 @@
|
||||
using Godot;
|
||||
using System;
|
||||
|
||||
// The in-VR menu content: plain Godot Controls, rendered by MenuScreen into its SubViewport.
|
||||
// Built in code (no .tscn) so the spike can instantiate it without XR. Actions are C# events;
|
||||
// WorldPanel wires them to the game. Sized for a 960x640 viewport on a 0.36x0.24 m panel
|
||||
// (~2.7 px/mm): 26 px body text ≈ 1.2° at 45 cm, 64 px buttons ≈ 24 mm touch targets.
|
||||
public partial class MainMenu : PanelContainer
|
||||
{
|
||||
public const string CalibrationGroup = "calibration";
|
||||
|
||||
private const int FontSize = 26;
|
||||
private const int ButtonHeight = 64;
|
||||
private const int MaxLogLines = 40;
|
||||
|
||||
public event Action ReturnToSpawnRequested;
|
||||
public event Action ResetPropsRequested;
|
||||
public event Action PingRequested;
|
||||
public event Action CloseRequested;
|
||||
public event Action Clicked;
|
||||
|
||||
public TabContainer Tabs { get; private set; }
|
||||
public Button ReturnToSpawnButton { get; private set; }
|
||||
public Button RecalibrateButton { get; private set; }
|
||||
public Button ResetPropsButton { get; private set; }
|
||||
public Button PingButton { get; private set; }
|
||||
public Button CloseButton { get; private set; }
|
||||
public Button HeaderCloseButton { get; private set; }
|
||||
public HSlider ClickVolumeSlider { get; private set; }
|
||||
public ScrollContainer LogScroll { get; private set; }
|
||||
|
||||
private Label _fpsLabel;
|
||||
private Label _calibrationStatus;
|
||||
private VBoxContainer _logLines;
|
||||
private double _refreshTimer;
|
||||
|
||||
public MainMenu()
|
||||
{
|
||||
Theme = BuildTheme();
|
||||
BuildLayout();
|
||||
}
|
||||
|
||||
public override void _Ready() => RefreshCalibration();
|
||||
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
_refreshTimer -= delta;
|
||||
if (_refreshTimer > 0)
|
||||
return;
|
||||
_refreshTimer = 0.25;
|
||||
_fpsLabel.Text = $"{Engine.GetFramesPerSecond():0} fps";
|
||||
RefreshCalibration();
|
||||
}
|
||||
|
||||
// Contract with the body-calibration work: a node in group "calibration" exposing
|
||||
// Recalibrate(). Absent → button disabled. We never create that node here.
|
||||
public Node FindCalibration()
|
||||
{
|
||||
if (!IsInsideTree())
|
||||
return null;
|
||||
Node node = GetTree().GetFirstNodeInGroup(CalibrationGroup);
|
||||
return node != null && node.HasMethod("Recalibrate") ? node : null;
|
||||
}
|
||||
|
||||
public void RefreshCalibration()
|
||||
{
|
||||
bool available = FindCalibration() != null;
|
||||
RecalibrateButton.Disabled = !available;
|
||||
_calibrationStatus.Text = available ? "" : "Not available";
|
||||
}
|
||||
|
||||
public void Log(string line)
|
||||
{
|
||||
_logLines.AddChild(new Label { Text = line, AutowrapMode = TextServer.AutowrapMode.WordSmart });
|
||||
while (_logLines.GetChildCount() > MaxLogLines)
|
||||
{
|
||||
Node oldest = _logLines.GetChild(0);
|
||||
_logLines.RemoveChild(oldest);
|
||||
oldest.QueueFree();
|
||||
}
|
||||
}
|
||||
|
||||
private void BuildLayout()
|
||||
{
|
||||
VBoxContainer root = new();
|
||||
root.AddThemeConstantOverride("separation", 10);
|
||||
AddChild(root);
|
||||
|
||||
HBoxContainer header = new();
|
||||
header.AddThemeConstantOverride("separation", 16);
|
||||
root.AddChild(header);
|
||||
Label title = new() { Text = "VR Messs", SizeFlagsHorizontal = SizeFlags.ExpandFill, VerticalAlignment = VerticalAlignment.Center };
|
||||
title.AddThemeFontSizeOverride("font_size", 30);
|
||||
header.AddChild(title);
|
||||
_fpsLabel = new Label { Text = "-- fps", VerticalAlignment = VerticalAlignment.Center };
|
||||
_fpsLabel.AddThemeColorOverride("font_color", new Color(0.6f, 0.68f, 0.8f));
|
||||
header.AddChild(_fpsLabel);
|
||||
HeaderCloseButton = MakeButton("Close", () => CloseRequested?.Invoke());
|
||||
HeaderCloseButton.CustomMinimumSize = new Vector2(120, 52);
|
||||
header.AddChild(HeaderCloseButton);
|
||||
|
||||
Tabs = new TabContainer { SizeFlagsVertical = SizeFlags.ExpandFill, TabFocusMode = FocusModeEnum.None };
|
||||
Tabs.TabChanged += _ => Clicked?.Invoke();
|
||||
root.AddChild(Tabs);
|
||||
|
||||
// Player
|
||||
VBoxContainer player = MakePage("Player");
|
||||
ReturnToSpawnButton = MakeButton("Return to spawn", () => ReturnToSpawnRequested?.Invoke());
|
||||
player.AddChild(ReturnToSpawnButton);
|
||||
HBoxContainer calibRow = new();
|
||||
calibRow.AddThemeConstantOverride("separation", 16);
|
||||
RecalibrateButton = MakeButton("Recalibrate body", Recalibrate);
|
||||
RecalibrateButton.SizeFlagsHorizontal = SizeFlags.ExpandFill;
|
||||
calibRow.AddChild(RecalibrateButton);
|
||||
_calibrationStatus = new Label { VerticalAlignment = VerticalAlignment.Center };
|
||||
_calibrationStatus.AddThemeColorOverride("font_color", new Color(0.6f, 0.6f, 0.65f));
|
||||
calibRow.AddChild(_calibrationStatus);
|
||||
player.AddChild(calibRow);
|
||||
Tabs.AddChild(player);
|
||||
|
||||
// World
|
||||
VBoxContainer world = MakePage("World");
|
||||
ResetPropsButton = MakeButton("Reset props", () => ResetPropsRequested?.Invoke());
|
||||
world.AddChild(ResetPropsButton);
|
||||
PingButton = MakeButton("Ping sound", () => PingRequested?.Invoke());
|
||||
world.AddChild(PingButton);
|
||||
Tabs.AddChild(world);
|
||||
|
||||
// System
|
||||
VBoxContainer system = MakePage("System");
|
||||
HBoxContainer volumeRow = new();
|
||||
volumeRow.AddThemeConstantOverride("separation", 20);
|
||||
volumeRow.AddChild(new Label { Text = "Click volume", VerticalAlignment = VerticalAlignment.Center, CustomMinimumSize = new Vector2(200, 0) });
|
||||
ClickVolumeSlider = new HSlider
|
||||
{
|
||||
MinValue = 0,
|
||||
MaxValue = 100,
|
||||
Step = 1,
|
||||
Value = 70,
|
||||
SizeFlagsHorizontal = SizeFlags.ExpandFill,
|
||||
SizeFlagsVertical = SizeFlags.ShrinkCenter,
|
||||
CustomMinimumSize = new Vector2(0, 48),
|
||||
FocusMode = FocusModeEnum.None,
|
||||
};
|
||||
volumeRow.AddChild(ClickVolumeSlider);
|
||||
system.AddChild(volumeRow);
|
||||
CloseButton = MakeButton("Close menu", () => CloseRequested?.Invoke());
|
||||
system.AddChild(CloseButton);
|
||||
Label logTitle = new() { Text = "Log (thumbstick scrolls)" };
|
||||
logTitle.AddThemeColorOverride("font_color", new Color(0.6f, 0.68f, 0.8f));
|
||||
system.AddChild(logTitle);
|
||||
LogScroll = new ScrollContainer
|
||||
{
|
||||
SizeFlagsVertical = SizeFlags.ExpandFill,
|
||||
HorizontalScrollMode = ScrollContainer.ScrollMode.Disabled,
|
||||
};
|
||||
_logLines = new VBoxContainer { SizeFlagsHorizontal = SizeFlags.ExpandFill };
|
||||
_logLines.AddThemeConstantOverride("separation", 2);
|
||||
LogScroll.AddChild(_logLines);
|
||||
system.AddChild(LogScroll);
|
||||
Tabs.AddChild(system);
|
||||
}
|
||||
|
||||
private void Recalibrate()
|
||||
{
|
||||
Node calibration = FindCalibration();
|
||||
if (calibration == null)
|
||||
{
|
||||
RefreshCalibration();
|
||||
return;
|
||||
}
|
||||
GD.Print("MainMenu: Recalibrate body → calibration.Recalibrate()");
|
||||
calibration.Call("Recalibrate");
|
||||
}
|
||||
|
||||
private static VBoxContainer MakePage(string title)
|
||||
{
|
||||
VBoxContainer page = new() { Name = title };
|
||||
page.AddThemeConstantOverride("separation", 14);
|
||||
return page;
|
||||
}
|
||||
|
||||
private Button MakeButton(string text, Action action)
|
||||
{
|
||||
Button button = new()
|
||||
{
|
||||
Text = text,
|
||||
CustomMinimumSize = new Vector2(0, ButtonHeight),
|
||||
FocusMode = FocusModeEnum.None,
|
||||
};
|
||||
button.Pressed += () =>
|
||||
{
|
||||
Clicked?.Invoke();
|
||||
// Deferred: actions may hide/move the panel; never do that from inside the
|
||||
// input event MenuScreen is injecting.
|
||||
Callable.From(action).CallDeferred();
|
||||
};
|
||||
return button;
|
||||
}
|
||||
|
||||
private static StyleBoxFlat Box(Color bg, int radius, float marginX, float marginY, Color? border = null, int borderWidth = 0)
|
||||
{
|
||||
StyleBoxFlat box = new() { BgColor = bg };
|
||||
box.SetCornerRadiusAll(radius);
|
||||
box.ContentMarginLeft = marginX;
|
||||
box.ContentMarginRight = marginX;
|
||||
box.ContentMarginTop = marginY;
|
||||
box.ContentMarginBottom = marginY;
|
||||
if (border is Color c)
|
||||
{
|
||||
box.BorderColor = c;
|
||||
box.SetBorderWidthAll(borderWidth);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
private static Theme BuildTheme()
|
||||
{
|
||||
Color accent = new(0.35f, 0.6f, 0.95f);
|
||||
Theme theme = new() { DefaultFontSize = FontSize };
|
||||
|
||||
theme.SetStylebox("panel", "PanelContainer", Box(new Color(0.07f, 0.08f, 0.11f), 0, 18, 14));
|
||||
|
||||
theme.SetStylebox("normal", "Button", Box(new Color(0.17f, 0.2f, 0.27f), 10, 20, 8));
|
||||
theme.SetStylebox("hover", "Button", Box(new Color(0.24f, 0.31f, 0.44f), 10, 20, 8, accent, 3));
|
||||
theme.SetStylebox("pressed", "Button", Box(new Color(0.33f, 0.5f, 0.78f), 10, 20, 8, new Color(0.7f, 0.85f, 1f), 3));
|
||||
theme.SetStylebox("hover_pressed", "Button", Box(new Color(0.33f, 0.5f, 0.78f), 10, 20, 8, new Color(0.7f, 0.85f, 1f), 3));
|
||||
theme.SetStylebox("disabled", "Button", Box(new Color(0.11f, 0.12f, 0.15f), 10, 20, 8));
|
||||
theme.SetStylebox("focus", "Button", new StyleBoxEmpty());
|
||||
theme.SetColor("font_color", "Button", new Color(0.92f, 0.94f, 0.98f));
|
||||
theme.SetColor("font_hover_color", "Button", Colors.White);
|
||||
theme.SetColor("font_pressed_color", "Button", Colors.White);
|
||||
theme.SetColor("font_disabled_color", "Button", new Color(0.45f, 0.47f, 0.52f));
|
||||
|
||||
theme.SetStylebox("panel", "TabContainer", Box(new Color(0.1f, 0.12f, 0.16f), 10, 18, 16));
|
||||
StyleBoxFlat tabSelected = Box(new Color(0.1f, 0.12f, 0.16f), 0, 28, 12);
|
||||
tabSelected.CornerRadiusTopLeft = 10;
|
||||
tabSelected.CornerRadiusTopRight = 10;
|
||||
tabSelected.BorderColor = accent;
|
||||
tabSelected.BorderWidthTop = 4;
|
||||
theme.SetStylebox("tab_selected", "TabContainer", tabSelected);
|
||||
StyleBoxFlat tabUnselected = Box(new Color(0.13f, 0.15f, 0.2f), 0, 28, 12);
|
||||
tabUnselected.CornerRadiusTopLeft = 10;
|
||||
tabUnselected.CornerRadiusTopRight = 10;
|
||||
theme.SetStylebox("tab_unselected", "TabContainer", tabUnselected);
|
||||
StyleBoxFlat tabHovered = Box(new Color(0.2f, 0.26f, 0.37f), 0, 28, 12);
|
||||
tabHovered.CornerRadiusTopLeft = 10;
|
||||
tabHovered.CornerRadiusTopRight = 10;
|
||||
theme.SetStylebox("tab_hovered", "TabContainer", tabHovered);
|
||||
theme.SetStylebox("tab_focus", "TabContainer", new StyleBoxEmpty());
|
||||
theme.SetConstant("tab_separation", "TabContainer", 6);
|
||||
theme.SetFontSize("font_size", "TabContainer", FontSize);
|
||||
|
||||
// Slider: thick track, big round grabber (default icons are ~16 px — too small in VR).
|
||||
StyleBoxFlat track = Box(new Color(0.17f, 0.2f, 0.27f), 6, 0, 6);
|
||||
theme.SetStylebox("slider", "HSlider", track);
|
||||
theme.SetStylebox("grabber_area", "HSlider", Box(accent, 6, 0, 6));
|
||||
theme.SetStylebox("grabber_area_highlight", "HSlider", Box(accent.Lightened(0.2f), 6, 0, 6));
|
||||
theme.SetIcon("grabber", "HSlider", Circle(36, new Color(0.9f, 0.94f, 1f)));
|
||||
theme.SetIcon("grabber_highlight", "HSlider", Circle(36, Colors.White));
|
||||
theme.SetIcon("grabber_disabled", "HSlider", Circle(36, new Color(0.4f, 0.4f, 0.45f)));
|
||||
|
||||
// Wide scrollbar so it is touchable.
|
||||
theme.SetStylebox("scroll", "VScrollBar", Box(new Color(0.13f, 0.15f, 0.2f), 6, 9, 0));
|
||||
theme.SetStylebox("grabber", "VScrollBar", Box(new Color(0.3f, 0.36f, 0.48f), 6, 9, 0));
|
||||
theme.SetStylebox("grabber_highlight", "VScrollBar", Box(new Color(0.4f, 0.5f, 0.68f), 6, 9, 0));
|
||||
theme.SetStylebox("grabber_pressed", "VScrollBar", Box(accent, 6, 9, 0));
|
||||
|
||||
theme.SetColor("font_color", "Label", new Color(0.88f, 0.9f, 0.95f));
|
||||
return theme;
|
||||
}
|
||||
|
||||
private static ImageTexture Circle(int size, Color color)
|
||||
{
|
||||
Image image = Image.CreateEmpty(size, size, false, Image.Format.Rgba8);
|
||||
float r = size * 0.5f;
|
||||
for (int y = 0; y < size; y++)
|
||||
{
|
||||
for (int x = 0; x < size; x++)
|
||||
{
|
||||
float d = new Vector2(x + 0.5f - r, y + 0.5f - r).Length();
|
||||
float a = Mathf.Clamp(r - d, 0f, 1f); // 1 px anti-aliased edge
|
||||
image.SetPixel(x, y, new Color(color, a));
|
||||
}
|
||||
}
|
||||
return ImageTexture.CreateFromImage(image);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user