BASeTris, my Tetris Clone which has been genericized such that I was even able to add a “Dr.Mario” Game mode, has now been around for nearly 5 years.
I originally created it because I wanted to create a sort of “Tetris all-stars” concept, where all the various implementations I was familiar with- Game boy, NES, DS, SNES, etc. could effectively mingle together their visuals and sounds.
Last night for some reason I was dwelling on the idea of “Pentris”- that is, it’s Tetris, but instead of 4 blocks, there are 5. In BASeTris, the Tetris tetrominoes are separate class instances that define the block positions appropriately. And while I could do that for Pentris, I got the idea that realistically it should be entirely feasible to simply have the game generate every possible unique combination, given the number of blocks. Stemming from that, it should, therefore, be possible to not only have Pentris, but even, say, “Duodectris” and have the game handle everything from there.
The algorithm I ended up constructing works in two sections. First, it creates every single possible combination, and then it filters out duplicates.
The construction of all the possibilities effectively works by starting with a Duomino at 0,0 and 1,0. From there, using 1,0 as the “head” it effectively calls itself recursively to add blocks going Right, left, and Forward from that point, with the end-case of course being when the constructed Nomino has the number of needed blocks. The implementation for that was thus:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
public static IEnumerable<List<nominopoint>> GetPieces(int BlockCount,List</nominopoint><nominopoint> CurrentBuild = null) { if (CurrentBuild == null) { CurrentBuild = new List<NominoPoint>() {new NominoPoint(0, 0), new NominoPoint(1, 0) }; //create first two blocks var subreturn = GetPieces(BlockCount - 2, CurrentBuild); //-2 since we added two blocks. foreach (var yieldit in subreturn) { yield return yieldit; } } else { //determine forward direction. There should be at least two tuples in the list. var Last = CurrentBuild[CurrentBuild.Count - 1]; var NextToLast = CurrentBuild[CurrentBuild.Count - 2]; var Direction = new NominoPoint(Last.X - NextToLast.X, Last.Y - NextToLast.Y); //Create three copies of the current List. List<NominoPoint>[] Copies = new List<NominoPoint>[] {new List<NominoPoint>(), new List<NominoPoint>(), new List<NominoPoint>() }; for (int i = 0; i < CurrentBuild.Count; i++) { for (int a = 0; a < 3; a++) { Copies[a].Add(CurrentBuild[i]); } } //copies established. index zero is left (-y,x), 1 is forward (x,y) and 2 is right (y,-x) List<NominoPoint> LeftwardList = Copies[0]; List<NominoPoint> ForwardList = Copies[1]; List<NominoPoint> RightwardList = Copies[2]; //what is the coordinate if we move leftward (-Y,X) NominoPoint LeftMove = new NominoPoint(Last.X - Direction.Y, Last.Y + Direction.X); NominoPoint ForwardMove = new NominoPoint(Last.X + Direction.X, Last.Y + Direction.Y); NominoPoint RightwardMove = new NominoPoint(Last.X + Direction.Y, Last.Y - Direction.X); if (!LeftwardList.Contains(LeftMove)) { LeftwardList.Add(LeftMove); if (BlockCount-1 > 0) { var LeftResult = GetPieces(BlockCount - 1, LeftwardList); foreach (var iterate in LeftResult) { yield return iterate; } } else { yield return LeftwardList; } } if (!ForwardList.Contains(ForwardMove)) { ForwardList.Add(ForwardMove); if (BlockCount-1 > 0) { var ForwardResult = GetPieces(BlockCount - 1, ForwardList); foreach (var iterate in ForwardResult) { yield return iterate; } } else { yield return ForwardList; } } if (!RightwardList.Contains(RightwardMove)) { RightwardList.Add(RightwardMove); if (BlockCount-1 > 0) { var RightResult = GetPieces(BlockCount-1, RightwardList); foreach (var iterate in RightResult) { yield return iterate; } } else { yield return RightwardList; } } } }</nominopoint> |
Note: NominoPoint is basically just “yet-another-point class” but doesn’t have much special capability)
This, of course, still did not yield (pun intended…) the correct set of Nominoes; this would yield each possible rotation as a separate Nomino, for example, and in fact it was quite possible for a different “path” to yield an identical nomino. For that reason, it is necessary to filter the pieces. So how, exactly, do we devise a way to remove duplicates? Well, if we “rebase” the coordinates so that they align in the same way, regardless of their generated position, we can create arrangements that match, if we can come up with a safe, unique “hash” for each, of course. We can then also rotate the nominoes 90 degrees, 180, and 270 degrees, and be able to detect if “rotated” versions of the candidate nomino are already in the resultset.
Which leads to the question of what to use for a “unique” value. At first, I tried to create a Hash for the set of points, by using the bijective algorithm on all the points sorted by x and y coordinate. This resulted in hash collisions, however, so some Nominoes that should have been present were not.
For debugging, I added some helper functions to create a string representation using Hash Marks. It occurred to me that that string representation would be perfectly sufficient for a unique hash. the same arrangement of blocks that were created a different way would still give that same text, and a string could be safely used as a HashSet key.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
public static String StringRepresentation(List<NominoPoint> Points) { int MinX = int.MaxValue, MaxX = int.MinValue; int MinY = int.MaxValue, MaxY = int.MinValue; foreach (var iteratepoint in Points) { if (iteratepoint.X < MinX) MinX = iteratepoint.X; if (iteratepoint.X > MaxX) MaxX = iteratepoint.X; if (iteratepoint.Y < MinY) MinY = iteratepoint.Y; if (iteratepoint.Y > MaxY) MaxY = iteratepoint.Y; } StringBuilder sb = new StringBuilder(); for (int ycoord = MaxY; ycoord >= MinY; ycoord--) { for (int xcoord = MinX; xcoord <= MaxX; xcoord++) { if (Points.Any((p) => (p.X == xcoord && p.Y == ycoord))) sb.Append("#"); else sb.Append(" "); } sb.AppendLine(""); } return sb.ToString(); } |
With this in hand, the FilterPieces function could now uh, filter pieces. The general idea is simple: we maintain a HashSet of all the previously returned pieces, and as we go through each piece, we basically check if it matches one in the HashSet. if it doesn’t, we will add the string representation of itself and the three rotations of that piece to the HashSet and yield it via the enumeration. Otherwise, it is filtered out as already having been returned.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
public static IEnumerable<List<NominoPoint>> FilterPieces(IEnumerable<List<NominoPoint>> Input) { HashSet<String> FilteredPieces = new HashSet<string>(); List<List<NominoPoint>> ProcessedList = new List<List<NominoPoint>>(); HashSet<String> PreviouslyProcessed = new HashSet<string>(); foreach (var iteratefilter in Input) { String DebugAid = StringRepresentation(iteratefilter); String sHash = DebugAid; if (PreviouslyProcessed.Contains(sHash)) { continue; } var CW1 = RotateCW(iteratefilter); var CW2 = RotateCW(CW1); var CW3 = RotateCW(CW2); PreviouslyProcessed.Add(sHash); PreviouslyProcessed.Add(StringRepresentation(CW1)); PreviouslyProcessed.Add(StringRepresentation(CW2)); PreviouslyProcessed.Add(StringRepresentation(CW3)); yield return iteratefilter; } } |
With this in hand, I could see how many of these combinations specific block counts provided. Pentominoes, or Pentris, with 5 blocks, yields 12 possibilities. with 6 blocks, there were 32; 7 had 81, 8 had 219, and so on. With 13 blocks, that’s 29,663 possible … Tridecominoes (?).
Now, for actual gameplay, the idea is that with more blocks in each, you would, of course, have a larger playfield. Thankfully, the TetrisField class that implements the GameField of BASeTris has supported this more or less since the beginning, and simply defaults to the standard size.
One aspect which occurred to me that while some aspects of the Game do require knowing all possible Nominoes, It really isn’t necessary for all parts of GamePlay. For the “Choosers”, they need to know all Nominoes since they are responsible for how they randomize, but, one could make contingencies for an imagined “N-block” “Xtris” implementation.
The first thing to address is to allow for non-deterministic generation of the Nominoes. That is actually surprisingly easy- with the recursive algorithm it is effectively a matter of randomizing the order that the directions are chosen. It was at this time I refactored the routine and made it shorter, too:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 |
public static IEnumerable<List<nominopoint>> GetPieces(int BlockCount,List</nominopoint><nominopoint> CurrentBuild = null,NominoPieceGenerationFlags GenerationFlags = NominoPieceGenerationFlags.Flag_None) { if (CurrentBuild == null) { CurrentBuild = new List<NominoPoint>() {new NominoPoint(0, 0), new NominoPoint(1, 0) }; //create first two blocks var subreturn = GetPieces(BlockCount - 2, CurrentBuild,GenerationFlags); //-2 since we added two blocks. foreach (var yieldit in subreturn) { yield return yieldit; } } else { //determine forward direction. There should be at least two tuples in the list. var Last = CurrentBuild[CurrentBuild.Count - 1]; var NextToLast = CurrentBuild[CurrentBuild.Count - 2]; var Direction = new NominoPoint(Last.X - NextToLast.X, Last.Y - NextToLast.Y); //Create three copies of the current List. List<NominoPoint>[] DirectionLists = new List<NominoPoint>[] {new List<NominoPoint>(), new List<NominoPoint>(), new List<NominoPoint>() }; for (int i = 0; i < CurrentBuild.Count; i++) { for (int a = 0; a < 3; a++) { DirectionLists[a].Add(CurrentBuild[i]); } } //copies established. index zero is left (-y,x), 1 is forward (x,y) and 2 is right (y,-x) List<NominoPoint> LeftwardList = DirectionLists[0]; List<NominoPoint> ForwardList = DirectionLists[1]; List<NominoPoint> RightwardList = DirectionLists[2]; //what is the coordinate if we move leftward (-Y,X) NominoPoint LeftMove = new NominoPoint(Last.X - Direction.Y, Last.Y + Direction.X); NominoPoint ForwardMove = new NominoPoint(Last.X + Direction.X, Last.Y + Direction.Y); NominoPoint RightwardMove = new NominoPoint(Last.X + Direction.Y, Last.Y - Direction.X); List<NominoPoint> MoveList = new List<NominoPoint>() { LeftMove, ForwardMove, RightwardMove }; int[] ArrayOrder = new int[] { 0, 1, 2 }; if (GenerationFlags.HasFlag(NominoPieceGenerationFlags.Flag_Randomize)) ArrayOrder = TetrisGame.Shuffle(ArrayOrder).ToArray(); foreach (int index in ArrayOrder) { if (!DirectionLists[index].Contains(MoveList[index])) { DirectionLists[index].Add(MoveList[index]); if (BlockCount - 1 > 0) { var Currresult = GetPieces(BlockCount - 1, DirectionLists[index],GenerationFlags); foreach (var iterate in Currresult) yield return iterate; } else { yield return DirectionLists[index]; } } } } }</nominopoint> |
Instead of the three almost identical blocks for each direction, they are now handled in a loop, and the order of that loop is determined via the ArrayOrder array; furthermore, if the Randomization is set, then the order of the array is randomized. This relatively straightforward change means effectively that while it will still go through every possibility, it will do so in an indeterministic order if the flag is passed.
By using an iterator method, this also allows for a rather interesting usage which simply wants one random piece:
1 |
return GetPieces(BlockCount, null, NominoPieceGenerationFlags.Flag_Randomize).FirstOrDefault(); |
For implementation, there are still issues. I quickly hacked the game such that the routine generating the next Nomino simply called the above. Results were a bit mixed.
Unfortunately, there are some architectural issues with this specific change. There’s good and bad indicators, here though. In particular, many things internally use the specific “Type” of the Nomino as a key into some dictionary or other cache; for example, the Images that are used for the Next Queue are actually indexed by the theme and the Nomino Type. Since all the “Pentominoes” here are actually just a Nomino, they share the same type, so all the Pentominoes share the image of the first one generated. The count of nominoes is of course not going to be useful here, since I didn’t change it. it is working properly in the sense that none of those Tetrominoes are used, though. Other than the presence of the Type being used as a Key for some aspects of the underlying handling, it actually handles the Pentominoes reasonably well; For example while it is just the one image, it did draw the Pentomino correctly, and it is handling them correctly within the game field, with some exceptions. A standard Tetris Field has 2 “hidden rows” which are basically used for generating the Tetrominoes off screen. All tetrominoes fit there. Pentominoes, however, do not; so what happens is some generated Pentominoes are generated and placed “off-screen” but they are now too high up and some blocks are actually outside the actual game field, including hidden rows. The game doesn’t handle this well. if the top row is outside, then it won’t let you move side to side until it falls one block. And it’s worse if a generated Nomino is two blocks above, because as soon as it falls one block, the game will detect that it cannot down (because the game considers the block that will still be outside the valid area invalid) and thus decides to “set” the block in place; and of course it’s at the very top which sets off the Game Over detection. Of course, the number of Hidden Rows is not hard-coded in my implementation, and nominoes could arguably be more intelligently spawned only as high as possible, instead of forcing them to be above the visible area.
In any case, My next task appears to be refactoring BASeTris such that the parts that use Nomino Class Types for HashSets and Dictionaries and such to instead use the string representation, which should address the “Next Queue” issue; From there I need to device a proper way for these extended “NTris” implementations to show for example the Nomino counts. My first thought is that the list of Nominoes would simply expand with each new unique one that is generated, and when there are too many to fit vertically it tries to create some sort of grid arrangement, with the Nomino image underneath and the count on top or something to that effect.
It’s a bit funny that I felt like doing this, and meanwhile the “Tetris 2” implementation is still largely unfinished and untested!
Have something to say about this post? Comment!