diff --git a/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs b/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs new file mode 100644 index 000000000..ebfedf5b8 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs @@ -0,0 +1,29 @@ +namespace ProjectM.Client +{ + /// + /// Client-local build-mode state shared between the HUD build palette (sets the selected buildable), the + /// build placement input (ground ghost preview + click-to-place + conveyor rotation), and the input gather + /// (suppresses Fire while a build is selected, so a left-click places instead of firing). Pure UI state — + /// never replicated, never touches the sim. Reset on play-enter (statics survive fast-enter domain reloads). + /// + public static class BuildPaletteState + { + /// Selected structure type (StructureType.*); 0 = none / build mode off. + public static byte Selected; + + /// Pending conveyor facing (0=+X,1=-X,2=+Z,3=-Z); rotated by [ / ] or R. + public static byte Direction; + + /// True while a buildable is selected (build mode active). + public static bool Active => Selected != 0; + + /// Select a type (or 0 to leave build mode), resetting the pending conveyor facing. + public static void Select(byte type) { Selected = type; Direction = 0; } + + /// Leave build mode. + public static void Clear() { Selected = 0; Direction = 0; } + + [UnityEngine.RuntimeInitializeOnLoadMethod(UnityEngine.RuntimeInitializeLoadType.SubsystemRegistration)] + static void ResetStatics() { Selected = 0; Direction = 0; } + } +} diff --git a/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs.meta b/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs.meta new file mode 100644 index 000000000..99674e144 --- /dev/null +++ b/Assets/_Project/Scripts/Client/Building/BuildPaletteState.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b0f12fac7a937bf418aaf47eba57cc74 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs b/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs index 303ef413b..63757be67 100644 --- a/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs +++ b/Assets/_Project/Scripts/Client/Building/BuildSendSystem.cs @@ -3,29 +3,53 @@ using Unity.Entities; using Unity.Mathematics; using Unity.NetCode; using Unity.Transforms; +using UnityEngine; namespace ProjectM.Client { /// - /// Client-only sender for build + upgrade RPCs. Keyboard: B builds a Turret at the local player's current - /// grid cell; U upgrades ability damage. Editor-only statics (PlaceStructure / PlaceTurret / UpgradeAbility) - /// drive the same path from execute_code for headless validation (a one-shot key can't be injected on an - /// unfocused editor — the StorageOpSendSystem idiom). Managed SystemBase (reads the Input System); - /// UnityEngine.InputSystem is fully qualified to avoid the ProjectM.Simulation.PlayerInput name collision. - /// The server re-validates legality + cost authoritatively; this only sends a hint. + /// Client-only build input + RPC sender. Two ways to build: + /// (1) the HUD build PALETTE (primary): a selected buildable () drives a ground + /// GHOST preview at the cursor cell — green when valid (in-plot, unoccupied, affordable; the same legality the + /// server re-validates), red otherwise — and a LEFT-CLICK places it; right-click / Esc cancels; [ / ] or R + /// rotates a conveyor's facing. Fire is suppressed while build mode is active (PlayerInputGatherSystem reads + /// ), so the place-click never also fires. Build mode is suspended while + /// the pause overlay is open, and the frame a palette button changes the selection never also places. + /// (2) keyboard hotkeys (fallback, suppressed in palette mode): B/V/N/H/F/C place at the local player's cell. + /// Editor-only statics (PlaceStructure / PlaceHarvester / ...) drive the same RPC path from execute_code for + /// headless validation. Managed SystemBase; UnityEngine.InputSystem types are fully qualified to avoid the + /// ProjectM.Simulation.PlayerInput name collision. The server re-validates legality + cost authoritatively. /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] public partial class BuildSendSystem : SystemBase { + static byte s_ConveyorDir; // key-flow pending conveyor facing (0=+X,1=-X,2=+Z,3=-Z) + + // Dev hotkey fallback: key -> buildable (Conveyor uses s_ConveyorDir for facing). + static readonly (UnityEngine.InputSystem.Key Key, byte Type)[] s_BuildHotkeys = + { + (UnityEngine.InputSystem.Key.B, StructureType.Turret), + (UnityEngine.InputSystem.Key.V, StructureType.Wall), + (UnityEngine.InputSystem.Key.N, StructureType.Pylon), + (UnityEngine.InputSystem.Key.H, StructureType.Harvester), + (UnityEngine.InputSystem.Key.F, StructureType.Fabricator), + (UnityEngine.InputSystem.Key.C, StructureType.Conveyor), + }; + + UnityEngine.Camera _camera; // cursor -> ground re-raycast for click-to-place (resolved lazily) + GameObject _ghost; // translucent ground preview cube + Material _ghostMat; + byte _lastSelected; // skip placing on the frame a palette click changes the selection + #if UNITY_EDITOR - struct PendingBuild { public byte Type; public int CellX; public int CellZ; } + struct PendingBuild { public byte Type; public int CellX; public int CellZ; public byte Direction; } static readonly System.Collections.Generic.Queue s_PendingBuild = new System.Collections.Generic.Queue(); static int s_PendingUpgrades = 0; /// EDITOR / execute_code hook: queue a structure placement at a specific cell. - public static void PlaceStructure(byte type, int cellX, int cellZ) => - s_PendingBuild.Enqueue(new PendingBuild { Type = type, CellX = cellX, CellZ = cellZ }); + public static void PlaceStructure(byte type, int cellX, int cellZ, byte direction = 0) => + s_PendingBuild.Enqueue(new PendingBuild { Type = type, CellX = cellX, CellZ = cellZ, Direction = direction }); /// EDITOR / execute_code hook: queue a turret placement at a specific cell. public static void PlaceTurret(int cellX, int cellZ) => PlaceStructure(StructureType.Turret, cellX, cellZ); @@ -36,6 +60,15 @@ namespace ProjectM.Client /// EDITOR / execute_code hook: queue a pylon placement at a specific cell. public static void PlacePylon(int cellX, int cellZ) => PlaceStructure(StructureType.Pylon, cellX, cellZ); + /// EDITOR / execute_code hook: queue a harvester placement at a specific cell. + public static void PlaceHarvester(int cellX, int cellZ) => PlaceStructure(StructureType.Harvester, cellX, cellZ); + + /// EDITOR / execute_code hook: queue a fabricator placement at a specific cell. + public static void PlaceFabricator(int cellX, int cellZ) => PlaceStructure(StructureType.Fabricator, cellX, cellZ); + + /// EDITOR / execute_code hook: queue a conveyor placement facing direction (0=+X,1=-X,2=+Z,3=-Z). + public static void PlaceConveyor(int cellX, int cellZ, byte direction) => PlaceStructure(StructureType.Conveyor, cellX, cellZ, direction); + /// EDITOR / execute_code hook: queue an ability-damage upgrade. public static void UpgradeAbility() => s_PendingUpgrades++; #endif @@ -45,20 +78,31 @@ namespace ProjectM.Client RequireForUpdate(); } + protected override void OnDestroy() + { + if (_ghost != null) Object.Destroy(_ghost); + if (_ghostMat != null) Object.Destroy(_ghostMat); + } + protected override void OnUpdate() { if (!SystemAPI.TryGetSingletonEntity(out var connection)) return; + HandleBuildMode(connection); + + // Hotkey fallback (suppressed while the palette build mode is active). var keyboard = UnityEngine.InputSystem.Keyboard.current; - if (keyboard != null) + if (keyboard != null && !BuildPaletteState.Active) { - if (keyboard.bKey.wasPressedThisFrame && TryGetLocalPlayerCell(out int2 cell)) - SendBuild(connection, StructureType.Turret, cell.x, cell.y); - if (keyboard.vKey.wasPressedThisFrame && TryGetLocalPlayerCell(out int2 wcell)) - SendBuild(connection, StructureType.Wall, wcell.x, wcell.y); - if (keyboard.nKey.wasPressedThisFrame && TryGetLocalPlayerCell(out int2 pcell)) - SendBuild(connection, StructureType.Pylon, pcell.x, pcell.y); + if (keyboard.leftBracketKey.wasPressedThisFrame) + s_ConveyorDir = (byte)((s_ConveyorDir + 3) % 4); + if (keyboard.rightBracketKey.wasPressedThisFrame) + s_ConveyorDir = (byte)((s_ConveyorDir + 1) % 4); + + foreach (var (key, type) in s_BuildHotkeys) + if (keyboard[key].wasPressedThisFrame && TryGetLocalPlayerCell(out int2 cell)) + SendBuild(connection, type, cell.x, cell.y, type == StructureType.Conveyor ? s_ConveyorDir : (byte)0); if (keyboard.uKey.wasPressedThisFrame) SendUpgrade(connection); } @@ -67,7 +111,7 @@ namespace ProjectM.Client while (s_PendingBuild.Count > 0) { var b = s_PendingBuild.Dequeue(); - SendBuild(connection, b.Type, b.CellX, b.CellZ); + SendBuild(connection, b.Type, b.CellX, b.CellZ, b.Direction); } while (s_PendingUpgrades > 0) { @@ -77,6 +121,124 @@ namespace ProjectM.Client #endif } + // ---- Palette-driven build mode: ground ghost preview + click-to-place ---- + void HandleBuildMode(Entity connection) + { + byte sel = BuildPaletteState.Selected; + bool justSelected = sel != _lastSelected; // the selecting click must not also place + _lastSelected = sel; + + bool active = BuildPaletteState.Active && !PauseMenuController.Open; + AimPresentation.ForceCursorVisible = active; + if (!active) { HideGhost(); return; } + + var keyboard = UnityEngine.InputSystem.Keyboard.current; + var mouse = UnityEngine.InputSystem.Mouse.current; + + // Cancel build mode (right-click / Esc). + if ((mouse != null && mouse.rightButton.wasPressedThisFrame) || + (keyboard != null && keyboard.escapeKey.wasPressedThisFrame)) + { + BuildPaletteState.Clear(); + HideGhost(); + return; + } + + // Rotate a conveyor's facing ([ / ] or R). + if (keyboard != null) + { + if (keyboard.leftBracketKey.wasPressedThisFrame) + BuildPaletteState.Direction = (byte)((BuildPaletteState.Direction + 3) % 4); + if (keyboard.rightBracketKey.wasPressedThisFrame || keyboard.rKey.wasPressedThisFrame) + BuildPaletteState.Direction = (byte)((BuildPaletteState.Direction + 1) % 4); + } + + if (!SystemAPI.TryGetSingleton(out var anchor)) { HideGhost(); return; } + if (_camera == null) _camera = ResolveCamera(); + if (_camera == null || mouse == null) { HideGhost(); return; } + + // Cursor -> ground -> cell. + UnityEngine.Vector2 sp = mouse.position.ReadValue(); + UnityEngine.Ray ray = _camera.ScreenPointToRay(new UnityEngine.Vector3(sp.x, sp.y, 0f)); + if (!AimMath.TryGroundHit((float3)ray.origin, (float3)ray.direction, anchor.GridOrigin.y, out var groundPoint)) + { HideGhost(); return; } + int2 targetCell = BaseGridMath.WorldToCell(anchor, groundPoint); + + // Validity — client mirror of the server check (in-plot, unoccupied, affordable). + bool occupied = false; + foreach (var (ps, xf) in SystemAPI.Query, RefRO>()) + if (math.all(BaseGridMath.WorldToCell(anchor, xf.ValueRO.Position) == targetCell)) { occupied = true; break; } + + int cost = CatalogCost(sel); + byte reason = BuildPreviewMath.Evaluate(anchor, targetCell, occupied, LedgerOre(), cost); + bool valid = reason == BuildPreviewMath.Valid; + + ShowGhost(BaseGridMath.CellToWorld(anchor, targetCell), anchor.CellSize, valid); + + // Place on a left-click (valid, not the selecting click). + if (valid && !justSelected && mouse.leftButton.wasPressedThisFrame) + SendBuild(connection, sel, targetCell.x, targetCell.y, BuildPaletteState.Direction); + } + + int LedgerOre() + { + if (!SystemAPI.TryGetSingletonEntity(out var le)) return 0; + var buf = SystemAPI.GetBuffer(le); + for (int i = 0; i < buf.Length; i++) if (buf[i].ItemId == ResourceId.Ore) return buf[i].Count; + return 0; + } + + int CatalogCost(byte type) + { + if (type == 0 || !SystemAPI.TryGetSingletonEntity(out var ce)) return int.MaxValue; + var cat = SystemAPI.GetBuffer(ce); + for (int i = 0; i < cat.Length; i++) if (cat[i].Type == type) return cat[i].CostAmount; + return int.MaxValue; + } + + // ---- Ground ghost preview (procedural translucent cube, like AimReticleSystem's reticle) ---- + void ShowGhost(float3 center, float cellSize, bool valid) + { + EnsureGhost(); + _ghost.transform.position = (Vector3)center + Vector3.up * 0.5f; + _ghost.transform.localScale = new Vector3(cellSize * 0.9f, 1f, cellSize * 0.9f); + _ghostMat.color = valid ? new Color(0.3f, 1f, 0.45f, 0.35f) : new Color(1f, 0.32f, 0.26f, 0.35f); + if (!_ghost.activeSelf) _ghost.SetActive(true); + } + + void HideGhost() + { + if (_ghost != null && _ghost.activeSelf) _ghost.SetActive(false); + } + + void EnsureGhost() + { + if (_ghost != null) return; + var shader = Shader.Find("Sprites/Default"); + if (shader == null) shader = Shader.Find("Universal Render Pipeline/Unlit"); + _ghostMat = new Material(shader) { color = new Color(0.3f, 1f, 0.45f, 0.35f) }; + _ghost = GameObject.CreatePrimitive(PrimitiveType.Cube); + _ghost.name = "~BuildGhost"; + var col = _ghost.GetComponent(); + if (col != null) Object.Destroy(col); + var mr = _ghost.GetComponent(); + mr.sharedMaterial = _ghostMat; + mr.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off; + mr.receiveShadows = false; + _ghost.SetActive(false); + } + + UnityEngine.Camera ResolveCamera() + { + var cam = UnityEngine.Camera.main; + if (cam == null) + { + var rig = Object.FindAnyObjectByType(); + if (rig != null) cam = rig.GetComponent(); + } + return cam; + } + bool TryGetLocalPlayerCell(out int2 cell) { cell = default; @@ -90,10 +252,10 @@ namespace ProjectM.Client return false; } - void SendBuild(Entity connection, byte type, int cellX, int cellZ) + void SendBuild(Entity connection, byte type, int cellX, int cellZ, byte direction) { var e = EntityManager.CreateEntity(); - EntityManager.AddComponentData(e, new BuildPlaceRequest { StructureType = type, CellX = cellX, CellZ = cellZ }); + EntityManager.AddComponentData(e, new BuildPlaceRequest { StructureType = type, CellX = cellX, CellZ = cellZ, Direction = direction }); EntityManager.AddComponentData(e, new SendRpcCommandRequest { TargetConnection = connection }); } diff --git a/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs b/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs index b9f330626..ebec3102b 100644 --- a/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs +++ b/Assets/_Project/Scripts/Client/Input/PlayerInputGatherSystem.cs @@ -71,7 +71,7 @@ namespace ProjectM.Client // Movement is source-agnostic (WASD or left stick) — read from the merged action. float2 move = (float2)gameplay.Move.ReadValue(); - bool firePressed = gameplay.Fire.WasPressedThisFrame(); + bool firePressed = gameplay.Fire.WasPressedThisFrame() && !BuildPaletteState.Active; // no fire while placing a build // --- Active-device detection: last meaningful actuation wins; hold last when idle --- var gamepad = UnityEngine.InputSystem.Gamepad.current; diff --git a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs index 5bf4699b2..2273611dd 100644 --- a/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/CombatFeedbackSystem.cs @@ -343,6 +343,7 @@ namespace ProjectM.Client tm.anchor = TextAnchor.MiddleCenter; tm.alignment = TextAlignment.Center; tm.color = Color.white; + tm.fontStyle = FontStyle.Bold; go.SetActive(false); return new FloatingNumber { Tm = tm, Tr = go.transform, Active = false }; } @@ -358,7 +359,7 @@ namespace ProjectM.Client fn.Age = 0f; fn.Life = 0.7f; fn.Tm.text = Mathf.Max(1, Mathf.RoundToInt(amount)).ToString(); - fn.BaseColor = isLocalPlayer ? new Color(1f, 0.32f, 0.26f) : new Color(1f, 0.92f, 0.45f); + fn.BaseColor = isLocalPlayer ? new Color(1f, 0.5f, 0.22f) : new Color(0.45f, 0.92f, 1f); // Blight orange (hurt) / Aether cyan (you hit) fn.Tm.color = fn.BaseColor; fn.Tr.position = worldPos + Vector3.up * 1.4f + new Vector3(UnityEngine.Random.Range(-0.25f, 0.25f), 0f, 0f); fn.Vel = new Vector3(0f, 2.2f, 0f); @@ -478,7 +479,7 @@ namespace ProjectM.Client static void PlayClip(AudioClip clip, Vector3 pos, float vol) { if (clip == null) return; - AudioSource.PlayClipAtPoint(clip, pos, vol); + AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx); } } } diff --git a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs index bb84091e7..08ff7c1d9 100644 --- a/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/HudSystem.cs @@ -1,37 +1,41 @@ +using System.Collections.Generic; using ProjectM.Simulation; using Unity.Entities; using Unity.NetCode; using UnityEngine; -using UnityEngine.UI; +using UnityEngine.UIElements; namespace ProjectM.Client { /// - /// Client-only screen HUD. A managed presentation SystemBase () that builds - /// a uGUI overlay canvas in code and drives it from the LOCAL player ghost each frame: a health bar - /// (Health / EffectiveCharacterStats.MaxHealth), an ability-cooldown bar (AbilityCooldown vs NetworkTime - /// ServerTick + EffectiveAbilityStats.CooldownTicks), a live Husk threat count, and a DOWNED/RESPAWNING overlay - /// (the derived gate). Presentation only — no simulation, client world only. Bars are - /// RawImages over Texture2D.whiteTexture (always available; the fill width is the RectTransform's - /// anchorMax.x), so the HUD needs no sprite assets — only a resolved builtin font for the labels. + /// Client-only screen HUD, rebuilt on UI Toolkit so it reads in the same Aether-cyan visual language as the + /// menu / pause / settings screens ( / ). A managed presentation + /// SystemBase () that OBSERVES the local player ghost + the global + /// cycle / ledger / goal each frame and pushes values into a runtime UIDocument (shared PanelSettings, + /// sortingOrder 50 so it sits BEHIND the pause overlay's 100). The root is pickingMode = Ignore so the + /// HUD never eats clicks meant for the game world — only the build-palette buttons pick. Presentation only + /// (client world, no simulation). The palette buttons set (client-local UI + /// state), which BuildSendSystem turns into a ground ghost + click-to-place. The visual tree is built on the + /// first OnUpdate where the UIDocument's root exists (giving the panel a frame to initialise its PanelSettings). /// [WorldSystemFilter(WorldSystemFilterFlags.ClientSimulation)] [UpdateInGroup(typeof(PresentationSystemGroup))] public partial class HudSystem : SystemBase { - Canvas _canvas; - RectTransform _healthFill; - RectTransform _cooldownFill; - Text _healthText; - Text _threatText; - Text _phaseText; - Text _resourceText; - Text _locationText; - RectTransform _goalFill; - Text _goalText; - GameObject _respawnOverlay; + GameObject _hudGo; + UIDocument _doc; + bool _built; + + VisualElement _healthFill, _cooldownFill, _goalFill, _downed, _paletteRow; + Label _healthText, _threatText, _phaseText, _resourceText, _locationText, _goalText; + + bool _paletteBuilt; + readonly Dictionary _palette = new(); + EntityQuery _huskQuery; + struct PaletteItem { public VisualElement Root; public Label Cost; public int CostAmount; } + protected override void OnCreate() { _huskQuery = GetEntityQuery(ComponentType.ReadOnly()); @@ -39,76 +43,88 @@ namespace ProjectM.Client protected override void OnStartRunning() { - if (_canvas == null) BuildHud(); + if (_hudGo != null) return; + MenuUi.EnsureEventSystem(); + _hudGo = new GameObject("~HUD"); + _doc = _hudGo.AddComponent(); + _doc.panelSettings = MenuUi.LoadPanelSettings(); + _doc.sortingOrder = 50; // behind the pause overlay (100) } protected override void OnDestroy() { - if (_canvas != null) Object.Destroy(_canvas.gameObject); + if (_hudGo != null) Object.Destroy(_hudGo); } protected override void OnUpdate() { - if (_canvas == null) return; + if (_doc == null) return; + if (!_built) + { + var r = _doc.rootVisualElement; + if (r == null) return; // panel not initialised yet (next frame) + BuildTree(r); + _built = true; + } bool haveTick = SystemAPI.TryGetSingleton(out var nt); + int huskCount = _huskQuery.CalculateEntityCount(); - // Macro-loop HUD (phase + cycle + countdown + location), read before the per-player early-out so it persists pre-spawn. + // ---- Macro loop: phase + cycle + countdown ---- bool haveCycle = SystemAPI.TryGetSingleton(out var cyc); - if (_phaseText != null && haveCycle) + if (haveCycle) { var endTick = new NetworkTick(cyc.PhaseEndTick); string detail; if (cyc.Phase == CyclePhase.Siege) - detail = "WAVE " + cyc.WaveNumber + " - " + _huskQuery.CalculateEntityCount() + " HUSKS"; + detail = "WAVE " + cyc.WaveNumber + " - " + huskCount + " HUSKS"; else if (haveTick && cyc.PhaseEndTick != 0 && endTick.IsValid && endTick.IsNewerThan(nt.ServerTick)) detail = "INCURSION IN " + (endTick.TicksSince(nt.ServerTick) / 60 + 1) + "s"; else detail = ""; _phaseText.text = PhaseLabel(cyc.Phase) + (detail.Length > 0 ? " - " + detail : ""); - _phaseText.color = PhaseColor(cyc.Phase); + _phaseText.style.color = PhaseColor(cyc.Phase); } - else if (_phaseText != null) + else { _phaseText.text = ""; } - if (_locationText != null) - { - var cam = Camera.main; - bool onExpedition = cam != null && cam.transform.position.x > 500f; - _locationText.text = onExpedition - ? "ON EXPEDITION - return through the gate" - : "AT BASE - deploy through the gate when you're ready"; - _locationText.color = onExpedition ? new Color(1f, 0.8f, 0.4f) : new Color(0.6f, 0.85f, 1f); - } + // ---- Location + gate hint ---- + var cam = Camera.main; + bool onExpedition = cam != null && cam.transform.position.x > 500f; + _locationText.text = onExpedition + ? "ON EXPEDITION - return through the gate" + : "AT BASE - deploy through the gate when you're ready"; + _locationText.style.color = onExpedition ? new Color(1f, 0.8f, 0.4f) : new Color(0.6f, 0.85f, 1f); - if (_goalFill != null && SystemAPI.TryGetSingleton(out var goal)) + // ---- Goal ---- + if (SystemAPI.TryGetSingleton(out var goal)) { float gfrac = goal.Target > 0 ? Mathf.Clamp01(goal.Charge / (float)goal.Target) : 0f; - SetFill(_goalFill, gfrac); - if (_goalText != null) _goalText.text = "GOAL " + goal.Charge + " / " + goal.Target; + HudUi.SetFill(_goalFill, gfrac); + _goalText.text = "GOAL " + goal.Charge + " / " + goal.Target; } - if (_resourceText != null) + // ---- Resources (Ore feeds the palette affordability) ---- + int aether = 0, ore = 0, bio = 0; + if (SystemAPI.TryGetSingletonEntity(out var ledgerE)) { - string res = ""; - if (SystemAPI.TryGetSingletonEntity(out var ledgerE)) + var buf = SystemAPI.GetBuffer(ledgerE); + for (int i = 0; i < buf.Length; i++) { - var buf = SystemAPI.GetBuffer(ledgerE); - int aether = 0, ore = 0, bio = 0; - for (int i = 0; i < buf.Length; i++) - { - var en = buf[i]; - if (en.ItemId == ResourceId.Aether) aether = en.Count; - else if (en.ItemId == ResourceId.Ore) ore = en.Count; - else if (en.ItemId == ResourceId.Biomass) bio = en.Count; - } - res = "AETHER " + aether + " ORE " + ore + " BIO " + bio; + var en = buf[i]; + if (en.ItemId == ResourceId.Aether) aether = en.Count; + else if (en.ItemId == ResourceId.Ore) ore = en.Count; + else if (en.ItemId == ResourceId.Biomass) bio = en.Count; } - _resourceText.text = res; } + _resourceText.text = "AETHER " + aether + " ORE " + ore + " BIO " + bio; + // ---- Build palette (lazy-built once the catalog has streamed; hidden off-base) ---- + UpdatePalette(ore, onExpedition); + + // ---- Per-player vitals ---- bool found = false; float hp = 0f, maxHp = 1f, cdFrac = 1f; bool dead = false, shielded = false; @@ -123,8 +139,6 @@ namespace ProjectM.Client maxHp = effChar.ValueRO.MaxHealth > 0f ? effChar.ValueRO.MaxHealth : health.ValueRO.Max; dead = SystemAPI.IsComponentEnabled(entity); - // Cooldown fraction via wrap-safe NetworkTick compare (raw uint subtraction is unsafe across - // tick wraparound — the project convention, mirroring AbilityFireSystem/EnemyAISystem). uint nextFire = cd.ValueRO.NextFireTick; int cdTicks = effAbility.ValueRO.CooldownTicks; var nextTick = new NetworkTick(nextFire); @@ -138,166 +152,144 @@ namespace ProjectM.Client break; } - _canvas.enabled = found || haveCycle; - if (!found) return; + _doc.rootVisualElement.style.display = (found || haveCycle) ? DisplayStyle.Flex : DisplayStyle.None; + if (!found) { _downed.style.display = DisplayStyle.None; return; } float frac = maxHp > 0f ? Mathf.Clamp01(hp / maxHp) : 0f; - SetFill(_healthFill, frac); - var hc = _healthFill.GetComponent(); - if (hc != null) - hc.color = shielded - ? new Color(0.45f, 0.85f, 1f) - : Color.Lerp(new Color(0.92f, 0.16f, 0.16f), new Color(0.25f, 0.9f, 0.5f), frac); - if (_healthText != null) - _healthText.text = Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(maxHp) + (shielded ? " SHIELDED" : ""); + HudUi.SetFill(_healthFill, frac); + _healthFill.style.backgroundColor = shielded + ? new Color(0.45f, 0.85f, 1f) + : Color.Lerp(new Color(0.92f, 0.16f, 0.16f), new Color(0.25f, 0.9f, 0.5f), frac); + _healthText.text = Mathf.CeilToInt(Mathf.Max(0f, hp)) + " / " + Mathf.CeilToInt(maxHp) + (shielded ? " SHIELDED" : ""); - SetFill(_cooldownFill, cdFrac); - - if (_threatText != null) - _threatText.text = "HUSKS " + _huskQuery.CalculateEntityCount(); - - _respawnOverlay.SetActive(dead); + HudUi.SetFill(_cooldownFill, cdFrac); + _threatText.text = "HUSKS " + huskCount; + _downed.style.display = dead ? DisplayStyle.Flex : DisplayStyle.None; } - static void SetFill(RectTransform fill, float frac) + void UpdatePalette(int ore, bool onExpedition) { - if (fill == null) return; - var max = fill.anchorMax; - max.x = Mathf.Clamp01(frac); - fill.anchorMax = max; + if (!_paletteBuilt && SystemAPI.TryGetSingletonEntity(out var catE)) + { + var cat = SystemAPI.GetBuffer(catE); + for (int i = 0; i < cat.Length; i++) + AddPaletteItem(cat[i].Type, cat[i].CostAmount); + _paletteBuilt = true; + } + if (!_paletteBuilt) { _paletteRow.style.display = DisplayStyle.None; return; } + + _paletteRow.style.display = onExpedition ? DisplayStyle.None : DisplayStyle.Flex; + foreach (var kv in _palette) + { + var item = kv.Value; + bool affordable = ore >= item.CostAmount; + bool selected = BuildPaletteState.Selected == kv.Key; + item.Root.style.opacity = affordable ? 1f : 0.45f; + item.Cost.style.color = affordable ? new Color(0.7f, 0.95f, 0.8f) : new Color(1f, 0.5f, 0.4f); + MenuUi.Border(item.Root, selected ? MenuUi.Accent : new Color(1f, 1f, 1f, 0.08f), selected ? 2 : 1); + item.Root.style.backgroundColor = selected + ? new Color(0.16f, 0.26f, 0.32f, 0.95f) + : new Color(0.09f, 0.11f, 0.15f, 0.92f); + } } - // ---- uGUI construction (code-built; no prefab/sprite assets) ---- - - void BuildHud() + void AddPaletteItem(byte type, int cost) { - var go = new GameObject("~HUD"); - _canvas = go.AddComponent(); - _canvas.renderMode = RenderMode.ScreenSpaceOverlay; - _canvas.sortingOrder = 100; - var scaler = go.AddComponent(); - scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize; - scaler.referenceResolution = new Vector2(1920, 1080); - go.AddComponent(); + if (type == 0 || _palette.ContainsKey(type)) return; + var root = new VisualElement(); + root.style.width = 92; + root.style.marginLeft = 4; root.style.marginRight = 4; + root.style.paddingTop = 6; root.style.paddingBottom = 6; + root.style.alignItems = Align.Center; + root.style.backgroundColor = new Color(0.09f, 0.11f, 0.15f, 0.92f); + root.pickingMode = PickingMode.Position; + MenuUi.Round(root, 6); + MenuUi.Border(root, new Color(1f, 1f, 1f, 0.08f), 1); - var font = GetFont(); + var nameLabel = HudUi.Text(StructureName(type), 13, MenuUi.TextCol, TextAnchor.MiddleCenter); + var costLabel = HudUi.Text(cost + " ORE", 12, new Color(0.7f, 0.95f, 0.8f), TextAnchor.MiddleCenter); + costLabel.style.marginTop = 2; + root.Add(nameLabel); + root.Add(costLabel); - // Health bar (bottom-left). - var hpBg = MakeBar("HealthBg", _canvas.transform, new Color(0f, 0f, 0f, 0.6f), - new Vector2(40, 46), new Vector2(440, 40)); - _healthFill = MakeFill("HealthFill", hpBg, new Color(0.25f, 0.9f, 0.5f)); - _healthText = MakeText("HealthText", hpBg, "100 / 100", 24, TextAnchor.MiddleCenter, Color.white, font); + byte t = type; + root.RegisterCallback(_ => + BuildPaletteState.Select(BuildPaletteState.Selected == t ? (byte)0 : t)); - // Cooldown bar (just above health). - var cdBg = MakeBar("CooldownBg", _canvas.transform, new Color(0f, 0f, 0f, 0.55f), - new Vector2(40, 92), new Vector2(440, 14)); - _cooldownFill = MakeFill("CooldownFill", cdBg, new Color(0.4f, 0.8f, 1f)); - - // Threat count (top-right). - _threatText = MakeText("ThreatText", _canvas.transform, "HUSKS 0", 30, TextAnchor.UpperRight, - new Color(1f, 0.62f, 0.4f), font); - var trt = _threatText.rectTransform; - trt.anchorMin = new Vector2(1, 1); trt.anchorMax = new Vector2(1, 1); trt.pivot = new Vector2(1, 1); - trt.anchoredPosition = new Vector2(-40, -30); trt.sizeDelta = new Vector2(380, 50); - - // Cycle phase + number (top-center). - _phaseText = MakeText("PhaseText", _canvas.transform, "EXPEDITION CYCLE 1", 34, TextAnchor.UpperCenter, - new Color(0.55f, 0.9f, 1f), font); - var prt = _phaseText.rectTransform; - prt.anchorMin = new Vector2(0.5f, 1f); prt.anchorMax = new Vector2(0.5f, 1f); prt.pivot = new Vector2(0.5f, 1f); - prt.anchoredPosition = new Vector2(0, -24); prt.sizeDelta = new Vector2(600, 50); - - // Resource ledger counts (top-center, below phase). - _resourceText = MakeText("ResourceText", _canvas.transform, "", 24, TextAnchor.UpperCenter, - new Color(0.7f, 0.95f, 0.8f), font); - var rrt = _resourceText.rectTransform; - rrt.anchorMin = new Vector2(0.5f, 1f); rrt.anchorMax = new Vector2(0.5f, 1f); rrt.pivot = new Vector2(0.5f, 1f); - rrt.anchoredPosition = new Vector2(0, -64); rrt.sizeDelta = new Vector2(600, 40); - - // Location + gate hint (top-center, below resources). - _locationText = MakeText("LocationText", _canvas.transform, "", 22, TextAnchor.UpperCenter, - new Color(0.6f, 0.85f, 1f), font); - var lrt = _locationText.rectTransform; - lrt.anchorMin = new Vector2(0.5f, 1f); lrt.anchorMax = new Vector2(0.5f, 1f); lrt.pivot = new Vector2(0.5f, 1f); - lrt.anchoredPosition = new Vector2(0, -96); lrt.sizeDelta = new Vector2(760, 36); - // Goal progress bar (top-center, below the location line). - var goalBg = MakeBar("GoalBg", _canvas.transform, new Color(0f, 0f, 0f, 0.55f), Vector2.zero, new Vector2(360, 16)); - goalBg.anchorMin = new Vector2(0.5f, 1f); goalBg.anchorMax = new Vector2(0.5f, 1f); goalBg.pivot = new Vector2(0.5f, 1f); - goalBg.anchoredPosition = new Vector2(0, -126); goalBg.sizeDelta = new Vector2(360, 16); - _goalFill = MakeFill("GoalFill", goalBg, new Color(0.8f, 0.6f, 1f)); - _goalText = MakeText("GoalText", goalBg, "GOAL 0 / 10", 15, TextAnchor.MiddleCenter, Color.white, font); - - // Downed / respawning overlay (full screen, toggled by Dead). - _respawnOverlay = new GameObject("RespawnOverlay", typeof(RectTransform)); - _respawnOverlay.transform.SetParent(_canvas.transform, false); - var ov = _respawnOverlay.AddComponent(); - ov.texture = Texture2D.whiteTexture; - ov.color = new Color(0.35f, 0f, 0f, 0.35f); - ov.raycastTarget = false; - Stretch((RectTransform)_respawnOverlay.transform); - var rtext = MakeText("RespawnText", _respawnOverlay.transform, "DOWNED - RESPAWNING", 56, - TextAnchor.MiddleCenter, new Color(1f, 0.45f, 0.4f), font); - Stretch(rtext.rectTransform); - _respawnOverlay.SetActive(false); + _paletteRow.Add(root); + _palette[type] = new PaletteItem { Root = root, Cost = costLabel, CostAmount = cost }; } - static RectTransform MakeBar(string name, Transform parent, Color color, Vector2 anchoredPos, Vector2 size) - { - var go = new GameObject(name, typeof(RectTransform)); - go.transform.SetParent(parent, false); - var img = go.AddComponent(); - img.texture = Texture2D.whiteTexture; - img.color = color; - img.raycastTarget = false; - var rt = (RectTransform)go.transform; - rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.zero; rt.pivot = Vector2.zero; - rt.anchoredPosition = anchoredPos; rt.sizeDelta = size; - return rt; - } + // ---- UITK construction ---- - static RectTransform MakeFill(string name, RectTransform parent, Color color) + void BuildTree(VisualElement root) { - var go = new GameObject(name, typeof(RectTransform)); - go.transform.SetParent(parent, false); - var img = go.AddComponent(); - img.texture = Texture2D.whiteTexture; - img.color = color; - img.raycastTarget = false; - var rt = (RectTransform)go.transform; - rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one; rt.pivot = Vector2.zero; - rt.offsetMin = new Vector2(3, 3); rt.offsetMax = new Vector2(-3, -3); - return rt; - } + 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 - static Text MakeText(string name, Transform parent, string text, int size, TextAnchor anchor, Color color, Font font) - { - var go = new GameObject(name, typeof(RectTransform)); - go.transform.SetParent(parent, false); - var t = go.AddComponent(); - t.text = text; - t.font = font; - t.fontSize = size; - t.alignment = anchor; - t.color = color; - t.horizontalOverflow = HorizontalWrapMode.Overflow; - t.verticalOverflow = VerticalWrapMode.Overflow; - t.raycastTarget = false; - Stretch(t.rectTransform); - return t; - } + // Health + cooldown (bottom-left). + var vitals = HudUi.Group(); + vitals.style.position = Position.Absolute; + vitals.style.left = 40; vitals.style.bottom = 40; + var cdBar = HudUi.Bar(440, 12, new Color(0.4f, 0.8f, 1f), out _cooldownFill); + cdBar.style.marginBottom = 6; + var hpBar = HudUi.Bar(440, 40, new Color(0.25f, 0.9f, 0.5f), out _healthFill); + _healthText = HudUi.Text("100 / 100", 22, Color.white, TextAnchor.MiddleCenter); + _healthText.style.position = Position.Absolute; + _healthText.style.left = 0; _healthText.style.right = 0; _healthText.style.top = 0; _healthText.style.bottom = 0; + hpBar.Add(_healthText); + vitals.Add(cdBar); + vitals.Add(hpBar); + root.Add(vitals); - static void Stretch(RectTransform rt) - { - rt.anchorMin = Vector2.zero; rt.anchorMax = Vector2.one; - rt.offsetMin = Vector2.zero; rt.offsetMax = Vector2.zero; - } + // Threat (top-right). + _threatText = HudUi.Text("HUSKS 0", 30, new Color(1f, 0.62f, 0.4f), TextAnchor.UpperRight); + _threatText.style.position = Position.Absolute; + _threatText.style.right = 40; _threatText.style.top = 28; _threatText.style.width = 380; + root.Add(_threatText); - static Font GetFont() - { - Font f = Resources.GetBuiltinResource("LegacyRuntime.ttf"); - if (f == null) f = Resources.GetBuiltinResource("Arial.ttf"); - if (f == null) f = Font.CreateDynamicFontFromOSFont(new[] { "Arial", "Liberation Sans", "DejaVu Sans" }, 28); - return f; + // Macro stack (top-center). + var macro = HudUi.Group(Align.Center); + macro.style.position = Position.Absolute; + macro.style.top = 22; macro.style.left = 0; macro.style.right = 0; + _phaseText = HudUi.Text("", 30, new Color(0.55f, 0.9f, 1f), TextAnchor.MiddleCenter); + _resourceText = HudUi.Text("", 22, new Color(0.7f, 0.95f, 0.8f), TextAnchor.MiddleCenter); + _resourceText.style.marginTop = 4; + _locationText = HudUi.Text("", 18, new Color(0.6f, 0.85f, 1f), TextAnchor.MiddleCenter); + _locationText.style.marginTop = 4; + var goalBar = HudUi.Bar(360, 16, new Color(0.8f, 0.6f, 1f), out _goalFill); + goalBar.style.marginTop = 8; + _goalText = HudUi.Text("GOAL 0 / 10", 13, Color.white, TextAnchor.MiddleCenter); + _goalText.style.position = Position.Absolute; + _goalText.style.left = 0; _goalText.style.right = 0; _goalText.style.top = 0; _goalText.style.bottom = 0; + goalBar.Add(_goalText); + macro.Add(_phaseText); + macro.Add(_resourceText); + macro.Add(_locationText); + macro.Add(goalBar); + root.Add(macro); + + // Build palette row (bottom-center). The row passes clicks through; its buttons pick. + _paletteRow = new VisualElement(); + _paletteRow.style.position = Position.Absolute; + _paletteRow.style.bottom = 24; _paletteRow.style.left = 0; _paletteRow.style.right = 0; + _paletteRow.style.flexDirection = FlexDirection.Row; + _paletteRow.style.justifyContent = Justify.Center; + _paletteRow.pickingMode = PickingMode.Ignore; + root.Add(_paletteRow); + + // Downed overlay. + _downed = new VisualElement(); + _downed.style.position = Position.Absolute; + _downed.style.left = 0; _downed.style.right = 0; _downed.style.top = 0; _downed.style.bottom = 0; + _downed.style.backgroundColor = new Color(0.35f, 0f, 0f, 0.35f); + _downed.style.alignItems = Align.Center; + _downed.style.justifyContent = Justify.Center; + _downed.pickingMode = PickingMode.Ignore; + _downed.Add(HudUi.Text("DOWNED - RESPAWNING", 52, new Color(1f, 0.45f, 0.4f), TextAnchor.MiddleCenter)); + _downed.style.display = DisplayStyle.None; + root.Add(_downed); } static Color PhaseColor(byte phase) @@ -319,5 +311,19 @@ namespace ProjectM.Client default: return ""; } } + + static string StructureName(byte type) + { + switch (type) + { + case StructureType.Turret: return "Turret"; + case StructureType.Wall: return "Wall"; + case StructureType.Pylon: return "Pylon"; + case StructureType.Harvester: return "Harvester"; + case StructureType.Fabricator: return "Fabricator"; + case StructureType.Conveyor: return "Conveyor"; + default: return "?"; + } + } } } diff --git a/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs b/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs index f1e6c82ea..1af64fc14 100644 --- a/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs +++ b/Assets/_Project/Scripts/Client/Presentation/WorldFeedbackSystem.cs @@ -220,7 +220,7 @@ namespace ProjectM.Client static void PlayClip(AudioClip clip, Vector3 pos, float vol) { if (clip == null) return; - AudioSource.PlayClipAtPoint(clip, pos, vol); + AudioSource.PlayClipAtPoint(clip, pos, vol * GameVolume.Sfx); } } } diff --git a/Assets/_Project/Scripts/Client/UI/HudUi.cs b/Assets/_Project/Scripts/Client/UI/HudUi.cs new file mode 100644 index 000000000..b19bdca9a --- /dev/null +++ b/Assets/_Project/Scripts/Client/UI/HudUi.cs @@ -0,0 +1,68 @@ +using UnityEngine; +using UnityEngine.UIElements; + +namespace ProjectM.Client +{ + /// + /// UI Toolkit factories for the in-game HUD — a thin extension of 's Aether palette so the + /// HUD reads in the same visual language as the menu / pause / settings screens. Bars are a dark rounded track + /// + a percent-width fill; labels use the shared text weights/colours. Every element is built + /// pickingMode = Ignore by default so the HUD never eats clicks meant for the game world (only the + /// interactive build-palette buttons opt back into picking). + /// + public static class HudUi + { + public static readonly Color Track = new(0f, 0f, 0f, 0.55f); + + /// A dark rounded bar track with a percent-width fill child (returned via ). + public static VisualElement Bar(float width, float height, Color fillColor, out VisualElement fill) + { + var track = new VisualElement(); + track.style.width = width; + track.style.height = height; + track.style.backgroundColor = Track; + track.style.paddingLeft = 2; track.style.paddingRight = 2; + track.style.paddingTop = 2; track.style.paddingBottom = 2; + track.style.flexDirection = FlexDirection.Row; + track.pickingMode = PickingMode.Ignore; + MenuUi.Round(track, 4); + + fill = new VisualElement(); + fill.style.height = Length.Percent(100); + fill.style.width = Length.Percent(100); + fill.style.backgroundColor = fillColor; + fill.pickingMode = PickingMode.Ignore; + MenuUi.Round(fill, 3); + track.Add(fill); + return track; + } + + /// A bold HUD label (non-interactive). + public static Label Text(string text, int size, Color color, TextAnchor align) + { + var l = new Label(text); + l.style.fontSize = size; + l.style.color = color; + l.style.unityTextAlign = align; + l.style.unityFontStyleAndWeight = FontStyle.Bold; + l.pickingMode = PickingMode.Ignore; + return l; + } + + /// Set a fill's width to a 0..1 fraction of its track. + public static void SetFill(VisualElement fill, float frac) + { + if (fill != null) fill.style.width = Length.Percent(Mathf.Clamp01(frac) * 100f); + } + + /// A semi-transparent rounded panel for grouping a cluster of HUD elements. + public static VisualElement Group(Align items = Align.FlexStart) + { + var g = new VisualElement(); + g.style.alignItems = items; + g.pickingMode = PickingMode.Ignore; + return g; + } + + } +} diff --git a/Assets/_Project/Scripts/Client/UI/HudUi.cs.meta b/Assets/_Project/Scripts/Client/UI/HudUi.cs.meta new file mode 100644 index 000000000..ccee9486d --- /dev/null +++ b/Assets/_Project/Scripts/Client/UI/HudUi.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f598e45d8e87c234186aaec809996041 \ No newline at end of file diff --git a/Assets/_Project/Scripts/Simulation/Building/BuildPreviewMath.cs b/Assets/_Project/Scripts/Simulation/Building/BuildPreviewMath.cs new file mode 100644 index 000000000..abcb1f777 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Building/BuildPreviewMath.cs @@ -0,0 +1,31 @@ +using Unity.Mathematics; + +namespace ProjectM.Simulation +{ + /// + /// Pure validity check for the client build-placement PREVIEW (the ground-ghost colour) — the same legality + /// the server re-validates authoritatively in BuildPlaceSystem, computed client-side so the ghost can read + /// green (valid) vs red (why-not). No managed types / RNG / wall-clock → unit-testable. The caller supplies + /// the live occupancy result + the affordability inputs (it owns the structure scan + the ledger read). + /// + public static class BuildPreviewMath + { + public const byte Valid = 0; + public const byte OutOfPlot = 1; + public const byte Occupied = 2; + public const byte Unaffordable = 3; + + /// + /// Evaluate placement at : must be in-plot, unoccupied, and affordable. + /// = the caller's live-structure cell check; / + /// the resource on hand vs the catalog cost. Returns the first failing reason, else . + /// + public static byte Evaluate(in BaseAnchor anchor, int2 cell, bool occupied, int have, int cost) + { + if (!BaseGridMath.IsCellInPlot(anchor, cell)) return OutOfPlot; + if (occupied) return Occupied; + if (have < cost) return Unaffordable; + return Valid; + } + } +} diff --git a/Assets/_Project/Scripts/Simulation/Building/BuildPreviewMath.cs.meta b/Assets/_Project/Scripts/Simulation/Building/BuildPreviewMath.cs.meta new file mode 100644 index 000000000..1e5bcddb2 --- /dev/null +++ b/Assets/_Project/Scripts/Simulation/Building/BuildPreviewMath.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4deb41f803f65bd4b946354e4a2adcf2 \ No newline at end of file diff --git a/Assets/_Project/Tests/EditMode/BuildPreviewMathTests.cs b/Assets/_Project/Tests/EditMode/BuildPreviewMathTests.cs new file mode 100644 index 000000000..bac797f42 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/BuildPreviewMathTests.cs @@ -0,0 +1,55 @@ +using NUnit.Framework; +using ProjectM.Simulation; +using Unity.Mathematics; + +namespace ProjectM.Tests +{ + /// + /// Pure tests for — the client build-ghost validity (in-plot, unoccupied, + /// affordable) that mirrors the server's authoritative BuildPlaceSystem check, colouring the ground ghost + /// green (valid) vs red (the first failing reason). + /// + public class BuildPreviewMathTests + { + static BaseAnchor Anchor() => new BaseAnchor + { + AnchorPos = new float3(0, 0, 0), + GridOrigin = new float3(0, 0, 0), + CellSize = 1f, + GridDims = new int2(8, 8), + }; + + [Test] + public void InPlot_Unoccupied_Affordable_IsValid() + { + Assert.AreEqual(BuildPreviewMath.Valid, + BuildPreviewMath.Evaluate(Anchor(), new int2(3, 3), occupied: false, have: 50, cost: 20)); + } + + [Test] + public void OutOfPlot_Reported_First() + { + Assert.AreEqual(BuildPreviewMath.OutOfPlot, + BuildPreviewMath.Evaluate(Anchor(), new int2(99, 0), occupied: true, have: 0, cost: 999), + "Out-of-plot is reported before occupancy / cost."); + Assert.AreEqual(BuildPreviewMath.OutOfPlot, + BuildPreviewMath.Evaluate(Anchor(), new int2(-1, 3), occupied: false, have: 50, cost: 10)); + } + + [Test] + public void Occupied_Cell_IsBlocked() + { + Assert.AreEqual(BuildPreviewMath.Occupied, + BuildPreviewMath.Evaluate(Anchor(), new int2(3, 3), occupied: true, have: 50, cost: 10)); + } + + [Test] + public void Unaffordable_When_Have_Below_Cost_Exact_Funds_Ok() + { + Assert.AreEqual(BuildPreviewMath.Unaffordable, + BuildPreviewMath.Evaluate(Anchor(), new int2(3, 3), occupied: false, have: 5, cost: 20)); + Assert.AreEqual(BuildPreviewMath.Valid, + BuildPreviewMath.Evaluate(Anchor(), new int2(3, 3), occupied: false, have: 20, cost: 20)); + } + } +} diff --git a/Assets/_Project/Tests/EditMode/BuildPreviewMathTests.cs.meta b/Assets/_Project/Tests/EditMode/BuildPreviewMathTests.cs.meta new file mode 100644 index 000000000..122020578 --- /dev/null +++ b/Assets/_Project/Tests/EditMode/BuildPreviewMathTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2d3f16d92d3045044a9efd5545320111 \ No newline at end of file