(Part 8 of the Panzer General Portable Project)
Next on the agenda for Panzer General was the domain modeling task. This is not a trivial undertaking because of the sheer numbers of units, equipment, and terrain that the game supports. Fortunately, resources by Scott Waschlin and Isaac Abraham give a good background and I have done some F# DDD in the past.
I started with a straightforward entity β the nation. PG supports 14 different nations representing two tribes β the allies and the axis. Ignoring the fact that Italy switched sides in 1943, my model looked like this:
typeΒ AxisNationΒ =
|Β Bulgaria
|Β German
|Β Hungary
|Β Italy
|Β Romania
typeΒ AlliedNationΒ =
|Β France
|Β Greece
|Β UnitedStates
|Β Norway
|Β Poland
|Β SovietUnion
|Β GreatBritian
|Β Yougaslovia
|Β OtherAllied
typeΒ NationΒ =
|Β AlliedΒ ofΒ AlliedNation
|Β AxisΒ ofΒ AxisNation
|Β Neutral
Β
So now in the game, whenever I assign units or cities to a side, I have to assign it to a nation. I can’t just say “this unit is an allied unit” and then deal with the consequences (like a null ref) later. F# forces me to assign a nation all of the time β and then guarantees that the nation is assigned later on in the program. This one simple concept eliminates so many potential bugs β and which is why F# is such a powerful language. Also, since I am guaranteed correctness, I don’t need unit tests, which makes my code base much more maintainable.
Β
I also needed a mapping function to interchange NationId (used by the data files of the game) and the Nation type. That was also straightforward:
Β
letΒ getNationΒ nationIdΒ =
Β Β Β Β matchΒ nationIdΒ with
Β Β Β Β |Β 2Β ->Β AlliedΒ OtherAllied
Β Β Β Β |Β 3Β ->Β AxisΒ Bulgaria
Β Β Β Β |Β 7Β ->Β AlliedΒ France
Β Β Β Β |Β 8Β ->Β AxisΒ German
Β Β Β Β |Β 9Β ->Β AlliedΒ Greece
Β Β Β Β |Β 10Β ->Β AlliedΒ UnitedStates
Β Β Β Β |Β 11Β ->Β AxisΒ Hungary
Β Β Β Β |Β 13Β ->Β AxisΒ Italy
Β Β Β Β |Β 15Β ->Β AlliedΒ Norway
Β Β Β Β |Β 16Β ->Β AlliedΒ Poland
Β Β Β Β |Β 18Β ->Β AxisΒ Romania
Β Β Β Β |Β 20Β ->Β AlliedΒ SovietUnion
Β Β Β Β |Β 23Β ->Β AlliedΒ GreatBritian
Β Β Β Β |Β 24Β ->Β AlliedΒ Yougaslovia
Β Β Β Β |Β _Β ->Β Neutral
Β
Β
Moving on from nation, I went to Equipment. This is a bit more complex. There are different types of equipment: Movable equipment, Flyable Equipment, etcβ¦. Instead of doing the typical OO “is-a” exercise, I started with the attributes for all equipment:
Β
typeΒ BaseEquipmentΒ =Β Β {
Β Β Β Β Id:Β int;Β Nation:Β Nation;
Β Β Β Β IconId:Β int;Β
Β Β Β Β Description:Β string;Β Cost:Β int;
Β Β Β Β YearAvailable:int;Β MonthAvailable:Β int;
Β Β Β Β YearRetired:Β int;Β MonthRetired:Β int;
Β Β Β Β MaximumSpottingRange:Β int;
Β Β Β Β GroundDefensePoints:Β int;Β
Β Β Β Β AirDefensePoints:Β int
Β Β Β Β NavalDefensePoints:Β int
Β Β Β Β }
Β
Since all units in PG can be attacked, they all need defense points. Also notice that there is a Nation attribute on the equipment β once again F# prevents null refs by lazy programmers (which is me quite often) β you can’t have equipment without a nation.
Β
Once the base equipment is set, I needed to assign attributes to different types of equipment. For example, tanks have motors so therefore have fuel capacity. It makes no sense to have a fuel attribute to a horse-drawn unit, for example. Therefore, I needed a movable and then motorized movable equipment types
Β
typeΒ MoveableEquipmentΒ =Β {
Β Β Β Β MaximumMovementPoints:Β int;
Β Β Β Β }
Β Β Β Β
typeΒ MotorizedEquipmentΒ =Β {
Β Β Β Β MoveableEquipment:Β MoveableEquipment
Β Β Β Β MaximumFuel:Β int}
Β
Β
Also, there are different types of motorized equipment for land (that might be tracked, wheeled, half-tracked) as well as sea and air equipment:
Β
typeΒ FullTrackEquipmentΒ =Β |Β FullTrackEquipmentΒ ofΒ MotorizedEquipment
typeΒ HalfTrackEquipmentΒ =Β |Β HalfTrackEquipmentΒ ofΒ MotorizedEquipment
typeΒ WheeledEquipmentΒ =Β |Β WheeledEquipmentΒ ofΒ MotorizedEquipment
typeΒ TrackedEquipmentΒ =
|Β FullTrackΒ ofΒ FullTrackEquipment
|Β HalfTrackΒ ofΒ HalfTrackEquipment
typeΒ LandMotorizedEquipmentΒ =
|Β TrackedΒ ofΒ TrackedEquipment
|Β WheeledΒ ofΒ WheeledEquipment
typeΒ SeaMoveableEquipmentΒ =Β {MotorizedEquipment:Β MotorizedEquipment}
typeΒ AirMoveableEquipmentΒ =Β {MoveableEquipment:Β MoveableEquipment}
Β
With the movement out of the way, some equipment can engage in combat (like a tank) and others cannot (like a transport)
Β
typeΒ LandTargetCombatEquipmentΒ =Β {
Β Β Β Β CombatEquipment:Β CombatEquipment;
Β Β Β Β HardAttackPoints:Β int;
Β Β Β Β SoftAttackPoints:Β int;
Β Β Β Β }
typeΒ AirTargetCombatEquipmentΒ =Β {
Β Β Β Β CombatEquipment:Β CombatEquipment;
Β Β Β Β AirAttackPoints:Β int;
Β Β Β Β }
typeΒ NavalTargetCombatEquipmentΒ =Β {
Β Β Β Β CombatEquipment:Β CombatEquipment;
Β Β Β Β NavalAttackPoints:Β int
Β Β Β Β }
Β
Β
With movement and combat accounted for, I could start building the types of equipment
Β
typeΒ InfantryEquipmentΒ =Β {
Β Β Β Β BaseEquipment:Β BaseEquipment;Β
Β Β Β Β EntrenchableEquipment:Β EntrenchableEquipment;
Β Β Β Β MoveableEquipment:Β MoveableEquipment;
Β Β Β Β LandTargetCombatEquipment:Β LandTargetCombatEquipment
Β Β Β Β }
typeΒ TankEquipmentΒ =Β {
Β Β Β Β BaseEquipment:Β BaseEquipment;Β
Β Β Β Β FullTrackedEquipment:Β FullTrackEquipment;
Β Β Β Β LandTargetCombatEquipment:Β LandTargetCombatEquipment
Β Β Β Β }
Β
Β
There are twenty two different equipment types β you can see them all in the github repsository here.
Β
With the equipment out of the way, I was ready to start creating units β unit have a few stats like name and strength, as well as how much ammo and experience they have if they are a combat unit
Β
Β
typeΒ UnitStatsΒ =Β {
Β Β Β Β Id:Β int;Β
Β Β Β Β Name:Β string;Β
Β Β Β Β Strength:Β int;
Β Β Β Β }
Β Β Β Β
typeΒ ReinforcementTypeΒ =
|Β Core
|Β Auxiliary
typeΒ CombatStatsΒ =Β {
Β Β Β Β Ammo:Β int;
Β Β Β Β Experience:int;
Β Β Β Β ReinforcementType:ReinforcementTypeΒ Β Β Β }
typeΒ MotorizedMovementStatsΒ =Β {
Β Β Β Β Fuel:Β int;}
Β
With these basic attributes accounted for, I could then make units of the different equipment types. For example:
Β
typeΒ InfantryUnitΒ =Β {UnitStats:Β UnitStats;Β CombatStats:Β CombatStats;Β Equipment:Β InfantryEquipment;Β
Β Β Β Β CanBridge:Β bool;Β CanParaDrop:Β bool}
typeΒ TankUnitΒ =Β {UnitStats:Β UnitStats;Β CombatStats:Β CombatStats;Β MotorizedMovementStats:MotorizedMovementStats;Β
Β Β Β Β Equipment:Β TankEquipment}
Β
Β
PG also has different kinds of infantry units like this:
typeΒ InfantryΒ =
|Β BasicΒ ofΒ InfantryUnit
|Β HeavyWeaponΒ ofΒ InfantryUnit
|Β EngineerΒ ofΒ InfantryUnit
|Β AirborneΒ ofΒ InfantryUnit
|Β RangerΒ ofΒ InfantryUnit
|Β BridgingΒ ofΒ InfantryUnit
Β
Β
and then all of the land units can be defined as:
Β
typeΒ LandCombatΒ =
|Β InfantryΒ ofΒ Infantry
|Β TankΒ ofΒ TankUnit
|Β ReconΒ ofΒ ReconUnit
|Β TankDestroyerΒ ofΒ TankDestroyerUnit
|Β AntiAirΒ ofΒ AntiAirUnit
|Β EmplacementΒ ofΒ Emplacement
|Β AirDefenseΒ ofΒ AirDefense
|Β AntiTankΒ ofΒ AntiTank
|Β ArtilleryΒ ofΒ Artillery
Β
Β
There are a bunch more for sea and air, you can see on the github repository. Once they are all defined, they can be brought together like so:
Β
typeΒ TransportΒ =
|Β LandΒ ofΒ LandTransportUnit
|Β AirΒ ofΒ AirTransportUnit
|Β NavalΒ ofΒ NavalTransport
typeΒ UnitΒ =
|Β CombatΒ ofΒ Combat
|Β TransportΒ ofΒ Transport
Β
It is interesting to compare this domain model to the C# implementation I created six years ago. They key difference that stick out to me is to take properties of classes and turn them into types. So instead of a Fuel property of a unit that may or may not be null, there are MotorizedUnit types that require a fuel level. Instead of a bool field of like CanAttack or an interface like IAttackable, the behavor is baked into the type
Β
Also, the number of files and code dropped significantly, which definitely improved the code base:
Β

