Colorless
Game Description:
In Colorless, an isometric 2D puzzle game, players utilize movement and teleportation to guide a colorless woman to the paint palettes that will restore her colors. The game combines challenging puzzles with breathtaking art to provide a thoughtful, beautiful gameplay experience.
Development Specification:
- Role: Gameplay Programmer
- Engine: Unity 2017.1.0p4
- Development Time: 2 Months
- Team Size: 4
- Number of Programmers: 1
Responsibilities
- Built a level generator tool for designers to layout tiles into an isometric level using data from XML files.
- Wrote all the gameplay code. Implemented isometric tile-based movement, movable tile highlighting, portals and other interactable objects.
- Designed and implemented tutorials.
- Created the trailer for the game.
Level Generator
Designing levels in isometric view would lead to long iterations and thus is not an effective strategy to implement when working on a short-term project. To alleviate this issue, I wrote a tool to help the level designers generate levels from XML data. The tool takes the necessary prefabs and xml data as input and generates the layout in isometric space using transformations as necessary. In this way the level designers can convert a paper sketch to a level in unity in just 15 minutes (including time to write xml files). The tool itself is able to be run in editor, so that further passes can be done in the level using the editor.
This enabled:
- Faster level iterations.
- Easier level visualizations.
- More time to focus on aesthetic passes.
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 | void GenerateTileMap() { for (int i = 0; i < numRows; i++) { for (int j = 0; j < numCols; j++) { Vector2 tileCoord = new Vector2(j * tileWidth, i * tileLength); GameObject tile = null; //Rotation is what is set on the original prefab instead of being hard coded switch (levelLayout[i, j]) { case (int)TileManager_TileConstants.TileType.TILE_NORMAL_TOP: tileType = tilePrefab; tile = PlaceTile(tileType, CartToIso(tileCoord), tileCoord, tileType.transform.rotation.eulerAngles); tile.tag = "Tiles"; tile.layer = LayerMask.NameToLayer("TileLayer"); break; case (int)TileManager_TileConstants.TileType.TILE_PORTAL_TOP: tileType = teleportPrefab; tile = PlaceTile(tileType, CartToIso(tileCoord), tileCoord, tileType.transform.rotation.eulerAngles); tile.tag = "Teleporter1"; tile.layer = LayerMask.NameToLayer("TeleporterLayer"); break; case (int)TileManager_TileConstants.TileType.TILE_FENCE_TOP: tileType = fencePrefab; tile = PlaceTile(tileType, CartToIso(tileCoord), tileCoord, tileType.transform.rotation.eulerAngles); tile.GetComponent<Tile_TileBehavior>().isWall = true; tile.tag = "Hole"; tile.layer = LayerMask.NameToLayer("Default"); break; case (int)TileManager_TileConstants.TileType.TILE_DESTINATION_TOP: tileType = goalPrefab; tile = PlaceTile(tileType, CartToIso(tileCoord), tileCoord, tileType.transform.rotation.eulerAngles); tile.tag = "Goal"; tile.layer = LayerMask.NameToLayer("TileLayer"); break; case (int)TileManager_TileConstants.TileType.TILE_PORTAL_LOCKED: tileType = teleportPrefab; tile = PlaceTile(tileType, CartToIso(tileCoord), tileCoord, tileType.transform.rotation.eulerAngles); tile.GetComponent<TeleportTile_TeleportScript>().isActive = false; tile.tag = "Teleporter0"; tile.layer = LayerMask.NameToLayer("TileLayer"); break; case (int)TileManager_TileConstants.TileType.TILE_PICKUP_KEY: tileType = keyPickupPrefab; tile = PlaceTile(tileType, CartToIso(tileCoord), tileCoord, tileType.transform.rotation.eulerAngles); tile.tag = "Key"; tile.layer = LayerMask.NameToLayer("TileLayer"); break; case (int)TileManager_TileConstants.TileType.TILE_PICKUP_TURN: tileType = turnPickupPrefab; tile = PlaceTile(tileType, CartToIso(tileCoord), tileCoord, tileType.transform.rotation.eulerAngles); tile.tag = "Pickup_Turn"; tile.layer = LayerMask.NameToLayer("TileLayer"); break; } GameObject isoTile = PlaceTile(tileIsoPrefab, CartToIso(tileCoord), tileCoord, Vector3.zero); isoTile.transform.position = new Vector3(isoTile.transform.position.x, isoTile.transform.position.y, 15); isoTile.GetComponentInChildren<SpriteRenderer>().sortingOrder = -(count+1); tile.transform.parent = gameObject.transform; isoTile.transform.parent = isoTileFolder.transform; tile.name = i + "" + j; // For finding tiles easier isoTile.name = i + "" + j + " ISOTILE"; count++; } } } |
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 | <?xml version="1.0" encoding="UTF-8"?> <LevelLayout> <levelData> <row> <col>210</col> <col>210</col> <col>220</col> <col>210</col> </row> <row> <col>230</col> <col>230</col> <col>230</col> <col>230</col> </row> <row> <col>200</col> <col>210</col> <col>220</col> <col>210</col> </row> </levelData> <numRows>3</numRows> <numCols>4</numCols> <star2>5</star2> <star3>2</star3> <player>20</player> <palleteStatus>2</palleteStatus> </LevelLayout> |
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 | <?xml version="1.0" encoding="UTF-8"?> <LevelLayout> <levelData> <row> <col>230</col> <col>230</col> <col>230</col> <col>210</col> <col>230</col> <col>220</col> <col>210</col> <col>210</col> </row> <row> <col>200</col> <col>210</col> <col>210</col> <col>220</col> <col>230</col> <col>210</col> <col>230</col> <col>210</col> </row> <row> <col>210</col> <col>230</col> <col>230</col> <col>210</col> <col>230</col> <col>210</col> <col>230</col> <col>220</col> </row> <row> <col>230</col> <col>230</col> <col>230</col> <col>230</col> <col>220</col> <col>210</col> <col>210</col> <col>210</col> </row> <row> <col>230</col> <col>230</col> <col>230</col> <col>230</col> <col>230</col> <col>230</col> <col>210</col> <col>230</col> </row> <row> <col>210</col> <col>210</col> <col>230</col> <col>230</col> <col>210</col> <col>230</col> <col>210</col> <col>230</col> </row> <row> <col>220</col> <col>210</col> <col>230</col> <col>210</col> <col>220</col> <col>210</col> <col>210</col> <col>230</col> </row> <row> <col>210</col> <col>210</col> <col>230</col> <col>230</col> <col>210</col> <col>230</col> <col>230</col> <col>230</col> </row> </levelData> <numRows>8</numRows> <numCols>8</numCols> <star2>6</star2> <star3>5</star3> <player>00</player> <palleteStatus>2</palleteStatus> </LevelLayout> |
Isometric Projection
We decided to go with an Isometric perspective for aesthetic reasons. This meant that I needed to figure out how it would be implemented and what the best method would be, given our short time frame. I ended up coding a coordinate space converter that could be used for all the automated behaviors like moving player, scripting objects, etc.
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 |
//edited from 0.5f to 0.7f to line up edges for 45* angles on tiles public Vector2 CartToIso(Vector2 cartCoords) { Vector2 temp; temp.x = cartCoords.x - cartCoords.y; temp.y = (cartCoords.x + cartCoords.y) * 0.7f; return temp; } //edited from 0.5f to 0.7f to line up edges for 45* angles on tiles public Vector2 IsotoCart(Vector2 isoCoords) { Vector2 temp; temp.x = (isoCoords.y / 0.7f + isoCoords.x ) * 0.5f; temp.y = (isoCoords.y / 0.7f - isoCoords.x ) * 0.5f; return temp; } GameObject PlaceTile(GameObject tileType, Vector2 isoCoords, Vector2 tileCoords, Vector3 rotation) { GameObject newTile = GameObject.Instantiate(tileType, isoCoords, Quaternion.Euler(rotation)); // Sort depth using the sum of the x and y coordinates newTile.transform.position = new Vector3(isoCoords.x, isoCoords.y, (tileCoords.x + tileCoords.y)); return newTile; } |
Movable Tile Highlighting - Conveyance
It was pointed out in our initial play tests that the game did not convey which tiles were movable. To solidify this conveyance, we used a highlighting system that:
- Shows all possible move tiles.
- Highlights tiles until a fence is reached.
- Highlights adjacent tiles to teleporter if the teleporter is linked.
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 | oid HighlightMovableArea( Vector2 directionToHighlight ) { int i = curRow; int j = curCol; GameObject currentTile; while ( i < numRows || j < numCols ) //Highlights the north side of character { currentTile = GetTileFromCoords(new Vector2(i, j)); if(currentTile) { if (currentTile.GetComponent<Tile_TileBehavior>().isWall) { break; // Stop highlighting if wall is reached } if (currentTile.tag == "Tiles" || currentTile.tag == "Key" || currentTile.tag == "Pickup_Turn" || currentTile.tag == "Goal") { currentTile.transform.GetChild(1).gameObject.SetActive(true); currentTile.transform.gameObject.layer = LayerMask.NameToLayer("TileLayer"); movableTiles.Add(currentTile); } if (currentTile.tag == "Teleporter1") { if (currentTile.GetComponent<TeleportTile_TeleportScript>().isTeleportActive && currentTile.GetComponent<TeleportTile_TeleportScript>().chooseTeleporter) { currentTile = GetTileFromCoords(new Vector2(i + 1, j)); if (currentTile) { if (currentTile.tag != "Hole") { currentTile.transform.GetChild(1).gameObject.SetActive(true); currentTile.transform.gameObject.layer = LayerMask.NameToLayer("TileLayer"); movableTiles.Add(currentTile); // Highlight the adjacent tile if teleporter is linked break; } } } } } i += (int) directionToHighlight.x; j += (int) directionToHighlight.y; } } |
Teleportation
The game teleports the player when the player reaches the center of the teleporter tile (if active and linked). Additionally, the player moves one step when exiting the teleporter in the direction that the player entered. The script also manages necessary flags during the teleport to ensure that the player doesn’t cycle between the locations repeatedly.
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 | IEnumerator TeleportPlayer() { GameObject player = GameObject.Find("Player"); PlayerCharacter_IsometricMove scriptRef = player.GetComponent<PlayerCharacter_IsometricMove>(); int indexOfNext = int.Parse(chooseTeleporter.name.Substring(0,2)); yield return new WaitForSeconds(0); scriptRef.targetPos = chooseTeleporter.transform.position; player.transform.position = chooseTeleporter.transform.position; AudioManager_AudioController.GetInstance().PlaySFX(eAudioName.PortalUse, audioSource); chooseTeleporter.GetComponent<TeleportTile_TeleportScript>().isTeleportUsed = true; string temp = null; if (scriptRef.MoveDir.x >= 0) { if(scriptRef.MoveDir.y >= 0) { indexOfNext++; temp = indexOfNext.ToString(); if (temp.Length == 1) temp = "0" + indexOfNext; } else { indexOfNext -= 10; temp = indexOfNext + ""; if (temp.Length == 1) temp = "0" + indexOfNext; } } if (scriptRef.MoveDir.x < 0) { if (scriptRef.MoveDir.y >= 0) { indexOfNext += 10; temp = indexOfNext + ""; if (temp.Length == 1) temp = "0" + indexOfNext; } else { indexOfNext--; temp = indexOfNext.ToString(); if (temp.Length == 1) temp = "0" + indexOfNext; } } GameObject target = GameObject.Find(temp); if (target && !target.GetComponent<Tile_TileBehavior>().isWall) { scriptRef.targetPos = GameObject.Find(temp).transform.position; scriptRef.isMoving = true; } else { scriptRef.isMoving = true ; scriptRef.currentTile = chooseTeleporter; } DeactivatePortal(this.gameObject); scriptRef.isTeleporting = false; StopCoroutine(TeleportPlayer()); } |
Personal Postmortem
- Isometric projections: Although this was the first time I’m working on a pseudo-isometric view, I was able to do my research and figure out how to project the space into an isometric view as we had been restricted from using the camera to project it that way. There were some depth sorting issues that came up little later in the build, but I was able to solve it soon and get the build going.
- Rapid iterations: Throughout the development process I was able to rapidly iterate on my code and solve bugs faster which helped me on the long run as the bug list was always under check.
- Communication: I was able to communicate the technical specs of the tools that I provided for the game’s development, for example, Tile generation script, in an efficient way and it was well received by the team. I was able to cater to the level designer’s requests rather soon and was able to provide my best in terms of making their work easier so that they get more design time.
- Tile indexing issue: During the initial stages of production, I’d setup the tile generation system in a way that wasn’t easy to index each tile efficiently. This led to some lags in the game due to repeated loop times. I had to change the implementation of the system to pave way to the tile-highlight conveyance mechanism. Due to the hacky start, it caused a little hiccup when I tried to implement the new highlighting system.
- Tutorial estimation: One major misestimation on my part was my estimation of time for the tutorials to be implemented. Making a good tutorial can be a pain-staking process and I ended up working on it for 6 hours more than I planned. This led to some cuts in juice elements. Luckily, we’d started implementing tutorials little later development. If it had been sooner, we would have had a big delay in our production.
- Art implementation: During our initial phases, I was stuck up on developing the systems on the game and thus, couldn’t implement most of the art that my artist put out. This led to some lost work and thus, wasted time. There were some issues in the art (orientation) that was found a bit later than expected. This would have been easier to solve if I’d put a bit of time into implementing the animation trees and the art sooner.
- Don’t ever start with hacky code even if you’re crunching unless you want to face major issues later.
- I learnt a lot about how projection math works and how you implement various projections without orienting the camera object.
- Don’t put off tasks even if you have it figured out unless you want to be questioned by your stake holder (or) he/she thinks it’s going to be a new feature in the late run.
- Implement all the assets and validate them to know how they work in the game. If not, it would lead to a lot of wasted time.
Team Postmortem
- We had a cohesive vision after finalizing on the game idea.
- Team attitude was great. Disputes were resolved as soon as they were brought up.
- Bug fixing was rather fast and well managed from the initial stage of development thus, leading to a bug-free game in the end.
- Level design and programming pipelines were implemented well.
- Task delegation was not always clear; people would occasionally overlap tasks, or leave important tasks undone.
- We ended up needing to crunch when important features got overlooked during the sprint.
- Naming conventions were not always clear; LD naming convention differed wildly from SD and Art naming conventions, which lead to problems integrating levels later in the development cycle.
- We often received feedback too late for it to be easily implemented, and we were not proactive about reaching out to try and get that feedback earlier on in the process
- Feature requests were sometimes vague and miscommunicated to us. Only later we find out that the request was actually a far smaller task than previously conveyed.
- Iterate quickly, especially at the beginning of a project. Making changes faster is better.
- Always keep your target audience in mind, particularly when testing for difficulty.
- Always keep in mind that estimates sometimes involve other people.
- Pay attention to all conversations, not just limited to your discipline.