Loading...

EC2019 Visualizer (Part 5)

Setting up the level

Previously...

In Part 4: The Blueprint Utility Functions I went over the basics of setting up the UI that allows match log selections and reading the relevant files from that directory.

In this post I'll cover the reading and parsing of the last set of files - Console.txt, from each round folder that contains details of the map states and setting up the initial components of the level.

My approach was to use the Console.txt to handle all the map transitions, and use the CSV files to manage player actions. The idea behind this approach is to avoid parsing the more complex TextMap.txt files.

Console.txt - Reading the Mapstates

The Console.txt files for each round contains two lines of player information, and an ASCII representation of the map for the current round. The file looks like this:
EC2019_P5_IMG_1

This file was pretty simple compared to the TextMap.txt file, and contained all the information I needed, as far as I knew.
Initially, I just wanted to read the map state, so I ignored the first two lines of data (and the blank line) and only read the map data.

The functions I built to read the map states are listed below:


void UEC2019Utilities::ReadMapStateFromText(TArray &mapStates, 
    const FString matchDirectory) {
    TArray roundStates;
    for(FString roundDir : roundDirectories) {
		roundStates.Empty();
		UEC2019Utilities::ReadMapStateFromFile(roundStates, roundDir, matchDirectory);
		UMapStateArray *newStateArray = NewObject();
		newStateArray->mapStates = roundStates;
		mapStates.Add(newStateArray);
    }
}

void UEC2019Utilities::ReadMapStateFromFile(TArray &roundStates,
    const FString directory, 
   const FString matchDirectory) {
	TArray PlayerDirectories;
	
	FString rootDir = matchDirectory + "/" + directory;
	UEC2019Utilities::GetAllRoundDirectories(PlayerDirectories, rootDir);
	
	FString finalPath = matchDirectory + "/" + directory + "/" + PlayerDirectories[0] + "/Console/Console.txt";
	TArray consoleLines;
	FFileHelper::LoadFileToStringArray(consoleLines, *finalPath);
	
	for(int y = 0; y < consoleLines.Num(); ++y) {
		if (y < 3) {
			continue;
		}
		FString line = consoleLines[y];
		TArray lineArray = line.GetCharArray();
		for(int x = 0; x < lineArray.Num() - 2; x += 2) {
			int intVal = FChar::ConvertCharDigitToInt(lineArray[x]);
			UMapState *mState = NewObject();
			
			char buffer[6];
			sprintf(buffer, "%d-%d",(x/2),(y-3));
			mState->CoordX = x/2;
			mState->CoordY = y-3;
			mState->actorKey = FString(ANSI_TO_TCHAR(buffer));
			
			switch(lineArray[x]) {
				case 9608:
					//Deep space
					mState->TerrainType = 
                                            MapTerrainType::MTT_SPACE;
					break;
				case 9619:
					//Dirt
					mState->TerrainType = 
                                            MapTerrainType::MTT_DIRT;
					break;
				case 9568:
					//Health left (9571 right)
					mState->TerrainType = 
                                            MapTerrainType::MTT_POWER;
					break;
				default:
					//Air
					mState->TerrainType = 
                                            MapTerrainType::MTT_AIR;
					break;
			}
			
			roundStates.Add(mState);
		}
	}
}

This is pretty a straightforward piece of code so I'm not going to cover it in too much detail.

The first function gets all the round directories in the match, and for each of those calls the second function. The second function load the Console.txt file for that particular round using the built-in FFileHelper::LoadFileToStringArray method.

Then, the code loops through each file in the Console.txt file, ignoring the first three. 

The map is specified to be 33x33 in the game rules, but the Console.txt files have 33 lines of 66 characters. Each tile is represented by 2 characters, and as such the each line is iterated over, taking every second character. 

The only odd piece of code happens right after each character is read. I needed an FString ID to identify each tile (for handling transitions between rounds). The ID has to be of type FString to be a blueprintable property, however instantiating and concatenating the FString object (to get the correct format) for each tile was very slow.
To get around this, I used a character buffer and the ever popular sprintf to place the tile x and y coordinates into the buffer. The buffer is then run through the built-in ANSI_TO_TCHAR macro, and the result is used to instantiate the FString.

The rest of the function simply checks the integer value of the character read, and assigns the relevant tile type to the object.

Each new tile object is added to a TArray<UMapState *> object for each round, and each array of tile objects is then added to a TArray<UMapStateArray *> object, to be used in the rest of the  blueprint. (The UMapStateArray object is a simple object, that contains a TArray<UMapState *> variable.)

Back to the Blueprints

In P4: The Utility Functions, I detailed the event graph for the loading screen widget. For the final step of reading the Console.txt files, the following nodes were added to the end of that graph (in the WBP_LOADING_SCREEN blueprint):
EC2019_P5_IMG_2

The only important nodes are the Read Map States From Text (the code for which was listed above) and the Call DidRemoveFromParent node that will tell the GameState blueprint when the loading screen is removed from view. (Very similar to how the splash screen call to the GameState when the directory is selected).

Back to the GameState blueprint - I have added nodes to the blueprint to bind bind the DidRemoveFromParent callback to a custom event as shown below:
EC2019_P5_IMG_3

