- Step rule + lower-body collider: no walking onto tables, steps <= 0.35 m - Structural hand strength: two-hand mantle/pull-up works, one hand can't - Arms aim from the physical shoulder horizontally, keep True Body pose vertically - Crawl pose, crawl carry, 35 deg slope limit, ground feed-forward ignores play-space corrections (fixes a runaway feedback loop) - Grab static world (climbing), procedural visual legs - Playground sandbox; menu strength slider, spectator (old drag) and turn toggles - Physics 144 Hz with interpolation; legacy chase-body spikes pin 90 Hz - RagdollSpike: 27 phases incl. mantle, pull-up, crawl, ledge drop, ramps Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
347 lines
15 KiB
C#
347 lines
15 KiB
C#
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<bool> SpectatorToggled;
|
||
public event Action<bool> SmoothTurnToggled;
|
||
public event Action<float> 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<bool> 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);
|
||
}
|
||
}
|