diff --git a/scenes/crag_spike.tscn b/scenes/crag_spike.tscn new file mode 100644 index 0000000..ebfea2d --- /dev/null +++ b/scenes/crag_spike.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/CragSpike.cs" id="1_spike"] + +[node name="CragSpike" type="Node3D"] +script = ExtResource("1_spike") diff --git a/scenes/main.tscn b/scenes/main.tscn index ce80674..25f8679 100644 --- a/scenes/main.tscn +++ b/scenes/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=21 format=3] +[gd_scene load_steps=22 format=3] [ext_resource type="Script" path="res://scripts/Main.cs" id="1_main"] [ext_resource type="PackedScene" path="res://scenes/teleport_marker.tscn" id="2_marker"] @@ -11,6 +11,7 @@ [ext_resource type="Script" path="res://scripts/TrueBody.cs" id="13_truebody"] [ext_resource type="Script" path="res://scripts/PlayerRig.cs" id="14_playerrig"] [ext_resource type="Script" path="res://scripts/Playground.cs" id="15_playground"] +[ext_resource type="Script" path="res://scripts/Crag.cs" id="16_crag"] [sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_sky"] sky_horizon_color = Color(0.66, 0.72, 0.81, 1) @@ -28,10 +29,10 @@ albedo_color = Color(0.35, 0.35, 0.38, 1) [sub_resource type="PlaneMesh" id="PlaneMesh_floor"] material = SubResource("Material_floor") -size = Vector2(40, 40) +size = Vector2(80, 80) [sub_resource type="BoxShape3D" id="BoxShape_floor"] -size = Vector3(40, 0.1, 40) +size = Vector3(80, 0.1, 80) [sub_resource type="SphereMesh" id="SphereMesh_hand"] radius = 0.04 @@ -111,6 +112,9 @@ script = ExtResource("5_anchormove") [node name="Playground" type="Node3D" parent="."] script = ExtResource("15_playground") +[node name="Crag" type="Node3D" parent="."] +script = ExtResource("16_crag") + [node name="PropShop" type="Node3D" parent="."] script = ExtResource("6_propshop") diff --git a/scripts/Crag.cs b/scripts/Crag.cs new file mode 100644 index 0000000..f371514 --- /dev/null +++ b/scripts/Crag.cs @@ -0,0 +1,358 @@ +using Godot; +using System.Collections.Generic; + +// The crag: a 26 m cliff of stacked rock boxes north of the playground, one route up, getting +// harder as it goes (built in code like Playground, same layers and grab rules): +// 1. bouldering blocks (1.2 / 2.1 / 3.0 m) to mantle and hop between; +// 2. a ladder up the first tier's face to REST LEDGE 1 (8 m, flag); +// 3. a jug wall (two staggered columns of holds) up the second tier to 13.5 m; +// 4. a narrow 0.4 m ledge traversed sideways along a handrail of holds, open air behind; +// 5. an overhang: monkey bars under a roof, a jug on its lip and a lip bar to pull over onto +// REST LEDGE 2 (16 m, flag); +// 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. +public partial class Crag : Node3D +{ + public enum Step { Stand, Hold } + 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(); + public const float SummitHeight = 26f; + public static readonly Vector3 SummitPoint = new(2.25f, SummitHeight, -18f); + + private readonly List<(RigidBody3D Body, Transform3D Start)> _dynamic = new(); + private const uint WorldLayer = 1, WorldMask = 0b11101, PropMask = 0b11101; + private readonly RandomNumberGenerator _rng = new() { Seed = 26 }; + + private static readonly Color[] Rock = + { + new(0.47f, 0.43f, 0.39f), new(0.54f, 0.5f, 0.45f), new(0.41f, 0.39f, 0.37f), new(0.58f, 0.53f, 0.47f), + }; + private static readonly Color Hold = new(0.9f, 0.45f, 0.15f), Metal = new(0.45f, 0.47f, 0.52f), + Wood = new(0.72f, 0.55f, 0.33f), Flag = new(0.85f, 0.15f, 0.15f), Bark = new(0.35f, 0.25f, 0.17f), + Leaves = new(0.25f, 0.45f, 0.22f), Pillar = new(0.7f, 0.7f, 0.68f); + + public override void _Ready() + { + BuildMass(); + BuildRoute(); + BuildBase(); + BuildSummitProps(); + } + + 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(); + } + } + + // ---------------------------------------------------------------- the rock + + private void BuildMass() + { + // Three tiers, each set back 1.5 m: A 0–8 m, B 8–16 m, C 16–26 m (C split by the chimney). + Mass("TierA", new Vector3(12f, 8f, 7f), new Vector3(0f, 4f, -16.5f), Rock[0]); + Mass("TierB", new Vector3(10f, 8f, 5.5f), new Vector3(0f, 12f, -17.25f), Rock[1]); + Mass("TierCWest", new Vector3(3.55f, 10f, 4f), new Vector3(-2.225f, 21f, -18f), Rock[2]); + Mass("TierCEast", new Vector3(3.55f, 10f, 4f), new Vector3(2.225f, 21f, -18f), Rock[2]); + Mass("TierCBack", new Vector3(0.9f, 10f, 3f), new Vector3(0f, 21f, -18.5f), Rock[3]); + Mass("Spire", new Vector3(1.3f, 2.6f, 1.3f), new Vector3(3.1f, 27.3f, -19.1f), Rock[1], new Basis(Vector3.Up, 0.3f)); + + // Shoulders and buttresses break the silhouette (sides and back only — the south faces + // carry the route). + Mass("ShoulderWestA", new Vector3(2.4f, 5.5f, 5f), new Vector3(-7f, 2.75f, -16.8f), Rock[2], new Basis(Vector3.Up, 0.12f)); + Mass("ShoulderEastA", new Vector3(2.8f, 6.8f, 5.5f), new Vector3(7.1f, 3.4f, -16.2f), Rock[3], new Basis(Vector3.Up, -0.15f)); + Mass("ShoulderEastB", new Vector3(1.8f, 5.5f, 4f), new Vector3(5.6f, 10.75f, -17.8f), Rock[0], new Basis(Vector3.Up, 0.1f)); + Mass("ShoulderWestB", new Vector3(1.6f, 3.6f, 3.5f), new Vector3(-5.5f, 9.8f, -18f), Rock[3], new Basis(Vector3.Up, -0.2f)); + Mass("ShoulderWestC", new Vector3(1.4f, 6f, 3f), new Vector3(-4.3f, 19f, -18.6f), Rock[0], new Basis(Vector3.Up, 0.18f)); + Mass("ShoulderEastC", new Vector3(1.5f, 7.5f, 3.2f), new Vector3(4.4f, 19.75f, -18.4f), Rock[3], new Basis(Vector3.Up, -0.12f)); + Mass("BackA", new Vector3(9f, 6f, 1.8f), new Vector3(0.5f, 3f, -20.6f), Rock[1], new Basis(Vector3.Up, 0.06f)); + Mass("BackB", new Vector3(6f, 5f, 1.4f), new Vector3(-0.8f, 13.5f, -20.4f), Rock[2], new Basis(Vector3.Up, -0.08f)); + + // Broken corners: big turned chunks knocking the tier edges out of square. + (Vector3 At, Vector3 Size, Vector3 Turn)[] corners = + { + (new(6.3f, 5.6f, -13.4f), new(2.2f, 4.4f, 2.4f), new(0.08f, 0.35f, -0.1f)), + (new(-6.6f, 5.5f, -13.6f), new(2f, 4.6f, 2.2f), new(-0.06f, -0.4f, 0.08f)), + (new(6.4f, 12.4f, -15.4f), new(1.6f, 4f, 2f), new(0.1f, 0.3f, 0.12f)), + (new(-5.5f, 13.6f, -15.2f), new(1.8f, 3.6f, 2f), new(-0.08f, -0.35f, -0.1f)), + (new(4.3f, 23f, -16.4f), new(1.6f, 4.4f, 1.8f), new(0.05f, 0.4f, 0.1f)), + (new(-4.4f, 22.2f, -16.6f), new(1.6f, 4.8f, 1.8f), new(-0.05f, -0.3f, -0.08f)), + (new(8.2f, 2.4f, -17.8f), new(2.6f, 4.8f, 2f), new(0f, 0.2f, -0.35f)), // slab leaning on the east side + }; + for (int i = 0; i < corners.Length; i++) + Mass($"Corner{i}", corners[i].Size, corners[i].At, Rock[(i + 1) % Rock.Length], Basis.FromEuler(corners[i].Turn)); + + // Talus: fallen blocks around the foot (clear of the route's boulders and the marker). + (Vector3 At, float Size)[] talus = + { + (new(9.5f, 0.9f, -13.5f), 1.8f), (new(11f, 0.6f, -16.2f), 1.2f), (new(-9.6f, 1.1f, -14.2f), 2.2f), + (new(-10.5f, 0.6f, -19.5f), 1.2f), (new(6.5f, 0.7f, -11.2f), 1.4f), (new(-8.3f, 0.45f, -11.8f), 0.9f), + (new(4f, 1.2f, -23f), 2.4f), (new(-5f, 0.8f, -23.4f), 1.6f), + }; + for (int i = 0; i < talus.Length; i++) + { + float s = talus[i].Size; + Vector3 size = new(s * _rng.RandfRange(0.9f, 1.4f), s, s * _rng.RandfRange(0.8f, 1.2f)); + Mass($"Talus{i}", size, talus[i].At, Rock[i % Rock.Length], Basis.FromEuler(new Vector3(_rng.RandfRange(-0.15f, 0.15f), _rng.RandfRange(-1f, 1f), _rng.RandfRange(-0.15f, 0.15f)))); + } + + // Rubble on the walls away from the route: protruding, slightly turned blocks. + Vector3[] spots = + { + new(3.5f, 1.2f, -12.8f), new(5f, 3.5f, -12.8f), new(1.6f, 5.6f, -12.8f), new(4.2f, 6.8f, -12.8f), + new(-0.2f, 2.4f, -12.8f), new(0.4f, 10.2f, -14.3f), new(-1.2f, 11.6f, -14.3f), new(-4.5f, 11.0f, -14.3f), + new(-6.1f, 4f, -14.2f), new(6.2f, 7.5f, -14.5f), new(-3.6f, 20.5f, -15.8f), new(3.2f, 22.8f, -15.8f), + new(-2.4f, 24.6f, -15.8f), new(1.8f, 18.4f, -15.8f), + }; + for (int i = 0; i < spots.Length; i++) + { + Vector3 size = new(_rng.RandfRange(0.8f, 1.8f), _rng.RandfRange(0.6f, 1.4f), 0.6f); + Basis turn = Basis.FromEuler(new Vector3(_rng.RandfRange(-0.12f, 0.12f), _rng.RandfRange(-0.25f, 0.25f), _rng.RandfRange(-0.15f, 0.15f))); + Mass($"Rubble{i}", size, spots[i], Rock[i % Rock.Length].Darkened(_rng.RandfRange(0f, 0.08f)), turn); + } + } + + // ---------------------------------------------------------------- the route + + private void BuildRoute() + { + // 1. Bouldering: three blocks rising 1.2 / 0.9 / 0.9 m with small gaps between. + Vector3 start = new(-5.6f, 0f, -8.2f); + AddStep(Step.Stand, start, "start"); + RouteBlock("Boulder1", new Vector3(1.4f, 1.2f, 1.4f), new Vector3(-5.6f, 0.6f, -9.6f), Rock[3]); + AddStep(Step.Stand, new Vector3(-5.6f, 1.2f, -9.1f), "boulder 1 (mantle 1.2 m)"); + AddStep(Step.Stand, new Vector3(-5.0f, 1.2f, -10.1f), "boulder 1 far edge"); + RouteBlock("Boulder2", new Vector3(1.4f, 2.1f, 1.4f), new Vector3(-4.6f, 1.05f, -11.2f), Rock[0]); + AddStep(Step.Stand, new Vector3(-4.8f, 2.1f, -10.7f), "boulder 2 (mantle 0.9 m)"); + AddStep(Step.Stand, new Vector3(-4.1f, 2.1f, -11.4f), "boulder 2 far edge"); + RouteBlock("Boulder3", new Vector3(1.5f, 3f, 1.6f), new Vector3(-2.7f, 1.5f, -12.2f), Rock[2]); + AddStep(Step.Stand, new Vector3(-3.3f, 3f, -11.8f), "boulder 3 (hop 0.45 m gap, mantle 0.9 m)"); + AddStep(Step.Stand, new Vector3(-2.7f, 3f, -12.5f), "foot of the ladder"); + + // 2. Ladder up tier A's face (z = -13) to rest ledge 1; rails run past the lip to pull over. + const float ladderX = -2.7f, ladderZ = -12.85f, half = 0.3f, rail = 0.03f, rung = 0.022f; + foreach (float sx in new[] { -half, half }) + { + RouteBar("LadderRail", new Vector3(ladderX + sx, 3f, ladderZ), new Vector3(ladderX + sx, 9f, ladderZ), rail, Metal); + for (float y = 4f; y < 9f; y += 2f) + RouteBlock("LadderStandoff", new Vector3(0.04f, 0.04f, 0.12f), new Vector3(ladderX + sx, y, -12.94f), Metal); + } + for (float y = 3.3f; y < 8.85f; y += 0.3f) + { + RouteBar("Rung", new Vector3(ladderX - half + rail, y, ladderZ), new Vector3(ladderX + half - rail, y, ladderZ), rung, Metal); + AddStep(Step.Hold, new Vector3(ladderX, y + rung + 0.02f, ladderZ), $"rung {y:0.0} m"); + } + + // Rest ledge 1: tier A's top strip in front of tier B (1.5 m deep, 12 m wide). + AddStep(Step.Stand, new Vector3(ladderX, 8f, -13.4f), "REST LEDGE 1 (8 m)"); + FlagPole("8 m", new Vector3(-5.3f, 8f, -13.7f)); + AddStep(Step.Stand, new Vector3(-3f, 8f, -14.1f), "foot of the jug wall"); + + // 3. Jug wall on tier B's face (z = -14.5): two staggered columns, 0.45 m apart vertically. + int n = 0; + for (float y = 9f; y < 13.2f; y += 0.45f, n++) + { + float x = n % 2 == 0 ? -3.25f : -2.75f; + Jug(new Vector3(x, y, -14.5f), Vector3.Back); + } + // 4. Traverse ledge at 13.5 m: 0.4 m deep; its lip is the last jug. + RouteBlock("TraverseLedge", new Vector3(8.2f, 0.12f, 0.4f), new Vector3(0.5f, 13.44f, -14.3f), Rock[3]); + AddStep(Step.Hold, new Vector3(-3f, 13.52f, -14.08f), "traverse ledge lip"); + AddStep(Step.Stand, new Vector3(-3f, 13.5f, -14.3f), "traverse ledge (0.4 m wide, 13.5 m)"); + for (float x = -2.4f; x <= 3.05f; x += 0.6f) + Jug(new Vector3(x, 14.9f, -14.5f), Vector3.Back); + AddStep(Step.Stand, new Vector3(3f, 13.5f, -14.3f), "under the overhang"); + + // 5. Overhang: a roof slab 1.5 m out from tier B, monkey bars under it, a jug on its front + // face and a bar along its top edge to pull over onto rest ledge 2. + RouteBlock("Overhang", new Vector3(3f, 0.6f, 1.5f), new Vector3(3f, 15.7f, -13.75f), Rock[0]); + const float barY = 15.2f, barR = 0.025f; + foreach (float z in new[] { -14.2f, -13.7f, -13.2f }) + { + RouteBar("RoofBar", new Vector3(1.7f, barY, z), new Vector3(4.3f, barY, z), barR, Metal); + foreach (float x in new[] { 1.8f, 4.2f }) + RouteBar("RoofHanger", new Vector3(x, barY + barR, z), new Vector3(x, 15.4f, z), 0.015f, Metal); + AddStep(Step.Hold, new Vector3(3f, barY - barR - 0.02f, z), $"roof bar z {z:0.0}"); + } + Jug(new Vector3(3f, 15.65f, -13f), Vector3.Back); + RouteBar("LipBar", new Vector3(1.7f, 16.03f, -13.05f), new Vector3(4.3f, 16.03f, -13.05f), 0.03f, Metal); + AddStep(Step.Hold, new Vector3(3f, 16.08f, -13.05f), "lip bar"); + AddStep(Step.Stand, new Vector3(3f, 16f, -13.6f), "REST LEDGE 2 (16 m)"); + FlagPole("16 m", new Vector3(-4.3f, 16f, -15.2f)); + AddStep(Step.Stand, new Vector3(0f, 16f, -15.6f), "mouth of the chimney"); + + // 6. Chimney: a 0.9 m slot between tier C's halves (x ±0.45, z -16 … -17), holds on both + // walls staggered 0.5 m and the back wall in between, topping out at the summit. + n = 0; + for (float y = 16.7f; y < 25.9f; y += 0.25f, n++) + { + bool west = n % 2 == 0; + Jug(new Vector3(west ? -0.45f : 0.45f, y, -16.5f), west ? Vector3.Right : Vector3.Left); + } + AddStep(Step.Hold, new Vector3(-0.47f, SummitHeight + 0.02f, -16.4f), "chimney exit lip"); + + // 7. Summit. + AddStep(Step.Stand, new Vector3(-1f, SummitHeight, -16.6f), "SUMMIT (26 m)"); + Sign($"Summit {SummitHeight:0} m", new Vector3(-2.4f, SummitHeight + 1.6f, -18.5f), 2f); + } + + // A hold: a 0.3 m wide, 0.12 m proud box on the face at `at`, sticking out along `out`. + private void Jug(Vector3 at, Vector3 outward) + { + Vector3 size = Mathf.Abs(outward.X) > 0.5f ? new Vector3(0.12f, 0.08f, 0.3f) : new Vector3(0.3f, 0.08f, 0.12f); + Vector3 center = at + outward * 0.06f; + RouteBlock("Jug", size, center, Hold, castShadow: false); + AddStep(Step.Hold, center + Vector3.Up * 0.06f + outward * 0.02f, $"jug {at.Y:0.00} m"); + } + + private void FlagPole(string text, Vector3 foot) + { + RouteBar("FlagPole", foot, foot + Vector3.Up * 2.4f, 0.03f, Metal); + Block("Flag", new Vector3(0.02f, 0.45f, 0.7f), foot + new Vector3(0f, 2.15f, 0.38f), Flag, false); + Sign(text, foot + Vector3.Up * 2.8f, 1.5f); + } + + // ---------------------------------------------------------------- base and summit + + private void BuildBase() + { + // Trees and pillars at the foot: things of known size to read the height against. + (Vector3 At, float Height)[] trees = { (new(-10f, 0f, -11.5f), 6f), (new(10.5f, 0f, -12f), 7f), (new(-12f, 0f, -17f), 5.5f), (new(12f, 0f, -18f), 6.5f) }; + foreach (var (at, h) in trees) + { + Bar("Trunk", at, at + Vector3.Up * h * 0.6f, 0.18f, Bark); + Block("Canopy", new Vector3(2.2f, h * 0.5f, 2.2f), at + Vector3.Up * h * 0.75f, Leaves, true, new Basis(Vector3.Up, at.X * 0.3f), grab: false); + } + Bar("PillarWest", new Vector3(-8.5f, 0f, -10.5f), new Vector3(-8.5f, 9f, -10.5f), 0.35f, Pillar); + Bar("PillarEast", new Vector3(8.5f, 0f, -10f), new Vector3(8.5f, 12f, -10f), 0.35f, Pillar); + Sign("The Crag — 26 m", new Vector3(-5.6f, 2.6f, -7.6f), 1.5f); + + // The way down: a teleport marker at the base (aim at it from the summit). + Node3D marker = GD.Load("res://scenes/teleport_marker.tscn").Instantiate(); + marker.Set("MarkerLabel", "Crag"); + marker.Position = new Vector3(1.5f, 0f, -9.5f); + AddChild(marker); + } + + private void BuildSummitProps() + { + for (int i = 0; i < 3; i++) + BoxProp($"SummitCrate{i}", Vector3.One * 0.4f, 4f, new Vector3(2f + i * 0.5f, SummitHeight + 0.2f, -17.2f), Wood.Darkened(0.05f * i)); + Prop("SummitBarrel", new CylinderShape3D { Radius = 0.28f, Height = 0.85f }, new CylinderMesh { TopRadius = 0.28f, BottomRadius = 0.28f, Height = 0.85f }, + 12f, new Vector3(-2.8f, SummitHeight + 0.425f, -18.8f), Flag.Darkened(0.2f)); + Prop("SummitBall", new SphereShape3D { Radius = 0.15f }, new SphereMesh { Radius = 0.15f, Height = 0.3f }, 1.5f, + new Vector3(-1.6f, SummitHeight + 0.15f, -18.4f), new Color(0.95f, 0.85f, 0.2f)); + BoxProp("SummitPlank", new Vector3(2f, 0.04f, 0.2f), 4f, new Vector3(2.4f, SummitHeight + 0.02f, -18.8f), Wood); + } + + // ---------------------------------------------------------------- builders + + private void AddStep(Step kind, Vector3 point, string name) => Route.Add((kind, point, name)); + + 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))); + 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))); + 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))); + 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.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); + return body; + } + + private StaticBody3D Bar(string name, Vector3 from, Vector3 to, float radius, Color color) + { + Vector3 axis = to - from; + float len = axis.Length(); + Vector3 y = axis / len; + Vector3 x = Mathf.Abs(y.Y) < 0.99f ? Vector3.Up.Cross(y).Normalized() : Vector3.Right; + 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); + return body; + } + + private RigidBody3D Prop(string name, Shape3D shape, Mesh mesh, float mass, Vector3 center, Color color) + { + RigidBody3D body = new() + { + Name = name, + Mass = mass, + Transform = new Transform3D(Basis.Identity, center), + CollisionMask = PropMask, + ContactMonitor = true, + MaxContactsReported = 4, + PhysicsMaterialOverride = new PhysicsMaterial { Friction = 0.8f, Bounce = 0.05f }, + }; + body.AddToGroup("prop"); + body.AddChild(new CollisionShape3D { Shape = shape }); + body.AddChild(Mesh(mesh, color)); + AddChild(body); + _dynamic.Add((body, body.GlobalTransform)); + return body; + } + + private RigidBody3D BoxProp(string name, Vector3 size, float mass, Vector3 center, Color color) => + Prop(name, new BoxShape3D { Size = size }, new BoxMesh { Size = size }, mass, center, color); + + private static MeshInstance3D Mesh(Mesh mesh, Color color) + { + MeshInstance3D m = new() { Mesh = mesh }; + m.SetSurfaceOverrideMaterial(0, new StandardMaterial3D { AlbedoColor = color, Roughness = 0.9f }); + return m; + } + + private void Sign(string text, Vector3 at, float scale) + { + AddChild(new Label3D + { + Text = text, + FontSize = 96, + PixelSize = 0.002f * scale, + OutlineSize = 12, + Billboard = BaseMaterial3D.BillboardModeEnum.FixedY, + Position = at, + }); + } +} diff --git a/scripts/Crag.cs.uid b/scripts/Crag.cs.uid new file mode 100644 index 0000000..b6c7126 --- /dev/null +++ b/scripts/Crag.cs.uid @@ -0,0 +1 @@ +uid://cqeckjtcuntlv diff --git a/scripts/CragSpike.cs b/scripts/CragSpike.cs new file mode 100644 index 0000000..b658b4f --- /dev/null +++ b/scripts/CragSpike.cs @@ -0,0 +1,166 @@ +using Godot; +using System.Collections.Generic; + +// Regression for the crag (no player): 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 _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); + AddChild(new Playground()); + _crag = new 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"]})" : "")); + } + } +} diff --git a/scripts/CragSpike.cs.uid b/scripts/CragSpike.cs.uid new file mode 100644 index 0000000..48223c0 --- /dev/null +++ b/scripts/CragSpike.cs.uid @@ -0,0 +1 @@ +uid://bohh3so3ue5jk diff --git a/scripts/Locomotion.cs b/scripts/Locomotion.cs index ffed2b5..8ef5b73 100644 --- a/scripts/Locomotion.cs +++ b/scripts/Locomotion.cs @@ -232,13 +232,16 @@ public partial class Locomotion : Node private void MoveToSpawn() => PlacePlayer(_spawn.GlobalPosition, ForwardYaw(_spawn.GlobalTransform)); - private void PlacePlayer(Vector3 targetXz, float yaw) + // Lands the play-space floor on the target's height (head height above the floor kept), so a + // marker at the foot of the crag brings you down from the summit instead of dropping you 26 m. + private void PlacePlayer(Vector3 target, float yaw) { Transform3D h = _camera.GlobalTransform; Transform3D hLocal = _origin.GlobalTransform.AffineInverse() * h; float hmdYaw = h.Basis.GetEuler().Y; Basis basis = Basis.FromEuler(new Vector3(0, yaw - hmdYaw, 0)) * h.Basis; - Transform3D desired = new Transform3D(basis, new Vector3(targetXz.X, h.Origin.Y, targetXz.Z)); + float headAboveFloor = h.Origin.Y - _origin.GlobalPosition.Y; + Transform3D desired = new Transform3D(basis, new Vector3(target.X, target.Y + headAboveFloor, target.Z)); _origin.GlobalTransform = desired * hLocal.AffineInverse(); } diff --git a/scripts/WorldPanel.cs b/scripts/WorldPanel.cs index 84a937d..d925a11 100644 --- a/scripts/WorldPanel.cs +++ b/scripts/WorldPanel.cs @@ -62,6 +62,7 @@ public partial class WorldPanel : Node3D { _propShop.Respawn(); GetNodeOrNull("../../Playground")?.Reset(); + GetNodeOrNull("../../Crag")?.Reset(); _menu.Log("Props reset"); }; _menu.PingRequested += () => diff --git a/tools/PlaygroundShots.cs b/tools/PlaygroundShots.cs index 135f40f..d6f2923 100644 --- a/tools/PlaygroundShots.cs +++ b/tools/PlaygroundShots.cs @@ -14,19 +14,25 @@ public partial class PlaygroundShots : Node3D (new Vector3(3f, 4f, 3f), new Vector3(9f, 1f, -4f)), (new Vector3(-2f, 3.5f, 4f), new Vector3(-7f, 1f, -1f)), (new Vector3(0f, 2.5f, 11f), new Vector3(1f, 0.6f, 7f)), + // The crag: from spawn eye height, from the side, the route up close, looking down from the summit. + (new Vector3(0f, 1.7f, 2.5f), new Vector3(-1f, 9f, -14f)), + (new Vector3(22f, 14f, 4f), new Vector3(0f, 12f, -16f)), + (new Vector3(-1f, 12f, -6f), new Vector3(-1f, 12f, -15f)), + (new Vector3(-0.8f, 27.7f, -16.4f), new Vector3(1.5f, 0f, -6f)), }; public override void _Ready() { foreach (string a in OS.GetCmdlineUserArgs()) _dir = a; - AddChild(new DirectionalLight3D { RotationDegrees = new Vector3(-50f, -30f, 0f), ShadowEnabled = true }); + AddChild(new DirectionalLight3D { DirectionalShadowMaxDistance = 60f, RotationDegrees = new Vector3(-50f, -30f, 0f), ShadowEnabled = true }); AddChild(new WorldEnvironment { Environment = new Environment { BackgroundMode = Environment.BGMode.Color, BackgroundColor = new Color(0.55f, 0.65f, 0.8f), AmbientLightSource = Environment.AmbientSource.Color, AmbientLightColor = new Color(0.5f, 0.5f, 0.55f) } }); StaticBody3D floor = new(); - floor.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(40, 0.1f, 40) }, Position = new Vector3(0, -0.05f, 0) }); - floor.AddChild(new MeshInstance3D { Mesh = new PlaneMesh { Size = new Vector2(40, 40) } }); + floor.AddChild(new CollisionShape3D { Shape = new BoxShape3D { Size = new Vector3(80, 0.1f, 80) }, Position = new Vector3(0, -0.05f, 0) }); + floor.AddChild(new MeshInstance3D { Mesh = new PlaneMesh { Size = new Vector2(80, 80) } }); AddChild(floor); AddChild(new Playground()); + AddChild(new Crag()); _cam = new Camera3D { Current = true }; AddChild(_cam); SetView();