diff --git a/Deps/TRGE.Coord.dll b/Deps/TRGE.Coord.dll index 22f232af0..e29dcce15 100644 Binary files a/Deps/TRGE.Coord.dll and b/Deps/TRGE.Coord.dll differ diff --git a/Deps/TRGE.Core.dll b/Deps/TRGE.Core.dll index 52a7abdfa..694526df3 100644 Binary files a/Deps/TRGE.Core.dll and b/Deps/TRGE.Core.dll differ diff --git a/TRDataControl/Data/Remastered/BaseTRRDataCache.cs b/TRDataControl/Data/Remastered/BaseTRRDataCache.cs index 111f127be..f50104589 100644 --- a/TRDataControl/Data/Remastered/BaseTRRDataCache.cs +++ b/TRDataControl/Data/Remastered/BaseTRRDataCache.cs @@ -13,6 +13,20 @@ public abstract class BaseTRRDataCache public string PDPFolder { get; set; } + public void Merge(ImportResult importResult, TRDictionary pdpData, Dictionary mapData) + { + foreach (TKey type in importResult.ImportedTypes) + { + SetData(pdpData, mapData, type, TranslateAlias(type)); + } + + foreach (TKey type in importResult.RemovedTypes) + { + pdpData.Remove(type); + mapData.Remove(type); + } + } + public void SetData(TRDictionary pdpData, Dictionary mapData, TKey sourceType, TKey destinationType = default) { if (EqualityComparer.Default.Equals(destinationType, default)) @@ -38,6 +52,10 @@ public void SetPDPData(TRDictionary pdpData, TKey sourceType, TKe { _pdpCache[sourceType] = models[translatedKey]; } + else if (models.ContainsKey(destinationType)) + { + _pdpCache[sourceType] = models[destinationType]; + } else { throw new KeyNotFoundException($"Could not load cached PDP data for {sourceType}"); @@ -63,5 +81,6 @@ public void SetMapData(Dictionary mapData, TKey sourceType, TKey d protected abstract TRPDPControlBase GetPDPControl(); public abstract string GetSourceLevel(TKey key); public abstract TKey TranslateKey(TKey key); + public abstract TKey TranslateAlias(TKey alias); public abstract TAlias GetAlias(TKey key); } diff --git a/TRDataControl/Data/Remastered/TR1RDataCache.cs b/TRDataControl/Data/Remastered/TR1RDataCache.cs index a02f45857..063b57b37 100644 --- a/TRDataControl/Data/Remastered/TR1RDataCache.cs +++ b/TRDataControl/Data/Remastered/TR1RDataCache.cs @@ -13,21 +13,25 @@ protected override TRPDPControlBase GetPDPControl() => _control ??= new(); public override TR1Type TranslateKey(TR1Type key) - => TR1TypeUtilities.TranslateSourceType(key); - - public override string GetSourceLevel(TR1Type key) { - _dataProvider ??= new(); - TR1Type translatedType = _dataProvider.TranslateAlias(key); - return _sourceLevels.ContainsKey(translatedType) ? _sourceLevels[translatedType] : null; + return key switch + { + TR1Type.SecretAnkh_M_H => TR1Type.Puzzle4_M_H, + TR1Type.SecretGoldBar_M_H or TR1Type.SecretGoldIdol_M_H => TR1Type.Puzzle1_M_H, + TR1Type.SecretLeadBar_M_H => TR1Type.LeadBar_M_H, + TR1Type.SecretScion_M_H => TR1Type.ScionPiece_M_H, + _ => key, + }; } + public override TR1Type TranslateAlias(TR1Type key) + => (_dataProvider ??= new()).TranslateAlias(key); + + public override string GetSourceLevel(TR1Type key) + => _sourceLevels.ContainsKey(key) ? _sourceLevels[key] : null; + public override TR1RAlias GetAlias(TR1Type key) - { - _dataProvider ??= new(); - TR1Type translatedType = _dataProvider.TranslateAlias(key); - return _mapAliases.ContainsKey(translatedType) ? _mapAliases[translatedType] : default; - } + => _mapAliases.ContainsKey(key) ? _mapAliases[key] : default; private static readonly Dictionary _sourceLevels = new() { @@ -36,6 +40,55 @@ public override TR1RAlias GetAlias(TR1Type key) [TR1Type.SecretGoldIdol_M_H] = TR1LevelNames.VILCABAMBA, [TR1Type.SecretLeadBar_M_H] = TR1LevelNames.MIDAS, [TR1Type.SecretScion_M_H] = TR1LevelNames.QUALOPEC, + + [TR1Type.Bat] = TR1LevelNames.CAVES, + [TR1Type.Bear] = TR1LevelNames.CAVES, + [TR1Type.Wolf] = TR1LevelNames.CAVES, + + [TR1Type.Raptor] = TR1LevelNames.VALLEY, + [TR1Type.TRex] = TR1LevelNames.VALLEY, + [TR1Type.LaraMiscAnim_H_Valley] = TR1LevelNames.VALLEY, + + [TR1Type.CrocodileLand] = TR1LevelNames.FOLLY, + [TR1Type.CrocodileWater] = TR1LevelNames.FOLLY, + [TR1Type.Gorilla] = TR1LevelNames.FOLLY, + [TR1Type.Lion] = TR1LevelNames.FOLLY, + [TR1Type.Lioness] = TR1LevelNames.FOLLY, + + [TR1Type.RatLand] = TR1LevelNames.CISTERN, + [TR1Type.RatWater] = TR1LevelNames.CISTERN, + + [TR1Type.Centaur] = TR1LevelNames.TIHOCAN, + [TR1Type.CentaurStatue] = TR1LevelNames.TIHOCAN, + [TR1Type.Pierre] = TR1LevelNames.TIHOCAN, + [TR1Type.ScionPiece_M_H] = TR1LevelNames.TIHOCAN, + [TR1Type.Key1_M_H] = TR1LevelNames.TIHOCAN, + + [TR1Type.Panther] = TR1LevelNames.KHAMOON, + [TR1Type.NonShootingAtlantean_N] = TR1LevelNames.KHAMOON, + + [TR1Type.BandagedAtlantean] = TR1LevelNames.OBELISK, + [TR1Type.BandagedFlyer] = TR1LevelNames.OBELISK, + [TR1Type.Missile2_H] = TR1LevelNames.OBELISK, + + [TR1Type.Missile3_H] = TR1LevelNames.SANCTUARY, + [TR1Type.MeatyAtlantean] = TR1LevelNames.SANCTUARY, + [TR1Type.MeatyFlyer] = TR1LevelNames.SANCTUARY, + [TR1Type.ShootingAtlantean_N] = TR1LevelNames.SANCTUARY, + [TR1Type.Larson] = TR1LevelNames.SANCTUARY, + + [TR1Type.CowboyOG] = TR1LevelNames.MINES, + [TR1Type.CowboyHeadless] = TR1LevelNames.MINES, + [TR1Type.SkateboardKid] = TR1LevelNames.MINES, + [TR1Type.Skateboard] = TR1LevelNames.MINES, + [TR1Type.Kold] = TR1LevelNames.MINES, + + [TR1Type.AtlanteanEgg] = TR1LevelNames.ATLANTIS, + + [TR1Type.Adam] = TR1LevelNames.PYRAMID, + [TR1Type.AdamEgg] = TR1LevelNames.PYRAMID, + [TR1Type.Natla] = TR1LevelNames.PYRAMID, + [TR1Type.LaraMiscAnim_H_Pyramid] = TR1LevelNames.PYRAMID, }; private static readonly Dictionary _mapAliases = new() @@ -45,5 +98,7 @@ public override TR1RAlias GetAlias(TR1Type key) [TR1Type.SecretGoldIdol_M_H] = TR1RAlias.PUZZLE_OPTION1_2, [TR1Type.SecretLeadBar_M_H] = TR1RAlias.LEADBAR_OPTION, [TR1Type.SecretScion_M_H] = TR1RAlias.SCION_OPTION, + + [TR1Type.Natla] = TR1RAlias.NATLA_MUTANT, }; } diff --git a/TRDataControl/Data/Remastered/TR2RDataCache.cs b/TRDataControl/Data/Remastered/TR2RDataCache.cs new file mode 100644 index 000000000..2ebd9f1ad --- /dev/null +++ b/TRDataControl/Data/Remastered/TR2RDataCache.cs @@ -0,0 +1,130 @@ +using TRLevelControl; +using TRLevelControl.Helpers; +using TRLevelControl.Model; + +namespace TRDataControl; + +public class TR2RDataCache : BaseTRRDataCache +{ + private TR2PDPControl _control; + private TR2DataProvider _dataProvider; + + protected override TRPDPControlBase GetPDPControl() + => _control ??= new(); + + public override TR2Type TranslateKey(TR2Type key) + => key; + + public override TR2Type TranslateAlias(TR2Type key) + => (_dataProvider ??= new()).TranslateAlias(key); + + public override string GetSourceLevel(TR2Type key) + => _sourceLevels.ContainsKey(key) ? _sourceLevels[key] : null; + + public override TR2RAlias GetAlias(TR2Type key) + => _mapAliases.ContainsKey(key) ? _mapAliases[key] : default; + + private static readonly Dictionary _sourceLevels = new() + { + [TR2Type.Crow] = TR2LevelNames.GW, + [TR2Type.Spider] = TR2LevelNames.GW, + [TR2Type.BengalTiger] = TR2LevelNames.GW, + [TR2Type.TRex] = TR2LevelNames.GW, + [TR2Type.LaraMiscAnim_H_Wall] = TR2LevelNames.GW, + + [TR2Type.Doberman] = TR2LevelNames.VENICE, + [TR2Type.MaskedGoon1] = TR2LevelNames.VENICE, + [TR2Type.MaskedGoon2] = TR2LevelNames.VENICE, + [TR2Type.MaskedGoon3] = TR2LevelNames.VENICE, + [TR2Type.Rat] = TR2LevelNames.VENICE, + [TR2Type.StickWieldingGoon1BodyWarmer] = TR2LevelNames.VENICE, + + [TR2Type.StickWieldingGoon1WhiteVest] = TR2LevelNames.BARTOLI, + + [TR2Type.ShotgunGoon] = TR2LevelNames.OPERA, + + [TR2Type.Gunman1OG] = TR2LevelNames.RIG, + [TR2Type.Gunman1TopixtorCAC] = TR2LevelNames.RIG, + [TR2Type.Gunman1TopixtorORC] = TR2LevelNames.RIG, + [TR2Type.Gunman2] = TR2LevelNames.RIG, + [TR2Type.ScubaDiver] = TR2LevelNames.RIG, + [TR2Type.ScubaHarpoonProjectile_H] = TR2LevelNames.RIG, + [TR2Type.StickWieldingGoon1Bandana] = TR2LevelNames.RIG, + + [TR2Type.FlamethrowerGoonOG] = TR2LevelNames.DA, + [TR2Type.FlamethrowerGoonTopixtor] = TR2LevelNames.DA, + + [TR2Type.BarracudaUnwater] = TR2LevelNames.FATHOMS, + [TR2Type.Shark] = TR2LevelNames.FATHOMS, + [TR2Type.LaraMiscAnim_H_Unwater] = TR2LevelNames.FATHOMS, + + [TR2Type.StickWieldingGoon1GreenVest] = TR2LevelNames.DORIA, + [TR2Type.YellowMorayEel] = TR2LevelNames.DORIA, + + [TR2Type.BlackMorayEel] = TR2LevelNames.LQ, + [TR2Type.StickWieldingGoon2] = TR2LevelNames.LQ, + + [TR2Type.Eagle] = TR2LevelNames.TIBET, + [TR2Type.Mercenary2] = TR2LevelNames.TIBET, + [TR2Type.Mercenary3] = TR2LevelNames.TIBET, + [TR2Type.MercSnowmobDriver] = TR2LevelNames.TIBET, + [TR2Type.BlackSnowmob] = TR2LevelNames.TIBET, + [TR2Type.RedSnowmobile] = TR2LevelNames.TIBET, + [TR2Type.SnowmobileBelt] = TR2LevelNames.TIBET, + [TR2Type.LaraSnowmobAnim_H] = TR2LevelNames.TIBET, + [TR2Type.SnowLeopard] = TR2LevelNames.TIBET, + + [TR2Type.Mercenary1] = TR2LevelNames.MONASTERY, + [TR2Type.MonkWithKnifeStick] = TR2LevelNames.MONASTERY, + [TR2Type.MonkWithLongStick] = TR2LevelNames.MONASTERY, + + [TR2Type.BarracudaIce] = TR2LevelNames.COT, + [TR2Type.Yeti] = TR2LevelNames.COT, + [TR2Type.LaraMiscAnim_H_Ice] = TR2LevelNames.COT, + + [TR2Type.BirdMonster] = TR2LevelNames.CHICKEN, + [TR2Type.WhiteTiger] = TR2LevelNames.CHICKEN, + + [TR2Type.BarracudaXian] = TR2LevelNames.XIAN, + [TR2Type.GiantSpider] = TR2LevelNames.XIAN, + + [TR2Type.Knifethrower] = TR2LevelNames.FLOATER, + [TR2Type.KnifeProjectile_H] = TR2LevelNames.FLOATER, + [TR2Type.XianGuardSword] = TR2LevelNames.FLOATER, + [TR2Type.XianGuardSpear] = TR2LevelNames.FLOATER, + [TR2Type.XianGuardSpearStatue] = TR2LevelNames.FLOATER, + [TR2Type.XianGuardSwordStatue] = TR2LevelNames.FLOATER, + + [TR2Type.MarcoBartoli] = TR2LevelNames.LAIR, + [TR2Type.DragonExplosionEmitter_N] = TR2LevelNames.LAIR, + [TR2Type.DragonExplosion1_H] = TR2LevelNames.LAIR, + [TR2Type.DragonExplosion2_H] = TR2LevelNames.LAIR, + [TR2Type.DragonExplosion3_H] = TR2LevelNames.LAIR, + [TR2Type.DragonFront_H] = TR2LevelNames.LAIR, + [TR2Type.DragonBack_H] = TR2LevelNames.LAIR, + [TR2Type.DragonBonesFront_H] = TR2LevelNames.LAIR, + [TR2Type.DragonBonesBack_H] = TR2LevelNames.LAIR, + [TR2Type.LaraMiscAnim_H_Xian] = TR2LevelNames.LAIR, + [TR2Type.Puzzle2_M_H_Dagger] = TR2LevelNames.LAIR, + + [TR2Type.StickWieldingGoon1BlackJacket] = TR2LevelNames.HOME, + + [TR2Type.Winston] = TR2LevelNames.ASSAULT, + }; + + private static readonly Dictionary _mapAliases = new() + { + [TR2Type.BengalTiger] = TR2RAlias.TIGER_EMPRTOMB_WALL, + [TR2Type.StickWieldingGoon1BodyWarmer] = TR2RAlias.WORKER3_BOAT, + [TR2Type.StickWieldingGoon1WhiteVest] = TR2RAlias.WORKER3_VENICE_OPERA, + [TR2Type.ShotgunGoon] = TR2RAlias.CULT3_OPERA, + [TR2Type.StickWieldingGoon1Bandana] = TR2RAlias.WORKER3_PLATFORM_RIG, + [TR2Type.BarracudaUnwater] = TR2RAlias.BARACUDDA_DECK_LIVING_KEEL_UNWATER, + [TR2Type.StickWieldingGoon1GreenVest] = TR2RAlias.WORKER3_DECK_LIVING_KEEL_UNWATER, + [TR2Type.SnowLeopard] = TR2RAlias.TIGER_CATACOMB_SKIDOO, + [TR2Type.BarracudaIce] = TR2RAlias.BARACUDDA_ICECAVE_CATACOMB, + [TR2Type.WhiteTiger] = TR2RAlias.TIGER_ICECAVE, + [TR2Type.BarracudaXian] = TR2RAlias.BARACUDDA_EMPRTOMB, + [TR2Type.StickWieldingGoon1BlackJacket] = TR2RAlias.WORKER3_HOUSE, + }; +} diff --git a/TRDataControl/Data/Remastered/TR3RDataCache.cs b/TRDataControl/Data/Remastered/TR3RDataCache.cs index dd97d0822..247e0c16f 100644 --- a/TRDataControl/Data/Remastered/TR3RDataCache.cs +++ b/TRDataControl/Data/Remastered/TR3RDataCache.cs @@ -22,19 +22,14 @@ public override TR3Type TranslateKey(TR3Type key) }; } + public override TR3Type TranslateAlias(TR3Type key) + => (_dataProvider ??= new()).TranslateAlias(key); + public override string GetSourceLevel(TR3Type key) - { - _dataProvider ??= new(); - TR3Type translatedType = _dataProvider.TranslateAlias(key); - return _sourceLevels.ContainsKey(translatedType) ? _sourceLevels[translatedType] : null; - } + => _sourceLevels.ContainsKey(key) ? _sourceLevels[key] : null; public override TR3RAlias GetAlias(TR3Type key) - { - _dataProvider ??= new(); - TR3Type translatedType = _dataProvider.TranslateAlias(key); - return _mapAliases.ContainsKey(translatedType) ? _mapAliases[translatedType] : default; - } + => _mapAliases.ContainsKey(key) ? _mapAliases[key] : default; private static readonly Dictionary _sourceLevels = new() { @@ -50,6 +45,81 @@ public override TR3RAlias GetAlias(TR3Type key) [TR3Type.Quest1_M_H] = TR3LevelNames.COASTAL, [TR3Type.Quest2_P] = TR3LevelNames.MADHOUSE, [TR3Type.Quest2_M_H] = TR3LevelNames.MADHOUSE, + + [TR3Type.Monkey] = TR3LevelNames.JUNGLE, + [TR3Type.MonkeyMedMeshswap] = TR3LevelNames.JUNGLE, + [TR3Type.MonkeyKeyMeshswap] = TR3LevelNames.JUNGLE, + [TR3Type.Tiger] = TR3LevelNames.JUNGLE, + + [TR3Type.Shiva] = TR3LevelNames.RUINS, + [TR3Type.ShivaStatue] = TR3LevelNames.RUINS, + [TR3Type.LaraExtraAnimation_H] = TR3LevelNames.RUINS, + [TR3Type.CobraIndia] = TR3LevelNames.RUINS, + + [TR3Type.Quad] = TR3LevelNames.GANGES, + [TR3Type.LaraVehicleAnimation_H_Quad] = TR3LevelNames.GANGES, + [TR3Type.Vulture] = TR3LevelNames.GANGES, + + [TR3Type.TonyFirehands] = TR3LevelNames.CAVES, + + [TR3Type.Croc] = TR3LevelNames.COASTAL, + [TR3Type.TribesmanAxe] = TR3LevelNames.COASTAL, + [TR3Type.TribesmanDart] = TR3LevelNames.COASTAL, + + [TR3Type.Compsognathus] = TR3LevelNames.CRASH, + [TR3Type.Mercenary] = TR3LevelNames.CRASH, + [TR3Type.Raptor] = TR3LevelNames.CRASH, + [TR3Type.Tyrannosaur] = TR3LevelNames.CRASH, + + [TR3Type.Kayak] = TR3LevelNames.MADUBU, + [TR3Type.LaraVehicleAnimation_H_Kayak] = TR3LevelNames.MADUBU, + [TR3Type.LizardMan] = TR3LevelNames.MADUBU, + + [TR3Type.Puna] = TR3LevelNames.PUNA, + + [TR3Type.Crow] = TR3LevelNames.THAMES, + [TR3Type.LondonGuard] = TR3LevelNames.THAMES, + [TR3Type.LondonMerc] = TR3LevelNames.THAMES, + [TR3Type.Rat] = TR3LevelNames.THAMES, + + [TR3Type.Punk] = TR3LevelNames.ALDWYCH, + [TR3Type.DogLondon] = TR3LevelNames.ALDWYCH, + + [TR3Type.ScubaSteve] = TR3LevelNames.LUDS, + [TR3Type.UPV] = TR3LevelNames.LUDS, + [TR3Type.LaraVehicleAnimation_H_UPV] = TR3LevelNames.LUDS, + + [TR3Type.SophiaLee] = TR3LevelNames.CITY, + + [TR3Type.DamGuard] = TR3LevelNames.NEVADA, + [TR3Type.CobraNevada] = TR3LevelNames.NEVADA, + + [TR3Type.MPWithStick] = TR3LevelNames.HSC, + [TR3Type.MPWithGun] = TR3LevelNames.HSC, + [TR3Type.Prisoner] = TR3LevelNames.HSC, + [TR3Type.DogNevada] = TR3LevelNames.HSC, + + [TR3Type.KillerWhale] = TR3LevelNames.AREA51, + [TR3Type.MPWithMP5] = TR3LevelNames.AREA51, + + [TR3Type.CrawlerMutantInCloset] = TR3LevelNames.ANTARC, + [TR3Type.Boat] = TR3LevelNames.ANTARC, + [TR3Type.LaraVehicleAnimation_H_Boat] = TR3LevelNames.ANTARC, + [TR3Type.RXRedBoi] = TR3LevelNames.ANTARC, + [TR3Type.DogAntarc] = TR3LevelNames.ANTARC, + + [TR3Type.Crawler] = TR3LevelNames.RXTECH, + [TR3Type.RXTechFlameLad] = TR3LevelNames.RXTECH, + [TR3Type.BruteMutant] = TR3LevelNames.RXTECH, + + [TR3Type.TinnosMonster] = TR3LevelNames.TINNOS, + [TR3Type.TinnosWasp] = TR3LevelNames.TINNOS, + + [TR3Type.Willie] = TR3LevelNames.WILLIE, + [TR3Type.RXGunLad] = TR3LevelNames.WILLIE, + + [TR3Type.Winston] = TR3LevelNames.ASSAULT, + [TR3Type.WinstonInCamoSuit] = TR3LevelNames.ASSAULT, }; private static readonly Dictionary _mapAliases = new() @@ -66,5 +136,8 @@ public override TR3RAlias GetAlias(TR3Type key) [TR3Type.Quest1_M_H] = TR3RAlias.PUZZLE_OPTION1_SHORE, [TR3Type.Quest2_P] = TR3RAlias.PUZZLE_ITEM1_HAND, [TR3Type.Quest2_M_H] = TR3RAlias.PUZZLE_OPTION1_HAND, + + [TR3Type.DogLondon] = TR3RAlias.DOG_SEWER, + [TR3Type.CobraNevada] = TR3RAlias.COBRA_NEVADA, }; } diff --git a/TRDataControl/Transport/ImportResult.cs b/TRDataControl/Transport/ImportResult.cs new file mode 100644 index 000000000..46adb1cb3 --- /dev/null +++ b/TRDataControl/Transport/ImportResult.cs @@ -0,0 +1,8 @@ +namespace TRDataControl; + +public class ImportResult + where T : Enum +{ + public List ImportedTypes { get; set; } = new(); + public List RemovedTypes { get; set; } = new(); +} diff --git a/TRDataControl/Transport/TRDataImporter.cs b/TRDataControl/Transport/TRDataImporter.cs index 82e97a5ab..8f505fa45 100644 --- a/TRDataControl/Transport/TRDataImporter.cs +++ b/TRDataControl/Transport/TRDataImporter.cs @@ -20,8 +20,9 @@ public abstract class TRDataImporter : TRDataTransport private List _nonGraphicsDependencies; - public void Import() + public ImportResult Import() { + ImportResult result = new(); _nonGraphicsDependencies = new(); List existingTypes = GetExistingTypes(); @@ -39,14 +40,14 @@ public void Import() if (blobs.Count == 0) { - return; + return result; } // Store the current dummy mesh in case we are replacing the master type. TRMesh dummyMesh = GetDummyMesh(); // Remove old types first and tidy up stale textures - RemoveData(); + RemoveData(result); // Try to pack the textures collectively now that we have cleared some space. // This will throw if it fails. @@ -54,6 +55,11 @@ public void Import() // Success - import the remaining data. ImportData(blobs, dummyMesh); + + result.ImportedTypes.AddRange(blobs.Where(b => b.Type == TRBlobType.Model).Select(b => b.Alias)); + result.RemovedTypes.RemoveAll(t => Data.GetAliases(t).Any(result.ImportedTypes.Contains)); + + return result; } private void CleanRemovalList() @@ -276,7 +282,7 @@ private void BuildBlobList(List standardBlobs, List modelTypes, T nextType standardBlobs.Add(nextBlob); } - protected void RemoveData() + protected void RemoveData(ImportResult resultTracker) { List staleTextures = new(); foreach (T type in TypesToRemove) @@ -291,9 +297,11 @@ protected void RemoveData() staleTextures.AddRange(Models[type].Meshes .SelectMany(m => m.TexturedFaces.Select(t => (int)t.Texture))); - if (!_nonGraphicsDependencies.Contains(id)) + if (!_nonGraphicsDependencies.Contains(id) + && !TypesToImport.Any(t => Data.GetDependencies(t).Contains(id))) { Models.Remove(id); + resultTracker.RemovedTypes.Add(type); } } break; diff --git a/TRDataControlTests/IO/ImportTests.cs b/TRDataControlTests/IO/ImportTests.cs index c492814c3..005b5bb58 100644 --- a/TRDataControlTests/IO/ImportTests.cs +++ b/TRDataControlTests/IO/ImportTests.cs @@ -1,4 +1,6 @@ using TRDataControl; +using TRLevelControl; +using TRLevelControl.Helpers; using TRLevelControl.Model; using TRLevelControlTests; @@ -28,6 +30,48 @@ public void TestTR1Import() Assert.IsTrue(level.Models.ContainsKey(TR1Type.Bear)); } + [TestMethod] + [Description("Test merging TR1R data.")] + public void TestTR1RMerge() + { + ExportTR1Model(TR1Type.Bear); + ExportTR1RPDP(TR1LevelNames.CAVES); + + TR1Level level = GetTR1AltTestLevel(); + level.Models[TR1Type.Larson] = new(); + + TR1DataImporter importer = new() + { + DataFolder = @"Objects\TR1", + Level = level, + TypesToImport = new() { TR1Type.Bear }, + TypesToRemove = new() { TR1Type.Larson }, + }; + ImportResult result = importer.Import(); + + Assert.IsTrue(result.ImportedTypes.Contains(TR1Type.Bear)); + Assert.IsTrue(result.RemovedTypes.Contains(TR1Type.Larson)); + + TRDictionary pdpData = new() + { + [TR1Type.Larson] = new(), + }; + Dictionary mapData = new() + { + [TR1Type.Larson] = TR1RAlias.LARSON_EGYPT + }; + + TR1RDataCache dataCache = new() + { + PDPFolder = "PDP" + }; + dataCache.Merge(result, pdpData, mapData); + + Assert.IsTrue(pdpData.ContainsKey(TR1Type.Bear)); + Assert.IsFalse(pdpData.ContainsKey(TR1Type.Larson)); + Assert.IsFalse(mapData.ContainsKey(TR1Type.Larson)); + } + [TestMethod] [Description("Test importing a TR2 model.")] public void TestTR2Import() @@ -48,6 +92,48 @@ public void TestTR2Import() Assert.IsTrue(level.Models.ContainsKey(TR2Type.TigerOrSnowLeopard)); } + [TestMethod] + [Description("Test merging TR2R data.")] + public void TestTR2RMerge() + { + ExportTR2Model(TR2Type.BengalTiger); + ExportTR2RPDP(TR2LevelNames.GW); + + TR2Level level = GetTR2AltTestLevel(); + level.Models[TR2Type.Yeti] = new(); + + TR2DataImporter importer = new() + { + DataFolder = @"Objects\TR2", + Level = level, + TypesToImport = new() { TR2Type.BengalTiger }, + TypesToRemove = new() { TR2Type.Yeti }, + }; + ImportResult result = importer.Import(); + + Assert.IsTrue(result.ImportedTypes.Contains(TR2Type.BengalTiger)); + Assert.IsTrue(result.RemovedTypes.Contains(TR2Type.Yeti)); + + TRDictionary pdpData = new() + { + [TR2Type.Yeti] = new(), + }; + Dictionary mapData = new() + { + [TR2Type.Yeti] = TR2RAlias.BANDIT2B_1 + }; + + TR2RDataCache dataCache = new() + { + PDPFolder = "PDP" + }; + dataCache.Merge(result, pdpData, mapData); + + Assert.IsTrue(pdpData.ContainsKey(TR2Type.TigerOrSnowLeopard)); + Assert.IsFalse(pdpData.ContainsKey(TR2Type.Yeti)); + Assert.IsFalse(mapData.ContainsKey(TR2Type.Yeti)); + } + [TestMethod] [Description("Test importing a TR3 model.")] public void TestTR3Import() @@ -68,6 +154,48 @@ public void TestTR3Import() Assert.IsTrue(level.Models.ContainsKey(TR3Type.Monkey)); } + [TestMethod] + [Description("Test merging TR3R data.")] + public void TestTR3RMerge() + { + ExportTR3Model(TR3Type.Monkey); + ExportTR3RPDP(TR3LevelNames.JUNGLE); + + TR3Level level = GetTR3AltTestLevel(); + level.Models[TR3Type.Dog] = new(); + + TR3DataImporter importer = new() + { + DataFolder = @"Objects\TR3", + Level = level, + TypesToImport = new() { TR3Type.Monkey }, + TypesToRemove = new() { TR3Type.Dog }, + }; + ImportResult result = importer.Import(); + + Assert.IsTrue(result.ImportedTypes.Contains(TR3Type.Monkey)); + Assert.IsTrue(result.RemovedTypes.Contains(TR3Type.Dog)); + + TRDictionary pdpData = new() + { + [TR3Type.Dog] = new(), + }; + Dictionary mapData = new() + { + [TR3Type.Dog] = TR3RAlias.DOG_SEWER + }; + + TR3RDataCache dataCache = new() + { + PDPFolder = "PDP" + }; + dataCache.Merge(result, pdpData, mapData); + + Assert.IsTrue(pdpData.ContainsKey(TR3Type.Monkey)); + Assert.IsFalse(pdpData.ContainsKey(TR3Type.Dog)); + Assert.IsFalse(mapData.ContainsKey(TR3Type.Dog)); + } + [TestMethod] [Description("Test importing a TR4 model.")] public void TestTR4Import() @@ -205,6 +333,14 @@ private static void ExportTR1Model(TR1Type type) } } + private static void ExportTR1RPDP(string levelName) + { + TR1Level level = GetTR1TestLevel(); + TR1PDPControl control = new(); + Directory.CreateDirectory("PDP"); + control.Write(level.Models, Path.Combine("PDP", Path.GetFileNameWithoutExtension(levelName) + ".PDP")); + } + private static void ExportTR2Model(TR2Type type) { TR2Level level = GetTR2TestLevel(); @@ -221,6 +357,14 @@ private static void ExportTR2Model(TR2Type type) } } + private static void ExportTR2RPDP(string levelName) + { + TR2Level level = GetTR2TestLevel(); + TR2PDPControl control = new(); + Directory.CreateDirectory("PDP"); + control.Write(level.Models, Path.Combine("PDP", Path.GetFileNameWithoutExtension(levelName) + ".PDP")); + } + private static void ExportTR3Model(TR3Type type) { TR3Level level = GetTR3TestLevel(); @@ -237,6 +381,14 @@ private static void ExportTR3Model(TR3Type type) } } + private static void ExportTR3RPDP(string levelName) + { + TR3Level level = GetTR3TestLevel(); + TR3PDPControl control = new(); + Directory.CreateDirectory("PDP"); + control.Write(level.Models, Path.Combine("PDP", Path.GetFileNameWithoutExtension(levelName) + ".PDP")); + } + private static void ExportTR4Model(TR4Type type) { TR4Level level = GetTR4TestLevel(); diff --git a/TRLevelControl/Helpers/TR1TypeUtilities.cs b/TRLevelControl/Helpers/TR1TypeUtilities.cs index 91e6b5d7c..cf9da9831 100644 --- a/TRLevelControl/Helpers/TR1TypeUtilities.cs +++ b/TRLevelControl/Helpers/TR1TypeUtilities.cs @@ -562,18 +562,6 @@ public static Dictionary GetSecretModels() }; } - public static TR1Type TranslateSourceType(TR1Type type) - { - return type switch - { - TR1Type.SecretAnkh_M_H => TR1Type.Puzzle4_M_H, - TR1Type.SecretGoldBar_M_H or TR1Type.SecretGoldIdol_M_H => TR1Type.Puzzle1_M_H, - TR1Type.SecretLeadBar_M_H => TR1Type.LeadBar_M_H, - TR1Type.SecretScion_M_H => TR1Type.ScionPiece_M_H, - _ => type, - }; - } - public static Dictionary GetSecretReplacements() { // Note Key1 is omitted because of Pierre diff --git a/TRRandomizerCore/Editors/TR1RemasteredEditor.cs b/TRRandomizerCore/Editors/TR1RemasteredEditor.cs index cf0fafa94..f54a48e4e 100644 --- a/TRRandomizerCore/Editors/TR1RemasteredEditor.cs +++ b/TRRandomizerCore/Editors/TR1RemasteredEditor.cs @@ -17,6 +17,8 @@ protected override void ApplyConfig(Config config) Settings.AllowReturnPathLocations = false; Settings.AddReturnPaths = false; Settings.FixOGBugs = false; + Settings.ReplaceRequiredEnemies = false; + Settings.SwapEnemyAppearance = false; } protected override int GetSaveTarget(int numLevels) @@ -28,6 +30,11 @@ protected override int GetSaveTarget(int numLevels) target += numLevels * 3; } + if (Settings.RandomizeEnemies) + { + target += Settings.CrossLevelEnemies ? numLevels * 3 : numLevels; + } + if (Settings.RandomizeItems) { target += numLevels; @@ -115,6 +122,22 @@ protected override void SaveImpl(AbstractTRScriptEditor scriptEditor, TRSaveMoni }.Randomize(Settings.SecretSeed); } + if (!monitor.IsCancelled && Settings.RandomizeEnemies) + { + monitor.FireSaveStateBeginning(TRSaveCategory.Custom, "Randomizing enemies"); + new TR1REnemyRandomizer + { + ScriptEditor = scriptEditor, + Levels = levels, + BasePath = wipDirectory, + BackupPath = backupDirectory, + SaveMonitor = monitor, + Settings = Settings, + ItemFactory = itemFactory, + DataCache = dataCache + }.Randomize(Settings.EnemySeed); + } + if (!monitor.IsCancelled && Settings.RandomizeItems) { monitor.FireSaveStateBeginning(TRSaveCategory.Custom, "Randomizing standard items"); diff --git a/TRRandomizerCore/Editors/TR2RemasteredEditor.cs b/TRRandomizerCore/Editors/TR2RemasteredEditor.cs index 11004ae25..5c75265b5 100644 --- a/TRRandomizerCore/Editors/TR2RemasteredEditor.cs +++ b/TRRandomizerCore/Editors/TR2RemasteredEditor.cs @@ -1,4 +1,5 @@ -using TRGE.Core; +using TRDataControl; +using TRGE.Core; using TRLevelControl.Model; using TRRandomizerCore.Helpers; using TRRandomizerCore.Randomizers; @@ -16,6 +17,8 @@ protected override void ApplyConfig(Config config) Settings.AllowReturnPathLocations = false; Settings.AddReturnPaths = false; Settings.FixOGBugs = false; + Settings.ReplaceRequiredEnemies = false; + Settings.SwapEnemyAppearance = false; } protected override int GetSaveTarget(int numLevels) @@ -41,6 +44,11 @@ protected override int GetSaveTarget(int numLevels) target += numLevels; } + if (Settings.RandomizeEnemies) + { + target += Settings.CrossLevelEnemies ? numLevels * 3 : numLevels; + } + if (Settings.RandomizeAudio) { target += numLevels; @@ -66,6 +74,11 @@ protected override void SaveImpl(AbstractTRScriptEditor scriptEditor, TRSaveMoni string backupDirectory = _io.BackupDirectory.FullName; string wipDirectory = _io.WIPOutputDirectory.FullName; + TR2RDataCache dataCache = new() + { + PDPFolder = backupDirectory, + }; + ItemFactory itemFactory = new() { DefaultItem = new() { Intensity1 = -1, Intensity2 = -1 } @@ -112,6 +125,22 @@ protected override void SaveImpl(AbstractTRScriptEditor scriptEditor, TRSaveMoni itemRandomizer.Randomize(Settings.ItemSeed); } + if (!monitor.IsCancelled && Settings.RandomizeEnemies) + { + monitor.FireSaveStateBeginning(TRSaveCategory.Custom, "Randomizing enemies"); + new TR2REnemyRandomizer + { + ScriptEditor = scriptEditor, + Levels = levels, + BasePath = wipDirectory, + BackupPath = backupDirectory, + SaveMonitor = monitor, + Settings = Settings, + ItemFactory = itemFactory, + DataCache = dataCache + }.Randomize(Settings.EnemySeed); + } + if (!monitor.IsCancelled && Settings.RandomizeStartPosition) { monitor.FireSaveStateBeginning(TRSaveCategory.Custom, "Randomizing start positions"); diff --git a/TRRandomizerCore/Editors/TR3RemasteredEditor.cs b/TRRandomizerCore/Editors/TR3RemasteredEditor.cs index 523714e96..7aa8e5977 100644 --- a/TRRandomizerCore/Editors/TR3RemasteredEditor.cs +++ b/TRRandomizerCore/Editors/TR3RemasteredEditor.cs @@ -17,6 +17,8 @@ protected override void ApplyConfig(Config config) Settings.AllowReturnPathLocations = false; Settings.AddReturnPaths = false; Settings.FixOGBugs = false; + Settings.ReplaceRequiredEnemies = false; + Settings.SwapEnemyAppearance = false; } protected override int GetSaveTarget(int numLevels) @@ -28,6 +30,11 @@ protected override int GetSaveTarget(int numLevels) target += numLevels * 3; } + if (Settings.RandomizeEnemies) + { + target += Settings.CrossLevelEnemies ? numLevels * 3 : numLevels; + } + if (Settings.RandomizeItems) { target += numLevels; @@ -139,6 +146,22 @@ protected override void SaveImpl(AbstractTRScriptEditor scriptEditor, TRSaveMoni }.Randomize(Settings.SecretRewardsPhysicalSeed); } + if (!monitor.IsCancelled && Settings.RandomizeEnemies) + { + monitor.FireSaveStateBeginning(TRSaveCategory.Custom, "Randomizing enemies"); + new TR3REnemyRandomizer + { + ScriptEditor = scriptEditor, + Levels = levels, + BasePath = wipDirectory, + BackupPath = backupDirectory, + SaveMonitor = monitor, + Settings = Settings, + ItemFactory = itemFactory, + DataCache = dataCache, + }.Randomize(Settings.EnemySeed); + } + if (!monitor.IsCancelled && Settings.RandomizeStartPosition) { monitor.FireSaveStateBeginning(TRSaveCategory.Custom, "Randomizing start positions"); diff --git a/TRRandomizerCore/Randomizers/Shared/EnemyAllocator.cs b/TRRandomizerCore/Randomizers/Shared/EnemyAllocator.cs new file mode 100644 index 000000000..e07dc0074 --- /dev/null +++ b/TRRandomizerCore/Randomizers/Shared/EnemyAllocator.cs @@ -0,0 +1,99 @@ +using TRLevelControl.Model; +using TRRandomizerCore.Editors; +using TRRandomizerCore.Helpers; + +namespace TRRandomizerCore.Randomizers; + +public abstract class EnemyAllocator + where T : Enum +{ + protected Dictionary> _gameEnemyTracker; + protected List _excludedEnemies; + protected HashSet _resultantEnemies; + + public RandomizerSettings Settings { get; set; } + public Random Generator { get; set; } + public IEnumerable GameLevels { get; set; } + + public void Initialise() + { + _resultantEnemies = new(); + _gameEnemyTracker = GetGameTracker(); + _excludedEnemies = Settings.UseEnemyExclusions + ? new(Settings.ExcludedEnemies.Select(s => (T)(object)(uint)s)) + : new(); + } + + public string GetExclusionStatusMessage() + { + if (!Settings.ShowExclusionWarnings) + { + return null; + } + + IEnumerable failedExclusions = _resultantEnemies.Where(_excludedEnemies.Contains); + if (failedExclusions.Any()) + { + List failureNames = failedExclusions.Select(f => Settings.ExcludableEnemies[(short)(uint)(object)f]).ToList(); + failureNames.Sort(); + return string.Format("The following enemies could not be excluded entirely from the randomization pool.{0}{0}{1}", Environment.NewLine, string.Join(Environment.NewLine, failureNames)); + } + + return null; + } + + protected T SelectRequiredEnemy(List pool, string levelName, RandoDifficulty difficulty) + { + pool.RemoveAll(t => !IsEnemySupported(levelName, t, difficulty)); + + if (pool.All(_excludedEnemies.Contains)) + { + // Select the last excluded enemy (lowest priority) + return _excludedEnemies.Last(pool.Contains); + } + + T type; + do + { + type = pool[Generator.Next(0, pool.Count)]; + } + while (_excludedEnemies.Contains(type)); + + return type; + } + + protected RandoDifficulty GetImpliedDifficulty() + { + if (_excludedEnemies.Count > 0 && Settings.RandoEnemyDifficulty == RandoDifficulty.Default) + { + // If every enemy in the pool has room restrictions for any level, we have to imply NoRestrictions difficulty mode + List includedEnemies = Settings.ExcludableEnemies.Keys.Except(Settings.ExcludedEnemies).Select(s => (T)(object)(uint)s).ToList(); + foreach (string level in GameLevels) + { + IEnumerable restrictedRoomEnemies = GetRestrictedRooms(level.ToUpper(), RandoDifficulty.Default).Keys; + if (includedEnemies.All(e => restrictedRoomEnemies.Contains(e) || _gameEnemyTracker.ContainsKey(e))) + { + return RandoDifficulty.NoRestrictions; + } + } + } + return Settings.RandoEnemyDifficulty; + } + + protected void SetOneShot(E entity, int index, FDControl floorData) + where E : TREntity + { + if (!IsOneShotType(entity.TypeID)) + { + return; + } + + floorData.GetEntityTriggers(index) + .ForEach(t => t.OneShot = true); + } + + protected abstract Dictionary> GetGameTracker(); + protected abstract bool IsEnemySupported(string levelName, T type, RandoDifficulty difficulty); + protected abstract Dictionary> GetRestrictedRooms(string levelName, RandoDifficulty difficulty); + protected abstract bool IsOneShotType(T type); +} diff --git a/TRRandomizerCore/Randomizers/Shared/EnemyCollections.cs b/TRRandomizerCore/Randomizers/Shared/EnemyCollections.cs new file mode 100644 index 000000000..576a026d5 --- /dev/null +++ b/TRRandomizerCore/Randomizers/Shared/EnemyCollections.cs @@ -0,0 +1,20 @@ +namespace TRRandomizerCore.Randomizers; + +public class EnemyTransportCollection + where T : Enum +{ + public List TypesToImport { get; set; } = new(); + public List TypesToRemove { get; set; } = new(); + public T BirdMonsterGuiser { get; set; } + public bool ImportResult { get; set; } +} + +public class EnemyRandomizationCollection + where T : Enum +{ + public List Available { get; set; } = new(); + public List Droppable { get; set; } = new(); + public List Water { get; set; } = new(); + public List All { get; set; } = new(); + public T BirdMonsterGuiser { get; set; } +} diff --git a/TRRandomizerCore/Randomizers/Shared/RandoConsts.cs b/TRRandomizerCore/Randomizers/Shared/RandoConsts.cs index 83065dd51..fbe2ac867 100644 --- a/TRRandomizerCore/Randomizers/Shared/RandoConsts.cs +++ b/TRRandomizerCore/Randomizers/Shared/RandoConsts.cs @@ -5,4 +5,7 @@ public static class RandoConsts public const uint DarknessRange = 10; // 0 = Dusk, 10 = Night public const short DarknessIntensity1 = 8000; public const ushort DarknessIntensity2 = 1000; + + public const int TRRTexLimit = 4096; + public const int TRRTileLimit = 32; } diff --git a/TRRandomizerCore/Randomizers/TR1/Classic/TR1EnemyRandomizer.cs b/TRRandomizerCore/Randomizers/TR1/Classic/TR1EnemyRandomizer.cs index 54cfb3fdf..cb5a7bc73 100644 --- a/TRRandomizerCore/Randomizers/TR1/Classic/TR1EnemyRandomizer.cs +++ b/TRRandomizerCore/Randomizers/TR1/Classic/TR1EnemyRandomizer.cs @@ -1,8 +1,4 @@ -using Newtonsoft.Json; -using System.Diagnostics; -using System.Numerics; -using TRDataControl; -using TRDataControl.Environment; +using TRDataControl; using TRGE.Core; using TRLevelControl; using TRLevelControl.Helpers; @@ -18,46 +14,23 @@ namespace TRRandomizerCore.Randomizers; public class TR1EnemyRandomizer : BaseTR1Randomizer { public static readonly uint MaxClones = 8; - private static readonly EnemyTransportCollection _emptyEnemies = new() - { - TypesToImport = new(), - TypesToRemove = new() - }; - private static readonly int _unkillableEgyptMummy = 163; - private static readonly Location _egyptMummyLocation = new() - { - X = 66048, - Y = -2304, - Z = 73216, - Room = 78 - }; - - private static readonly int _unreachableStrongholdRoom = 18; - private static readonly Location _strongholdCentaurLocation = new() - { - X = 57856, - Y = -26880, - Z = 43520, - Room = 14 - }; - - private Dictionary> _gameEnemyTracker; - private Dictionary> _pistolLocations; - private Dictionary> _eggLocations; - private Dictionary> _pierreLocations; - private List _excludedEnemies; - private ISet _resultantEnemies; + private TR1EnemyAllocator _allocator; internal TR1TextureMonitorBroker TextureMonitor { get; set; } public ItemFactory ItemFactory { get; set; } public override void Randomize(int seed) { - _generator = new Random(seed); - _pistolLocations = JsonConvert.DeserializeObject>>(ReadResource(@"TR1\Locations\unarmed_locations.json")); - _eggLocations = JsonConvert.DeserializeObject>>(ReadResource(@"TR1\Locations\egg_locations.json")); - _pierreLocations = JsonConvert.DeserializeObject>>(ReadResource(@"TR1\Locations\pierre_locations.json")); + _generator = new(seed); + _allocator = new() + { + Settings = Settings, + ItemFactory = ItemFactory, + Generator = _generator, + GameLevels = Levels.Select(l => l.LevelFileBaseName), + }; + _allocator.Initialise(); if (Settings.CrossLevelEnemies) { @@ -78,20 +51,13 @@ public override void Randomize(int seed) private void RandomizeExistingEnemies() { - _excludedEnemies = new List(); - _resultantEnemies = new HashSet(); - foreach (TR1ScriptedLevel lvl in Levels) { - //Read the level into a combined data/script level object LoadLevelInstance(lvl); + EnemyRandomizationCollection enemies = _allocator.RandomizeEnemiesNatively(_levelInstance.Name, _levelInstance.Data); + ApplyPostRandomization(_levelInstance, enemies); - //Apply the modifications - RandomizeEnemiesNatively(_levelInstance); - - //Write back the level file SaveLevelInstance(); - if (!TriggerProgress()) { break; @@ -126,979 +92,213 @@ private void RandomizeEnemiesCrossLevel() processorIndex = processorIndex == _maxThreads - 1 ? 0 : processorIndex + 1; } - // Track enemies whose counts across the game are restricted - _gameEnemyTracker = TR1EnemyUtilities.PrepareEnemyGameTracker(Settings.RandoEnemyDifficulty, Levels.Select(l => l.Name)); - - // #272 Selective enemy pool - convert the shorts in the settings to actual entity types - _excludedEnemies = Settings.UseEnemyExclusions ? - Settings.ExcludedEnemies.Select(s => (TR1Type)s).ToList() : - new List(); - _resultantEnemies = new HashSet(); - SetMessage("Randomizing enemies - importing models"); - foreach (EnemyProcessor processor in processors) - { - processor.Start(); - } - - foreach (EnemyProcessor processor in processors) - { - processor.Join(); - } + processors.ForEach(p => p.Start()); + processors.ForEach(p => p.Join()); if (!SaveMonitor.IsCancelled && _processingException == null) { SetMessage("Randomizing enemies - saving levels"); - foreach (EnemyProcessor processor in processors) - { - processor.ApplyRandomization(); - } + processors.ForEach(p => p.ApplyRandomization()); } _processingException?.Throw(); - // If any exclusions failed to be avoided, send a message - if (Settings.ShowExclusionWarnings) + string statusMessage = _allocator.GetExclusionStatusMessage(); + if (statusMessage != null) { - VerifyExclusionStatus(); + SetWarning(statusMessage); } } - private void VerifyExclusionStatus() + private void RandomizeEnemies(TR1CombinedLevel level, EnemyRandomizationCollection enemies) { - List failedExclusions = _resultantEnemies.ToList().FindAll(_excludedEnemies.Contains); - if (failedExclusions.Count > 0) - { - // A little formatting - List failureNames = new(); - foreach (TR1Type entity in failedExclusions) - { - failureNames.Add(Settings.ExcludableEnemies[(short)entity]); - } - failureNames.Sort(); - SetWarning(string.Format("The following enemies could not be excluded entirely from the randomization pool.{0}{0}{1}", Environment.NewLine, string.Join(Environment.NewLine, failureNames))); - } - } + level.Script.ItemDrops.Clear(); - private void AdjustUnkillableEnemies(TR1CombinedLevel level) - { - if (level.Is(TR1LevelNames.EGYPT)) - { - // The OG mummy normally falls out of sight when triggered, so move it. - level.Data.Entities[_unkillableEgyptMummy].SetLocation(_egyptMummyLocation); - } - else if (level.Is(TR1LevelNames.STRONGHOLD)) - { - // There is a triggered centaur in room 18, plus several untriggered eggs for show. - // Move the centaur, and free the eggs to be repurposed elsewhere. - foreach (TR1Entity enemy in level.Data.Entities.Where(e => e.Room == _unreachableStrongholdRoom)) - { - int index = level.Data.Entities.IndexOf(enemy); - if (level.Data.FloorData.GetEntityTriggers(index).Count == 0) - { - enemy.TypeID = TR1Type.CameraTarget_N; - ItemFactory.FreeItem(level.Name, index); - } - else - { - enemy.SetLocation(_strongholdCentaurLocation); - } - } - } + _allocator.RandomizeEnemies(level.Name, level.Data, enemies); - level.Script.UnobtainableKills = null; + ApplyPostRandomization(level, enemies); } - private EnemyTransportCollection SelectCrossLevelEnemies(TR1CombinedLevel level) + private void ApplyPostRandomization(TR1CombinedLevel level, EnemyRandomizationCollection enemies) { - // For the assault course, nothing will be imported for the time being - if (level.IsAssault) - { - return null; - } - - AdjustUnkillableEnemies(level); - - if (Settings.UseEnemyClones && Settings.CloneOriginalEnemies) - { - // Skip import altogether for OG clone mode - return _emptyEnemies; - } - - // If level-ending Larson is disabled, we make an alternative ending to ToQ. - // Do this at this stage as it effectively gets rid of ToQ-Larson meaning - // Sanctuary-Larson can potentially be imported. - if (level.Is(TR1LevelNames.QUALOPEC) && Settings.ReplaceRequiredEnemies) - { - AmendToQLarson(level); - } - - if (level.IsExpansion) - { - // Ensure big eggs are randomized by converting to normal ones because - // big eggs are never part of the enemy pool. - level.Data.Entities.FindAll(e => e.TypeID == TR1Type.AdamEgg) - .ForEach(e => e.TypeID = TR1Type.AtlanteanEgg); - } - - RandoDifficulty difficulty = GetImpliedDifficulty(); - - // Get the list of enemy types currently in the level - List oldEntities = GetCurrentEnemyEntities(level); - - // Get the list of canidadates - List allEnemies = TR1TypeUtilities.GetCandidateCrossLevelEnemies(); - - // Work out how many we can support - int enemyCount = oldEntities.Count + TR1EnemyUtilities.GetEnemyAdjustmentCount(level.Name); - if (level.Is(TR1LevelNames.QUALOPEC) && Settings.ReplaceRequiredEnemies) - { - // Account for Larson having been removed above. - ++enemyCount; - } - List newEntities = new(enemyCount); - - // TR1 doesn't kill land creatures when underwater, so if "no restrictions" is - // enabled, don't enforce any by default. - bool waterEnemyRequired = difficulty == RandoDifficulty.Default - && TR1TypeUtilities.GetWaterEnemies().Any(oldEntities.Contains); - - // Let's try to populate the list. Start by adding a water enemy if needed. - if (waterEnemyRequired) - { - List waterEnemies = TR1TypeUtilities.GetWaterEnemies(); - newEntities.Add(SelectRequiredEnemy(waterEnemies, level, difficulty)); - } - - // Are there any other types we need to retain? - if (!Settings.ReplaceRequiredEnemies) - { - foreach (TR1Type entity in TR1EnemyUtilities.GetRequiredEnemies(level.Name)) - { - if (!newEntities.Contains(entity)) - { - newEntities.Add(entity); - } - } - } - - // Remove all exclusions from the pool, and adjust the target capacity - allEnemies.RemoveAll(e => _excludedEnemies.Contains(e)); - - IEnumerable ex = allEnemies.Where(e => !newEntities.Any(TR1TypeUtilities.GetFamily(e).Contains)); - List unalisedEntities = TR1TypeUtilities.RemoveAliases(ex); - while (unalisedEntities.Count < newEntities.Capacity - newEntities.Count) - { - --newEntities.Capacity; - } - - // Fill the list from the remaining candidates. Keep track of ones tested to avoid - // looping infinitely if it's not possible to fill to capacity - ISet testedEntities = new HashSet(); - List eggEntities = TR1TypeUtilities.GetAtlanteanEggEnemies(); - while (newEntities.Count < newEntities.Capacity && testedEntities.Count < allEnemies.Count) - { - TR1Type entity = allEnemies[_generator.Next(0, allEnemies.Count)]; - testedEntities.Add(entity); - - // Make sure this isn't known to be unsupported in the level - if (!TR1EnemyUtilities.IsEnemySupported(level.Name, entity, difficulty)) - { - continue; - } - - // Atlanteans and mummies are complex creatures. Grounded ones require the flyer for meshes - // so we can't have a grounded mummy and meaty flyer, or vice versa as a result. - if (entity == TR1Type.BandagedAtlantean && newEntities.Contains(TR1Type.MeatyFlyer) && !newEntities.Contains(TR1Type.MeatyAtlantean)) - { - entity = TR1Type.MeatyAtlantean; - } - else if (entity == TR1Type.MeatyAtlantean && newEntities.Contains(TR1Type.BandagedFlyer) && !newEntities.Contains(TR1Type.BandagedAtlantean)) - { - entity = TR1Type.BandagedAtlantean; - } - else if (entity == TR1Type.BandagedFlyer && newEntities.Contains(TR1Type.MeatyAtlantean)) - { - continue; - } - else if (entity == TR1Type.MeatyFlyer && newEntities.Contains(TR1Type.BandagedAtlantean)) - { - continue; - } - else if (entity == TR1Type.AtlanteanEgg && !newEntities.Any(eggEntities.Contains)) - { - // Try to pick a type in the inclusion list if possible - List preferredEggTypes = eggEntities.FindAll(allEnemies.Contains); - if (preferredEggTypes.Count == 0) - { - preferredEggTypes = eggEntities; - } - TR1Type eggType = preferredEggTypes[_generator.Next(0, preferredEggTypes.Count)]; - newEntities.Add(eggType); - testedEntities.Add(eggType); - } - - // If this is a tracked enemy throughout the game, we only allow it if the number - // of unique levels is within the limit. Bear in mind we are collecting more than - // one group of enemies per level. - if (_gameEnemyTracker.ContainsKey(entity) && !_gameEnemyTracker[entity].Contains(level.Name)) - { - if (_gameEnemyTracker[entity].Count < _gameEnemyTracker[entity].Capacity) - { - // The entity is allowed, so store the fact that this level will have it - _gameEnemyTracker[entity].Add(level.Name); - } - else - { - // Otherwise, pick something else. If we tried to previously exclude this - // enemy and couldn't, it will slip through the net and so the appearances - // will increase. - if (allEnemies.Except(newEntities).Count() > 1) - { - continue; - } - } - } - - // GetEntityFamily returns all aliases for the likes of the dogs, but if an entity - // doesn't have any, the returned list just contains the entity itself. This means - // we can avoid duplicating standard enemies as well as avoiding alias-clashing. - List family = TR1TypeUtilities.GetFamily(entity); - if (!newEntities.Any(e1 => family.Any(e2 => e1 == e2))) - { - newEntities.Add(entity); - } - } - - if - ( - newEntities.All(e => TR1TypeUtilities.IsWaterCreature(e) || TR1EnemyUtilities.IsEnemyRestricted(level.Name, e, difficulty)) || - (newEntities.Capacity > 1 && newEntities.All(e => TR1EnemyUtilities.IsEnemyRestricted(level.Name, e, difficulty))) - ) + if (enemies == null) { - // Make sure we have an unrestricted enemy available for the individual level conditions. This will - // guarantee a "safe" enemy for the level; we avoid aliases here to avoid further complication. - bool RestrictionCheck(TR1Type e) => - !TR1EnemyUtilities.IsEnemySupported(level.Name, e, difficulty) - || newEntities.Contains(e) - || TR1TypeUtilities.IsWaterCreature(e) - || TR1EnemyUtilities.IsEnemyRestricted(level.Name, e, difficulty) - || TR1TypeUtilities.TranslateAlias(e) != e; - - List unrestrictedPool = allEnemies.FindAll(e => !RestrictionCheck(e)); - if (unrestrictedPool.Count == 0) - { - // We are going to have to pull in the full list of candidates again, so ignoring any exclusions - unrestrictedPool = TR1TypeUtilities.GetCandidateCrossLevelEnemies().FindAll(e => !RestrictionCheck(e)); - } - - TR1Type entity = unrestrictedPool[_generator.Next(0, unrestrictedPool.Count)]; - newEntities.Add(entity); - - if (entity == TR1Type.AtlanteanEgg && !newEntities.Any(eggEntities.Contains)) - { - // Try to pick a type in the inclusion list if possible - List preferredEggTypes = eggEntities.FindAll(allEnemies.Contains); - if (preferredEggTypes.Count == 0) - { - preferredEggTypes = eggEntities; - } - TR1Type eggType = preferredEggTypes[_generator.Next(0, preferredEggTypes.Count)]; - newEntities.Add(eggType); - } - } - - if (level.Is(TR1LevelNames.PYRAMID) && Settings.ReplaceRequiredEnemies && !newEntities.Contains(TR1Type.Adam)) - { - AmendPyramidTorso(level); - } - - if (Settings.DevelopmentMode) - { - Debug.WriteLine(level.Name + ": " + string.Join(", ", newEntities)); + return; } - return new EnemyTransportCollection - { - TypesToImport = newEntities, - TypesToRemove = oldEntities - }; - } + level.Script.UnobtainableKills = null; - private static List GetCurrentEnemyEntities(TR1CombinedLevel level) - { - List allGameEnemies = TR1TypeUtilities.GetFullListOfEnemies(); - ISet allLevelEnts = new SortedSet(); - level.Data.Entities.ForEach(e => allLevelEnts.Add(e.TypeID)); - List oldEntities = allLevelEnts.ToList().FindAll(e => allGameEnemies.Contains(e)); - return oldEntities; + FixColosseumBats(level); + AdjustTihocanEnding(level); + FixEnemyAnimations(level); + CloneEnemies(level); + AddUnarmedLevelAmmo(level); + RandomizeMeshes(level, enemies.Available); } - private TR1Type SelectRequiredEnemy(List pool, TR1CombinedLevel level, RandoDifficulty difficulty) + private void FixColosseumBats(TR1CombinedLevel level) { - pool.RemoveAll(e => !TR1EnemyUtilities.IsEnemySupported(level.Name, e, difficulty)); - - TR1Type entity; - if (pool.All(_excludedEnemies.Contains)) - { - // Select the last excluded enemy (lowest priority) - entity = _excludedEnemies.Last(e => pool.Contains(e)); - } - else + if (!level.Is(TR1LevelNames.COLOSSEUM) || !Settings.FixOGBugs) { - do - { - entity = pool[_generator.Next(0, pool.Count)]; - } - while (_excludedEnemies.Contains(entity)); + return; } - return entity; - } - - private RandoDifficulty GetImpliedDifficulty() - { - if (_excludedEnemies.Count > 0 && Settings.RandoEnemyDifficulty == RandoDifficulty.Default) + // Fix the bat trigger in Colosseum. Done outside of environment mods to allow for cloning. + // Item 74 is duplicated in each trigger. + foreach (FDTriggerEntry trigger in level.Data.FloorData.GetEntityTriggers(74)) { - // If every enemy in the pool has room restrictions for any level, we have to imply NoRestrictions difficulty mode - List includedEnemies = Settings.ExcludableEnemies.Keys.Except(Settings.ExcludedEnemies).Select(s => (TR1Type)s).ToList(); - foreach (TR1ScriptedLevel level in Levels) + List actions = trigger.Actions + .FindAll(a => a.Action == FDTrigAction.Object && a.Parameter == 74); + if (actions.Count == 2) { - IEnumerable restrictedRoomEnemies = TR1EnemyUtilities.GetRestrictedEnemyRooms(level.LevelFileBaseName.ToUpper(), RandoDifficulty.Default).Keys; - if (includedEnemies.All(e => restrictedRoomEnemies.Contains(e) || _gameEnemyTracker.ContainsKey(e))) - { - return RandoDifficulty.NoRestrictions; - } + actions[0].Parameter = 73; } } - return Settings.RandoEnemyDifficulty; } - private void RandomizeEnemiesNatively(TR1CombinedLevel level) + private void AdjustTihocanEnding(TR1CombinedLevel level) { - // For the assault course, nothing will be changed for the time being - if (level.IsAssault) + if (!level.Is(TR1LevelNames.TIHOCAN) || (Settings.RandomizeItems && Settings.IncludeKeyItems)) { return; } - AdjustUnkillableEnemies(level); - EnemyRandomizationCollection enemies = new() + TR1Entity pierreReplacement = level.Data.Entities[TR1ItemRandomizer.TihocanPierreIndex]; + if (Settings.AllowEnemyKeyDrops + && TR1EnemyUtilities.CanDropItems(pierreReplacement, level)) { - Available = new(), - Water = new() - }; - - if (!Settings.UseEnemyClones || !Settings.CloneOriginalEnemies) - { - enemies.Available.AddRange(GetCurrentEnemyEntities(level)); - enemies.Water.AddRange(TR1TypeUtilities.FilterWaterEnemies(enemies.Available)); - } - - RandomizeEnemies(level, enemies); - } - - private void RandomizeEnemies(TR1CombinedLevel level, EnemyRandomizationCollection enemies) - { - AmendAtlanteanModels(level, enemies); - - // Clear all default enemy item drops - level.Script.ItemDrops.Clear(); - - // Get a list of current enemy entities - List allEnemies = TR1TypeUtilities.GetFullListOfEnemies(); - List enemyEntities = level.Data.Entities.FindAll(e => allEnemies.Contains(e.TypeID)); - - RandoDifficulty difficulty = GetImpliedDifficulty(); - - // First iterate through any enemies that are restricted by room - Dictionary> enemyRooms = TR1EnemyUtilities.GetRestrictedEnemyRooms(level.Name, difficulty); - if (enemyRooms != null) - { - foreach (TR1Type entity in enemyRooms.Keys) + // Whichever enemy has taken Pierre's place will drop the items. Move the pickups to the enemy for trview lookup. + level.Script.AddItemDrops(TR1ItemRandomizer.TihocanPierreIndex, TR1ItemRandomizer.TihocanEndItems + .Select(e => ItemUtilities.ConvertToScriptItem(e.TypeID))); + foreach (TR1Entity drop in TR1ItemRandomizer.TihocanEndItems) { - if (!enemies.Available.Contains(entity)) - { - continue; - } - - List rooms = enemyRooms[entity]; - int maxEntityCount = TR1EnemyUtilities.GetRestrictedEnemyLevelCount(entity, difficulty); - if (maxEntityCount == -1) - { - // We are allowed any number, but this can't be more than the number of unique rooms, - // so we will assume 1 per room as these restricted enemies are likely to be tanky. - maxEntityCount = rooms.Count; - } - else + level.Data.Entities.Add(new() { - maxEntityCount = Math.Min(maxEntityCount, rooms.Count); - } - - // Pick an actual count - int enemyCount = _generator.Next(1, maxEntityCount + 1); - for (int i = 0; i < enemyCount; i++) - { - // Find an entity in one of the rooms that the new enemy is restricted to - TR1Entity targetEntity = null; - do - { - int room = enemyRooms[entity][_generator.Next(0, enemyRooms[entity].Count)]; - targetEntity = enemyEntities.Find(e => e.Room == room); - } - while (targetEntity == null); - - // If the room has water but this enemy isn't a water enemy, we will assume that environment - // modifications will handle assignment of the enemy to entities. - if (!TR1TypeUtilities.IsWaterCreature(entity) && level.Data.Rooms[targetEntity.Room].ContainsWater) - { - continue; - } - - targetEntity.TypeID = TR1TypeUtilities.TranslateAlias(entity); - - // #146 Ensure OneShot triggers are set for this enemy if needed - TR1EnemyUtilities.SetEntityTriggers(level.Data, targetEntity); - - if (Settings.HideEnemiesUntilTriggered || entity == TR1Type.Adam) - { - targetEntity.Invisible = true; - } - - // Remove the target entity so it doesn't get replaced - enemyEntities.Remove(targetEntity); - } - - // Remove this entity type from the available rando pool - enemies.Available.Remove(entity); + TypeID = drop.TypeID, + X = pierreReplacement.X, + Y = pierreReplacement.Y, + Z = pierreReplacement.Z, + Room = pierreReplacement.Room, + }); + ItemUtilities.HideEntity(level.Data.Entities[^1]); } } - - foreach (TR1Entity currentEntity in enemyEntities) + else { - if (enemies.Available.Count == 0) - { - continue; - } - - int entityIndex = level.Data.Entities.IndexOf(currentEntity); - TR1Type currentEntityType = currentEntity.TypeID; - TR1Type newEntityType = currentEntityType; - - // If it's an existing enemy that has to remain in the same spot, skip it - if (!Settings.ReplaceRequiredEnemies && TR1EnemyUtilities.IsEnemyRequired(level.Name, currentEntityType)) - { - _resultantEnemies.Add(currentEntityType); - continue; - } - - List enemyPool; - if (difficulty == RandoDifficulty.Default && IsEnemyInOrAboveWater(currentEntity, level.Data)) - { - // Make sure we replace with another water enemy - enemyPool = enemies.Water; - } - else - { - // Otherwise we can pick any other available enemy - enemyPool = enemies.Available; - } - - // Pick a new type - newEntityType = enemyPool[_generator.Next(0, enemyPool.Count)]; - - // If we are restricting count per level for this enemy and have reached that count, pick - // something else. This applies when we are restricting by in-level count, but not by room - // (e.g. Kold, SkateboardKid). - int maxEntityCount = TR1EnemyUtilities.GetRestrictedEnemyLevelCount(newEntityType, difficulty); - if (maxEntityCount != -1) - { - if (GetEntityCount(level, newEntityType) >= maxEntityCount) - { - List pool = enemyPool.FindAll(e => !TR1EnemyUtilities.IsEnemyRestricted(level.Name, TR1TypeUtilities.TranslateAlias(e))); - if (pool.Count > 0) - { - newEntityType = pool[_generator.Next(0, pool.Count)]; - } - } - } - - // Rather than individual enemy limits, this accounts for enemy groups such as all Atlanteans - RandoDifficulty groupDifficulty = difficulty; - if (level.Is(TR1LevelNames.QUALOPEC) && newEntityType == TR1Type.Larson && Settings.ReplaceRequiredEnemies) - { - // Non-level ending Larson is not restricted in ToQ, otherwise we adhere to the normal rules. - groupDifficulty = RandoDifficulty.NoRestrictions; - } - RestrictedEnemyGroup enemyGroup = TR1EnemyUtilities.GetRestrictedEnemyGroup(level.Name, TR1TypeUtilities.TranslateAlias(newEntityType), groupDifficulty); - if (enemyGroup != null) - { - if (level.Data.Entities.FindAll(e => enemyGroup.Enemies.Contains(e.TypeID)).Count >= enemyGroup.MaximumCount) - { - List pool = enemyPool.FindAll(e => !TR1EnemyUtilities.IsEnemyRestricted(level.Name, TR1TypeUtilities.TranslateAlias(e), groupDifficulty)); - if (pool.Count > 0) - { - newEntityType = pool[_generator.Next(0, pool.Count)]; - } - } - } - - // Tomp1 switches rats/crocs automatically if a room is flooded or drained. But we may have added a normal - // land enemy to a room that eventually gets flooded. So in default difficulty, ensure the entity is a - // hybrid, otherwise allow land creatures underwater (which works, but is obviously more difficult). - if (difficulty == RandoDifficulty.Default) - { - TR1Room currentRoom = level.Data.Rooms[currentEntity.Room]; - if (currentRoom.AlternateRoom != -1 && level.Data.Rooms[currentRoom.AlternateRoom].ContainsWater && TR1TypeUtilities.IsWaterLandCreatureEquivalent(currentEntityType) && !TR1TypeUtilities.IsWaterLandCreatureEquivalent(newEntityType)) - { - Dictionary hybrids = TR1TypeUtilities.GetWaterEnemyLandCreatures(); - List pool = enemies.Available.FindAll(e => hybrids.ContainsKey(e) || hybrids.ContainsValue(e)); - if (pool.Count > 0) - { - newEntityType = TR1TypeUtilities.GetWaterEnemyLandCreature(pool[_generator.Next(0, pool.Count)]); - } - } - } - - if (Settings.HideEnemiesUntilTriggered) - { - // Default to hiding the enemy - checks below for eggs, ex-eggs, Adam and centaur - // statues will override as necessary. - currentEntity.Invisible = true; - } - - if (newEntityType == TR1Type.AtlanteanEgg) - { - List allEggTypes = TR1TypeUtilities.GetAtlanteanEggEnemies(); - List spawnTypes = enemies.Available.FindAll(allEggTypes.Contains); - TR1Type spawnType = TR1TypeUtilities.TranslateAlias(spawnTypes[_generator.Next(0, spawnTypes.Count)]); - - Location eggLocation = _eggLocations.ContainsKey(level.Name) - ? _eggLocations[level.Name].Find(l => l.EntityIndex == entityIndex) - : null; - - if (eggLocation != null || currentEntityType == newEntityType) - { - if (Settings.AllowEmptyEggs) - { - // Add 1/4 chance of an empty egg, provided at least one spawn model is not available - List allModels = level.Data.Models.Keys.ToList(); - - // We can add Adam to make it possible for a dud spawn - he's not normally available for eggs because - // of his own restrictions. - if (!allModels.Contains(TR1Type.Adam)) - { - allEggTypes.Add(TR1Type.Adam); - } - - if (!allEggTypes.All(e => allModels.Contains(TR1TypeUtilities.TranslateAlias(e))) && _generator.NextDouble() < 0.25) - { - do - { - spawnType = TR1TypeUtilities.TranslateAlias(allEggTypes[_generator.Next(0, allEggTypes.Count)]); - } - while (allModels.Contains(spawnType)); - } - } - - currentEntity.CodeBits = TR1EnemyUtilities.AtlanteanToCodeBits(spawnType); - if (eggLocation != null) - { - currentEntity.X = eggLocation.X; - currentEntity.Y = eggLocation.Y; - currentEntity.Z = eggLocation.Z; - currentEntity.Angle = eggLocation.Angle; - currentEntity.Room = eggLocation.Room; - } - - // Eggs will always be visible - currentEntity.Invisible = false; - } - else - { - // We don't want an egg for this particular enemy, so just make it spawn as the actual type - newEntityType = spawnType; - } - } - else if (currentEntityType == TR1Type.AtlanteanEgg) - { - // Hide what used to be eggs and reset the CodeBits otherwise this can interfere with trigger masks. - currentEntity.Invisible = true; - currentEntity.CodeBits = 0; - } - - if (newEntityType == TR1Type.CentaurStatue) - { - AdjustCentaurStatue(currentEntity, level.Data); - } - else if (newEntityType == TR1Type.Adam) - { - // Adam should always be invisible as he is inactive high above the ground - // so this can interfere with Lara's route - see Cistern item 36 - currentEntity.Invisible = true; - } - - // Make sure to convert back to the actual type - currentEntity.TypeID = TR1TypeUtilities.TranslateAlias(newEntityType); - - // #146 Ensure OneShot triggers are set for this enemy if needed - TR1EnemyUtilities.SetEntityTriggers(level.Data, currentEntity); - - if (currentEntity.TypeID == TR1Type.Pierre - && _pierreLocations.ContainsKey(level.Name) - && _pierreLocations[level.Name].Find(l => l.EntityIndex == entityIndex) is Location location) - { - // Pierre is the only enemy who cannot be underwater, so location shifts have been predefined - // for specific entities. - currentEntity.SetLocation(location); - } - - // Track every enemy type across the game - _resultantEnemies.Add(newEntityType); + // Add Pierre's pickups in a default place. Allows pacifist runs effectively. + level.Data.Entities.AddRange(TR1ItemRandomizer.TihocanEndItems); } + } - if (level.Is(TR1LevelNames.COLOSSEUM) && Settings.FixOGBugs) + private void FixEnemyAnimations(TR1CombinedLevel level) + { + // Model transport will handle these missing SFX by default, but we need to fix them in + // the levels where these enemies already exist. + if (level.Data.Models.ContainsKey(TR1Type.Pierre) + && (level.Is(TR1LevelNames.FOLLY) || level.Is(TR1LevelNames.COLOSSEUM) || level.Is(TR1LevelNames.CISTERN) || level.Is(TR1LevelNames.TIHOCAN))) { - FixColosseumBats(level); - } + TR1DataExporter.AmendPierreGunshot(level.Data); + TR1DataExporter.AmendPierreDeath(level.Data); - if (level.Is(TR1LevelNames.TIHOCAN) && (!Settings.RandomizeItems || !Settings.IncludeKeyItems)) - { - TR1Entity pierreReplacement = level.Data.Entities[TR1ItemRandomizer.TihocanPierreIndex]; - if (Settings.AllowEnemyKeyDrops - && TR1EnemyUtilities.CanDropItems(pierreReplacement, level)) - { - // Whichever enemy has taken Pierre's place will drop the items. Move the pickups to the enemy for trview lookup. - level.Script.AddItemDrops(TR1ItemRandomizer.TihocanPierreIndex, TR1ItemRandomizer.TihocanEndItems - .Select(e => ItemUtilities.ConvertToScriptItem(e.TypeID))); - foreach (TR1Entity drop in TR1ItemRandomizer.TihocanEndItems) - { - level.Data.Entities.Add(new() - { - TypeID = drop.TypeID, - X = pierreReplacement.X, - Y = pierreReplacement.Y, - Z = pierreReplacement.Z, - Room = pierreReplacement.Room, - }); - ItemUtilities.HideEntity(level.Data.Entities[^1]); - } - } - else + // Non one-shot-Pierre levels won't have the death sound by default, so borrow it from ToT. + if (!level.Data.SoundEffects.ContainsKey(TR1SFX.PierreDeath)) { - // Add Pierre's pickups in a default place. Allows pacifist runs effectively. - level.Data.Entities.AddRange(TR1ItemRandomizer.TihocanEndItems); + TR1Level tihocan = new TR1LevelControl().Read(Path.Combine(BackupPath, TR1LevelNames.TIHOCAN)); + level.Data.SoundEffects[TR1SFX.PierreDeath] = tihocan.SoundEffects[TR1SFX.PierreDeath]; } } - // Fix missing OG animation SFX - FixEnemyAnimations(level); - - if (Settings.UseEnemyClones) - { - CloneEnemies(level); - } - - // Add extra ammo based on this level's difficulty - if (Settings.CrossLevelEnemies && level.Script.RemovesWeapons) - { - AddUnarmedLevelAmmo(level); - } - - if (Settings.SwapEnemyAppearance) - { - RandomizeMeshes(level, enemies.Available); - } - } - - private static int GetEntityCount(TR1CombinedLevel level, TR1Type entityType) - { - int count = 0; - TR1Type translatedType = TR1TypeUtilities.TranslateAlias(entityType); - foreach (TR1Entity entity in level.Data.Entities) + if (level.Data.Models.ContainsKey(TR1Type.Larson) && level.Is(TR1LevelNames.SANCTUARY)) { - TR1Type type = entity.TypeID; - if (type == translatedType) - { - count++; - } - else if (type == TR1Type.AdamEgg || type == TR1Type.AtlanteanEgg) - { - TR1Type eggType = TR1EnemyUtilities.CodeBitsToAtlantean(entity.CodeBits); - if (eggType == translatedType && level.Data.Models.ContainsKey(eggType)) - { - count++; - } - } + TR1DataExporter.AmendLarsonDeath(level.Data); } - return count; - } - private static bool IsEnemyInOrAboveWater(TR1Entity entity, TR1Level level) - { - if (level.Rooms[entity.Room].ContainsWater) + if (level.Data.Models.ContainsKey(TR1Type.SkateboardKid) && level.Is(TR1LevelNames.MINES)) { - return true; + TR1DataExporter.AmendSkaterBoyDeath(level.Data); } - // Example where we have to search is Midas room 21 - TRRoomSector sector = level.GetRoomSector(entity.X, entity.Y - TRConsts.Step1, entity.Z, entity.Room); - while (sector.RoomBelow != TRConsts.NoRoom) + if (level.Data.Models.ContainsKey(TR1Type.Natla) && level.Is(TR1LevelNames.PYRAMID)) { - if (level.Rooms[sector.RoomBelow].ContainsWater) - { - return true; - } - sector = level.GetRoomSector(entity.X, (sector.Floor + 1) * TRConsts.Step1, entity.Z, sector.RoomBelow); + TR1DataExporter.AmendNatlaDeath(level.Data); } - return false; } - private static void AmendToQLarson(TR1CombinedLevel level) + private void CloneEnemies(TR1CombinedLevel level) { - // Convert the Larson model into the Great Pyramid scion to allow ending the level. Larson will - // become a raptor to allow for normal randomization. Environment mods will handle the specifics here. - if (!level.Data.Models.ChangeKey(TR1Type.Larson, TR1Type.ScionPiece3_S_P)) + if (!Settings.UseEnemyClones) { return; } - level.Data.Entities - .Where(e => e.TypeID == TR1Type.Larson) - .ToList() - .ForEach(e => e.TypeID = TR1Type.Raptor); + List enemyTypes = TR1TypeUtilities.GetFullListOfEnemies(); + List enemies = level.Data.Entities.FindAll(e => enemyTypes.Contains(e.TypeID)); - // Make the scion invisible. - MeshEditor editor = new(); - foreach (TRMesh mesh in level.Data.Models[TR1Type.ScionPiece3_S_P].Meshes) + // If Adam is still in his egg, clone the egg as well. Otherwise there will be separate + // entities inside the egg that will have already been accounted for. + TR1Entity adamEgg = level.Data.Entities.Find(e => e.TypeID == TR1Type.AdamEgg); + if (adamEgg != null + && TR1EnemyUtilities.CodeBitsToAtlantean(adamEgg.CodeBits) == TR1Type.Adam + && level.Data.Models.ContainsKey(TR1Type.Adam)) { - editor.Mesh = mesh; - editor.ClearAllPolygons(); + enemies.Add(adamEgg); } - } - - private void AmendPyramidTorso(TR1CombinedLevel level) - { - // We want to keep Adam's egg, but simulate something else hatching. - // In hard mode, two enemies take his place. - level.Data.Models.Remove(TR1Type.Adam); - TR1Entity egg = level.Data.Entities.Find(e => e.TypeID == TR1Type.AdamEgg); - TR1Entity lara = level.Data.Entities.Find(e => e.TypeID == TR1Type.Lara); - - EMAppendTriggerActionFunction trigFunc = new() - { - Location = new() - { - X = lara.X, - Y = lara.Y, - Z = lara.Z, - Room = lara.Room - }, - Actions = new() - }; - - int count = Settings.RandoEnemyDifficulty == RandoDifficulty.Default ? 1 : 2; - for (int i = 0; i < count; i++) - { - trigFunc.Actions.Add(new() - { - Parameter = (short)level.Data.Entities.Count - }); - - level.Data.Entities.Add(new() - { - TypeID = TR1Type.Adam, - X = egg.X, - Y = egg.Y - i * TRConsts.Step4, - Z = egg.Z - TRConsts.Step4, - Room = egg.Room, - Angle = egg.Angle, - Intensity = egg.Intensity, - Invisible = true - }); - } - - trigFunc.ApplyToLevel(level.Data); - } + uint cloneCount = Math.Max(2, Math.Min(MaxClones, Settings.EnemyMultiplier)) - 1; + short angleDiff = (short)Math.Ceiling(ushort.MaxValue / (cloneCount + 1d)); - private void AmendAtlanteanModels(TR1CombinedLevel level, EnemyRandomizationCollection enemies) - { - // If non-shooting grounded Atlanteans are present, we can just duplicate the model to make shooting Atlanteans - if (enemies.Available.Any(TR1TypeUtilities.GetFamily(TR1Type.ShootingAtlantean_N).Contains)) + foreach (TR1Entity enemy in enemies) { - TRModel shooter = level.Data.Models[TR1Type.ShootingAtlantean_N]; - TRModel nonShooter = level.Data.Models[TR1Type.NonShootingAtlantean_N]; - if (shooter == null && nonShooter != null) + List triggers = level.Data.FloorData.GetEntityTriggers(level.Data.Entities.IndexOf(enemy)); + if (Settings.UseKillableClonePierres && enemy.TypeID == TR1Type.Pierre) { - shooter = nonShooter.Clone(); - level.Data.Models[TR1Type.ShootingAtlantean_N] = shooter; - enemies.Available.Add(TR1Type.ShootingAtlantean_N); + // Ensure OneShot, otherwise only ever one runaway Pierre + triggers.ForEach(t => t.OneShot = true); } - } - // If we're using flying mummies, add a chance that they'll have proper wings - if (enemies.Available.Contains(TR1Type.BandagedFlyer) && _generator.NextDouble() < 0.5) - { - List meshes = level.Data.Models[TR1Type.FlyingAtlantean].Meshes; - ushort bandageTexture = meshes[1].TexturedRectangles[3].Texture; - for (int i = 15; i < 21; i++) + for (int i = 0; i < cloneCount; i++) { - TRMesh mesh = meshes[i]; - foreach (TRMeshFace face in mesh.TexturedFaces) + foreach (FDTriggerEntry trigger in triggers) { - face.Texture = bandageTexture; + trigger.Actions.Add(new() + { + Parameter = (short)level.Data.Entities.Count + }); } - } - } - } - - private static void AdjustCentaurStatue(TR1Entity entity, TR1Level level) - { - // If they're floating, they tend not to trigger as Lara's not within range - TR1LocationGenerator locationGenerator = new(); - int y = entity.Y; - short room = entity.Room; - TRRoomSector sector = level.GetRoomSector(entity.X, y, entity.Z, room); - while (sector.RoomBelow != TRConsts.NoRoom) - { - y = (sector.Floor + 1) * TRConsts.Step1; - room = sector.RoomBelow; - sector = level.GetRoomSector(entity.X, y, entity.Z, room); - } - - entity.Y = sector.Floor * TRConsts.Step1; - entity.Room = room; + TR1Entity clone = (TR1Entity)enemy.Clone(); + level.Data.Entities.Add(clone); - // Change this GetHeight - if (sector.FDIndex != 0) - { - FDEntry entry = level.FloorData[sector.FDIndex].Find(e => e is FDSlantEntry s && s.Type == FDSlantType.Floor); - if (entry is FDSlantEntry slant) - { - Vector4? bestMidpoint = locationGenerator.GetBestSlantMidpoint(slant); - if (bestMidpoint.HasValue) + if (enemy.TypeID != TR1Type.AtlanteanEgg + && enemy.TypeID != TR1Type.AdamEgg) { - entity.Y += (int)bestMidpoint.Value.Y; + clone.Angle -= (short)((i + 1) * angleDiff); } } } - - entity.Invisible = false; } private void AddUnarmedLevelAmmo(TR1CombinedLevel level) { - if (!Settings.GiveUnarmedItems) + if (!level.Script.RemovesWeapons) { return; } - // Find out which gun we have for this level - List weaponTypes = TR1TypeUtilities.GetWeaponPickups(); - List levelWeapons = level.Data.Entities.FindAll(e => weaponTypes.Contains(e.TypeID)); - TR1Entity weaponEntity = null; - foreach (TR1Entity weapon in levelWeapons) + _allocator.AddUnarmedLevelAmmo(level.Name, level.Data, (loc, type) => { - int match = _pistolLocations[level.Name].FindIndex - ( - location => - location.X == weapon.X && - location.Y == weapon.Y && - location.Z == weapon.Z && - location.Room == weapon.Room - ); - if (match != -1) - { - weaponEntity = weapon; - break; - } - } + level.Script.AddStartInventoryItem(ItemUtilities.ConvertToScriptItem(type)); + }); + } - if (weaponEntity == null) + private void RandomizeMeshes(TR1CombinedLevel level, List availableEnemies) + { + if (!Settings.SwapEnemyAppearance) { return; } - List allEnemies = TR1TypeUtilities.GetFullListOfEnemies(); - List levelEnemies = level.Data.Entities.FindAll(e => allEnemies.Contains(e.TypeID)); - // #409 Eggs are excluded as they are not part of the cross-level enemy pool, so create copies of any - // of these using their actual types so to ensure they are part of the difficulty calculation. - for (int i = 0; i < level.Data.Entities.Count; i++) - { - TR1Entity entity = level.Data.Entities[i]; - if ((entity.TypeID == TR1Type.AtlanteanEgg || entity.TypeID == TR1Type.AdamEgg) - && level.Data.FloorData.GetEntityTriggers(i).Count > 0) - { - TR1Entity resultantEnemy = new() - { - TypeID = TR1EnemyUtilities.CodeBitsToAtlantean(entity.CodeBits) - }; - - // Only include it if the model is present i.e. it's not an empty egg. - if (level.Data.Models.ContainsKey(resultantEnemy.TypeID)) - { - levelEnemies.Add(resultantEnemy); - } - } - } - - EnemyDifficulty difficulty = TR1EnemyUtilities.GetEnemyDifficulty(levelEnemies); - - if (difficulty > EnemyDifficulty.Easy) - { - while (weaponEntity.TypeID == TR1Type.Pistols_S_P) - { - weaponEntity.TypeID = weaponTypes[_generator.Next(0, weaponTypes.Count)]; - } - } - - TR1Type weaponType = weaponEntity.TypeID; - uint ammoToGive = TR1EnemyUtilities.GetStartingAmmo(weaponType); - if (ammoToGive > 0) - { - ammoToGive *= (uint)difficulty; - TR1Type ammoType = TR1TypeUtilities.GetWeaponAmmo(weaponType); - level.Script.AddStartInventoryItem(ItemUtilities.ConvertToScriptItem(ammoType), ammoToGive); - - uint smallMediToGive = 0; - uint largeMediToGive = 0; - - if (difficulty == EnemyDifficulty.Medium || difficulty == EnemyDifficulty.Hard) - { - smallMediToGive++; - largeMediToGive++; - } - if (difficulty > EnemyDifficulty.Medium) - { - largeMediToGive++; - } - if (difficulty == EnemyDifficulty.VeryHard) - { - largeMediToGive++; - } - - level.Script.AddStartInventoryItem(ItemUtilities.ConvertToScriptItem(TR1Type.SmallMed_S_P), smallMediToGive); - level.Script.AddStartInventoryItem(ItemUtilities.ConvertToScriptItem(TR1Type.LargeMed_S_P), largeMediToGive); - } - - // Add the pistols as a pickup if the level is hard and there aren't any other pistols around - if (difficulty > EnemyDifficulty.Medium - && levelWeapons.Find(e => e.TypeID == TR1Type.Pistols_S_P) == null - && ItemFactory.CanCreateItem(level.Name, level.Data.Entities)) - { - TR1Entity pistols = ItemFactory.CreateItem(level.Name, level.Data.Entities); - pistols.TypeID = TR1Type.Pistols_S_P; - pistols.X = weaponEntity.X; - pistols.Y = weaponEntity.Y; - pistols.Z = weaponEntity.Z; - pistols.Room = weaponEntity.Room; - } - } - - private void RandomizeMeshes(TR1CombinedLevel level, List availableEnemies) - { if (level.Is(TR1LevelNames.ATLANTIS)) { // Atlantis scion swap - Model => Mesh index @@ -1179,7 +379,7 @@ private void RandomizeMeshes(TR1CombinedLevel level, List availableEnem else { TR1Type laraSwapType = _generator.NextDouble() < 0.5 ? TR1Type.LaraUziAnimation_H : TR1Type.Lara; - replacement = level.Data.Models[laraSwapType].Meshes[14]; + replacement = level.Data.Models[laraSwapType].Meshes[14]; } adam[3] = replacement.Clone(); @@ -1227,114 +427,16 @@ private void RandomizeMeshes(TR1CombinedLevel level, List availableEnem } } - private void FixEnemyAnimations(TR1CombinedLevel level) - { - // Model transport will handle these missing SFX by default, but we need to fix them in - // the levels where these enemies already exist. - if (level.Data.Models.ContainsKey(TR1Type.Pierre) - && (level.Is(TR1LevelNames.FOLLY) || level.Is(TR1LevelNames.COLOSSEUM) || level.Is(TR1LevelNames.CISTERN) || level.Is(TR1LevelNames.TIHOCAN))) - { - TR1DataExporter.AmendPierreGunshot(level.Data); - TR1DataExporter.AmendPierreDeath(level.Data); - - // Non one-shot-Pierre levels won't have the death sound by default, so borrow it from ToT. - if (!level.Data.SoundEffects.ContainsKey(TR1SFX.PierreDeath)) - { - TR1Level tihocan = new TR1LevelControl().Read(Path.Combine(BackupPath, TR1LevelNames.TIHOCAN)); - level.Data.SoundEffects[TR1SFX.PierreDeath] = tihocan.SoundEffects[TR1SFX.PierreDeath]; - } - } - - if (level.Data.Models.ContainsKey(TR1Type.Larson) && level.Is(TR1LevelNames.SANCTUARY)) - { - TR1DataExporter.AmendLarsonDeath(level.Data); - } - - if (level.Data.Models.ContainsKey(TR1Type.SkateboardKid) && level.Is(TR1LevelNames.MINES)) - { - TR1DataExporter.AmendSkaterBoyDeath(level.Data); - } - - if (level.Data.Models.ContainsKey(TR1Type.Natla) && level.Is(TR1LevelNames.PYRAMID)) - { - TR1DataExporter.AmendNatlaDeath(level.Data); - } - } - - private static void FixColosseumBats(TR1CombinedLevel level) - { - // Fix the bat trigger in Colosseum. Done outside of environment mods to allow for cloning. - // Item 74 is duplicated in each trigger. - foreach (FDTriggerEntry trigger in level.Data.FloorData.GetEntityTriggers(74)) - { - List actions = trigger.Actions - .FindAll(a => a.Action == FDTrigAction.Object && a.Parameter == 74); - if (actions.Count == 2) - { - actions[0].Parameter = 73; - } - } - } - - private void CloneEnemies(TR1CombinedLevel level) - { - List enemyTypes = TR1TypeUtilities.GetFullListOfEnemies(); - List enemies = level.Data.Entities.FindAll(e => enemyTypes.Contains(e.TypeID)); - - // If Adam is still in his egg, clone the egg as well. Otherwise there will be separate - // entities inside the egg that will have already been accounted for. - TR1Entity adamEgg = level.Data.Entities.Find(e => e.TypeID == TR1Type.AdamEgg); - if (adamEgg != null - && TR1EnemyUtilities.CodeBitsToAtlantean(adamEgg.CodeBits) == TR1Type.Adam - && level.Data.Models.ContainsKey(TR1Type.Adam)) - { - enemies.Add(adamEgg); - } - - uint cloneCount = Math.Max(2, Math.Min(MaxClones, Settings.EnemyMultiplier)) - 1; - short angleDiff = (short)Math.Ceiling(ushort.MaxValue / (cloneCount + 1d)); - - foreach (TR1Entity enemy in enemies) - { - List triggers = level.Data.FloorData.GetEntityTriggers(level.Data.Entities.IndexOf(enemy)); - if (Settings.UseKillableClonePierres && enemy.TypeID == TR1Type.Pierre) - { - // Ensure OneShot, otherwise only ever one runaway Pierre - triggers.ForEach(t => t.OneShot = true); - } - - for (int i = 0; i < cloneCount; i++) - { - foreach (FDTriggerEntry trigger in triggers) - { - trigger.Actions.Add(new() - { - Parameter = (short)level.Data.Entities.Count - }); - } - - TR1Entity clone = (TR1Entity)enemy.Clone(); - level.Data.Entities.Add(clone); - - if (enemy.TypeID != TR1Type.AtlanteanEgg - && enemy.TypeID != TR1Type.AdamEgg) - { - clone.Angle -= (short)((i + 1) * angleDiff); - } - } - } - } - internal class EnemyProcessor : AbstractProcessorThread { - private readonly Dictionary _enemyMapping; + private readonly Dictionary> _enemyMapping; internal override int LevelCount => _enemyMapping.Count; internal EnemyProcessor(TR1EnemyRandomizer outer) : base(outer) { - _enemyMapping = new Dictionary(); + _enemyMapping = new(); } internal void AddLevel(TR1CombinedLevel level) @@ -1349,7 +451,7 @@ protected override void StartImpl() List levels = new(_enemyMapping.Keys); foreach (TR1CombinedLevel level in levels) { - _enemyMapping[level] = _outer.SelectCrossLevelEnemies(level); + _enemyMapping[level] = _outer._allocator.SelectCrossLevelEnemies(level.Name, level.Data); } } @@ -1360,7 +462,7 @@ protected override void ProcessImpl() { if (!level.IsAssault) { - EnemyTransportCollection enemies = _enemyMapping[level]; + EnemyTransportCollection enemies = _enemyMapping[level]; List importModels = new(enemies.TypesToImport); if (level.Is(TR1LevelNames.KHAMOON) && (importModels.Contains(TR1Type.BandagedAtlantean) || importModels.Contains(TR1Type.BandagedFlyer))) { @@ -1379,7 +481,7 @@ protected override void ProcessImpl() TextureMonitor = _outer.TextureMonitor.CreateMonitor(level.Name, enemies.TypesToImport) }; - string remapPath = @"TR1\Textures\Deduplication\" + level.Name + "-TextureRemap.json"; + string remapPath = $@"TR1\Textures\Deduplication\{level.Name}-TextureRemap.json"; if (_outer.ResourceExists(remapPath)) { importer.TextureRemapPath = _outer.GetResourcePath(remapPath); @@ -1403,7 +505,7 @@ internal void ApplyRandomization() { if (!level.IsAssault) { - EnemyRandomizationCollection enemies = new() + EnemyRandomizationCollection enemies = new() { Available = _enemyMapping[level].TypesToImport, Water = TR1TypeUtilities.FilterWaterEnemies(_enemyMapping[level].TypesToImport) @@ -1420,16 +522,4 @@ internal void ApplyRandomization() } } } - - internal class EnemyTransportCollection - { - internal List TypesToImport { get; set; } - internal List TypesToRemove { get; set; } - } - - internal class EnemyRandomizationCollection - { - internal List Available { get; set; } - internal List Water { get; set; } - } } diff --git a/TRRandomizerCore/Randomizers/TR1/Remastered/TR1REnemyRandomizer.cs b/TRRandomizerCore/Randomizers/TR1/Remastered/TR1REnemyRandomizer.cs new file mode 100644 index 000000000..96a6c1a82 --- /dev/null +++ b/TRRandomizerCore/Randomizers/TR1/Remastered/TR1REnemyRandomizer.cs @@ -0,0 +1,255 @@ +using TRDataControl; +using TRGE.Core; +using TRLevelControl.Helpers; +using TRLevelControl.Model; +using TRRandomizerCore.Helpers; +using TRRandomizerCore.Levels; +using TRRandomizerCore.Processors; +using TRRandomizerCore.Utilities; + +namespace TRRandomizerCore.Randomizers; + +public class TR1REnemyRandomizer : BaseTR1RRandomizer +{ + private static readonly List _tihocanEndEnemies = new() { 73, 74, 82 }; + + private TR1EnemyAllocator _allocator; + + public TR1RDataCache DataCache { get; set; } + public ItemFactory ItemFactory { get; set; } + + public override void Randomize(int seed) + { + _generator = new(seed); + _allocator = new() + { + Settings = Settings, + ItemFactory = ItemFactory, + Generator = _generator, + GameLevels = Levels.Select(l => l.LevelFileBaseName), + }; + _allocator.Initialise(); + + if (Settings.CrossLevelEnemies) + { + RandomizeEnemiesCrossLevel(); + } + else + { + RandomizeExistingEnemies(); + } + } + + private void RandomizeExistingEnemies() + { + foreach (TRRScriptedLevel lvl in Levels) + { + LoadLevelInstance(lvl); + EnemyRandomizationCollection enemies = _allocator.RandomizeEnemiesNatively(_levelInstance.Name, _levelInstance.Data); + ApplyPostRandomization(_levelInstance, enemies); + + SaveLevelInstance(); + if (!TriggerProgress()) + { + break; + } + } + } + + private void RandomizeEnemiesCrossLevel() + { + SetMessage("Randomizing enemies - loading levels"); + + List processors = new(); + for (int i = 0; i < _maxThreads; i++) + { + processors.Add(new(this)); + } + + List levels = new(Levels.Count); + foreach (TRRScriptedLevel lvl in Levels) + { + levels.Add(LoadCombinedLevel(lvl)); + if (!TriggerProgress()) + { + return; + } + } + + int processorIndex = 0; + foreach (TR1RCombinedLevel level in levels) + { + processors[processorIndex].AddLevel(level); + processorIndex = processorIndex == _maxThreads - 1 ? 0 : processorIndex + 1; + } + + SetMessage("Randomizing enemies - importing models"); + processors.ForEach(p => p.Start()); + processors.ForEach(p => p.Join()); + + if (!SaveMonitor.IsCancelled && _processingException == null) + { + SetMessage("Randomizing enemies - saving levels"); + processors.ForEach(p => p.ApplyRandomization()); + } + + _processingException?.Throw(); + + string statusMessage = _allocator.GetExclusionStatusMessage(); + if (statusMessage != null) + { + SetWarning(statusMessage); + } + } + + private void RandomizeEnemies(TR1RCombinedLevel level, EnemyRandomizationCollection enemies) + { + _allocator.RandomizeEnemies(level.Name, level.Data, enemies); + ApplyPostRandomization(level, enemies); + } + + private void ApplyPostRandomization(TR1RCombinedLevel level, EnemyRandomizationCollection enemies) + { + UpdateAtlanteanPDP(level, enemies); + AdjustTihocanEnding(level); + AddUnarmedLevelAmmo(level); + } + + private void UpdateAtlanteanPDP(TR1RCombinedLevel level, EnemyRandomizationCollection enemies) + { + if (!enemies.Available.Contains(TR1Type.ShootingAtlantean_N) || level.PDPData.ContainsKey(TR1Type.ShootingAtlantean_N)) + { + return; + } + + // The allocator may have cloned non-shooters, so copy into the PDP as well + DataCache.SetPDPData(level.PDPData, TR1Type.ShootingAtlantean_N, TR1Type.ShootingAtlantean_N); + } + + private static void AdjustTihocanEnding(TR1RCombinedLevel level) + { + if (!level.Is(TR1LevelNames.TIHOCAN) + || _tihocanEndEnemies.Any(e => level.Data.Entities[e].TypeID == TR1Type.Pierre)) + { + return; + } + + // Add Pierre's pickups in a default place. Allows pacifist runs effectively. + level.Data.Entities.AddRange(TR1ItemRandomizer.TihocanEndItems); + } + + private void AddUnarmedLevelAmmo(TR1RCombinedLevel level) + { + if (!level.Script.RemovesWeapons) + { + return; + } + + _allocator.AddUnarmedLevelAmmo(level.Name, level.Data, (loc, type) => + { + if (ItemFactory.CanCreateItem(level.Name, level.Data.Entities)) + { + TR1Entity item = ItemFactory.CreateItem(level.Name, level.Data.Entities, loc); + item.TypeID = type; + } + }); + } + + internal class EnemyProcessor : AbstractProcessorThread + { + private readonly Dictionary> _enemyMapping; + + internal override int LevelCount => _enemyMapping.Count; + + internal EnemyProcessor(TR1REnemyRandomizer outer) + : base(outer) + { + _enemyMapping = new(); + } + + internal void AddLevel(TR1RCombinedLevel level) + { + _enemyMapping.Add(level, null); + } + + protected override void StartImpl() + { + List levels = new(_enemyMapping.Keys); + foreach (TR1RCombinedLevel level in levels) + { + _enemyMapping[level] = _outer._allocator.SelectCrossLevelEnemies(level.Name, level.Data); + } + } + + // Executed in parallel, so just store the import result to process later synchronously. + protected override void ProcessImpl() + { + foreach (TR1RCombinedLevel level in _enemyMapping.Keys) + { + if (!level.IsAssault) + { + EnemyTransportCollection enemies = _enemyMapping[level]; + List importModels = new(enemies.TypesToImport); + if (level.Is(TR1LevelNames.KHAMOON) && (importModels.Contains(TR1Type.BandagedAtlantean) || importModels.Contains(TR1Type.BandagedFlyer))) + { + // Mummies may become shooters in Khamoon, but the missiles won't be available by default, so ensure they do get imported. + importModels.Add(TR1Type.Missile2_H); + importModels.Add(TR1Type.Missile3_H); + } + + TR1DataImporter importer = new(true) + { + TypesToImport = importModels, + TypesToRemove = enemies.TypesToRemove, + Level = level.Data, + LevelName = level.Name, + DataFolder = _outer.GetResourcePath(@"TR1\Objects"), + }; + + importer.Data.TextureObjectLimit = RandoConsts.TRRTexLimit; + importer.Data.TextureTileLimit = RandoConsts.TRRTileLimit; + + string remapPath = @"TR1\Textures\Deduplication\" + level.Name + "-TextureRemap.json"; + if (_outer.ResourceExists(remapPath)) + { + importer.TextureRemapPath = _outer.GetResourcePath(remapPath); + } + + importer.Data.AliasPriority = TR1EnemyUtilities.GetAliasPriority(level.Name, enemies.TypesToImport); + + ImportResult result = importer.Import(); + _outer.DataCache.Merge(result, level.PDPData, level.MapData); + } + + if (!_outer.TriggerProgress()) + { + break; + } + } + } + + // This is triggered synchronously after the import work to ensure the RNG remains consistent + internal void ApplyRandomization() + { + foreach (TR1RCombinedLevel level in _enemyMapping.Keys) + { + if (!level.IsAssault) + { + EnemyRandomizationCollection enemies = new() + { + Available = _enemyMapping[level].TypesToImport, + Water = TR1TypeUtilities.FilterWaterEnemies(_enemyMapping[level].TypesToImport) + }; + + _outer.RandomizeEnemies(level, enemies); + _outer.SaveLevel(level); + } + + if (!_outer.TriggerProgress()) + { + break; + } + } + } + } +} diff --git a/TRRandomizerCore/Randomizers/TR1/Shared/TR1EnemyAllocator.cs b/TRRandomizerCore/Randomizers/TR1/Shared/TR1EnemyAllocator.cs new file mode 100644 index 000000000..e60282bb1 --- /dev/null +++ b/TRRandomizerCore/Randomizers/TR1/Shared/TR1EnemyAllocator.cs @@ -0,0 +1,817 @@ +using Newtonsoft.Json; +using System.Numerics; +using TRDataControl.Environment; +using TRLevelControl; +using TRLevelControl.Helpers; +using TRLevelControl.Model; +using TRRandomizerCore.Helpers; +using TRRandomizerCore.Utilities; + +namespace TRRandomizerCore.Randomizers; + +public class TR1EnemyAllocator : EnemyAllocator +{ + private static readonly EnemyTransportCollection _emptyEnemies = new(); + + private static readonly int _unkillableEgyptMummy = 163; + private static readonly Location _egyptMummyLocation = new() + { + X = 66048, + Y = -2304, + Z = 73216, + Room = 78 + }; + + private static readonly int _unreachableStrongholdRoom = 18; + private static readonly Location _strongholdCentaurLocation = new() + { + X = 57856, + Y = -26880, + Z = 43520, + Room = 14 + }; + + private static readonly double _emptyEggChance = 0.25; + private static readonly double _mummyWingChance = 0.5; + + private readonly Dictionary> _pistolLocations; + private readonly Dictionary> _eggLocations; + private readonly Dictionary> _pierreLocations; + + public ItemFactory ItemFactory { get; set; } + + public TR1EnemyAllocator() + { + _pistolLocations = JsonConvert.DeserializeObject>>(File.ReadAllText(@"Resources\TR1\Locations\unarmed_locations.json")); + _eggLocations = JsonConvert.DeserializeObject>>(File.ReadAllText(@"Resources\TR1\Locations\egg_locations.json")); + _pierreLocations = JsonConvert.DeserializeObject>>(File.ReadAllText(@"Resources\TR1\Locations\pierre_locations.json")); + } + + protected override Dictionary> GetGameTracker() + => TR1EnemyUtilities.PrepareEnemyGameTracker(Settings.RandoEnemyDifficulty, GameLevels); + + protected override bool IsEnemySupported(string levelName, TR1Type type, RandoDifficulty difficulty) + => TR1EnemyUtilities.IsEnemySupported(levelName, type, difficulty); + + protected override Dictionary> GetRestrictedRooms(string levelName, RandoDifficulty difficulty) + => TR1EnemyUtilities.GetRestrictedEnemyRooms(levelName, RandoDifficulty.Default); + + protected override bool IsOneShotType(TR1Type type) + => type == TR1Type.Pierre; + + public EnemyTransportCollection SelectCrossLevelEnemies(string levelName, TR1Level level) + { + if (levelName == TR1LevelNames.ASSAULT) + { + return null; + } + + AdjustUnkillableEnemies(levelName, level); + + if (Settings.UseEnemyClones && Settings.CloneOriginalEnemies) + { + // Skip import altogether for OG clone mode + return _emptyEnemies; + } + + // If level-ending Larson is disabled, we make an alternative ending to ToQ. + // Do this at this stage as it effectively gets rid of ToQ-Larson meaning + // Sanctuary-Larson can potentially be imported. + if (levelName == TR1LevelNames.QUALOPEC && Settings.ReplaceRequiredEnemies) + { + AmendToQLarson(level); + } + + if (TR1LevelNames.AsListGold.Contains(levelName)) + { + // Ensure big eggs are randomized by converting to normal ones because + // big eggs are never part of the enemy pool. + level.Entities.FindAll(e => e.TypeID == TR1Type.AdamEgg) + .ForEach(e => e.TypeID = TR1Type.AtlanteanEgg); + } + + RandoDifficulty difficulty = GetImpliedDifficulty(); + + List oldTypes = GetCurrentEnemyEntities(level); + List allEnemies = TR1TypeUtilities.GetCandidateCrossLevelEnemies(); + + int enemyCount = oldTypes.Count + TR1EnemyUtilities.GetEnemyAdjustmentCount(levelName); + if (levelName == TR1LevelNames.QUALOPEC && Settings.ReplaceRequiredEnemies) + { + // Account for Larson having been removed above. + ++enemyCount; + } + List newTypes = new(enemyCount); + + // TR1 doesn't kill land creatures when underwater, so if "no restrictions" is + // enabled, don't enforce any by default. + bool waterEnemyRequired = difficulty == RandoDifficulty.Default + && TR1TypeUtilities.GetWaterEnemies().Any(oldTypes.Contains); + + if (waterEnemyRequired) + { + List waterEnemies = TR1TypeUtilities.GetWaterEnemies(); + newTypes.Add(SelectRequiredEnemy(waterEnemies, levelName, difficulty)); + } + + if (!Settings.ReplaceRequiredEnemies) + { + foreach (TR1Type type in TR1EnemyUtilities.GetRequiredEnemies(levelName)) + { + if (!newTypes.Contains(type)) + { + newTypes.Add(type); + } + } + } + + // Remove all exclusions from the pool, and adjust the target capacity + allEnemies.RemoveAll(_excludedEnemies.Contains); + + IEnumerable ex = allEnemies.Where(e => !newTypes.Any(TR1TypeUtilities.GetFamily(e).Contains)); + List unalisedTypes = TR1TypeUtilities.RemoveAliases(ex); + while (unalisedTypes.Count < newTypes.Capacity - newTypes.Count) + { + --newTypes.Capacity; + } + + // Fill the remainder to capacity as randomly as we can + HashSet testedTypes = new(); + List eggTypes = TR1TypeUtilities.GetAtlanteanEggEnemies(); + while (newTypes.Count < newTypes.Capacity && testedTypes.Count < allEnemies.Count) + { + TR1Type type = allEnemies[Generator.Next(0, allEnemies.Count)]; + testedTypes.Add(type); + + if (!TR1EnemyUtilities.IsEnemySupported(levelName, type, difficulty)) + { + continue; + } + + // Grounded Atlanteans require the flyer for meshes so we can't have a grounded mummy and meaty flyer, or vice versa as a result. + if (type == TR1Type.BandagedAtlantean && newTypes.Contains(TR1Type.MeatyFlyer) && !newTypes.Contains(TR1Type.MeatyAtlantean)) + { + type = TR1Type.MeatyAtlantean; + } + else if (type == TR1Type.MeatyAtlantean && newTypes.Contains(TR1Type.BandagedFlyer) && !newTypes.Contains(TR1Type.BandagedAtlantean)) + { + type = TR1Type.BandagedAtlantean; + } + else if (type == TR1Type.BandagedFlyer && newTypes.Contains(TR1Type.MeatyAtlantean)) + { + continue; + } + else if (type == TR1Type.MeatyFlyer && newTypes.Contains(TR1Type.BandagedAtlantean)) + { + continue; + } + else if (type == TR1Type.AtlanteanEgg && !newTypes.Any(eggTypes.Contains)) + { + List preferredEggTypes = eggTypes.FindAll(allEnemies.Contains); + if (preferredEggTypes.Count == 0) + { + preferredEggTypes = eggTypes; + } + TR1Type eggType = preferredEggTypes[Generator.Next(0, preferredEggTypes.Count)]; + newTypes.Add(eggType); + testedTypes.Add(eggType); + } + + // If this is a tracked enemy throughout the game, we only allow it if the number + // of unique levels is within the limit. Bear in mind we are collecting more than + // one group of enemies per level. + if (_gameEnemyTracker.ContainsKey(type) && !_gameEnemyTracker[type].Contains(levelName)) + { + if (_gameEnemyTracker[type].Count < _gameEnemyTracker[type].Capacity) + { + _gameEnemyTracker[type].Add(levelName); + } + else + { + // If we tried to previously exclude this enemy and couldn't, it will slip + // through the net and so the appearances will increase. + if (allEnemies.Except(newTypes).Count() > 1) + { + continue; + } + } + } + + List family = TR1TypeUtilities.GetFamily(type); + if (!newTypes.Any(family.Contains)) + { + newTypes.Add(type); + } + } + + if + ( + newTypes.All(e => TR1TypeUtilities.IsWaterCreature(e) || TR1EnemyUtilities.IsEnemyRestricted(levelName, e, difficulty)) || + (newTypes.Capacity > 1 && newTypes.All(e => TR1EnemyUtilities.IsEnemyRestricted(levelName, e, difficulty))) + ) + { + // Make sure we have an unrestricted enemy available for the individual level conditions. This will + // guarantee a "safe" enemy for the level; we avoid aliases here to avoid further complication. + bool RestrictionCheck(TR1Type e) => + !TR1EnemyUtilities.IsEnemySupported(levelName, e, difficulty) + || newTypes.Contains(e) + || TR1TypeUtilities.IsWaterCreature(e) + || TR1EnemyUtilities.IsEnemyRestricted(levelName, e, difficulty) + || TR1TypeUtilities.TranslateAlias(e) != e; + + List unrestrictedPool = allEnemies.FindAll(e => !RestrictionCheck(e)); + if (unrestrictedPool.Count == 0) + { + // We are going to have to pull in the full list of candidates again, so ignoring any exclusions + unrestrictedPool = TR1TypeUtilities.GetCandidateCrossLevelEnemies().FindAll(e => !RestrictionCheck(e)); + } + + TR1Type type = unrestrictedPool[Generator.Next(0, unrestrictedPool.Count)]; + newTypes.Add(type); + + if (type == TR1Type.AtlanteanEgg && !newTypes.Any(eggTypes.Contains)) + { + List preferredEggTypes = eggTypes.FindAll(allEnemies.Contains); + if (preferredEggTypes.Count == 0) + { + preferredEggTypes = eggTypes; + } + TR1Type eggType = preferredEggTypes[Generator.Next(0, preferredEggTypes.Count)]; + newTypes.Add(eggType); + } + } + + if (levelName == TR1LevelNames.PYRAMID && Settings.ReplaceRequiredEnemies && !newTypes.Contains(TR1Type.Adam)) + { + AmendPyramidTorso(level); + } + + return new() + { + TypesToImport = newTypes, + TypesToRemove = oldTypes + }; + } + + private static List GetCurrentEnemyEntities(TR1Level level) + { + List allGameEnemies = TR1TypeUtilities.GetFullListOfEnemies(); + SortedSet allLevelEnts = new(level.Entities.Select(e => e.TypeID)); + return allLevelEnts.Where(allGameEnemies.Contains).ToList(); + } + + public EnemyRandomizationCollection RandomizeEnemiesNatively(string levelName, TR1Level level) + { + if (levelName == TR1LevelNames.ASSAULT) + { + return null; + } + + AdjustUnkillableEnemies(levelName, level); + EnemyRandomizationCollection enemies = new() + { + Available = new(), + Water = new() + }; + + if (!Settings.UseEnemyClones || !Settings.CloneOriginalEnemies) + { + enemies.Available.AddRange(GetCurrentEnemyEntities(level)); + enemies.Water.AddRange(TR1TypeUtilities.FilterWaterEnemies(enemies.Available)); + } + + RandomizeEnemies(levelName, level, enemies); + + return enemies; + } + + public void RandomizeEnemies(string levelName, TR1Level level, EnemyRandomizationCollection enemies) + { + AmendAtlanteanModels(level, enemies); + + // Get a list of current enemy entities + List allEnemies = TR1TypeUtilities.GetFullListOfEnemies(); + List enemyEntities = level.Entities.FindAll(e => allEnemies.Contains(e.TypeID)); + + RandoDifficulty difficulty = GetImpliedDifficulty(); + + // First iterate through any enemies that are restricted by room + Dictionary> enemyRooms = TR1EnemyUtilities.GetRestrictedEnemyRooms(levelName, difficulty); + if (enemyRooms != null) + { + foreach (TR1Type type in enemyRooms.Keys) + { + if (!enemies.Available.Contains(type)) + { + continue; + } + + List rooms = enemyRooms[type]; + int maxEntityCount = TR1EnemyUtilities.GetRestrictedEnemyLevelCount(type, difficulty); + if (maxEntityCount == -1) + { + // We are allowed any number, but this can't be more than the number of unique rooms, + // so we will assume 1 per room as these restricted enemies are likely to be tanky. + maxEntityCount = rooms.Count; + } + else + { + maxEntityCount = Math.Min(maxEntityCount, rooms.Count); + } + + // Pick an actual count + int enemyCount = Generator.Next(1, maxEntityCount + 1); + for (int i = 0; i < enemyCount; i++) + { + // Find an entity in one of the rooms that the new enemy is restricted to + TR1Entity targetEntity = null; + do + { + int room = enemyRooms[type][Generator.Next(0, enemyRooms[type].Count)]; + targetEntity = enemyEntities.Find(e => e.Room == room); + } + while (targetEntity == null); + + // If the room has water but this enemy isn't a water enemy, we will assume that environment + // modifications will handle assignment of the enemy to entities. + if (!TR1TypeUtilities.IsWaterCreature(type) && level.Rooms[targetEntity.Room].ContainsWater) + { + continue; + } + + targetEntity.TypeID = TR1TypeUtilities.TranslateAlias(type); + SetOneShot(targetEntity, level.Entities.IndexOf(targetEntity), level.FloorData); + enemyEntities.Remove(targetEntity); + + if (Settings.HideEnemiesUntilTriggered || type == TR1Type.Adam) + { + targetEntity.Invisible = true; + } + } + + enemies.Available.Remove(type); + } + } + + foreach (TR1Entity currentEntity in enemyEntities) + { + if (enemies.Available.Count == 0) + { + continue; + } + + int entityIndex = level.Entities.IndexOf(currentEntity); + TR1Type currentType = currentEntity.TypeID; + TR1Type newType = currentType; + + // If it's an existing enemy that has to remain in the same spot, skip it + if (!Settings.ReplaceRequiredEnemies && TR1EnemyUtilities.IsEnemyRequired(levelName, currentType)) + { + _resultantEnemies.Add(currentType); + continue; + } + + List enemyPool; + if (difficulty == RandoDifficulty.Default && IsEnemyInOrAboveWater(currentEntity, level)) + { + // Make sure we replace with another water enemy + enemyPool = enemies.Water; + } + else + { + // Otherwise we can pick any other available enemy + enemyPool = enemies.Available; + } + + // Pick a new type + newType = enemyPool[Generator.Next(0, enemyPool.Count)]; + + // If we are restricting count per level for this enemy and have reached that count, pick + // something else. This applies when we are restricting by in-level count, but not by room + // (e.g. Kold, SkateboardKid). + int maxEntityCount = TR1EnemyUtilities.GetRestrictedEnemyLevelCount(newType, difficulty); + if (maxEntityCount != -1) + { + if (GetEntityCount(level, newType) >= maxEntityCount) + { + List pool = enemyPool.FindAll(e => !TR1EnemyUtilities.IsEnemyRestricted(levelName, TR1TypeUtilities.TranslateAlias(e))); + if (pool.Count > 0) + { + newType = pool[Generator.Next(0, pool.Count)]; + } + } + } + + // Rather than individual enemy limits, this accounts for enemy groups such as all Atlanteans + RandoDifficulty groupDifficulty = difficulty; + if (levelName == TR1LevelNames.QUALOPEC && newType == TR1Type.Larson && Settings.ReplaceRequiredEnemies) + { + // Non-level ending Larson is not restricted in ToQ, otherwise we adhere to the normal rules. + groupDifficulty = RandoDifficulty.NoRestrictions; + } + RestrictedEnemyGroup enemyGroup = TR1EnemyUtilities.GetRestrictedEnemyGroup(levelName, TR1TypeUtilities.TranslateAlias(newType), groupDifficulty); + if (enemyGroup != null) + { + if (level.Entities.FindAll(e => enemyGroup.Enemies.Contains(e.TypeID)).Count >= enemyGroup.MaximumCount) + { + List pool = enemyPool.FindAll(e => !TR1EnemyUtilities.IsEnemyRestricted(levelName, TR1TypeUtilities.TranslateAlias(e), groupDifficulty)); + if (pool.Count > 0) + { + newType = pool[Generator.Next(0, pool.Count)]; + } + } + } + + // Tomp1 switches rats/crocs automatically if a room is flooded or drained. But we may have added a normal + // land enemy to a room that eventually gets flooded. So in default difficulty, ensure the entity is a + // hybrid, otherwise allow land creatures underwater (which works, but is obviously more difficult). + if (difficulty == RandoDifficulty.Default) + { + TR1Room currentRoom = level.Rooms[currentEntity.Room]; + if (currentRoom.AlternateRoom != -1 + && level.Rooms[currentRoom.AlternateRoom].ContainsWater + && TR1TypeUtilities.IsWaterLandCreatureEquivalent(currentType) + && !TR1TypeUtilities.IsWaterLandCreatureEquivalent(newType)) + { + Dictionary hybrids = TR1TypeUtilities.GetWaterEnemyLandCreatures(); + List pool = enemies.Available.FindAll(e => hybrids.ContainsKey(e) || hybrids.ContainsValue(e)); + if (pool.Count > 0) + { + newType = TR1TypeUtilities.GetWaterEnemyLandCreature(pool[Generator.Next(0, pool.Count)]); + } + } + } + + if (Settings.HideEnemiesUntilTriggered) + { + // Default to hiding the enemy - checks below for eggs, ex-eggs, Adam and centaur + // statues will override as necessary. + currentEntity.Invisible = true; + } + + if (newType == TR1Type.AtlanteanEgg) + { + List allEggTypes = TR1TypeUtilities.GetAtlanteanEggEnemies(); + List spawnTypes = enemies.Available.FindAll(allEggTypes.Contains); + TR1Type spawnType = TR1TypeUtilities.TranslateAlias(spawnTypes[Generator.Next(0, spawnTypes.Count)]); + + Location eggLocation = _eggLocations.ContainsKey(levelName) + ? _eggLocations[levelName].Find(l => l.EntityIndex == entityIndex) + : null; + + if (eggLocation != null || currentType == newType) + { + if (Settings.AllowEmptyEggs) + { + // We can add Adam to make it possible for a dud spawn - he's not normally available for eggs because + // of his own restrictions. + if (!level.Models.ContainsKey(TR1Type.Adam)) + { + allEggTypes.Add(TR1Type.Adam); + } + + if (!allEggTypes.All(e => level.Models.ContainsKey(TR1TypeUtilities.TranslateAlias(e))) && Generator.NextDouble() < _emptyEggChance) + { + do + { + spawnType = TR1TypeUtilities.TranslateAlias(allEggTypes[Generator.Next(0, allEggTypes.Count)]); + } + while (level.Models.ContainsKey(spawnType)); + } + } + + currentEntity.CodeBits = TR1EnemyUtilities.AtlanteanToCodeBits(spawnType); + if (eggLocation != null) + { + currentEntity.SetLocation(eggLocation); + } + + // Eggs will always be visible + currentEntity.Invisible = false; + } + else + { + // We don't want an egg for this particular enemy, so just make it spawn as the actual type + newType = spawnType; + } + } + else if (currentType == TR1Type.AtlanteanEgg) + { + // Hide what used to be eggs and reset the CodeBits otherwise this can interfere with trigger masks. + currentEntity.Invisible = true; + currentEntity.CodeBits = 0; + } + + if (newType == TR1Type.CentaurStatue) + { + AdjustCentaurStatue(currentEntity, level); + } + else if (newType == TR1Type.Adam) + { + // Adam should always be invisible as he is inactive high above the ground + // so this can interfere with Lara's route - see Cistern item 36 + currentEntity.Invisible = true; + } + else if (newType == TR1Type.Pierre + && _pierreLocations.ContainsKey(levelName) + && _pierreLocations[levelName].Find(l => l.EntityIndex == entityIndex) is Location location) + { + // Pierre is the only enemy who cannot be underwater, so location shifts have been predefined + // for specific entities. + currentEntity.SetLocation(location); + } + + // Final step is to convert/set the type and ensure OneShot is set if needed (#146) + currentEntity.TypeID = TR1TypeUtilities.TranslateAlias(newType); + SetOneShot(currentEntity, entityIndex, level.FloorData); + _resultantEnemies.Add(newType); + } + } + + private static int GetEntityCount(TR1Level level, TR1Type entityType) + { + int count = 0; + TR1Type translatedType = TR1TypeUtilities.TranslateAlias(entityType); + foreach (TR1Entity entity in level.Entities) + { + TR1Type type = entity.TypeID; + if (type == translatedType) + { + count++; + } + else if (type == TR1Type.AdamEgg || type == TR1Type.AtlanteanEgg) + { + TR1Type eggType = TR1EnemyUtilities.CodeBitsToAtlantean(entity.CodeBits); + if (eggType == translatedType && level.Models.ContainsKey(eggType)) + { + count++; + } + } + } + return count; + } + + private static bool IsEnemyInOrAboveWater(TR1Entity entity, TR1Level level) + { + if (level.Rooms[entity.Room].ContainsWater) + { + return true; + } + + // Example where we have to search is Midas room 21 + TRRoomSector sector = level.GetRoomSector(entity.X, entity.Y - TRConsts.Step1, entity.Z, entity.Room); + while (sector.RoomBelow != TRConsts.NoRoom) + { + if (level.Rooms[sector.RoomBelow].ContainsWater) + { + return true; + } + sector = level.GetRoomSector(entity.X, (sector.Floor + 1) * TRConsts.Step1, entity.Z, sector.RoomBelow); + } + return false; + } + + private static void AdjustCentaurStatue(TR1Entity entity, TR1Level level) + { + // If they're floating, they tend not to trigger as Lara's not within range + TR1LocationGenerator locationGenerator = new(); + + int y = entity.Y; + short room = entity.Room; + TRRoomSector sector = level.GetRoomSector(entity.X, y, entity.Z, room); + while (sector.RoomBelow != TRConsts.NoRoom) + { + y = (sector.Floor + 1) * TRConsts.Step1; + room = sector.RoomBelow; + sector = level.GetRoomSector(entity.X, y, entity.Z, room); + } + + entity.Y = sector.Floor * TRConsts.Step1; + entity.Room = room; + + // Change this GetHeight + if (sector.FDIndex != 0) + { + FDEntry entry = level.FloorData[sector.FDIndex].Find(e => e is FDSlantEntry s && s.Type == FDSlantType.Floor); + if (entry is FDSlantEntry slant) + { + Vector4? bestMidpoint = locationGenerator.GetBestSlantMidpoint(slant); + if (bestMidpoint.HasValue) + { + entity.Y += (int)bestMidpoint.Value.Y; + } + } + } + + entity.Invisible = false; + } + + public void AdjustUnkillableEnemies(string levelName, TR1Level level) + { + if (levelName == TR1LevelNames.EGYPT) + { + // The OG mummy normally falls out of sight when triggered, so move it. + level.Entities[_unkillableEgyptMummy].SetLocation(_egyptMummyLocation); + } + else if (levelName == TR1LevelNames.STRONGHOLD) + { + // There is a triggered centaur in room 18, plus several untriggered eggs for show. + // Move the centaur, and free the eggs to be repurposed elsewhere. + foreach (TR1Entity enemy in level.Entities.Where(e => e.Room == _unreachableStrongholdRoom)) + { + int index = level.Entities.IndexOf(enemy); + if (level.FloorData.GetEntityTriggers(index).Count == 0) + { + enemy.TypeID = TR1Type.CameraTarget_N; + ItemFactory.FreeItem(levelName, index); + } + else + { + enemy.SetLocation(_strongholdCentaurLocation); + } + } + } + } + + private static void AmendToQLarson(TR1Level level) + { + // Convert the Larson model into the Great Pyramid scion to allow ending the level. Larson will + // become a raptor to allow for normal randomization. Environment mods will handle the specifics here. + if (!level.Models.ChangeKey(TR1Type.Larson, TR1Type.ScionPiece3_S_P)) + { + return; + } + + level.Entities + .FindAll(e => e.TypeID == TR1Type.Larson) + .ForEach(e => e.TypeID = TR1Type.Raptor); + + // Make the scion invisible. + MeshEditor editor = new(); + foreach (TRMesh mesh in level.Models[TR1Type.ScionPiece3_S_P].Meshes) + { + editor.Mesh = mesh; + editor.ClearAllPolygons(); + } + } + + private void AmendPyramidTorso(TR1Level level) + { + // We want to keep Adam's egg, but simulate something else hatching. + // In hard mode, two enemies take his place. + level.Models.Remove(TR1Type.Adam); + + TR1Entity egg = level.Entities.Find(e => e.TypeID == TR1Type.AdamEgg); + TR1Entity lara = level.Entities.Find(e => e.TypeID == TR1Type.Lara); + + EMAppendTriggerActionFunction trigFunc = new() + { + Location = new() + { + X = lara.X, + Y = lara.Y, + Z = lara.Z, + Room = lara.Room + }, + Actions = new() + }; + + int count = Settings.RandoEnemyDifficulty == RandoDifficulty.Default ? 1 : 2; + for (int i = 0; i < count; i++) + { + trigFunc.Actions.Add(new() + { + Parameter = (short)level.Entities.Count + }); + + level.Entities.Add(new() + { + TypeID = TR1Type.Adam, + X = egg.X, + Y = egg.Y - i * TRConsts.Step4, + Z = egg.Z - TRConsts.Step4, + Room = egg.Room, + Angle = egg.Angle, + Intensity = egg.Intensity, + Invisible = true + }); + } + + trigFunc.ApplyToLevel(level); + } + + private void AmendAtlanteanModels(TR1Level level, EnemyRandomizationCollection enemies) + { + // If non-shooting grounded Atlanteans are present, we can just duplicate the model to make shooting Atlanteans + if (enemies.Available.Any(TR1TypeUtilities.GetFamily(TR1Type.ShootingAtlantean_N).Contains)) + { + TRModel shooter = level.Models[TR1Type.ShootingAtlantean_N]; + TRModel nonShooter = level.Models[TR1Type.NonShootingAtlantean_N]; + if (shooter == null && nonShooter != null) + { + shooter = nonShooter.Clone(); + level.Models[TR1Type.ShootingAtlantean_N] = shooter; + enemies.Available.Add(TR1Type.ShootingAtlantean_N); + } + } + + // If we're using flying mummies, add a chance that they'll have proper wings + if (enemies.Available.Contains(TR1Type.BandagedFlyer) && Generator.NextDouble() < _mummyWingChance) + { + List meshes = level.Models[TR1Type.FlyingAtlantean].Meshes; + ushort bandageTexture = meshes[1].TexturedRectangles[3].Texture; + for (int i = 15; i < 21; i++) + { + TRMesh mesh = meshes[i]; + foreach (TRMeshFace face in mesh.TexturedFaces) + { + face.Texture = bandageTexture; + } + } + } + } + + public void AddUnarmedLevelAmmo(string levelName, TR1Level level, Action createItemCallback) + { + if (!Settings.CrossLevelEnemies || !Settings.GiveUnarmedItems) + { + return; + } + + // Find out which gun we have for this level + List weaponTypes = TR1TypeUtilities.GetWeaponPickups(); + TR1Entity weaponEntity = level.Entities.Find(e => + weaponTypes.Contains(e.TypeID) + && _pistolLocations[levelName].Any(l => l.IsEquivalent(e.GetLocation()))); + + if (weaponEntity == null) + { + return; + } + + Location weaponLocation = weaponEntity.GetLocation(); + + List allEnemies = TR1TypeUtilities.GetFullListOfEnemies(); + List levelEnemies = level.Entities.FindAll(e => allEnemies.Contains(e.TypeID)); + + for (int i = 0; i < level.Entities.Count; i++) + { + TR1Entity entity = level.Entities[i]; + if ((entity.TypeID == TR1Type.AtlanteanEgg || entity.TypeID == TR1Type.AdamEgg) + && level.FloorData.GetEntityTriggers(i).Any()) + { + TR1Entity resultantEnemy = new() + { + TypeID = TR1EnemyUtilities.CodeBitsToAtlantean(entity.CodeBits) + }; + + if (level.Models.ContainsKey(resultantEnemy.TypeID)) + { + levelEnemies.Add(resultantEnemy); + } + } + } + + EnemyDifficulty difficulty = TR1EnemyUtilities.GetEnemyDifficulty(levelEnemies); + + if (difficulty > EnemyDifficulty.Medium + && !level.Entities.Any(e => e.TypeID == TR1Type.Pistols_S_P)) + { + createItemCallback(weaponLocation, TR1Type.Pistols_S_P); + } + + if (difficulty > EnemyDifficulty.Easy) + { + while (weaponEntity.TypeID == TR1Type.Pistols_S_P) + { + weaponEntity.TypeID = weaponTypes[Generator.Next(0, weaponTypes.Count)]; + } + } + + TR1Type weaponType = weaponEntity.TypeID; + int ammoAllocation = TR1EnemyUtilities.GetStartingAmmo(weaponType); + if (ammoAllocation > 0) + { + ammoAllocation *= (int)difficulty; + TR1Type ammoType = TR1TypeUtilities.GetWeaponAmmo(weaponType); + for (int i = 0; i < ammoAllocation; i++) + { + createItemCallback(weaponLocation, ammoType); + } + } + + if (difficulty == EnemyDifficulty.Medium || difficulty == EnemyDifficulty.Hard) + { + createItemCallback(weaponLocation, TR1Type.SmallMed_S_P); + createItemCallback(weaponLocation, TR1Type.LargeMed_S_P); + } + if (difficulty > EnemyDifficulty.Medium) + { + createItemCallback(weaponLocation, TR1Type.LargeMed_S_P); + } + if (difficulty == EnemyDifficulty.VeryHard) + { + createItemCallback(weaponLocation, TR1Type.LargeMed_S_P); + } + } +} diff --git a/TRRandomizerCore/Randomizers/TR2/Classic/TR2EnemyRandomizer.cs b/TRRandomizerCore/Randomizers/TR2/Classic/TR2EnemyRandomizer.cs index 6e94d474d..7d431d7cb 100644 --- a/TRRandomizerCore/Randomizers/TR2/Classic/TR2EnemyRandomizer.cs +++ b/TRRandomizerCore/Randomizers/TR2/Classic/TR2EnemyRandomizer.cs @@ -1,5 +1,4 @@ -using System.Diagnostics; -using TRDataControl; +using TRDataControl; using TRGE.Core; using TRImageControl.Packing; using TRLevelControl.Helpers; @@ -14,22 +13,26 @@ namespace TRRandomizerCore.Randomizers; public class TR2EnemyRandomizer : BaseTR2Randomizer { - private Dictionary> _gameEnemyTracker; - private List _excludedEnemies; - private ISet _resultantEnemies; + private static readonly double _cloneChance = 0.5; + + private TR2EnemyAllocator _allocator; - internal int MaxPackingAttempts { get; set; } internal TR2TextureMonitorBroker TextureMonitor { get; set; } public ItemFactory ItemFactory { get; set; } - public TR2EnemyRandomizer() - { - MaxPackingAttempts = 5; - } - public override void Randomize(int seed) { - _generator = new Random(seed); + _generator = new(seed); + _allocator = new() + { + Settings = Settings, + ItemFactory = ItemFactory, + Generator = _generator, + GameLevels = Levels.Select(l => l.LevelFileBaseName), + DragonLevels = TR2LevelNames.AsList, + }; + _allocator.Initialise(); + if (Settings.CrossLevelEnemies) { RandomizeEnemiesCrossLevel(); @@ -42,20 +45,12 @@ public override void Randomize(int seed) private void RandomizeExistingEnemies() { - _excludedEnemies = new List(); - _resultantEnemies = new HashSet(); - foreach (TR2ScriptedLevel lvl in Levels) { - //Read the level into a combined data/script level object LoadLevelInstance(lvl); - - //Apply the modifications RandomizeEnemiesNatively(_levelInstance); - //Write back the level file SaveLevelInstance(); - if (!TriggerProgress()) { break; @@ -63,16 +58,20 @@ private void RandomizeExistingEnemies() } } - private void RandomizeEnemiesCrossLevel() + private void RandomizeEnemiesNatively(TR2CombinedLevel level) { - MaxPackingAttempts = Math.Max(1, MaxPackingAttempts); + EnemyRandomizationCollection enemies = _allocator.RandomizeEnemiesNatively(level.Name, level.Data); + ApplyPostRandomization(level, enemies); + } + private void RandomizeEnemiesCrossLevel() + { SetMessage("Randomizing enemies - loading levels"); List processors = new(); for (int i = 0; i < _maxThreads; i++) { - processors.Add(new EnemyProcessor(this)); + processors.Add(new(this)); } List levels = new(Levels.Count); @@ -95,817 +94,69 @@ private void RandomizeEnemiesCrossLevel() processorIndex = processorIndex == _maxThreads - 1 ? 0 : processorIndex + 1; } - // Track enemies whose counts across the game are restricted - _gameEnemyTracker = TR2EnemyUtilities.PrepareEnemyGameTracker(Settings.DocileChickens, Settings.RandoEnemyDifficulty); - - // #272 Selective enemy pool - convert the shorts in the settings to actual entity types - _excludedEnemies = Settings.UseEnemyExclusions ? - Settings.ExcludedEnemies.Select(s => (TR2Type)s).ToList() : - new List(); - _resultantEnemies = new HashSet(); - SetMessage("Randomizing enemies - importing models"); - foreach (EnemyProcessor processor in processors) - { - processor.Start(); - } - - foreach (EnemyProcessor processor in processors) - { - processor.Join(); - } + processors.ForEach(p => p.Start()); + processors.ForEach(p => p.Join()); if (!SaveMonitor.IsCancelled && _processingException == null) { SetMessage("Randomizing enemies - saving levels"); - foreach (EnemyProcessor processor in processors) - { - processor.ApplyRandomization(); - } + processors.ForEach(p => p.ApplyRandomization()); } _processingException?.Throw(); - // If any exclusions failed to be avoided, send a message - if (Settings.ShowExclusionWarnings) - { - VerifyExclusionStatus(); - } - } - - private void VerifyExclusionStatus() - { - List failedExclusions = _resultantEnemies.ToList().FindAll(_excludedEnemies.Contains); - if (failedExclusions.Count > 0) - { - // A little formatting - List failureNames = new(); - foreach (TR2Type entity in failedExclusions) - { - failureNames.Add(Settings.ExcludableEnemies[(short)entity]); - } - failureNames.Sort(); - SetWarning(string.Format("The following enemies could not be excluded entirely from the randomization pool.{0}{0}{1}", Environment.NewLine, string.Join(Environment.NewLine, failureNames))); - } - } - - private EnemyTransportCollection SelectCrossLevelEnemies(TR2CombinedLevel level, int reduceEnemyCountBy = 0) - { - // For the assault course, nothing will be imported for the time being - if (level.IsAssault) - { - return null; - } - - // Get the list of enemy types currently in the level - List oldEntities = TR2TypeUtilities.GetEnemyTypeDictionary()[level.Name]; - - // Work out how many we can support - int enemyCount = oldEntities.Count - reduceEnemyCountBy + TR2EnemyUtilities.GetEnemyAdjustmentCount(level.Name); - List newEntities = new(enemyCount); - - List chickenGuisers = TR2EnemyUtilities.GetEnemyGuisers(TR2Type.BirdMonster); - TR2Type chickenGuiser = TR2Type.BirdMonster; - - RandoDifficulty difficulty = GetImpliedDifficulty(); - - // #148 For HSH, we lock the enemies that are required for the kill counter to work outside - // the gate, which means the game still has the correct target kill count, while allowing - // us to randomize the ones inside the gate (except the final shotgun goon). - // If however, we are on the final packing attempt, we will just change the stick goon - // alias and add docile bird monsters (if selected) as this is known to be supported. - if (level.Is(TR2LevelNames.HOME) && reduceEnemyCountBy > 0) - { - TR2Type newGoon = TR2Type.StickWieldingGoon1BlackJacket; - List goonies = TR2TypeUtilities.GetFamily(newGoon); - do - { - newGoon = goonies[_generator.Next(0, goonies.Count)]; - } - while (newGoon == TR2Type.StickWieldingGoon1BlackJacket); - - newEntities.AddRange(oldEntities); - newEntities.Remove(TR2Type.StickWieldingGoon1); - newEntities.Add(newGoon); - - if (Settings.DocileChickens) - { - newEntities.Remove(TR2Type.MaskedGoon1); - newEntities.Add(TR2Type.BirdMonster); - chickenGuiser = TR2Type.MaskedGoon1; - } - } - else - { - // Do we need at least one water creature? - bool waterEnemyRequired = TR2EnemyUtilities.IsWaterEnemyRequired(level); - // Do we need at least one enemy that can drop? - bool droppableEnemyRequired = TR2EnemyUtilities.IsDroppableEnemyRequired(level); - - // Let's try to populate the list. Start by adding one water enemy and one droppable - // enemy if they are needed. If we want to exclude, try to select based on user priority. - if (waterEnemyRequired) - { - List waterEnemies = TR2TypeUtilities.KillableWaterCreatures(); - newEntities.Add(SelectRequiredEnemy(waterEnemies, level, difficulty)); - } - - if (droppableEnemyRequired) - { - List droppableEnemies = TR2TypeUtilities.GetCrossLevelDroppableEnemies(!Settings.ProtectMonks, Settings.UnconditionalChickens); - newEntities.Add(SelectRequiredEnemy(droppableEnemies, level, difficulty)); - } - - // Are there any other types we need to retain? - foreach (TR2Type entity in TR2EnemyUtilities.GetRequiredEnemies(level.Name)) - { - if (!newEntities.Contains(entity)) - { - newEntities.Add(entity); - } - } - - // Some secrets may have locked enemies in place - we must retain those types - foreach (int itemIndex in ItemFactory.GetLockedItems(level.Name)) - { - TR2Entity item = level.Data.Entities[itemIndex]; - if (TR2TypeUtilities.IsEnemyType(item.TypeID)) - { - List family = TR2TypeUtilities.GetFamily(TR2TypeUtilities.GetAliasForLevel(level.Name, item.TypeID)); - if (!newEntities.Any(family.Contains)) - { - newEntities.Add(family[_generator.Next(0, family.Count)]); - } - } - } - - // Get all other candidate supported enemies - List allEnemies = TR2TypeUtilities.GetCandidateCrossLevelEnemies() - .FindAll(e => TR2EnemyUtilities.IsEnemySupported(level.Name, e, difficulty, Settings.ProtectMonks)); - if (Settings.OneEnemyMode || Settings.IncludedEnemies.Count < newEntities.Capacity || Settings.DragonSpawnType == DragonSpawnType.Minimum) - { - // Marco isn't excludable in his own right because supporting a dragon-only game is impossible. - // If we want a minimum dragon game, he is excluded here as well (for Lair he is required, so already added above). - allEnemies.Remove(TR2Type.MarcoBartoli); - } - - // Remove all exclusions from the pool, and adjust the target capacity - allEnemies.RemoveAll(e => _excludedEnemies.Contains(e)); - - IEnumerable ex = allEnemies.Where(e => !newEntities.Any(TR2TypeUtilities.GetFamily(e).Contains)); - List unalisedEntities = TR2TypeUtilities.RemoveAliases(ex); - while (unalisedEntities.Count < newEntities.Capacity - newEntities.Count) - { - --newEntities.Capacity; - } - - // Fill the list from the remaining candidates. Keep track of ones tested to avoid - // looping infinitely if it's not possible to fill to capacity - ISet testedEntities = new HashSet(); - while (newEntities.Count < newEntities.Capacity && testedEntities.Count < allEnemies.Count) - { - TR2Type entity; - // Try to enforce Marco's appearance, but only if this isn't the final packing attempt - if (Settings.DragonSpawnType == DragonSpawnType.Maximum - && !newEntities.Contains(TR2Type.MarcoBartoli) - && TR2EnemyUtilities.IsEnemySupported(level.Name, TR2Type.MarcoBartoli, difficulty, Settings.ProtectMonks) - && reduceEnemyCountBy == 0) - { - entity = TR2Type.MarcoBartoli; - } - else - { - entity = allEnemies[_generator.Next(0, allEnemies.Count)]; - } - - testedEntities.Add(entity); - - int adjustmentCount = TR2EnemyUtilities.GetTargetEnemyAdjustmentCount(level.Name, entity); - if (!Settings.OneEnemyMode && adjustmentCount != 0) - { - while (newEntities.Count > 0 && newEntities.Count >= newEntities.Capacity + adjustmentCount) - { - newEntities.RemoveAt(newEntities.Count - 1); - } - newEntities.Capacity += adjustmentCount; - } - - // Check if the use of this enemy triggers an overwrite of the pool, for example - // the dragon in HSH. Null means nothing special has been defined. - List> restrictedCombinations = TR2EnemyUtilities.GetPermittedCombinations(level.Name, entity, difficulty); - if (restrictedCombinations != null) - { - do - { - // Pick a combination, ensuring we honour docile bird monsters if present, - // and try to select a group that doesn't contain an excluded enemy. - newEntities.Clear(); - newEntities.AddRange(restrictedCombinations[_generator.Next(0, restrictedCombinations.Count)]); - } - while (Settings.DocileChickens && newEntities.Contains(TR2Type.BirdMonster) && chickenGuisers.All(g => newEntities.Contains(g)) - || (newEntities.Any(_excludedEnemies.Contains) && restrictedCombinations.Any(c => !c.Any(_excludedEnemies.Contains)))); - break; - } - - // If it's the chicken in HSH with default behaviour, we don't want it ending the level - if (Settings.DefaultChickens && entity == TR2Type.BirdMonster && level.Is(TR2LevelNames.HOME) && allEnemies.Except(newEntities).Count() > 1) - { - continue; - } - - // If this is a tracked enemy throughout the game, we only allow it if the number - // of unique levels is within the limit. Bear in mind we are collecting more than - // one group of enemies per level. - if (_gameEnemyTracker.ContainsKey(entity) && !_gameEnemyTracker[entity].Contains(level.Name)) - { - if (_gameEnemyTracker[entity].Count < _gameEnemyTracker[entity].Capacity) - { - // The entity is allowed, so store the fact that this level will have it - _gameEnemyTracker[entity].Add(level.Name); - } - else - { - // Otherwise, pick something else. If we tried to previously exclude this - // enemy and couldn't, it will slip through the net and so the appearances - // will increase. - if (allEnemies.Except(newEntities).Count() > 1) - { - continue; - } - } - } - - // GetEntityFamily returns all aliases for the likes of the tigers, but if an entity - // doesn't have any, the returned list just contains the entity itself. This means - // we can avoid duplicating standard enemies as well as avoiding alias-clashing. - List family = TR2TypeUtilities.GetFamily(entity); - if (!newEntities.Any(e1 => family.Any(e2 => e1 == e2))) - { - // #144 We can include docile chickens provided we aren't including everything - // that can be disguised as a chicken. - if (Settings.DocileChickens) - { - bool guisersAvailable = !chickenGuisers.All(g => newEntities.Contains(g)); - // If the selected entity is the chicken, it can be added provided there are - // available guisers. - if (!guisersAvailable && entity == TR2Type.BirdMonster) - { - continue; - } - - // If the selected entity is a potential guiser, it can only be added if it's not - // the last available guiser. Otherwise, it will become the guiser. - if (chickenGuisers.Contains(entity) && newEntities.Contains(TR2Type.BirdMonster)) - { - if (newEntities.FindAll(e => chickenGuisers.Contains(e)).Count == chickenGuisers.Count - 1) - { - continue; - } - } - } - - newEntities.Add(entity); - } - } - } - - // If everything we are including is restriced by room, we need to provide at least one other enemy type - Dictionary> restrictedRoomEnemies = TR2EnemyUtilities.GetRestrictedEnemyRooms(level.Name, difficulty); - if (restrictedRoomEnemies != null && newEntities.All(e => restrictedRoomEnemies.ContainsKey(e))) - { - List pool = TR2TypeUtilities.GetCrossLevelDroppableEnemies(!Settings.ProtectMonks, Settings.UnconditionalChickens); - do - { - TR2Type fallbackEnemy; - do - { - fallbackEnemy = pool[_generator.Next(0, pool.Count)]; - } - while ((_excludedEnemies.Contains(fallbackEnemy) && pool.Any(e => !_excludedEnemies.Contains(e))) - || newEntities.Contains(fallbackEnemy) - || !TR2EnemyUtilities.IsEnemySupported(level.Name, fallbackEnemy, difficulty, Settings.ProtectMonks)); - newEntities.Add(fallbackEnemy); - } - while (newEntities.All(e => restrictedRoomEnemies.ContainsKey(e))); - } - else + string statusMessage = _allocator.GetExclusionStatusMessage(); + if (statusMessage != null) { - // #345 Barkhang/Opera with only Winstons causes freezing issues - List friends = TR2EnemyUtilities.GetFriendlyEnemies(); - if ((level.Is(TR2LevelNames.OPERA) || level.Is(TR2LevelNames.MONASTERY)) && newEntities.All(friends.Contains)) - { - // Add an additional "safe" enemy - so pick from the droppable range, monks and chickens excluded - List droppableEnemies = TR2TypeUtilities.GetCrossLevelDroppableEnemies(false, false); - newEntities.Add(SelectRequiredEnemy(droppableEnemies, level, difficulty)); - } - } - - // #144 Decide at this point who will be guising unless it has already been decided above (e.g. HSH) - if (Settings.DocileChickens && newEntities.Contains(TR2Type.BirdMonster) && chickenGuiser == TR2Type.BirdMonster) - { - int guiserIndex = chickenGuisers.FindIndex(g => !newEntities.Contains(g)); - if (guiserIndex != -1) - { - chickenGuiser = chickenGuisers[guiserIndex]; - } + SetWarning(statusMessage); } - - return new EnemyTransportCollection - { - TypesToImport = newEntities, - TypesToRemove = oldEntities, - BirdMonsterGuiser = chickenGuiser - }; } - private TR2Type SelectRequiredEnemy(List pool, TR2CombinedLevel level, RandoDifficulty difficulty) + private void RandomizeEnemies(TR2CombinedLevel level, EnemyRandomizationCollection enemies) { - pool.RemoveAll(e => !TR2EnemyUtilities.IsEnemySupported(level.Name, e, difficulty, Settings.ProtectMonks)); - - TR2Type entity; - if (pool.All(_excludedEnemies.Contains)) - { - // Select the last excluded enemy (lowest priority) - entity = _excludedEnemies.Last(e => pool.Contains(e)); - } - else - { - do - { - entity = pool[_generator.Next(0, pool.Count)]; - } - while (_excludedEnemies.Contains(entity)); - } - - return entity; + _allocator.RandomizeEnemies(level.Name, level.Data, enemies); + ApplyPostRandomization(level, enemies); } - private RandoDifficulty GetImpliedDifficulty() + private void ApplyPostRandomization(TR2CombinedLevel level, EnemyRandomizationCollection enemies) { - if (_excludedEnemies.Count > 0 && Settings.RandoEnemyDifficulty == RandoDifficulty.Default) - { - // If every enemy in the pool has room restrictions for any level, we have to imply NoRestrictions difficulty mode - List includedEnemies = Settings.ExcludableEnemies.Keys.Except(Settings.ExcludedEnemies).Select(s => (TR2Type)s).ToList(); - foreach (TR2ScriptedLevel level in Levels) - { - IEnumerable restrictedRoomEnemies = TR2EnemyUtilities.GetRestrictedEnemyRooms(level.LevelFileBaseName.ToUpper(), RandoDifficulty.Default).Keys; - if (includedEnemies.All(e => restrictedRoomEnemies.Contains(e) || _gameEnemyTracker.ContainsKey(e))) - { - return RandoDifficulty.NoRestrictions; - } - } - } - return Settings.RandoEnemyDifficulty; + MakeChickensUnconditional(level.Data); + RandomizeEnemyMeshes(level, enemies); } - private void RandomizeEnemiesNatively(TR2CombinedLevel level) + private void MakeChickensUnconditional(TR2Level level) { - // For the assault course, nothing will be changed for the time being - if (level.IsAssault) + if (!Settings.UnconditionalChickens) { return; } - List availableEnemyTypes = TR2TypeUtilities.GetEnemyTypeDictionary()[level.Name]; - List droppableEnemies = TR2TypeUtilities.DroppableEnemyTypes()[level.Name]; - List waterEnemies = TR2TypeUtilities.FilterWaterEnemies(availableEnemyTypes); - - if (Settings.DocileChickens && level.Is(TR2LevelNames.CHICKEN)) - { - DisguiseEntity(level, TR2Type.MaskedGoon1, TR2Type.BirdMonster); - } - - RandomizeEnemies(level, new EnemyRandomizationCollection - { - Available = availableEnemyTypes, - Droppable = droppableEnemies, - Water = waterEnemies, - All = new List(availableEnemyTypes), - BirdMonsterGuiser = TR2Type.MaskedGoon1 // If randomizing natively, this will only apply to Ice Palace - }); - } - - private static void DisguiseEntity(TR2CombinedLevel level, TR2Type guiser, TR2Type targetType) - { - if (targetType == TR2Type.BirdMonster && level.Is(TR2LevelNames.CHICKEN)) - { - // We have to keep the original model for the boss, so in - // this instance we just clone the model for the guiser - level.Data.Models[guiser] = level.Data.Models[targetType].Clone(); - } - else - { - level.Data.Models.ChangeKey(targetType, guiser); - } - } - - private void RandomizeEnemies(TR2CombinedLevel level, EnemyRandomizationCollection enemies) - { - bool shotgunGoonSeen = level.Is(TR2LevelNames.HOME); // 1 ShotgunGoon in HSH only - bool dragonSeen = level.Is(TR2LevelNames.LAIR); // 1 Marco in DL only - - // Get a list of current enemy entities - List enemyEntities = level.GetEnemyEntities(); - - RandoDifficulty difficulty = GetImpliedDifficulty(); - - if (level.Is(TR2LevelNames.HOME) && !enemies.Available.Contains(TR2Type.Doberman)) - { - // The game requires 15 items of type dog, stick goon or masked goon. The models will have been - // eliminated at this stage, so just create a placeholder to trigger the correct HSH behaviour. - level.Data.Models[TR2Type.Doberman] = new() - { - Meshes = new() { level.Data.Models[TR2Type.Lara].Meshes.First() } - }; - for (int i = 0; i < 15; i++) - { - level.Data.Entities.Add(new() - { - TypeID = TR2Type.Doberman, - Room = 85, - X = 61952, - Y = 2560, - Z = 74240, - Invisible = true, - }); - } - } - - // First iterate through any enemies that are restricted by room - Dictionary> enemyRooms = TR2EnemyUtilities.GetRestrictedEnemyRooms(level.Name, difficulty); - if (enemyRooms != null) - { - foreach (TR2Type entity in enemyRooms.Keys) - { - if (!enemies.Available.Contains(entity)) - { - continue; - } - - List rooms = enemyRooms[entity]; - int maxEntityCount = TR2EnemyUtilities.GetRestrictedEnemyLevelCount(entity, difficulty); - if (maxEntityCount == -1) - { - // We are allowed any number, but this can't be more than the number of unique rooms, - // so we will assume 1 per room as these restricted enemies are likely to be tanky. - maxEntityCount = rooms.Count; - } - else - { - maxEntityCount = Math.Min(maxEntityCount, rooms.Count); - } - - // Pick an actual count - int enemyCount = _generator.Next(1, maxEntityCount + 1); - for (int i = 0; i < enemyCount; i++) - { - // Find an entity in one of the rooms that the new enemy is restricted to - TR2Entity targetEntity = null; - do - { - int room = enemyRooms[entity][_generator.Next(0, enemyRooms[entity].Count)]; - targetEntity = enemyEntities.Find(e => e.Room == room); - } - while (targetEntity == null); - - // If the room has water but this enemy isn't a water enemy, we will assume that environment - // modifications will handle assignment of the enemy to entities. - if (!TR2TypeUtilities.IsWaterCreature(entity) && level.Data.Rooms[targetEntity.Room].ContainsWater) - { - continue; - } - - targetEntity.TypeID = TR2TypeUtilities.TranslateAlias(entity); - - // #146 Ensure OneShot triggers are set for this enemy if needed - TR2EnemyUtilities.SetEntityTriggers(level.Data, targetEntity); - - // Remove the target entity so it doesn't get replaced - enemyEntities.Remove(targetEntity); - } - - // Remove this entity type from the available rando pool - enemies.Available.Remove(entity); - } - } - - foreach (TR2Entity currentEntity in enemyEntities) - { - TR2Type currentEntityType = currentEntity.TypeID; - TR2Type newEntityType = currentEntityType; - int enemyIndex = level.Data.Entities.IndexOf(currentEntity); - - // If it's an existing enemy that has to remain in the same spot, skip it - if (TR2EnemyUtilities.IsEnemyRequired(level.Name, currentEntityType) - || ItemFactory.IsItemLocked(level.Name, enemyIndex)) - { - continue; - } - - // Generate a new type, ensuring to test for item drops - newEntityType = enemies.Available[_generator.Next(0, enemies.Available.Count)]; - bool hasPickupItem = level.Data.Entities - .Any(item => TR2EnemyUtilities.HasDropItem(currentEntity, item)); - - if (hasPickupItem - && !TR2TypeUtilities.CanDropPickups(newEntityType, !Settings.ProtectMonks, Settings.UnconditionalChickens)) - { - newEntityType = enemies.Droppable[_generator.Next(0, enemies.Droppable.Count)]; - } - - short roomIndex = currentEntity.Room; - TR2Room room = level.Data.Rooms[roomIndex]; - - if (level.Is(TR2LevelNames.DA) && roomIndex == 77) - { - // Make sure the end level trigger isn't blocked by an unkillable enemy - while (TR2TypeUtilities.IsHazardCreature(newEntityType) || (Settings.ProtectMonks && TR2TypeUtilities.IsMonk(newEntityType))) - { - newEntityType = enemies.Available[_generator.Next(0, enemies.Available.Count)]; - } - } - - if (TR2TypeUtilities.IsWaterCreature(currentEntityType) && !TR2TypeUtilities.IsWaterCreature(newEntityType)) - { - // Check alternate rooms too - e.g. rooms 74/48 in 40 Fathoms - short roomDrainIndex = -1; - if (room.ContainsWater) - { - roomDrainIndex = roomIndex; - } - else if (room.AlternateRoom != -1 && level.Data.Rooms[room.AlternateRoom].ContainsWater) - { - roomDrainIndex = room.AlternateRoom; - } - - if (roomDrainIndex != -1) - { - // Draining cannot be performed so make the entity a water creature. - // The list of provided water creatures will either be those native - // to this level, or if randomizing cross-level, a pre-check will - // have already been performed on draining so if it's not possible, - // at least one water creature will be available. - newEntityType = enemies.Water[_generator.Next(0, enemies.Water.Count)]; - } - } - - // Ensure that if we have to pick a different enemy at this point that we still - // honour any pickups in the same spot. - List enemyPool = hasPickupItem ? enemies.Droppable : enemies.Available; - - if (newEntityType == TR2Type.ShotgunGoon && shotgunGoonSeen) // HSH only - { - while (newEntityType == TR2Type.ShotgunGoon) - { - newEntityType = enemyPool[_generator.Next(0, enemyPool.Count)]; - } - } - - if (newEntityType == TR2Type.MarcoBartoli && dragonSeen) // DL only, other levels use quasi-zoning for the dragon - { - while (newEntityType == TR2Type.MarcoBartoli) - { - newEntityType = enemyPool[_generator.Next(0, enemyPool.Count)]; - } - } - - // #278 Flamethrowers in room 29 after pulling the lever are too difficult, but if difficulty is set to unrestricted - // and they do end up here, environment mods will change their positions. - int totalRestrictionCount = TR2EnemyUtilities.GetRestrictedEnemyTotalTypeCount(difficulty); - if (level.Is(TR2LevelNames.FLOATER) && difficulty == RandoDifficulty.Default && (enemyIndex == 34 || enemyIndex == 35) && enemyPool.Count > totalRestrictionCount) - { - while (newEntityType == TR2Type.FlamethrowerGoon) - { - newEntityType = enemyPool[_generator.Next(0, enemyPool.Count)]; - } - } - - // If we are restricting count per level for this enemy and have reached that count, pick - // something else. This applies when we are restricting by in-level count, but not by room - // (e.g. Winston). - int maxEntityCount = TR2EnemyUtilities.GetRestrictedEnemyLevelCount(newEntityType, difficulty); - if (maxEntityCount != -1) - { - if (level.Data.Entities.FindAll(e => e.TypeID == newEntityType).Count >= maxEntityCount && enemyPool.Count > totalRestrictionCount) - { - TR2Type tmp = newEntityType; - while (newEntityType == tmp) - { - newEntityType = enemyPool[_generator.Next(0, enemyPool.Count)]; - } - } - } - - // #144 Disguise something as the Chicken. Pre-checks will have been done to ensure - // the guiser is suitable for the level. - if (Settings.DocileChickens && newEntityType == TR2Type.BirdMonster) - { - newEntityType = enemies.BirdMonsterGuiser; - } - - // Make sure to convert BengalTiger, StickWieldingGoonBandana etc back to their actual types - currentEntity.TypeID = TR2TypeUtilities.TranslateAlias(newEntityType); - - // #146 Ensure OneShot triggers are set for this enemy if needed. This currently only applies - // to the dragon, which will be handled above in defined rooms, but the check should be made - // here in case this needs to be extended later. - TR2EnemyUtilities.SetEntityTriggers(level.Data, currentEntity); - - // Track every enemy type across the game - _resultantEnemies.Add(newEntityType); - } - - // MercSnowMobDriver relies on RedSnowmobile so it will be available in the model list - if (!level.Is(TR2LevelNames.TIBET)) - { - TR2Entity mercDriver = level.Data.Entities.Find(e => e.TypeID == TR2Type.MercSnowmobDriver); - if (mercDriver != null) - { - TR2Entity skidoo = new() - { - TypeID = TR2Type.RedSnowmobile, - Intensity1 = -1, - Intensity2 = -1 - }; - level.Data.Entities.Add(skidoo); - - Location randomLocation = VehicleUtilities.GetRandomLocation(level, TR2Type.RedSnowmobile, _generator); - if (randomLocation != null) - { - skidoo.Room = randomLocation.Room; - skidoo.X = randomLocation.X; - skidoo.Y = randomLocation.Y; - skidoo.Z = randomLocation.Z; - skidoo.Angle = randomLocation.Angle; - } - else - { - skidoo.Room = mercDriver.Room; - skidoo.X = mercDriver.X; - skidoo.Y = mercDriver.Y; - skidoo.Z = mercDriver.Z; - skidoo.Angle = mercDriver.Angle; - } - } - } - else - { - TR2Entity skidoo = level.Data.Entities.Find(e => e.TypeID == TR2Type.RedSnowmobile); - if (skidoo != null) - { - Location randomLocation = VehicleUtilities.GetRandomLocation(level, TR2Type.RedSnowmobile, _generator); - if (randomLocation != null) - { - skidoo.Room = randomLocation.Room; - skidoo.X = randomLocation.X; - skidoo.Y = randomLocation.Y; - skidoo.Z = randomLocation.Z; - skidoo.Angle = randomLocation.Angle; - } - else - { - // A secret depends on this skidoo, so just rotate it for variety. - skidoo.Angle = (short)(_generator.Next(0, 8) * (ushort.MaxValue + 1) / 8); - } - } - } - - // Check in case there are too many skidoo drivers - if (level.Data.Entities.Any(e => e.TypeID == TR2Type.MercSnowmobDriver)) - { - LimitSkidooEntities(level); - } - - // Or too many friends - #345 - List friends = TR2EnemyUtilities.GetFriendlyEnemies(); - if ((level.Is(TR2LevelNames.OPERA) || level.Is(TR2LevelNames.MONASTERY)) && enemies.Available.Any(friends.Contains)) - { - LimitFriendlyEnemies(level, enemies.Available.Except(friends).ToList(), friends); - } - - if (Settings.SwapEnemyAppearance) - { - RandomizeEnemyMeshes(level, enemies); - } - - if (Settings.UnconditionalChickens) - { - MakeChickensUnconditional(level); - } - - if (!Settings.AllowEnemyKeyDrops && (!Settings.RandomizeItems || !Settings.IncludeKeyItems)) - { - // Shift enemies who are on top of key items so they don't pick them up. - IEnumerable keyEnemies = level.Data.Entities.Where(enemy => TR2TypeUtilities.IsEnemyType(enemy.TypeID) - && level.Data.Entities.Any(key => TR2TypeUtilities.IsKeyItemType(key.TypeID) - && key.GetLocation().IsEquivalent(enemy.GetLocation())) - ); - - foreach (TR2Entity enemy in keyEnemies) - { - enemy.X++; - } - } - } - - private void LimitSkidooEntities(TR2CombinedLevel level) - { - // Ensure that the total implied enemy count does not exceed that of the original - // level. The limit actually varies depending on the number of traps and other objects - // so for those levels with high entity counts, we further restrict the limit. - int skidooLimit = TR2EnemyUtilities.GetSkidooDriverLimit(level.Name); - - List enemies = level.GetEnemyEntities(); - int normalEnemyCount = enemies.FindAll(e => e.TypeID != TR2Type.MercSnowmobDriver).Count; - int skidooMenCount = enemies.Count - normalEnemyCount; - int skidooRemovalCount = skidooMenCount - skidooMenCount / 2; - if (skidooLimit > 0) - { - while (skidooMenCount - skidooRemovalCount > skidooLimit) - { - ++skidooRemovalCount; - } - } - - if (skidooRemovalCount == 0) - { - return; - } - - List pickupLocations = level.Data.Entities - .Where(e => TR2TypeUtilities.IsAnyPickupType(e.TypeID) && !TR2TypeUtilities.IsSecretType(e.TypeID)) - .Select(e => e.GetLocation()) - .ToList(); - - List replacementPool; - if (!Settings.RandomizeItems || Settings.RandoItemDifficulty == ItemDifficulty.Default) - { - // The user is not specifically attempting one-item rando, so we can add anything as replacements - replacementPool = TR2TypeUtilities.GetAmmoTypes(); - } - else - { - // Camera targets don't take up any savegame space, so in one-item mode use these as replacements - replacementPool = new() { TR2Type.CameraTarget_N }; - } - - List skidMen; - for (int i = 0; i < skidooRemovalCount; i++) - { - skidMen = level.Data.Entities.FindAll(e => e.TypeID == TR2Type.MercSnowmobDriver); - if (skidMen.Count == 0) - { - break; - } - - // Select a random Skidoo driver and convert him into something else - TR2Entity skidMan = skidMen[_generator.Next(0, skidMen.Count)]; - TR2Type newType = replacementPool[_generator.Next(0, replacementPool.Count)]; - skidMan.TypeID = newType; - skidMan.Invisible = false; - - if (TR2TypeUtilities.IsAnyPickupType(newType)) - { - // Move the pickup to another pickup location - skidMan.SetLocation(pickupLocations[_generator.Next(0, pickupLocations.Count)]); - } - - // Get rid of the old enemy's triggers - level.Data.FloorData.RemoveEntityTriggers(level.Data.Entities.IndexOf(skidMan)); - } - } - - private void LimitFriendlyEnemies(TR2CombinedLevel level, List pool, List friends) - { - // Hard limit of 20 friendly enemies in trap-heavy levels to avoid freezing issues - const int limit = 20; - List levelFriends = level.Data.Entities.FindAll(e => friends.Contains(e.TypeID)); - while (levelFriends.Count > limit) + // #327 Trick the game into never reaching the final frame of the death animation. + // This results in a very abrupt death but avoids the level ending. For Ice Palace, + // environment modifications will be made to enforce an alternative ending. + TRAnimation birdDeathAnim = level.Models[TR2Type.BirdMonster]?.Animations[20]; + if (birdDeathAnim != null) { - TR2Entity entity = levelFriends[_generator.Next(0, levelFriends.Count)]; - entity.TypeID = TR2TypeUtilities.TranslateAlias(pool[_generator.Next(0, pool.Count)]); - levelFriends.Remove(entity); + birdDeathAnim.FrameEnd = -1; } } - private void RandomizeEnemyMeshes(TR2CombinedLevel level, EnemyRandomizationCollection enemies) + private void RandomizeEnemyMeshes(TR2CombinedLevel level, EnemyRandomizationCollection enemies) { - // #314 A very primitive start to mixing-up enemy meshes - monks and yetis can take on Lara's meshes - // without manipulation, so add a random chance of this happening if any of these models are in place. - if (!Settings.CrossLevelEnemies) + if (!Settings.CrossLevelEnemies || !Settings.SwapEnemyAppearance) { return; } List laraClones = new(); - const int chance = 2; if (!Settings.DocileChickens) { - AddRandomLaraClone(enemies, TR2Type.MonkWithKnifeStick, laraClones, chance); - AddRandomLaraClone(enemies, TR2Type.MonkWithLongStick, laraClones, chance); + AddRandomLaraClone(enemies, TR2Type.MonkWithKnifeStick, laraClones); + AddRandomLaraClone(enemies, TR2Type.MonkWithLongStick, laraClones); } - AddRandomLaraClone(enemies, TR2Type.Yeti, laraClones, chance); + AddRandomLaraClone(enemies, TR2Type.Yeti, laraClones); if (laraClones.Count > 0) { @@ -935,7 +186,7 @@ private void RandomizeEnemyMeshes(TR2CombinedLevel level, EnemyRandomizationColl if (enemies.All.Contains(TR2Type.MarcoBartoli) && enemies.All.Contains(TR2Type.Winston) - && _generator.Next(0, chance) == 0) + && _generator.NextDouble() < _cloneChance) { // Make Marco look and behave like Winston, until Lara gets too close TRModel marcoModel = level.Data.Models[TR2Type.MarcoBartoli]; @@ -946,76 +197,55 @@ private void RandomizeEnemyMeshes(TR2CombinedLevel level, EnemyRandomizationColl } } - private void AddRandomLaraClone(EnemyRandomizationCollection enemies, TR2Type enemyType, List cloneCollection, int chance) + private void AddRandomLaraClone(EnemyRandomizationCollection enemies, TR2Type enemyType, List cloneCollection) { - if (enemies.All.Contains(enemyType) && _generator.Next(0, chance) == 0) + if (enemies.All.Contains(enemyType) && _generator.NextDouble() < _cloneChance) { cloneCollection.Add(enemyType); } } - private static void MakeChickensUnconditional(TR2CombinedLevel level) - { - // #327 Trick the game into never reaching the final frame of the death animation. - // This results in a very abrupt death but avoids the level ending. For Ice Palace, - // environment modifications will be made to enforce an alternative ending. - TRAnimation birdDeathAnim = level.Data.Models[TR2Type.BirdMonster]?.Animations[20]; - if (birdDeathAnim != null) - { - birdDeathAnim.FrameEnd = -1; - } - } - internal class EnemyProcessor : AbstractProcessorThread { - private readonly Dictionary> _enemyMapping; + private const int _maxPackingAttempts = 5; + + private readonly Dictionary>> _enemyMapping; internal override int LevelCount => _enemyMapping.Count; internal EnemyProcessor(TR2EnemyRandomizer outer) : base(outer) { - _enemyMapping = new Dictionary>(); + _enemyMapping = new(); } internal void AddLevel(TR2CombinedLevel level) { - _enemyMapping.Add(level, new List(_outer.MaxPackingAttempts)); + _enemyMapping.Add(level, new()); } protected override void StartImpl() { - // Load initially outwith the processor thread to ensure the RNG selected for each - // level/enemy group remains consistent between randomization sessions. We allocate - // MaxPackingAttempts number of enemy collections to attempt for packing. On the final - // attempt, the number of entities will be reduced by one. List levels = new(_enemyMapping.Keys); foreach (TR2CombinedLevel level in levels) { - int count = _enemyMapping[level].Capacity; - for (int i = 0; i < count; i++) + for (int i = 0; i < _maxPackingAttempts; i++) { - _enemyMapping[level].Add(_outer.SelectCrossLevelEnemies(level, i == count - 1 ? 1 : 0)); + _enemyMapping[level].Add(_outer._allocator + .SelectCrossLevelEnemies(level.Name, level.Data, i == _maxPackingAttempts - 1 ? 1 : 0)); } } } - // Executed in parallel, so just store the import result to process later synchronously. protected override void ProcessImpl() { foreach (TR2CombinedLevel level in _enemyMapping.Keys) { if (!level.IsAssault) { - int count = _enemyMapping[level].Capacity; - for (int i = 0; i < count; i++) + for (int i = 0; i < _maxPackingAttempts; i++) { - //if (i > 0) - //{ - // _outer.SetMessage(string.Format("Randomizing enemies [{0} - attempt {1} / {2}]", level.Name, i + 1, _outer.MaxPackingAttempts)); - //} - - EnemyTransportCollection enemies = _enemyMapping[level][i]; + EnemyTransportCollection enemies = _enemyMapping[level][i]; if (Import(level, enemies)) { enemies.ImportResult = true; @@ -1031,12 +261,10 @@ protected override void ProcessImpl() } } - private bool Import(TR2CombinedLevel level, EnemyTransportCollection enemies) + private bool Import(TR2CombinedLevel level, EnemyTransportCollection enemies) { try { - // The importer will handle any duplication between the entities to import and - // remove so just pass the unfiltered lists to it. TR2DataImporter importer = new() { ClearUnusedSprites = true, @@ -1045,13 +273,12 @@ private bool Import(TR2CombinedLevel level, EnemyTransportCollection enemies) Level = level.Data, LevelName = level.Name, DataFolder = _outer.GetResourcePath(@"TR2\Objects"), - TextureRemapPath = _outer.GetResourcePath(@"TR2\Textures\Deduplication\" + level.JsonID + "-TextureRemap.json"), + TextureRemapPath = _outer.GetResourcePath($@"TR2\Textures\Deduplication\{level.JsonID}-TextureRemap.json"), TextureMonitor = _outer.TextureMonitor.CreateMonitor(level.Name, enemies.TypesToImport) }; importer.Data.AliasPriority = TR2EnemyUtilities.GetAliasPriority(level.Name, enemies.TypesToImport); - // Try to import the selected models into the level. importer.Import(); return true; } @@ -1059,21 +286,19 @@ private bool Import(TR2CombinedLevel level, EnemyTransportCollection enemies) { // We need to reload the level to undo anything that may have changed. _outer.ReloadLevelData(level); - // Tell the monitor to no longer track what we tried to import _outer.TextureMonitor.ClearMonitor(level.Name, enemies.TypesToImport); return false; } } - // This is triggered synchronously after the import work to ensure the RNG remains consistent internal void ApplyRandomization() { foreach (TR2CombinedLevel level in _enemyMapping.Keys) { if (!level.IsAssault) { - EnemyTransportCollection importedCollection = null; - foreach (EnemyTransportCollection enemies in _enemyMapping[level]) + EnemyTransportCollection importedCollection = null; + foreach (EnemyTransportCollection enemies in _enemyMapping[level]) { if (enemies.ImportResult) { @@ -1084,43 +309,29 @@ internal void ApplyRandomization() if (importedCollection == null) { - // Cross-level was not possible with the enemy combinations. This could be due to either - // a lack of space for texture packing, or the max ObjectTexture count (2048) was reached. + // Cross-level was not possible with the enemy combinations, so just go native. _outer.TextureMonitor.RemoveMonitor(level.Name); - - // And just randomize normally - // TODO: maybe trigger a warning to display at the end of randomizing to say that cross- - // level was not possible? _outer.RandomizeEnemiesNatively(level); - //System.Diagnostics.Debug.WriteLine(level.Name + ": Native enemies"); } else { - // The import worked, so randomize the entities based on what we now have in place. - // All refers to the unmodified list so that checks such as those in RandomizeEnemyMeshes - // can refer to the original list, as actual entity randomization may remove models. - EnemyRandomizationCollection enemies = new() + EnemyRandomizationCollection enemies = new() { Available = importedCollection.TypesToImport, Droppable = TR2TypeUtilities.FilterDroppableEnemies(importedCollection.TypesToImport, !_outer.Settings.ProtectMonks, _outer.Settings.UnconditionalChickens), Water = TR2TypeUtilities.FilterWaterEnemies(importedCollection.TypesToImport), - All = new List(importedCollection.TypesToImport) + All = new(importedCollection.TypesToImport) }; if (_outer.Settings.DocileChickens && importedCollection.BirdMonsterGuiser != TR2Type.BirdMonster) { - DisguiseEntity(level, importedCollection.BirdMonsterGuiser, TR2Type.BirdMonster); + TR2EnemyAllocator.DisguiseType(level.Name, level.Data, importedCollection.BirdMonsterGuiser, TR2Type.BirdMonster); enemies.BirdMonsterGuiser = importedCollection.BirdMonsterGuiser; } _outer.RandomizeEnemies(level, enemies); - if (_outer.Settings.DevelopmentMode) - { - Debug.WriteLine(level.Name + ": " + string.Join(", ", enemies.All)); - } + _outer.SaveLevel(level); } - - _outer.SaveLevel(level); } if (!_outer.TriggerProgress()) @@ -1130,26 +341,4 @@ internal void ApplyRandomization() } } } - - internal class EnemyTransportCollection - { - internal List TypesToImport { get; set; } - internal List TypesToRemove { get; set; } - internal TR2Type BirdMonsterGuiser { get; set; } - internal bool ImportResult { get; set; } - - internal EnemyTransportCollection() - { - ImportResult = false; - } - } - - internal class EnemyRandomizationCollection - { - internal List Available { get; set; } - internal List Droppable { get; set; } - internal List Water { get; set; } - internal List All { get; set; } - internal TR2Type BirdMonsterGuiser { get; set; } - } } diff --git a/TRRandomizerCore/Randomizers/TR2/Classic/TR2ItemRandomizer.cs b/TRRandomizerCore/Randomizers/TR2/Classic/TR2ItemRandomizer.cs index cfe55031e..a1b2a1b5c 100644 --- a/TRRandomizerCore/Randomizers/TR2/Classic/TR2ItemRandomizer.cs +++ b/TRRandomizerCore/Randomizers/TR2/Classic/TR2ItemRandomizer.cs @@ -683,7 +683,7 @@ private void RandomizeVehicles() int checkCount = 0; while (location2ndBoat.IsEquivalent(vehicles[entity]) && checkCount < 5)//compare locations in bottom of water ( authorize 5 round max in case there is only 1 valid location) { - location2ndBoat = VehicleUtilities.GetRandomLocation(_levelInstance, TR2Type.Boat, _generator, false); + location2ndBoat = VehicleUtilities.GetRandomLocation(_levelInstance.Name, _levelInstance.Data, TR2Type.Boat, _generator, false); checkCount++; } @@ -722,7 +722,7 @@ private void RandomizeVehicles() /// Dictionnary EntityType/location private void PopulateVehicleLocation(TR2Type entity, Dictionary locationMap) { - Location location = VehicleUtilities.GetRandomLocation(_levelInstance, entity, _generator); + Location location = VehicleUtilities.GetRandomLocation(_levelInstance.Name, _levelInstance.Data, entity, _generator); if (location != null) { locationMap[entity] = location; diff --git a/TRRandomizerCore/Randomizers/TR2/Remastered/TR2REnemyRandomizer.cs b/TRRandomizerCore/Randomizers/TR2/Remastered/TR2REnemyRandomizer.cs new file mode 100644 index 000000000..fcb0e3944 --- /dev/null +++ b/TRRandomizerCore/Randomizers/TR2/Remastered/TR2REnemyRandomizer.cs @@ -0,0 +1,329 @@ +using Newtonsoft.Json; +using System.Diagnostics; +using TRDataControl; +using TRGE.Core; +using TRLevelControl.Helpers; +using TRLevelControl.Model; +using TRRandomizerCore.Helpers; +using TRRandomizerCore.Levels; +using TRRandomizerCore.Processors; +using TRRandomizerCore.Utilities; + +namespace TRRandomizerCore.Randomizers; + +public class TR2REnemyRandomizer : BaseTR2RRandomizer +{ + private static readonly List _dragonLevels = new() + { + TR2LevelNames.GW, + TR2LevelNames.DORIA, + TR2LevelNames.DECK, + TR2LevelNames.TIBET, + TR2LevelNames.COT, + TR2LevelNames.CHICKEN, + TR2LevelNames.XIAN, + }; + + private Dictionary> _pistolLocations; + private TR2EnemyAllocator _allocator; + + public TR2RDataCache DataCache { get; set; } + public ItemFactory ItemFactory { get; set; } + + public override void Randomize(int seed) + { + _generator = new(seed); + _allocator = new() + { + Settings = Settings, + ItemFactory = ItemFactory, + Generator = _generator, + GameLevels = Levels.Select(l => l.LevelFileBaseName), + DragonLevels = _dragonLevels, + }; + _allocator.Initialise(); + + _pistolLocations = JsonConvert.DeserializeObject>>(ReadResource(@"TR2\Locations\unarmed_locations.json")); + if (Settings.CrossLevelEnemies) + { + RandomizeEnemiesCrossLevel(); + } + else + { + RandomizeExistingEnemies(); + } + } + + private void RandomizeExistingEnemies() + { + foreach (TRRScriptedLevel lvl in Levels) + { + LoadLevelInstance(lvl); + RandomizeEnemiesNatively(_levelInstance); + + SaveLevelInstance(); + if (!TriggerProgress()) + { + break; + } + } + } + + private void RandomizeEnemiesNatively(TR2RCombinedLevel level) + { + _allocator.RandomizeEnemiesNatively(level.Name, level.Data); + ApplyPostRandomization(level); + } + + private void RandomizeEnemiesCrossLevel() + { + SetMessage("Randomizing enemies - loading levels"); + + List processors = new(); + for (int i = 0; i < _maxThreads; i++) + { + processors.Add(new(this)); + } + + List levels = new(Levels.Count); + foreach (TRRScriptedLevel lvl in Levels) + { + levels.Add(LoadCombinedLevel(lvl)); + if (!TriggerProgress()) + { + return; + } + } + + int processorIndex = 0; + foreach (TR2RCombinedLevel level in levels) + { + processors[processorIndex].AddLevel(level); + processorIndex = processorIndex == _maxThreads - 1 ? 0 : processorIndex + 1; + } + + SetMessage("Randomizing enemies - importing models"); + processors.ForEach(p => p.Start()); + processors.ForEach(p => p.Join()); + + if (!SaveMonitor.IsCancelled && _processingException == null) + { + SetMessage("Randomizing enemies - saving levels"); + processors.ForEach(p => p.ApplyRandomization()); + } + + _processingException?.Throw(); + + string statusMessage = _allocator.GetExclusionStatusMessage(); + if (statusMessage != null) + { + SetWarning(statusMessage); + } + } + + private void RandomizeEnemies(TR2RCombinedLevel level, EnemyRandomizationCollection enemies) + { + _allocator.RandomizeEnemies(level.Name, level.Data, enemies); + ApplyPostRandomization(level); + } + + private void ApplyPostRandomization(TR2RCombinedLevel level) + { + RestoreHSHDog(level); + MakeChickensUnconditional(level); + AddUnarmedItems(level); + } + + private static void RestoreHSHDog(TR2RCombinedLevel level) + { + if (!level.Is(TR2LevelNames.HOME)) + { + return; + } + + // This will have been eliminated earlier, but a dummy model is still needed in the PDP. + level.PDPData[TR2Type.Doberman] = new(); + } + + private void MakeChickensUnconditional(TR2RCombinedLevel level) + { + if (level.Is(TR2LevelNames.CHICKEN) || !Settings.UnconditionalChickens) + { + return; + } + + TRAnimation birdDeathAnim = level.Data.Models[TR2Type.BirdMonster]?.Animations[20]; + if (birdDeathAnim != null) + { + birdDeathAnim.FrameEnd = -1; + } + + birdDeathAnim = level.PDPData[TR2Type.BirdMonster]?.Animations[20]; + if (birdDeathAnim != null) + { + birdDeathAnim.FrameEnd = -1; + } + } + + private void AddUnarmedItems(TR2RCombinedLevel level) + { + if (!level.Script.RemovesWeapons) + { + return; + } + + // Only applies to Rig and HSH. + // - Pistols guaranteed in Rig + // - Pistols break HSH, so just add a silly amount of shotgun shells + // - Extra meds loosely based on difficulty + List enemies = level.Data.Entities.FindAll(e => TR2TypeUtilities.GetFullListOfEnemies().Contains(e.TypeID)); + EnemyDifficulty difficulty = TR2EnemyUtilities.GetEnemyDifficulty(enemies); + + TR2Entity item = level.Data.Entities.Find(e => + (e.TypeID == TR2Type.Pistols_S_P || TR2TypeUtilities.IsGunType(e.TypeID)) + && _pistolLocations[level.Name].Any(l => l.IsEquivalent(e.GetLocation()))); + + item ??= level.Data.Entities.Find(e => TR2TypeUtilities.IsAnyPickupType(e.TypeID)); + item ??= level.Data.Entities.Find(e => e.TypeID == TR2Type.Lara); + + if (item == null) + { + return; + } + + void AddItem(TR2Type type, int count) + { + for (int i = 0; i < count; i++) + { + item = (TR2Entity)item.Clone(); + item.TypeID = type; + level.Data.Entities.Add(item); + } + } + + if (level.Is(TR2LevelNames.HOME)) + { + const int shellCount = 8; + AddItem(TR2Type.ShotgunAmmo_S_P, shellCount * (int)difficulty * 2); + } + else if (!level.Data.Entities.Any(e => e.TypeID == TR2Type.Pistols_S_P)) + { + AddItem(TR2Type.Pistols_S_P, 1); + } + + if (Settings.GiveUnarmedItems) + { + int smallMeds = 0; + int largeMeds = 0; + + if (difficulty >= EnemyDifficulty.Medium) + { + smallMeds++; + largeMeds++; + } + while (difficulty-- >= EnemyDifficulty.Medium) + { + largeMeds++; + } + + AddItem(TR2Type.SmallMed_S_P, smallMeds); + AddItem(TR2Type.LargeMed_S_P, largeMeds); + } + } + + internal class EnemyProcessor : AbstractProcessorThread + { + private readonly Dictionary> _enemyMapping; + + internal override int LevelCount => _enemyMapping.Count; + + internal EnemyProcessor(TR2REnemyRandomizer outer) + : base(outer) + { + _enemyMapping = new(); + } + + internal void AddLevel(TR2RCombinedLevel level) + { + _enemyMapping.Add(level, new()); + } + + protected override void StartImpl() + { + List levels = new(_enemyMapping.Keys); + foreach (TR2RCombinedLevel level in levels) + { + _enemyMapping[level] = _outer._allocator.SelectCrossLevelEnemies(level.Name, level.Data); + } + } + + protected override void ProcessImpl() + { + foreach (TR2RCombinedLevel level in _enemyMapping.Keys) + { + if (!level.IsAssault) + { + EnemyTransportCollection enemies = _enemyMapping[level]; + TR2DataImporter importer = new(true) + { + TypesToImport = enemies.TypesToImport, + TypesToRemove = enemies.TypesToRemove, + Level = level.Data, + LevelName = level.Name, + DataFolder = _outer.GetResourcePath(@"TR2\Objects"), + }; + + importer.Data.TextureObjectLimit = RandoConsts.TRRTexLimit; + importer.Data.TextureTileLimit = RandoConsts.TRRTileLimit; + + string remapPath = $@"TR2\Textures\Deduplication\{level.Name}-TextureRemap.json"; + if (_outer.ResourceExists(remapPath)) + { + importer.TextureRemapPath = _outer.GetResourcePath(remapPath); + } + + importer.Data.AliasPriority = TR2EnemyUtilities.GetAliasPriority(level.Name, enemies.TypesToImport); + + ImportResult result = importer.Import(); + _outer.DataCache.Merge(result, level.PDPData, level.MapData); + } + + if (!_outer.TriggerProgress()) + { + break; + } + } + } + + internal void ApplyRandomization() + { + foreach (TR2RCombinedLevel level in _enemyMapping.Keys) + { + if (!level.IsAssault) + { + EnemyTransportCollection importedCollection = _enemyMapping[level]; + EnemyRandomizationCollection enemies = new() + { + Available = importedCollection.TypesToImport, + Droppable = TR2TypeUtilities.FilterDroppableEnemies(importedCollection.TypesToImport, !_outer.Settings.ProtectMonks, _outer.Settings.UnconditionalChickens), + Water = TR2TypeUtilities.FilterWaterEnemies(importedCollection.TypesToImport), + All = new(importedCollection.TypesToImport) + }; + + _outer.RandomizeEnemies(level, enemies); + if (_outer.Settings.DevelopmentMode) + { + Debug.WriteLine(level.Name + ": " + string.Join(", ", enemies.All)); + } + + _outer.SaveLevel(level); + } + + if (!_outer.TriggerProgress()) + { + break; + } + } + } + } +} diff --git a/TRRandomizerCore/Randomizers/TR2/Shared/TR2EnemyAllocator.cs b/TRRandomizerCore/Randomizers/TR2/Shared/TR2EnemyAllocator.cs new file mode 100644 index 000000000..dd9fbe776 --- /dev/null +++ b/TRRandomizerCore/Randomizers/TR2/Shared/TR2EnemyAllocator.cs @@ -0,0 +1,666 @@ +using TRLevelControl; +using TRLevelControl.Helpers; +using TRLevelControl.Model; +using TRRandomizerCore.Helpers; +using TRRandomizerCore.Utilities; + +namespace TRRandomizerCore.Randomizers; + +public class TR2EnemyAllocator : EnemyAllocator +{ + private const int _friendlyEnemyLimit = 20; + private const int _hshPlaceholderCount = 15; + private const int _platformEndRoom = 77; + + private static readonly List _floaterFlameEnemies = new() { 34, 35 }; + private static readonly TR2Entity _hshPlaceholderDog = new() + { + TypeID = TR2Type.Doberman, + X = 61952, + Y = 2560, + Z = 74240, + Room = 85, + Invisible = true, + }; + + private static readonly List _friendlyLimitLevels = new() + { + TR2LevelNames.OPERA, + TR2LevelNames.MONASTERY, + }; + + public List DragonLevels { get; set; } + public ItemFactory ItemFactory { get; set; } + + protected override Dictionary> GetGameTracker() + => TR2EnemyUtilities.PrepareEnemyGameTracker(Settings.DocileChickens, Settings.RandoEnemyDifficulty); + + protected override bool IsEnemySupported(string levelName, TR2Type type, RandoDifficulty difficulty) + => TR2EnemyUtilities.IsEnemySupported(levelName, type, difficulty, Settings.ProtectMonks); + + protected override Dictionary> GetRestrictedRooms(string levelName, RandoDifficulty difficulty) + => TR2EnemyUtilities.GetRestrictedEnemyRooms(levelName, RandoDifficulty.Default); + + protected override bool IsOneShotType(TR2Type type) + => type == TR2Type.MarcoBartoli; + + public EnemyTransportCollection SelectCrossLevelEnemies(string levelName, TR2Level level, int reduceEnemyCountBy = 0) + { + if (levelName == TR2LevelNames.ASSAULT) + { + return null; + } + + List oldTypes = TR2TypeUtilities.GetEnemyTypeDictionary()[levelName]; + int enemyCount = oldTypes.Count - reduceEnemyCountBy + TR2EnemyUtilities.GetEnemyAdjustmentCount(levelName); + List newTypes = new(enemyCount); + + List chickenGuisers = TR2EnemyUtilities.GetEnemyGuisers(TR2Type.BirdMonster); + TR2Type chickenGuiser = TR2Type.BirdMonster; + + RandoDifficulty difficulty = GetImpliedDifficulty(); + + if (levelName == TR2LevelNames.HOME && reduceEnemyCountBy > 0) + { + // #148 Fallback for HSH if all but the final packing attempt has failed. + TR2Type newGoon = TR2Type.StickWieldingGoon1BlackJacket; + List goonies = TR2TypeUtilities.GetFamily(newGoon); + do + { + newGoon = goonies[Generator.Next(0, goonies.Count)]; + } + while (newGoon == TR2Type.StickWieldingGoon1BlackJacket); + + newTypes.AddRange(oldTypes); + newTypes.Remove(TR2Type.StickWieldingGoon1); + newTypes.Add(newGoon); + + if (Settings.DocileChickens) + { + newTypes.Remove(TR2Type.MaskedGoon1); + newTypes.Add(TR2Type.BirdMonster); + chickenGuiser = TR2Type.MaskedGoon1; + } + } + else + { + if (TR2EnemyUtilities.IsWaterEnemyRequired(level)) + { + List waterEnemies = TR2TypeUtilities.KillableWaterCreatures(); + newTypes.Add(SelectRequiredEnemy(waterEnemies, levelName, difficulty)); + } + + if (TR2EnemyUtilities.IsDroppableEnemyRequired(level)) + { + List droppableEnemies = TR2TypeUtilities.GetCrossLevelDroppableEnemies(!Settings.ProtectMonks, Settings.UnconditionalChickens); + newTypes.Add(SelectRequiredEnemy(droppableEnemies, levelName, difficulty)); + } + + foreach (TR2Type type in TR2EnemyUtilities.GetRequiredEnemies(levelName)) + { + if (!newTypes.Contains(type)) + { + newTypes.Add(type); + } + } + + // Some secrets may have locked enemies in place - we must retain those types + foreach (int itemIndex in ItemFactory.GetLockedItems(levelName)) + { + TR2Entity item = level.Entities[itemIndex]; + if (TR2TypeUtilities.IsEnemyType(item.TypeID)) + { + List family = TR2TypeUtilities.GetFamily(TR2TypeUtilities.GetAliasForLevel(levelName, item.TypeID)); + if (!newTypes.Any(family.Contains)) + { + newTypes.Add(family[Generator.Next(0, family.Count)]); + } + } + } + + // Get all other candidate supported enemies + List allEnemies = TR2TypeUtilities.GetCandidateCrossLevelEnemies() + .FindAll(e => TR2EnemyUtilities.IsEnemySupported(levelName, e, difficulty, Settings.ProtectMonks)); + + if (Settings.OneEnemyMode + || Settings.IncludedEnemies.Count < newTypes.Capacity + || Settings.DragonSpawnType == DragonSpawnType.Minimum + || !DragonLevels.Contains(levelName)) + { + allEnemies.Remove(TR2Type.MarcoBartoli); + } + + // Remove all exclusions from the pool, and adjust the target capacity + allEnemies.RemoveAll(_excludedEnemies.Contains); + + IEnumerable ex = allEnemies.Where(e => !newTypes.Any(TR2TypeUtilities.GetFamily(e).Contains)); + List unalisedTypes = TR2TypeUtilities.RemoveAliases(ex); + while (unalisedTypes.Count < newTypes.Capacity - newTypes.Count) + { + --newTypes.Capacity; + } + + // Fill the remainder to capacity as randomly as we can + HashSet testedTypes = new(); + while (newTypes.Count < newTypes.Capacity && testedTypes.Count < allEnemies.Count) + { + TR2Type type; + // Try to enforce Marco's appearance, but only if this isn't the final packing attempt + if (Settings.DragonSpawnType == DragonSpawnType.Maximum + && !newTypes.Contains(TR2Type.MarcoBartoli) + && TR2EnemyUtilities.IsEnemySupported(levelName, TR2Type.MarcoBartoli, difficulty, Settings.ProtectMonks) + && reduceEnemyCountBy == 0) + { + type = TR2Type.MarcoBartoli; + } + else + { + type = allEnemies[Generator.Next(0, allEnemies.Count)]; + } + + testedTypes.Add(type); + + int adjustmentCount = TR2EnemyUtilities.GetTargetEnemyAdjustmentCount(levelName, type); + if (!Settings.OneEnemyMode && adjustmentCount != 0) + { + while (newTypes.Count > 0 && newTypes.Count >= newTypes.Capacity + adjustmentCount) + { + newTypes.RemoveAt(newTypes.Count - 1); + } + newTypes.Capacity += adjustmentCount; + } + + // Check if the use of this enemy triggers an overwrite of the pool, for example + // the dragon in HSH. Null means nothing special has been defined. + List> restrictedCombinations = TR2EnemyUtilities.GetPermittedCombinations(levelName, type, difficulty); + if (restrictedCombinations != null) + { + do + { + // Pick a combination, ensuring we honour docile bird monsters if present, + // and try to select a group that doesn't contain an excluded enemy. + newTypes.Clear(); + newTypes.AddRange(restrictedCombinations[Generator.Next(0, restrictedCombinations.Count)]); + } + while (Settings.DocileChickens && newTypes.Contains(TR2Type.BirdMonster) && chickenGuisers.All(g => newTypes.Contains(g)) + || (newTypes.Any(_excludedEnemies.Contains) && restrictedCombinations.Any(c => !c.Any(_excludedEnemies.Contains)))); + break; + } + + // If it's the chicken in HSH with default behaviour, we don't want it ending the level + if (Settings.DefaultChickens && type == TR2Type.BirdMonster && levelName == TR2LevelNames.HOME && allEnemies.Except(newTypes).Count() > 1) + { + continue; + } + + // If this is a tracked enemy throughout the game, we only allow it if the number + // of unique levels is within the limit. Bear in mind we are collecting more than + // one group of enemies per level. + if (_gameEnemyTracker.ContainsKey(type) && !_gameEnemyTracker[type].Contains(levelName)) + { + if (_gameEnemyTracker[type].Count < _gameEnemyTracker[type].Capacity) + { + _gameEnemyTracker[type].Add(levelName); + } + else + { + // If we tried to previously exclude this enemy and couldn't, it will slip + // through the net and so the appearances will increase. + if (allEnemies.Except(newTypes).Count() > 1) + { + continue; + } + } + } + + List family = TR2TypeUtilities.GetFamily(type); + if (!newTypes.Any(family.Contains)) + { + // #144 We can include docile chickens provided we aren't including everything + // that can be disguised as a chicken. + if (Settings.DocileChickens) + { + bool guisersAvailable = !chickenGuisers.All(newTypes.Contains); + if (!guisersAvailable && type == TR2Type.BirdMonster) + { + continue; + } + + // If the selected type is a potential guiser, it can only be added if it's not + // the last available guiser. Otherwise, it will become the guiser. + if (chickenGuisers.Contains(type) && newTypes.Contains(TR2Type.BirdMonster)) + { + if (newTypes.FindAll(chickenGuisers.Contains).Count == chickenGuisers.Count - 1) + { + continue; + } + } + } + + newTypes.Add(type); + } + } + } + + // If everything we are including is restriced by room, we need to provide at least one other enemy type + Dictionary> restrictedRoomEnemies = TR2EnemyUtilities.GetRestrictedEnemyRooms(levelName, difficulty); + if (restrictedRoomEnemies != null && newTypes.All(e => restrictedRoomEnemies.ContainsKey(e))) + { + List pool = TR2TypeUtilities.GetCrossLevelDroppableEnemies(!Settings.ProtectMonks, Settings.UnconditionalChickens); + do + { + TR2Type fallbackEnemy; + do + { + fallbackEnemy = pool[Generator.Next(0, pool.Count)]; + } + while ((_excludedEnemies.Contains(fallbackEnemy) && pool.Any(e => !_excludedEnemies.Contains(e))) + || newTypes.Contains(fallbackEnemy) + || !TR2EnemyUtilities.IsEnemySupported(levelName, fallbackEnemy, difficulty, Settings.ProtectMonks)); + newTypes.Add(fallbackEnemy); + } + while (newTypes.All(e => restrictedRoomEnemies.ContainsKey(e))); + } + else + { + // #345 Barkhang/Opera with only Winstons causes freezing issues + List friends = TR2EnemyUtilities.GetFriendlyEnemies(); + if (_friendlyLimitLevels.Contains(levelName) && newTypes.All(friends.Contains)) + { + // Add an additional "safe" enemy - so pick from the droppable range, monks and chickens excluded + List droppableEnemies = TR2TypeUtilities.GetCrossLevelDroppableEnemies(false, false); + newTypes.Add(SelectRequiredEnemy(droppableEnemies, levelName, difficulty)); + } + } + + // #144 Decide at this point who will be guising unless it has already been decided above (e.g. HSH) + if (Settings.DocileChickens && newTypes.Contains(TR2Type.BirdMonster) && chickenGuiser == TR2Type.BirdMonster) + { + int guiserIndex = chickenGuisers.FindIndex(g => !newTypes.Contains(g)); + if (guiserIndex != -1) + { + chickenGuiser = chickenGuisers[guiserIndex]; + } + } + + return new() + { + TypesToImport = newTypes, + TypesToRemove = oldTypes, + BirdMonsterGuiser = chickenGuiser + }; + } + + public static List GetEnemyEntities(TR2Level level) + { + List allEnemies = TR2TypeUtilities.GetFullListOfEnemies(); + return level.Entities.FindAll(e => allEnemies.Contains(e.TypeID)); + } + + public EnemyRandomizationCollection RandomizeEnemiesNatively(string levelName, TR2Level level) + { + if (levelName == TR2LevelNames.ASSAULT) + { + return null; + } + + List availableEnemyTypes = TR2TypeUtilities.GetEnemyTypeDictionary()[levelName]; + List droppableEnemies = TR2TypeUtilities.DroppableEnemyTypes()[levelName]; + List waterEnemies = TR2TypeUtilities.FilterWaterEnemies(availableEnemyTypes); + + if (Settings.DocileChickens && levelName == TR2LevelNames.CHICKEN) + { + DisguiseType(levelName, level, TR2Type.MaskedGoon1, TR2Type.BirdMonster); + } + + EnemyRandomizationCollection enemies = new() + { + Available = availableEnemyTypes, + Droppable = droppableEnemies, + Water = waterEnemies, + All = new(availableEnemyTypes), + BirdMonsterGuiser = TR2Type.MaskedGoon1, + }; + + RandomizeEnemies(levelName, level, enemies); + + return enemies; + } + + public static void DisguiseType(string levelName, TR2Level level, TR2Type guiser, TR2Type targetType) + { + if (targetType == TR2Type.BirdMonster && levelName == TR2LevelNames.CHICKEN) + { + // We have to keep the original model for the boss, so in + // this instance we just clone the model for the guiser + level.Models[guiser] = level.Models[targetType].Clone(); + } + else + { + level.Models.ChangeKey(targetType, guiser); + } + } + + public void RandomizeEnemies(string levelName, TR2Level level, EnemyRandomizationCollection enemies) + { + bool shotgunGoonSeen = levelName == TR2LevelNames.HOME; + bool dragonSeen = levelName == TR2LevelNames.LAIR; + + List enemyEntities = GetEnemyEntities(level); + RandoDifficulty difficulty = GetImpliedDifficulty(); + + if (levelName == TR2LevelNames.HOME && !enemies.Available.Contains(TR2Type.Doberman)) + { + // The game requires 15 items of type dog, stick goon or masked goon. The models will have been + // eliminated at this stage, so just create a placeholder to trigger the correct HSH behaviour. + level.Models[TR2Type.Doberman] = new() + { + Meshes = new() { level.Models[TR2Type.Lara].Meshes.First() } + }; + + short angleDiff = (short)Math.Ceiling(ushort.MaxValue / (_hshPlaceholderCount + 1d)); + for (int i = 0; i < _hshPlaceholderCount; i++) + { + level.Entities.Add((TR2Entity)_hshPlaceholderDog.Clone()); + level.Entities[^1].Angle -= (short)((i + 1) * angleDiff); + } + } + + // First iterate through any enemies that are restricted by room + Dictionary> enemyRooms = TR2EnemyUtilities.GetRestrictedEnemyRooms(levelName, difficulty); + if (enemyRooms != null) + { + foreach (TR2Type type in enemyRooms.Keys) + { + if (!enemies.Available.Contains(type)) + { + continue; + } + + List rooms = enemyRooms[type]; + int maxEntityCount = TR2EnemyUtilities.GetRestrictedEnemyLevelCount(type, difficulty); + if (maxEntityCount == -1) + { + // We are allowed any number, but this can't be more than the number of unique rooms, + // so we will assume 1 per room as these restricted enemies are likely to be tanky. + maxEntityCount = rooms.Count; + } + else + { + maxEntityCount = Math.Min(maxEntityCount, rooms.Count); + } + + // Pick an actual count + int enemyCount = Generator.Next(1, maxEntityCount + 1); + for (int i = 0; i < enemyCount; i++) + { + // Find an entity in one of the rooms that the new enemy is restricted to + TR2Entity targetEntity = null; + do + { + int room = enemyRooms[type][Generator.Next(0, enemyRooms[type].Count)]; + targetEntity = enemyEntities.Find(e => e.Room == room); + } + while (targetEntity == null); + + // If the room has water but this enemy isn't a water enemy, we will assume that environment + // modifications will handle assignment of the enemy to entities. + if (!TR2TypeUtilities.IsWaterCreature(type) && level.Rooms[targetEntity.Room].ContainsWater) + { + continue; + } + + targetEntity.TypeID = TR2TypeUtilities.TranslateAlias(type); + SetOneShot(targetEntity, level.Entities.IndexOf(targetEntity), level.FloorData); + enemyEntities.Remove(targetEntity); + } + + // Remove this entity type from the available rando pool + enemies.Available.Remove(type); + } + } + + foreach (TR2Entity currentEntity in enemyEntities) + { + TR2Type currentType = currentEntity.TypeID; + TR2Type newType = currentType; + int enemyIndex = level.Entities.IndexOf(currentEntity); + + // If it's an existing enemy that has to remain in the same spot, skip it + if (TR2EnemyUtilities.IsEnemyRequired(levelName, currentType) + || ItemFactory.IsItemLocked(levelName, enemyIndex)) + { + continue; + } + + // Generate a new type, ensuring to test for item drops + newType = enemies.Available[Generator.Next(0, enemies.Available.Count)]; + bool hasPickupItem = level.Entities + .Any(item => TR2EnemyUtilities.HasDropItem(currentEntity, item)); + + if (hasPickupItem + && !TR2TypeUtilities.CanDropPickups(newType, !Settings.ProtectMonks, Settings.UnconditionalChickens)) + { + newType = enemies.Droppable[Generator.Next(0, enemies.Droppable.Count)]; + } + + short roomIndex = currentEntity.Room; + TR2Room room = level.Rooms[roomIndex]; + + if (levelName == TR2LevelNames.DA && roomIndex == _platformEndRoom) + { + // Make sure the end level trigger isn't blocked by an unkillable enemy + while (TR2TypeUtilities.IsHazardCreature(newType) || (Settings.ProtectMonks && TR2TypeUtilities.IsMonk(newType))) + { + newType = enemies.Available[Generator.Next(0, enemies.Available.Count)]; + } + } + + if (TR2TypeUtilities.IsWaterCreature(currentType) && !TR2TypeUtilities.IsWaterCreature(newType)) + { + // Check alternate rooms too - e.g. rooms 74/48 in 40 Fathoms + short roomDrainIndex = -1; + if (room.ContainsWater) + { + roomDrainIndex = roomIndex; + } + else if (room.AlternateRoom != -1 && level.Rooms[room.AlternateRoom].ContainsWater) + { + roomDrainIndex = room.AlternateRoom; + } + + if (roomDrainIndex != -1) + { + newType = enemies.Water[Generator.Next(0, enemies.Water.Count)]; + } + } + + // Ensure that if we have to pick a different enemy at this point that we still + // honour any pickups in the same spot. + List enemyPool = hasPickupItem ? enemies.Droppable : enemies.Available; + + while (newType == TR2Type.ShotgunGoon && shotgunGoonSeen) // HSH only + { + newType = enemyPool[Generator.Next(0, enemyPool.Count)]; + } + + while (newType == TR2Type.MarcoBartoli && dragonSeen) // DL only, other levels use quasi-zoning for the dragon + { + newType = enemyPool[Generator.Next(0, enemyPool.Count)]; + } + + // #278 Flamethrowers in room 29 after pulling the lever are too difficult, but if difficulty is set to unrestricted + // and they do end up here, environment mods will change their positions. + int totalRestrictionCount = TR2EnemyUtilities.GetRestrictedEnemyTotalTypeCount(difficulty); + if (levelName == TR2LevelNames.FLOATER + && difficulty == RandoDifficulty.Default + && _floaterFlameEnemies.Contains(enemyIndex) + && enemyPool.Count > totalRestrictionCount) + { + while (newType == TR2Type.FlamethrowerGoon) + { + newType = enemyPool[Generator.Next(0, enemyPool.Count)]; + } + } + + // If we are restricting count per level for this enemy and have reached that count, pick + // something else. This applies when we are restricting by in-level count, but not by room + // (e.g. Winston). + int maxEntityCount = TR2EnemyUtilities.GetRestrictedEnemyLevelCount(newType, difficulty); + if (maxEntityCount != -1) + { + if (level.Entities.FindAll(e => e.TypeID == newType).Count >= maxEntityCount + && enemyPool.Count > totalRestrictionCount) + { + TR2Type tmp = newType; + while (newType == tmp) + { + newType = enemyPool[Generator.Next(0, enemyPool.Count)]; + } + } + } + + // Final step is to convert/set the type and ensure OneShot is set if needed (#146) + if (Settings.DocileChickens && newType == TR2Type.BirdMonster) + { + newType = enemies.BirdMonsterGuiser; + } + + currentEntity.TypeID = TR2TypeUtilities.TranslateAlias(newType); + SetOneShot(currentEntity, enemyIndex, level.FloorData); + _resultantEnemies.Add(newType); + } + + // MercSnowMobDriver relies on RedSnowmobile so it will be available in the model list + if (levelName != TR2LevelNames.TIBET) + { + TR2Entity mercDriver = level.Entities.Find(e => e.TypeID == TR2Type.MercSnowmobDriver); + if (mercDriver != null) + { + TR2Entity skidoo = new() + { + TypeID = TR2Type.RedSnowmobile, + Intensity1 = -1, + Intensity2 = -1 + }; + level.Entities.Add(skidoo); + + Location randomLocation = VehicleUtilities.GetRandomLocation(levelName, level, TR2Type.RedSnowmobile, Generator) + ?? mercDriver.GetLocation(); + skidoo.SetLocation(randomLocation); + } + } + else + { + TR2Entity skidoo = level.Entities.Find(e => e.TypeID == TR2Type.RedSnowmobile); + if (skidoo != null) + { + Location randomLocation = VehicleUtilities.GetRandomLocation(levelName, level, TR2Type.RedSnowmobile, Generator); + if (randomLocation != null) + { + skidoo.SetLocation(randomLocation); + } + else + { + // A secret depends on this skidoo, so just rotate it for variety. + skidoo.Angle = (short)(Generator.Next(0, 8) * -TRConsts.Angle45); + } + } + } + + // Check in case there are too many skidoo drivers + if (level.Entities.Any(e => e.TypeID == TR2Type.MercSnowmobDriver)) + { + LimitSkidooEntities(levelName, level); + } + + // Or too many friends - #345 + List friends = TR2EnemyUtilities.GetFriendlyEnemies(); + if (_friendlyLimitLevels.Contains(levelName) && enemies.Available.Any(friends.Contains)) + { + LimitFriendlyEnemies(level, enemies.Available.Except(friends).ToList(), friends); + } + + if (!Settings.AllowEnemyKeyDrops && (!Settings.RandomizeItems || !Settings.IncludeKeyItems)) + { + // Shift enemies who are on top of key items so they don't pick them up. + IEnumerable keyEnemies = level.Entities.Where(enemy => TR2TypeUtilities.IsEnemyType(enemy.TypeID) + && level.Entities.Any(key => TR2TypeUtilities.IsKeyItemType(key.TypeID) + && key.GetLocation().IsEquivalent(enemy.GetLocation())) + ); + + foreach (TR2Entity enemy in keyEnemies) + { + enemy.X++; + } + } + } + + private void LimitSkidooEntities(string levelName, TR2Level level) + { + // Ensure that the total implied enemy count does not exceed that of the original + // level. The limit actually varies depending on the number of traps and other objects + // so for those levels with high entity counts, we further restrict the limit. + int skidooLimit = TR2EnemyUtilities.GetSkidooDriverLimit(levelName); + + List enemies = GetEnemyEntities(level); + int normalEnemyCount = enemies.FindAll(e => e.TypeID != TR2Type.MercSnowmobDriver).Count; + int skidooMenCount = enemies.Count - normalEnemyCount; + int skidooRemovalCount = skidooMenCount - skidooMenCount / 2; + if (skidooLimit > 0) + { + while (skidooMenCount - skidooRemovalCount > skidooLimit) + { + ++skidooRemovalCount; + } + } + + if (skidooRemovalCount == 0) + { + return; + } + + List pickupLocations = level.Entities + .Where(e => TR2TypeUtilities.IsAnyPickupType(e.TypeID) && !TR2TypeUtilities.IsSecretType(e.TypeID)) + .Select(e => e.GetLocation()) + .ToList(); + + List replacementPool = !Settings.RandomizeItems || Settings.RandoItemDifficulty == ItemDifficulty.Default + ? TR2TypeUtilities.GetAmmoTypes() + : new() { TR2Type.CameraTarget_N }; + + List skidMen; + for (int i = 0; i < skidooRemovalCount; i++) + { + skidMen = level.Entities.FindAll(e => e.TypeID == TR2Type.MercSnowmobDriver); + if (skidMen.Count == 0) + { + break; + } + + // Select a random Skidoo driver and convert him into something else + TR2Entity skidMan = skidMen[Generator.Next(0, skidMen.Count)]; + TR2Type newType = replacementPool[Generator.Next(0, replacementPool.Count)]; + skidMan.TypeID = newType; + skidMan.Invisible = false; + + if (TR2TypeUtilities.IsAnyPickupType(newType)) + { + skidMan.SetLocation(pickupLocations[Generator.Next(0, pickupLocations.Count)]); + } + + level.FloorData.RemoveEntityTriggers(level.Entities.IndexOf(skidMan)); + } + } + + private void LimitFriendlyEnemies(TR2Level level, List pool, List friends) + { + List levelFriends = level.Entities.FindAll(e => friends.Contains(e.TypeID)); + while (levelFriends.Count > _friendlyEnemyLimit) + { + TR2Entity entity = levelFriends[Generator.Next(0, levelFriends.Count)]; + entity.TypeID = TR2TypeUtilities.TranslateAlias(pool[Generator.Next(0, pool.Count)]); + levelFriends.Remove(entity); + } + } +} diff --git a/TRRandomizerCore/Randomizers/TR3/Classic/TR3EnemyRandomizer.cs b/TRRandomizerCore/Randomizers/TR3/Classic/TR3EnemyRandomizer.cs index 65554dd54..c954f6231 100644 --- a/TRRandomizerCore/Randomizers/TR3/Classic/TR3EnemyRandomizer.cs +++ b/TRRandomizerCore/Randomizers/TR3/Classic/TR3EnemyRandomizer.cs @@ -1,8 +1,5 @@ -using Newtonsoft.Json; -using System.Diagnostics; -using TRDataControl; +using TRDataControl; using TRGE.Core; -using TRLevelControl; using TRLevelControl.Helpers; using TRLevelControl.Model; using TRRandomizerCore.Helpers; @@ -15,18 +12,22 @@ namespace TRRandomizerCore.Randomizers; public class TR3EnemyRandomizer : BaseTR3Randomizer { - private Dictionary> _gameEnemyTracker; - private Dictionary> _pistolLocations; - private List _excludedEnemies; - private ISet _resultantEnemies; + private TR3EnemyAllocator _allocator; internal TR3TextureMonitorBroker TextureMonitor { get; set; } public ItemFactory ItemFactory { get; set; } public override void Randomize(int seed) { - _generator = new Random(seed); - _pistolLocations = JsonConvert.DeserializeObject>>(ReadResource(@"TR3\Locations\unarmed_locations.json")); + _generator = new(seed); + _allocator = new() + { + Settings = Settings, + ItemFactory = ItemFactory, + Generator = _generator, + GameLevels = Levels.Select(l => l.LevelFileBaseName), + }; + _allocator.Initialise(); if (Settings.CrossLevelEnemies) { @@ -40,20 +41,13 @@ public override void Randomize(int seed) private void RandomizeExistingEnemies() { - _excludedEnemies = new List(); - _resultantEnemies = new HashSet(); - foreach (TR3ScriptedLevel lvl in Levels) { - //Read the level into a combined data/script level object LoadLevelInstance(lvl); + _allocator.RandomizeEnemiesNatively(_levelInstance.Name, _levelInstance.Data, _levelInstance.Sequence); + ApplyPostRandomization(_levelInstance); - //Apply the modifications - RandomizeEnemiesNatively(_levelInstance); - - //Write back the level file SaveLevelInstance(); - if (!TriggerProgress()) { break; @@ -68,7 +62,7 @@ private void RandomizeEnemiesCrossLevel() List processors = new(); for (int i = 0; i < _maxThreads; i++) { - processors.Add(new EnemyProcessor(this)); + processors.Add(new(this)); } List levels = new(Levels.Count); @@ -88,619 +82,54 @@ private void RandomizeEnemiesCrossLevel() processorIndex = processorIndex == _maxThreads - 1 ? 0 : processorIndex + 1; } - // Track enemies whose counts across the game are restricted - _gameEnemyTracker = TR3EnemyUtilities.PrepareEnemyGameTracker(Settings.RandoEnemyDifficulty); - - // #272 Selective enemy pool - convert the shorts in the settings to actual entity types - _excludedEnemies = Settings.UseEnemyExclusions ? - Settings.ExcludedEnemies.Select(s => (TR3Type)s).ToList() : - new List(); - _resultantEnemies = new HashSet(); - SetMessage("Randomizing enemies - importing models"); - foreach (EnemyProcessor processor in processors) - { - processor.Start(); - } - - foreach (EnemyProcessor processor in processors) - { - processor.Join(); - } + processors.ForEach(p => p.Start()); + processors.ForEach(p => p.Join()); if (!SaveMonitor.IsCancelled && _processingException == null) { SetMessage("Randomizing enemies - saving levels"); - foreach (EnemyProcessor processor in processors) - { - processor.ApplyRandomization(); - } + processors.ForEach(p => p.ApplyRandomization()); } _processingException?.Throw(); - // If any exclusions failed to be avoided, send a message - if (Settings.ShowExclusionWarnings) - { - VerifyExclusionStatus(); - } - } - - private void VerifyExclusionStatus() - { - List failedExclusions = _resultantEnemies.ToList().FindAll(_excludedEnemies.Contains); - if (failedExclusions.Count > 0) - { - // A little formatting - List failureNames = new(); - foreach (TR3Type entity in failedExclusions) - { - failureNames.Add(Settings.ExcludableEnemies[(short)entity]); - } - failureNames.Sort(); - SetWarning(string.Format("The following enemies could not be excluded entirely from the randomization pool.{0}{0}{1}", Environment.NewLine, string.Join(Environment.NewLine, failureNames))); - } - } - - private EnemyTransportCollection SelectCrossLevelEnemies(TR3CombinedLevel level) - { - // For the assault course, nothing will be imported for the time being - if (level.IsAssault) - { - return null; - } - - // Get the list of enemy types currently in the level - List oldEntities = GetCurrentEnemyEntities(level); - - // Get the list of canidadates - List allEnemies = TR3TypeUtilities.GetCandidateCrossLevelEnemies().FindAll(e => TR3EnemyUtilities.IsEnemySupported(level.Name, e, Settings.RandoEnemyDifficulty)); - - // Work out how many we can support - int enemyCount = oldEntities.Count + TR3EnemyUtilities.GetEnemyAdjustmentCount(level.Name); - List newEntities = new(enemyCount); - - // Do we need at least one water creature? - bool waterEnemyRequired = TR3TypeUtilities.GetWaterEnemies().Any(e => oldEntities.Contains(e)); - // Do we need at least one enemy that can drop? - bool droppableEnemyRequired = TR3EnemyUtilities.IsDroppableEnemyRequired(level); - - // Let's try to populate the list. Start by adding one water enemy - // and one droppable enemy if they are needed. - if (waterEnemyRequired) - { - List waterEnemies = TR3TypeUtilities.GetKillableWaterEnemies(); - newEntities.Add(SelectRequiredEnemy(waterEnemies, level, Settings.RandoEnemyDifficulty)); - } - - if (droppableEnemyRequired) - { - List droppableEnemies = TR3TypeUtilities.FilterDroppableEnemies(allEnemies, Settings.ProtectMonks); - newEntities.Add(SelectRequiredEnemy(droppableEnemies, level, Settings.RandoEnemyDifficulty)); - } - - // Are there any other types we need to retain? - foreach (TR3Type entity in TR3EnemyUtilities.GetRequiredEnemies(level.Name)) - { - if (!newEntities.Contains(entity)) - { - newEntities.Add(entity); - } - } - - // Some secrets may have locked enemies in place - we must retain those types - foreach (int itemIndex in ItemFactory.GetLockedItems(level.Name)) - { - TR3Entity item = level.Data.Entities[itemIndex]; - if (TR3TypeUtilities.IsEnemyType(item.TypeID)) - { - List family = TR3TypeUtilities.GetFamily(TR3TypeUtilities.GetAliasForLevel(level.Name, item.TypeID)); - if (!newEntities.Any(family.Contains)) - { - newEntities.Add(family[_generator.Next(0, family.Count)]); - } - } - } - - if (!Settings.DocileWillard || Settings.OneEnemyMode || Settings.IncludedEnemies.Count < newEntities.Capacity) - { - // Willie isn't excludable in his own right because supporting a Willie-only game is impossible - allEnemies.Remove(TR3Type.Willie); - } - - // Remove all exclusions from the pool, and adjust the target capacity - allEnemies.RemoveAll(e => _excludedEnemies.Contains(e)); - - IEnumerable ex = allEnemies.Where(e => !newEntities.Any(TR3TypeUtilities.GetFamily(e).Contains)); - List unalisedEntities = TR3TypeUtilities.RemoveAliases(ex); - while (unalisedEntities.Count < newEntities.Capacity - newEntities.Count) - { - --newEntities.Capacity; - } - - // Fill the list from the remaining candidates. Keep track of ones tested to avoid - // looping infinitely if it's not possible to fill to capacity - ISet testedEntities = new HashSet(); - while (newEntities.Count < newEntities.Capacity && testedEntities.Count < allEnemies.Count) - { - TR3Type entity = allEnemies[_generator.Next(0, allEnemies.Count)]; - testedEntities.Add(entity); - - // Make sure this isn't known to be unsupported in the level - if (!TR3EnemyUtilities.IsEnemySupported(level.Name, entity, Settings.RandoEnemyDifficulty)) - { - continue; - } - - // If it's Willie but Cavern is off-sequence, he can't be used - if (entity == TR3Type.Willie && level.Is(TR3LevelNames.WILLIE) && !level.IsWillardSequence) - { - continue; - } - - // Monkeys are friendly when the tiger model is present, and when they are friendly, - // mounting a vehicle will crash the game. - if (level.HasVehicle - && ((entity == TR3Type.Monkey && newEntities.Contains(TR3Type.Tiger)) - || (entity == TR3Type.Tiger && newEntities.Contains(TR3Type.Monkey)))) - { - continue; - } - - // If this is a tracked enemy throughout the game, we only allow it if the number - // of unique levels is within the limit. Bear in mind we are collecting more than - // one group of enemies per level. - if (_gameEnemyTracker.ContainsKey(entity) && !_gameEnemyTracker[entity].Contains(level.Name)) - { - if (_gameEnemyTracker[entity].Count < _gameEnemyTracker[entity].Capacity) - { - // The entity is allowed, so store the fact that this level will have it - _gameEnemyTracker[entity].Add(level.Name); - } - else - { - // Otherwise, pick something else. If we tried to previously exclude this - // enemy and couldn't, it will slip through the net and so the appearances - // will increase. - if (allEnemies.Except(newEntities).Count() > 1) - { - continue; - } - } - } - - // GetEntityFamily returns all aliases for the likes of the dogs, but if an entity - // doesn't have any, the returned list just contains the entity itself. This means - // we can avoid duplicating standard enemies as well as avoiding alias-clashing. - List family = TR3TypeUtilities.GetFamily(entity); - if (!newEntities.Any(e1 => family.Any(e2 => e1 == e2))) - { - newEntities.Add(entity); - } - } - - if (newEntities.Count == 0 - || (newEntities.Capacity > 1 && newEntities.All(e => TR3EnemyUtilities.IsEnemyRestricted(level.Name, e)))) + string statusMessage = _allocator.GetExclusionStatusMessage(); + if (statusMessage != null) { - // Make sure we have an unrestricted enemy available for the individual level conditions. This will - // guarantee a "safe" enemy for the level; we avoid aliases here to avoid further complication. - bool RestrictionCheck(TR3Type e) => - (droppableEnemyRequired && !TR3TypeUtilities.CanDropPickups(e, Settings.ProtectMonks)) - || !TR3EnemyUtilities.IsEnemySupported(level.Name, e, Settings.RandoEnemyDifficulty) - || newEntities.Contains(e) - || TR3TypeUtilities.IsWaterCreature(e) - || TR3EnemyUtilities.IsEnemyRestricted(level.Name, e) - || TR3TypeUtilities.TranslateAlias(e) != e; - - List unrestrictedPool = allEnemies.FindAll(e => !RestrictionCheck(e)); - if (unrestrictedPool.Count == 0) - { - // We are going to have to pull in the full list of candidates again, so ignoring any user-defined exclusions - unrestrictedPool = TR3TypeUtilities.GetCandidateCrossLevelEnemies().FindAll(e => !RestrictionCheck(e)); - } - - newEntities.Add(unrestrictedPool[_generator.Next(0, unrestrictedPool.Count)]); + SetWarning(statusMessage); } - - if (Settings.DevelopmentMode) - { - Debug.WriteLine(level.Name + ": " + string.Join(", ", newEntities)); - } - - return new EnemyTransportCollection - { - TypesToImport = newEntities, - TypesToRemove = oldEntities - }; } - private static List GetCurrentEnemyEntities(TR3CombinedLevel level) + private void RandomizeEnemies(TR3CombinedLevel level, EnemyRandomizationCollection enemies) { - List allGameEnemies = TR3TypeUtilities.GetFullListOfEnemies(); - ISet allLevelEnts = new SortedSet(); - level.Data.Entities.ForEach(e => allLevelEnts.Add(e.TypeID)); - List oldEntities = allLevelEnts.ToList().FindAll(e => allGameEnemies.Contains(e)); - return oldEntities; + _allocator.RandomizeEnemies(level.Name, level.Data, level.Sequence, enemies); + ApplyPostRandomization(level); } - private TR3Type SelectRequiredEnemy(List pool, TR3CombinedLevel level, RandoDifficulty difficulty) + private void ApplyPostRandomization(TR3CombinedLevel level) { - pool.RemoveAll(e => !TR3EnemyUtilities.IsEnemySupported(level.Name, e, difficulty)); - - TR3Type entity; - if (pool.All(_excludedEnemies.Contains)) - { - // Select the last excluded enemy (lowest priority) - entity = _excludedEnemies.Last(e => pool.Contains(e)); - } - else - { - do - { - entity = pool[_generator.Next(0, pool.Count)]; - } - while (_excludedEnemies.Contains(entity)); - } - - return entity; - } - - private void RandomizeEnemiesNatively(TR3CombinedLevel level) - { - // For the assault course, nothing will be changed for the time being - if (level.IsAssault) + if (!level.Script.RemovesWeapons) { return; } - List availableEnemyTypes = GetCurrentEnemyEntities(level); - if (level.HasVehicle - && availableEnemyTypes.Contains(TR3Type.Tiger) - && availableEnemyTypes.Contains(TR3Type.Monkey)) - { - TR3Type banishedType = _generator.NextDouble() < 0.5 ? TR3Type.Tiger : TR3Type.Monkey; - availableEnemyTypes.Remove(banishedType); - level.Data.Models.Remove(banishedType); - } - - List droppableEnemies = TR3TypeUtilities.FilterDroppableEnemies(availableEnemyTypes, Settings.ProtectMonks); - List waterEnemies = TR3TypeUtilities.FilterWaterEnemies(availableEnemyTypes); - - RandomizeEnemies(level, new EnemyRandomizationCollection + _allocator.AddUnarmedLevelAmmo(level.Name, level.Data, (loc, type) => { - Available = availableEnemyTypes, - Droppable = droppableEnemies, - Water = waterEnemies + level.Script.AddStartInventoryItem(ItemUtilities.ConvertToScriptItem(type)); }); } - private void RandomizeEnemies(TR3CombinedLevel level, EnemyRandomizationCollection enemies) - { - // Get a list of current enemy entities - List allEnemies = TR3TypeUtilities.GetFullListOfEnemies(); - List enemyEntities = level.Data.Entities.FindAll(e => allEnemies.Contains(e.TypeID)); - - // First iterate through any enemies that are restricted by room - Dictionary> enemyRooms = TR3EnemyUtilities.GetRestrictedEnemyRooms(level.Name, Settings.RandoEnemyDifficulty); - if (enemyRooms != null) - { - foreach (TR3Type entity in enemyRooms.Keys) - { - if (!enemies.Available.Contains(entity)) - { - continue; - } - - List rooms = enemyRooms[entity]; - int maxEntityCount = TR3EnemyUtilities.GetRestrictedEnemyLevelCount(entity, Settings.RandoEnemyDifficulty); - if (maxEntityCount == -1) - { - // We are allowed any number, but this can't be more than the number of unique rooms, - // so we will assume 1 per room as these restricted enemies are likely to be tanky. - maxEntityCount = rooms.Count; - } - else - { - maxEntityCount = Math.Min(maxEntityCount, rooms.Count); - } - - // Pick an actual count - int enemyCount = _generator.Next(1, maxEntityCount + 1); - for (int i = 0; i < enemyCount; i++) - { - // Find an entity in one of the rooms that the new enemy is restricted to - TR3Entity targetEntity = null; - do - { - int room = enemyRooms[entity][_generator.Next(0, enemyRooms[entity].Count)]; - targetEntity = enemyEntities.Find(e => e.Room == room); - } - while (targetEntity == null); - - // If the room has water but this enemy isn't a water enemy, we will assume that environment - // modifications will handle assignment of the enemy to entities. - if (!TR3TypeUtilities.IsWaterCreature(entity) && level.Data.Rooms[targetEntity.Room].ContainsWater) - { - continue; - } - - // Some enemies need pathing like Willard but we have to honour the entity limit - List paths = TR3EnemyUtilities.GetAIPathing(level.Name, entity, targetEntity.Room); - if (ItemFactory.CanCreateItems(level.Name, level.Data.Entities, paths.Count)) - { - targetEntity.TypeID = TR3TypeUtilities.TranslateAlias(entity); - - // #146 Ensure OneShot triggers are set for this enemy if needed - TR3EnemyUtilities.SetEntityTriggers(level.Data, targetEntity); - - // Remove the target entity from the tracker list so it doesn't get replaced - enemyEntities.Remove(targetEntity); - - // Add the pathing if necessary - foreach (Location path in paths) - { - TR3Entity pathItem = ItemFactory.CreateItem(level.Name, level.Data.Entities, path); - pathItem.TypeID = TR3Type.AIPath_N; - } - } - else - { - break; - } - } - - // Remove this entity type from the available rando pool - enemies.Available.Remove(entity); - } - } - - foreach (TR3Entity currentEntity in enemyEntities) - { - TR3Type currentEntityType = currentEntity.TypeID; - TR3Type newEntityType = currentEntityType; - int enemyIndex = level.Data.Entities.IndexOf(currentEntity); - - // If it's an existing enemy that has to remain in the same spot, skip it - if (TR3EnemyUtilities.IsEnemyRequired(level.Name, currentEntityType) - || ItemFactory.IsItemLocked(level.Name, enemyIndex)) - { - continue; - } - - List enemyPool = enemies.Available; - - // Check if the enemy drops an item - bool hasPickupItem = level.Data.Entities - .Any(item => TR3EnemyUtilities.HasDropItem(currentEntity, item)); - - if (hasPickupItem) - { - enemyPool = enemies.Droppable; - } - else if (TR3TypeUtilities.IsWaterCreature(currentEntityType)) - { - enemyPool = enemies.Water; - } - - // Pick a new type - newEntityType = enemyPool[_generator.Next(0, enemyPool.Count)]; - - // If we are restricting count per level for this enemy and have reached that count, pick - // something else. This applies when we are restricting by in-level count, but not by room - // (e.g. Winston). - int maxEntityCount = TR3EnemyUtilities.GetRestrictedEnemyLevelCount(newEntityType, Settings.RandoEnemyDifficulty); - if (maxEntityCount != -1) - { - if (level.Data.Entities.FindAll(e => e.TypeID == newEntityType).Count >= maxEntityCount && enemyPool.Count > maxEntityCount) - { - TR3Type tmp = newEntityType; - while (newEntityType == tmp || TR3EnemyUtilities.IsEnemyRestricted(level.Name, newEntityType)) - { - newEntityType = enemyPool[_generator.Next(0, enemyPool.Count)]; - } - } - } - - TR3Entity targetEntity = currentEntity; - - if (level.Is(TR3LevelNames.CRASH) && currentEntity.Room == 15) - { - // Crash site raptor spawns need special treatment. The 3 entities in this (unreachable) room - // are normally raptors, and the game positions them to the spawn points. If we no longer have - // raptors, then replace the spawn points with the actual enemies. Otherwise, ensure they remain - // as raptors. - if (!enemies.Available.Contains(TR3Type.Raptor)) - { - TR3Entity raptorSpawn = level.Data.Entities.Find(e => e.TypeID == TR3Type.RaptorRespawnPoint_N && e.Room != 15); - if (raptorSpawn != null) - { - (targetEntity = raptorSpawn).TypeID = TR3TypeUtilities.TranslateAlias(newEntityType); - currentEntity.TypeID = TR3Type.RaptorRespawnPoint_N; - } - } - } - else if (level.Is(TR3LevelNames.RXTECH) - && level.IsWillardSequence - && Settings.RandoEnemyDifficulty == RandoDifficulty.Default - && newEntityType == TR3Type.RXTechFlameLad - && (currentEntity.Room == 14 || currentEntity.Room == 45)) - { - // #269 We don't want flamethrowers here because they're hostile, so getting off the minecart - // safely is too difficult. We can only change them if there is something else unrestricted available. - List safePool = enemyPool.FindAll(e => e != TR3Type.RXTechFlameLad && !TR3EnemyUtilities.IsEnemyRestricted(level.Name, e)); - if (safePool.Count > 0) - { - newEntityType = safePool[_generator.Next(0, safePool.Count)]; - } - } - else if (level.Is(TR3LevelNames.HSC)) - { - if (currentEntity.Room == 87 && newEntityType != TR3Type.Prisoner) - { - // #271 The prisoner is needed here to activate the heavy trigger for the trapdoor. If we still have - // prisoners in the pool, ensure one is chosen. If this isn't the case, environment rando will provide - // a workaround. - if (enemies.Available.Contains(TR3Type.Prisoner)) - { - newEntityType = TR3Type.Prisoner; - } - } - else if (currentEntity.Room == 78 && newEntityType == TR3Type.Monkey) - { - // #286 Monkeys cannot share AI Ambush spots largely, but these are needed here to ensure the enemies - // come through the gate before the timer closes them again. Just ensure no monkeys are here. - List safePool = enemyPool.FindAll(e => e != TR3Type.Monkey && !TR3EnemyUtilities.IsEnemyRestricted(level.Name, e)); - if (safePool.Count > 0) - { - newEntityType = safePool[_generator.Next(0, safePool.Count)]; - } - else - { - // Full monkey mode means we have to move them inside the gate - currentEntity.Z -= 4096; - } - } - } - else if (level.Is(TR3LevelNames.THAMES) && (currentEntity.Room == 61 || currentEntity.Room == 62) && newEntityType == TR3Type.Monkey) - { - // #286 Move the monkeys away from the AI entities - currentEntity.Z -= TRConsts.Step4; - } - - // Make sure to convert back to the actual type - targetEntity.TypeID = TR3TypeUtilities.TranslateAlias(newEntityType); - - // #146 Ensure OneShot triggers are set for this enemy if needed - TR3EnemyUtilities.SetEntityTriggers(level.Data, targetEntity); - - // #291 Cobras don't seem to come back into reality when the - // engine disables them when too many enemies are active, unless - // invisible is false. - if (targetEntity.TypeID == TR3Type.Cobra) - { - targetEntity.Invisible = false; - } - - // Track every enemy type across the game - _resultantEnemies.Add(newEntityType); - } - - // Add extra ammo based on this level's difficulty - if (Settings.CrossLevelEnemies && level.Script.RemovesWeapons) - { - AddUnarmedLevelAmmo(level); - } - - if (!Settings.AllowEnemyKeyDrops && (!Settings.RandomizeItems || !Settings.IncludeKeyItems)) - { - // Shift enemies who are on top of key items so they don't pick them up. - IEnumerable keyEnemies = level.Data.Entities.Where(enemy => TR3TypeUtilities.IsEnemyType(enemy.TypeID) - && level.Data.Entities.Any(key => TR3TypeUtilities.IsKeyItemType(key.TypeID) - && key.GetLocation().IsEquivalent(enemy.GetLocation())) - ); - - foreach (TR3Entity enemy in keyEnemies) - { - enemy.X++; - } - } - } - - private void AddUnarmedLevelAmmo(TR3CombinedLevel level) - { - if (!Settings.GiveUnarmedItems) - { - return; - } - - // Find out which gun we have for this level - List weaponTypes = TR3TypeUtilities.GetWeaponPickups(); - List levelWeapons = level.Data.Entities.FindAll(e => weaponTypes.Contains(e.TypeID)); - TR3Entity weaponEntity = null; - foreach (TR3Entity weapon in levelWeapons) - { - int match = _pistolLocations[level.Name].FindIndex - ( - location => - location.X == weapon.X && - location.Y == weapon.Y && - location.Z == weapon.Z && - location.Room == weapon.Room - ); - if (match != -1) - { - weaponEntity = weapon; - break; - } - } - - if (weaponEntity == null) - { - return; - } - - List allEnemies = TR3TypeUtilities.GetFullListOfEnemies(); - List levelEnemies = level.Data.Entities.FindAll(e => allEnemies.Contains(e.TypeID)); - EnemyDifficulty difficulty = TR3EnemyUtilities.GetEnemyDifficulty(levelEnemies); - - if (difficulty > EnemyDifficulty.Easy) - { - while (weaponEntity.TypeID == TR3Type.Pistols_P) - { - weaponEntity.TypeID = weaponTypes[_generator.Next(0, weaponTypes.Count)]; - } - } - - TR3Type weaponType = weaponEntity.TypeID; - uint ammoToGive = TR3EnemyUtilities.GetStartingAmmo(weaponType); - if (ammoToGive > 0) - { - ammoToGive *= (uint)difficulty; - TR3Type ammoType = TR3TypeUtilities.GetWeaponAmmo(weaponType); - level.Script.AddStartInventoryItem(ItemUtilities.ConvertToScriptItem(ammoType), ammoToGive); - - uint smallMediToGive = 0; - uint largeMediToGive = 0; - - if (difficulty == EnemyDifficulty.Medium || difficulty == EnemyDifficulty.Hard) - { - smallMediToGive++; - } - if (difficulty > EnemyDifficulty.Medium) - { - largeMediToGive++; - } - if (difficulty == EnemyDifficulty.VeryHard) - { - largeMediToGive++; - } - - level.Script.AddStartInventoryItem(ItemUtilities.ConvertToScriptItem(TR3Type.SmallMed_P), smallMediToGive); - level.Script.AddStartInventoryItem(ItemUtilities.ConvertToScriptItem(TR3Type.LargeMed_P), largeMediToGive); - } - - // Add the pistols as a pickup if the level is hard and there aren't any other pistols around - if (difficulty > EnemyDifficulty.Medium && levelWeapons.Find(e => e.TypeID == TR3Type.Pistols_P) == null && ItemFactory.CanCreateItem(level.Name, level.Data.Entities)) - { - TR3Entity pistols = ItemFactory.CreateItem(level.Name, level.Data.Entities); - pistols.TypeID = TR3Type.Pistols_P; - pistols.X = weaponEntity.X; - pistols.Y = weaponEntity.Y; - pistols.Z = weaponEntity.Z; - pistols.Room = weaponEntity.Room; - } - } - internal class EnemyProcessor : AbstractProcessorThread { - private readonly Dictionary _enemyMapping; + private readonly Dictionary> _enemyMapping; internal override int LevelCount => _enemyMapping.Count; internal EnemyProcessor(TR3EnemyRandomizer outer) : base(outer) { - _enemyMapping = new Dictionary(); + _enemyMapping = new(); } internal void AddLevel(TR3CombinedLevel level) @@ -710,23 +139,20 @@ internal void AddLevel(TR3CombinedLevel level) protected override void StartImpl() { - // Load initially outwith the processor thread to ensure the RNG selected for each - // level/enemy group remains consistent between randomization sessions. List levels = new(_enemyMapping.Keys); foreach (TR3CombinedLevel level in levels) { - _enemyMapping[level] = _outer.SelectCrossLevelEnemies(level); + _enemyMapping[level] = _outer._allocator.SelectCrossLevelEnemies(level.Name, level.Data, level.Sequence); } } - // Executed in parallel, so just store the import result to process later synchronously. protected override void ProcessImpl() { foreach (TR3CombinedLevel level in _enemyMapping.Keys) { if (!level.IsAssault) { - EnemyTransportCollection enemies = _enemyMapping[level]; + EnemyTransportCollection enemies = _enemyMapping[level]; TR3DataImporter importer = new() { TypesToImport = enemies.TypesToImport, @@ -737,7 +163,7 @@ protected override void ProcessImpl() TextureMonitor = _outer.TextureMonitor.CreateMonitor(level.Name, enemies.TypesToImport) }; - string remapPath = @"TR3\Textures\Deduplication\" + level.Name + "-TextureRemap.json"; + string remapPath = $@"TR3\Textures\Deduplication\{level.Name}-TextureRemap.json"; if (_outer.ResourceExists(remapPath)) { importer.TextureRemapPath = _outer.GetResourcePath(remapPath); @@ -767,7 +193,7 @@ internal void ApplyRandomization() { if (!level.IsAssault) { - EnemyRandomizationCollection enemies = new() + EnemyRandomizationCollection enemies = new() { Available = _enemyMapping[level].TypesToImport, Droppable = TR3TypeUtilities.FilterDroppableEnemies(_enemyMapping[level].TypesToImport, _outer.Settings.ProtectMonks), @@ -785,17 +211,4 @@ internal void ApplyRandomization() } } } - - internal class EnemyTransportCollection - { - internal List TypesToImport { get; set; } - internal List TypesToRemove { get; set; } - } - - internal class EnemyRandomizationCollection - { - internal List Available { get; set; } - internal List Droppable { get; set; } - internal List Water { get; set; } - } } diff --git a/TRRandomizerCore/Randomizers/TR3/Remastered/TR3REnemyRandomizer.cs b/TRRandomizerCore/Randomizers/TR3/Remastered/TR3REnemyRandomizer.cs new file mode 100644 index 000000000..baaca7e9a --- /dev/null +++ b/TRRandomizerCore/Randomizers/TR3/Remastered/TR3REnemyRandomizer.cs @@ -0,0 +1,217 @@ +using TRDataControl; +using TRGE.Core; +using TRLevelControl.Helpers; +using TRLevelControl.Model; +using TRRandomizerCore.Helpers; +using TRRandomizerCore.Levels; +using TRRandomizerCore.Processors; + +namespace TRRandomizerCore.Randomizers; + +public class TR3REnemyRandomizer : BaseTR3RRandomizer +{ + private TR3EnemyAllocator _allocator; + + public TR3RDataCache DataCache { get; set; } + public ItemFactory ItemFactory { get; set; } + + public override void Randomize(int seed) + { + _generator = new(seed); + _allocator = new() + { + Settings = Settings, + ItemFactory = ItemFactory, + Generator = _generator, + GameLevels = Levels.Select(l => l.LevelFileBaseName), + }; + _allocator.Initialise(); + + if (Settings.CrossLevelEnemies) + { + RandomizeEnemiesCrossLevel(); + } + else + { + RandomizeExistingEnemies(); + } + } + + private void RandomizeExistingEnemies() + { + foreach (TRRScriptedLevel lvl in Levels) + { + LoadLevelInstance(lvl); + _allocator.RandomizeEnemiesNatively(_levelInstance.Name, _levelInstance.Data, _levelInstance.Sequence); + ApplyPostRandomization(_levelInstance); + + SaveLevelInstance(); + if (!TriggerProgress()) + { + break; + } + } + } + + private void RandomizeEnemiesCrossLevel() + { + SetMessage("Randomizing enemies - loading levels"); + + List processors = new(); + for (int i = 0; i < _maxThreads; i++) + { + processors.Add(new(this)); + } + + List levels = new(Levels.Count); + foreach (TRRScriptedLevel lvl in Levels) + { + levels.Add(LoadCombinedLevel(lvl)); + if (!TriggerProgress()) + { + return; + } + } + + int processorIndex = 0; + foreach (TR3RCombinedLevel level in levels) + { + processors[processorIndex].AddLevel(level); + processorIndex = processorIndex == _maxThreads - 1 ? 0 : processorIndex + 1; + } + + SetMessage("Randomizing enemies - importing models"); + processors.ForEach(p => p.Start()); + processors.ForEach(p => p.Join()); + + if (!SaveMonitor.IsCancelled && _processingException == null) + { + SetMessage("Randomizing enemies - saving levels"); + processors.ForEach(p => p.ApplyRandomization()); + } + + _processingException?.Throw(); + + string statusMessage = _allocator.GetExclusionStatusMessage(); + if (statusMessage != null) + { + SetWarning(statusMessage); + } + } + + private void RandomizeEnemies(TR3RCombinedLevel level, EnemyRandomizationCollection enemies) + { + _allocator.RandomizeEnemies(level.Name, level.Data, level.Sequence, enemies); + ApplyPostRandomization(level); + } + + private void ApplyPostRandomization(TR3RCombinedLevel level) + { + if (!level.Script.RemovesWeapons) + { + return; + } + + _allocator.AddUnarmedLevelAmmo(level.Name, level.Data, (loc, type) => { }); + + // We can't give more ammo because HSC is so close to the limit. Instead just guarantee + // pistols in the starting area + List pistolLocations = _allocator.GetPistolLocations(level.Name); + Location location; + do + { + location = pistolLocations[_generator.Next(0, pistolLocations.Count)]; + } + while (location.Room != 7); + + TR3Entity pistols = ItemFactory.CreateItem(level.Name, level.Data.Entities, location); + pistols.TypeID = TR3Type.Pistols_P; + } + + internal class EnemyProcessor : AbstractProcessorThread + { + private readonly Dictionary> _enemyMapping; + + internal override int LevelCount => _enemyMapping.Count; + + internal EnemyProcessor(TR3REnemyRandomizer outer) + : base(outer) + { + _enemyMapping = new(); + } + + internal void AddLevel(TR3RCombinedLevel level) + { + _enemyMapping.Add(level, null); + } + + protected override void StartImpl() + { + List levels = new(_enemyMapping.Keys); + foreach (TR3RCombinedLevel level in levels) + { + _enemyMapping[level] = _outer._allocator.SelectCrossLevelEnemies(level.Name, level.Data, level.Sequence); + } + } + + protected override void ProcessImpl() + { + foreach (TR3RCombinedLevel level in _enemyMapping.Keys) + { + if (!level.IsAssault) + { + EnemyTransportCollection enemies = _enemyMapping[level]; + TR3DataImporter importer = new() + { + TypesToImport = enemies.TypesToImport, + TypesToRemove = enemies.TypesToRemove, + Level = level.Data, + LevelName = level.Name, + DataFolder = _outer.GetResourcePath(@"TR3\Objects"), + }; + + importer.Data.TextureObjectLimit = RandoConsts.TRRTexLimit; + importer.Data.TextureTileLimit = RandoConsts.TRRTileLimit; + + string remapPath = $@"TR3\Textures\Deduplication\{level.Name}-TextureRemap.json"; + if (_outer.ResourceExists(remapPath)) + { + importer.TextureRemapPath = _outer.GetResourcePath(remapPath); + } + + ImportResult result = importer.Import(); + _outer.DataCache.Merge(result, level.PDPData, level.MapData); + } + + if (!_outer.TriggerProgress()) + { + break; + } + } + } + + internal void ApplyRandomization() + { + foreach (TR3RCombinedLevel level in _enemyMapping.Keys) + { + if (!level.IsAssault) + { + EnemyRandomizationCollection enemies = new() + { + Available = _enemyMapping[level].TypesToImport, + Droppable = TR3TypeUtilities.FilterDroppableEnemies(_enemyMapping[level].TypesToImport, _outer.Settings.ProtectMonks), + Water = TR3TypeUtilities.FilterWaterEnemies(_enemyMapping[level].TypesToImport) + }; + + _outer.RandomizeEnemies(level, enemies); + _outer.SaveLevel(level); + } + + if (!_outer.TriggerProgress()) + { + break; + } + } + } + } +} diff --git a/TRRandomizerCore/Randomizers/TR3/Shared/TR3EnemyAllocator.cs b/TRRandomizerCore/Randomizers/TR3/Shared/TR3EnemyAllocator.cs new file mode 100644 index 000000000..5ee30e89d --- /dev/null +++ b/TRRandomizerCore/Randomizers/TR3/Shared/TR3EnemyAllocator.cs @@ -0,0 +1,494 @@ +using Newtonsoft.Json; +using TRLevelControl; +using TRLevelControl.Helpers; +using TRLevelControl.Model; +using TRRandomizerCore.Helpers; +using TRRandomizerCore.Utilities; + +namespace TRRandomizerCore.Randomizers; + +public class TR3EnemyAllocator : EnemyAllocator +{ + private const int _willardSequence = 19; + + private static readonly List _oneShotEnemies = new() + { + TR3Type.Croc, + TR3Type.KillerWhale, + TR3Type.Raptor, + TR3Type.Rat, + }; + + private readonly Dictionary> _pistolLocations; + + public ItemFactory ItemFactory { get; set; } + + public TR3EnemyAllocator() + { + _pistolLocations = JsonConvert.DeserializeObject>>(File.ReadAllText(@"Resources\TR3\Locations\unarmed_locations.json")); + } + + protected override Dictionary> GetGameTracker() + => TR3EnemyUtilities.PrepareEnemyGameTracker(Settings.RandoEnemyDifficulty); + + protected override bool IsEnemySupported(string levelName, TR3Type type, RandoDifficulty difficulty) + => TR3EnemyUtilities.IsEnemySupported(levelName, type, difficulty); + + protected override Dictionary> GetRestrictedRooms(string levelName, RandoDifficulty difficulty) + => TR3EnemyUtilities.GetRestrictedEnemyRooms(levelName, difficulty); + + protected override bool IsOneShotType(TR3Type type) + => _oneShotEnemies.Contains(type); + + public EnemyTransportCollection SelectCrossLevelEnemies(string levelName, TR3Level level, int levelSequence) + { + if (levelName == TR3LevelNames.ASSAULT) + { + return null; + } + + List oldTypes = GetCurrentEnemyEntities(level); + List allEnemies = TR3TypeUtilities.GetCandidateCrossLevelEnemies() + .FindAll(e => TR3EnemyUtilities.IsEnemySupported(levelName, e, Settings.RandoEnemyDifficulty)); + + int enemyCount = oldTypes.Count + TR3EnemyUtilities.GetEnemyAdjustmentCount(levelName); + List newTypes = new(enemyCount); + + if (TR3TypeUtilities.GetWaterEnemies().Any(oldTypes.Contains)) + { + List waterEnemies = TR3TypeUtilities.GetKillableWaterEnemies(); + newTypes.Add(SelectRequiredEnemy(waterEnemies, levelName, Settings.RandoEnemyDifficulty)); + } + + bool droppableEnemyRequired = TR3EnemyUtilities.IsDroppableEnemyRequired(level); + if (droppableEnemyRequired) + { + List droppableEnemies = TR3TypeUtilities.FilterDroppableEnemies(allEnemies, Settings.ProtectMonks); + newTypes.Add(SelectRequiredEnemy(droppableEnemies, levelName, Settings.RandoEnemyDifficulty)); + } + + foreach (TR3Type type in TR3EnemyUtilities.GetRequiredEnemies(levelName)) + { + if (!newTypes.Contains(type)) + { + newTypes.Add(type); + } + } + + foreach (int itemIndex in ItemFactory.GetLockedItems(levelName)) + { + TR3Entity item = level.Entities[itemIndex]; + if (TR3TypeUtilities.IsEnemyType(item.TypeID)) + { + List family = TR3TypeUtilities.GetFamily(TR3TypeUtilities.GetAliasForLevel(levelName, item.TypeID)); + if (!newTypes.Any(family.Contains)) + { + newTypes.Add(family[Generator.Next(0, family.Count)]); + } + } + } + + if (!Settings.DocileWillard || Settings.OneEnemyMode || Settings.IncludedEnemies.Count < newTypes.Capacity) + { + // Willie isn't excludable in his own right because supporting a Willie-only game is impossible + allEnemies.Remove(TR3Type.Willie); + } + + // Remove all exclusions from the pool, and adjust the target capacity + allEnemies.RemoveAll(_excludedEnemies.Contains); + + IEnumerable ex = allEnemies.Where(e => !newTypes.Any(TR3TypeUtilities.GetFamily(e).Contains)); + List unalisedTypes = TR3TypeUtilities.RemoveAliases(ex); + while (unalisedTypes.Count < newTypes.Capacity - newTypes.Count) + { + --newTypes.Capacity; + } + + // Fill the list from the remaining candidates. Keep track of ones tested to avoid + // looping infinitely if it's not possible to fill to capacity + HashSet testedTypes = new(); + while (newTypes.Count < newTypes.Capacity && testedTypes.Count < allEnemies.Count) + { + TR3Type type = allEnemies[Generator.Next(0, allEnemies.Count)]; + testedTypes.Add(type); + + if (!TR3EnemyUtilities.IsEnemySupported(levelName, type, Settings.RandoEnemyDifficulty)) + { + continue; + } + + if (type == TR3Type.Willie && levelName == TR3LevelNames.WILLIE && levelSequence != _willardSequence) + { + continue; + } + + // Monkeys are friendly when the tiger model is present, and when they are friendly, + // mounting a vehicle will crash the game. + if (level.Entities.Any(e => TR3TypeUtilities.IsVehicleType(e.TypeID)) + && ((type == TR3Type.Monkey && newTypes.Contains(TR3Type.Tiger)) + || (type == TR3Type.Tiger && newTypes.Contains(TR3Type.Monkey)))) + { + continue; + } + + if (_gameEnemyTracker.ContainsKey(type) && !_gameEnemyTracker[type].Contains(levelName)) + { + if (_gameEnemyTracker[type].Count < _gameEnemyTracker[type].Capacity) + { + _gameEnemyTracker[type].Add(levelName); + } + else + { + if (allEnemies.Except(newTypes).Count() > 1) + { + continue; + } + } + } + + List family = TR3TypeUtilities.GetFamily(type); + if (!newTypes.Any(family.Contains)) + { + newTypes.Add(type); + } + } + + if (newTypes.Count == 0 + || (newTypes.Capacity > 1 && newTypes.All(e => TR3EnemyUtilities.IsEnemyRestricted(levelName, e)))) + { + // Make sure we have an unrestricted enemy available for the individual level conditions. This will + // guarantee a "safe" enemy for the level; we avoid aliases here to avoid further complication. + bool RestrictionCheck(TR3Type e) => + (droppableEnemyRequired && !TR3TypeUtilities.CanDropPickups(e, Settings.ProtectMonks)) + || !TR3EnemyUtilities.IsEnemySupported(levelName, e, Settings.RandoEnemyDifficulty) + || newTypes.Contains(e) + || TR3TypeUtilities.IsWaterCreature(e) + || TR3EnemyUtilities.IsEnemyRestricted(levelName, e) + || TR3TypeUtilities.TranslateAlias(e) != e; + + List unrestrictedPool = allEnemies.FindAll(e => !RestrictionCheck(e)); + if (unrestrictedPool.Count == 0) + { + // We are going to have to pull in the full list of candidates again, so ignoring any user-defined exclusions + unrestrictedPool = TR3TypeUtilities.GetCandidateCrossLevelEnemies().FindAll(e => !RestrictionCheck(e)); + } + + newTypes.Add(unrestrictedPool[Generator.Next(0, unrestrictedPool.Count)]); + } + + return new() + { + TypesToImport = newTypes, + TypesToRemove = oldTypes + }; + } + + private static List GetCurrentEnemyEntities(TR3Level level) + { + List allGameEnemies = TR3TypeUtilities.GetFullListOfEnemies(); + SortedSet allLevelEnts = new(level.Entities.Select(e => e.TypeID)); + return allLevelEnts.Where(allGameEnemies.Contains).ToList(); + } + + public EnemyRandomizationCollection RandomizeEnemiesNatively(string levelName, TR3Level level, int levelSequence) + { + if (levelName == TR3LevelNames.ASSAULT) + { + return null; + } + + List availableEnemyTypes = GetCurrentEnemyEntities(level); + if (level.Entities.Any(e => TR3TypeUtilities.IsVehicleType(e.TypeID)) + && availableEnemyTypes.Contains(TR3Type.Tiger) + && availableEnemyTypes.Contains(TR3Type.Monkey)) + { + TR3Type banishedType = Generator.NextDouble() < 0.5 ? TR3Type.Tiger : TR3Type.Monkey; + availableEnemyTypes.Remove(banishedType); + level.Models.Remove(banishedType); + } + + EnemyRandomizationCollection enemies = new() + { + Available = availableEnemyTypes, + Droppable = TR3TypeUtilities.FilterDroppableEnemies(availableEnemyTypes, Settings.ProtectMonks), + Water = TR3TypeUtilities.FilterWaterEnemies(availableEnemyTypes) + }; + + RandomizeEnemies(levelName, level, levelSequence, enemies); + + return enemies; + } + + public void RandomizeEnemies(string levelName, TR3Level level, int levelSequence, EnemyRandomizationCollection enemies) + { + List allEnemies = TR3TypeUtilities.GetFullListOfEnemies(); + List enemyEntities = level.Entities.FindAll(e => allEnemies.Contains(e.TypeID)); + + // First iterate through any enemies that are restricted by room + Dictionary> enemyRooms = TR3EnemyUtilities.GetRestrictedEnemyRooms(levelName, Settings.RandoEnemyDifficulty); + if (enemyRooms != null) + { + foreach (TR3Type type in enemyRooms.Keys) + { + if (!enemies.Available.Contains(type)) + { + continue; + } + + List rooms = enemyRooms[type]; + int maxEntityCount = TR3EnemyUtilities.GetRestrictedEnemyLevelCount(type, Settings.RandoEnemyDifficulty); + if (maxEntityCount == -1) + { + // We are allowed any number, but this can't be more than the number of unique rooms, + // so we will assume 1 per room as these restricted enemies are likely to be tanky. + maxEntityCount = rooms.Count; + } + else + { + maxEntityCount = Math.Min(maxEntityCount, rooms.Count); + } + + // Pick an actual count + int enemyCount = Generator.Next(1, maxEntityCount + 1); + for (int i = 0; i < enemyCount; i++) + { + // Find an entity in one of the rooms that the new enemy is restricted to + TR3Entity targetEntity = null; + do + { + int room = enemyRooms[type][Generator.Next(0, enemyRooms[type].Count)]; + targetEntity = enemyEntities.Find(e => e.Room == room); + } + while (targetEntity == null); + + // If the room has water but this enemy isn't a water enemy, we will assume that environment + // modifications will handle assignment of the enemy to entities. + if (!TR3TypeUtilities.IsWaterCreature(type) && level.Rooms[targetEntity.Room].ContainsWater) + { + continue; + } + + // Some enemies need pathing like Willard but we have to honour the entity limit + List paths = TR3EnemyUtilities.GetAIPathing(levelName, type, targetEntity.Room); + if (ItemFactory.CanCreateItems(levelName, level.Entities, paths.Count)) + { + targetEntity.TypeID = TR3TypeUtilities.TranslateAlias(type); + SetOneShot(targetEntity, level.Entities.IndexOf(targetEntity), level.FloorData); + enemyEntities.Remove(targetEntity); + + // Add the pathing if necessary + foreach (Location path in paths) + { + TR3Entity pathItem = ItemFactory.CreateItem(levelName, level.Entities, path); + pathItem.TypeID = TR3Type.AIPath_N; + } + } + else + { + break; + } + } + + enemies.Available.Remove(type); + } + } + + foreach (TR3Entity currentEntity in enemyEntities) + { + TR3Type currentType = currentEntity.TypeID; + TR3Type newType = currentType; + int enemyIndex = level.Entities.IndexOf(currentEntity); + + // If it's an existing enemy that has to remain in the same spot, skip it + if (TR3EnemyUtilities.IsEnemyRequired(levelName, currentType) + || ItemFactory.IsItemLocked(levelName, enemyIndex)) + { + continue; + } + + List enemyPool = enemies.Available; + if (level.Entities.Any(item => TR3EnemyUtilities.HasDropItem(currentEntity, item))) + { + enemyPool = enemies.Droppable; + } + else if (TR3TypeUtilities.IsWaterCreature(currentType)) + { + enemyPool = enemies.Water; + } + + newType = enemyPool[Generator.Next(0, enemyPool.Count)]; + + // If we are restricting count per level for this enemy and have reached that count, pick + // something else. This applies when we are restricting by in-level count, but not by room + // (e.g. Winston). + int maxEntityCount = TR3EnemyUtilities.GetRestrictedEnemyLevelCount(newType, Settings.RandoEnemyDifficulty); + if (maxEntityCount != -1) + { + if (level.Entities.FindAll(e => e.TypeID == newType).Count >= maxEntityCount && enemyPool.Count > maxEntityCount) + { + TR3Type tmp = newType; + while (newType == tmp || TR3EnemyUtilities.IsEnemyRestricted(levelName, newType)) + { + newType = enemyPool[Generator.Next(0, enemyPool.Count)]; + } + } + } + + TR3Entity targetEntity = currentEntity; + + if (levelName == TR3LevelNames.CRASH && currentEntity.Room == 15) + { + // Crash site raptor spawns need special treatment. The 3 entities in this (unreachable) room + // are normally raptors, and the game positions them to the spawn points. If we no longer have + // raptors, then replace the spawn points with the actual enemies. Otherwise, ensure they remain + // as raptors. + if (!enemies.Available.Contains(TR3Type.Raptor)) + { + TR3Entity raptorSpawn = level.Entities.Find(e => e.TypeID == TR3Type.RaptorRespawnPoint_N && e.Room != 15); + if (raptorSpawn != null) + { + (targetEntity = raptorSpawn).TypeID = TR3TypeUtilities.TranslateAlias(newType); + currentEntity.TypeID = TR3Type.RaptorRespawnPoint_N; + } + } + } + else if (levelName == TR3LevelNames.RXTECH + && levelSequence == _willardSequence + && Settings.RandoEnemyDifficulty == RandoDifficulty.Default + && newType == TR3Type.RXTechFlameLad + && (currentEntity.Room == 14 || currentEntity.Room == 45)) + { + // #269 We don't want flamethrowers here because they're hostile, so getting off the minecart + // safely is too difficult. We can only change them if there is something else unrestricted available. + List safePool = enemyPool.FindAll(e => e != TR3Type.RXTechFlameLad && !TR3EnemyUtilities.IsEnemyRestricted(levelName, e)); + if (safePool.Count > 0) + { + newType = safePool[Generator.Next(0, safePool.Count)]; + } + } + else if (levelName == TR3LevelNames.HSC) + { + if (currentEntity.Room == 87 && newType != TR3Type.Prisoner) + { + // #271 The prisoner is needed here to activate the heavy trigger for the trapdoor. If we still have + // prisoners in the pool, ensure one is chosen. If this isn't the case, environment rando will provide + // a workaround. + if (enemies.Available.Contains(TR3Type.Prisoner)) + { + newType = TR3Type.Prisoner; + } + } + else if (currentEntity.Room == 78 && newType == TR3Type.Monkey) + { + // #286 Monkeys cannot share AI Ambush spots largely, but these are needed here to ensure the enemies + // come through the gate before the timer closes them again. Just ensure no monkeys are here. + List safePool = enemyPool.FindAll(e => e != TR3Type.Monkey && !TR3EnemyUtilities.IsEnemyRestricted(levelName, e)); + if (safePool.Count > 0) + { + newType = safePool[Generator.Next(0, safePool.Count)]; + } + else + { + // Full monkey mode means we have to move them inside the gate + currentEntity.Z -= 4096; + } + } + } + else if (levelName == TR3LevelNames.THAMES && (currentEntity.Room == 61 || currentEntity.Room == 62) && newType == TR3Type.Monkey) + { + // #286 Move the monkeys away from the AI entities + currentEntity.Z -= TRConsts.Step4; + } + + if (targetEntity.TypeID == TR3Type.Cobra) + { + targetEntity.Invisible = false; + } + + // Final step is to convert/set the type and ensure OneShot is set if needed (#146) + targetEntity.TypeID = TR3TypeUtilities.TranslateAlias(newType); + SetOneShot(targetEntity, level.Entities.IndexOf(targetEntity), level.FloorData); + _resultantEnemies.Add(newType); + } + + if (!Settings.AllowEnemyKeyDrops && (!Settings.RandomizeItems || !Settings.IncludeKeyItems)) + { + // Shift enemies who are on top of key items so they don't pick them up. + IEnumerable keyEnemies = level.Entities.Where(enemy => TR3TypeUtilities.IsEnemyType(enemy.TypeID) + && level.Entities.Any(key => TR3TypeUtilities.IsKeyItemType(key.TypeID) + && key.GetLocation().IsEquivalent(enemy.GetLocation())) + ); + + foreach (TR3Entity enemy in keyEnemies) + { + enemy.X++; + } + } + } + + public List GetPistolLocations(string levelName) + => _pistolLocations[levelName]; + + public void AddUnarmedLevelAmmo(string levelName, TR3Level level, Action createItemCallback) + { + if (!Settings.CrossLevelEnemies || !Settings.GiveUnarmedItems) + { + return; + } + + List weaponTypes = TR3TypeUtilities.GetWeaponPickups(); + TR3Entity weaponEntity = level.Entities.Find(e => + weaponTypes.Contains(e.TypeID) + && _pistolLocations[levelName].Any(l => l.IsEquivalent(e.GetLocation()))); + + if (weaponEntity == null) + { + return; + } + + Location weaponLocation = weaponEntity.GetLocation(); + + List allEnemies = TR3TypeUtilities.GetFullListOfEnemies(); + List levelEnemies = level.Entities.FindAll(e => allEnemies.Contains(e.TypeID)); + EnemyDifficulty difficulty = TR3EnemyUtilities.GetEnemyDifficulty(levelEnemies); + + if (difficulty > EnemyDifficulty.Easy) + { + while (weaponEntity.TypeID == TR3Type.Pistols_P) + { + weaponEntity.TypeID = weaponTypes[Generator.Next(0, weaponTypes.Count)]; + } + } + + if (difficulty > EnemyDifficulty.Medium + && !level.Entities.Any(e => e.TypeID == TR3Type.Pistols_P)) + { + createItemCallback(weaponLocation, TR3Type.Pistols_P); + } + + int ammoAllocation = TR3EnemyUtilities.GetStartingAmmo(weaponEntity.TypeID); + if (ammoAllocation > 0) + { + ammoAllocation *= (int)difficulty; + TR3Type ammoType = TR3TypeUtilities.GetWeaponAmmo(weaponEntity.TypeID); + for (int i = 0; i < ammoAllocation; i++) + { + createItemCallback(weaponLocation, ammoType); + } + } + + if (difficulty == EnemyDifficulty.Medium || difficulty == EnemyDifficulty.Hard) + { + createItemCallback(weaponLocation, TR3Type.SmallMed_P); + createItemCallback(weaponLocation, TR3Type.LargeMed_P); + } + if (difficulty > EnemyDifficulty.Medium) + { + createItemCallback(weaponLocation, TR3Type.LargeMed_P); + } + if (difficulty == EnemyDifficulty.VeryHard) + { + createItemCallback(weaponLocation, TR3Type.LargeMed_P); + } + } +} diff --git a/TRRandomizerCore/TRRandomizerType.cs b/TRRandomizerCore/TRRandomizerType.cs index 9c72edd4f..59fbe600a 100644 --- a/TRRandomizerCore/TRRandomizerType.cs +++ b/TRRandomizerCore/TRRandomizerType.cs @@ -32,6 +32,7 @@ public enum TRRandomizerType VFX, DragonSpawn, BirdMonsterBehaviour, + DocileBirdMonster, SecretAudio, Mediless, KeyItems, diff --git a/TRRandomizerCore/TRVersionSupport.cs b/TRRandomizerCore/TRVersionSupport.cs index 75548e090..0d7ed7275 100644 --- a/TRRandomizerCore/TRVersionSupport.cs +++ b/TRRandomizerCore/TRVersionSupport.cs @@ -58,9 +58,12 @@ internal class TRVersionSupport private static readonly List _tr1RTypes = new() { + TRRandomizerType.AtlanteanEggBehaviour, TRRandomizerType.Audio, + TRRandomizerType.Enemy, TRRandomizerType.GlitchedSecrets, TRRandomizerType.HardSecrets, + TRRandomizerType.HiddenEnemies, TRRandomizerType.Item, TRRandomizerType.KeyItems, TRRandomizerType.Secret, @@ -76,6 +79,7 @@ internal class TRVersionSupport TRRandomizerType.Audio, TRRandomizerType.BirdMonsterBehaviour, TRRandomizerType.Braid, + TRRandomizerType.DocileBirdMonster, TRRandomizerType.DisableDemos, TRRandomizerType.DragonSpawn, TRRandomizerType.DynamicEnemyTextures, @@ -115,6 +119,8 @@ internal class TRVersionSupport private static readonly List _tr2RTypes = new() { TRRandomizerType.Audio, + TRRandomizerType.BirdMonsterBehaviour, + TRRandomizerType.Enemy, TRRandomizerType.GlitchedSecrets, TRRandomizerType.HardSecrets, TRRandomizerType.Item, @@ -170,6 +176,7 @@ internal class TRVersionSupport private static readonly List _tr3RTypes = new() { TRRandomizerType.Audio, + TRRandomizerType.Enemy, TRRandomizerType.GlitchedSecrets, TRRandomizerType.HardSecrets, TRRandomizerType.Item, diff --git a/TRRandomizerCore/Utilities/TR1EnemyUtilities.cs b/TRRandomizerCore/Utilities/TR1EnemyUtilities.cs index edde3270d..5a1342ead 100644 --- a/TRRandomizerCore/Utilities/TR1EnemyUtilities.cs +++ b/TRRandomizerCore/Utilities/TR1EnemyUtilities.cs @@ -147,20 +147,6 @@ public static List GetRequiredEnemies(string lvlName) return entities; } - public static void SetEntityTriggers(TR1Level level, TR1Entity entity) - { - if (_oneShotEnemies.Contains(entity.TypeID)) - { - int entityID = level.Entities.IndexOf(entity); - - List triggers = level.FloorData.GetEntityTriggers(entityID); - foreach (FDTriggerEntry trigger in triggers) - { - trigger.OneShot = true; - } - } - } - public static EnemyDifficulty GetEnemyDifficulty(List enemyEntities) { if (enemyEntities.Count == 0) @@ -202,7 +188,7 @@ public static EnemyDifficulty GetEnemyDifficulty(List enemyEntities) return allDifficulties[weight]; } - public static uint GetStartingAmmo(TR1Type weaponType) + public static int GetStartingAmmo(TR1Type weaponType) { if (_startingAmmoToGive.ContainsKey(weaponType)) { @@ -495,12 +481,6 @@ public static RestrictedEnemyGroup GetRestrictedEnemyGroup(string lvlName, TR1Ty = 2, // Defaults: 4 types, 56 enemies }; - // Enemies who can only spawn once. - private static readonly List _oneShotEnemies = new() - { - TR1Type.Pierre - }; - private static readonly Dictionary> _enemyDifficulties = new() { [EnemyDifficulty.VeryEasy] = new List @@ -529,7 +509,7 @@ public static RestrictedEnemyGroup GetRestrictedEnemyGroup(string lvlName, TR1Ty } }; - private static readonly Dictionary _startingAmmoToGive = new() + private static readonly Dictionary _startingAmmoToGive = new() { [TR1Type.Shotgun_S_P] = 10, [TR1Type.Magnums_S_P] = 6, diff --git a/TRRandomizerCore/Utilities/TR2EnemyUtilities.cs b/TRRandomizerCore/Utilities/TR2EnemyUtilities.cs index a75437e4f..f7206ec6e 100644 --- a/TRRandomizerCore/Utilities/TR2EnemyUtilities.cs +++ b/TRRandomizerCore/Utilities/TR2EnemyUtilities.cs @@ -1,6 +1,5 @@ using Newtonsoft.Json; using TRRandomizerCore.Helpers; -using TRRandomizerCore.Levels; using TRLevelControl.Helpers; using TRLevelControl.Model; @@ -32,16 +31,16 @@ public static int GetTargetEnemyAdjustmentCount(string lvlName, TR2Type enemy) return 0; } - public static bool IsWaterEnemyRequired(TR2CombinedLevel level) + public static bool IsWaterEnemyRequired(TR2Level level) { - return level.Data.Entities.Any(e => TR2TypeUtilities.IsWaterCreature(e.TypeID)); + return level.Entities.Any(e => TR2TypeUtilities.IsWaterCreature(e.TypeID)); } - public static bool IsDroppableEnemyRequired(TR2CombinedLevel level) + public static bool IsDroppableEnemyRequired(TR2Level level) { - return level.Data.Entities + return level.Entities .Where(e => TR2TypeUtilities.IsEnemyType(e.TypeID)) - .Any(enemy => level.Data.Entities.Any(item => HasDropItem(enemy, item))); + .Any(enemy => level.Entities.Any(item => HasDropItem(enemy, item))); } public static bool HasDropItem(TR2Entity enemy, TR2Entity item) @@ -465,26 +464,6 @@ public static List GetFriendlyEnemies() TR2Type.Winston, TR2Type.MonkWithKnifeStick, TR2Type.MonkWithLongStick }; - // #146 Ensure Marco is spawned only once - private static readonly List _oneShotEnemies = new() - { - TR2Type.MarcoBartoli - }; - - public static void SetEntityTriggers(TR2Level level, TR2Entity entity) - { - if (_oneShotEnemies.Contains(entity.TypeID)) - { - int entityID = level.Entities.IndexOf(entity); - - List triggers = level.FloorData.GetEntityTriggers(entityID); - foreach (FDTriggerEntry trigger in triggers) - { - trigger.OneShot = true; - } - } - } - public static Dictionary GetAliasPriority(string lvlName, List importEntities) { // If the priorities map doesn't contain an entity we are trying to import as a key, TRModelTransporter diff --git a/TRRandomizerCore/Utilities/TR3EnemyUtilities.cs b/TRRandomizerCore/Utilities/TR3EnemyUtilities.cs index 089df4825..3ce5eeb7b 100644 --- a/TRRandomizerCore/Utilities/TR3EnemyUtilities.cs +++ b/TRRandomizerCore/Utilities/TR3EnemyUtilities.cs @@ -2,7 +2,6 @@ using TRLevelControl.Helpers; using TRLevelControl.Model; using TRRandomizerCore.Helpers; -using TRRandomizerCore.Levels; namespace TRRandomizerCore.Utilities; @@ -153,11 +152,11 @@ public static List GetAIPathing(string lvlName, TR3Type entity, short return locations; } - public static bool IsDroppableEnemyRequired(TR3CombinedLevel level) + public static bool IsDroppableEnemyRequired(TR3Level level) { - return level.Data.Entities + return level.Entities .Where(e => TR3TypeUtilities.IsEnemyType(e.TypeID)) - .Any(enemy => level.Data.Entities.Any(item => HasDropItem(enemy, item))); + .Any(enemy => level.Entities.Any(item => HasDropItem(enemy, item))); } public static bool HasDropItem(TR3Entity enemy, TR3Entity item) @@ -168,20 +167,6 @@ public static bool HasDropItem(TR3Entity enemy, TR3Entity item) && item.Z == enemy.Z; } - public static void SetEntityTriggers(TR3Level level, TR3Entity entity) - { - if (_oneShotEnemies.Contains(entity.TypeID)) - { - int entityID = level.Entities.IndexOf(entity); - - List triggers = level.FloorData.GetEntityTriggers(entityID); - foreach (FDTriggerEntry trigger in triggers) - { - trigger.OneShot = true; - } - } - } - public static EnemyDifficulty GetEnemyDifficulty(List enemyEntities) { if (enemyEntities.Count == 0) @@ -223,7 +208,7 @@ public static EnemyDifficulty GetEnemyDifficulty(List enemyEntities) return allDifficulties[weight]; } - public static uint GetStartingAmmo(TR3Type weaponType) + public static int GetStartingAmmo(TR3Type weaponType) { if (_startingAmmoToGive.ContainsKey(weaponType)) { @@ -374,15 +359,6 @@ public static uint GetStartingAmmo(TR3Type weaponType) = 0 // Defaults: 2 types, 2 enemies }; - // Enemies who can only spawn once. These are enemies whose triggers in OG are all OneShot throughout. - private static readonly List _oneShotEnemies = new() - { - TR3Type.Croc, - TR3Type.KillerWhale, - TR3Type.Raptor, - TR3Type.Rat - }; - private static readonly Dictionary> _enemyDifficulties = new() { [EnemyDifficulty.VeryEasy] = new List @@ -414,7 +390,7 @@ public static uint GetStartingAmmo(TR3Type weaponType) } }; - private static readonly Dictionary _startingAmmoToGive = new() + private static readonly Dictionary _startingAmmoToGive = new() { [TR3Type.Shotgun_P] = 8, [TR3Type.Deagle_P] = 4, diff --git a/TRRandomizerCore/Utilities/VehicleUtilities.cs b/TRRandomizerCore/Utilities/VehicleUtilities.cs index e7aa34418..3efb37849 100644 --- a/TRRandomizerCore/Utilities/VehicleUtilities.cs +++ b/TRRandomizerCore/Utilities/VehicleUtilities.cs @@ -1,8 +1,7 @@ using Newtonsoft.Json; -using TRRandomizerCore.Helpers; -using TRRandomizerCore.Levels; -using TRLevelControl.Model; using TRLevelControl.Helpers; +using TRLevelControl.Model; +using TRRandomizerCore.Helpers; namespace TRRandomizerCore.Utilities; @@ -17,14 +16,14 @@ static VehicleUtilities() _secretLocations = JsonConvert.DeserializeObject>>(File.ReadAllText(@"Resources\TR2\Locations\locations.json")); } - public static Location GetRandomLocation(TR2CombinedLevel level, TR2Type vehicle, Random random, bool testSecrets = true) + public static Location GetRandomLocation(string levelName, TR2Level level, TR2Type vehicle, Random random, bool testSecrets = true) { - if (_vehicleLocations.ContainsKey(level.Name)) + if (_vehicleLocations.ContainsKey(levelName)) { short vehicleID = (short)vehicle; if (testSecrets) { - IEnumerable dependencies = GetDependentLocations(level); + IEnumerable dependencies = GetDependentLocations(levelName, level); if (dependencies.Any(l => l.TargetType == vehicleID)) { // Vehicles that have secrets dependent on their OG positions will not be moved. @@ -32,7 +31,7 @@ public static Location GetRandomLocation(TR2CombinedLevel level, TR2Type vehicle } } - List vehicleLocations = _vehicleLocations[level.Name] + List vehicleLocations = _vehicleLocations[levelName] .FindAll(l => l.TargetType == vehicleID); if (vehicleLocations.Count > 0) { @@ -43,15 +42,15 @@ public static Location GetRandomLocation(TR2CombinedLevel level, TR2Type vehicle return null; } - public static IEnumerable GetDependentLocations(TR2CombinedLevel level) + public static IEnumerable GetDependentLocations(string levelName, TR2Level level) { - if (!_secretLocations.ContainsKey(level.Name)) + if (!_secretLocations.ContainsKey(levelName)) { return Array.Empty(); } - IEnumerable levelLocations = _secretLocations[level.Name].Where(l => l.VehicleRequired); - IEnumerable secrets = level.Data.Entities.Where(e => TR2TypeUtilities.IsSecretType(e.TypeID)); + IEnumerable levelLocations = _secretLocations[levelName].Where(l => l.VehicleRequired); + IEnumerable secrets = level.Entities.Where(e => TR2TypeUtilities.IsSecretType(e.TypeID)); return levelLocations .Where(l => secrets.Any(s => l.X == s.X && l.Y == s.Y && l.Z == s.Z && l.Room == s.Room)); diff --git a/TRRandomizerView/Model/ControllerOptions.cs b/TRRandomizerView/Model/ControllerOptions.cs index 3d4d1a79f..1ffe5ccd3 100644 --- a/TRRandomizerView/Model/ControllerOptions.cs +++ b/TRRandomizerView/Model/ControllerOptions.cs @@ -3946,6 +3946,7 @@ public void Unload() public bool IsChallengeRoomsTypeSupported => IsRandomizationSupported(TRRandomizerType.ChallengeRooms); public bool IsWeatherTypeSupported => IsRandomizationSupported(TRRandomizerType.Weather); public bool IsBirdMonsterBehaviourTypeSupported => IsRandomizationSupported(TRRandomizerType.BirdMonsterBehaviour); + public bool IsDocileBirdMonsterTypeSupported => IsRandomizationSupported(TRRandomizerType.DocileBirdMonster); public bool IsDragonSpawnTypeSupported => IsRandomizationSupported(TRRandomizerType.DragonSpawn); public bool IsSecretTexturesTypeSupported => IsRandomizationSupported(TRRandomizerType.SecretTextures); public bool IsKeyItemTexturesTypeSupported => IsRandomizationSupported(TRRandomizerType.KeyItemTextures); @@ -3989,7 +3990,7 @@ private void FireSupportPropertiesChanged() } else { - _randomSecretsControl.Description = "Randomize secret locations. Artefacts will be added as pickups and rewards will appear when all secrets are collected."; + _randomSecretsControl.Description = "Randomize secret locations. Artefacts will be added as pickups and rewards will be stacked with them."; } FirePropertyChanged(nameof(RandomizeSecretsText)); diff --git a/TRRandomizerView/Windows/AdvancedWindow.xaml b/TRRandomizerView/Windows/AdvancedWindow.xaml index 382acb04e..52306fd2d 100644 --- a/TRRandomizerView/Windows/AdvancedWindow.xaml +++ b/TRRandomizerView/Windows/AdvancedWindow.xaml @@ -414,7 +414,8 @@ - + + Grid.Column="1" + Visibility="{Binding ControllerProxy.IsDocileBirdMonsterTypeSupported, Converter={StaticResource BoolToCollapsedConverter}}">