Files
VR-Mess/scripts/MenuSpike.cs
T
FoxyzandClaude Opus 5.5 2aff2b61da Body step 2.1: climbing, mantling, crawling, legs, playground, 144 Hz
- 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>
2026-10-02 07:56:08 +03:00

329 lines
14 KiB
C#

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, _hapticTicks;
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++;
_screen.Haptic += (_, _, _) => _hapticTicks++;
_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);
}
// Touch down at `from`, slide to `to` while in contact, lift. fling = a fast 4-frame slide.
private async Task TouchDrag(int hand, Vector2 from, Vector2 to, bool fling = false)
{
for (float d = 0.06f; d > -0.004f; d -= 0.004f)
{
TipAt(hand, from, d);
await Frame();
}
// A fling lifts while still moving (as a real flick does); a slow drag stops, then lifts.
int steps = fling ? 8 : 30;
for (int i = 1; i <= steps; i++)
{
float lift = fling ? Mathf.Max(0f, i - 4) * 0.008f : 0f;
TipAt(hand, from.Lerp(to, i / (float)steps), -0.004f + lift);
await Frame();
}
float start = fling ? -0.004f + (steps - 4) * 0.008f : -0.004f;
for (float d = start; d < 0.08f; d += 0.006f)
{
TipAt(hand, to, 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})");
// 6b) Touch swipe vs tap inside the scrollable log.
Button logButton = new() { Text = "in-log button", CustomMinimumSize = new Vector2(0, 48) };
int logButtonPresses = 0;
logButton.Pressed += () => logButtonPresses++;
_menu.LogScroll.GetChild(0).AddChild(logButton);
_menu.LogScroll.GetChild(0).MoveChild(logButton, 0);
_menu.LogScroll.ScrollVertical = 0;
await Frame(5);
int clicksBefore = _clicks;
await TouchDrag(1, logPx, logPx + new Vector2(0, -150));
int swiped = _menu.LogScroll.ScrollVertical;
Check("touch: swipe up scrolls the log", swiped >= 120, $"(scroll 0 → {swiped})");
Check("touch: swipe sends no click", _clicks == clicksBefore && logButtonPresses == 0, $"(clicks +{_clicks - clicksBefore})");
await TouchDrag(1, logPx, logPx + new Vector2(0, 80), fling: true);
await Frame(60);
int flung = _menu.LogScroll.ScrollVertical;
Check("touch: fast swipe down flings past the finger", flung < swiped - 60, $"({swiped} → {flung})");
_menu.LogScroll.ScrollVertical = 0;
await Frame(5);
Vector2 btnPx = Center(logButton);
await TouchDrag(1, btnPx, btnPx + new Vector2(6, 5));
Check("touch: small-jitter tap on in-log button clicks", logButtonPresses == 1 && _menu.LogScroll.ScrollVertical == 0,
$"(presses={logButtonPresses}, scroll={_menu.LogScroll.ScrollVertical})");
VScrollBar vbar = _menu.LogScroll.GetVScrollBar();
Rect2 br = vbar.GetGlobalRect();
await TouchDrag(1, new Vector2(br.GetCenter().X, br.Position.Y + 10), new Vector2(br.GetCenter().X, br.End.Y - 10));
Check("touch: scrollbar still drags directly", _menu.LogScroll.ScrollVertical > 100, $"(scroll={_menu.LogScroll.ScrollVertical})");
// 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})");
// 7b) Player-page toggles: spectator and smooth turn fire with the new state.
bool? spectator = null, smooth = null;
_menu.SpectatorToggled += on => spectator = on;
_menu.SmoothTurnToggled += on => smooth = on;
await TouchPress(1, TabCenter(0));
await TouchPress(1, Center(_menu.SpectatorButton));
await TouchPress(1, Center(_menu.TurnModeButton));
await Frame(3);
Check("touch: spectator toggled on", spectator == true, $"({spectator})");
Check("touch: smooth turn toggled on", smooth == true, $"({smooth})");
await TouchPress(1, Center(_menu.SpectatorButton));
await Frame(3);
Check("touch: spectator toggled back off", spectator == false, $"({spectator})");
// 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})");
Check("haptic ticks fired (hover/release/detents)", _hapticTicks >= 30, $"(ticks={_hapticTicks})");
GD.Print($"MENU: overall {(_allPass ? "PASS" : "FAIL")}");
GetTree().Quit(_allPass ? 0 : 1);
}
}