using System.Collections.Generic; using BlockOutReplica.Domain; using BlockOutReplica.Gameplay; using UnityEngine; namespace BlockOutReplica.Presentation { public sealed class LevelViewFactory { private static readonly Color Purple = new Color(0.225f, 0.155f, 0.57f); private static readonly Color PurpleHighlight = new Color(0.285f, 0.215f, 0.67f); private static readonly Color PurpleShadow = new Color(0.055f, 0.035f, 0.16f); private static readonly Color DeepPurple = new Color(0.075f, 0.065f, 0.20f); private static readonly Color CellPurple = new Color(0.105f, 0.09f, 0.27f); private readonly Transform root; private readonly Material baseMaterial; private readonly List generated; public LevelViewFactory(Transform root, Material material, List generated) { this.root = root; baseMaterial = material; this.generated = generated; } public void BuildBoard(LevelDefinition level) { float width = level.MaxX - level.MinX + 1f; float height = level.MaxY - level.MinY + 1f; GameObject shadow = CreateCube("Board Drop Shadow", new Vector3(.12f, -0.38f, -.14f), new Vector3(width + .34f, .20f, height + .34f), PurpleShadow); Object.Destroy(shadow.GetComponent()); GameObject back = CreateCube("Board Back", new Vector3(0f, -0.30f, 0f), new Vector3(width + .08f, 0.30f, height + .08f), DeepPurple); Object.Destroy(back.GetComponent()); Sprite cellSprite = OriginalAssetCatalog.LoadBoardCell(); for (int i = 0; i < level.Cells.Count; i++) { GridPosition cell = level.Cells[i]; if (cellSprite != null) { Color tint = (cell.X + cell.Y) % 2 == 0 ? Color.white : new Color(.91f, .91f, .98f); CreateFlatSprite($"Cell {cell.X},{cell.Y}", cellSprite, level.GridToWorld(cell, -0.06f), new Vector2(.965f, .965f), tint, 2); } else { Color color = (cell.X + cell.Y) % 2 == 0 ? CellPurple : CellPurple * .91f; GameObject cellObject = CreateCube($"Cell {cell.X},{cell.Y}", level.GridToWorld(cell, -0.10f), new Vector3(.965f, 0.20f, .965f), color); Object.Destroy(cellObject.GetComponent()); } } if (BuildRecoveredWalls(level)) return; Sprite wallSprite = OriginalAssetCatalog.LoadWallTile(); HashSet wallSet = new HashSet(level.Walls); for (int i = 0; i < level.Walls.Count; i++) { GridPosition wall = level.Walls[i]; Vector3 wallPosition = level.GridToWorld(wall, 0.08f); if (wallSprite == null) { GameObject fallback = CreateCube($"Wall {wall.X},{wall.Y}", wallPosition, new Vector3(1.035f, 0.44f, 1.035f), Purple); Object.Destroy(fallback.GetComponent()); continue; } GameObject wallBody = CreateCube($"Wall Body {wall.X},{wall.Y}", wallPosition + Vector3.down * .19f, new Vector3(1.055f, .30f, 1.055f), Purple); Object.Destroy(wallBody.GetComponent()); CreateFlatSprite($"Wall Shadow {wall.X},{wall.Y}", wallSprite, wallPosition + new Vector3(.075f, -.04f, -.075f), new Vector2(1.09f, 1.09f), PurpleShadow, 8); CreateFlatSprite($"Wall {wall.X},{wall.Y}", wallSprite, wallPosition, new Vector2(1.04f, 1.04f), Purple, 10); CreateFlatSprite($"Wall Highlight {wall.X},{wall.Y}", wallSprite, wallPosition + new Vector3(-.018f, .025f, .018f), new Vector2(.94f, .94f), PurpleHighlight, 11); GridPosition right = new GridPosition(wall.X + 1, wall.Y); if (wallSet.Contains(right)) CreateWallBridge(wallSprite, wallPosition + Vector3.right * .5f, true); GridPosition up = new GridPosition(wall.X, wall.Y + 1); if (wallSet.Contains(up)) CreateWallBridge(wallSprite, wallPosition + Vector3.forward * .5f, false); } } // Also used by the isolated editor bounds validation, without creating // floor primitives or invoking gameplay/controller code. public bool BuildRecoveredWalls(LevelDefinition level) { Sprite top = Resources.Load("OriginalRecovered/LinearX-Up"); if (top == null) return false; var corners = new Dictionary(); foreach (string key in new[] { "ConvexLeftTop", "ConvexRightTop", "ConvexLeftBottom", "ConvexRightBottom" }) { Sprite art = Resources.Load("OriginalRecovered/" + key); if (art == null) return false; corners.Add(key, art); } var wallSet = new HashSet(level.Walls); var visited = new HashSet(); var orderedWalls = new List(level.Walls); orderedWalls.Sort((a, b) => a.Y != b.Y ? a.Y.CompareTo(b.Y) : a.X.CompareTo(b.X)); foreach (GridPosition cell in orderedWalls) { if (visited.Contains(cell)) continue; bool left = cell.X < level.MinX, right = cell.X > level.MaxX; bool down = cell.Y < level.MinY, up = cell.Y > level.MaxY; bool corner = (left || right) && (up || down); string name = corner ? "Convex" + (left ? "Left" : "Right") + (up ? "Top" : "Bottom") : up ? "LinearX-Up" : down ? "LinearX-Down_0" : "LinearX-Up"; Sprite sprite = corner ? corners[name] : Resources.Load("OriginalRecovered/" + name); if (sprite == null) sprite = top; string cornerName = BoardRimLayout.ProfileNameFor(right ? DoorSide.Right : down ? DoorSide.Bottom : DoorSide.Top); Sprite profile = corner ? sprite : corners[cornerName]; // The original corner meshes are .66x.82 (top) and .66x.74 // (bottom). Preserve their cross-section instead of squeezing // bottom corners to .68 and applying unrelated hand offsets. Vector2 artSize = profile.rect.size / profile.pixelsPerUnit; if (!corner) { // Continue the exact cross-section of a wall corner. BorderLeft // belongs to another UI frame; it is not this board's purple wall. bool horizontalStrip = up || down; sprite = BoardRimLayout.CreateStraightSprite(profile, horizontalStrip); } int length = 1; if (!corner) { bool horizontal = up || down; GridPosition next = new GridPosition(cell.X + (horizontal ? 1 : 0), cell.Y + (horizontal ? 0 : 1)); while (wallSet.Contains(next) && !visited.Contains(next) && (horizontal ? next.X <= level.MaxX : next.Y <= level.MaxY)) { visited.Add(next); length++; next = new GridPosition(next.X + (horizontal ? 1 : 0), next.Y + (horizontal ? 0 : 1)); } } visited.Add(cell); Rect bounds = BoardRimLayout.WorldBounds(level, cell, length, artSize); Vector3 position = new Vector3(bounds.center.x, BoardRimLayout.SurfaceHeight, bounds.center.y); Vector2 size = bounds.size; CreateCenteredWallSprite("Continuous Rim Shadow", sprite, position + new Vector3(.02f, -.07f, -.14f), size, new Color(.32f, .30f, .60f), 8); CreateCenteredWallSprite($"Original Continuous Rim {name} [{cell.X},{cell.Y};{length}]", sprite, position, size, Color.white, 10); } return true; } private void CreateCenteredWallSprite(string name, Sprite sprite, Vector3 center, Vector2 size, Color color, int sortingOrder) { Vector3 origin = BoardRimLayout.CenteredSpriteOrigin(center, size, sprite.bounds.size, sprite.bounds.center); CreateFlatSprite(name, sprite, origin, size, color, sortingOrder); } private void CreateWallBridge(Sprite sprite, Vector3 position, bool horizontal) { Vector2 size = horizontal ? new Vector2(.88f, .99f) : new Vector2(.99f, .88f); CreateFlatSprite("Wall Connection Shadow", sprite, position + new Vector3(.075f, -.04f, -.075f), size * 1.05f, PurpleShadow, 8); CreateFlatSprite("Wall Connection", sprite, position, size, Purple, 10); CreateFlatSprite("Wall Connection Highlight", sprite, position + new Vector3(-.018f, .025f, .018f), size * .91f, PurpleHighlight, 11); } public DoorView CreateDoor(LevelDefinition level, DoorDefinition definition, int index) { Vector3 position = level.AverageWorld(definition.Cells, 0.28f); bool horizontal = definition.Side == DoorSide.Top || definition.Side == DoorSide.Bottom; Vector3 inward = definition.Side == DoorSide.Top ? Vector3.back : definition.Side == DoorSide.Bottom ? Vector3.forward : definition.Side == DoorSide.Left ? Vector3.right : Vector3.left; position += inward * (horizontal ? .08f : .16f); Vector3 scale = horizontal ? new Vector3(definition.Cells.Count, 0.58f, 0.72f) : new Vector3(0.72f, 0.58f, definition.Cells.Count); GameObject door = new GameObject($"Door {index} Color {definition.ColorId}"); door.transform.SetParent(root); door.transform.position = position; generated.Add(door); DoorView doorView = door.AddComponent(); doorView.ConfigureWallClosure(level, definition); GameObject visualRootObject = new GameObject("Animated Door Root"); visualRootObject.transform.SetParent(door.transform, false); Transform visualRoot = visualRootObject.transform; string prefabName = $"DoorPiece{definition.Side}{Mathf.Clamp(definition.Cells.Count, 1, 3)}"; GameObject recoveredPrefab = Resources.Load("OriginalDoorFx/Prefabs/" + prefabName); if (recoveredPrefab != null && definition.Cells.Count <= 3) { GameObject recovered = Object.Instantiate(recoveredPrefab, visualRoot); recovered.name = "Recovered Original Door Visual"; recovered.transform.localPosition = Vector3.zero; recovered.transform.localRotation = Quaternion.identity; recovered.transform.localScale = Vector3.one; // Source prefabs use XY. Center their native panel, then map XY to // the replica's XZ board without rotating individual particles. Transform front = recovered.transform.Find("DoorViewRoot/DoorRoot/DoorImage"); Vector3 sourceCenter = front != null ? front.position - recovered.transform.position : Vector3.zero; recovered.transform.localRotation = Quaternion.Euler(90f, 0f, 0f); recovered.transform.localPosition = -(recovered.transform.localRotation * sourceCenter); var effect = recovered.AddComponent(); effect.Configure(definition.ColorId, definition.Side); doorView.Configure(definition.ColorId, definition.Side, visualRoot); doorView.ConfigureRecovered(effect); return doorView; } Color doorColor = OriginalAssetCatalog.FallbackColor(definition.ColorId); Sprite panelFill = OriginalAssetCatalog.LoadTile(definition.ColorId, 4); if (panelFill != null) { Vector2 panel = horizontal ? new Vector2(definition.Cells.Count, .84f) : new Vector2(.68f, definition.Cells.Count); float bevel = .16f; float[] sizesX = { bevel, panel.x - bevel * 2f, bevel }; float[] sizesZ = { bevel, panel.y - bevel * 2f, bevel }; float[] centersX = { -(panel.x - bevel) * .5f, 0f, (panel.x - bevel) * .5f }; float[] centersZ = { -(panel.y - bevel) * .5f, 0f, (panel.y - bevel) * .5f }; for (int row = 0; row < 3; row++) for (int column = 0; column < 3; column++) { Sprite slice = OriginalAssetCatalog.LoadTile(definition.ColorId, (2 - row) * 3 + column); CreatePieceSlice("Original Door Bevel", visualRoot, slice, new Vector3(centersX[column], .15f, centersZ[row]), new Vector2(sizesX[column] + .002f, sizesZ[row] + .002f), Color.white, 20); } Sprite arrow = OriginalAssetCatalog.LoadDoor(definition.ColorId, definition.Side); if (arrow != null) { Vector2 arrowSize = arrow.bounds.size; float arrowScale = .68f / Mathf.Max(arrowSize.x, arrowSize.y); CreatePieceSlice("Original Door Arrow", visualRoot, arrow, new Vector3(0f, .22f, 0f), arrowSize * arrowScale, Color.white, 22); } doorView.Configure(definition.ColorId, definition.Side, visualRoot); return doorView; } Vector3 panelSize = horizontal ? new Vector3(Mathf.Max(.92f, definition.Cells.Count - .04f), .28f, .82f) : new Vector3(.82f, .28f, Mathf.Max(.92f, definition.Cells.Count - .04f)); CreateDoorCube("Door Shadow", visualRoot, new Vector3(.055f, -.10f, -.055f), panelSize + new Vector3(.08f, .02f, .08f), Color.Lerp(doorColor, Color.black, .67f)); CreateDoorCube("Door Panel", visualRoot, new Vector3(0f, .04f, 0f), panelSize, doorColor); Vector3 shineSize = horizontal ? new Vector3(panelSize.x * .88f, .035f, .12f) : new Vector3(.12f, .035f, panelSize.z * .88f); Vector3 shinePosition = horizontal ? new Vector3(0f, .195f, .24f) : new Vector3(-.24f, .195f, 0f); CreateDoorCube("Door Highlight", visualRoot, shinePosition, shineSize, Color.Lerp(doorColor, Color.white, .34f)); CreateDoorGlow("Door Inner Glow", visualRoot, horizontal, panelSize, Color.Lerp(doorColor, Color.white, .24f), .12f); CreateDoorGlow("Door Outer Glow", visualRoot, horizontal, panelSize, doorColor, .05f); Sprite sprite = OriginalAssetCatalog.LoadDoor(definition.ColorId, definition.Side); if (sprite == null) { GameObject fallback = CreateCube("Fallback Door", position, scale, doorColor); fallback.transform.SetParent(visualRoot, true); Object.Destroy(fallback.GetComponent()); doorView.Configure(definition.ColorId, definition.Side, visualRoot); return doorView; } GameObject visual = new GameObject("Original Door Sprite"); visual.transform.SetParent(visualRoot, false); visual.transform.localPosition = new Vector3(0f, 0.25f, 0f); visual.transform.localRotation = Quaternion.Euler(90f, 0f, 0f); float targetWidth = horizontal ? Mathf.Min(1.15f, definition.Cells.Count * .58f) : .38f; float targetHeight = horizontal ? .38f : Mathf.Min(1.15f, definition.Cells.Count * .58f); visual.transform.localScale = new Vector3( targetWidth / Mathf.Max(0.001f, sprite.bounds.size.x), targetHeight / Mathf.Max(0.001f, sprite.bounds.size.y), 1f); SpriteRenderer renderer = visual.AddComponent(); renderer.sprite = sprite; renderer.sortingOrder = 22; doorView.Configure(definition.ColorId, definition.Side, visualRoot); return doorView; } private void CreateDoorCube(string name, Transform parent, Vector3 localPosition, Vector3 size, Color color) { GameObject item = GameObject.CreatePrimitive(PrimitiveType.Cube); item.name = name; item.transform.SetParent(parent, false); item.transform.localPosition = localPosition; item.transform.localScale = size; Object.Destroy(item.GetComponent()); ApplyColor(item.GetComponent(), color); } private void CreateDoorGlow(string name, Transform parent, bool horizontal, Vector3 panelSize, Color color, float height) { GameObject glow = GameObject.CreatePrimitive(PrimitiveType.Cube); glow.name = name; glow.transform.SetParent(parent, false); glow.transform.localPosition = new Vector3(0f, height, 0f); glow.transform.localScale = horizontal ? new Vector3(panelSize.x * 1.12f, .018f, panelSize.z * 1.22f) : new Vector3(panelSize.x * 1.22f, .018f, panelSize.z * 1.12f); Object.Destroy(glow.GetComponent()); color.a = 0f; ApplyColor(glow.GetComponent(), color); } public BlockPieceView CreatePiece(LevelDefinition level, PieceDefinition definition, int index) { GetBounds(definition, out int minX, out int maxX, out int minY, out int maxY); Vector2 size = new Vector2(maxX - minX + 1, maxY - minY + 1); GameObject piece = new GameObject($"Piece {index} Color {definition.ColorId}"); piece.transform.SetParent(root); Vector3 minWorld = level.GridToWorld(new GridPosition(minX, minY), 0.28f); Vector3 maxWorld = level.GridToWorld(new GridPosition(maxX, maxY), 0.28f); piece.transform.position = (minWorld + maxWorld) * 0.5f; piece.AddComponent().size = new Vector3(size.x - 0.08f, 0.48f, size.y - 0.08f); generated.Add(piece); GameObject visualRootObject = new GameObject("Animated Visual Root"); visualRootObject.transform.SetParent(piece.transform, false); Transform visualRoot = visualRootObject.transform; BlockPieceView view = piece.AddComponent(); if (!CreateOriginalVisual(visualRoot, piece.transform, level, definition)) { GameObject fallback = CreateCube("Fallback Body", piece.transform.position, new Vector3(size.x - 0.08f, 0.48f, size.y - 0.08f), OriginalAssetCatalog.FallbackColor(definition.ColorId)); fallback.transform.SetParent(visualRoot, true); Object.Destroy(fallback.GetComponent()); } view.Configure(definition, size, level, visualRoot); return view; } private bool CreateOriginalVisual( Transform visualRoot, Transform piece, LevelDefinition level, PieceDefinition definition) { HashSet occupied = new HashSet(definition.Cells); for (int i = 0; i < definition.Cells.Count; i++) { GridPosition cell = definition.Cells[i]; Vector3 world = level.GridToWorld(cell, 0.55f); Vector3 local = piece.InverseTransformPoint(world); bool up = !occupied.Contains(new GridPosition(cell.X, cell.Y + 1)); bool down = !occupied.Contains(new GridPosition(cell.X, cell.Y - 1)); bool left = !occupied.Contains(new GridPosition(cell.X - 1, cell.Y)); bool right = !occupied.Contains(new GridPosition(cell.X + 1, cell.Y)); // The original unit has nine separate renderers at +/-0.32. // Only exposed edges receive a bevel; adjacent cells share a flat fill. float[] centers = { -.34f, 0f, .34f }; float[] widths = { .32f, .36f, .32f }; for (int row = 0; row < 3; row++) for (int column = 0; column < 3; column++) { int sliceRow = row == 2 && up ? 0 : row == 0 && down ? 2 : 1; int sliceColumn = column == 0 && left ? 0 : column == 2 && right ? 2 : 1; int tileIndex = sliceRow * 3 + sliceColumn; Sprite sprite = OriginalAssetCatalog.LoadTile(definition.ColorId, tileIndex); if (sprite == null) return false; Vector3 offset = local + new Vector3(centers[column], 0f, centers[row]); Vector2 size = new Vector2(widths[column] + .002f, widths[row] + .002f); CreatePieceSlice("Block Contact Shadow", visualRoot, sprite, offset + new Vector3(.025f, -.1f, -.075f), size, new Color(.08f, .06f, .14f, .65f), 27); CreatePieceSlice($"Original Nine Slice {tileIndex}", visualRoot, sprite, offset, size, Color.white, 30); } } CreateStuds(visualRoot, piece, level, definition); return true; } private static void CreatePieceSlice(string name, Transform root, Sprite sprite, Vector3 position, Vector2 size, Color color, int sortOrder) { var slice = new GameObject(name); slice.transform.SetParent(root, false); slice.transform.localPosition = position; slice.transform.localRotation = Quaternion.Euler(90f, 0f, 0f); slice.transform.localScale = new Vector3(size.x / sprite.bounds.size.x, size.y / sprite.bounds.size.y, 1f); var renderer = slice.AddComponent(); renderer.sprite = sprite; renderer.color = color; renderer.sortingOrder = sortOrder; } private void CreateStuds(Transform visualRoot, Transform piece, LevelDefinition level, PieceDefinition definition) { float[] offsets = { -.23f, .23f }; for (int i = 0; i < definition.Cells.Count; i++) { Vector3 cellWorld = level.GridToWorld(definition.Cells[i], .64f); for (int x = 0; x < offsets.Length; x++) for (int z = 0; z < offsets.Length; z++) { Vector3 local = piece.InverseTransformPoint(cellWorld) + new Vector3(offsets[x], 0f, offsets[z]); Sprite studSprite = OriginalAssetCatalog.LoadStud(definition.ColorId); if (studSprite == null) continue; GameObject stud = new GameObject("Original Stud"); stud.name = "Color-Matched Stud"; stud.transform.SetParent(visualRoot, false); stud.transform.localPosition = local + Vector3.up * .075f; stud.transform.localRotation = Quaternion.Euler(90f, 0f, 0f); float size = .52f / Mathf.Max(.001f, studSprite.bounds.size.x); stud.transform.localScale = new Vector3(size, size, 1f); SpriteRenderer studRenderer = stud.AddComponent(); studRenderer.sprite = studSprite; studRenderer.color = Color.white; studRenderer.sortingOrder = 34; } } } private void CreateFlatSprite(string name, Sprite sprite, Vector3 position, Vector2 size, Color color, int sortingOrder, float rotation = 0f) { GameObject item = new GameObject(name); item.transform.SetParent(root); item.transform.position = position; item.transform.rotation = Quaternion.Euler(90f, 0f, 0f) * Quaternion.Euler(0f, 0f, rotation); item.transform.localScale = new Vector3( size.x / Mathf.Max(.001f, sprite.bounds.size.x), size.y / Mathf.Max(.001f, sprite.bounds.size.y), 1f); SpriteRenderer renderer = item.AddComponent(); renderer.sprite = sprite; renderer.color = color; renderer.sortingOrder = sortingOrder; generated.Add(item); } private static int ResolveTileIndex(GridPosition cell, HashSet occupied) { bool up = occupied.Contains(new GridPosition(cell.X, cell.Y + 1)); bool down = occupied.Contains(new GridPosition(cell.X, cell.Y - 1)); bool left = occupied.Contains(new GridPosition(cell.X - 1, cell.Y)); bool right = occupied.Contains(new GridPosition(cell.X + 1, cell.Y)); if (!up && !left) return 0; if (!up && !right) return 2; if (!down && !left) return 6; if (!down && !right) return 8; if (!up) return 1; if (!left) return 3; if (!right) return 5; if (!down) return 7; return 4; } private static void GetBounds(PieceDefinition definition, out int minX, out int maxX, out int minY, out int maxY) { minX = maxX = definition.Cells[0].X; minY = maxY = definition.Cells[0].Y; for (int i = 1; i < definition.Cells.Count; i++) { minX = Mathf.Min(minX, definition.Cells[i].X); maxX = Mathf.Max(maxX, definition.Cells[i].X); minY = Mathf.Min(minY, definition.Cells[i].Y); maxY = Mathf.Max(maxY, definition.Cells[i].Y); } } private GameObject CreateCube(string name, Vector3 position, Vector3 scale, Color color) { GameObject item = GameObject.CreatePrimitive(PrimitiveType.Cube); item.name = name; item.transform.SetParent(root); item.transform.position = position; item.transform.localScale = scale; ApplyColor(item.GetComponent(), color); generated.Add(item); return item; } private void ApplyColor(Renderer target, Color color) { Material material = baseMaterial != null ? new Material(baseMaterial) : new Material(Shader.Find("Sprites/Default")); material.color = color; target.material = material; } } }