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 event Action SpectatorToggled; public event Action SmoothTurnToggled; public event Action StrengthChanged; 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 HSlider StrengthSlider { get; private set; } public Button SpectatorButton { get; private set; } public Button TurnModeButton { 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); // Nimso's single strength value: every muscle and the hand guides scale with it. HBoxContainer strengthRow = new(); strengthRow.AddThemeConstantOverride("separation", 20); Label strengthLabel = new() { Text = "Strength 1.00×", VerticalAlignment = VerticalAlignment.Center, CustomMinimumSize = new Vector2(220, 0) }; strengthRow.AddChild(strengthLabel); StrengthSlider = new HSlider { MinValue = 0.25, MaxValue = 3, Step = 0.05, Value = 1, SizeFlagsHorizontal = SizeFlags.ExpandFill, SizeFlagsVertical = SizeFlags.ShrinkCenter, CustomMinimumSize = new Vector2(0, 48), FocusMode = FocusModeEnum.None, }; StrengthSlider.ValueChanged += v => { strengthLabel.Text = $"Strength {v:0.00}×"; StrengthChanged?.Invoke((float)v); }; strengthRow.AddChild(StrengthSlider); player.AddChild(strengthRow); HBoxContainer modeRow = new(); modeRow.AddThemeConstantOverride("separation", 16); TurnModeButton = MakeToggle("Turning: snap", "Turning: smooth", on => SmoothTurnToggled?.Invoke(on)); modeRow.AddChild(TurnModeButton); // Spectator: no body; fly around by grabbing the air (the old drag movement). SpectatorButton = MakeToggle("Spectator: off", "Spectator: on (grab-fly)", on => SpectatorToggled?.Invoke(on)); modeRow.AddChild(SpectatorButton); player.AddChild(modeRow); 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 (swipe or thumbstick to scroll)" }; 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 MakeToggle(string offText, string onText, Action changed) { Button button = new() { Text = offText, ToggleMode = true, CustomMinimumSize = new Vector2(0, ButtonHeight), SizeFlagsHorizontal = SizeFlags.ExpandFill, FocusMode = FocusModeEnum.None, }; button.Toggled += on => { button.Text = on ? onText : offText; Clicked?.Invoke(); Callable.From(() => changed(on)).CallDeferred(); }; return button; } 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), 8, 14, 0)); theme.SetStylebox("grabber", "VScrollBar", Box(new Color(0.3f, 0.36f, 0.48f), 8, 14, 0)); theme.SetStylebox("grabber_highlight", "VScrollBar", Box(new Color(0.4f, 0.5f, 0.68f), 8, 14, 0)); theme.SetStylebox("grabber_pressed", "VScrollBar", Box(accent, 8, 14, 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); } }