Subsystem: Units - Adam-Poppenheimer/Civ-Clone GitHub Wiki

The Units subsystem handles a class of object that is controlled by Civilizations, produced by Cities, and can move about the game map, possibly making changes to that map and possibly fighting other units and capturing cities. Because of the complexity of units themselves, the subsystem that manages them is also very complex, certainly one of the largest in the codebase. It's also undergone a tremendous amount of refactoring over development, and its current architecture is not ideal.

Design Requirements

Units are entities, owned by Civilizations, that are capable of moving around the map. Units are constructed at cities from various different types (archers, workers, horsemen, etc). Units can be created and destroyed, and it's expected that this will happen many times over the course of a game.

Units move about the map by spending movement points (often just called Movement in the project). Every unit has a Current Movement that represents how much farther it can go on this turn, and a Maximum Movement, which its Current Movement refreshes to at the beginning of every round. By default, a unit can spend 1 movement to move into an adjacent hex, but there are a lot of factors that modify this. Forests, jungles, and hills hinder movement, consuming more movement points when entered. Rivers consume all of a unit's movement when they're crossed. Roads reduce the movement cost for units and overwrite terrain penalties, but only if the roads aren't in hostile territory. There are also aquatic and non-aquatic units, which can only operate in water cells or on land cells, respectively.

A unit can be ordered to move to a hex it doesn't have enough movement points to reach. When this happens, the unit sets up a path and goes as far down it as it can manage. At the end of each round, that unit will continue to spend movement points and advance across the map until it reaches its destination, is given new orders, the destination becomes unreachable, or it is destroyed.

Most units are capable of attacking other units, assuming their respective owners are at war. Every unit has hitpoints, both a current and maximum value, which are reduced in combat. Units have combat strength and ranged attack strength, and also an attack range. If a unit's hitpoints are reduced to zero, it is destroyed.

There are two types of combat: melee combat and ranged combat.

Melee combat requires an appropriate melee unit to move onto a cell occupied by a valid combatant. They then use their Combat Strength, modified by numerous factors, to determine an attacking strength and defending strength, and both units will end up taking damage. The comparison of those values determines how much damage each unit receives. If a unit has more strength than its opponent, it'll take less damage. If it has less strength, it'll take more damage. How much damage is inflicted is standardized across all units, with a certain amount of damage dealt to all units whose combat strengths are identical, and then the same damage progression for favorable and unfavorable contests. If a unit attacking into melee destroys the defending unit, the attacking unit moves into the cell the defending unit occupied. Regardless of the result, the attacker loses all its movement points.

Ranged combat requires a unit with a positive ranged attack strength to be within attack range of a valid combatant. Whether a unit is in range is based on the Attack Range of the attacker (measured in hexes). Ranged attacks can also be blocked by intervening terrain: hills, mountains, forests, and jungles will block line-of-attack, preventing a ranged unit from choosing targets behind these features. Climbing hills can negate some of these factors.

Ranged combat compares the Ranged Attack Strength of the attacker to the Combat Strength of the defender. A variety of factors modify these base values, which then determine how much damage the ranged attacker does to the defender: more damage if the comparison is favorable, less if it isn't. Regardless of the result, the ranged unit takes no damage. If a ranged unit manages to kill its target, it remains in place. Regardless of the outcome, the attacker loses all its movement points.

