From a48c79ba992c5f1100bb857dd2d6ba676575b631 Mon Sep 17 00:00:00 2001 From: Texel Date: Fri, 27 Jan 2023 10:35:44 -0500 Subject: [PATCH] Activation and clearing logic draw order changes --- Assets/Texel/Gameplay/GameBoard.cs | 417 +++++++++++++++++- Assets/Texel/Gameplay/GameBoardDrawer.cs | 13 +- .../Texel/Gameplay/TileObjects/TileDrawer.cs | 16 +- 3 files changed, 436 insertions(+), 10 deletions(-) diff --git a/Assets/Texel/Gameplay/GameBoard.cs b/Assets/Texel/Gameplay/GameBoard.cs index 6f2baef..c52a7b2 100644 --- a/Assets/Texel/Gameplay/GameBoard.cs +++ b/Assets/Texel/Gameplay/GameBoard.cs @@ -15,10 +15,24 @@ using BoardData = System.Collections.Generic.List; +using Cluster = System.Collections.Generic.List<(int x, int y)>; + #region Board Logic public class GameBoard : EntityBase, IAutoSerialize, IAutoDeserialize { public BoardState board = new BoardState(); + GameBoardDrawer _drawer; + public GameBoardDrawer drawer { + get { + if (_drawer) return _drawer; + return _drawer = GetComponent(); + } + } + + public void BreakFX(int x, int y) { + drawer[x, y]?.OnBreakFX(); + } + public override void Deserialize(Hashtable h) { base.Serialize(h); if (h.TryGetValue('b', out var hashedBoard)) @@ -54,6 +68,9 @@ public class GameBoard : EntityBase, IAutoSerialize, IAutoDeserialize { board = BoardState.Copy(board); Debug.Log(board.ToString()); } + + [ContextMenu("Recursive Sim")] + void RecursiveSim() => board = board.SimulateRecursive(this, out _); } #endregion @@ -85,7 +102,7 @@ public enum TileDetail : byte { // Gets cast to byte a lot Air = 0x0, // None and Air as aliases Normal = 0x1, // Flag that tile exists Pending = 0x2, // State for active in combo but not cleared - //Damaged = 0x04, // Used for Tanuki and Ice -- Actually unneccessary by replacement setup + Exploded = 0x04, // State for a block that is being exploded Dropped = 0x08, // Flag for tiles that were just dropped, for smears Fairy = 0x10, // do not clear air under this Poofed = 0x20, // Drawn as a cloud, freshly removed @@ -137,16 +154,40 @@ public class TileInfo { data = ti.data; // More delicious garbage } + public static bool SameAs(TileInfo a, TileInfo b) { + if (a.color != b.color) return false; + + // Internal flags we don't consider for if a tile is the same + var irrelevant = TileDetail.Dropped | TileDetail.Poofed | TileDetail.Pending | TileDetail.Exploded; + if ((a.detail & ~irrelevant) != (b.detail & ~irrelevant)) + return false; + + return true; + } + + public TileInfo SetFalling(bool fall) { + detail &= ~TileDetail.Dropped; + if (fall) detail |= TileDetail.Dropped; + return this; + } + public TileInfo AsTanuki() { - detail &= TileDetail.Tanuki; + detail |= TileDetail.Tanuki; return this; } public TileInfo AsIce() { - detail &= TileDetail.Ice; + detail |= TileDetail.Ice; return this; } + public TileInfo SetPending() { + detail |= TileDetail.Pending; + return this; + } + + public bool isPending => (detail & TileDetail.Pending) != 0; + public TileInfo BreakTile(params TileColor[] options) { var d = detail; @@ -170,17 +211,22 @@ public class TileInfo { public static implicit operator int(TileInfo ti) => ti.data; public static explicit operator TileInfo(int i) => new TileInfo(i); - public bool CombosWith(TileInfo other) => CombosWith(color); + public bool CombosWith(TileInfo other) { + if (other == null) return false; + return CombosWith(other.color); + } public bool CombosWith(TileColor c) { if (c == 0) return false; // nothing combos with air if (color == 0) return false; // Also don't combo with us if we're air + if (c == color && (byte)c < 200) return true; + if ((byte)c < 200) // If it's a simple color block return c == color; // just match if it matches our own color switch (c) { // Special handling for wildcard case TileColor.Wildcard: - if ((byte)color < 200) return true; // If we are a simple block + //if ((byte)color < 200) return true; // If we are a simple block return false; // room for more weird custom magic logic? @@ -192,11 +238,347 @@ public class TileInfo { } #endregion +#region Board Logic + +public static class BoardLogic { + // return height of tallest column + public static int TallestStack(this BoardState bs) + => bs.state.Max(t => t.Count); + + + // Get a tile from a stack padded with nulls + public static TileInfo NullPadded(this TileStack ts, int row) { + if (row < 0) return null; + if (row >= ts.Count) return null; + + return ts[row]; + } + // Get the tile from the stack padded with air + public static TileInfo Padded(this TileStack ts, int row) + => ts.NullPadded(row) ?? TileInfo.Air; + + public static void ChangeTile(this TileStack ts, int row, TileInfo ti) { + if (row < 0) return; + if (row >= ts.Count) return; + ts[row] = ti; + } + + public static bool StackHasMatches(this TileStack ts) { + // Check up the length of the column for a match 3 + for (int y = 0; y < ts.Count; ++y) { + var at = ts[y]; + var match = at.CombosWith(ts.Padded(y + 1)) && at.CombosWith(ts.Padded(y + 2)); + if (match) return true; + } + return false; + } + + public static IEnumerable Neighbors(this BoardState bs, int x, int y) { + var self = bs.tile(x, y); + var neighborsWithNull = + new[] { + bs.tile(x + 1, y), + bs.tile(x - 1, y), + bs.tile(x, y + 1), + bs.tile(x, y - 1) }; + + return neighborsWithNull.Where(t => t != null).Where(t => t != self); + } + + public static IEnumerable ComboAdjacents(this BoardState bs, int x, int y) { + var neighbors = bs.Neighbors(x, y); + var self = bs.tile(x, y); + + return neighbors.Where(ti => ti.CombosWith(self)); + } + + public static IEnumerable<(int x, int y)> MatchingAdjacentCoordinates(this BoardState bs, (int x, int y) p) { + var matches = new List<(int x, int y)>(); + var self = bs.TileAtPoint(p); + + // HACK return empty set if self is null + if (self == null) return new (int x, int y)[] { }; + + var (x, y) = p; + + if (self.CombosWith(bs.tile(p.x + 1, p.y))) + matches.Add((x+1, y)); + if (self.CombosWith(bs.tile(p.x - 1, p.y))) + matches.Add((x-1, y)); + if (self.CombosWith(bs.tile(p.x, p.y + 1))) + matches.Add((x, y+1)); + if (self.CombosWith(bs.tile(p.x, p.y - 1))) + matches.Add((x, y-1)); + + return matches; + } + + + public static Cluster Clusterize(this BoardState bs, int x, int y) { + var at = bs.tile(x, y); + if (at == null) return null; + + List<(int x, int y)> OpenSet, ClosedSet; + OpenSet = new List<(int x, int y)>(); + ClosedSet = new List<(int x, int y)>(); + + ClosedSet.Add((x, y)); + OpenSet.AddRange(bs.MatchingAdjacentCoordinates((x, y))); + + while (OpenSet.Count > 0) { + var element = OpenSet[0]; + OpenSet.RemoveAt(0); + + ClosedSet.Add(element); + + var matches = bs.MatchingAdjacentCoordinates(element); + foreach (var match in matches) { + if (ClosedSet.Contains(match)) + continue; + OpenSet.Add(match); + } + } + + return ClosedSet; + } + + public static int[] MatchingNeighbors(this TileStack ts, int y) { + var self = ts[y]; + if (self == null) goto Empty; + if (self.isAir) goto Empty; + + var above = ts.NullPadded(y + 1); + var below = ts.NullPadded(y - 1); + + bool aboveMatch = false; + bool belowMatch = false; + + if (above != null && TileInfo.SameAs(self, above)) + aboveMatch = true; + + if (below != null && TileInfo.SameAs(self, below)) + belowMatch = true; + + if (aboveMatch && belowMatch) + return new[] {y+1, y-1}; + if (aboveMatch) + return new[] { y + 1 }; + if (belowMatch) + return new[] { y - 1 }; + + Empty: + return new int[] { }; + } + + public static Cluster ClusterizeVertical(this BoardState bs, int x, int y) { + var stack = bs[x]; + + var self = stack[y]; + if (self == null) return null; + if (self.isAir) return null; + + var OpenSet = new List(); + var ClosedSet = new List(new[] { y }); + + OpenSet.AddRange(stack.MatchingNeighbors(y)); + + while (OpenSet.Count > 0) { + var element = OpenSet[0]; + OpenSet.RemoveAt(0); + + ClosedSet.Add(element); + var matches = stack.MatchingNeighbors(element); + + foreach(var match in matches) { + if (ClosedSet.Contains(match)) + continue; + OpenSet.Add(match); + } + } + + // Expand back out to (x, y) from the list of y's + return ClosedSet.Select(e => (x, e)).ToList(); + } + + public static BoardState Collapse(this BoardState bs) { + // TODO: Proper support for fairy blocks + + for (int x = 0; x < bs.state.Count; ++x) { + var col = bs[x]; + + // First, pad to length with air + while (col.Count < BoardState.BoardHeight) + col.Add(TileInfo.Air); + + var oldCol = col.Copy(); + + col.RemoveAll(t => t.isAir); + + // Repad with air + while (col.Count < BoardState.BoardHeight) + col.Add(TileInfo.Air); + + // TODO: Better falling logic + /* + // Set the falling flag for drawing + for(int i = 0; i < col.Count; ++i) { + col[i].SetFalling(TileInfo.SameAs(col[i], oldCol[i])); + }*/ + } + return bs; + } + + public static BoardState Place(this BoardState bs,Move m) { + var state = bs.SelfCopy(); + + var (first, second, third) = m.triplet; + + state.SetAtPoint(m.location, first); + state.SetAtPoint(m.locationB, second); + state.SetAtPoint(m.locationC, third); + + return state; + } + + // Set all matches tiledetails to pending + public static BoardState Activate(this BoardState bs, out int activations) { + activations = 0; + + var expandedClusters = new List(); + + // First, determine if any column contains a match + for (int x = 0; x < bs.state.Count; ++x) { + var col = bs[x]; + + // Exit if there is no match in the column + if (!col.StackHasMatches()) + continue; + + // We know there is a match in the column, get the coordinates of all valid clusters + var validBlobs = new List(); + + for (int y = 0; y < col.Count; ++y) { + var stackClusters = bs.ClusterizeVertical(x, y); + if (stackClusters != null && stackClusters.Count >= 3) { + validBlobs.Add(stackClusters); + } else + continue; + } + + // Expand matches horizontally to matches + foreach(var cluster in validBlobs) { + foreach(var point in cluster) { + expandedClusters.Add(bs.Clusterize(point.x,point.y)); + } + } + } + + foreach(var cluster in expandedClusters) { + foreach(var point in cluster) { + var at = bs.TileAtPoint(point); + if (at == null || at.isPending) + continue; + + at.SetPending(); + activations += 1; // Increment the activation counter + } + } + + return bs; + } + + public static BoardState BreakPending(this BoardState bs, GameBoard gb, bool sendCallbacks = false) { + var broken = new Cluster(); + for(int x = 0; x < bs.state.Count; ++x) { + for (int y = 0; y < bs.state.Count; ++y) { + var at = bs.tile(x, y); + + if (at.isAir) continue; // Skip air + + if (at.isPending) { + broken.Add((x, y)); + + if (sendCallbacks) + gb.BreakFX(x, y); + at = at.BreakTile(gb.Options); + } + } + } + return bs; + } + + // Simulate the eventual outcome of this board + // Note this is NOT deterministic because of how random tiles break + public static BoardState SimulateRecursive(this BoardState bs, GameBoard gb, out int activations) { + activations = 0; + + // Iterations for the current step + int stepActivations = 0; + do { + bs = bs.Collapse().Activate(out stepActivations).BreakPending(gb); + activations += stepActivations; + // Repeat until no new tiles activate + } while (stepActivations > 0); + + return bs; + } + + // Create a copy of a column + static TileStack Copy(this TileStack ts) { + var intform = ts.Select(tile => (int)tile); + return intform.Select(tile => (TileInfo)tile).ToList(); + } + + public static (int x, int y) ToPair(this MoveDir md) { + switch(md) { + case MoveDir.Left: + return (-1, 0); + case MoveDir.Right: + return (1, 0); + case MoveDir.Up: + return (0, 1); + case MoveDir.Down: + return (0, -1); + default: + return (0, 0); + } + } +} + +#endregion + +#region moves + +public enum MoveDir { None, Left, Right, Up, Down } + +public class Move { + public (TileInfo first, TileInfo second, TileInfo third) triplet; + public (int x, int y) location; + + public MoveDir first, second; + + public (int x, int y) locationB { + get { + var (x, y) = first.ToPair(); + return (location.x + x, location.y + y); + } + } + + public (int x, int y) locationC { + get { + var (x, y) = second.ToPair(); + return (locationB.x + x, locationB.y + y); + } + } +} + +#endregion + #region BoardStates [System.Serializable] public class BoardState { - public static readonly int BoardWidth = 4, BoardHeight = 9; + public static readonly int BoardWidth = 6, BoardHeight = 12; // Top THREE rows of board are the placement zone // Internal state of the board, as a list @@ -214,11 +596,30 @@ public class BoardState { public bool TryCol(int col, out TileStack ts) { ts = null; if (col < 0) return false; - if (col > state.Count) return false; + if (col >= state.Count) return false; ts = this[col]; return true; } - public bool TileAt(int x, int y, out TileInfo ti) { + + public TileInfo TileAtPoint((int x, int y) p) { + if (TryCol(p.x, out var col)) + return col.NullPadded(p.y); + return null; + } + + public void SetAtPoint((int x, int y) p, TileInfo tile) { + if (TryCol(p.x, out var col)) { + col.ChangeTile(p.x, tile); + } + } + + // Return the tile at a position with null if it's not valid + public TileInfo tile(int x, int y) { + TryTileAt(x, y, out var ti); + return ti; + } + + public bool TryTileAt(int x, int y, out TileInfo ti) { ti = null; if (y < 0) return false; // fail out if asking for negative y coords diff --git a/Assets/Texel/Gameplay/GameBoardDrawer.cs b/Assets/Texel/Gameplay/GameBoardDrawer.cs index 2f76430..47015e3 100644 --- a/Assets/Texel/Gameplay/GameBoardDrawer.cs +++ b/Assets/Texel/Gameplay/GameBoardDrawer.cs @@ -32,6 +32,17 @@ public class GameBoardDrawer : MonoBehaviour { List> TileDrawers; + // Get the Offset for an X/Y coord + public Vector3 Position(int x, int y) { + return new Vector3(x, y) * TileSize; + } + + public TileDrawer this[int x, int y] { + get { + return TileDrawers[x][y]; + } + } + public float TileSize = 0.5f; void ValidateDrawers() { @@ -69,7 +80,7 @@ public class GameBoardDrawer : MonoBehaviour { var tileT = tile.transform; tileT.localPosition = new Vector3(x, y) * TileSize; - if (boardState.TileAt(x, y,out TileInfo ti)) { + if (boardState.TryTileAt(x, y,out TileInfo ti)) { tile.toDraw = ti; } else { tile.toDraw = TileInfo.Air; diff --git a/Assets/Texel/Gameplay/TileObjects/TileDrawer.cs b/Assets/Texel/Gameplay/TileObjects/TileDrawer.cs index a0aa4ce..8bea302 100644 --- a/Assets/Texel/Gameplay/TileObjects/TileDrawer.cs +++ b/Assets/Texel/Gameplay/TileObjects/TileDrawer.cs @@ -13,6 +13,12 @@ public class TileDrawer : MonoBehaviour { public TileDetail detail = TileDetail.Normal; public TileColor color; + // TODO Use this FX hook + // This is called BEFORE the tile detail is updated + public void OnBreakFX() { + + } + void OnValidate() { if (toDraw == null) { toDraw = new TileInfo(color,detail); @@ -139,6 +145,10 @@ public class TileDrawer : MonoBehaviour { if (!tsi) return; if (toDraw == null) return; + // Higher tiles draw in front + var t = transform; + t.LeanSetPosZ(t.position.y); + // Copy the data from the TileInfo reference to local enums color = toDraw.color; detail = toDraw.detail; @@ -162,10 +172,14 @@ public class TileDrawer : MonoBehaviour { sprite = deets.TopOfStack; } - // non-snowman-y states if (detail.HasFlag(TileDetail.Poofed)) sprite = deets.Puff; + + + // FIXME This is wrong + if (detail.HasFlag(TileDetail.Pending)) + sprite = deets.Puff; } else { // no matching TileObject data switch (color) {