Files
VR-Mess/scripts/CragSpike.cs
T
FoxyzandClaude Opus 5.5 df7a2c5a89 Maps are editor-built scenes: [Tool] Playground/Crag, headless rebuild for agents
scenes/maps/playground.tscn and crag.tscn are generated by the map scripts' Rebuild (inspector
button, or godot-mono --headless --path . tools/rebuild_maps.tscn) and loaded as saved by the game,
so hand edits stick. The crag route is saved as Marker3D nodes and crag_spike checks the saved
scene. Layout verified identical to the code-built one (tools/map_dump.tscn).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 20:10:10 +03:00

169 lines
7.7 KiB
C#

using Godot;
using System.Collections.Generic;
// Regression for the crag (no player), checked on the SAVED scenes/maps/crag.tscn (route = its
// Route markers; rebuild with tools/rebuild_maps.tscn): the route is climbable on paper — consecutive route points
// within human reach, every point in free space, route pieces not buried in each other or the
// rock — the summit is high enough, and it's in plain view from spawn. Prints "CRAG: overall PASS".
public partial class CragSpike : Node3D
{
public const float MaxHoldToHold = 1.0f; // m between consecutive holds (two hands span the chimney)
public const float MaxHoldGain = 0.7f; // m of height gained per hand move
public const float MaxReachAboveFeet = 2.3f; // standing overhead grip height, 1.75 m person
public const float MaxReachOut = 1.0f;
public const float MaxMantle = 1.3f; // m a stand-to-stand step may rise (mantle / jump)
public const float MaxGap = 1.0f; // m horizontal between stand points across a rise or a gap
private Crag _crag;
private int _frame;
private readonly List<string> _fails = new();
public override void _Ready()
{
StaticBody3D floor = new();
floor.AddToGroup(PropGrabber.NoGrabGroup);
floor.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(80, 0.1f, 80) }, Position = new Vector3(0, -0.05f, 0) });
AddChild(floor);
// The SAVED scenes (as the game loads them, hand edits included), not a fresh rebuild.
AddChild(GD.Load<PackedScene>("res://scenes/maps/playground.tscn").Instantiate());
_crag = GD.Load<PackedScene>("res://scenes/maps/crag.tscn").Instantiate<Crag>();
AddChild(_crag);
}
public override void _PhysicsProcess(double delta)
{
if (++_frame < 3)
return;
PhysicsDirectSpaceState3D space = GetWorld3D().DirectSpaceState;
CheckReach();
CheckFreeSpace(space);
CheckWalks(space);
CheckOverlaps();
Check(Crag.SummitHeight >= 25f, $"summit {Crag.SummitHeight} m >= 25 m");
CheckView(space);
foreach (string f in _fails)
GD.Print("CRAG: FAIL " + f);
GD.Print($"CRAG: {_crag.Route.Count} route points, {_crag.RoutePieces.Count} route pieces, {_crag.MassPieces.Count} rock pieces, {_crag.GetChildCount()} nodes");
GD.Print(_fails.Count == 0 ? "CRAG: overall PASS" : $"CRAG: overall FAIL ({_fails.Count})");
GetTree().Quit();
}
private void Check(bool ok, string what)
{
if (!ok)
_fails.Add(what);
}
private static float Flat(Vector3 a, Vector3 b) => new Vector2(a.X - b.X, a.Z - b.Z).Length();
private void CheckReach()
{
var r = _crag.Route;
for (int i = 1; i < r.Count; i++)
{
var (ka, a, na) = r[i - 1];
var (kb, b, nb) = r[i];
string pair = $"'{na}' -> '{nb}'";
if (ka == Crag.Step.Hold && kb == Crag.Step.Hold)
{
Check(a.DistanceTo(b) <= MaxHoldToHold, $"{pair}: holds {a.DistanceTo(b):0.00} m apart");
Check(b.Y - a.Y <= MaxHoldGain, $"{pair}: hand move gains {b.Y - a.Y:0.00} m");
}
else if (ka == Crag.Step.Stand && kb == Crag.Step.Hold)
{
Check(b.Y - a.Y <= MaxReachAboveFeet && b.Y - a.Y >= -0.3f, $"{pair}: hold {b.Y - a.Y:0.00} m above feet");
Check(Flat(a, b) <= MaxReachOut, $"{pair}: hold {Flat(a, b):0.00} m out");
}
else if (ka == Crag.Step.Hold && kb == Crag.Step.Stand)
{
Check(b.Y <= a.Y + 0.3f, $"{pair}: stand {b.Y - a.Y:0.00} m above the last hold");
Check(Flat(a, b) <= MaxReachOut, $"{pair}: stand {Flat(a, b):0.00} m from the last hold");
}
else
{
float rise = b.Y - a.Y;
Check(rise <= MaxMantle, $"{pair}: rise {rise:0.00} m");
if (Mathf.Abs(rise) > 0.05f)
Check(Flat(a, b) <= MaxGap, $"{pair}: {Flat(a, b):0.00} m across a {rise:0.00} m step");
}
}
}
// Every route point is in the open; standing points have a body's worth of room above them
// and ground right under them.
private void CheckFreeSpace(PhysicsDirectSpaceState3D space)
{
foreach (var (kind, p, name) in _crag.Route)
{
var pq = new PhysicsPointQueryParameters3D { Position = p + (kind == Crag.Step.Stand ? Vector3.Up * 0.05f : Vector3.Zero) };
Check(space.IntersectPoint(pq, 1).Count == 0, $"'{name}' point is inside geometry");
if (kind != Crag.Step.Stand)
continue;
var box = new PhysicsShapeQueryParameters3D
{
Shape = new BoxShape3D { Size = new Vector3(0.3f, 1.2f, 0.3f) },
Transform = new Transform3D(Basis.Identity, p + Vector3.Up * 0.7f),
};
Check(space.IntersectShape(box, 1).Count == 0, $"'{name}' has no room to stand");
var ray = PhysicsRayQueryParameters3D.Create(p + Vector3.Up * 0.05f, p + Vector3.Down * 0.1f);
Check(space.IntersectRay(ray).Count > 0, $"'{name}' has nothing underfoot");
}
}
// Level stand-to-stand moves are walks: there is ground all along the way.
private void CheckWalks(PhysicsDirectSpaceState3D space)
{
var r = _crag.Route;
for (int i = 1; i < r.Count; i++)
{
if (r[i - 1].Kind != Crag.Step.Stand || r[i].Kind != Crag.Step.Stand || Mathf.Abs(r[i].Point.Y - r[i - 1].Point.Y) > 0.05f)
continue;
for (float t = 0.1f; t < 1f; t += 0.1f)
{
Vector3 p = r[i - 1].Point.Lerp(r[i].Point, t);
var ray = PhysicsRayQueryParameters3D.Create(p + Vector3.Up * 0.05f, p + Vector3.Down * 0.1f);
if (space.IntersectRay(ray).Count == 0)
{
_fails.Add($"walk '{r[i - 1].Name}' -> '{r[i].Name}': no ground at {p}");
break;
}
}
}
}
// No route piece is buried in another piece or in the rock (touching is fine).
private void CheckOverlaps()
{
var route = _crag.RoutePieces;
for (int i = 0; i < route.Count; i++)
{
for (int j = i + 1; j < route.Count; j++)
CheckPair(route[i], route[j]);
foreach (var m in _crag.MassPieces)
CheckPair(route[i], m);
}
}
private void CheckPair((CollisionObject3D Body, Aabb Box) a, (CollisionObject3D Body, Aabb Box) b)
{
if (!a.Box.Intersects(b.Box))
return;
Aabb i = a.Box.Intersection(b.Box);
float depth = Mathf.Min(i.Size.X, Mathf.Min(i.Size.Y, i.Size.Z));
if (depth > 0.01f)
_fails.Add($"{a.Body.Name} overlaps {b.Body.Name} by {depth:0.000} m");
}
// From the spawn point's eye height, the summit's front edge, both flags and the foot of the route are in view.
private void CheckView(PhysicsDirectSpaceState3D space)
{
Vector3 eye = new(0f, 1.7f, 2.5f);
foreach (Vector3 target in new[] { new Vector3(Crag.SummitPoint.X, Crag.SummitHeight - 0.1f, -15.95f), new Vector3(-5.6f, 1f, -9.6f), new Vector3(-2.7f, 6f, -12.8f), new Vector3(-5.3f, 10.6f, -13.7f), new Vector3(-4.3f, 18.6f, -15.2f) })
{
var hit = space.IntersectRay(PhysicsRayQueryParameters3D.Create(eye, target));
bool clear = hit.Count == 0 || ((Vector3)hit["position"]).DistanceTo(target) < 1f;
Check(clear, $"no line of sight from spawn to {target}" + (hit.Count > 0 ? $" (blocked at {(Vector3)hit["position"]})" : ""));
}
}
}