Units can also attack cities, which are defended by an immobile unit with special properties. Its Combat Strength and Ranged Attack Strength are informed not by a unit template but by that city's population and certain buildings. City "defenders" can make ranged attacks but cannot move or make melee attacks. A city's defenders cannot be destroyed in normal combat. If a ranged attack would bring them below 1 health, instead they remain at 1 health. If a melee attack would do so, the attacker instead conquers the city (or sacks it if it's a barbarian), transferring control of that city to the attacking civilization.

All units also have certain abilities, actions they can take (most of which consume movement points) that have some effect other than moving and attacking. Using an ability can also consume the unit, removing them from the map. Units can fortify, gaining a defensive bonus as long as they don't move. Units with nonzero Combat Strength can pillage improvements beneath them, rendering them unusable until repaired. Workers are capable of building roads and improvements on valid cells, and of clearing vegetation from them (both of which cost their full movement over multiple turns). Settlers can consume themselves to found new cities. Some ranged units need to set up to bombard before they can attack, which is an ability, as well. An ability might do multiple things, like build an improvement and expand one's borders.

There are also a number of specialized abilities used mostly by Great People: The ability to annex nearby territory, grant free techs, hurry production in a city, repair adjacent ships, and start golden ages are available actions, as well.

Certain units possess passive abilities that can provide bonuses, referred to as Auras, to nearby units. These abilities don't require activation and don't consume movement.

Damaged units can be healed in a number of ways. By far the most common is to remain in place, without moving or attacking, for an entire turn. When this happens, the unit will heal some amount of health based on where they are. Units in enemy or neutral territory heal 10 HP per turn, units in friendly territory 20, and units within a friendly city heal 30. The exception is cities, which heal every turn at a particular rate.

When a non-city unit engages in combat, it gets some amount of experience. When a unit gains enough experience, it levels up and can choose a new Promotion. A Promotion is a passive bonus applied to that unit until it is destroyed. Promotions are arranged in Promotion Trees, with certain Promotions having prerequisite Promotions that must be taken first. Units will often have multiple promotions from which they can choose, only one of which can be chosen at a time. Promotions don't exclude each-other, though: unselected options can be taken the next time a unit levels.

Units extend the visibility of the civilization who owns them. Every unit has a certain visual range that allows them to see that many hexes away from their current location in all directions. Forests, Mountains, Hills, and Jungles block this visual range, allowing units to see into these cells but not beyond them. Climbing hills allows units to see over flat Forests, flat Jungles, and other Hills, but not Mountains or other Hills with Forests or Jungles.

There are also Great People: special units that provide unique capabilities to a civilization. Great People can't be built in cities, but are instead automatically created when a civilization has accumulated enough of a certain type of Great Person yield (each Great Person has its own yield). When this happens, the stockpile is cleared and a Great Person of the appropriate type is created. The exceptions to this are the Great General and Great Admiral, for which stockpile is generated when units gain experience (land for the general, naval for the admiral). Great People have powerful single-use abilities that allow civs to do things difficult or impossible to do any other way. Some also have passive benefits they bestow to nearby units.

Units can be upgraded into more advanced versions of themselves by spending gold. Every unit has a certain Upgrade Line, describing which units can be upgraded into it and which units it can upgrade into.

Implementation

The Units subsystem consists of 4 different parts. There's a core that handles fundamental unit behaviors and a handful of miscellaneous edge cases. There's a part devoted entirely to combat, which turns out to be a very complex system. There's a part that handles the definition and execution of abilities. And there's one that handles unit experience and promotions. These pieces are often connected to each-other fairly tightly together and shouldn't be treated as decoupled components. Rather, they're subdivisions of the same larger system, organized this way for reasons of organization and comprehensibility rather than technical concerns.

The Core

Units are defined through the IUnit interface and implemented through its standard implementation GameUnit. Unit could not be chosen because it overlaps with UniRx in a handful of inconvenient ways. GameUnit is implemented as a MonoBehaviour and constructed via the Factory pattern through IUnitFactory/UnitFactory, which requires a template, an owner, and a location. A custom IPromotionTree can also be passed. Unit placement is handled via IUnitPositionCanon/UnitPositionCanon. IUnits are constructed around IUnitTemplates, which define many of their important properties. IUnitTemplates are created at design time and are implemented as ScriptableObjects with read-only properties (enabled through the use of the [SerializeField] attribute around a private field). The numerous dependencies, Responder, and Signal classes are instantiated and injected into the codebase via UnitInstaller.

IUnitTemplate's standard implementation, UnitTemplate, makes use of the same "ScriptableObject with read-only properties backed by serialized private fields" pattern as the rest of the templates do.

IUnit contains properties for current and max movement, current and max HP, unit type, available abilities, attack range, combat strength, ranged attack strength, experience, level, promotions, and vision range. It reveals the path the IUnit's currently on, whether it can attack (melee or ranged), and what state it's occupying. It reveals methods for changing its state and performing movement (moving along its current path) as well as a relocation operation that moves a unit to a new IHexCell instantly and with fewer checks. It also reveals a method by which it can destroy itself.

IUnit also contains a pair of interfaces suffixed with the word "Summary": IUnitMovementSummary and IUnitCombatSummary. During development, it proved somewhat difficult to collect all possible improvements/modifications from a unit's promotions in a way that allowed external systems to remain ignorant the existence of promotions themselves. The chosen solution was to wrap all the various modifications an IPromotion could effect on an IUnit in a pair of read-only data structures that other classes and subsystems can consume. The concrete implementations of these summaries are then passed by GameUnit into IPromotionParser, which sets the fields on UnitMovement/CombatSummary as necessary.

There are some problems with this construction. For starters, it couples GameUnit along with IPromotionParser and all of its implementations/helper classes to the concrete UnitMovementSummary and UnitCombatSummary, an architectural messiness that'd be more severe if the summaries weren't PODs. It also adds more responsibilities onto the already-sizable GameUnit class, which might represent the beginnings of a God Object. It also forces GameUnit to contend with promotions itself, coupling it to IPromotionParser in a way that might be inconsistent with design decisions made elsewhere in the codebase. It might've been better to create some dedicated IUnitSummaryLogic/UnitSummaryLogic class for consumers of the data therein. It's worth noting, however, that GameUnit needs the information presented in its IUnitMovementSummary, so it'd remain coupled to IUnitSummaryLogic, as well.

More generally, GameUnit is a fairly complex class overall. It makes use of a number of signals, both firing new events on them and listening to events from them. Being a MonoBehaviour, it makes use of Unity's built-in event system to fire various pointer and drag events on itself, a solution that wouldn't work if the visual representation of a unit wasn't attached to GameUnit itself.

The biggest and most complex task GameUnit performs, however, pertains to movement. Movement across the grid is expected to take time: Civilization 5 by default has units walk across the terrain in a believable manner. For this reason GameUnit's overloaded PerformMovement() implementations make use of a Unity Coroutine to execute movement across multiple frames. This is implemented mostly in the private PerformMovementCoroutine() method, which must make sure the unit's facing in the right direction, that it's being moved along its path in as smooth an arc as can be managed, that the cell it's occupying is being updated when it reaches new cells, and that it cancels movement gracefully and without error when such movement becomes impossible. Much of the math for this section was either taken directly from Catlike Coding's Hex Map tutorial or else based on their implementation. Because it's stretched across a large number of frames, PerformMovement() implementations are not covered by unit tests, a fact that has (predictably) caused problems during development.

GameUnits handle state (whether they're fortified, attacking, moving, sitting idle, set up to bombard) through clunky use of an Animator as a finite state machine. Properties like IsIdling and IsFortified query the name of the current state the animator is in, a decision that tightly couples GameUnit to the inner workings of the very heavy Animator component. The various states are also represented by a collection of boolean values rather than a single Enum.

GameUnit is definitely a candidate for organizational refactoring. It most likely violates the principle of Single Responsibility, and the fact that its construction makes unit testing infeasible is an issue. The handling of state is in dire need of modification, as well.

There are two classes that manage/contain signals for units: UnitSignals and CompositeUnitSignals. The former is by far the larger and contains numerous signals for unit behavioral change and UI events relative to units. The latter should, in theory, maintain signals which make use of more basic unit signals to inform its own. At the moment the class contains only a single signal, ActiveCivUnitClickedSignal, and should most likely be wrapped up into UnitSignals.

The Units core makes use of an IUnitModifier/UnitModifier interface/class pair to make it easier for IUnits to be affected by things like Social Policies. These are very similar to ICityModifier/CityModifier and are implemented in analogous ways. Currently only a single IUnitModifier exists: ExperienceGain, but it seems likely that the modifiers encapsulated in IUnitMovementSummary and IUnitCombatSummary should be converted into this format. This would allow GameUnit to remain decoupled from promotions and the concrete -Summary classes, and would make it easier to extend these modifiers in the future, since they're being calculated in a single place.

From there, Core is largely filled out with PODs and auxiliary -Logic classes. There's logic to determine whether a unit is garrisoned in a city, how much it should heal, what cells are visible to the unit, whether it's valid for a city to construct a unit of a certain template, and IUnitConfig/UnitConfig which contains a ton of configuration variables set at design time. There's a FreeUnitsResponder that constructs new units when certain buildings are constructed or policies unlocked.

The Units core also handles the creation and management of Great People, a task that seemed too simple to justify its own subsystem. It presents a special IGreatPersonFactory whose standard implementation creates a great person of the chosen type and attempts to place it nearby the owning civilization's capital. Since free Great People are a common reward for wonders and tech discovery, a IFreeGreatPeopleCanon/FreeGreatPeopleCanon interface/class pair exists to keep track of how many free Great People civilizations have left (through public methods and responses to various signals).

Combat

The majority of the Combat section of Units is constructed around ICombatExecuter/CombatExecuter. CombatExecuter sets out the high-level procedure for ranged and melee attacks and then calls into a network of helper classes to figure out the rests. It also fires MeleeCombatWithUnit and RangedCombatWithUnit signals that are consumed by a number of -Responder classes. It implements melee attacks by calling into IUnit.PerformMovement() with a callback that executes combat proper. This is done because a melee unit needs to walk up to another unit in order to fight it, and since movement takes multiple frames, so, too, must melee combat. Ranged combat is performed immediately and neither moves nor rotates a unit, which is most likely a visual bug.

One of the most important Combat helper classes to CombatExecuter is (I)CombatInfoLogic. This class goes through all the many factors that could modify combat modifiers for attackers and defenders: The presence of rivers; terrain, shape, and vegetation defensive bonuses; improvement defensive bonuses; fortification bonuses; nearby unit auras; modifiers from garrisoned cities; and the possible effects of unhappiness. It also adds all the appropriate modifiers from the attacker's and defender's IUnitCombatSummaries.

A number of helper classes are made use of to aid CombatInfoLogic and provide for code more easy to unit test: (I)UnitFortificationLogic, (I)CombatAuraLogic, and (I)CityCombatModifierLogic. The first checks to see if a unit is fortified and how long it has been, giving more defensive bonus if it's been there longer. The second checks all nearby allies to see if they have auras that might affect the given unit. The third increases damage done by ranged attacks from garrisoned cities based on their GarrisonedRangedCombatStrength.

The various ICombatModifiers within an IUnitCombatSummary determine for themselves whether they apply based on the two units in contest, the location of the fight, and the type of combat engaged. There are two implementations: PermanentCombatModifier and ConditionalCombatModifier. The former's DoesModifierApply() method always returns true. The second contains a list of CombatConditions joined together by either AND or OR operations in a somewhat user-unfriendly way.

ICombatInfoLogic isn't just used by CombatExecuter. It's also used by (I)CombatEstimator to predict the results of combat without applying them, and (I)UnitComparativeStrengthEstimator to determine how strong one unit is compared to another. This need for estimation informs the architecture of combat substantially, not just in how CombatInfo is generated, but how combat is executed.

Going back to CombatExecuter: After the appropriate CombatInfo has been obtained, CombatExecuter calls ICommonCombatExecutionLogic.PerformCommonCombatTasks() to handle the consequences of combat, and sends the results of the combat into either UnitSignals.MeleeCombatWithUnit or UnitSignals.RangedCombatWithUnit.

CombatExecutionLogic begins by getting the combat calculations from ICombatCalculator (separated out for the purposes of estimation elsewhere). It then reduces attacker and defender hitpoints by an appropriate amount and calls into a number of PostCombatResponders, defined in UnitInstaller, who check the combat that's just occurred and see if they can apply their special behaviors to it. After all responders have been called, the attacker's CanAttack property is set appropriately.

(I)CombatCalculator figures out what combat would do given a particular attacker, defender, and CombatInfo, reporting a Tuple<int, int> containing the damage done to the attacker and defender, respectively. Defenders with no strength are assigned their current health in damage. Attackers with zero strength lead to no damage at all. Otherwise ratios are formed from the comparative strength, and damage is inflicted based on that and the configured CombatBaseDamage of the program. Ranged attackers never receive damage.

There are four IPostCombatResponders: CityConquestResponder, CitySackResponder, DestructionPostCombatResponder, and MovementPostCombatResponder. They're instantiated in UnitInstaller and their order is considered important.

CityConquestResponder checks to see if a city's defense unit has just been reduced to zero hitpoints by a melee attack. If it has, the city corresponding to that unit is transferred from the defending civ to the attacking civ, and the attacker is moved into the city. CitySackResponder is the barbarian equivalent, which causes some amount of gold to be stolen from the defending civilization's stockpile.

DestructionPostCombatResponder checks to see if either the attacker or the defender have 0 or fewer hitpoints. It destroys military units with no hitpoints, captures civilian units (defined by UnitConfig.CapturableTemplates), and reduces cities to 1 hitpoint without destroying them. If the defender was destroyed in melee combat and the attacker remains, the attacker is then moved onto the defender's location.

MovementPostCombatResponder sets the movement of the attacker after attacking, reducing it to zero if the unit can't move after attacking. If it can, its movement is reduced by the traversal cost of moving from the attacker's location to the defender's (if the attack was melee) or by 1 if the attack was ranged.

There's also a GoldRaidingResponder which handles the situation where the attacker can steal gold by damaging the defender. This occurs only when cities are attacked, and is based on the GoldRaidingPercentage of the attacker's IUnitCombatSummary.

Abilities

Abilities are defined through the (I)AbilityDefinition class. The standard implementation is a ScriptableObject that reveals read-only fields accessible at design time through the [SerializeField] attribute. Abilities contain information about what happens to the unit when it triggers this ability, and a set of AbilityCommandRequests the ability wants executed. Command requests consist of an AbilityCommandType (an Enum of everything an ability might achieve) and a list of strings to be passed as args.

Ability execution is performed by the aptly-named (I)AbilityExecuter class with a pattern similar to Chain of Responsibility, except AbilityExecuter keeps a list of the handlers to isolate them from each-other. Each AbilityCommandRequest is passed down the chain until one of the IAbilityHandlers can handle it, at which point execution is delegated to that handler.

The rest of the Abilities section contains a large number of IAbilityHandler implementations for each different type of action an ability can perform, most of which implement the game logic of Civilization 5 for various tasks. The exceptions are ClearVegetationAbilityHandler and BuildRoadAbilityHandler, both of which perform their tasks instantly rather than over time. The various classes in Abilities are instantiated and injected via a dedicated AbilityInstaller.

Promotions

The Promotions section of Units is based around a handful of data structures. At its base lies IPromotion, whose standard implementation is a ScriptableObject with read-only fields that are created at design time. IPromotion reveals a sizable number of fields defining anything a promotion can do to a unit. The vast majority of these fields will be empty or unattended to by the vast majority of Promotions.

Alongside this are IPromotionTree and IPromotionTreeTemplate. IPromotionTrees belong to particular IUnits and manage which promotions that IUnit has unlocked. CanChoosePromotion() and ChoosePromotion() are the normal ways of selecting promotions during runtime. There are also AppendPromotion() and RemoveAppendedPromotion() which allow the addition and removal of promotions gained independently of the IPromotionTree when the unit is produced. IPromotionTreeTemplate holds a set of IPromotionPrerequisiteData, which both describes the available promotions and the prerequisite promotions necessary to get that promotion. Prerequisites are handled in IPromotionTree rather than IPromotion because not all Trees have the same prerequisite chains to reach a particular Promotion.

There are then a handful of classes to handle certain promotion-related behaviors. (I)StartingExperienceLogic determines how much experience a particular unit from a particular city begins with (determined largely by the buildings). (I)UnitExperienceLogic determines the amount of experience necessary for a unit's next level, and also bestows experience onto units for engaging in melee and ranged combat (a job that should probably be delegated to an IPostCombatResponder implementation). Last among these is the somewhat confusingly-named (I)UnitPromotionLogic, which helps determine the full package of promotions a unit currently has. This includes not only the promotions in its IPromotionTree, but also the StartingPromotions of its IUnitTemplate, along with the global promotions of its owning civilization. It's not clear if this construction is the best way of organizing these special cases, and a refactor should be considered if development continues.

The rest of the section handles promotion parsing to determine how a Unit's Promotions modify combat, movement, vision, and healing. (I)PromotionParser modifies UnitCombatSummaries and UnitMovementSummaries as well as producing HealingInfo through its various messages. It does this by making requests to (I)CombatPromotionParser, (I)MovementPromotionParser, and (I)HealingPromotionParser. All three of these classes are implemented in fairly straightforward ways. In theory IPromotionParser it would also provide VisionInfo, but that method is currently unimplemented and the codebase makes no use of VisionInfo.

⚠️ **GitHub.com Fallback** ⚠️