using UnityEngine; namespace Heroes.Battle { /// /// Component that materializes a BattlefieldConfig on the scene: swaps the /// background sprite, instantiates hex prefabs for the playable grid, and /// spawns obstacle sprites at their anchor hexes. /// /// Layout follows VCMI's BattleFieldController but auto-derives the pixel /// step from the hex prefab's sprite (pointy-top tessellation) so that /// hexes never overlap for an arbitrary sprite size. The grid is /// auto-centered around the view origin; fine-tune with . /// /// Works in edit mode (used by the editor window) and at runtime. /// [ExecuteAlways] [DisallowMultipleComponent] public sealed class BattlefieldView : MonoBehaviour { [Header("References")] public ObstacleDatabase database; [Tooltip("Prefab for a single hex cell. Expected to have a SpriteRenderer.")] public GameObject hexPrefab; [Tooltip("Material applied to every obstacle SpriteRenderer. Typically the shared BattleObstacle material.")] public Material obstacleMaterial; [Header("Layout (pixel space)")] [Tooltip("Pixels per unit used for world-space conversion. Should match your sprite imports.")] [Min(1)] public int pixelsPerUnit = BattlefieldConfig.ReferencePixelsPerUnit; [Tooltip("Auto-compute hex spacing from the hex prefab's sprite (pointy-top tessellation: step = sprite width, " + "row step = 0.75 * sprite height). Disable to use the manual values below.")] public bool autoComputeSpacing = true; [Tooltip("Manual horizontal step in pixels (used only when autoComputeSpacing is off).")] [Min(1)] public int hexHorizontalStepPx = BattlefieldConfig.HexHorizontalStepPx; [Tooltip("Manual vertical step in pixels (used only when autoComputeSpacing is off).")] [Min(1)] public int hexVerticalStepPx = BattlefieldConfig.HexVerticalStepPx; [Tooltip("Manual horizontal shift of odd rows, in pixels (used only when autoComputeSpacing is off).")] public int oddRowOffsetPx = BattlefieldConfig.OddRowOffsetPx; [Tooltip("Pixel offset applied to the auto-centered grid position. " + "Use to shift the grid within the background's safe zone.")] public Vector2 gridOffsetPx = Vector2.zero; [Header("Background")] [Tooltip("Additional pixel offset applied to the background position after auto-fit. " + "Use to nudge the artwork without breaking the grid alignment.")] public Vector2 backgroundOffsetPx = Vector2.zero; [Tooltip("If true, the background sprite is rescaled so it renders at pixelsPerUnit regardless of its import PPU.")] public bool normalizeBackgroundScale = true; [Tooltip("If true, the background is uniformly scaled and positioned so the hex grid fits inside its safe zone.")] public bool autoFitBackgroundToGrid = true; [Tooltip("Safe zone rectangle inside the background sprite, in normalized (0..1) coords. " + "Origin is bottom-left. When autoFitBackgroundToGrid is on, the grid is scaled to " + "cover this region — keep the sky/horizon OUTSIDE it so hexes do not land on it. " + "Defaults match an H3-style background with ~17% sky at the top and a small bottom margin.")] public Rect backgroundSafeZone = new Rect(0.03f, 0.05f, 0.94f, 0.78f); [Header("Sorting")] public int backgroundSortingOrder = 0; public int hexSortingOrder = 1; public int obstacleSortingOrder = 2; [Header("Optional")] [Tooltip("If enabled, the hex grid layer is drawn. Disable to show just the background and obstacles.")] public bool showHexGrid = true; [Tooltip("If true, OnDrawGizmos draws the outline of the hex grid to help tuning the layout.")] public bool drawGridGizmo = true; private Transform _backgroundRoot; private Transform _hexRoot; private Transform _obstacleRoot; private SpriteRenderer _backgroundRenderer; // Effective spacing, resolved at every HexToWorld call. private int _stepXpx; private int _stepYpx; private int _offXpx; private Vector2 _originPx; /// Effective horizontal step in pixels after resolution. public int EffectiveHorizontalStepPx { get { ResolveLayout(); return _stepXpx; } } /// Effective vertical step in pixels after resolution. public int EffectiveVerticalStepPx { get { ResolveLayout(); return _stepYpx; } } /// Effective odd-row shift in pixels after resolution. public int EffectiveOddRowOffsetPx { get { ResolveLayout(); return _offXpx; } } /// Effective pixels-per-unit used for world conversion (never below 1). public int EffectivePixelsPerUnit => Mathf.Max(1, pixelsPerUnit); /// Deploys the given configuration on the scene. public void Deploy(BattlefieldConfig config) { if (config == null) { Debug.LogWarning($"{nameof(BattlefieldView)}: Deploy called with null config.", this); return; } if (database == null) { Debug.LogError($"{nameof(BattlefieldView)}: obstacle database reference is missing.", this); return; } if (hexPrefab == null) { Debug.LogError($"{nameof(BattlefieldView)}: hex prefab reference is missing.", this); return; } EnsureRoots(); ClearChildren(_hexRoot); ClearChildren(_obstacleRoot); ApplyBackground(config); var occupied = ComputeOccupancy(config); if (showHexGrid) BuildHexGrid(occupied); PlaceObstacles(config); } /// /// Applies H3-style default layout values: safe zone = 78% of bg height with /// 17% sky reserved on top; 3% side margins; no extra grid offset. /// Useful when resetting an already-serialized component. /// [ContextMenu("Apply H3 Defaults")] public void ApplyH3Defaults() { gridOffsetPx = Vector2.zero; backgroundSafeZone = new Rect(0.03f, 0.05f, 0.94f, 0.78f); autoFitBackgroundToGrid = true; autoComputeSpacing = true; normalizeBackgroundScale = true; } /// Removes generated children and clears the background sprite. public void Clear() { EnsureRoots(); ClearChildren(_hexRoot); ClearChildren(_obstacleRoot); if (_backgroundRenderer != null) _backgroundRenderer.sprite = null; } /// World-space position of a logical hex (col, row). public Vector3 HexToWorld(int col, int row) { ResolveLayout(); float px = _originPx.x + col * _stepXpx + ((row & 1) != 0 ? _offXpx : 0); float py = _originPx.y - row * _stepYpx; float ppu = Mathf.Max(1, pixelsPerUnit); return new Vector3(px / ppu, py / ppu, 0f); } private void ResolveLayout() { if (autoComputeSpacing && TryGetHexSpritePixelSize(out float spriteWpx, out float spriteHpx)) { _stepXpx = Mathf.Max(1, Mathf.RoundToInt(spriteWpx)); _stepYpx = Mathf.Max(1, Mathf.RoundToInt(spriteHpx * 0.75f)); _offXpx = Mathf.RoundToInt(spriteWpx * 0.5f); } else { _stepXpx = Mathf.Max(1, hexHorizontalStepPx); _stepYpx = Mathf.Max(1, hexVerticalStepPx); _offXpx = oddRowOffsetPx; } // Center the grid around (0,0) plus manual offset. The horizontal span is // (Width-1)*stepX on even rows and (Width-1)*stepX + offX on odd rows, so the // full rectangle goes from 0 to (Width-1)*stepX + offX in x. float halfSpanX = ((BattlefieldConfig.Width - 1) * _stepXpx + _offXpx) * 0.5f; float halfSpanY = (BattlefieldConfig.Height - 1) * _stepYpx * 0.5f; _originPx = new Vector2(-halfSpanX + gridOffsetPx.x, halfSpanY + gridOffsetPx.y); } private bool TryGetHexSpritePixelSize(out float widthPx, out float heightPx) { widthPx = 0f; heightPx = 0f; if (hexPrefab == null) return false; var sr = hexPrefab.GetComponent(); if (sr == null || sr.sprite == null) return false; var sprite = sr.sprite; float spritePpu = Mathf.Max(1f, sprite.pixelsPerUnit); float viewPpu = Mathf.Max(1f, pixelsPerUnit); float scale = viewPpu / spritePpu; widthPx = sprite.rect.width * scale; heightPx = sprite.rect.height * scale; return widthPx > 0f && heightPx > 0f; } /// AABB of the full hex grid in local/world space (at z=0). public Bounds GetGridBounds() { var tl = HexToWorld(0, 0); var tr = HexToWorld(BattlefieldConfig.Width - 1, 0); var bl = HexToWorld(0, BattlefieldConfig.Height - 1); var br = HexToWorld(BattlefieldConfig.Width - 1, BattlefieldConfig.Height - 1); float minX = Mathf.Min(Mathf.Min(tl.x, tr.x), Mathf.Min(bl.x, br.x)); float maxX = Mathf.Max(Mathf.Max(tl.x, tr.x), Mathf.Max(bl.x, br.x)); float minY = Mathf.Min(Mathf.Min(tl.y, tr.y), Mathf.Min(bl.y, br.y)); float maxY = Mathf.Max(Mathf.Max(tl.y, tr.y), Mathf.Max(bl.y, br.y)); var center = new Vector3((minX + maxX) * 0.5f, (minY + maxY) * 0.5f, 0f); var size = new Vector3(maxX - minX, maxY - minY, 0f); return new Bounds(center, size); } private void EnsureRoots() { _backgroundRoot = GetOrCreateChild("Background"); _hexRoot = GetOrCreateChild("Hexes"); _obstacleRoot = GetOrCreateChild("Obstacles"); _backgroundRenderer = _backgroundRoot.GetComponent(); if (_backgroundRenderer == null) { _backgroundRenderer = _backgroundRoot.gameObject.AddComponent(); } _backgroundRenderer.sortingOrder = backgroundSortingOrder; } private Transform GetOrCreateChild(string childName) { var existing = transform.Find(childName); if (existing != null) return existing; var go = new GameObject(childName); go.transform.SetParent(transform, false); return go.transform; } private void ApplyBackground(BattlefieldConfig config) { var bg = database.FindBackgrounds(config.Terrain); Sprite sprite = bg != null ? bg.GetVariantById(config.BackgroundId) : null; _backgroundRenderer.sprite = sprite; _backgroundRenderer.sortingOrder = backgroundSortingOrder; _backgroundRoot.localRotation = Quaternion.identity; if (sprite == null) { _backgroundRoot.localPosition = Vector3.zero; _backgroundRoot.localScale = Vector3.one; return; } float viewPpu = EffectivePixelsPerUnit; float spritePpu = Mathf.Max(1f, sprite.pixelsPerUnit); Vector2 bgPx = sprite.rect.size; float baseScale = normalizeBackgroundScale ? spritePpu / viewPpu : 1f; float fitScale = 1f; if (autoFitBackgroundToGrid) { Vector2 gridPx = GetGridSizePx(); float safeW = Mathf.Max(0.001f, backgroundSafeZone.width); float safeH = Mathf.Max(0.001f, backgroundSafeZone.height); float needX = gridPx.x / (bgPx.x * safeW); float needY = gridPx.y / (bgPx.y * safeH); fitScale = Mathf.Max(1f, Mathf.Max(needX, needY)); } float totalScale = baseScale * fitScale; _backgroundRoot.localScale = new Vector3(totalScale, totalScale, 1f); // Align the safe-zone center with the (auto-centered) grid center. Vector2 gridCenterWorld = gridOffsetPx / viewPpu; if (autoFitBackgroundToGrid) { Vector2 bgWorldSize = (bgPx / spritePpu) * totalScale; Vector2 pivotNorm = new Vector2(sprite.pivot.x / bgPx.x, sprite.pivot.y / bgPx.y); Vector2 safeCenterNorm = new Vector2( backgroundSafeZone.x + backgroundSafeZone.width * 0.5f, backgroundSafeZone.y + backgroundSafeZone.height * 0.5f); Vector2 safeCenterLocal = new Vector2( (safeCenterNorm.x - pivotNorm.x) * bgWorldSize.x, (safeCenterNorm.y - pivotNorm.y) * bgWorldSize.y); Vector2 bgPos = gridCenterWorld - safeCenterLocal + backgroundOffsetPx / viewPpu; _backgroundRoot.localPosition = new Vector3(bgPos.x, bgPos.y, 0f); } else { _backgroundRoot.localPosition = new Vector3(backgroundOffsetPx.x / viewPpu, backgroundOffsetPx.y / viewPpu, 0f); } } /// Full grid size in pixels (at view PPU), including the odd-row horizontal shift. public Vector2 GetGridSizePx() { ResolveLayout(); float w = (BattlefieldConfig.Width - 1) * _stepXpx + _offXpx; float h = (BattlefieldConfig.Height - 1) * _stepYpx; return new Vector2(w, h); } private void BuildHexGrid(bool[,] occupied) { for (int y = 0; y < BattlefieldConfig.Height; y++) { for (int x = 0; x < BattlefieldConfig.Width; x++) { var go = InstantiateChild(hexPrefab, _hexRoot); go.name = $"Hex_{x}_{y}"; go.transform.localPosition = HexToWorld(x, y); go.transform.localRotation = Quaternion.identity; var sr = go.GetComponent(); if (sr != null) sr.sortingOrder = hexSortingOrder; if (occupied[x, y]) go.SetActive(false); } } } private bool[,] ComputeOccupancy(BattlefieldConfig config) { var occupied = new bool[BattlefieldConfig.Width, BattlefieldConfig.Height]; foreach (var placed in config.Obstacles) { var definition = database.FindById(placed.ObstacleId); if (definition == null || definition.blockedHexes == null) continue; foreach (var offset in definition.blockedHexes) { int x = placed.X + offset.dx; int y = placed.Y + offset.dy; if (x < 0 || x >= BattlefieldConfig.Width) continue; if (y < 0 || y >= BattlefieldConfig.Height) continue; occupied[x, y] = true; } } return occupied; } private void PlaceObstacles(BattlefieldConfig config) { foreach (var placed in config.Obstacles) { var definition = database.FindById(placed.ObstacleId); if (definition == null || definition.sprite == null) continue; var go = new GameObject($"Obs_{placed.ObstacleId}_{placed.X}_{placed.Y}"); go.transform.SetParent(_obstacleRoot, false); go.transform.localPosition = HexToWorld(placed.X, placed.Y) + (Vector3)definition.spriteOffset; var sr = go.AddComponent(); sr.sprite = definition.sprite; sr.sortingOrder = obstacleSortingOrder; if (obstacleMaterial != null) sr.sharedMaterial = obstacleMaterial; } } private GameObject InstantiateChild(GameObject prefab, Transform parent) { #if UNITY_EDITOR if (!Application.isPlaying) { var prefabInstance = UnityEditor.PrefabUtility.InstantiatePrefab(prefab, parent) as GameObject; if (prefabInstance != null) return prefabInstance; } #endif return Instantiate(prefab, parent); } private void ClearChildren(Transform root) { if (root == null) return; for (int i = root.childCount - 1; i >= 0; i--) { var child = root.GetChild(i).gameObject; if (Application.isPlaying) Destroy(child); else DestroyImmediate(child); } } private void OnDrawGizmos() { if (!drawGridGizmo) return; var prevMatrix = Gizmos.matrix; Gizmos.matrix = transform.localToWorldMatrix; Gizmos.color = new Color(0.2f, 1f, 0.3f, 0.8f); var bounds = GetGridBounds(); Gizmos.DrawWireCube(bounds.center, bounds.size); Gizmos.color = new Color(1f, 1f, 0f, 0.6f); Gizmos.DrawSphere(HexToWorld(0, 0), 0.05f); Gizmos.DrawSphere(HexToWorld(BattlefieldConfig.Width - 1, BattlefieldConfig.Height - 1), 0.05f); if (_backgroundRenderer != null && _backgroundRenderer.sprite != null && autoFitBackgroundToGrid) { var sprite = _backgroundRenderer.sprite; Vector2 bgWorldSize = sprite.rect.size / Mathf.Max(1f, sprite.pixelsPerUnit) * _backgroundRoot.localScale.x; Vector2 pivotNorm = new Vector2(sprite.pivot.x / sprite.rect.size.x, sprite.pivot.y / sprite.rect.size.y); Vector2 safeMinLocal = new Vector2( (backgroundSafeZone.x - pivotNorm.x) * bgWorldSize.x, (backgroundSafeZone.y - pivotNorm.y) * bgWorldSize.y); Vector2 safeSize = new Vector2(backgroundSafeZone.width * bgWorldSize.x, backgroundSafeZone.height * bgWorldSize.y); Vector3 bgLocalPos = _backgroundRoot.localPosition; Vector3 safeCenter = bgLocalPos + new Vector3(safeMinLocal.x + safeSize.x * 0.5f, safeMinLocal.y + safeSize.y * 0.5f, 0f); Gizmos.color = new Color(1f, 0.4f, 0.2f, 0.5f); Gizmos.DrawWireCube(safeCenter, new Vector3(safeSize.x, safeSize.y, 0f)); } Gizmos.matrix = prevMatrix; } } }