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;
}
}
}