A Technical Guide To Armor Modules
Guide by Starficz
Before reading this guide I would recomend reading the guide: [A Basic Guide to Modules] That should give you all the basic considerations needed to decide if you wish to implement a modular ship or not. Reader beware, as this guide will delve deep into the intricacies of modules and will have lots of code examples.
This specific guide will be focused on armor modules, and the considerations we should be giving them compared to normal modules. In particular I will be explaning step by step how the Snowdrop class destroyer from mod The Knights of Ludd was made.
Part 1: The Visuals
///IMAGE HERE
Starsector has a specific rendering order for a ship and its modules, in general it goes like this:
Rendered from Bottom to Top: Parent Hull < Engines and Engine Glows < Module Hull < Module Weapons < Parent Weapons
Knowing this information the observent among you might have noticed something odd about the Snowdrop shown above. The render order in the image seems to have a Module Hull and its weapon (a Vulcan) in-between 2 Parent Weapons (the Pillum LRM, and SCY's Nano-Needle Machinegun) That is not possible according to the render order I just told you, so what gives?
Working with Decos
If you are not familar with decos, I will not be covering them in detail here, just know that they are sprites that you can place on a ship rendered along with the weapons. The following Image is a Snowdrop with all its decos removed:
///IMAGE HERE
As you can see, the Pillum does indeed render over the modules. This can fixed with a Deco. Keeping in mind the render order however: The deco can not be on the module itself, as all module and module weapons render under parent hull weapons. Thus the deco must be on the parent, rendered with the parent weapons.
///IMAGE HERE
Shown above is a deco for the top left module, lets put it on the parent ship and set the "renderOrderMod" correctly for all the mounts. In this case, the "renderOrderMod" for the Pillums are lower then the Deco, which is lower then the Vulcan, which is lower then the Main guns.
///IMAGE HERE
Uh oh. Looking back at the render order now reveals a problem, Module weapons are rendered below Parent Weapons. The module Vulcans are still there on the ship, but are covered by the deco now.
How do we fix this? If you guessed "Another Deco" then you are correct. In fact the Vulcans shown in the first image are decos themselves! After sticking another pair of deco vulcans on the main hull with "renderOrderMod" in-between the previous decos and the main hull weapons, we run into another issue.
///IMAGE HERE
Decos are not real weapons. They don't track anything. Here is where coding enters the picture. The Vulcan decos need to have an "everyFrameEffect" with code similar to the following:
public class vulcanDeco implements EveryFrameWeaponEffectPlugin {
public void advance(float amount, CombatEngineAPI engine, WeaponAPI weapon){
// note that the modules are named "kol_snowdrop_tl" and the decos "kol_deco_snowdrop_tl", "kol_deco_snowdrop_vulcan_tl"
String weaponID = weapon.getSpec().getWeaponId().substring(weapon.getSpec().getWeaponId().length() - 2);
for(ShipAPI module : ship.getChildModulesCopy()){
String moduleID = module.getHullSpec().getBaseHullId().substring(module.getHullSpec().getBaseHullId().length() - 2);
if (weaponID.equals(moduleID) && module.getHitpoints() > 0 && weapon.getSpec().getWeaponId().contains("vulcan")){
for(WeaponAPI moduleWeapon : module.getAllWeapons()){
if(moduleWeapon.getSpec().getWeaponId().contains("vulcan")){
weapon.setFacing(moduleWeapon.getCurrAngle());
}
}
}
}
}
}
What the above code does is find the vulcans on the correct module, and then match the deocs angle using code (setFacing) to be the same as the real vulcan on the module.
So are we now done with the visual issues? ... Not quite, take a look at the following image of 3 Snowdrops:
///IMAGE HERE
The first Snowdrop is about to be hit with a reaper, Oh No! The second Snowdrop is what what happen if there were no additional scripts. The center of the module we hit seems to have no damage visuals! What happened?
The issue is that the Deco covering the module is on the main hull, and the Parent Ship has not taken any damage, so it displays no damage visuals. To fix this more code (in an EveryFrameWeaponEffectPlugin ) is needed to set the Deco HP to be proportional to the module's current armor:
public class moduleDamage implements EveryFrameWeaponEffectPlugin {
boolean hidden = false;
public void advance(float amount, CombatEngineAPI engine, WeaponAPI weapon){
// note that the modules are named "kol_snowdrop_tl" and the decos "kol_deco_snowdrop_tl", "kol_deco_snowdrop_vulcan_tl"
String weaponID = weapon.getSpec().getWeaponId().substring(weapon.getSpec().getWeaponId().length() - 2);
for(ShipAPI module : ship.getChildModulesCopy()){
String moduleID = module.getHullSpec().getBaseHullId().substring(module.getHullSpec().getBaseHullId().length() - 2);
if (weaponID.equals(moduleID) && module.getHitpoints() > 0){
float healthLevel = StarficzAIUtils.getCurrentArmorRating(module) / module.getArmorGrid().getArmorRating();
weapon.setCurrHealth(weapon.getMaxHealth() * healthLevel);
}
}
}
}
This results in the third Snowdrop shown above, with correct damage visuals.
Now all the visual issues must be solved right? Almost, one last thing.
///IMAGE HERE
What happens when a module gets destroyed? as you can see the Decos are still there! This is again, becasue they are on the Parent ship! Finally to fix this we again look to code, and set the sprites to transparent when a module gets destroyed. Combining this hideDeco code with all the above code results in this final combined script:
public class hideDeco implements EveryFrameWeaponEffectPlugin {
boolean hidden = false;
public void advance(float amount, CombatEngineAPI engine, WeaponAPI weapon){
if(hidden || engine.isPaused() || Global.getCurrentState() != GameState.COMBAT) return;
ShipAPI ship = weapon.getShip();
if(ship == null || !ship.isAlive()){
hidden = true;
}
else{
String weaponID = weapon.getSpec().getWeaponId().substring(weapon.getSpec().getWeaponId().length() - 2);
boolean moduleDead = true;
for(ShipAPI module : ship.getChildModulesCopy()){
String moduleID = module.getHullSpec().getBaseHullId().substring(module.getHullSpec().getBaseHullId().length() - 2);
if (weaponID.equals(moduleID) && module.getHitpoints() > 0){
moduleDead = false;
float healthLevel = StarficzAIUtils.getCurrentArmorRating(module) / module.getArmorGrid().getArmorRating();
weapon.setCurrHealth(weapon.getMaxHealth() * healthLevel);
if(weapon.getSpec().getWeaponId().contains("vulcan")){
for(WeaponAPI moduleWeapon : module.getAllWeapons()){
if(moduleWeapon.getSpec().getWeaponId().contains("vulcan")){
weapon.setFacing(moduleWeapon.getCurrAngle());
}
}
}
}
}
if(moduleDead) hidden = true;
}
if(hidden){
weapon.getSprite().setNormalBlend();
SpriteAPI sprite = weapon.getSprite();
sprite.setColor(new Color(0,0,0,0));
if (weapon.getBarrelSpriteAPI() != null){
weapon.getBarrelSpriteAPI().setColor(new Color(0,0,0,0));
}
}
}
}
And with this, all visual issues are now solved.
Part 2: The Mechanics
When working with armor modules there are 2 main mechanical issues:
- Enemy AI
- Explosion Splash Damage
Working around Enemy AI
Enemy AI prioritizes targeting modules over the base hull. While logical for ship/station sections, this creates issues with armor modules.
///IMAGE HERE
To prevent this, I use code to set the modules to a 'hulk' state, causing the enemy AI to ignore it. ShipAPI.setHulk(true) Doing this however results in another issue, the modules "explode" visually when they are first set to be a hulk.
///IMAGE HERE
As a workaround for this workaround, the ships modules are teleported to a far off screen area before setting them to a hulk. This spawns the explosions off screen. (note that this teleport only happens when the ships are in the map bounds otherwise the game will despawn them)
Another issue is that the modules are individual ships to the enemy ai's eyes. The Snowdrop has 4 armor modules. This results in the enemy ai thinking there are 4 frigate escorts for the Snowdrop at all times, and thus enemies will be very hesitant to engage. To solve this AI issue, we set the modules to be Station drones ShipAPI.setDrone(true); (Sidenote: setting the module hullsize to be a fighter also solves this issue, but as fighters always render above everything else, the render order would be unfixable)
A third issue with setting the modules to hulks is that ShipSystems and Weapons do not work on hulks as they are considered dead. (We want them to be "dead" as this is what makes enemy ai ignore them) Thus they are manualy aimed and fired via code.
@Override
public void advanceInCombat(ShipAPI module, float amount) {
if(module.getParentStation() == null || !module.getParentStation().isAlive() || module.getHitpoints() <= 0.0f || module.hasTag(KOL_MODULE_DEAD) ||
Global.getCurrentState() != GameState.COMBAT || !Global.getCombatEngine().isEntityInPlay(module) ) return;
CombatEngineAPI engine = Global.getCombatEngine();
if (!module.hasTag(KOL_MODULE_HULKED) && module.getLocation().getY() > -engine.getMapHeight()/2 && module.getLocation().getY() < engine.getMapHeight()/2 &&
module.getLocation().getX() > -engine.getMapWidth()/2 && module.getLocation().getX() < engine.getMapWidth()/2){
// only teleport to inside the map border
float borderEdgeX = module.getLocation().getX() > 0 ? engine.getMapWidth()/2 : -engine.getMapWidth()/2;
float borderEdgeY = module.getLocation().getY() > 0 ? engine.getMapHeight()/2 : -engine.getMapHeight()/2;
module.getLocation().set(borderEdgeX, borderEdgeY);
module.setHulk(true);
module.setDrone(true);
module.addTag(KOL_MODULE_HULKED);
}
else if(!module.isHulk() && module.hasTag(KOL_MODULE_HULKED)){
module.setHulk(true);
}
for(WeaponAPI weapon : module.getAllWeapons()){
if(!weapon.isDecorative() && weapon.getType() != WeaponAPI.WeaponType.MISSILE && weapon.hasAIHint(WeaponAPI.AIHints.PD)){
aimAndFirePD(module, weapon, amount);
}
}
}
(A snippet of the advanceInCombat method of a hullmod that is on each module, please look up the KoL github and find src/org/selkie/kol/hullmods/KnightModule.java for the full code)
A final issue with setting the modules to be hulks is the game does not display hit explosions and visuals for hulks. This also means that they do not explode and detatch like normal modules would.
To fix this another bit of code is used in the form of a DamageListener and a HullDamageAboutToBeTakenListener This bit of code unsets the module to not be a hulk, lets the game process damage, then next frame advanceInCombat re-sets the modules to be a hulk.
public static class ModuleUnhulker implements DamageListener, HullDamageAboutToBeTakenListener {
@Override // unset hulk for right before any damage gets dealt to the module, this allows for normal processing of hit explosions
public void reportDamageApplied(Object source, CombatEntityAPI target, ApplyDamageResultAPI result) {
ShipAPI module = (ShipAPI) target;
if(module.isHulk() && module.getHitpoints() > 0 && !module.hasTag(KOL_MODULE_DEAD)) module.setHulk(false);
}
@Override // for some reason the above listener doesn't catch when the module is actually going to be dead.
public boolean notifyAboutToTakeHullDamage(Object param, ShipAPI module, Vector2f point, float damageAmount) {
if(module.getHitpoints() <= damageAmount && !module.hasTag(KOL_MODULE_DEAD)){
module.setHulk(false);
module.addTag(KOL_MODULE_DEAD);
}
return false;
}
}
Dealing with Explosion Splash Damage
In the middle of part 1 you might have noticed something intresting: The Snowdrop with only 3000 Hull and 600 Armor was able to take a hit from a 4000 damage Reaper and still survive, under vanilla mechanics this is impossible.
Vanilla explosion splash damage has no occlusion, an explosion hits everything in an area, no matter if something else was in the way or not. This is quite problematic for armor modules, as exposions "bypass" armor modules.
///IMAGE HERE
To change the vanilla behaviour, the parent hull and each armor module has a DamageTakenModifier on them that triggers when an explosion happens. This code Raycasts a bunch of lines and depending on what they hit, is the percentage of damage that is actually done to the ships. In the above image example 11 rays hit ships, 5 on the front module, 2 to the ship itself, and 4 on the back module.
For the reapers 4000 damage, this results in :
- 5/11 (45.5%) of 4000 = 1818 damage to the front module
- 2/11 (18.1%) of 4000 = 727 damage to the parent hull, and
- 4/11 (36.3%) of 4000 = 1454 damage to the back module
(note that the third image is only displaying 658 damage to the parent hull as that number is damage after armor DR)
@Override
public String modifyDamageTaken(Object param, CombatEntityAPI target, DamageAPI damage, Vector2f point, boolean shieldHit) {
ShipAPI ship = (ShipAPI) target;
ShipAPI parent = ship.getParentStation() == null ? ship : ship.getParentStation();
if (param instanceof DamagingExplosion || param instanceof MissileAPI) {
DamagingProjectileAPI projectile = (DamagingProjectileAPI) param;
HashMap<DamagingProjectileAPI, HashMap<String, Float>> explosionMap = (HashMap<DamagingProjectileAPI, HashMap<String, Float>>) parent.getCustomData().get(RAYCAST_KEY);
// if this is the explosion from the missile, look up the cached result and use that
if(projectile instanceof DamagingExplosion){
for(DamagingProjectileAPI pastProjectile : explosionMap.keySet()){
if (pastProjectile instanceof MissileAPI && pastProjectile.getSource() == projectile.getSource() &&
MathUtils.getDistanceSquared(pastProjectile.getLocation(), projectile.getSpawnLocation()) < 1f){
projectile = pastProjectile;
break;
}
}
}
generateExplosionRayhitMap(projectile, damage, parent);
explosionMap = (HashMap<DamagingProjectileAPI, HashMap<String, Float>>) parent.getCustomData().get(RAYCAST_KEY);
damage.getModifier().modifyMult(this.getClass().getName(), explosionMap.get(projectile).get(ship.getId()));
return this.getClass().getName();
}
return null;
}
(A snippet of the modifyDamageTaken method, the ray casting is done in generateExplosionRayhitMap and is too long to fit/explain in this post, please look up the KoL github and find src/org/selkie/kol/hullmods/KnightRefit.java for the full code)
Part 3: Paperdoll Armor UI and Hullmod Tooltips in Refit
Paperdoll Armor UI
You may have noticed in previous images another feature not in vanilla, Module Armor Paperdolls.
///IMAGE HERE
I won't be posting the code here as its both in kotlin and a bit too long, but know that I went with the lazyer method of drawing recolored white sprites. This was done in a renderInUICoords() method of a BaseEveryFrameCombatPlugin. If you wish to copy this 1 to 1, you will need a folder with white sprites of all the armor modules:
///IMAGE HERE
and then load them all into settings.json as seen below or via code by using Global.getSettings().loadTexture()
"graphics":{
"paperdolls": {
"kol_snowdrop_tl" : "graphics/ships/modules/paperdolls/kol_snowdrop_tl_paperdoll.png",
"kol_snowdrop_tr" : "graphics/ships/modules/paperdolls/kol_snowdrop_tr_paperdoll.png",
"kol_snowdrop_ll" : "graphics/ships/modules/paperdolls/kol_snowdrop_ll_paperdoll.png",
"kol_snowdrop_lr" : "graphics/ships/modules/paperdolls/kol_snowdrop_lr_paperdoll.png",
"kol_tamarisk_tl" : "graphics/ships/modules/paperdolls/kol_tamarisk_tl_paperdoll.png",
"kol_tamarisk_tr" : "graphics/ships/modules/paperdolls/kol_tamarisk_tr_paperdoll.png",
"kol_tamarisk_ll" : "graphics/ships/modules/paperdolls/kol_tamarisk_ll_paperdoll.png",
"kol_tamarisk_lr" : "graphics/ships/modules/paperdolls/kol_tamarisk_lr_paperdoll.png",
... ect
}
},
As always, the full code is in the github, src/org/selkie/kol/plugins/KOL_ArmorPaperdolls.kt
Hullmod Tooltips in Refit
While this guide won't be going over how to make custom hullmod tooltips (as that can take up a whole guide in itself),
///IMAGE HERE
(As an example, here is KoL's hullmod tooltip)
I will be talking about the issues of hullmods in general with modules.
Refit Ship do not have Module Childs A common pitfall is that calling ShipAPI.getChildModulesCopy() in the refit tooltip will not return any ships. Don't ask me why, this is just another one of the black magic things of the refit screen. For KoL I solved this this problem by making blank variants of all the KoL ships, then using Regex to match the module id's. Finally I used Global.getSettings.getHullspec() to pull up the base module stats.
// getting the stats of child modules in refit shouldn't have to be this hard
ShipVariantAPI variant = Global.getSettings().getVariant(ship.getHullSpec().getBaseHullId() + "_Blank");
Pattern kolPattern = Pattern.compile("kol_.+?_[tml][lr]", Pattern.CASE_INSENSITIVE);
for (String module : variant.getStationModules().values()) {
Matcher matcher = kolPattern.matcher(module);
if(matcher.find()){
ShipHullSpecAPI hull = Global.getSettings().getHullSpec(matcher.group());
float hullMult = getTotalHullMult(ship.getVariant(), hull.getHitpoints());
float armorMult = getTotalArmorMult(ship.getVariant(), hull.getArmorRating());
... Tootip Code here ...
}
}
Hullmods on the parent ship do not apply to modules
This is should be pretty obvious, but as modules are seperate ships, the hullmod effects do not carry over from the parent hull. The solution is to manualy apply armor/hull Mults as mutableStats to the module ships during combat:
for(ShipAPI module : ship.getChildModulesCopy()){
if (module.getHitpoints() <= 0f) continue;
if(ship.getVariant() == null || module.getVariant() == null) continue;
float hullmult = getTotalHullMult(ship.getVariant(), module.getVariant().getHullSpec().getHitpoints());
float armorMult = getTotalArmorMult(ship.getVariant(), module.getVariant().getHullSpec().getArmorRating());
module.getMutableStats().getHullDamageTakenMult().modifyMult("kol_module_parent_hullmods", hullmult);
module.getMutableStats().getArmorDamageTakenMult().modifyMult("kol_module_parent_hullmods", armorMult);
}
where getHullDamageTakenMult()/getTotalArmorMult() is
public float getTotalHullMult(ShipVariantAPI variant, float baseHull){
if(variant == null) return 1f;
Map<String, ArmorEffect> effects = HULLMOD_EFFECTS.get(variant.getHullSize());
float totalFlat = 0;
float totalPercent = 0;
for(String hullmodID : variant.getHullMods()){
if(effects.containsKey(hullmodID)){
totalFlat += effects.get(hullmodID).hullFlat;
totalPercent += effects.get(hullmodID).hullPercent;
}
}
return baseHull / (baseHull + totalFlat + (baseHull * totalPercent));
}
Closing Remarks
As you may have guessed from the length of this guide, modules are incredibly hard to get right, and this guide still mostly skips all the finicky implementation details for the more complex stuff. If after reading this you still want to implement modules for yourself, please feel free to look past the KoL github and contact me @Starficz for further questions.