Merge branch 'worktree-agent-a07f1b74ffe22c320'

# Conflicts:
#	HANDOFF.md
This commit is contained in:
Foxyz committed 2026-10-02 05:52:00 +03:00
commit b5e7241ac1
11 files changed
+1281 -315

No files matched your search

+12 -5
View File
@@ -7,6 +7,7 @@ public partial class Locomotion : Node
private const float RayLength = 50f;
private const float LaserMinDistance = 0.35f;
private const float MarkerNearDistance = 1.2f;
private const float LaserStart = 0.12f;
private XROrigin3D _origin;
private XRCamera3D _camera;
@@ -78,7 +79,7 @@ public partial class Locomotion : Node
Node3D pivot = new() { Visible = false };
pivot.AddChild(beam);
Basis rot = Basis.FromEuler(new Vector3(-Mathf.Pi / 2f, 0, 0));
pivot.Transform = new Transform3D(rot, new Vector3(0, 0, -0.12f));
pivot.Transform = new Transform3D(rot, new Vector3(0, 0, -LaserStart));
controller.AddChild(pivot);
return pivot;
}
@@ -127,13 +128,19 @@ public partial class Locomotion : Node
bool show = false;
float dist = 0f;
if (hit.TryGetValue("collider", out Variant collider) && collider.As<Node>() is Node node)
if (WorldPanel.TryGetLaserDistance(controller, out float panelDist))
{
// The menu decides touch vs laser itself (tip near the surface = touch, no beam).
dist = panelDist;
show = dist > LaserMinDistance;
}
else if (hit.TryGetValue("collider", out Variant collider) && collider.As<Node>() is Node node)
{
bool interactive;
if (node.GetParent() is TeleportMarker marker)
interactive = !marker.IsNear;
else
interactive = WorldPanel.IsPanelCollider(node);
interactive = false; // panel hit but the menu isn't in laser mode for this hand
if (interactive && hit.TryGetValue("position", out Variant posVariant))
{
dist = from.DistanceTo(posVariant.As<Vector3>());
@@ -142,8 +149,8 @@ public partial class Locomotion : Node
}
laser.Visible = show;
if (show)
laser.Scale = new Vector3(1, dist, 1);
if (show) // beam starts at the stylus tip (LaserStart ahead of the controller)
laser.Scale = new Vector3(1, Mathf.Max(0.01f, dist - LaserStart), 1);
}
private void CheckTeleport(XRController3D controller, ref bool wasPressed)
+288
View File
@@ -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);
}
}
+1
View File
@@ -0,0 +1 @@
uid://dv7mp0go4oxof
+467
View File
@@ -0,0 +1,467 @@
using Godot;
using System;
// A thin 3D panel showing Godot Control UI rendered into a SubViewport (the project's ONE
// SubViewport screen, see AGENTS.md), plus the touch-first hybrid cursor that drives it.
// Input-agnostic: callers feed per-hand rays (origin + forward dir), trigger and thumbstick;
// the screen projects them onto its plane and injects InputEventMouseMotion/MouseButton into
// the SubViewport via Viewport.PushInput(event, in_local_coords: true).
//
// Cursor rules per hand (panel-local frame: X right, Y up, +Z = front normal, origin = center):
// TOUCH — the stylus tip (origin + dir * TipLength) is within HoverDepth in front of the plane
// (or pushed up to PushThroughDepth behind it) and over the panel: the tip's
// projection is the cursor. Crossing ContactDepth from the front presses, backing out
// past ReleaseDepth releases (hysteresis; entering from the side/behind never presses).
// LASER — otherwise, the ray hits the panel from the front: the hit is the cursor and the
// trigger (edge) clicks; holding the trigger drags (sliders).
// One Godot mouse exists per viewport, so one hand owns it: the hand holding a press keeps it,
// else a touching hand beats a laser hand, else the previous owner is preferred.
public partial class MenuScreen : Node3D
{
public const float TipLength = 0.12f;
public const float HoverDepth = 0.06f;
public const float ContactDepth = 0.006f;
public const float ReleaseDepth = 0.018f;
public const float PushThroughDepth = 0.08f;
public const float TouchEdgeMargin = 0.015f;
public const float ScrollDeadzone = 0.3f;
public const float ScrollNotchesPerSecond = 14f;
public enum PointerMode { None, Touch, Laser }
public struct PointerInput
{
public bool Valid;
public Vector3 Origin;
public Vector3 Direction;
public bool Trigger;
public Vector2 Stick;
}
private struct HandState
{
public PointerMode Mode;
public Vector3 Local;
public Vector2 Pixel;
public float Depth;
public float RayDistance;
public bool Armed;
public bool TriggerPrev;
public bool WantDown;
}
public Vector2 SizeMeters { get; }
public Vector2I SizePixels { get; }
public SubViewport Viewport { get; private set; }
public StaticBody3D Body { get; private set; }
// Fired when a hand presses down on the UI (touch contact or trigger) — for haptics.
public event Action<int, PointerMode> PointerPressed;
private readonly HandState[] _hands = new HandState[2];
private readonly MeshInstance3D[] _dots = new MeshInstance3D[2];
private readonly StandardMaterial3D[] _dotMaterials = new StandardMaterial3D[2];
private int _owner = -1;
private bool _down;
private bool _mouseInside;
private Vector2 _lastSent = new(-1, -1);
private float _scrollAccum;
private StandardMaterial3D _screenMaterial;
public MenuScreen() : this(new Vector2(0.36f, 0.24f), new Vector2I(960, 640)) { }
public MenuScreen(Vector2 sizeMeters, Vector2I sizePixels)
{
SizeMeters = sizeMeters;
SizePixels = sizePixels;
Build();
}
public override void _Ready()
{
// ViewportTexture is fetched once the viewport is inside the tree (docs: may return
// incorrect data before the scene root is ready).
_screenMaterial.AlbedoTexture = Viewport.GetTexture();
}
public PointerMode GetMode(int hand) => _hands[hand].Mode;
public bool IsPressing(int hand) => _owner == hand && _down;
public Vector2 GetPixel(int hand) => _hands[hand].Pixel;
public bool TryGetLaserDistance(int hand, out float distance)
{
distance = _hands[hand].RayDistance;
return Visible && _hands[hand].Mode == PointerMode.Laser;
}
public Vector2 LocalToPixel(Vector3 local) => new(
(local.X / SizeMeters.X + 0.5f) * SizePixels.X,
(0.5f - local.Y / SizeMeters.Y) * SizePixels.Y);
public Vector3 PixelToLocal(Vector2 pixel) => new(
(pixel.X / SizePixels.X - 0.5f) * SizeMeters.X,
(0.5f - pixel.Y / SizePixels.Y) * SizeMeters.Y,
0f);
public Vector3 PixelToGlobal(Vector2 pixel) => GlobalTransform * PixelToLocal(pixel);
public void SetContent(Control content)
{
content.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
Viewport.AddChild(content);
}
public void SetActive(bool active)
{
if (!active)
ReleaseAll();
Visible = active;
Viewport.RenderTargetUpdateMode = active ? SubViewport.UpdateMode.Always : SubViewport.UpdateMode.Disabled;
foreach (Node child in Body.GetChildren())
if (child is CollisionShape3D shape)
shape.SetDeferred(CollisionShape3D.PropertyName.Disabled, !active);
}
public void ReleaseAll()
{
if (_owner >= 0 && _down)
SendButton(_lastSent, false);
_down = false;
_owner = -1;
if (_mouseInside)
{
Viewport.NotifyMouseExited();
_mouseInside = false;
}
_scrollAccum = 0f;
for (int i = 0; i < 2; i++)
{
// TriggerPrev survives: a trigger still held across a reset must not re-click.
_hands[i] = new HandState { TriggerPrev = _hands[i].TriggerPrev };
if (_dots[i] != null)
_dots[i].Visible = false;
}
}
private void Build()
{
Viewport = new SubViewport
{
Name = "MenuViewport",
Size = SizePixels,
Disable3D = true,
TransparentBg = false,
GuiEmbedSubwindows = true,
HandleInputLocally = true,
Msaa2D = Godot.Viewport.Msaa.Msaa4X,
RenderTargetUpdateMode = SubViewport.UpdateMode.Disabled,
};
AddChild(Viewport);
// Screen quad with explicit UVs so the pixel mapping above is ours, not QuadMesh's.
float hx = SizeMeters.X * 0.5f, hy = SizeMeters.Y * 0.5f;
var st = new SurfaceTool();
st.Begin(Mesh.PrimitiveType.Triangles);
Vector3[] corners = { new(-hx, hy, 0), new(hx, hy, 0), new(hx, -hy, 0), new(-hx, -hy, 0) };
Vector2[] uvs = { new(0, 0), new(1, 0), new(1, 1), new(0, 1) };
int[] order = { 0, 1, 2, 0, 2, 3 };
foreach (int i in order)
{
st.SetNormal(Vector3.Back);
st.SetUV(uvs[i]);
st.AddVertex(corners[i]);
}
_screenMaterial = new StandardMaterial3D
{
ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded,
TextureFilter = BaseMaterial3D.TextureFilterEnum.Linear,
CullMode = BaseMaterial3D.CullModeEnum.Disabled,
};
MeshInstance3D screen = new()
{
Name = "Screen",
Mesh = st.Commit(),
CastShadow = GeometryInstance3D.ShadowCastingSetting.Off,
};
screen.SetSurfaceOverrideMaterial(0, _screenMaterial);
AddChild(screen);
// Thin backing frame (hides the mirrored back side, gives the panel an edge).
StandardMaterial3D frameMaterial = new() { AlbedoColor = new Color(0.05f, 0.06f, 0.08f) };
MeshInstance3D frame = new()
{
Name = "Frame",
Mesh = new BoxMesh { Size = new Vector3(SizeMeters.X + 0.012f, SizeMeters.Y + 0.012f, 0.008f) },
Position = new Vector3(0, 0, -0.0045f),
CastShadow = GeometryInstance3D.ShadowCastingSetting.Off,
};
frame.SetSurfaceOverrideMaterial(0, frameMaterial);
AddChild(frame);
// UI layer 2, mask 0: lasers/raycasts see it, props pass through it.
Body = new StaticBody3D { Name = "PanelBody", CollisionLayer = 2, CollisionMask = 0 };
Body.AddChild(new CollisionShape3D
{
Shape = new BoxShape3D { Size = new Vector3(SizeMeters.X, SizeMeters.Y, 0.02f) },
Position = new Vector3(0, 0, -0.005f),
});
AddChild(Body);
for (int i = 0; i < 2; i++)
{
_dotMaterials[i] = new StandardMaterial3D
{
ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded,
AlbedoColor = Colors.White,
NoDepthTest = true,
RenderPriority = 1,
};
_dots[i] = new MeshInstance3D
{
Name = $"Cursor{i}",
Mesh = new SphereMesh { Radius = 0.004f, Height = 0.008f, RadialSegments = 12, Rings = 6 },
CastShadow = GeometryInstance3D.ShadowCastingSetting.Off,
Visible = false,
};
_dots[i].SetSurfaceOverrideMaterial(0, _dotMaterials[i]);
AddChild(_dots[i]);
}
}
// Call once per frame with both hands' input (index 0 = left, 1 = right).
public void UpdatePointers(PointerInput[] inputs, double delta)
{
if (!Visible)
{
// Keep trigger edges current while hidden so a held trigger can't click on summon.
for (int hand = 0; hand < 2 && hand < inputs.Length; hand++)
_hands[hand].TriggerPrev = inputs[hand].Trigger;
return;
}
for (int hand = 0; hand < 2; hand++)
Classify(hand, hand < inputs.Length ? inputs[hand] : default);
int owner = PickOwner();
if (owner != _owner)
{
if (_owner >= 0 && _down)
{
SendButton(_lastSent, false);
_down = false;
}
_owner = owner;
_scrollAccum = 0f;
}
if (_owner < 0)
{
if (_mouseInside)
{
Viewport.NotifyMouseExited();
_mouseInside = false;
}
}
else
{
HandState h = _hands[_owner];
if (!_mouseInside)
{
Viewport.NotifyMouseEntered();
_mouseInside = true;
}
if (h.Pixel != _lastSent)
SendMotion(h.Pixel);
int current = _owner;
if (h.WantDown != _down)
{
_down = h.WantDown;
SendButton(h.Pixel, _down);
if (_down)
PointerPressed?.Invoke(current, h.Mode);
}
// A UI handler may have reset us (ReleaseAll) during the injected event.
if (_owner == current && current < inputs.Length)
Scroll(inputs[current].Stick, h.Pixel, (float)delta);
}
UpdateDots();
}
private void Classify(int hand, PointerInput input)
{
HandState h = _hands[hand];
PointerMode mode = PointerMode.None;
bool wasOwnerDown = _owner == hand && _down;
if (input.Valid && input.Direction.LengthSquared() > 1e-6f)
{
Transform3D inv = GlobalTransform.AffineInverse();
Vector3 from = inv * input.Origin;
Vector3 dir = (inv.Basis * input.Direction).Normalized();
Vector3 tip = from + dir * TipLength;
float hx = SizeMeters.X * 0.5f, hy = SizeMeters.Y * 0.5f;
bool tipOver = Mathf.Abs(tip.X) <= hx + TouchEdgeMargin && Mathf.Abs(tip.Y) <= hy + TouchEdgeMargin;
if (from.Z > 0f && tipOver && tip.Z <= HoverDepth && tip.Z >= -PushThroughDepth)
{
mode = PointerMode.Touch;
h.Local = new Vector3(Mathf.Clamp(tip.X, -hx, hx), Mathf.Clamp(tip.Y, -hy, hy), 0f);
h.Depth = tip.Z;
h.RayDistance = 0f;
}
else if (from.Z > 0f && dir.Z < -1e-4f)
{
float t = -from.Z / dir.Z;
Vector3 hit = from + dir * t;
if (Mathf.Abs(hit.X) <= hx && Mathf.Abs(hit.Y) <= hy)
{
mode = PointerMode.Laser;
h.Local = hit;
h.Depth = float.MaxValue;
h.RayDistance = t;
}
}
}
h.Mode = mode;
if (mode != PointerMode.None)
{
Vector2 px = LocalToPixel(h.Local);
h.Pixel = new Vector2(
Mathf.Clamp(px.X, 0f, SizePixels.X - 1),
Mathf.Clamp(px.Y, 0f, SizePixels.Y - 1));
}
switch (mode)
{
case PointerMode.Touch:
h.WantDown = wasOwnerDown ? h.Depth <= ReleaseDepth : h.Armed && h.Depth <= ContactDepth;
break;
case PointerMode.Laser:
h.WantDown = wasOwnerDown ? input.Trigger : input.Trigger && !h.TriggerPrev;
break;
default:
h.WantDown = false;
break;
}
h.Armed = mode == PointerMode.Touch && h.Depth > ContactDepth;
h.TriggerPrev = input.Trigger;
_hands[hand] = h;
}
private int PickOwner()
{
if (_owner >= 0 && _down && _hands[_owner].Mode != PointerMode.None)
return _owner;
int best = -1;
for (int hand = 0; hand < 2; hand++)
{
if (_hands[hand].Mode != PointerMode.Touch)
continue;
if (best < 0 || _hands[hand].Depth < _hands[best].Depth)
best = hand;
}
if (best >= 0)
return best;
if (_owner >= 0 && _hands[_owner].Mode == PointerMode.Laser)
return _owner;
for (int hand = 0; hand < 2; hand++)
if (_hands[hand].Mode == PointerMode.Laser)
return hand;
return -1;
}
private void Scroll(Vector2 stick, Vector2 pixel, float delta)
{
if (Mathf.Abs(stick.Y) < ScrollDeadzone)
{
_scrollAccum = 0f;
return;
}
_scrollAccum += stick.Y * ScrollNotchesPerSecond * delta;
while (Mathf.Abs(_scrollAccum) >= 1f)
{
bool up = _scrollAccum > 0f;
_scrollAccum -= up ? 1f : -1f;
SendWheel(pixel, up ? MouseButton.WheelUp : MouseButton.WheelDown);
}
}
public void SendWheel(Vector2 pixel, MouseButton wheel)
{
foreach (bool pressed in new[] { true, false })
{
Viewport.PushInput(new InputEventMouseButton
{
Position = pixel,
GlobalPosition = pixel,
ButtonIndex = wheel,
Pressed = pressed,
Factor = 1f,
ButtonMask = _down ? MouseButtonMask.Left : 0,
}, true);
}
}
private void SendMotion(Vector2 pixel)
{
Vector2 relative = _lastSent.X < 0 ? Vector2.Zero : pixel - _lastSent;
Viewport.PushInput(new InputEventMouseMotion
{
Position = pixel,
GlobalPosition = pixel,
Relative = relative,
ButtonMask = _down ? MouseButtonMask.Left : 0,
}, true);
_lastSent = pixel;
}
private void SendButton(Vector2 pixel, bool pressed)
{
Viewport.PushInput(new InputEventMouseButton
{
Position = pixel,
GlobalPosition = pixel,
ButtonIndex = MouseButton.Left,
Pressed = pressed,
ButtonMask = pressed ? MouseButtonMask.Left : 0,
}, true);
_lastSent = pixel;
}
private void UpdateDots()
{
for (int hand = 0; hand < 2; hand++)
{
HandState h = _hands[hand];
MeshInstance3D dot = _dots[hand];
dot.Visible = h.Mode != PointerMode.None;
if (!dot.Visible)
continue;
bool pressing = _owner == hand && _down;
float scale;
Color color;
if (h.Mode == PointerMode.Touch)
{
// Shrinks as the tip approaches the surface: a proximity cue for touch.
float k = Mathf.Clamp(h.Depth / HoverDepth, 0f, 1f);
scale = Mathf.Lerp(0.6f, 1.6f, k);
color = pressing ? new Color(0.5f, 1f, 0.6f) : new Color(0.55f, 0.85f, 1f);
}
else
{
scale = 1f;
color = pressing ? new Color(1f, 0.85f, 0.4f) : new Color(1f, 0.55f, 0.15f);
}
if (_owner != hand)
color.A = 0.5f;
_dotMaterials[hand].AlbedoColor = color;
_dotMaterials[hand].Transparency = color.A < 1f
? BaseMaterial3D.TransparencyEnum.Alpha
: BaseMaterial3D.TransparencyEnum.Disabled;
dot.Position = new Vector3(h.Local.X, h.Local.Y, 0.002f);
dot.Scale = Vector3.One * scale;
}
}
}
+1
View File
@@ -0,0 +1 @@
uid://cnjnswvcuecyf
+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);
}
}
+1
View File
@@ -0,0 +1 @@
uid://bybf7naa4ron3
+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();
}
}