After DidRemoveFromParent_Loading is called (once the Console.txt files have been read) I set the returned MapStatesArray as a variable on the GameMode blueprint for easy access later. After this I called the built-in function Load Level Instance to load the Worms level.
Once the level load has been called, I remove the loading screen from the viewport. 
There is room here for a small refactor, where I could check if the level loaded succesfully before removing the loading screen from view.

The Level

Looking at the level outliner, there are a few component in this level:

EC2019_P5_IMG_4

First, I have added three camera actors, one top-down view of the map, and two angled cameras - one on each side of the map. The user can freely switch between these cameras at runtime and I will cover the mechanics of that part later in this post.

The directional light simulates the sun, by casting paralel rays in the specified direction. Since I didn't use emissive materials, I needed a global light source to illuminate the scene.

The post process volume takes every rendered component that fall within its bounds and applies a post-processing effect to the result - in this case, a toon shader. The post processing material assigned to the volume will also be explained in more detail a bit later.

Finally, I didn't want the level to float in the middle of some nothing and instead used a nifty tool to generate a cube map that I used to build a Skybox for the backdrop. Some detail around this to follow.

The Cameras

Since none of the tiles are spawned before the level loads, placing the cameras was a little tricky. What I did was to calculate where the map corners would be, and placed some of the built in geometry in the editor at those points. Then for each camera, I selected the camera in the world outliner to get a preview of what the camera would see. From here, you can right click a camera and go into pilot mode, and then position the camera using the WASD keys and mouse. 

After positioning each of these three cameras, I had to assign keys to switch between them. This is done in the project preferences screen. Since the key is a simple press I added the keyboard keys 1, 2 and 3 as Action Mappings
EC2019_P5_IMG_5

This screen can be accessed by clicking Edit then Project Settings and locating the Input section. To add an action mapping simply click the plus icon next to Action Mappings. For each camera I added an action named for the appropriate camera, and bound each to a number key using the dropdown list.

In the level blueprint, I added three CustomEvents, one for each of the action mappings. These events call a blueprint function Switch Camera. Blueprint functions are very useful to keep the node graphs clean. This is what it looks like:
EC2019_P5_IMG_6

Inside the Switch Camera function is where everything happens:
EC2019_P5_IMG_7
Not the most efficient solution, but it works. The key pressed is an input to the function, so a series of if-statements determine which of the three keys are pressed. After the key is determined, the built-in function Set View Target with Blend gets called and it takes in a reference to the camera which the user selected.
To get a camera reference, simply drag the camera from the level outliner onto the blueprint.
This method will then blend from the current view to the selected camera view. Pretty simple.

The Post Process elements

To get the correct visual look and feel I was aiming for, I built a post process volume and post process material to give the level a toon-shaded effect.
The post process material is assigned to the post process volume, and any object inside this volume will be rendered using this effect.

EC2019_P5_IMG_8

Creating a post process volume is as simple is finding the Post Process volume in the objects list and drag it into the scene. Then using the scale options I ensured that the volume would be large enough to hold all the tiles that will spawn.

The post process volume needs a post process material to tell it how to adjust objects within its bounds. The material graph looks like this:

EC2019_P5_IMG_9

There are two parts - The first is setting up the toon shader, and the second is to isolate the pixel depth of the rendered object from the rest of the scene. Initially, I had my post process volume setup to be unbounded (of infinite size) and that broke the Skybox (since the Skybox uses emissive materials which aren't compatible with this post process effect). Even though I've bounded my post process volume now, I figured it might be good to have some extra control.

To start off, I created a new material and changed the material domain to be Post Process like so:
EC2019_P5_IMG_10

I started with the Toon Shader part of the material:

EC2019_P5_IMG_11

The first to nodes are built in properties of Post Process materials. Creating the effect is fairly straight forward. PostProcessInput0 provides the scene colour and lighting information, and the DiffuseColour input provides colour information of the object to be rendered.
Running these through the build in Desaturation nodes, gives us the scene lighting information and the object colour information as grayscale maps. Dividing the scene lighting information with the grayscale colour information, and clamping that to a value between 0 and 1, gives a value that roughly indicates the strength of the normal vector at a point on the object.
This value is then used as an index to a Look Up Table (LUT). The LUT is simple texture sample, setup in this example to give discrete values ranging from white to black. The value returned from the LUT is then multiplied with the diffuse colour of the object, effectively splitting the diffuse colour into a limited number of discrete bands. (Note: The append 0 node is there because we need a Constant4Vector type while the LUT gives us a Constant3Vector).

The LUT I made looks like this (rotated horizontally for space considerations):
EC2019_P5_IMG_12

The output of the Toon Shader component is used as an input into the depth checking function.

The node graph of the depth check looks like this:
EC2019_P5_IMG_13
Here I compare the SceneDepth value of the object and a CustomDepth value of the object. Both are masked to use only the Red component of the colour, although any would be fine as the depth maps are grayscale.
If the custom depth is greater than the scene depth, the post process input (the original colour and lighting information) is set as the emissive output of the material.
If the custom depth is less than the scene depth, the toon shader component output is set as the emissive property of the material, and will produce the toon shaded effect.

This setup allows me to include or exclude objects from the post process shader simply by setting a custom depth value on the model.

Conclusion

This post is getting fairly long, so I will end it here. In the next post I will cover setting up the Skybox, the tile actors that make up the map, and generating the initial map state.

As always, any comments or suggestions would be appreciated. If you have more general questions or comments not related to this post drop a message on the Contact Page.

Until next time...