using System.Collections.Generic; using ProjectM.Simulation; using Unity.Entities; using Unity.NetCode; using Unity.Transforms; using Unity.Mathematics; using UnityEngine; using UnityEngine.UIElements; namespace ProjectM.Client { /// /// The drawn branching route map (RouteSelect) — extracted from into its own client-only, /// observe-only presentation in . Owns its own /// runtime UIDocument sharing (sortingOrder 55). The map is regenerated /// client-side from RunInfo.RunSeed for DISPLAY only; the clickable next-layer nodes bind to the /// authoritative RouteOpt* bytes (never the regen) via . Also owns the /// client-local visited-path trace (nodeIds; reset per RunSeed) that lights walked edges. /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] public partial class RouteMapHudSystem : SystemBase { GameObject _go; UIDocument _doc; bool _built; VisualElement _routePanel; Label _routeTitle; bool _routePanelBuilt; VisualElement _routeMapHost; // node circles + Painter2D edges int _routeMapSig; // (seed, room, col, options) signature the map was drawn for readonly List _routeVisited = new(); // client-local path trace (nodeIds), reset per RunSeed uint _routeVisitedSeed; protected override void OnStartRunning() { if (_go != null) return; MenuUi.EnsureEventSystem(); _go = new GameObject("~HUDRouteMap"); _doc = _go.AddComponent(); _doc.panelSettings = MenuUi.LoadPanelSettings(); _doc.sortingOrder = 55; } protected override void OnDestroy() { if (_go != null) Object.Destroy(_go); } protected override void OnUpdate() { if (_doc == null) return; var root = _doc.rootVisualElement; if (root == null) return; // panel not initialised yet (next frame) if (!_built) { root.style.position = Position.Absolute; root.style.left = 0; root.style.right = 0; root.style.top = 0; root.style.bottom = 0; root.pickingMode = PickingMode.Ignore; // never eat game-world clicks _built = true; } bool haveRun = SystemAPI.TryGetSingleton(out var runInfo); UpdateRoutePanel(haveRun ? runInfo : default); // Client-local path trace for the route map (nodeIds visited this run; display-only). if (haveRun && runInfo.RunSeed != _routeVisitedSeed) { _routeVisited.Clear(); _routeVisitedSeed = runInfo.RunSeed; } if (haveRun && runInfo.Lifecycle == RunLifecycle.InRoom) { int visitedNode = RunMap.NodeId(runInfo.CurrentRoom, runInfo.CurrentCol); if (_routeVisited.Count == 0 || _routeVisited[^1] != visitedNode) _routeVisited.Add(visitedNode); } } // ---- the drawn branching route map (Slay-the-Spire style; display regen from RunSeed, clicks bind // to the authoritative RouteOpt* bytes) ---- const float MapStrideX = 58f, MapStrideY = 46f, MapNodeSize = 34f, MapPad = 14f; static Vector2 MapNodePos(int layer, int col, byte layerWidth) { float x = MapPad + layer * MapStrideX; float y = MapPad + MapStrideY + (col - (layerWidth - 1) * 0.5f) * MapStrideY; return new Vector2(x, y); } static string RoomGlyph(byte t) => t == RoomTypeId.Boss ? "B" : t == RoomTypeId.Elite ? "E" : t == RoomTypeId.Reward ? "R" : "C"; static Color RoomColor(byte t) => t == RoomTypeId.Boss ? new Color(0.92f, 0.28f, 0.22f) : t == RoomTypeId.Elite ? new Color(0.80f, 0.45f, 1f) : t == RoomTypeId.Reward ? new Color(0.45f, 0.95f, 0.55f) : new Color(1f, 0.72f, 0.35f); void UpdateRoutePanel(RunInfo runInfo) { // Keyed on the LIFECYCLE (never RouteOptionCount alone — the review's D-F6 criterion). bool show = runInfo.Lifecycle == RunLifecycle.RouteSelect && runInfo.RouteOptionCount > 0; if (!show) { if (_routePanel != null) _routePanel.style.display = DisplayStyle.None; _routeMapSig = 0; return; } var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return; if (!_routePanelBuilt) { BuildRoutePanel(root); _routePanelBuilt = true; } int sig = (int)runInfo.RunSeed ^ (runInfo.CurrentRoom + 1) * 131 ^ runInfo.CurrentCol * 31 ^ (runInfo.RouteOptionCount << 24) ^ (runInfo.RouteOpt0Col << 16) ^ (runInfo.RouteOpt1Col << 18) ^ (runInfo.RouteOpt2Col << 20); if (sig == 0) sig = 1; if (_routeMapSig != sig) { RebuildRouteMap(runInfo); _routeTitle.text = "CHOOSE YOUR PATH — room " + (runInfo.CurrentRoom + 2) + "/" + runInfo.RoomCount; _routeMapSig = sig; } _routePanel.style.display = DisplayStyle.Flex; } void BuildRoutePanel(VisualElement root) { _routePanel = new VisualElement { pickingMode = PickingMode.Ignore }; _routePanel.style.position = Position.Absolute; _routePanel.style.left = 0; _routePanel.style.right = 0; _routePanel.style.top = 0; _routePanel.style.bottom = 0; _routePanel.style.alignItems = Align.Center; _routePanel.style.justifyContent = Justify.Center; _routePanel.style.display = DisplayStyle.None; var box = new VisualElement { pickingMode = PickingMode.Position }; // swallow world clicks under the map box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.95f); box.style.borderTopLeftRadius = 10; box.style.borderTopRightRadius = 10; box.style.borderBottomLeftRadius = 10; box.style.borderBottomRightRadius = 10; box.style.paddingLeft = 18; box.style.paddingRight = 18; box.style.paddingTop = 12; box.style.paddingBottom = 12; box.style.alignItems = Align.Center; _routeTitle = new Label("CHOOSE YOUR PATH"); _routeTitle.style.color = new Color(0.55f, 0.85f, 1f); _routeTitle.style.fontSize = 16; _routeTitle.style.unityFontStyleAndWeight = FontStyle.Bold; _routeTitle.style.marginBottom = 10; box.Add(_routeTitle); _routeMapHost = new VisualElement { pickingMode = PickingMode.Ignore }; _routeMapHost.style.position = Position.Relative; box.Add(_routeMapHost); var cap = HudUi.Text("your path is lit — click a highlighted room to commit the party", 13, MenuUi.SubCol, TextAnchor.MiddleCenter); cap.style.marginTop = 10; box.Add(cap); _routePanel.Add(box); root.Add(_routePanel); } void RebuildRouteMap(RunInfo runInfo) { _routeMapHost.Clear(); var map = RunMapMath.Generate(runInfo.RunSeed); _routeMapHost.style.width = MapPad * 2f + (map.LayerCount - 1) * MapStrideX + MapNodeSize; _routeMapHost.style.height = MapPad * 2f + 2f * MapStrideY + MapNodeSize; // Edges under the nodes (Painter2D); walked segments glow, the rest are faint. var edges = new VisualElement { pickingMode = PickingMode.Ignore }; edges.style.position = Position.Absolute; edges.style.left = 0; edges.style.top = 0; edges.style.right = 0; edges.style.bottom = 0; var mapCopy = map; var visited = new List(_routeVisited); edges.generateVisualContent += ctx => { var p = ctx.painter2D; p.lineWidth = 2f; var c = new Vector2(MapNodeSize * 0.5f, MapNodeSize * 0.5f); for (int layer = 0; layer < mapCopy.LayerCount - 1; layer++) for (int col = 0; col < mapCopy.LayerWidths[layer]; col++) { var node = mapCopy.Node(layer, col); if (node.NextMask == 0) continue; var a = MapNodePos(layer, col, mapCopy.LayerWidths[layer]); for (int j = 0; j < mapCopy.LayerWidths[layer + 1]; j++) { if ((node.NextMask & (1 << j)) == 0) continue; var b = MapNodePos(layer + 1, j, mapCopy.LayerWidths[layer + 1]); bool walked = visited.Contains(RunMap.NodeId(layer, col)) && visited.Contains(RunMap.NodeId(layer + 1, j)); p.strokeColor = walked ? new Color(0.55f, 0.85f, 1f, 0.9f) : new Color(1f, 1f, 1f, 0.16f); p.BeginPath(); p.MoveTo(a + c); p.LineTo(b + c); p.Stroke(); } } }; _routeMapHost.Add(edges); int nextLayer = runInfo.CurrentRoom + 1; for (int layer = 0; layer < map.LayerCount; layer++) for (int col = 0; col < map.LayerWidths[layer]; col++) { var node = map.Node(layer, col); bool isCurrent = layer == runInfo.CurrentRoom && col == runInfo.CurrentCol; bool wasVisited = _routeVisited.Contains(RunMap.NodeId(layer, col)); byte opt = 255; if (layer == nextLayer) { if (runInfo.RouteOptionCount > 0 && col == runInfo.RouteOpt0Col) opt = 0; else if (runInfo.RouteOptionCount > 1 && col == runInfo.RouteOpt1Col) opt = 1; else if (runInfo.RouteOptionCount > 2 && col == runInfo.RouteOpt2Col) opt = 2; } _routeMapHost.Add(MakeMapNode(node.RoomType, MapNodePos(layer, col, map.LayerWidths[layer]), isCurrent, wasVisited, opt, layer <= runInfo.CurrentRoom)); } } VisualElement MakeMapNode(byte roomType, Vector2 pos, bool isCurrent, bool visited, byte optionIndex, bool past) { bool clickable = optionIndex != 255; var n = new VisualElement { pickingMode = clickable ? PickingMode.Position : PickingMode.Ignore }; n.style.position = Position.Absolute; n.style.left = pos.x; n.style.top = pos.y; n.style.width = MapNodeSize; n.style.height = MapNodeSize; MenuUi.Round(n, MapNodeSize * 0.5f); var c = RoomColor(roomType); float bgA = clickable ? 0.95f : visited || isCurrent ? 0.85f : past ? 0.20f : 0.40f; var restBg = new Color(c.r * 0.35f, c.g * 0.35f, c.b * 0.35f, bgA); n.style.backgroundColor = restBg; MenuUi.Border(n, isCurrent ? new Color(0.55f, 0.85f, 1f) : clickable ? c : new Color(1f, 1f, 1f, 0.18f), isCurrent || clickable ? 2.5f : 1.2f); var lbl = new Label(RoomGlyph(roomType)) { pickingMode = PickingMode.Ignore }; lbl.style.unityTextAlign = TextAnchor.MiddleCenter; lbl.style.flexGrow = 1; lbl.style.color = clickable || visited || isCurrent ? c : new Color(1f, 1f, 1f, 0.35f); lbl.style.fontSize = 15; lbl.style.unityFontStyleAndWeight = FontStyle.Bold; n.Add(lbl); if (clickable) { byte pick = optionIndex; // closure copy, never the loop variable n.RegisterCallback(_ => RouteSendSystem.PickRoute(pick)); n.RegisterCallback(_ => n.style.backgroundColor = new Color(c.r * 0.55f, c.g * 0.55f, c.b * 0.55f, 1f)); n.RegisterCallback(_ => n.style.backgroundColor = restBg); } return n; } } }