diff --git a/Assets/Game/Addons/UnityConsole/Console/Scripts/DefaultCommands.cs b/Assets/Game/Addons/UnityConsole/Console/Scripts/DefaultCommands.cs
index 5318d9ce5a..4ae3746c5b 100644
--- a/Assets/Game/Addons/UnityConsole/Console/Scripts/DefaultCommands.cs
+++ b/Assets/Game/Addons/UnityConsole/Console/Scripts/DefaultCommands.cs
@@ -443,7 +443,7 @@ public static string Execute(params string[] args)
if (!int.TryParse(args[0], out id))
return "Invalid mobile ID.";
- if (!Enum.IsDefined(typeof(MobileTypes), id))
+ if (!Enum.IsDefined(typeof(MobileTypes), id) && DaggerfallEntity.GetCustomCareerTemplate(id) == null)
return "Invalid mobile ID.";
int team = 0;
diff --git a/Assets/Scripts/Game/Entities/DaggerfallEntity.cs b/Assets/Scripts/Game/Entities/DaggerfallEntity.cs
index 10987d14ab..2ac23916f7 100644
--- a/Assets/Scripts/Game/Entities/DaggerfallEntity.cs
+++ b/Assets/Scripts/Game/Entities/DaggerfallEntity.cs
@@ -929,6 +929,38 @@ public static DFCareer GetMonsterCareerTemplate(MonsterCareers career)
return monsterFile.GetMonsterClass((int)career);
}
+ ///
+ /// Allows mods to register a DFCareer template for IDs outside of the values in MobileTypes
+ ///
+ static readonly Dictionary CustomCareerTemplates = new Dictionary();
+
+ ///
+ /// Gets the career template for a custom (ie: mod-provided) enemy type
+ ///
+ /// ID, as defined in EnemyBasics.Enemies
+ /// The custom DFCareer template registered for this id, or null
+ public static DFCareer GetCustomCareerTemplate(int enemyId)
+ {
+ if (!CustomCareerTemplates.TryGetValue(enemyId, out DFCareer value))
+ {
+ return null;
+ }
+
+ return value;
+ }
+
+ ///
+ /// Sets the career template for a custom (ie: mod-provided) enemy type.
+ ///
+ /// ID, as defined in EnemyBasics.Enemies
+ /// The custom DFCareer template to register
+ public static void RegisterCustomCareerTemplate(int enemyId, DFCareer career)
+ {
+ // Use indexer so that mods can overwrite previous values added by mods
+ // ex: mod 1 provides new enemies, mod 2 rebalances them
+ CustomCareerTemplates[enemyId] = career;
+ }
+
public static SoundClips GetRaceGenderAttackSound(Races race, Genders gender, bool isPlayerAttack = false)
{
// Check for racial override attack sound for player only
diff --git a/Assets/Scripts/Game/Entities/EnemyEntity.cs b/Assets/Scripts/Game/Entities/EnemyEntity.cs
index 59da632ccf..2fc257f230 100644
--- a/Assets/Scripts/Game/Entities/EnemyEntity.cs
+++ b/Assets/Scripts/Game/Entities/EnemyEntity.cs
@@ -246,7 +246,33 @@ public override void ClearConstantEffects()
///
public void SetEnemyCareer(MobileEnemy mobileEnemy, EntityTypes entityType)
{
- if (entityType == EntityTypes.EnemyMonster)
+ // Try custom career first
+ career = GetCustomCareerTemplate(mobileEnemy.ID);
+
+ if (career != null)
+ {
+ // Custom enemy
+ careerIndex = mobileEnemy.ID;
+ stats.SetPermanentFromCareer(career);
+
+ if (entityType == EntityTypes.EnemyMonster)
+ {
+ // Default like a monster
+ level = mobileEnemy.Level;
+ maxHealth = Random.Range(mobileEnemy.MinHealth, mobileEnemy.MaxHealth + 1);
+ for (int i = 0; i < ArmorValues.Length; i++)
+ {
+ ArmorValues[i] = (sbyte)(mobileEnemy.ArmorValue * 5);
+ }
+ }
+ else
+ {
+ // Default like a class enemy
+ level = GameManager.Instance.PlayerEntity.Level;
+ maxHealth = FormulaHelper.RollEnemyClassMaxHealth(level, career.HitPointsPerLevel);
+ }
+ }
+ else if (entityType == EntityTypes.EnemyMonster)
{
careerIndex = mobileEnemy.ID;
career = GetMonsterCareerTemplate((MonsterCareers)careerIndex);
diff --git a/Assets/Scripts/Game/SetupDemoEnemy.cs b/Assets/Scripts/Game/SetupDemoEnemy.cs
index de54e93be1..5a92a0cdf0 100644
--- a/Assets/Scripts/Game/SetupDemoEnemy.cs
+++ b/Assets/Scripts/Game/SetupDemoEnemy.cs
@@ -167,6 +167,23 @@ public void ApplyEnemySettings(MobileGender gender)
entityBehaviour.EntityType = EntityTypes.EnemyClass;
entity.SetEnemyCareer(mobileEnemy, entityBehaviour.EntityType);
}
+ else if (DaggerfallEntity.GetCustomCareerTemplate(enemyIndex) != null)
+ {
+ // For custom enemies, we use the 7th bit to tell whether a class or monster was intended
+ // 0-127 is monster
+ // 128-255 is class
+ // 256-383 is monster again
+ // etc
+ if ((enemyIndex & 128) != 0)
+ {
+ entityBehaviour.EntityType = EntityTypes.EnemyClass;
+ }
+ else
+ {
+ entityBehaviour.EntityType = EntityTypes.EnemyMonster;
+ }
+ entity.SetEnemyCareer(mobileEnemy, entityBehaviour.EntityType);
+ }
else
{
entityBehaviour.EntityType = EntityTypes.None;
diff --git a/Assets/Scripts/Game/TextManager.cs b/Assets/Scripts/Game/TextManager.cs
index 840837870d..249d382db0 100644
--- a/Assets/Scripts/Game/TextManager.cs
+++ b/Assets/Scripts/Game/TextManager.cs
@@ -18,6 +18,7 @@
using UnityEngine.Localization.Settings;
using Wenzil.Console;
using DaggerfallWorkshop.Game.UserInterface;
+using DaggerfallWorkshop.Game.Entity;
using UnityEngine.Localization.Tables;
namespace DaggerfallWorkshop.Game
@@ -331,15 +332,31 @@ public string[] GetLocalizedTextList(string key, TextCollections collection = Te
///
/// Gets display name of an enemy from their ID.
///
- /// ID of enemy. Valid IDs are 0-42 and 128-146.
+ /// ID of enemy. Valid IDs are 0-42 and 128-146, or values registered in Daggerfallentity.CustomCareerTemplates
/// Name of enemy from localization if found, or exception if not found.
public string GetLocalizedEnemyName(int enemyID)
{
- string[] enemyNames = GetLocalizedTextList("enemyNames", exception:true);
- if (enemyID < 128)
- return enemyNames[enemyID];
+ if (Enum.IsDefined(typeof(MobileTypes), (MobileTypes)enemyID))
+ {
+ string[] enemyNames = GetLocalizedTextList("enemyNames", exception: true);
+ if (enemyID < 128)
+ return enemyNames[enemyID];
+ else
+ return enemyNames[43 + enemyID - 128];
+ }
+ // Handle custom enemies
else
- return enemyNames[43 + enemyID - 128];
+ {
+ // TODO: maybe go through TextProvider so mods can try to offer localization?
+ DaggerfallConnect.DFCareer career = DaggerfallEntity.GetCustomCareerTemplate(enemyID);
+ if (career == null)
+ {
+ Debug.LogError($"Enemy ID '{enemyID}' did not have a registered custom career template");
+ return "(invalid enemy)";
+ }
+
+ return career.Name;
+ }
}
#endregion