From 7847af4babb5a5d1d2bb271100de9a857da8b2af Mon Sep 17 00:00:00 2001 From: lahm86 <33758420+lahm86@users.noreply.github.com> Date: Sat, 25 May 2024 10:02:56 +0100 Subject: [PATCH] Add TRR enemy support (#682) Part of #614. --- Deps/TRGE.Coord.dll | Bin 4357120 -> 4357632 bytes Deps/TRGE.Core.dll | Bin 242688 -> 242176 bytes .../Data/Remastered/BaseTRRDataCache.cs | 19 + .../Data/Remastered/TR1RDataCache.cs | 77 +- .../Data/Remastered/TR2RDataCache.cs | 130 ++ .../Data/Remastered/TR3RDataCache.cs | 93 +- TRDataControl/Transport/ImportResult.cs | 8 + TRDataControl/Transport/TRDataImporter.cs | 18 +- TRDataControlTests/IO/ImportTests.cs | 152 +++ TRLevelControl/Helpers/TR1TypeUtilities.cs | 12 - .../Editors/TR1RemasteredEditor.cs | 23 + .../Editors/TR2RemasteredEditor.cs | 31 +- .../Editors/TR3RemasteredEditor.cs | 23 + .../Randomizers/Shared/EnemyAllocator.cs | 99 ++ .../Randomizers/Shared/EnemyCollections.cs | 20 + .../Randomizers/Shared/RandoConsts.cs | 3 + .../TR1/Classic/TR1EnemyRandomizer.cs | 1162 ++--------------- .../TR1/Remastered/TR1REnemyRandomizer.cs | 255 ++++ .../TR1/Shared/TR1EnemyAllocator.cs | 817 ++++++++++++ .../TR2/Classic/TR2EnemyRandomizer.cs | 949 +------------- .../TR2/Classic/TR2ItemRandomizer.cs | 4 +- .../TR2/Remastered/TR2REnemyRandomizer.cs | 329 +++++ .../TR2/Shared/TR2EnemyAllocator.cs | 666 ++++++++++ .../TR3/Classic/TR3EnemyRandomizer.cs | 653 +-------- .../TR3/Remastered/TR3REnemyRandomizer.cs | 217 +++ .../TR3/Shared/TR3EnemyAllocator.cs | 494 +++++++ TRRandomizerCore/TRRandomizerType.cs | 1 + TRRandomizerCore/TRVersionSupport.cs | 7 + .../Utilities/TR1EnemyUtilities.cs | 24 +- .../Utilities/TR2EnemyUtilities.cs | 31 +- .../Utilities/TR3EnemyUtilities.cs | 34 +- .../Utilities/VehicleUtilities.cs | 21 +- TRRandomizerView/Model/ControllerOptions.cs | 3 +- TRRandomizerView/Windows/AdvancedWindow.xaml | 6 +- 34 files changed, 3713 insertions(+), 2668 deletions(-) create mode 100644 TRDataControl/Data/Remastered/TR2RDataCache.cs create mode 100644 TRDataControl/Transport/ImportResult.cs create mode 100644 TRRandomizerCore/Randomizers/Shared/EnemyAllocator.cs create mode 100644 TRRandomizerCore/Randomizers/Shared/EnemyCollections.cs create mode 100644 TRRandomizerCore/Randomizers/TR1/Remastered/TR1REnemyRandomizer.cs create mode 100644 TRRandomizerCore/Randomizers/TR1/Shared/TR1EnemyAllocator.cs create mode 100644 TRRandomizerCore/Randomizers/TR2/Remastered/TR2REnemyRandomizer.cs create mode 100644 TRRandomizerCore/Randomizers/TR2/Shared/TR2EnemyAllocator.cs create mode 100644 TRRandomizerCore/Randomizers/TR3/Remastered/TR3REnemyRandomizer.cs create mode 100644 TRRandomizerCore/Randomizers/TR3/Shared/TR3EnemyAllocator.cs diff --git a/Deps/TRGE.Coord.dll b/Deps/TRGE.Coord.dll index 22f232af06513c046bfc86ebe7f82194427a6138..e29dcce15dfbb8b8b115491c1812453a72618a73 100644 GIT binary patch delta 17961 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 L;BTeouCD(Fq3ip~ delta 17577 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

KI( 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 diff --git a/Deps/TRGE.Core.dll b/Deps/TRGE.Core.dll index 52a7abdfa5a26f0ab913a46d6c3676cc282b6f97..694526df392e5f948f4ccd4fb603728673dccbf6 100644 GIT binary patch delta 71198 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 RI@<23@r3-lv7}YC{{y$ik4OLj delta 71740 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 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}}">