Β
Β
It is not all fun and games though, because I still need a mapping function to take the data files from the game and map them, to the types
Β
as well as functions to pull actionable data out of the type like this:
Β
letΒ getMoveableEquipmentΒ unitΒ =
Β Β Β Β matchΒ unitΒ withΒ
Β Β Β Β |Β Unit.CombatΒ cΒ ->Β
Β Β Β Β Β Β Β Β matchΒ cΒ withΒ
Β Β Β Β Β Β Β Β |Β Combat.AirΒ acΒ ->Β
Β Β Β Β Β Β Β Β Β Β Β Β matchΒ acΒ with
Β Β Β Β Β Β Β Β Β Β Β Β |Β AirCombat.FighterΒ acfΒ ->
Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β matchΒ Β acfΒ with
Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β |Β Fighter.PropΒ acfpΒ ->Β SomeΒ acfp.Equipment.MotorizedEquipment.MoveableEquipment
Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β |Β Fighter.JetΒ acfjΒ ->Β SomeΒ acfj.Equipment.MotorizedEquipment.MoveableEquipment
Β Β Β Β Β Β Β Β Β Β Β Β |Β AirCombat.BomberΒ acbΒ ->
Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β matchΒ acbΒ with
Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β |Β Bomber.TacticalΒ acbtΒ ->Β SomeΒ acbt.Equipment.MotorizedEquipment.MoveableEquipment
Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β |Β Bomber.StrategicΒ acbsΒ ->Β SomeΒ acbs.Equipment.MotorizedEquipment.MoveableEquipment
Β Β Β Β Β Β Β Β |Β Combat.LandΒ lcΒ ->
Β Β Β Β Β Β Β Β Β Β Β Β matchΒ lcΒ withΒ
Β
So far, that trade-off seems worth it because I just have to write these supporting functions once and I get guaranteed correctness across the entire code base β without hopes, prayers, and unit testsβ¦.
Β
Β
Once I had the units set up, I followed a similar exercise for Terrain. The real fun for me came to the next module β the series of functions to calculate movement of a unit across terrain. Each tile has a movement cost that is calculated based on the kind of equipment and the condition of a tile (tanks move slower over muddy ground)
Β
letΒ getMovmentCostΒ (movementTypeId:int)Β (tarrainConditionId:int)
Β Β Β Β (terrainTypeId:int)Β (mcs:Β MovementCostContext.MovementCostΒ array)Β =
Β Β Β Β mcsΒ |>Β Array.tryFind(funΒ mcΒ ->Β mc.MovementTypeIdΒ =Β movementTypeIdΒ &&
Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β mc.TerrainConditionIdΒ =Β tarrainConditionIdΒ &&
Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β Β mc.TerrainTypeIdΒ =Β terrainTypeId)
Β
Β
I need the ability to calculate all possible moveable tiles for a given unit. There are some supporting functions that you can review in the repository and the final calculator I am very happy with
Β
letΒ getMovableTilesΒ (board:Β TileΒ array)Β (landCondition:Β LandCondition)Β (tile:Tile)Β (unit:Unit)Β Β =
Β Β Β Β letΒ baseTileΒ =Β getBaseTileΒ tile
Β Β Β Β letΒ maximumDistanceΒ =Β (getUnitMovementPointsΒ unit)Β –Β 1
Β Β Β Β letΒ accumulatorΒ =Β Array.zeroCreate<TileΒ option>Β 0
Β Β Β Β letΒ adjacentTilesΒ =Β getExtendedAdjacentTilesΒ accumulatorΒ boardΒ tileΒ 0Β maximumDistance
Β Β Β Β adjacentTiles
Β Β Β Β |>Β Array.filter(funΒ tΒ ->Β t.IsSome)
Β Β Β Β |>Β Array.map(funΒ tΒ ->Β t.Value)
Β Β Β Β |>Β Array.map(funΒ tΒ ->Β t,Β getBaseTileΒ t)
Β Β Β Β |>Β Array.filter(funΒ (t,bt)Β ->Β bt.EarthUnit.IsNone)
Β Β Β Β |>Β Array.filter(funΒ (t,bt)Β ->Β canLandUnitsEnter(bt.Terrain))
Β Β Β Β |>Β Array.map(funΒ (t,bt)Β ->Β t)
Β
Β
and the results show:
Β

Β
With the domain set up, I can then concentrate on the game play
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β
Β