Importance of props in storytelling
According to Mark Brown from Game Maker's Toolkit, level design can convey three different messages :
- The feelings of the character (e.g. in God of War (2018), when Kratos is looking for Atreus, he's surrounded by rocky cliffs, the vision is blocked by a thick fog and he's being slowed down by a really narrow passage. This passage tries to convey a sense of panic as he's frantically looking for his son)
- Defining the player identity (e.g. in Hitman (2016), the level developers rely on the player's understanding of social behaviours to let them understand where they can or cannot go under certain conditions)
- Help the understanding of the world
This third point is where props are involved, as it falls under environmental storytelling. Mark defines it as a "small, optional, self-contained vignette", which requires the player to use their deductive reasoning, making them an active participant in the storytelling.
Environmental storytelling
Norsevar already had a handful of points of interest which prompted a short remark from Erik, the protagonist, when interacted with. Those remarks were entirely disconnected from the narrative and added nothing to the game. I decided to keep those elements and rewrite the attached dialogues to make them fit with the story of the game and Erik's personnality. I also asked Erik's voice actor to record new voice lines to accompany those dialogues.
Listed below are all points of interest and both the old and new dialogues.
—————————————————————————————————————
Point of interest : Axe and shield
Location : Start of the level, next to the player
Triggers : Trying to progress without grabbing both / Grabbing both items
Old dialogue : I should not leave without my Axe and Shield! / Now i'm prepared to go!
New dialogue : Can't leave my gear there ! / Now I can return to the village.
—————————————————————————————————————
Point of interest : Ritual circle
Location : Clearing
Trigger : Finishing the first fight
Old dialogue : Who performs those rituals ?
New dialogue : Hmm… What in Hel is that ritual ?
—————————————————————————————————————
Point of interest : Lakeside dock
Location : Lakeside by the clearing
Trigger : Finishing the first fight
Old dialogue : I can see the village from here
New dialogue : That's odd, I can see no smoke.
—————————————————————————————————————
Point of interest : Sign
Location : Clearing
Trigger : Finishing the first fight
Old dialogue : Must be a sign
New dialogue : Beware, avalanches... Least of my problems, I need to get to the village.
—————————————————————————————————————
Point of interest : Comment
Location : Valley
Trigger : Finishing the second fight
Old dialogue : Why are all these Animals going Feral?
New dialogue : By the Gods, what’s taken over them ?
—————————————————————————————————————
Point of interest : Fireplace
Location : Village
Trigger : Finishing the third fight
Old dialogue : Cozy
New dialogue : Still burning. They must be around here somewhere…
—————————————————————————————————————
Point of interest : House
Location : Village
Trigger : Finishing the third fight
Old dialogue : It's Locked
New dialogue : This seems to be locked.
—————————————————————————————————————
Point of interest : Fisherman's house
Location : Village
Trigger : Finishing the third fight
Old dialogue : Looks like the Fisher's House
New dialogue : Nobody here either… As fresh as usual though…
—————————————————————————————————————
Point of interest : Oceanside dock
Location : Oceanside by the village
Trigger : Finishing the third fight
Old dialogue : I'd be up for another Raid...
New dialogue : They don’t seem to have run to the seas. Sailing blue in the barren waves…
—————————————————————————————————————
Point of interest : Chief's House
Location : Village
Trigger : Finishing the third fight
Old dialogue : Looks like the Warrior's House
New dialogue : The Chief’s not here either… Evil must be afoot…
—————————————————————————————————————
Point of interest : Law Rock
Location : Village
Trigger : Finishing the third fight
Old dialogue : Here's their Law Rock
New dialogue : It’s too quiet…
—————————————————————————————————————
Point of interest : Blacksmith's forge
Location : Village
Trigger : Finishing the third fight
Old dialogue : I could use a better weapon!
New dialogue : Gunvor’s not hammering away… Now something is definitely wrong.
—————————————————————————————————————
Point of interest : Fox (1)
Location : Next to the player's spawn
Trigger : None
Old dialogue : Want me to follow you?
New dialogue : Hm ?
—————————————————————————————————————
Point of interest : Fox (2)
Location : Between the spawn and the clearing
Trigger : Completing Fox (1)
Old dialogue : What are you trying to show me?
New dialogue : You again ?
—————————————————————————————————————
Point of interest : Fox (3)
Location : Between the clearing and the valley
Trigger : Completing Fox (2)
Old dialogue : Keep safe, little Fox
New dialogue : You’re rather brave, for a fox…
—————————————————————————————————————
Point of interest : Fox (4)
Location : Between the valley and the village
Trigger : Completing Fox (3)
Old dialogue : Where are you leading me?
New dialogue : Heh, brave and reckless it is… Wait, what’s that ?
—————————————————————————————————————
Point of interest : Fox (5)
Location : Village
Trigger : Completing Fox (4)
Old dialogue : Don't leave me!!
New dialogue : Oh no, you’re not leaving in the forest before I get answers.
Traditional storytelling in Norsevar
Besides environmental storytelling, there are also elements pertaining to more traditional storytelling, most notably regarding exposition. As there are only two levels at the moment, being the first demo level and the hub where the player will respawn after a death during playthroughs, I will talk about what narrative elements are contained in both.
On the technical side of things, I rely on four main functions to play dialogues :
- PlaySound(ENorseGameEvent) raises an event at the player's position, which plays that sound. It can be run as a Coroutine.
- PlayDialogue(ENorseGameEvent) plays a dialogue voice line.
- PlayVoiceLine(ENorseGameEvent) plays a "one-liner".
- PlayRandomVoiceLine(ENorseGameEvent[], {float}) picks a random voice line from a given list and plays it. The optional float parameter allows for no voice line to play sometimes.
General functions used for playing voice lines
Level 1 - Game demo
The first level stands out from the rest, as the player will only play through it once. With that in mind, I wrote dialogues specifically for that level in order to introduce some key plot points. Erik will remark on the oddity of being attacked by wolves and snakes, even in the village. Drawing a comparison with the Hero's Journey, this first level would represent the first two steps, the Ordinary World and the Call to Adventure, as Erik's normal life whilst returning to the village gets disrupted through repeated wolf and snake attacks.
In order to trigger those dialogues, I altered my old methods for making Erik say a random line of dialogue when a combat starts or ends so that he would say new dialogues exclusively in this level. In the level itself, I added three box colliders to coincide with the three main fights, and gave them a tag related to each fight, so that the game would use the correct line, whilst still maintaining the random voice lines during other fights.
Bounds of the dialogue areas (left to right : Fight 1, 2 and 3)
OnEnterCombatVoiceLine
OnExitCombatVoiceLine
Level 2 - Hub
In a hypothetically complete version of the game, the player would first arrive in this level after dying during their very first run. As they wake up, the Foreknowing God Heimdall greets them telepathically, explaining to Erik what's going on and Loki's involvement in this, and concluding with Erik deciding to go after Loki. The parallel with the Hero's Journey continues, with the Refusal of the Call corresponding with Erik's first unravelled death : he tried to answer the call whilst in the comfort of his ordinary life, but he needs to become better and stronger to be able to progress. Meeting the Mentor happens as Erik wakes back up and is introduced to Heimdall, who explains to him the powers at play and the stakes. At this point, Erik could refuse the call deliberately, but he choses to face Loki, and is therefore Crossing the Threshold.
For those dialogues, I have simply placed a box collider around the player's starting point, and attached both a scriptable object which holds the number of times the player died, and a listener which, once the player leaves that box, plays either the Resurrection dialogue if this is the player's first death, or a random taunt from Loki for any subsequent death. Once the player leaves the box collider, it deactivates itself so that the player cannot trigger the dialogue multiple times. The Resurrection dialogue consists of a chain of events triggered one after the other with a short delay between lines. I tried using the dialogue system instead, but it felt clunky and unintuitive, especially for players using a controller. What's more, Erik's voice lines didn't seem to play as dialogue responses.
Bounds of the starting area
ResurrectionDialogue (all routines are the same, they just play the corresponding voice line and wait for its entire duration)
OnRespawnVoiceLine
Voice line manipulation in FMOD
For the voice lines, I asked some friends to record the dialogues I had written. Whilst the quality was pretty good, I wanted to add a bit more identity to the audio files. That's why I, with a bit of help from Simmone to explain how some of the technical features work, altered those voice lines in FMOD.
Erik (voiced by Léo V.)
The protagonist has quite a few voice lines, all spoken in one of three main environments :
- A forest (first section of the first level, hub and all run levels)
- The mountains (second section of the first level)
- The village (third section of the first level)
To reflect those environments, I associated a different level of reverb for each. The village has the least reverb, followed by the forest, and the mountains has the most reverb. Some of the dialogues could easily be assigned a level of reverb, as they are only spoken in a single environment. Others, on the other hand, can be uttered in any of those three environments. For that purpose, I created a global parameter called Environment and paired a level of reverb for each possible value. Then, I added three box colliders in the first level to determine the bounds of each environment, which I paired with FMOD Studio Global Parameter Triggers to alter the value of Environment relative to the player's position in the first level. Those aren't needed in subsequent levels, as from the hub onwards, Erik only progresses through a forest.
Bounds of the Environment areas (left to right : Forest, Mountains and Village)
FMOD settings - Erik (Forest)
FMOD settings - Erik (Mountains)
FMOD settings - Erik (Village)
Heimdall (voiced by Liam M.)
The Foreknowing God is only present in an introduction dialogue with Erik after the player's first death, explaining the situation to Erik. I wanted Heimdall to have a deep, booming voice, so I adjusted the pitch, shifting it down to get it deep enough without sounding fake. I also gave him a lot of reverb, to emulate the fact that he's communicating with Erik through telepathy, and to make him sound even more grand. When combined with him speaking in rhymes, it creates a figure that's grandiose and noble, which is exactly how my interpretation of Heimdall would look like.
FMOD settings - Heimdall
Loki (voiced by Rei S.)
The God of Mischief is only ever heard when Erik is saved by Heimdall's magic more than once, taunting the player to incentivize them to try again. The overall idea was to make Loki sound malicious, while also including elements reminiscing of their shapeshifting abilities. For that purpose, I added a delay to their voice lines to make it echo around the player, as if there were multiple Lokis taunting you. I also added the same reverb as for Heimdall to mimic the effect of telepathy for Loki as well.
FMOD settings - Loki
Example voice line : Erik (Forest)
Example voice line : Erik (Mountains)
Example voice line : Erik (Village)
Resurrection dialogue : Erik and Heimdall
Example voice line : Loki
Demonstration : Voice lines in level 1
Introduction to Procedural Level Generation
Being a roguelite, Norsevar could benefit from having randomly generated levels, as it would mean that less time needs to be allocated to level design, which can be used to work on other features. As I worked on this, I have broken this task into several, smaller pieces, before assembling them back together to create a complex generator.
Whilst working on the Procedural Level Generator, I have worked on the following components :
- Procedural Layout Generation, using evolutionary computing (abandoned)
- Procedural Terrain Generation, using layered Perlin noise applied to a plane
- Random Object Placement, using Poisson Disc Sampling
- Random Prop Placement, using Wave Function Collapse
- Procedural Tree Generation, using a modified hexagonal tile generator
- Procedural Rock Generation, using layered Perlin noise applied to a sphere
Procedural Layout Generation
Data structures
As suggested by the research paper, a Room as a rectangular mesh holds four integer values : the coordinates of the top-left corner (x ; y), and the room's length and width. The class also contains methods to detect if two rooms overlap, to find the bounds of the overlap, to calculate the area of said overlap, to find the combined area of two rooms, to build the mesh corresponding to that room, as well as a delegate methods to allow for mutations.
As Evolutionary Computing is closely related to genetics, some terminology is drawn over, including the notion of a Chromosome. As a data structure, it only holds a list of rooms (the genes) and their meshes. It also has methods to calculate the overall area, the number of narrow and tiny rooms, add a room, and build a mesh from overlapping the rooms on top of one another.
Evolutionary Computing
Evolutionary Computing designs a family of optimization algorithms which draw inspiration to the theory of Evolution, and uses a population of chromosomes to determine the most optimized (or fittest) outcome. Here, it relies on two key operations :
- Crossover is when a child is created from picking genes at random from either parent.
- Mutation is when a random chromosome's gene gets randomly shuffled.
Visual representation of crossover and mutation
C# implementation of the Crossover operation
C# implementation of the Mutation operation
In the context of Evolutionary Computing, a Population is comprised of several chromosomes and relies on a fitness function to determine the "viability" of each chromosome. A population starts with fully randomized chromosomes. Then, every time the population evolves, the following happens :
- The fitness function is used to assign a fitness rating to each chromosome.
- A new generation is created by operating a crossover between the two fittest chromosomes.
- This new generation is then mutated.
The idea is that, after enough generations have passed, the fitness rating would converse towards a certain value, meaning the fittest chromosome would be the absolutely ideal candidate. Once applied to the concept of room generation, several fitness functions can be considered, and three in particular were implemented :
- Maximizing the area, where the fitness rating simply equals the area of the room generated by the chromosome.
- Minimizing the area, where the fitness rating is inversely proportional to the room's surface area.
- Penalizing the area, where the fitness rating gets negatively impacted if the room has "narrow" or "tiny" sections (sections that are 1-unit wide or have a surface area of 1 respectively).
C# implementation of the three fitness functions.
Results
I have developed a function that can build a rectangular Room mesh based on the values held by the object, which works well. When implementing a similar method for chromosomes, I encountered a major issue, as vertices could get duplicated, which would cause problems with triangles. To solve this, I decided to turn each room into a grid of mesh squares, each containing two isosceles triangles and their indices to create a square with sides that are 1-unit long. With this, I was able to use HashSets to hold all vertices and squares, whilst being able to create a single mesh.
However, the function used to calculate the area doesn't seem to work properly, and gives vastly incorrect errors (including 0 and negative values). After struggling on this for a while, I decided to move on and return to it later. Once I implemented the other elements of the level generator, I ended up not needing a layout generator.
The non-functional Area function, based on a solution suggested on StackExchange
Procedural Terrain Generation
Most of the explanations provided here come from Sebastian Lague's video series on the subject. What's more, he incorrectly spelled persistence as persistance. If the wrong spelling shows up in my code, that is why and I have missed it during refactoring.
How does it work ?
Unlike regular noise, which provides independently generated random values, Perlin noise uses a pseudo-random appearance to generate coherent noise which looks like a gradient. However, Perlin noise alone creates curves that are way too smooth to emulate realistic terrain. To remedy this, we layer multiple levels of noise on top of one another, let's call them octaves. As Perlin noise is a function, it has a frequency and an amplitude. To scale each octave, we introduce a variable called lacunarity, which controls the increase in frequency of subsequent octaves, being more and more important the more octaves are used. However, the intensity of each octave should be inversely proportional to its frequency. That's why we introduce a second variable, persistence, which controls the decrease in amplitude of the octaves. Lacunarity is always greater than 1, whilst persistence is always between 0 and 1.
With that in mind and assuming that indexing starts at 0, for the nth octave, we have
- frequency = lacunarityn
- amplitude = persistencen
The greater n is, the larger the frequency and the smaller the amplitude.
Visual summary of the theory behind Procedural Terrain Generation (by Sebastian Lague)
With these two parameters, we can implement a 3D mesh generator, as Unity has a built-in function to evaluate the Perlin noise value at any given point.
Data structures
In order to make this reusable, several data structures are used to hold key settings.
First, Noise Settings are used to define key values for generating the noise map. Persistence and lacunarity are included, alongside the following parameters :
- scale (float) : Used to adjust the overall height of the terrain
- octaves (integer) : Determines the level of detail in the noise map
- seed (integer) : Used in conjunction with a pseudo random number generator in order to be able to recreate a terrain
- offset (Vector2) : Used to move within a given terrain smoothly
Noise Settings class
Second, Height Map Settings are used to turn the noise map into a height map. It includes the aforementioned noise settings, as well as :
- useFalloff (boolean) : If set to true, a falloff map will be laid on top of the noise map, which will create an island-like terrain
- heightMultiplier (float) : Determines the influence of the height curve
- heightCurve (Animation Curve) : Used to alter the scale of each height value
Height Map Settings class
Third, Mesh Settings are used to determine elements more specific to the terrain mesh, including :
- meshScale (float) : Used to enlarge or shrink a mesh
- useFlatShading (boolean) : If set to true, flat shading will be used
- chunkSizeIndex (integer) : Determines the size* of the chunks
- fSChunkSizeIndex (integer) : Determines the size* of the chunks when flat shading is used
* due to technicalities in how the mesh is constructed, the size of a chunk can only be set to certain values
Mesh Settings class
Finally Texture Data is used to store information regarding the texture applied to the terrain. It only contains an array of data structures used to store information about each Layer of terrain. A Layer is composed of the following :
- texture (Texture2D) : The layer's texture
- tint (Color) : Used to give a specific tint to the texture
- tintStrength (float) : Determines the intensity of the tint
- startHeight (float) : The layer will apply to all points that are at this height or higher
- blendStrength (float) : Used to merge layers together, for a smoother look
- textureScale (float) : Scales the texture
Layer class
Note : I used to have multiple textures, based on shaders covered by Sebastian Lague in his terrain generation series. The first only used colours, the second only used textures, and the third was using tinted textures. As he coded his using SRP, some techniques were not available to me, as I was using URP. Therefore, I tried to recreate those in ShaderGraph to the best of my ability. Later, I went back and reused the shader he designed for the planet generator, as it was also using tinted textures. I kept the old code in as an indicator of the change, but commented it out.
Texture Data class
Results
Whilst the original terrain generator allows for infinite world generation, this feature is not used in the level generator, as a single chunk is more than enough for a single level. In order to create randomized terrain, a random seed and offset are picked. The falloff map was used in order to define the limits of the stage (coloured in tan in the renders above). Various shades of white are used to mimic the appearance of snow, although I'm don't know whether the snow shader used in the main game could be used here as well. Below are the values for each set of settings.
Height Map settings
Mesh settings
Texture settings
Procedural Prop Placement
Poisson Disc Sampling
Poisson Disc Sampling is an algorithm used to randomly place objects within a given area where all objects at more than a given distance apart from each other. The number of points placed is random, but depends on the minimal distance. The lower the radius, the denser the point population. The code below mostly comes from Sebastian Lague, with some refactoring from me in order to conform to my coding practices.
C# implementation of Poisson Disc Sampling (by Sebastian Lague)
In order to reuse parameters, I bundled them all as Sampling Settings. These include :
- radius (float) : The minimal distance between any two points
- regionCentre (Vector2) : The centre of the sampling region
- regionSize (Vector2) : The size of the sampling region on the X and Z axis
- rejectionSamples (integer) : How many times the algorithm will attempt to place a point before moving on
Sampling settings also include a method to sample points and return a list of all those points.
Sampling Settings class
Wave Function Collapse
Wave Function Collapse is an algorithm used to solve a grid where each possible value a tile can take is set by an ensemble of constraints. For instance, in sudoku, each cell of the grid can take a digit between 1 and 9, with the constraint being "Any given digit can only appear once in any given row, column, and box". This algorithm is named as such because, when you collapse the value of a cell (for instance, you write a 9 in a cell of a sudoku grid), the constraints will be applied to nearby cells and alter the number of possible values they can take (e.g. all cells in the same row, column or box as the collapsed cell cannot be a 9). And altering the possible values of a cell might affect other nearby cells, creating a ripple effect across the entire grid like a wave.
I did not find a Unity implementation, so I made my own. For starters, I created a generic Data Grid type based on a video by Code Monkey to hold any type of data in a 2D array, whilst also supporting a debug display and mouse interactivity.
Data Grid class
Then, in order to emulate the placement of props, I decided to create an object to describe a Constrained Tile, using solely adjacency constraints. The default assertion is that a tile can always be adjacent to itself. For the purpose of the generator, I created Air, Tree, and Rock tiles.
Constrained Tile class
Finally, in order to properly implement Wave Function Collapse, I combined the previous two classes and created a Collapsing Grid, which also holds a 2D array of possible values for each cell along a list of each possible value any cell can take. It also contains the method SetAndCollapse(int, int, ConstrainedTile), which collapses a cell, then applies the Wave Function Collapse algorithm to adjacent cells, repeated until no affected cell is left. Finally, there is also the Generate() method, which collapses random cells until the entire grid is set.
Collapsing Grid class
Procedural Prop Placement
By combining the two previous methods (which I dubbed Poisson Collapse), it is possible to randomly place props across a given area. The Poisson Collapse algorithm works as follows :
- Points are placed using Poisson Disc Sampling
- A random value is given to each point using Wave Function Collapse, converting the point's position (Vector3) into grid coordinates (pair of integers)
- If the given value is that of an air tile, nothing else happens. Otherwise, a prop of the corresponding type (tree or rock) is generated.
Poisson Collapse class
Results
The Poisson Collapse algorithm works as intended, placing random points in a given area and associating them with a type of prop. Air tiles were implemented in order to give a bit more breathing room and make it feel more like an actual forest with more open areas instead of being constantly surrounded by rocks and trees. Below are the sampling settings and tile types used.
Prop Placement sampling settings
Air tile (skipped tile)
Rock tile
Tree tile
Prop Generation
When I tried to search for props to use in Norsevar's file, I couldn't find the trees and rocks that were used. That's why I decided to make generators for both of them. To create those, I repurposed tutorials on the Internet in order to create what I wanted to build. The tree generator was built from a modified hex grid generator, and the rock generator is in reality a planet generator with specific settings to make it look like rocks.
Tree Generator
Implementation
When I started working on this generator, I determined that, with the time I had, my best course of action was to create low-poly trees by stacking cones on top of one another. Creating a cone was a complex task in itself, and I struggled for some time. My original idea was to first build a regular polygon as the base and use the generated vertices to link them together and with the top vertex to create the faces. I was able to create that first function as BuildCirclePoints(Vector2, float, int), which returned a list of all points on the base. After that, I got stuck, unsure of what to make of those points.
Code of BuildCirclePoints
After a while, I watched a video by Game Dev Guide about hexagonal grids and how to create one in Unity. Looking at the code from the tutorial, I realised that I could easily alter it to make it work with any number of sides, as well as turn it into a regular pyramid. I created a class to hold all the essential values to build a Regular Pyramid, and altered the original code to make use of that class.
Regular Pyramid class
The original code made use of a data structure to store mesh data for each Face, so I implemented that as well.
Face structure
And with all those changes implemented, the hexagon cell generator was turned into a "low-poly cone" generator.
Cone generation
Now that I had a working cone generator, the next step was to use those to create a tree. My plan was to have a large tall cone as the trunk and stack smaller, thinner cones on top to serve as foliage. To be able to generate different-looking trees, I created a class called Tree Settings to hold the extrema for possible values each parameter a Regular Pyramid can take, alongside methods to get a random value.
Tree Settings class
Then, I created a class to serve as a Tree Generator. It holds settings for both the trunk and foliage, as well as other extrema for the height of the lowest piece of foliage, the interval between two pieces of foliage, as well as the number of pieces of foliage on one tree. Finally, it also holds a value called shrink factor, used to make each piece of foliage slightly smaller than the one below it.
When I first wrote GenerateTree(), I placed the trunk first, followed by the foliage. However, this led to some trees having the trunk popping above the foliage. To fix this, the foliage is now placed first, the trunk can then be placed and its size can be adjusted if needed.
Tree Generator class
Results
The generator can create randomized trees. When adding it to the level generator, I had to tweak certain parameters, as the trees were too big.
Tree Generator settings
Rock Generator
Implementation
The rock generator works similarly to the terrain generator, making use of layered Perlin noise to create irregular surfaces. Here, it is applied to a sphere in order to create a planet. I used a tutorial series by Sebastian Lague, but the implementation of Perlin noise that was used is the one from the libnoise-dotnet library.
Procedurally generating a planet relies on two sets of settings. The first is a set of Shape Settings, holding the radius and a list of noise layers. Each Noise Layer holds a boolean to choose whether to use the first layer as a mask, as well as some noise settings.
Shape Settings class
Noise Settings allow to choose between a Simple or Rigid Noise Filter, alongside the following settings :
- strength (float) : Intensity of the noise
- numLayers (integer) : How many layers of Perlin noise are overlaid
- baseRoughness and roughness (float) : Lacunarity of the noise
- persistence (float) : Persistence of the noise
- centre (Vector3) : Point at which the Perlin noise is evaluated
- minValue (float) : Minimal threshold for the noise to be registered, can provide some flat areas
- weightMultiplier (float) : Intensity of the ridges (Rigid Noise Filter only)
Noise Settings class
Both Simple Noise Filter and Rigid Noise Filter inherit from the Noise Filter interface, which only features the Evaluate(Vector3) function. The Simple Noise Filter simply evaluates the noise value at a given point, like how the terrain generator handled it. The Rigid Noise Filter, on the other hand, tries to create more extreme ridges with steeper elevation. In order to make it simpler to create, a Noise Filter Factory was created to create the apt noise filter depending on the Noise Settings.
Noise Filter interface
Simple Noise Filter class
Rigid Noise Filter class
Noise Filter Factory class
The second of settings used are Colour Settings. They hold a material (using a specific shader made in Shader Graph), a set of Biome Colour Settings, as well as a Gradient used to paint the oceans. Biome Colour Settings hold a list of biomes, a set of Noise Settings, values for noise offset and strength, as well as the blend amount. A Biome is composed of a gradient, a tint, a starting height, and a value to set the strength of the tint.
Colour Settings class
Rock Shader shader graph
With those settings, it is possible to turn a unit cube into a unit sphere, then turn that sphere into a more complicated planet. In order to construct the unit sphere, each cube face is turned into a Rock Face by calculating the coordinates of each vertex relative to the centre, then normalizing it to turn the cube into a sphere.
Rock Face class
In order to calculate the elevation of a given point on the unit sphere, a separate class is used as a Shape Generator. It can calculate the elevation, both scaled and unscaled. Scaled elevation is used to set the position of the vertices, whereas unscaled elevation is used to set the UVs of the mesh. Then, once the meshes are constructed, Colour Settings are used by a Colour Generator to update the rock's texture.
Shape Generator class
Colour Generator class
With all those components, all that's needed is to attach a final script to a Game Object in order to turn it into a Rock. The initial code made it possible to generate the rock in the Editor, but I added the option to generate it through a method call, and even to randomize the rock's appearance.
Rock class
Finally, I created one more class to serve as a Rock Generator, which randomizes the radius of each generated rock.
Rock Generator class
Results
Finding the ideal settings was achieved through trial and error. The radius and centre of noise settings weren't taken into consideration, as the scripts already randomize those two. Similarly to what happened with the tree generator, the maximum radius had to be tuned down as it was too big for the generated terrain otherwise.
Rock shape settings
Rock colour settings
Procedural Level Generation
With all the parts ready, all that's left is to combine them in order to create the level generator. First, the Prop Placer needs to be adjusted to fit the Terrain Generator.
3D Prop Placement
In order to fit the generated terrain, different settings are used, with similar results. In Poisson Collapse, only the Skipped Tile, Sampling Settings and Wave Function Collapse possible values were altered, using the objects below.
Forest Generation sampling settings
Forest Air tile (skipped tile)
Forest Rock tile
Forest Tree tile
To adjust the Prop Placer to a 3D space, the height of the props needs to be adjusted and remove props on terrain that's too low, as they would be out of bounds.
Prop Placer 3D class
Now, the final step was to attach the 3D Prop Placer to the Procedural Terrain Generation to create the Forest Generator. In order to randomize the terrain, a random seed and offset are picked on generation.
Forest Generator class
Results
The level generator is now functional. Had I more time, I would've liked to work on the following features :
- Adding the player character, to see how the core gameplay would work with the generated levels
- Implementing the snow shader from the base game
- Add invisible walls to limit where the player can go
- Add randomized enemy spawners, possibly with some way of scaling difficulty
- Attaching the level generator to the run handler from the base game
- Using the props used in the base game instead of the generated ones
Summer 2022 Repeat Journal RSS
Planned work
This week, I have to work on
- Research procedural terrain generation (est. 4h)
- Implement procedural terrain generation (est. 4h)
- Research and implement a blizzard effect when a fight starts (est. 4h)
- Research Poisson Disc Sampling (est. 2h)
- Research Poisson Disc Sampling (est. 2h)
Update 1 : 27/06
I watched Sebastian Lague's introduction video to Procedural Terrain Generation (link) and updated the portfolio to talk about the failed attempt at layout generation and explain how terrain generation works (4h).
Update 2 : 28/06
I started implementing Sebastian Lague's Procedural Terrain Generation (4h). Somehow, I hadn't taken into account the duration of the video tutorials whilst estimating the time each task would take, meaning this would have to span over multiple days. Other than that, I haven't encountered any issues.
Update 3 : 29/06
I continued the Procedural Terrain Generation implementation (6h). This took longer than expected as the tutorials were using the Standard Render Pipeline, meaning the shader code using Cg didn't work with the Universal Render Pipeline. To remedy this, I had to spend time recreating the shaders in ShaderGraph. Fortunately, I found a thread on the Unity Forum started by someone who followed this exact tutorial and encountered the same issue. I also added triplanar mapping on a separate shader. Later during refactoring, the generation stopped working for me. I lost a lot of time trying to fix it before realizing one of the values was set to zero. Once that was set to a positive value, it worked fine again.
Update 4 : 30/06
I finished implementing Procedural Terrain Generation, cleaned up the code, re-enabled the possibility to use a falloff map and added a shader which uses multiple textures and tints to paint the terrain based on the height of the various layers (6h). I didn't encounter any major issues.
Update 5 : 01/07
I researched and implemented Poisson Disc Sampling based on a tutorial by Sebastian Lague (4h). I also implemented a scriptable object to store sampling settings. After doing a bit of research, I couldn't find a way to create a blizzard effect, so I'll put this task off for later.
Planned work
This week, I have to work on
- Research how to tell a story through the decor and find appropriate props (est. 6h)
- Implement said props in the first level (est. 6h)
- Research Hotline Miami-inspired procedural level generation (est. 4h)
- Implement Hotline Miami-inspired procedural level generation (est. 4h)
From here on, I'll use the following format to convey :
- Green represents narrative-related tasks
- Olive represents technical tasks
- Estimated and actual spent time are in bold
Update 1 : 20/06
I watched a pair of videos by Game Maker's Toolkit and Extra Credits on storytelling, which gave me the insight I needed to figure out the theory of telling a story through its decor (4h), relying on environmental storytelling to help build the lore and the characters.
Update 2 : 21/06
I decided to take the points of interest (POI) which were already implemented in the first level by other team members and inject plot relevance in them. In that optic, I took each POI and wrote new dialogues spoken by Erik when interacting with them (4h). I have also contacted Erik's voice actor so that he could record voice lines for those new reactions. This will also help make the game more uniform by having all lines of dialogue spoken instead of having some just being displayed lines of text.
Once I'll have enough time to do so, I also plan to add a Credits button in the menu to actually give credit to the three voice actors of Erik, Heimdall and Loki. While the latter two's dialogues still aren't used in the game, this is something which I intend to remedy if I find the time to do so.
Circling back to the environmental storytelling, once the current POIs are all repurposed, I'll have some family and friends play the level and collect their feedback. Based on that, I'll decided whether to include more POIs or not.
Update 3 : 22/06
I carried on with yesterday's work and replaced the old POI dialogues with the new ones (1h). From here on, this task will have to be put on stand-by until I can get the voice lines recorded and feedback from ensuing play-testing.
That's why I moved on to the next task, procedural level generation. The first step was research, so I studied a paper by J. A. Brown et al. about Procedural Content Generation of Level Layouts for Hotline Miami (3h). Procedural level generation being a dropped feature, I have already read through this paper a handful of times, so technical concepts relating to evolutionary computing are not foreign and I was able to extract the information I needed relatively easily. I have good hopes that I'll be able to wrap up the procedural level generation by tomorrow, although I might need to tweak the generator to fit the following features I intend to add.
Update 4 : 23/06
I continued studying the paper and established a general data structure which I will use to create the level layout generator (4h). It took longer than expected because of fatigue, but I'm still on track with my planning.
Update 5 : 24/06
I started turning the data structure I established into usable code for the generator (4h). Before moving on to the evolutionary computing part, I wanted to make sure the mesh generation worked as intended first. Creating a grid mesh from a room five-tuple was easy enough, even if I had to rely on code by Catlike Coding to help me get it working. Turning a chromosome into many meshes that are cut to remove the overlap, however, proved to be a more difficult task.
Removing the unnecessary vertices is the easy part, you only have to look out for those on the edge of the "mask" which need to stay even if they are shared.
Removing the unnecessary triangles, however, is another can of worms, as it relies in indices, which change the moment you alter the number of vertices. My solution was to rely on "squares" (basically, two adjacent triangles which form a square) to identify which can stay and which should go.
As of today, I have encountered two problems :
- The mesh cutting doesn't work properly for rooms with a placement type of U ;
- The function which is supposed to calculate a room's surface area doesn't return the proper value.
I have no idea so far how to fix this, but I'll come back to it tomorrow and will hopefully find a solution.
Update 6 : 26/06 - Weekend work and reflection
I decided to work on the project this weekend as I don't want to fall back too much on my work planning. I continued working on procedural level layout generation on Saturday (6h) and Sunday (8h). I encountered even more issues as I was working with non-rectangular meshes, which was a struggle to generate. Even once I managed to generate those, the evolutionary computing part decided to put up a fight and refused to cooperate. If I can't manage to get it working by tonight, I'll move on and find another way to create a non-rectangular level layout.
As this week concludes, it's now time to reflect on what I've accomplished.
What I had to do | Research how to tell a story through the decor and find appropriate props |
Implement said props in the first level | Research Hotline Miami-inspired procedural level generation | Implement Hotline Miami-inspired procedural level generation |
Estimated time | 6 hours | 6 hours | 4 hours | 4 hours |
What I did | Learnt about environmental storytelling and the importance of using the decor to tell the player about the lore |
Replaced all written dialogues with newer, more pertinent ones Commissioned Eric's voice actor to record more voice lines |
Reread the paper Took notes and wrote the bases for the code |
Implemented the system as explained in the paper Added a system to display the layout as a mesh |
Time spent | 4 hours | 5 hours | 7 hours | 18 hours |
Why I had to do it | As it stands, the narrative isn't palpable at all within the game, which needs to be fixed | The game only has one level, I want to add a procedural level generator, step-by-step | ||
Problems encountered | None |
I hadn't planned new voice lines, so this task will have to continue in parallel until I can complete it for good
|
Fatigue |
The mesh generator didn't work properly The function to calculate a room's surface didn't work The mesh cutting didn't work properly The evolutionary computing didn't work properly |
Reflection | This was really educative | I should plan a bit further ahead from now on, to avoid being taken aback | I should be a tad less overconfident about my ability to make something work. At least I am certain to have tried everything before moving on |
- Research how to tell a story through the decor and find appropriate props (est. 6h)
As it stands, the narrative isn't conveyed in the game, which needs to be fixed. To solve this, I watched videos and learnt about environmental storytelling and the importance of using the decor to let the player know about the lore. This took me 4h, and I encountered no major roadblocks for this.
- Implement said props in the first level (est. 6h)
As it stands, the narrative isn't conveyed in the game, which needs to be fixed. To remedy this, I have replaced most written dialogues with more pertinent ones, and I also contacted Erik's voice actor to record more voice lines. This took me roughly 5h, and it posed no major problem. However, as I hadn't planned for new voice lines, it added more tasks to do, which means I'll have to continue working on this task in parallel to the rest until it's finished (wait for the new voice lines to be recorded, implement them, get the level play-tested to see if the narrative is conveyed better and react accordingly).
- Research Hotline Miami-inspired procedural level generation (est. 4h)
The game only has one level, so I want to add a procedural level generator, doing so step-by-step. For starters, I reread the paper I found earlier this year about procedural level layout generation and established the bases for the code. This took me 7h, mostly due to fatigue, but the time I saved on the narrative tasks meant it didn't lose me any time for this week.
- Implement Hotline Miami-inspired procedural level generation (est. 4h)
The game only has one level, so I want to add a procedural level generator, doing so step-by-step. To get the level layout generator working, I implemented the system as explained in the paper, as well as another generator to turn the layout into a usable mesh. This took me a staggering 18h, as I encountered issues every single step of the way : The mesh cutter didn't work properly, the area calculating function returned incorrect values, it took me a day and a half to get the more complex mesh generator working, and the evolutionary computing part doesn't seem to work with the mesh generator. This made me feel a tad less overconfident in my ability to make something work, and it took me most of my weekend for nothing, but at least I know I have tried everything before abandoning the feature and moving on.
All in all, I had planned to work 20h this week, but ended up working 34h, mostly due to the implementation of a complex feature for which I couldn't find any pre-made solution on the Internet.
Useful general links
Mahara skin made by my teammate Paul Bichler.
Useful narrative links
Importance of props in storytelling :
Useful technical links
Procedural Terrain Generation by Sebastian Lague
Poisson Disc Sampling by Sebastian Lague
Wave Function Collapse :
- Introduction and Godot implementation by Martin Donald
- Examples of an implementation in Unity by DV Gen
- Grid class by Code Monkey
Hexagon Grid Generation by Game Dev Guide
Procedural Planet Generation by Sebastian Lague