- New earth level cover boulders
- New earth level trees and bushes with shootable leaves
- New earth level gophers
- New earth level triple shot enemy
- Dozens of new rooms
- Several technical and performance enhancements
Category: GameDev
Major Update Version 0.7
Version 0.7 includes:
-
New explodable reactors
-
New breakable fish tanks
-
Faster loading speeds and smaller file sizes
-
New earth boss
-
New air strikes
-
Many enhancements for the first level
-
New talking holograms
-
Refined story and opening sequence
-
Dozens of new rooms
-
New health boxes
-
Improved mouse and keyboard controls.
Major Update Version 0.6
The new update includes:
- 2 New playable characters with different weapons and attributes!
- New Rescue Missions! Save your teammates then fight alongside them
- New unlockable item system. Earn experience points and spend them to expand your arsenal!
- Expanded opening sequence
- New weapon favoriting system
- Fire and Ice grenades!
- New character voices and improved dialogue
- Electric bullet improvements.
Thanks again everyone for the great feedback, it has been extremely useful in shaping RSR.
The Importance of C# Extensions in Unity
Unity is a great tool for building games, it offers a ton of features and makes it almost painless to export your game to multiple platforms. However it it still has some shortcomings, and you can’t expect it to do everything you need right out of the box. Today we’ll discuss C# extensions and how they can be used in Unity to cut your design time and make your code cleaner. We’ll discuss some of the most useful ones we’ve used during the development of Rogue Star Rescue.
What is an extension?
Simply put, an extensions adds a public function to a class you didn’t write. In Unity this is especially useful because we don’t want to override the GameObject or Transform class every time we need some extra functionality. They are defined as static functions and use the ‘this’ keyword to reference the instanced object the function is applied to. The declaration looks like this:
public static ReturnClass extensionFunctionName(this ExtendedClass object) {}
Vector Extensions
In 2d games Vector2s are used almost everywhere. The problem is that they don’t always ‘fit’ with a lot of the 3d functions of GameObject, causing a lot of compiler errors/warnings from casts. We can use some extensions to make life easier.
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 | public static Vector3Int toVector3Int(this Vector2Int v) { return new Vector3Int(v.x, v.y, 0); } public static Vector2Int toVector2Int(this Vector3Int v) { return new Vector2Int(v.x, v.y); } public static Vector2 tileCenter(this Vector2Int v) { return new Vector2(v.x + 0.5f, v.y + 0.5f); } public static Vector2 tileCenter(this Vector3Int v) { return new Vector2(v.x + 0.5f, v.y + 0.5f); } public static Vector2Int toVector2IntRound(this Vector3 v) { return new Vector2Int(Mathf.RoundToInt(v.x), Mathf.RoundToInt(v.y)); } public static Vector3Int toVector3IntRound(this Vector3 v) { return new Vector3Int(Mathf.RoundToInt(v.x), Mathf.RoundToInt(v.y), Mathf.RoundToInt(v.z)); } public static Vector3Int toVector3IntFloor(this Vector3 v) { return new Vector3Int(Mathf.FloorToInt(v.x), Mathf.FloorToInt(v.y), Mathf.FloorToInt(v.z)); } public static Vector2 toVector2(this Vector2Int v) { return v; } public static Vector2 toVector2(this Vector3 v) { return v; } public static Vector2 Rotate(this Vector2 v, float degrees) { float radians = degrees * Mathf.Deg2Rad; float sin = Mathf.Sin(radians); float cos = Mathf.Cos(radians); float tx = v.x; float ty = v.y; return new Vector2(cos * tx - sin * ty, sin * tx + cos * ty); } public static Vector3 toVector3(this Vector2Int v) { return new Vector3(v.x, v.y); } public static Vector2Int toVector2IntFloor(this Vector2 v) { return new Vector2Int(Mathf.FloorToInt(v.x), Mathf.FloorToInt(v.y)); } |
Furthermore if you’re using Tilemaps you’ll need a lot of Vector3Ints. These neighbour functions are very helpful for procedural tile generation logic.
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 | public static List getNeighbors(this Vector3Int center, int degree =1) { List neighbors = new List(); for (int x = center.x - degree; x <= center.x + degree; x++) for (int y = center.y - degree; y <= center.y + degree; y++) { if (x == center.x && y == center.y) continue; //skip center point neighbors.Add(new Vector3Int(x, y, 0)); } return neighbors; } public static List get4PointNeighbors(this Vector3Int center, int degree = 1) { List neighbors = new List(); for (int x = center.x - degree; x <= center.x + degree; x++) for (int y = center.y - degree; y <= center.y + degree; y++) { if (x == center.x && y == center.y) continue; //skip center point if (x == center.x || y == center.y) //make sure it shares one axis, this ensures 4 point values only { neighbors.Add(new Vector3Int(x, y, 0)); } } return neighbors; } public static Vector3Int getNeighbor(this Vector3Int center, CCDirection direction) { return center + direction.toVector2Int().toVector3Int(); } |
List and Enumerable Extensions
Any game is going to have a lot of List and Enumerable objects. Below are some useful extensions.
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 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 | public static bool containsAll(this List a, List b) { foreach (var bItem in b) { if (!a.Contains(bItem)) return false; } return true; } public static void Shuffle(this IList ts) { var count = ts.Count; var last = count - 1; for (var i = 0; i < last; ++i) { var r = UnityEngine.Random.Range(i, count); var tmp = ts[i]; ts[i] = ts[r]; ts[r] = tmp; } } public static List cutIndexes(this IList list) { var indexes = new List(); int cutPoint = Random.Range(0, list.Count); for(int i = cutPoint; i<list.Count; i++) //up { indexes.Add(i); } for(int i =cutPoint; i>=0; i--) //down { if (i == cutPoint) continue; indexes.Add(i); } return indexes; } public static void removeNull(this IList list) where T:Object { for (var i = list.Count - 1; i > -1; i--) { if (list[i] == null) list.RemoveAt(i); } } public static T getRandom(this IList ts, IEnumerable exclusionList = null) { if (ts.Count == 0) return default(T); if (exclusionList != null) { //remove exclusion list first var tempList = new List(); tempList.AddRange(ts); foreach (var exclude in exclusionList) tempList.Remove(exclude); if (tempList.Count == 0) return default(T); return tempList[Random.Range(0, tempList.Count)];//exclusive end } return ts[Random.Range(0, ts.Count)];//exclusive end } public static T circularToggleStruct(this IList ts, T currentSelection, bool toggleUp = true) where T : struct { if (ts.Count == 0) return default(T); int index = ts.IndexOf(currentSelection); if (toggleUp) index++; else index--; //circular indexing if (index < 0) index = ts.Count - 1; if (index == ts.Count) index = 0; return ts[index]; } //string is IEnumerable, so doesn't work with object or struct public static string circularToggleString(this IList ts, string currentSelection, bool toggleUp = true) { if (ts.Count == 0) return null; //null case if (currentSelection == null) return ts[0]; int index = ts.IndexOf(currentSelection); if (toggleUp) index++; else index--; //circular indexing if (index < 0) index = ts.Count - 1; if (index == ts.Count) index = 0; return ts[index]; } public static T circularToggle(this IList ts, T currentSelection, bool toggleUp = true) where T : Object { if (ts.Count == 0) return null; //null case if (currentSelection == null) return ts[0]; int index = ts.IndexOf(currentSelection); if (toggleUp) index++; else index--; //circular indexing if (index < 0) index = ts.Count - 1; if (index == ts.Count) index = 0; return ts[index]; } |
GameObject and Transform Extensions
GameObjects and Transforms are at the core of any Unity game. Unfortunately they are still missing some conveniences that makes them hard to work with. The following extensions can make working with them much smoother.
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 | public static bool isPlayer(this GameObject go) { return go.CompareTag(Player.tagIdentifier); } public static bool HasComponent(this GameObject go) where T : Component { return go.GetComponent() != null; } //maintains the current z public static void setXYPosition(this Transform transform, Vector2 pos) { transform.position = new Vector3(pos.x, pos.y, transform.position.z); } public static void setZPosition(this Transform transform, float zPos) { transform.position = new Vector3(transform.position.x, transform.position.y, zPos); } //maintains the current z public static void setXYLocalPosition(this Transform transform, Vector2 pos) { transform.localPosition = new Vector3(pos.x, pos.y, transform.localPosition.z); } public static void setYLocalPosition(this Transform transform, float yPos) { transform.localPosition = new Vector3(transform.localPosition.x, yPos, transform.localPosition.z); } public static void setZLocalPosition(this Transform transform, float zPos) { transform.localPosition = new Vector3(transform.localPosition.x, transform.localPosition.y, zPos); } |
The great thing about these extensions is that they can be reused in your future projects! I like to put them all in one file but you can just as well use multiple files, depending on how complicated your game is. Rogue Star Rescue’s code base would be much larger without them, so use them wisely to keep your project more maintainable.
2D Multi-Layer Tilemap Design in Unity
In 2D game design it is essential to have an organized and efficient way to determine how our tilemaps are designed and how they interact with each other. This can quickly become a debugging nightmare if you do not setup the appropriate rules, especially when the perspective is a slightly angled top-down like in Rogue Star Rescue. Today we’ll discuss multi-tilemap strategies to minimize headaches and ensure realistic gameplay.
Tilemaps
The first thing you’ll want to determine is how many tilemap layers you will have. This is important for both the rendering and the collision physics of the layer. In our game we prefabricate rooms, Here’s an example of the tile layers we use for each room:
- Floor Tilemap
- Floor Overlay Tilemap
- Pit Tilemap
- AbovePlayerWall Tilemap
- DynamicWall Tilemap
- MidWallCollider Tilemap
- Map Tilemap
(here you can see the many tilemaps of a simple room, also visible are doors, navigation maps, and enemy containers)
Floor and Floor Overlay Tilemaps
Conceptually these are simple layers. They both use a sorting layer called ‘Floor’ on their TilemapRenderers. The Floor tilemap has a sorting order of 0 and the overlay tilemap has a sorting order greater than 0. This ensures that everything on the overlay layer is always above the floor. In this example the stripes around the pit are on the overlay layer. These tilemaps don’t have any colliders since the player and enemy walk freely on top of them.
Pit Tilemap
The pit tilemap is similar to the floor in that it is always rendered below the player. It is on the same sorting layer (Floor) and has the same sorting order (0). The main difference is that this layer uses colliders as triggers, to detect when a player or enemy falls in a pit. This requires a TilemapCollider2D component. We also use this with a CompositeCollider2D to combine and decrease the number of collider vertices, this is important for performance.
(the pit tilemap with Composite and Tilemap colliders, visible as green lines over the pit)
AbovePlayerWall and DynamicWall Tilemaps
These are two sets of tile maps that only differ by their sorting layer. The AbovePlayer map is on a sorting layer called “AbovePlayer” and the DynamicWall map is on a layer called “Dynamic”. In many cases, you may be able to combine these into a single wall tilemap. However, we found this was better for cases where the player is rubbing up against walls. The sorting for the overlapping gun looks more realistic. The Dynamic sorting layer is what most characters and gameplay objects are on. It uses axis dependent sorting to determine what goes on top of what. We will discuss sorting in more detail in a future article. These tilemaps also have composite colliders to restrict the movement of the player and enemies;
MidWallCollider Tilemap
This map won’t apply to everyone. It’s a tilemap without a renderer that is derived from the wall tilemaps programmatically. Essentially it copies the structure of the wall tilemaps and offsets the collider by roughly half a tile. This is put on its own collision layer (do not confuse with sorting layer) that bullets interact with. This allows bullets to collide with the center of the wall instead of the base of the wall where the player’s feet collide. The collision rules used in each game are different so it’s up to you to determine what the most efficient way of dealing with this is. It can get a bit tricky in 2-D games where you essentially have to fake a third dimension (height).
Map TileMap
This is a tilemap on its own collision layer “Map” even though it does not collide with anything. Its sole purpose is to create what is shown on the minimap. This is done by using a secondary camera that only sees the collision layer “Map” on its culling mask. This tilemap is derived by combining the others. Copy all the floor tiles and wall tiles to it, optionally you can include the pits if you want them in your minimap.
(a secondary minimap camera that only sees the Map layer with its culling mask)
That’s the basic structure we use for rooms and hallways in Rogue Star Rescue. Each game will be different but hopefully, that gives you some insight on how to structure your game’s many tilemaps.
Procedural Level Generation with Rooms
Create randomized levels with prefabricated rooms is a great way to increase replayability and is often found in what is known as the Rogue-like/lite genre. Given a collection of rooms, we need to find a way to connect them in a logical manner while maintaining a target room count. In Rouge Star Rescue we’ve devised an algorithm that ensures the player must complete a minimum amount of rooms before clearing the level.
The Main Chain
The main room chain is the shortest path the player can follow, from the start room to the end room. At a high level the algorithm for this is as follows:
— Start by placing a random start room at 0,0.
— Find an unconnected door in that room.
— Create a hallway with limited length from that door, check that it doesn’t overlap with any previously placed rooms or hallways.
— Attach a new random room to the end of that hallway (check that it doesn’t overlap).
— Repeat this loop, treating the newly placed room as the start room, for X amount of times. X is the length of the chain.
(The main chain, of length 8, start room at the top, end room at the bottom)
Branching
Although the main chain is fully randomized, it still doesn’t create very interesting gameplay. We don’t want the player to just go from the start room to end room. We want the player to be able to explore around. In roguelite games it shouldn’t be immediately obvious which door the player should choose. There has to be some side and backtracking to maintain a sense of mystery.
To fix this we add branch rooms to the main chain. These are rooms that extend from the main chain rooms. The high-level algorithm is as follows:
— Choose a room in the existing chain.
— Check if it has any unconnected doors.
— If yes, create a hallway, check that it doesn’t overlap.
— If the hallway is valid, place a random room at the end of it, check that it doesn’t overlap.
— Repeat this for each room in the main chain.
Additionally, you can choose to branch even more by repeating the same algorithm on the branch rooms. You can continue to loop over them as many times as you want, creating a large branch out effect. In our game we specify MAX_ROOMS for each level, and the branches will continue to loop over itself until the MAX_ROOMS number is achieved.
(The main chain with 4 branches added)
Post-Linking Hallways
Branching has gone a long way to create more interesting levels. However after the player plays the game for a while, he might notice that there is a pattern that feels like he’s in a tree with branches. We want to eliminate any chance of the player being able to predict which room to go to for the shortest level completion. For this we add post linking hallways. The high-level algorithm is as follows:
— Loop through each generated room
— check if that room has an unconnected door
— if yes, check if there is another room with an unconnected door nearby
— if yes, make a hallway between them (check for overlaps).
It’s a fairly simple algorithm but the effects of this on gameplay are profound. With this the level generates loops that can lead a player in an exploration circle. Optionally you can check that you are only post linking two rooms that are at a similar degree in the chain generation. For example, you don’t want to post link the second room in the chain with the 7th room in the chain, since by doing so the player will be able to bypass a large part of the chain. In Rogue Star Rescue we ensure that only rooms with a chain degree of +/- 2 can post link.
(Post linking hallways has created a nice loop around chain link #2 and #3)
The details of this level generation implementation are complicated and we will cover more detailed aspects of it in the future. Use this high-level plan to create interesting levels of your own.
Effective Aim Assist in 2D Shooters
Fast-action twin-stick shooters can be made much more enjoyable with aim assist. Aim assist is used to detect a target in a narrow search area, and then adjust the aim of the gun to point directly at the found target. Admittedly this feature isn’t for everybody, but for most controller players it’s a welcome addition. In the past couple weeks we’ve examined a few different approaches to implementing this in Unity.
(Searching for a target)
Circle Cast 2D Approach
The Unity documentation states: A CircleCast is conceptually like dragging a circle through the Scene in a particular direction. Any collider making contact with the circle can be detected and reported.
At first glance this might seem like an ideal solution for finding targets. Though we’ve found this has some shortcomings.
First, even though the circle cast has a variable circle radius it will still only detect one collision (unless you use CircleCastAll() but then you have to manage distance sorting logic). Imagine an enemy right next to a wall. If the circle cast is wide enough, it might hit the wall before hitting the enemy and hence not provide the expected target.
Secondly, the circle cast has a constant radius throughout its cast. Which means you cannot make cone-shaped casts. Cone shapes are very useful for aim assist since they effectively make the search area wider at further distances and narrower at closer ranges. Conceptually this is better as it leads to a less jerky experience at close range, and better target finding at a distance where it is most needed.
Angled Multi-Raycast Approach (Cone)
A better solution is to use many raycasts set off from the player. This way we can evaluate each ray individually and set them off in a cone shape. The following code demonstrates how this is achieved.
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 | public Transform lookForEnemyWithThickRaycast(Vector2 startWorldPos, Vector2 direction, float visibilityThickness) { if (visibilityThickness == 0) return null; //aim assist disabled int[] castOrder = { 2, 1, 3, 0, 4 }; int numberOfRays = castOrder.Length; const float minDistanceAway = 2.5f; //don't find results closer than this const float castDistance = 30f; const float flareOutAngle = 4f; Transform target = null; foreach (int i in castOrder) { Vector2 perpDirection = Vector2.Perpendicular(direction); float perpDistance = -visibilityThickness * 0.5f + i * visibilityThickness /(numberOfRays-1); Vector2 startPos = perpDirection * perpDistance + startWorldPos; float angleOffset = -flareOutAngle * 0.5f + i * flareOutAngle / (numberOfRays - 1); Vector2 flaredDirection = direction.Rotate(angleOffset); RaycastHit2D hit = Physics2D.Raycast(startPos, flaredDirection, castDistance, obstaclesEnemyExplosiveMask); Debug.DrawRay(startPos, flaredDirection * castDistance, Color.yellow, Time.deltaTime); if (hit && isInTargetLayer(hit.collider.gameObject.layer)) { //make sure it's in range float distanceAwaySqr = (hit.transform.position.toVector2() - startWorldPos).sqrMagnitude; if (distanceAwaySqr > minDistanceAway * minDistanceAway) { Debug.DrawRay(startPos, direction * castDistance, Color.red, Time.deltaTime); target = hit.transform; return target; } } } return target; } |
This function takes the visibility thickness as a parameter. It’s useful to tie this to a persistent setting. In Rogue Star Rescue we’re including a slider in settings which adjusts this from 0 to 100%, which is effectively the strength of the aim assist. This has the added bonus of letting users disable it completely, many mouse/keyboard players prefer that.
This function is called every frame in FixedUpdate from our main Player Controller. You can see the number of raycasts can be adjusted, although we found 5 to be the optimal amount. More raycasts will have a better search precision, but will cost more in terms of performance. Since this is called every frame, it’s better to keep it fast.
We also want to make sure aim assist isn’t active when an enemy is too close to the player (this creates a jerky effect). We use the minDistanceAway to ignore any targets that are too close. Also when evaluating distance we use the square of the distance between objects. Without getting too technical, it’s much more efficient to do it this way since it avoids the costly square root calculations of Pythagorean’s theorem.
Using the multi-raycast approach produces smooth and effective aim assist. This is a big part of the ‘feel’ of a game so it’s important to fine tune the parameters while still leaving an adjustable strength in the game settings.
(Found a target)
Abstracted 2D Tilemap Room Design in Unity
For many roguelike (or rogue-lite) games, developers choose to handcraft their rooms. This creates a super polished feel and a generally more enjoyable play experience. In non-procedural games this takes less time since the number of rooms or scenes is typically less than a hundred.
But what about designing games with hundreds or thousands of unique rooms? Imagine carefully designing these large sets of rooms over the course of months, to find out that a key feature of each room needs to be changed (such as a collider or door structure). Or that we need to replace all the rooms with a completely different tileset. In Rogue Star Rescue we’ve developed a system that allows us to invest weeks of time in good room design, but without tying it to one set of rules.
Room Abstraction
Instead of sitting down and designing each room directly. Let’s create an abstraction layer between the shape of the room and its stylized features. For this we create a MarkerSet. A MarkerSet is a simple tile set that defines the key features of a room, with no direct styling. Here’s an example of a simple marker tile set, and how it is used to design a room:
The logic for the markers is as follows:
Solid Orange Tile = Wall Marker
Light Gray Tile = Floor Marker
Dark Gray Tile = Pit Marker (not used in this example)
Diagonal Orange Tile = Door Marker
S and E Tiles = Special Start and End markers (not used in this example)
It’s good to solidify this logic by making a MarkerSet component class, in which each property holds a reference to the tile’s meaning.
TileSet Abstraction
To separate the sprite sheets from the room generation logic, we create a MyTileSet class that stores the information of what each tile *is*. As you can see below we define several fields in the class that dictate whether a tile is a north wall, pit, wall variant, floor etc. This is also very useful when working with artists and having to regularly update sprite sheets. This doesn’t lock our room logic to the positioning of a sprite in a sprite sheet.
To turn this into a finished room we will need unity editor scripts to transform our marker set. This can be done in many ways, but the basic idea is:
(MarkerTileMap + MarkerSet + MyTileSet) -> Editor Generation Logic -> Finished Room
The editor generation logic will use different logic to generate a finished room. A simple example of some logic will be for floors. The generation logic can search through each tile in the MarkerTileMap, check if it is a ‘floor’ tile. If so it can produce a floor tile in the same location on the finished room (with the value specified in MyTileSet).
Wall generation can get more complicated. When you detect a Wall Marker in your MarkerTileMap you will need to perform additional logic to check what type of wall will be placed. This is done by checking what other tiles are around the wall tile in question. For example, a wall tile in your MarkerTileMap may have a floor tile to the south of it, and no tile to the north of it. This would mean a NorthWall should be generated in the finished room. The logic for this type of generation can be very complicated, depending on your game style and view perspective. The main thing to remember here is that we want to keep the MarkerSet and MarkerTileMap as simple as possible so that we can generate rooms quickly. Let the editor generation logic do the hard math of what specific tile should be placed.
The beauty of this system is that if we want to change our room style to something completely different. We simply need to change the MyTileSet used for generation. Here is the above MarkerTileMap generated in two different styles:
This technique will save your sanity if you ever have to make major structural changes to your rooms. Use abstraction wisely and minimize your development risk.