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 choice-of-3 boon modal (RoomReward) — extracted from into its own client-only, /// observe-only presentation in . Owns its own /// runtime UIDocument sharing (sortingOrder 55) so it composes into the /// same UITK panel + event dispatcher as the HUD (50) / markers (48) / onboarding (60). Reads the local player's /// replicated + the blob; card clicks enqueue through /// . Built lazily on first show. /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] public partial class BoonModalHudSystem : SystemBase { GameObject _go; UIDocument _doc; bool _built; VisualElement _boonModal, _boonCardRow; int _boonShownFor; // last exact (Option0|Option1<<8|Option2<<16)+1 signature the modal was built for bool _boonModalBuilt; protected override void OnStartRunning() { if (_go != null) return; MenuUi.EnsureEventSystem(); _go = new GameObject("~HUDBoonModal"); _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); BoonOffer localOffer = default; bool hasOffer = false; foreach (var off in SystemAPI.Query>().WithAll()) { localOffer = off.ValueRO; hasOffer = true; break; } BlobAssetReference boonPool = default; if (SystemAPI.TryGetSingleton(out var bcat)) boonPool = bcat.Value; // Lifecycle gate (post-impl review): even a stale replicated Pending never shows the modal outside // the reward window. UpdateBoonModal(localOffer, hasOffer && localOffer.Pending == 1 && haveRun && runInfo.Lifecycle == RunLifecycle.RoomReward, boonPool); } void UpdateBoonModal(BoonOffer offer, bool show, BlobAssetReference pool) { if (!show || !pool.IsCreated) { if (_boonModal != null) _boonModal.style.display = DisplayStyle.None; _boonShownFor = 0; return; } var root = _doc != null ? _doc.rootVisualElement : null; if (root == null) return; if (!_boonModalBuilt) { BuildBoonModal(root); _boonModalBuilt = true; } // Rebuild the three cards only when the offer actually changes (a new room's deal). // Exact signature (post-impl review): the old lossy byte XOR could collide across consecutive // rooms and leave stale card labels. +1 keeps 0 as the hidden/reset sentinel. int sig = 1 + (offer.Option0 | (offer.Option1 << 8) | (offer.Option2 << 16)); if (_boonShownFor != sig) { _boonCardRow.Clear(); ref var defs = ref pool.Value; for (byte k = 0; k < 3; k++) { byte id = k == 2 ? offer.Option2 : k == 1 ? offer.Option1 : offer.Option0; int idx = BoonMath.FindDef(ref defs, id); string title = idx >= 0 ? defs.Defs[idx].Name.ToString() : ("BOON " + id); string desc = idx >= 0 ? defs.Defs[idx].Desc.ToString() : ""; byte weight = idx >= 0 ? defs.Defs[idx].Weight : (byte)100; byte pick = k; // capture a COPY into the closure, never the loop variable var card = MenuUi.Button(title + "\n" + desc, () => BoonSendSystem.PickBoon(pick)); card.style.width = 200; card.style.height = StyleKeyword.Auto; // long descs grow the card card.style.minHeight = 96; // Rarity from the draw weight (100 common / 60 uncommon / 30 rare / 10 epic). var rare = weight <= 10 ? new Color(1f, 0.82f, 0.30f) : weight <= 30 ? new Color(0.65f, 0.50f, 1f) : weight <= 60 ? new Color(0.45f, 0.95f, 0.55f) : new Color(1f, 1f, 1f, 0.30f); MenuUi.Border(card, rare, weight <= 30 ? 2.5f : 1.5f); card.style.marginLeft = 8; card.style.marginRight = 8; card.style.whiteSpace = WhiteSpace.Normal; _boonCardRow.Add(card); } _boonShownFor = sig; } _boonModal.style.display = DisplayStyle.Flex; } void BuildBoonModal(VisualElement root) { _boonModal = new VisualElement { pickingMode = PickingMode.Ignore }; _boonModal.style.position = Position.Absolute; _boonModal.style.left = 0; _boonModal.style.right = 0; _boonModal.style.top = 0; _boonModal.style.bottom = 0; _boonModal.style.alignItems = Align.Center; _boonModal.style.justifyContent = Justify.Center; _boonModal.style.display = DisplayStyle.None; var box = new VisualElement(); box.style.backgroundColor = new Color(0.07f, 0.09f, 0.12f, 0.96f); 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 = 14; box.style.paddingBottom = 16; box.style.alignItems = Align.Center; var title = new Label("ROOM CLEARED — CHOOSE A BOON"); title.style.color = new Color(0.6f, 1f, 0.7f); title.style.fontSize = 18; title.style.unityFontStyleAndWeight = FontStyle.Bold; title.style.marginBottom = 12; box.Add(title); _boonCardRow = new VisualElement(); _boonCardRow.style.flexDirection = FlexDirection.Row; box.Add(_boonCardRow); _boonModal.Add(box); root.Add(_boonModal); } } }