|
|
|
@ -15,10 +15,24 @@ using BoardData = System.Collections.Generic.List<System.Collections.Generic.Lis
|
|
|
|
|
// 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))
|
|
|
|
@ -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<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 = 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
|
|
|
|
|