using System.Collections; using System.Collections.Generic; using UnityEngine; using System.Linq; using Hashtable = ExitGames.Client.Photon.Hashtable; using BoardState = System.Collections.Generic.List>; using ExitGames.Client.Photon; using EntityNetwork; public class GameBoard : EntityBase, IAutoSerialize, IAutoDeserialize { public const int COLUMN = 6, ROW = 12; public GameBoardRender render; [System.NonSerialized] public BoardState board; private BoardState lastSentBoard; public bool active; public enum DelayState { None, Collapse, Combo, Loss } public DelayState delayState = DelayState.None; // TIL I need to always have an update timer or the dispatcher doesn't get created [NetVar('n',true,true,100)] public int NextDrop; [NetVar('c',true,true)] public int CurrentDrop; public (TileInfo left, TileInfo right) currentPair { get => TileInfo.FromPairInt(CurrentDrop); set => CurrentDrop = TileInfo.ToPairInt(value); } public (TileInfo left, TileInfo right) nextPair { get => TileInfo.FromPairInt(NextDrop); set => NextDrop = TileInfo.ToPairInt(value); } // Simple reference rotation public void SwapTiles() { var pair = currentPair; currentPair = nextPair; nextPair = pair; } [Header("Settings")] // Tile timing private int TilesUntilActivator = 3; int nextActivator = -1; private int ActivatorsUntilSpecial = 10; int nextSpecial = -1; public float nextRootX = -1, nextRootY = 12; TileColor lastActivatorColor; public void ReplaceNextTile() { (TileInfo left, TileInfo right) pair = (TileInfo.CreateRandomBlockTile(), TileInfo.CreateRandomBlockTile()); --nextActivator; if (nextActivator < 1) { nextActivator = TilesUntilActivator; var activator = TileInfo.CreateRandomActivatorTile(); while (activator.color == lastActivatorColor) activator = TileInfo.CreateRandomActivatorTile(); lastActivatorColor = activator.color; if (++nextSpecial >= ActivatorsUntilSpecial) { nextSpecial = 0; activator.kind = TileKind.Special; } if (Random.value > 0.5f) { pair.left = activator; } else { pair.right = activator; } } nextPair = pair; } public float airCollapseTime = 1f; public float activationSpreadTime = 0.5f; public int dropHeight = 12; // Y value to drop from [Header("Scoring")] [NetVar('C',true,true)] public int Combo; // Increments on successively chained combos [NetVar('S',true,true)] public int score = 0; #region GameLogic // Time without moving that the game will kill you if you have lethal trash public float AutoDeathTime = 10f; public float timeInState = 0; public DelayState lastState = DelayState.None; void GameLogic() { if (delayState != lastState) { timeInState = 0; } lastState = delayState; timeInState += Time.deltaTime; // Handle player input always, but block dropping when in a state if (!AIEnabled) PlayerInput(); switch (delayState) { case DelayState.Collapse: if (timeInState > airCollapseTime) { board = Collapse(board); // Remove air board = SpecialActivation(board, out bool didSpecial); board = ActivateOnce(board, out bool didActivate); if (didActivate || didSpecial) { delayState = DelayState.Combo; ++Combo; } else { Combo = 0; ApplyTrash(); delayState = DelayState.None; } } return; case DelayState.Combo: if (timeInState > activationSpreadTime) { board = ActivateOnce(board, out bool didActivate); if (didActivate) { timeInState = 0; return; } else { int scoreValue = CountActivations(board) * Combo; // Score increments too slow, make it get bigger! score += scoreValue * scoreValue; SendTrash(scoreValue - 3); // TODO - Trigger animation for attacking GhostActivations(); // Cool FX! board = ClearActivation(board); //board = Collapse(board); // Turns out THIS is what breaks the cool collapse effects // Overriding this like a dirty fellow to let me add negative buffer time timeInState = -0.5f * airCollapseTime; // Take 1.5x longer to collapse from combo chain lastState = DelayState.Collapse; delayState = DelayState.Collapse; return; } } return; case DelayState.None: if (HighestStack(board) >= 13) { delayState = DelayState.Loss; StartCoroutine(HandleLoss(3f)); // TODO - Do a bunch of networking silliness to end the game here } // If the player isn't taking actions, trash won't appear, so make instakills kill through inaction if (timeInState > AutoDeathTime) { var incomingHeight = HighestStackWithTrash(board, incomingTrash); if (incomingHeight > ROW) { ApplyTrash(); } else { timeInState = 0; // Reset the time in state before we check autodeath again } } return; } } // Draw the big collapse of pieces after a few seconds IEnumerator HandleLoss(float timer) { if (board == null) yield break; // Wait three seconds before doing the crumble so they can watch in dismay // at the piles of trash that killed them active = false; yield return new WaitForSeconds(timer); // First, crumble the board to be really cool for(int x = 0; x < board.Count; ++x) { var col = board[x]; for(int y = 0; y < col.Count; ++y) { render.Crumble(col[y], (x, y)); } } // Now, re-initialize the board, so those falling pieces are the last of our board board = BoardStateExtension.Initialize(); render.RebuildStack(board); render.Render(board); } #endregion #region Input /// /// Awaiting control stick neutral for control inputs to return /// public bool awaitingNeutral; public static float inputThreshold = 0.3f; public void PlayerInput() { float rightIntent = Input.GetAxisRaw("Horizontal"); float upIntent = Input.GetAxisRaw("Rotate"); var max = Mathf.Max(Mathf.Abs(rightIntent), Mathf.Abs(upIntent)); if (max > 0.3f) { if (!awaitingNeutral) { if (rightIntent > 0.2f) { dropColumn += 1; } else if (rightIntent < -0.2f) { dropColumn -= 1; } if (upIntent > 0.2f) { playerRotation += 1; } else if (upIntent < -0.2f) { playerRotation -= 1; } // Shitty wrapping if (playerRotation > 3) playerRotation = 0; if (playerRotation < 0) playerRotation = 3; dropColumn = ClampRotatedPosition(playerRotation); dropHeight = playerRotation != 3 ? 12 : 13; // Shift the drop height based on rotation } awaitingNeutral = true; } else { awaitingNeutral = false; } if (Input.GetButtonDown("Drop")) { if (delayState == DelayState.None) { board = Collapse(board); board = DropNow(board); } } if (Input.GetButtonDown("Swap")) { SwapTiles(); } } public BoardState DropNow(BoardState bs) { bs = Place(bs, currentPair, playerRotation, dropColumn); //this[dropColumn, dropHeight] = TileInfo.CreateRandomBlockTile(); bs = ReduceCountdowns(bs); bs = Collapse(bs); delayState = DelayState.Collapse; SwapTiles(); ReplaceNextTile(); return bs; } #endregion #region placement [NetVar('p',true,true)] public int dropColumn; [NetVar('r', true, true)] public int playerRotation = 0; // Rotations (Coordinate is always on left) // 0 - L/R // 1 - L // R // 2 - R/L // 3 - R // L private ((int x, int y) left, (int x, int y) right) GetPlacePosition(BoardState bs, int placeRotation, int dropColumn){ (int x, int y) left = (dropColumn, dropHeight), right = (dropColumn, dropHeight); switch(placeRotation){ case 0: right.x += 1; break; case 1: right.y += 1; break; case 2: right.x -= 1; break; case 3: right.y -= 1; break; } return (left, right); } public BoardState Place(BoardState bs,(TileInfo left, TileInfo right) pair, int placeRotation, int dropColumn) { //Debug.LogFormat("Placing tile frame {0}",Time.frameCount); (int x, int y) dropLeft = (dropColumn, dropHeight), dropRight = (dropColumn, dropHeight); switch(placeRotation) { case 0: dropRight.x += 1; break; case 1: dropRight.y += 1; break; case 2: dropRight.x -= 1; break; case 3: dropRight.y -= 1; break; } // Fix an issue with placing tiles in the wrong order not working right if (dropRight.y < dropLeft.y) { // Texel - Switch to modifying the passed board state, instead of the authority board state bs.SetTile(pair.right, dropRight.x, dropRight.y); bs.SetTile(pair.left, dropLeft.x, dropLeft.y); //this[dropRight.x, dropRight.y] = pair.right; //this[dropLeft.x, dropLeft.y] = pair.left; } else { bs.SetTile(pair.left, dropLeft.x, dropLeft.y); bs.SetTile(pair.right, dropRight.x, dropRight.y); //this[dropLeft.x, dropLeft.y] = pair.left; //this[dropRight.x, dropRight.y] = pair.right; } return bs; } public int ClampRotatedPosition(int sourceRotation) { switch(sourceRotation) { case 0: return Mathf.Clamp(dropColumn, 0, COLUMN-2); case 1: case 3: return Mathf.Clamp(dropColumn, 0, COLUMN-1); case 2: return Mathf.Clamp(dropColumn, 1, COLUMN-1); default: throw new System.IndexOutOfRangeException("Rotation is out of bounds you dolt"); } } #endregion #region network // Serialize/Deserialize network board state public override void Deserialize(Hashtable h) { base.Deserialize(h); //Debug.Log("Deserializing"); if (h.TryGetValue('b', out var val)) { board.FromHashtable((Hashtable)val); //Debug.Log(val.ToString()); } } public override void Serialize(Hashtable h) { base.Serialize(h); //Debug.Log("Serializing"); h.Add('b', board.ToHashtable()); lastSentBoard = board.Copy(); stateDirty = false; } // Would be a lot better if we could get a good hash off of the board, but the data is too regular we'd collide constantly void checkDirty() { if (lastSentBoard != null && !board.Matches(lastSentBoard)) { stateDirty = true; return; } } #endregion #region trash BoardState ReduceCountdowns(BoardState bs) { for(int i = 0; i < bs.Count; ++i) { var col = bs[i]; for(int y = 0; y < col.Count; ++y) { var tile = col[y]; if (tile.kind == TileKind.Trash){ tile.counter--; if (tile.counter == 0){ // Revert tiles to blocks tile.kind = TileKind.Block; } } } } return bs; } // Funny how THIS one is super easy, but once trash is involved it's hard int HighestStack(BoardState bs) => bs.Max(t => t.Count); /// /// Calculate the highest stack with trash. /// int HighestStackWithTrash(BoardState bs, int amount) { var stackHeights = new List(); for(int c = 0; c < bs.Count; ++c) stackHeights.Add(bs[c].Count); var cursorHead = trashCursorHead; while (amount > 0) { --amount; stackHeights[cursorHead] += 1; if (cursorHead >= COLUMN) { cursorHead = 0; } } return stackHeights.Max(); } public int incomingTrash = 0; void SendTrash(int amount) { if (amount < 1) return; // Uncached find? Blasphamy! Also whatever fukkit var otherBoards = FindObjectsOfType().Where(t => t != this); foreach (var other in otherBoards) { other.RaiseEvent('t', true, amount); } } [NetEvent('t')] void AddTrashNetwork(int i) { incomingTrash += i; } // Column to drop trash int trashCursorHead = 0; void ApplyTrash() { if (incomingTrash > 0) { delayState = DelayState.Collapse; timeInState = 0; } while (incomingTrash > 0) { --incomingTrash; var trash = TileInfo.CreateRandomTrashTile(); board[trashCursorHead].Add(trash); trashCursorHead += 1; if (trashCursorHead >= COLUMN) trashCursorHead = 0; } } #endregion #region activation BoardState ClearActivation(BoardState bs) { foreach(var col in bs) { for(int y = 0; y < col.Count; ++y) { var tile = col[y]; if (tile.kind.Equals(TileKind.Activiting)){ TileInfo.SetAirTile(tile); } } } return bs; } // Activate all special tiles BoardState SpecialActivation(BoardState bs, out bool didActivate) { didActivate = false; //return bs; // Test short-circuit for (int x = 0; x < bs.Count; ++x) { var col = bs[x]; for (int y = 0; y < col.Count; ++y) { var tile = col[y]; if (tile.kind == TileKind.Special) { if (y > 0) { // Get the color of the tile we are on top of var under = col[y - 1]; tile.kind = TileKind.Activiting; tile.color = under.color; bs = ActivateAllColor(bs, under.color); didActivate = true; } else { // Special tile is on the bottom, swap it to a random activating tile tile = TileInfo.CreateRandomActivatorTile(); } } } } return bs; } BoardState ActivateAllColor(BoardState bs,TileColor tc) { for (int x = 0; x < bs.Count; ++x) { var col = bs[x]; for (int y = 0; y < col.Count; ++y) { var tile = col[y]; if (tile.color == tc) { tile.kind = TileKind.Activiting; } } } return bs; } int CountActivations(BoardState bs) { int total = 0; foreach(var col in bs) { total += col.Count(t => t.kind.Equals(TileKind.Activiting)); } return total; } void GhostActivations() { for(int x = 0; x < board.Count; ++x) { var col = board[x]; for (int y = 0; y < col.Count; ++y) { var tile = col[y]; if (tile.kind.Equals(TileKind.Activiting)) { render.Ghost(tile, (x, y)); } } } } BoardState ActivateColorOnce(BoardState bs,TileColor tc,out bool success) { success = false; for(int x = 0; x < bs.Count; ++x) { var col = bs[x]; for (int y = 0; y < col.Count; ++y) { //var tile = this[x, y]; var tile = bs.tile(x, y); if (tile.kind.Equals(TileKind.Activator) || tile.kind.Equals(TileKind.Activiting)) { // Check adjacency for tiles to activate, filtering for unactivated blocks or other activators of our color var neighbors = Neighbors(x, y,bs).Where(t => t.kind.Equals(TileKind.Block) || t.kind.Equals(TileKind.Activator)).Where(t => t.color.Equals(tile.color)); foreach(var neighbor in neighbors) { success = true; tile.kind = TileKind.Activiting; neighbor.kind = TileKind.Activiting; } } } } return bs; } BoardState ActivateColorOnceSplit(BoardState bs, TileColor tc, out bool success) { success = false; var activable = new List<(int x, int y)>(); for (int x = 0; x < bs.Count; ++x) { var col = bs[x]; for(int y = 0; y < col.Count; ++y) { var tile = bs.tile(x, y);//this[x, y]; if (tile.color != tc) continue; if (tile.kind.Equals(TileKind.Activator) || tile.kind.Equals(TileKind.Activiting)) activable.Add((x, y)); } } foreach(var potential in activable) { var tile = bs.tile(potential.x, potential.y);//this[potential.x, potential.y]; if (tile.kind.Equals(TileKind.Activator) || tile.kind.Equals(TileKind.Activiting)) { // Check adjacency for tiles to activate, filtering for unactivated blocks or other activators of our color var neighbors = Neighbors(potential.x, potential.y,bs).Where(t => t.kind.Equals(TileKind.Block) || t.kind.Equals(TileKind.Activator)).Where(t => t.color.Equals(tile.color)); foreach (var neighbor in neighbors) { success = true; tile.kind = TileKind.Activiting; neighbor.kind = TileKind.Activiting; } } } return bs; } BoardState ActivateColor(BoardState bs, TileColor tc) { bool didActivate; do { bs = ActivateColorOnce(bs, tc, out didActivate); } while (didActivate); return bs; } BoardState ActivateOnce(BoardState bs, out bool success) { var colors = (TileColor[])System.Enum.GetValues(typeof(TileColor)); success = false; foreach(var tc in colors) { //bs = ActivateColorOnce(bs,tc, out bool colorSuccess); bs = ActivateColorOnceSplit(bs, tc, out bool colorSuccess); if (colorSuccess) success = true; } return bs; } BoardState Activate(BoardState bs) { var colors = (TileColor[])System.Enum.GetValues(typeof(TileColor)); foreach(var tc in colors) { bs = ActivateColor(bs, tc); } return bs; } #endregion static BoardState Collapse(BoardState bs) { for(int i = 0; i < bs.Count; ++i) { bs[i] = Collapse(bs[i]); } return bs; } static List Collapse(List ti) { return ti.Where(t => t.kind != TileKind.Air).ToList(); } bool stateDirty = false; [Header("Network")] public float networkTick = 0.1f; private float nextNetworkTick; public void Update() { if (!active) { render.RenderName(); return; } if (isMine) { //checkDirty(); render.RebuildStack(board); GameLogic(); render.RebuildStack(board); if (Time.time >= nextNetworkTick && NetworkManager.inRoom){ UpdateNow(); nextNetworkTick = Time.time + networkTick; } } render.Render(board); // HACK So uh, there's an issue with air tiles taking two renders to be removed right, so uh, two renders it is. - Texel // Seriously I can't believe this works this is dirty // It does make it render twice as fast, I have idea // i fix by doing two rebuild stacks >:) // render.Render(board); render.SetComboLevel(Combo); render.SetScoreValue(score); var pair = currentPair; var pairp = GetPlacePosition(board, playerRotation, dropColumn); render.RenderPlacement(pair.left, pair.right, pairp.left, pairp.right); render.RenderName(); } public override void Awake() { base.Awake(); Register(); } public void Setup(){ board = BoardStateExtension.Initialize(); StopAI(); // This is to reset the AI // Build the list of possible placements the AI may use PossiblePlacements = GetAllPossibilities(); lastActivatorColor = TileInfo.CreateRandomActivatorTile().color; nextActivator = TilesUntilActivator; nextSpecial = ActivatorsUntilSpecial; delayState = DelayState.None; timeInState = 0; ReplaceNextTile(); SwapTiles(); ReplaceNextTile(); if (isMine){ foreach(var c in board){ c.Add(TileInfo.CreateRandomBlockTile()); c.Add(TileInfo.CreateRandomBlockTile()); } } active = true; } public void Stop(){ StopAI(); active = false; } public void Clear(){ delayState = DelayState.None; StartCoroutine(HandleLoss(0f)); } public IEnumerable Neighbors(int x, int y, BoardState bs) { 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); /*var self = this[x, y]; var neighborsWithNull = new[] { this[x + 1, y], this[x - 1, y], this[x, y + 1], this[x, y - 1] }; return neighborsWithNull.Where(t => t != null).Where(t=>t!=self);*/ } public TileInfo this[int x, int y] { get { // Pass null on sides/top, or anywhere where x, y doesn't exist. if (x < 0 || x >= board.Count) return null; if (y < 0) return null; var col = board[x]; if (y < col.Count) return col[y]; return null; } set { var col = board[x]; // Set value or append to end if (y < col.Count) col[y] = value; else col.Add(value); // Added else. Otherwises, any set would have grown board -- Ebony } } #region AI List<(int r, int c)> PossiblePlacements; /// /// Given input board state, return the end result for number of tiles that would be sent in an attack /// This is destructive to the input board state /// int ProjectAttack(BoardState bs) { int total = 0; int comboLength = 0; bool repeat = false; do { repeat = false; bs = Collapse(bs); bs = Activate(bs); bs = SpecialActivation(bs, out bool didActivate); var newActivations = CountActivations(bs); if (newActivations > 0) { comboLength += 1; repeat = true; if (newActivations > 3) total += (newActivations - 3) * comboLength; bs = ClearActivation(bs); } } while (repeat); return total; } // Generates actual subset of possible moves List<(int simRotation, int simCol)> GetAllPossibilities() { var Considerables = new List<(int r, int c)>(); for(int col = 0; col < COLUMN; ++col) { Considerables.AddRange(new[] { (0,col), (1,col), (2,col), (3,col) }); } for(int i = 0; i < Considerables.Count; ++i) { Considerables[i] = ClampPossibility(Considerables[i]); } return Considerables.Distinct().ToList(); } (int r, int c) ClampPossibility((int r, int c) possibility) { int oldDrop = dropColumn; dropColumn = possibility.c; possibility.c = ClampRotatedPosition(possibility.r); dropColumn = oldDrop; return possibility; } float averageConnectedSize(BoardState bs) { int maxRed =0, maxGreen=0, maxBlue=0, maxYellow=0; for (int i = 0; i < bs.Count; ++i) { (int count, TileColor color) cluster = ExposedClusterSize(bs, i); switch(cluster.color) { case TileColor.Blue: maxBlue = Mathf.Max(maxBlue, cluster.count); break; case TileColor.Green: maxGreen = Mathf.Max(maxGreen, cluster.count); break; case TileColor.Red: maxRed = Mathf.Max(maxRed, cluster.count); break; case TileColor.Yellow: maxYellow = Mathf.Max(maxYellow, cluster.count); break; } } return (maxRed + maxGreen + maxBlue + maxYellow) * 0.25f; } // Texel - I gave up writing this and made it use a board copy destructively (int count, TileColor color) ExposedClusterSize(BoardState bs, int col) { var bc = bs.Copy(); //ReduceToBlocksOnly(bc); int startX = col; int startY = bc[col].Count - 1; if (startY < 0) return (0, TileColor.Blue); // Oops can't eval an empty col var tile = bc.tile(startX, startY); tile.kind = TileKind.Activator; //ProjectAttack(bc); bc = Activate(bc); return (CountActivations(bc),tile.color); } void ReduceToBlocksOnly(BoardState bs) { for(int x = 0; x < bs.Count; ++x) { var col = bs[x]; for(int y = 0; y < bs.Count; ++y) { var tile = col[y]; if (tile.kind.Equals(TileKind.Air)) continue; tile.kind = TileKind.Block; tile.counter = 0; } } } /// /// Create an internal copy of the board, simulate the given rotation/col against it. /// (int score,float averageConnectedSize, int maxHeight) EvalPossibility(BoardState bs, (int r, int c) possibility) { var bc = bs.Copy(); try { bc = Place(bc, currentPair, possibility.r, possibility.c); // TODO - Highest connected size float connectness = averageConnectedSize(bc); int trashValue = ProjectAttack(bc); // Forward simulate the board to it's next stable position int tallest = HighestStack(bc); return (trashValue, connectness, tallest); } catch (System.Exception e) { Debug.LogFormat("Possibility ({0}, {1}) has encountered an exception", possibility.r, possibility.c); throw (e); } } // Methodology -> Place pieces to fire the longest combo, then place pieces to minimize our tallest stack. (int r, int c) GetBestMove(BoardState bs) { return PossiblePlacements .Select(possiblity => (possiblity, EvalPossibility(bs, possiblity))) .OrderByDescending(t => t.Item2.score) // First priority, attacking .ThenByDescending(t => t.Item2.maxHeight < 10 ? 1 : 0) // Second priority, not offing ourselves .ThenByDescending(t => t.Item2.averageConnectedSize) // Third priority, maximize cluster sizes .ThenBy(t => t.Item2.maxHeight) // Fourth priority, keeping our height down .Select(t => t.possiblity).First(); } [ContextMenu("Startup AI")] public void StartAIDefault() { StartAI(); } Coroutine aiThinkCoroutine; public void StartAI(float thinkTime = 0.5f) { AIEnabled = true; AIMoveTime = thinkTime; aiThinkCoroutine = StartCoroutine(AIThink()); } public void StopAI(){ AIEnabled = false; if (aiThinkCoroutine != null) StopCoroutine(aiThinkCoroutine); aiThinkCoroutine = null; } public bool AIEnabled = false; public float AIMoveTime = 0.5f; IEnumerator AIThink() { yield return null; // Wait a frame before trying any shit int totalMoves = 0; while (delayState != DelayState.Loss) { if (!AIEnabled) yield return new WaitUntil(() => AIEnabled); var (r, c) = GetBestMove(board); Debug.LogFormat("AI: Column {0}, Rotation {1}", c, r); totalMoves += 1; // First match rotation while (playerRotation != r) { yield return new WaitForSeconds(AIMoveTime); if (playerRotation < r) ++playerRotation; if (playerRotation > r) --playerRotation; dropColumn = ClampRotatedPosition(playerRotation); } // Then move to the right column while (dropColumn != c) { yield return new WaitForSeconds(AIMoveTime); if (dropColumn > c) --dropColumn; if (dropColumn < c) ++dropColumn; } // Wait until we're allowed to drop a piece yield return new WaitUntil(() => delayState.Equals(DelayState.None)); board = DropNow(board); // Now wait for it all to settle before repeating yield return new WaitUntil(() => delayState.Equals(DelayState.None)); } Debug.LogFormat("AI lost after {0} moves",totalMoves); } #endregion }