2023-01-27 10:35:44 -05:00

710 lines
17 KiB
C#

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Linq;
using Hashtable = ExitGames.Client.Photon.Hashtable; // Compatibility
using ExitGames.Client.Photon;
using EntityNetwork;
// Shorthand aliases for nested list nonsense
using BoardData = System.Collections.Generic.List<System.Collections.Generic.List<TileInfo>>;
// Tile Stack is the vertical list of tiles
using TileStack = System.Collections.Generic.List<TileInfo>;
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<GameBoardDrawer>();
}
}
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))
board = new BoardState((Hashtable)hashedBoard);
}
public override void Serialize(Hashtable h) {
base.Serialize(h);
h.Add('b', board.ToHashtable());
}
public TileColor[] Options;
// Short form for random tiles
TileInfo Random => TileInfo.Random(Options);
[ContextMenu("Test board")]
public void TestBoard() {
board = new BoardState();
/*board[0] = new TileStack(new[] {
new TileInfo(1),
new TileInfo(10),
new TileInfo(100),
new TileInfo(1000),
});*/
for(int i = 0; i < BoardState.BoardWidth; ++i) {
board[i] = new TileStack(new[] { Random, Random, Random });
}
}
[ContextMenu("Test Board Serialization")]
public void TestSerialize() {
board = BoardState.Copy(board);
Debug.Log(board.ToString());
}
[ContextMenu("Recursive Sim")]
void RecursiveSim() => board = board.SimulateRecursive(this, out _);
}
#endregion
#region Tiles
public enum TileColor : byte {
Empty = 0, // Used for ID'ing air by color alone
Red = 1,
Blue = 2,
Green = 3,
Pink = 4,
Purple = 5,
// Room for more colors?
Rock = 200, // Cannot be matched, destroyed fully on adjacent match
//Fairy = 201, // Fairy is handled as detail
Bomb = 202, // 3x3 radius destruction
Dynamite = 203, // 5x5 cross destruction
Seal = 204, // destroys all matching color on landing
Mystery = 205, // Becomes random basic on destroy, otherwise as rock
Wildcard = 206, // Pairs with any
Spark = 207 // Removes entire column on create
}
public enum TileDetail : byte { // Gets cast to byte a lot
None = 0x0,
Air = 0x0, // None and Air as aliases
Normal = 0x1, // Flag that tile exists
Pending = 0x2, // State for active in combo but not cleared
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
Tanuki = 0x40, // Changes to a simple colored tile when it would expire
Ice = 0x80, // Same as Tanuki but doesn't change color
}
// Tile info is made with packed bytes into an int
// Could this be thinner? Yes, but Jam Game -- Texel
[System.Serializable]
public class TileInfo {
public int data;
public TileDetail detail {
// Pack detail into least significant bits (0xFF)
set {
data = (data & ~0xFF) | ((byte)value & 0xFF);
}
get => (TileDetail)(data & 0xFF);
}
public TileColor color {
// color gets second least significant bits (0xFF00)
set {
data = (data & ~0xFF00) | (((byte)value & 0xFF) << 8);
}
get => (TileColor)((data & 0xFF00) >> 8);
}
// Constructor using raw data
public TileInfo(int _data) {
data = _data;
}
public static TileInfo Random(params TileColor[] options) {
return new TileInfo(options.GetRandom());
}
public TileInfo(TileColor c, byte detail = 1) {
// Make a new Tile from the color, assuming detail with a default
var ti = new TileInfo((byte)detail | (((byte)c & (byte)0xFF) << 8));
data = ti.data; // Create garbage but whatever
}
public TileInfo(TileColor c, params TileDetail[] deets) {
byte b = 0;
foreach (var detail in deets)
b |= (byte)detail;
var ti = new TileInfo(c, b);
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;
return this;
}
public TileInfo AsIce() {
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;
switch (detail) {
case TileDetail.Tanuki:
return Random(options); // Replace with new random tile
case TileDetail.Ice: // Remove just the ice flag, keep color
detail = detail & ~TileDetail.Ice;
return this;
case TileDetail.Normal:
goto default;
default:
return Air;
}
}
// Make and return a new air tile
public static TileInfo Air => new TileInfo(0); // Just air
public bool isAir => data == 0;
public static implicit operator int(TileInfo ti) => ti.data;
public static explicit operator TileInfo(int i) => new TileInfo(i);
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
return false;
// room for more weird custom magic logic?
default:
return false;
}
}
}
#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<TileInfo> 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<TileInfo> 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<int>();
var ClosedSet = new List<int>(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<Cluster>();
// 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<Cluster>();
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 = 6, BoardHeight = 12;
// Top THREE rows of board are the placement zone
// Internal state of the board, as a list
public BoardData state;
// Grab a particular stack, allows [][] notation (no bounds checking)
public TileStack this[int col] {
get => state[col];
set {
state[col] = value;
}
}
// Failable indexing setup
public bool TryCol(int col, out TileStack ts) {
ts = null;
if (col < 0) return false;
if (col >= state.Count) return false;
ts = this[col];
return true;
}
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
// Grab the column and fail out if we can't get it
if (!TryCol(x, out TileStack col))
return false;
//if (col == null) return false;
if (col.Count == 0) return false;
// Fail out if the column isn't tall enough
if (y >= col.Count)
return false;
ti = col[y];
return true;
}
public void init() {
state = new List<List<TileInfo>>();
for (int i = 0; i < BoardWidth; ++i)
state.Add(new TileStack());
}
public BoardState() {
init();
}
// Extremely bad allocation heavy string dump of the board state
public override string ToString() {
var str = base.ToString();
str += "\n";
foreach(TileStack ts in state) {
foreach(TileInfo ti in ts) {
// Why are enums so shiiiiit
str += System.Enum.GetName(typeof(TileColor),ti.color) + "(" + ti.data + "), ";
}
str += "\n";
}
return str;
}
// Create a copy of a column
TileStack Copy(TileStack ts) {
var intform = ts.Select(tile => (int)tile);
return intform.Select(tile => (TileInfo)tile).ToList();
}
// Create a copy of a board
public static BoardState Copy(BoardState bs) => bs.SelfCopy();
public BoardState SelfCopy() {
var bs = new BoardState();
for(int i = 0; i < state.Count; ++i)
bs.state[i] = Copy(state[i]);
return bs;
}
// Constructor that uses the networked hashtable
public BoardState(Hashtable ht) {
init(); // Setup the empty column configuration
for (int i = 0; i < BoardWidth; ++i) {
// Grab the int[]'d data from the hashtable
var intArray = (int[])ht[i];
// Convert it to a List<TileInfo> (TileStack) and load it into the column slot
state[i] = intArray.Select(tile => (TileInfo)tile).ToList();
}
}
// Hashtable conversions
public static BoardState FromHashtable(Hashtable ht) => new BoardState(ht);
public Hashtable ToHashtable() {
var ht = new Hashtable();
for (int i = 0; i < state.Count; ++i) {
var column = state[i];
// Convert to an int array and push into the hashtable, keyed to our column number
ht.Add(i, column.Select(t => (int)t).ToArray());
}
return ht;
}
}
#endregion