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>
This commit is contained in:
FoxyzandClaude Opus 5.5 committed 2026-10-02 20:10:10 +03:00
1 parent 3e300c7f04
commit df7a2c5a89
17 files changed
+6863 -65

No files matched your search

+89 -26
View File
@@ -12,11 +12,20 @@ using System.Collections.Generic;
// 6. a 0.9 m chimney with holds on both walls up the third tier;
// 7. the summit (26 m) with throwable props. Way down: aim at the "Crag" teleport marker at the
// base (or jump — no fall damage).
// The cliff faces south (+Z) toward spawn. Every route point is recorded in Route so crag_spike
// can check reach; route bodies in RoutePieces so it can check nothing intersects them.
// The cliff faces south (+Z) toward spawn. EDITOR-BUILT: this is the root of scenes/maps/crag.tscn
// and Rebuild() generates everything into that scene (MapScene: inspector button, or
// tools/rebuild_maps.tscn headless); the game loads the saved scene. The route is saved too: every
// route point is a Marker3D under "Route" (metadata kind/label, in climbing order), route bodies are in
// group "crag_route" and rock in "crag_mass" — Route/RoutePieces/MassPieces are read back from the
// saved nodes on _Ready, so crag_spike checks the scene as saved (and as hand-edited).
[Tool]
public partial class Crag : Node3D
{
[ExportToolButton("Rebuild (replaces all children)")]
public Callable RebuildButton => Callable.From(RebuildInEditor);
public enum Step { Stand, Hold }
public const string RouteNode = "Route", RouteGroup = "crag_route", MassGroup = "crag_mass";
public readonly List<(Step Kind, Vector3 Point, string Name)> Route = new();
public readonly List<(CollisionObject3D Body, Aabb Box)> RoutePieces = new();
public readonly List<(CollisionObject3D Body, Aabb Box)> MassPieces = new();
@@ -37,25 +46,76 @@ public partial class Crag : Node3D
public override void _Ready()
{
if (Engine.IsEditorHint())
return;
MapScene.CollectProps(this, _dynamic);
ReadLayout();
}
public void Reset() => MapScene.Reset(_dynamic);
private void RebuildInEditor()
{
if (!MapScene.CanRebuildInEditor(this))
return;
Rebuild();
MapScene.MarkUnsaved();
}
// Replaces every child with the generated layout, owned by this map (saved with its scene).
public void Rebuild()
{
MapScene.Clear(this);
_rng.Seed = 26; // same rubble/talus every rebuild
_route = new Node3D { Name = RouteNode };
AddChild(_route);
BuildMass();
BuildRoute();
BuildBase();
BuildSummitProps();
MapScene.Adopt(this);
ReadLayout();
}
public void Reset()
// Route points and piece bounds from the (saved) nodes.
public void ReadLayout()
{
foreach (var (body, start) in _dynamic)
Route.Clear();
RoutePieces.Clear();
MassPieces.Clear();
if (GetNodeOrNull<Node>(RouteNode) is Node route)
foreach (Node child in route.GetChildren())
if (child is Marker3D m)
Route.Add(((Step)(int)m.GetMeta("kind"), m.GlobalPosition, (string)m.GetMeta("label")));
foreach (Node child in GetChildren())
{
if (!IsInstanceValid(body))
if (child is not CollisionObject3D body)
continue;
body.GlobalTransform = start;
body.LinearVelocity = Vector3.Zero;
body.AngularVelocity = Vector3.Zero;
body.ResetPhysicsInterpolation();
if (body.IsInGroup(RouteGroup))
RoutePieces.Add((body, Bounds(body)));
else if (body.IsInGroup(MassGroup))
MassPieces.Add((body, Bounds(body)));
}
}
// World bounds of a box or a cylinder piece (a cylinder's ends are flat: radius across its axis only).
private static Aabb Bounds(CollisionObject3D body)
{
CollisionShape3D cs = body.GetChildOrNull<CollisionShape3D>(0);
Transform3D t = body.GlobalTransform * cs.Transform;
if (cs.Shape is CylinderShape3D cyl)
{
Vector3 from = t * new Vector3(0f, -cyl.Height * 0.5f, 0f), to = t * new Vector3(0f, cyl.Height * 0.5f, 0f);
Vector3 d = (to - from).Normalized(), r = new(
cyl.Radius * Mathf.Sqrt(Mathf.Max(0f, 1f - d.X * d.X)), cyl.Radius * Mathf.Sqrt(Mathf.Max(0f, 1f - d.Y * d.Y)), cyl.Radius * Mathf.Sqrt(Mathf.Max(0f, 1f - d.Z * d.Z)));
return new Aabb(from - r, Vector3.Zero).Expand(from + r).Expand(to - r).Expand(to + r);
}
Vector3 size = ((BoxShape3D)cs.Shape).Size;
return t * new Aabb(-size * 0.5f, size);
}
private Node3D _route;
// ---------------------------------------------------------------- the rock
private void BuildMass()
@@ -241,8 +301,9 @@ public partial class Crag : Node3D
// The way down: a teleport marker at the base (aim at it from the summit).
Node3D marker = GD.Load<PackedScene>("res://scenes/teleport_marker.tscn").Instantiate<Node3D>();
marker.Set("MarkerLabel", "Crag");
marker.Name = "CragMarker";
marker.Position = new Vector3(1.5f, 0f, -9.5f);
AddChild(marker);
AddChild(marker, true);
}
private void BuildSummitProps()
@@ -258,45 +319,47 @@ public partial class Crag : Node3D
// ---------------------------------------------------------------- builders
private void AddStep(Step kind, Vector3 point, string name) => Route.Add((kind, point, name));
// A route point: an editor-visible Marker3D under "Route", in climbing order.
private void AddStep(Step kind, Vector3 point, string name)
{
Marker3D m = new() { Name = kind == Step.Hold ? "Hold" : "Stand", Position = point };
m.SetMeta("kind", (int)kind);
m.SetMeta("label", name);
_route.AddChild(m, true);
}
private StaticBody3D Mass(string name, Vector3 size, Vector3 center, Color color, Basis? basis = null)
{
StaticBody3D b = Block(name, size, center, color, true, basis);
MassPieces.Add((b, BoxAabb(size, b.Transform)));
b.AddToGroup(MassGroup, true);
return b;
}
private StaticBody3D RouteBlock(string name, Vector3 size, Vector3 center, Color color, bool castShadow = true)
{
StaticBody3D b = Block(name, size, center, color, castShadow);
RoutePieces.Add((b, BoxAabb(size, b.Transform)));
b.AddToGroup(RouteGroup, true);
return b;
}
private StaticBody3D RouteBar(string name, Vector3 from, Vector3 to, float radius, Color color)
{
StaticBody3D b = Bar(name, from, to, radius, color);
// Bounds of the cylinder: radius across the axis only (the ends are flat).
Vector3 d = (to - from).Normalized(), r = new Vector3(
radius * Mathf.Sqrt(Mathf.Max(0f, 1f - d.X * d.X)), radius * Mathf.Sqrt(Mathf.Max(0f, 1f - d.Y * d.Y)), radius * Mathf.Sqrt(Mathf.Max(0f, 1f - d.Z * d.Z)));
RoutePieces.Add((b, new Aabb(from - r, Vector3.Zero).Expand(from + r).Expand(to - r).Expand(to + r)));
b.AddToGroup(RouteGroup, true);
return b;
}
private static Aabb BoxAabb(Vector3 size, Transform3D t) => t * new Aabb(-size * 0.5f, size);
private StaticBody3D Block(string name, Vector3 size, Vector3 center, Color color, bool castShadow, Basis? basis = null, bool grab = true)
{
StaticBody3D body = new() { Name = name, CollisionLayer = WorldLayer, CollisionMask = WorldMask, Transform = new Transform3D(basis ?? Basis.Identity, center) };
if (!grab)
body.AddToGroup(PropGrabber.NoGrabGroup);
body.AddToGroup(PropGrabber.NoGrabGroup, true);
body.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = size } });
MeshInstance3D mesh = Mesh(new BoxMesh { Size = size }, color);
if (!castShadow)
mesh.CastShadow = GeometryInstance3D.ShadowCastingSetting.Off;
body.AddChild(mesh);
AddChild(body);
AddChild(body, true);
return body;
}
@@ -309,7 +372,7 @@ public partial class Crag : Node3D
StaticBody3D body = new() { Name = name, CollisionLayer = WorldLayer, CollisionMask = WorldMask, Transform = new Transform3D(new Basis(x, y, x.Cross(y)), (from + to) * 0.5f) };
body.AddChild(new CollisionShape3D { Shape = new CylinderShape3D { Radius = radius, Height = len } });
body.AddChild(Mesh(new CylinderMesh { TopRadius = radius, BottomRadius = radius, Height = len, RadialSegments = 12 }, color));
AddChild(body);
AddChild(body, true);
return body;
}
@@ -325,11 +388,10 @@ public partial class Crag : Node3D
MaxContactsReported = 4,
PhysicsMaterialOverride = new PhysicsMaterial { Friction = 0.8f, Bounce = 0.05f },
};
body.AddToGroup("prop");
body.AddToGroup(MapScene.PropGroup, true);
body.AddChild(new CollisionShape3D { Shape = shape });
body.AddChild(Mesh(mesh, color));
AddChild(body);
_dynamic.Add((body, body.GlobalTransform));
AddChild(body, true);
return body;
}
@@ -347,12 +409,13 @@ public partial class Crag : Node3D
{
AddChild(new Label3D
{
Name = "Sign",
Text = text,
FontSize = 96,
PixelSize = 0.002f * scale,
OutlineSize = 12,
Billboard = BaseMaterial3D.BillboardModeEnum.FixedY,
Position = at,
});
}, true);
}
}
+5 -3
View File
@@ -1,7 +1,8 @@
using Godot;
using System.Collections.Generic;
// Regression for the crag (no player): the route is climbable on paper — consecutive route points
// 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
@@ -23,8 +24,9 @@ public partial class CragSpike : Node3D
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);
AddChild(new Playground());
_crag = new Crag();
// 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);
}
+80
View File
@@ -0,0 +1,80 @@
using Godot;
using System.Collections.Generic;
// Shared plumbing for the editor-built maps (Playground, Crag). Each map is the ROOT of its own scene
// (scenes/maps/*.tscn) with a [Tool] script whose Rebuild() regenerates every child from code and
// gives it to the scene (Owner = the map root) so it is saved into the .tscn like hand-made nodes.
// The game never regenerates: it loads what was saved, so edits made in the editor stick (until the
// next Rebuild, which replaces ALL children). Rebuild from the inspector button with the map scene
// open, or headless for agents: godot-mono --headless --path . tools/rebuild_maps.tscn
public static class MapScene
{
public const string PropGroup = "prop";
// The inspector button only works with the map scene itself open (nodes owned by an instanced
// sub-scene's root aren't saved into the outer scene).
public static bool CanRebuildInEditor(Node map)
{
if (map.GetTree()?.EditedSceneRoot == map)
return true;
GD.PushWarning($"{map.Name}: open its own scene (scenes/maps/) to rebuild it — it's instanced here.");
return false;
}
public static void Clear(Node map)
{
foreach (Node child in map.GetChildren())
{
map.RemoveChild(child);
child.Free();
}
}
// Every generated node belongs to the map scene; children of instanced sub-scenes (e.g. a
// teleport marker) keep their own owner.
public static void Adopt(Node map)
{
foreach (Node child in map.GetChildren())
Adopt(map, child);
}
private static void Adopt(Node root, Node node)
{
if (node.Owner == null)
node.Owner = root;
if (node.SceneFilePath != "")
return;
foreach (Node child in node.GetChildren())
Adopt(root, child);
}
public static void MarkUnsaved()
{
#if TOOLS
if (Engine.IsEditorHint())
EditorInterface.Singleton.MarkSceneAsUnsaved();
#endif
}
// Runtime: the dynamic props (group "prop") and where they start, for Reset().
public static void CollectProps(Node map, List<(RigidBody3D Body, Transform3D Start)> props)
{
props.Clear();
foreach (Node child in map.GetChildren())
if (child is RigidBody3D body && body.IsInGroup(PropGroup))
props.Add((body, body.GlobalTransform));
}
public static void Reset(List<(RigidBody3D Body, Transform3D Start)> props)
{
foreach (var (body, start) in props)
{
if (!GodotObject.IsInstanceValid(body))
continue;
body.GlobalTransform = start;
body.LinearVelocity = Vector3.Zero;
body.AngularVelocity = Vector3.Zero;
body.ResetPhysicsInterpolation();
}
}
}
+1
View File
@@ -0,0 +1 @@
uid://d0oo5qx6v57bs
+42 -26
View File
@@ -1,7 +1,9 @@
using Godot;
using System.Collections.Generic;
// The body-physics sandbox (Nimso-sandbox style), built in code around the spawn/table area:
// The body-physics sandbox (Nimso-sandbox style) around the spawn/table area. EDITOR-BUILT: this is the
// root of scenes/maps/playground.tscn and Rebuild() generates the pieces below into that scene
// (MapScene: inspector button, or tools/rebuild_maps.tscn headless); the game loads the saved scene.
// EAST — mantling ledges (0.3 … 1.5 m), a 1 m platform with stairs, a walkable 18° ramp and a
// too-steep 40° one, a climbing wall with holds onto a 2.4 m top, a pull-up bar, monkey bars.
// WEST — physics toys: a plank bridge on two blocks (walk on it / knock it off), loose planks,
@@ -10,8 +12,12 @@ using System.Collections.Generic;
// SOUTH — a crawl tunnel (0.6 m clearance), a duck-under bar (1.2 m) and a doorway.
// Static pieces are world (layer 1) and grabbable for climbing (PropGrabber grabs any static body
// not in "no_grab"); dynamic pieces are "prop"s (grab/push) and come back with Reset().
[Tool]
public partial class Playground : Node3D
{
[ExportToolButton("Rebuild (replaces all children)")]
public Callable RebuildButton => Callable.From(RebuildInEditor);
private readonly List<(RigidBody3D Body, Transform3D Start)> _dynamic = new();
private const uint WorldLayer = 1, WorldMask = 0b11101, PropMask = 0b11101;
@@ -21,22 +27,28 @@ public partial class Playground : Node3D
public override void _Ready()
{
if (!Engine.IsEditorHint())
MapScene.CollectProps(this, _dynamic);
}
public void Reset() => MapScene.Reset(_dynamic);
private void RebuildInEditor()
{
if (!MapScene.CanRebuildInEditor(this))
return;
Rebuild();
MapScene.MarkUnsaved();
}
// Replaces every child with the generated layout, owned by this map (saved with its scene).
public void Rebuild()
{
MapScene.Clear(this);
BuildEast();
BuildWest();
BuildSouth();
}
public void Reset()
{
foreach (var (body, start) in _dynamic)
{
if (!IsInstanceValid(body))
continue;
body.GlobalTransform = start;
body.LinearVelocity = Vector3.Zero;
body.AngularVelocity = Vector3.Zero;
body.ResetPhysicsInterpolation();
}
MapScene.Adopt(this);
}
// ---------------------------------------------------------------- builders
@@ -46,7 +58,7 @@ public partial class Playground : Node3D
StaticBody3D body = new() { Name = name, CollisionLayer = WorldLayer, CollisionMask = WorldMask, Transform = new Transform3D(basis ?? Basis.Identity, center) };
body.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = size } });
body.AddChild(Mesh(new BoxMesh { Size = size }, color));
AddChild(body);
AddChild(body, true);
return body;
}
@@ -59,7 +71,7 @@ public partial class Playground : Node3D
StaticBody3D body = new() { Name = name, CollisionLayer = WorldLayer, CollisionMask = WorldMask, Transform = new Transform3D(new Basis(x, y, x.Cross(y)), (from + to) * 0.5f) };
body.AddChild(new CollisionShape3D { Shape = new CylinderShape3D { Radius = radius, Height = len } });
body.AddChild(Mesh(new CylinderMesh { TopRadius = radius, BottomRadius = radius, Height = len, RadialSegments = 12 }, color));
AddChild(body);
AddChild(body, true);
return body;
}
@@ -75,11 +87,10 @@ public partial class Playground : Node3D
MaxContactsReported = 4,
PhysicsMaterialOverride = new PhysicsMaterial { Friction = friction, Bounce = 0.05f },
};
body.AddToGroup("prop");
body.AddToGroup(MapScene.PropGroup, true);
body.AddChild(new CollisionShape3D { Shape = shape });
body.AddChild(Mesh(mesh, color));
AddChild(body);
_dynamic.Add((body, body.GlobalTransform));
AddChild(body, true);
return body;
}
@@ -97,13 +108,14 @@ public partial class Playground : Node3D
{
AddChild(new Label3D
{
Name = "Sign",
Text = text,
FontSize = 96,
PixelSize = 0.002f,
OutlineSize = 12,
Billboard = BaseMaterial3D.BillboardModeEnum.FixedY,
Position = at,
});
}, true);
}
// ---------------------------------------------------------------- zones
@@ -183,13 +195,16 @@ public partial class Playground : Node3D
// Seesaw: a plank hinged on a pivot block.
Block("SeesawPivot", new Vector3(0.3f, 0.45f, 0.5f), new Vector3(-8.5f, 0.225f, -1.5f), Metal);
RigidBody3D seesaw = BoxProp("Seesaw", new Vector3(3.2f, 0.06f, 0.45f), 12f, new Vector3(-8.5f, 0.48f, -1.5f), DarkWood);
// Joint paths resolve relative to the joint (a sibling here) and are saved that way; set
// before AddChild so the joint never exists without its bodies.
HingeJoint3D pivot = new()
{
Name = "SeesawHinge",
Transform = new Transform3D(new Basis(Vector3.Right, Vector3.Up, Vector3.Back), new Vector3(-8.5f, 0.48f, -1.5f)),
NodeA = GetNode<Node>("SeesawPivot").GetPath(), // absolute: joint paths resolve relative to the joint
NodeB = seesaw.GetPath(),
NodeA = $"../{GetNode<Node>("SeesawPivot").Name}",
NodeB = $"../{seesaw.Name}",
};
AddChild(pivot);
AddChild(pivot, true);
Sign("Seesaw", new Vector3(-8.5f, 1.3f, -1.5f));
// Swing board: a 10 kg board hinged at its top edge from a 2.4 m beam — grab it and hang.
@@ -199,11 +214,12 @@ public partial class Playground : Node3D
RigidBody3D board = BoxProp("SwingBoard", new Vector3(1.2f, 1.2f, 0.05f), 10f, new Vector3(-6f, 1.75f, 1.5f), Wood);
HingeJoint3D swing = new()
{
Name = "SwingHinge",
Transform = new Transform3D(new Basis(Vector3.Back, Vector3.Up, Vector3.Left), new Vector3(-6f, 2.35f, 1.5f)),
NodeA = beam.GetPath(),
NodeB = board.GetPath(),
NodeA = $"../{beam.Name}",
NodeB = $"../{board.Name}",
};
AddChild(swing);
AddChild(swing, true);
Sign("Hanging board", new Vector3(-6f, 2.9f, 1.5f));
// Crate pyramid (4 kg each), a heavy 25 kg box, barrels.
+2 -1
View File
@@ -1128,7 +1128,8 @@ public partial class RagdollSpike : Node3D
private void SetupPlayground()
{
Rest();
_playground = new Playground { Position = new Vector3(0f, 0f, 40f) }; // far from the player
_playground = GD.Load<PackedScene>("res://scenes/maps/playground.tscn").Instantiate<Playground>(); // the saved scene
_playground.Position = new Vector3(0f, 0f, 40f); // far from the player
_stage.AddChild(_playground);
}