-
Notifications
You must be signed in to change notification settings - Fork 0
4. Features and Core Mechanics
Located in res://AI/economy
. Consists of two scenes (economy.tscn
and desirability.tscn
) and the corresponding scripts used by them. A behavior tree is used as the framework for this AI structure. Because Godot does not offer native support for behavior trees, a third-party library is used to implement the behavior tree through Godot’s AssetLib. The GitHub page for this library can be found here. This tree is technically a form of AI, since a behavior tree is a type of AI structure, but in reality it is more of an advanced calculator than anything else. It does make decisions, for example, about whether a tile has low, neutral, or high taxes, but the artificial intelligence component is minor.
The Desirability Tree focuses on calculating the value of the desirability
member variable of a zoned Tile. This value ranges between 0 and 1 and can be thought of as how desirable a zoned tile is, given the location of the tile and the state of the game. This member variable is then used in updatePopulation.gd
to control the rate of building construction and population migration into the city. Low desirability values have a low probability of having buildings being constructed and people moving in while high desirability values have the opposite effect. Desirability also affects a tile's value, which affects the revenue that tile brings to the city, so it is one of the main drivers of the economy as a whole.
economy.tscn
is the driving scene of the AI. It has the behavior tree as a child node with which it feeds in a queue of zoned tiles to update their desirability values. Thus, the only job this scene object has is to instantiate the AI and to continuously feed the AI queues of zoned tiles by using the Blackboard node.
desirability.tscn
is the behavior tree itself. It checks if the blackboard’s queue is empty before iterating through different sequences that each calculates a value or determines a boolean. The action leaf nodes are grouped together by theme, indicated by the sequence name. All values/booleans that are calculated in each leaf node are stored within the Tile object itself. The following lists the purpose of each leaf node:
This node currently does nothing. In previous versions of the desirability tree, this was the documentation:
Checks if the zoned tile is near an ocean tile. Ocean tiles that are touching the tile of focus in one of the four cardinal directions is considered a ‘close’ water tile. Ocean tiles that are not directly touching the tile of focus but are still within the circular radius of said tile are considered ‘far’ water tiles. A zoned tile can have a ‘close’ water tile, a ‘far’ water tile, or both. This is indicated in
Tile.gd
as two boolean variables with which Presence of Water manipulates.
This functionality has been outsourced to a function in Tile.gd
that is called as-needed in validator.gd
. The node is kept in the tree because, when a removal was attempted, the Behavior Tree structure broke, so it was decided to keep it for ease of use. In future, the node may be used again.
Checks what type of Tile base the zone was built upon. A zoned tile can either be built on dirt, rock, or sand. These three types correspond to three boolean variables within Tile.gd
with which Tile base Type manipulates.
Checks how many of each zone type is near the tile of interest, up to a cap of 3 per zone type. This leaf node checks using a circular radius on the tile of interest and counts how many residential, commercial, public works, and industrial (utilities plant) zones neighbor said tile. The number of each neighboring zone type is stored in Tile.gd
.
Checks how many zones and people inhabit the game map, up to a cap of 30 and 100, respectively. These values are stored in Global.gd
. The influence on desirability is using the number of residential versus the number of commercial zones. If the numbers differ, desirability is negatively impacted, representing that residents require enough commercial space to have jobs, and commercial space requires enough residents to generate income.
Checks how damaged the tile of interest is, which can be caused by events such as flooding. Low tile ‘health’ values correspond to high values for tile damage in the economy AI. This value is stored in Tile.gd
.
A filler leaf node. At the time of writing this documentation, only one functional leaf node was added to the Land Use sequence. The behavior tree library throws an exception if a sequence node has less than 2 child nodes.
This node depends on the values calculated each game tick in Econ.calc_profit_rates
. It checks to see if the profitRate
of each tile is significantly below the average, near the average, or above average. Depending on the result of that check, a strongly/weakly negative or positive number (no neutral 0) is used in the desirability equation, representing the impact of high or low wealth on the desirability of a tile.
Uses real-world-based values to determine whether the tax rate in a given category is high, neutral, or low. The three tax rates are income, property, and sales, and each has its own range of values to denote high, neutral, and low. High taxes impact desirability positively, neutral has no impact, and low taxes impact positively. The constants denoting the strength of the impact can be found in Tile.gd
.
The final leaf node, Validator, uses these stored Tile.gd
variables to calculate a final Tile desirability value based on an equation. The coefficients of this equation are stored in Tile.gd
. Because desirability can only be between 0 and 100, a check is done to determine if the calculated value is within bounds. Because of edge cases, the lower limit is 0.01 while the upper limit is 0.99. The full list of terms in the desirability equation is as follows:
- water: a function of a given tile's distance to water (null if not within 6 tiles), with closer tiles being more desirable.
-
neighbors: the sum of the number of neighboring zones of each type, as calculated in
zone_connections.gd
, multiplied by the specific weights for each type of zone. - zone_balance: the absolute difference between the number of residential and commercial zones.
- population: The number of people in the city, capped at 100, multiplied by a weight.
-
growth: the growth values are updated monthly in
updateDate.gd
, using the standard population growth formula. High growth is positive impact. -
tax_burden: the result of combining all three categories of taxes that are addressed in
taxation_rate.gd
. -
wealth_influence: the impact of the wealth calculated in
city_wealth.gd
. - tile_dmg_weight: a neutral or negative direct modifier to desirability. If no damage, the modifier is 0. Otherwise, the modifier becomes steeply more negative as tile damage increases.
The use of an equation to determine Tile desirability originated as a result of a bug early in the AI’s development. Originally each leaf node would increment/decrement the desirability value individually. Validator.gd
, hence the name, originally only checked if this new value was within bounds. However, because the AI has no knowledge of previously incrementing/decrementing desirability based on the last game state, desirability would quickly reach the cap. Thus, to solve this bug, an equation is used in order to mark the checking of state using variables.
At the time of writing this document, the constant values and calculated desirability values are arbitrary. Further research is needed in order to produce desirability values that match real-world land values.
Happiness is an abstract value designed to gauge how happy the population of a given tile is during the game. Happiness calculations can be found in the singleton class UpdateHappiness.gd
. Happiness depends on several factors currently, and may be expanded to more in the future. As it stands, Happiness is a value that ranges from 0 to 100, inclusive, and is clamped to these bounds should it exceed 100 or go below 0 for a given tile. Happiness depends on these four things:
- The number of water tiles surrounding a given tile (happiness currently goes up by 5 for each adjacent water tile, this can be adjusted using the
WATER_TILE_WEIGHT
constant inUpdateValue.gd
. - The zones and buildings on the tiles surrounding a given tile. Some zones will add to happiness, while others detract from it.
- The current damage state of a given tile. The more severely damaged a given tile is, the lower the happiness of its population.
- The tax rate. This number is the base tax rate for the game, multiplied by a weight that will give the taxation rate more or less importance in the happiness calculations depending on its value.
Happiness impacts a wide range of systems within SimCoast. So far, its most relevant application is its use in the calculation of move-in/out chances for the population of a given tile.
Demand is a value that represents the 'desire' for a given type of zoning. In SimCoast, we have two types of demand: Residential and Commercial. Demand is displayed on the top of the screen along with the tax rate, population, and total wealth of the city.
Residential Demand represents the need for more housing in our city. If more citizens are leaving than moving into the city, the residential demand will be zero, since there is no need to build more housing.
Otherwise, demand scales with the total population. As the current population approaches the maximum population, demand increases. Demand also scales with the happiness of the population. Since happiness starts out fairly low, demand will stay low as a result unless happiness is deliberately increased. This can be achieved by, for example, placing down several parks.
Commercial demand represents the need for more commercial space in our city. Specifically, for every 48 total population (3 full residential zones), there will be one additional commercial zone demanded. It currently doesn't matter whether that zone is LIGHT_COMMERCIAL or HEAVY_COMMERCIAL, but this may be revised in the future. Because of this, commercial demand will be 0 until there are at least 48 people in the city.
Building damage is tracked as a percentile value within each Tile object, from 0% to 100%. This percentage variable should not be interacted with directly, but is displayed to the user in the HUD. There is also a building status that is tracked within the data array of each tile, in the fourth index. This value can be Tile.TileStatus.NONE
, Tile.TileStatus.LIGHT_DAMAGE
, Tile.TileStatus.MEDIUM_DAMAGE
, or Tile.TileStatus.HEAVY_DAMAGE
. Tiles that do not have buildings do not have any behavior based on damage. Each of these statuses has an associated color to give the user a visual identification of the state of their city.
Damage can be added to a tile using the Tile.set_damage()
function. This function accepts one argument, which should be a TileStatus
--one of the four enumerated earlier. Decide how much damage (light, medium, or heavy) a particular event has done to a tile, and set the damage accordingly. The function will take that status and use it to increase the tile damage percentage accordingly, and update the TileStatus
accordingly. For example, if two set_damage(Tile.TileStatus.LIGHT_DAMAGE)
calls occur on the same tile, the damage for that tile will go up to Tile.TileStatus.MEDIUM_DAMAGE
.
If a tile's percentile damage goes over 100%, the tile will be cleared of all buildings and people, representing a total annihilation of that area of the city.
Currently, the only way to damage a tile is by flooding it. If the tile floods, it will take damage depending on the water height of that flood. High water levels will do more damage, and lower water levels will do less damage.
There is a weather system currently in development that will damage tiles directly through natural disasters, e.g. hurricanes, but this is not yet in the main game branch.
The only way to repair a tile is by using the Repair Tool
on the left side of the screen. This will instantly repair the tiles for no cost. However, this is a development-only feature, and will be replaced with a mechanic that costs resources in the production game.
The primary effect of tile damage is that it causes population to move out of a tile. New people will also not move into a damaged tile, so the only way to increase population if all tiles are damaged is to repair them as discussed above.
Tile damage also reduces a tile's value and a tile's happiness metric, which in turn impacts demand. Having a lot of damaged tiles will be--and is meant to be--detrimental to the player, incentivizing them to repair those tiles and to protect them from damage at the outset.
Our implementation of an event system is the observer system. Observers are part of the code that does some action when a certain notification is thrown.
A class for keeping track of statistics relative to the observers. Currently stores: Number of powered and unpowered residential and commercial Areas, number of power plants, number of parks, money, profit and total population. It is updated at the beginning of notify in announcer, so it is up to date for all observers.
Observer.gd is a virtual class that has only one function “onNotify” which is called whenever we are notified when an event occurs. Should check the event type to see if it’s what we’re looking for. More on this in Event.gd and SfxObserver.gd.
Announcer.gd stores a list of all the observers. This list can be modified using addObserver or removeObserver. This class is used to notify the observers that an event has occurred. For example, if we want to tell the observers that a building has been built, you navigate to the part in the code where the building is built, add a line like Announcer.notify([event that building is built]) and then announcer goes through its list of observers and call their onNotify() functions. Announcer.gd calls Stats.gd to keep track of the statistics and then it’s stored observers access Stats.gd directly using getStat().
We notify that an event has occurred using Announcer.notify(event) and event.gd is the event that is passed as an argument. It contains an eventName, eventDescription, eventValue. The way I’ve used this is to make the if statements in observers flow more naturally. So, for building a residential area, the name is “Added Tile”, the description is “Added Residential Area” and the value is the number of buildings built.
SFXObserver is a great example of an observer that does something every time an action occurs. Whenever it is notified, it checks if it’s the right event and then plays its sound effect if so.
Some observers care about checking milestones, rather than each time an event occurs so I made Goal.gd to clean up how those execute. Goal.gd represents some goal that the user will try to achieve. The stats that announcer holds is contained in a dictionary that is passed to each observer. Goal contains:
- varToCheck: The string name of the variable it’s watching.
- greaterThan: A Boolean of whether we want greater than or less than (true means greater)
- constGoal: The goal integer we check against
- achievementName, achievementDescription
- achievementType: This is for the type of achievement popup. The goal class has a function isComplete() that takes in the stats, and checks if the variable it keeps track of has exceeded or is below the expected amount.
A simple goal-based observer. Has a function createAchievements() that creates some example achievements (these achievements are instances of goals). On notify, we check if each achievement isComplete() and if it is we use the unlock() function to handle any behavior of an achievement being complete. Like giving an award or displaying a popup.
MissionObserver.gd is a slightly more complex AchievementObserver. Rather than unlocking achievements in any order, you must complete a set of missions to move onto the next set. Achievements stored their goals in a list, whereas MissionObserver stores its goals in a list of lists. Each list represents one group of goals. Once a single goal is complete, it is removed from its list and once all the goals are removed in a set, we removed that list from the list, so that the first list in the 2d array is our current missions. I could see this either being contained in another class and storing completed missions later down the line. Acts like AchievementObserver besides that, iterates over the first list, check isComplete() and unlock() if so. We do need a check after this to see if we are done with our current set of missions.
No matter what your observer does, first make sure you extend the virtual class at the beginning of your code. Just add ‘extends "res://scripts/Observers/Observer.gd"’ to the very top of your code. Once you have done that, there are one of two observer types that you could implement: event-based observers that trigger every time an event occurs, like SfxObserver, or goal-based observers that check if you’ve completed a goal, like AchievementObserver.
Event-based observers:
- In your onNotify(), add checks to see if event is the kind of event that you want to check for.
- Add your code inside the if statement.
- Add an Announcer.notify(event) to the part in the code that handles this event if it does not already exist.
Goal-based observers:
- Add whatever stats you need to keep track of in announcer.gd.
- Make sure to add it to updateStats() as well
- Create a way of storing your goals and a function to initialize your goals.
- In your onNotify, check over each goal to see if it’s complete.
- If it is, unlock that goal, do any actions once that goal is complete and then remove it from the watched goals.
- If you care to keep track of the goal after the fact, you should store the goal here too. Once your observer is complete, add it to the list of observers in start_map.gd. There is a function there called initObservers() that handles adding the observers to the announcer and creating any necessary goals.