quot;Mark {m}m", new Vector3(platformDepth * 0.5f + 0.1f, 0.02f, m), new Vector3(big ? 0.6f : 0.3f, 0.04f, 0.05f), big ? warning : grayB); if (big) Label(lane, quot;{m} m", new Vector3(platformDepth * 0.5f + 0.8f, 0.1f, m)); } Label(lane, quot;Run {metrics.runSpeed:F1} m/s Sprint {metrics.sprintSpeed:F1} m/s", new Vector3(0f, 2f, -1f)); } void BuildGapLane(string name, float jumpDist) { var lane = Lane(name); float z = 0f; Box(lane, "Start", new Vector3(0f, -0.25f, z + 5f), new Vector3(platformDepth, 0.5f, 10f), grayA); z += 10f; foreach (float pct in new[] { 0.8f, 1.0f, 1.2f }) { float gap = jumpDist * pct; Label(lane, quot;{pct * 100f:F0}% {gap:F2} m", new Vector3(0f, 1.5f, z + gap * 0.5f)); z += gap; Box(lane, quot;Land {pct * 100f:F0}%", new Vector3(0f, -0.25f, z + 3f), new Vector3(platformDepth, 0.5f, 6f), pct > 1.001f ? warning : grayB); z += 6f; } } void BuildLedgeLane() { var lane = Lane("Ledges"); Box(lane, "Floor", new Vector3(0f, -0.25f, 15f), new Vector3(platformDepth * 4f, 0.5f, 30f), grayA); float z = 4f; foreach (float pct in new[] { 0.5f, 0.9f, 1.0f, 1.1f }) { float h = metrics.jumpHeight * pct; Box(lane, quot;Ledge {pct * 100f:F0}%", new Vector3(0f, h * 0.5f, z), new Vector3(platformDepth, h, 2f), pct > 1.001f ? warning : grayB); Label(lane, quot;{pct * 100f:F0}% {h:F2} m", new Vector3(0f, h + 0.6f, z)); z += 6f; } } void BuildDoorLane() { var lane = Lane("Doors"); Box(lane, "Floor", new Vector3(0f, -0.25f, 15f), new Vector3(platformDepth * 3f, 0.5f, 30f), grayA); float r2 = metrics.radius * 2f; var doors = new (string label, float w, float h)[] { ("Tight", r2 + 0.10f, metrics.height + 0.05f), ("Comfort", r2 + 0.40f, 2.1f), ("Standard", 1.0f, 2.1f), ("Crawlspace", 1.0f, metrics.height * 0.6f), }; float z = 4f; foreach (var d in doors) { Wall(lane, d.label, z, d.w, d.h, 3f); Label(lane, quot;{d.label}: {d.w:F2} x {d.h:F2} m", new Vector3(0f, Mathf.Max(d.h, 2.2f) + 0.5f, z)); z += 6f; } } void BuildStairLane() { var lane = Lane("Stairs"); Box(lane, "Floor", new Vector3(0f, -0.25f, 15f), new Vector3(platformDepth * 2f, 0.5f, 30f), grayA); float z = 3f; foreach (float pct in new[] { 1.0f, 1.2f }) { float step = metrics.stepHeight * pct; for (int i = 0; i < 6; i++) Box(lane, quot;Step {pct * 100f:F0}% #{i}", new Vector3(0f, step * (i + 0.5f), z + i * 0.3f), new Vector3(platformDepth * 0.8f, step, 0.3f), pct > 1.001f ? warning : grayB); Label(lane, quot;Step {pct * 100f:F0}% {step:F2} m", new Vector3(0f, step * 6f + 0.6f, z)); z += 10f; } } void BuildSlopeLane() { var lane = Lane("Slopes"); Box(lane, "Floor", new Vector3(0f, -0.25f, 20f), new Vector3(platformDepth * 2f, 0.5f, 40f), grayA); float z = 4f; foreach (float a in new[] { metrics.maxSlope - 10f, metrics.maxSlope - 1f, metrics.maxSlope + 5f }) { float len = 6f; var ramp = Box(lane, quot;Ramp {a:F0} deg", Vector3.zero, new Vector3(platformDepth * 0.8f, 0.2f, len), a > metrics.maxSlope ? warning : grayB); ramp.transform.localRotation = Quaternion.Euler(-a, 0f, 0f); float rise = Mathf.Sin(a * Mathf.Deg2Rad) * len * 0.5f; float run = Mathf.Cos(a * Mathf.Deg2Rad) * len * 0.5f; ramp.transform.localPosition = new Vector3(0f, rise, z + run); Label(lane, quot;{a:F0} deg", new Vector3(0f, rise * 2f + 0.6f, z + run)); z += 12f; } } // ---------- helpers ---------- Transform Lane(string name) { var go = new GameObject(name); Register(go); go.transform.SetParent(transform, false); go.transform.localPosition = new Vector3(_laneZ, 0f, 0f); // lanes side by side along X _laneZ += laneSpacing + platformDepth; return go.transform; } GameObject Box(Transform parent, string name, Vector3 localPos, Vector3 size, Material mat) { var go = GameObject.CreatePrimitive(PrimitiveType.Cube); Register(go); go.name = name; go.transform.SetParent(parent, false); go.transform.localPosition = localPos; go.transform.localScale = size; if (mat) go.GetComponent<Renderer>().sharedMaterial = mat; return go; } void Wall(Transform parent, string name, float z, float doorW, float doorH, float wallH) { float total = platformDepth * 3f; float side = (total - doorW) * 0.5f; Box(parent, name + " L", new Vector3(-(doorW + side) * 0.5f, wallH * 0.5f, z), new Vector3(side, wallH, 0.3f), grayB); Box(parent, name + " R", new Vector3((doorW + side) * 0.5f, wallH * 0.5f, z), new Vector3(side, wallH, 0.3f), grayB); Box(parent, name + " Lintel", new Vector3(0f, doorH + (wallH - doorH) * 0.5f, z), new Vector3(doorW, wallH - doorH, 0.3f), warning); } void Label(Transform parent, string text, Vector3 localPos) { var go = new GameObject("Label " + text); Register(go); go.transform.SetParent(parent, false); go.transform.localPosition = localPos; var tm = go.AddComponent<TextMesh>(); tm.text = text; tm.characterSize = 0.15f; tm.fontSize = 48; tm.anchor = TextAnchor.MiddleCenter; tm.color = Color.black; } void Register(GameObject go) { _created.Add(go); #if UNITY_EDITOR if (!Application.isPlaying) Undo.RegisterCreatedObjectUndo(go, "Build Metrics Gym"); #endif } } ``` > [!warning] Code not compiled in Unity > Not compiled or play-tested. Labels face +Z; add a billboard script or rotate the lane root if you view from another side. `Clear()` destroys *all children* of the builder object - keep the builder on its own empty GameObject. ### Steps 1. Empty GameObject `MetricsGym` at the origin, add `MetricsGymBuilder`, assign the metrics asset and three materials (light gray, mid gray, orange for "should fail"). 2. Right-click the component header > **Build Gym**. 3. Drop the Part 3 or Part 4 player at the start of each lane and play. 4. Change `jumpHeight` or `runSpeed` in the asset, **Clear Gym**, **Build Gym**, replay. 5. Students record pass/fail per piece in the comparison table in Part 4. ## 5.3 Blob shadow ("Me and My Shadow") A dark disc directly under the character tells the player where they will land - Rogers' Level 5 landing aid. Real-time shadows only show this when the light is overhead. ```csharp using UnityEngine; /// Places a flat dark quad under the character on the first surface below; fades with height. public class BlobShadow : MonoBehaviour { [SerializeField] Transform target; // the character root [SerializeField] LayerMask groundMask = ~0; [SerializeField] float maxDistance = 12f; [SerializeField] float baseSize = 0.8f; [SerializeField] float surfaceOffset = 0.02f; [SerializeField] Renderer quadRenderer; // Quad with an unlit transparent soft-circle material MaterialPropertyBlock _mpb; static readonly int ColorId = Shader.PropertyToID("_BaseColor"); void Awake() => _mpb = new MaterialPropertyBlock(); void LateUpdate() { Vector3 origin = target.position + Vector3.up * 0.1f; if (Physics.Raycast(origin, Vector3.down, out RaycastHit hit, maxDistance, groundMask, QueryTriggerInteraction.Ignore)) { quadRenderer.enabled = true; transform.position = hit.point + hit.normal * surfaceOffset; transform.rotation = Quaternion.LookRotation(-hit.normal); // Quad faces -Z; point it into the ground normal float h01 = Mathf.Clamp01(hit.distance / maxDistance); float size = Mathf.Lerp(baseSize, baseSize * 0.5f, h01); transform.localScale = new Vector3(size, size, 1f); _mpb.SetColor(ColorId, new Color(0f, 0f, 0f, Mathf.Lerp(0.6f, 0.15f, h01))); quadRenderer.SetPropertyBlock(_mpb); } else quadRenderer.enabled = false; } } ``` > [!warning] Code not compiled in Unity > Not compiled. `_BaseColor` is the URP Lit/Unlit color property; a Quad's visible face points along its -Z, hence `LookRotation(-hit.normal)` - flip if the quad is invisible. Keep the shadow object *unparented* from the character. A URP Decal Projector is the higher-quality alternative ([[URP Shaders and Shader Graph]]). ## 5.4 From gym to level Once the gym passes, the numbers become your **level kit**: door width, corridor width, max gap, max ledge, stair rise. Build level modules ("re-using re-use", Level 9) to those sizes - in 2D with [[Tilemaps and Tilesets]], in 3D with ProBuilder or primitives, in VR with the room in [[7.Quest 2 Worked Example Room]]. Every dead-end still needs a reward. ## Docs - `GameObject.CreatePrimitive`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/GameObject.CreatePrimitive.html - `TextMesh`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/TextMesh.html - `ContextMenu`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/ContextMenu.html - `Undo.RegisterCreatedObjectUndo`: https://docs.unity3d.com/6000.5/Documentation/ScriptReference/Undo.RegisterCreatedObjectUndo.html See also (added 2026-11-10): [[ProBuilder Gray-Boxing and Landmarks]] - the next step after this primitive gym.