diff --git a/dScripts/02_server/Enemy/AG/BossSpiderQueenEnemyServer.cpp b/dScripts/02_server/Enemy/AG/BossSpiderQueenEnemyServer.cpp index f2a994f5c..e66772cd0 100644 --- a/dScripts/02_server/Enemy/AG/BossSpiderQueenEnemyServer.cpp +++ b/dScripts/02_server/Enemy/AG/BossSpiderQueenEnemyServer.cpp @@ -20,6 +20,7 @@ #include "PlayerManager.h" #include "eStateChangeType.h" +#include #include //---------------------------------------------------------------- @@ -434,31 +435,83 @@ void BossSpiderQueenEnemyServer::RapidFireShooterManager(Entity* self) { } void BossSpiderQueenEnemyServer::RunRapidFireShooter(Entity* self) { - const auto targets = self->GetTargetsInPhantom(); - - if (targets.empty()) { - LOG("Failed to find RFS targets"); - + // The sweep starts around a random player, from the zone volume that player last entered + const auto& players = PlayerManager::GetAllPlayers(); + if (players.empty()) { self->AddTimer("PollSpiderSkillManager", static_cast(GeneralUtils::GenerateRandomNumber(s1DelayMin, s1DelayMax))); - return; } ToggleForSpecial(self, true); - const auto randomTarget = GeneralUtils::GenerateRandomNumber(0, targets.size() - 1); + const auto* const attackFocus = players[GeneralUtils::GenerateRandomNumber(0, players.size() - 1)]; + const auto inZone = inZoneTable.find(attackFocus->GetObjectID()); + const auto& primaryZone = inZone != inZoneTable.end() ? inZone->second : defaultFacingZone; - auto attackFocus = targets[randomTarget]; + // Clockwise or counter-clockwise at random + const bool clockwise = GeneralUtils::GenerateRandomNumber(1, 2) == 1; - attackTargetTable.push_back(attackFocus); + attackTargetTable = BuildRapidFireTargets(primaryZone, clockwise, [](const std::string& group) { + std::vector targets; + for (auto* const target : Game::entityManager->GetEntitiesInGroup(group)) { + targets.push_back({ target->GetObjectID(), target->GetVar(u"CWOrder"), target->GetVar(u"CWOrder2") }); + } + return targets; + }); + // Turn towards the fourth target, then shoot every target in turn auto* skillComponent = self->GetComponent(); - - skillComponent->CalculateBehavior(1480, 36652, attackFocus, true); + skillComponent->CalculateBehavior(1480, 36652, attackTargetTable.size() >= 4 ? attackTargetTable[3] : LWOOBJID_EMPTY, true); RapidFireShooterManager(self); - PlayAnimAndReturnTime(self, spiderSingleShot); + PlayAnimAndReturnTime(self, clockwise ? spiderShootRght : spiderShootLeft); +} + +std::vector BossSpiderQueenEnemyServer::BuildRapidFireTargets(const std::string& primaryZone, const bool clockwise, + const std::function(const std::string& group)>& groupTargets) { + std::vector attackTargets; + + const auto zoneGroups = rapidFireTargetTable.find(primaryZone); + if (zoneGroups == rapidFireTargetTable.end()) return attackTargets; + + const auto& groups = zoneGroups->second; + const auto& primaryGroup = clockwise ? groups[0] : groups[2]; + const auto& secondaryGroup = groups[1]; + const auto& tertiaryGroup = clockwise ? groups[2] : groups[0]; + + // Zones 8 and 1 meet where CWOrder starts again; sweeps over that edge sort by CWOrder2 + const bool crossesStart = std::ranges::any_of(groups, [](const std::string& group) { + return group == "Zone1Targets" || group == "Zone8Targets"; + }); + + for (const auto* const group : { &primaryGroup, &secondaryGroup, &tertiaryGroup }) { + auto targets = groupTargets(*group); + std::ranges::stable_sort(targets, [crossesStart, clockwise](const RapidFireTarget& a, const RapidFireTarget& b) { + const auto orderA = crossesStart ? a.cwOrder2 : a.cwOrder; + const auto orderB = crossesStart ? b.cwOrder2 : b.cwOrder; + return clockwise ? orderA < orderB : orderA > orderB; + }); + + // The middle group leaves out its first and last target + const bool isSecondary = group == &secondaryGroup; + for (size_t i = 0; i < targets.size(); i++) { + if (isSecondary && (i == 0 || i == targets.size() - 1)) continue; + attackTargets.push_back(targets[i].id); + } + } + + return attackTargets; +} + +void BossSpiderQueenEnemyServer::OnZoneVolumeEntered(Entity* self, Entity* player, const std::string& volumeGroup) { + if (!player || !player->IsPlayer()) return; + + // The aggro volume is not a quadrant (the boss's aggro uses her proximity radius) + if (volumeGroup == "AggroVol") return; + + // A teleported player faces the default zone + inZoneTable[player->GetObjectID()] = volumeGroup == "TeleVol" ? defaultFacingZone : volumeGroup; } void BossSpiderQueenEnemyServer::OnTimerDone(Entity* self, const std::string timerName) { diff --git a/dScripts/02_server/Enemy/AG/BossSpiderQueenEnemyServer.h b/dScripts/02_server/Enemy/AG/BossSpiderQueenEnemyServer.h index 813e533c1..a5495314f 100644 --- a/dScripts/02_server/Enemy/AG/BossSpiderQueenEnemyServer.h +++ b/dScripts/02_server/Enemy/AG/BossSpiderQueenEnemyServer.h @@ -1,6 +1,9 @@ #pragma once #include "CppScripts.h" +#include +#include + /* -------------------------------------------------------------- @@ -49,6 +52,22 @@ public: void RunRapidFireShooter(Entity* self); + // A target of the rapid fire shooter (groups Zone1Targets..Zone8Targets) and its clockwise order in the arena + struct RapidFireTarget { + LWOOBJID id = LWOOBJID_EMPTY; + int32_t cwOrder = 0; + int32_t cwOrder2 = 0; // the order where the sweep crosses between zones 8 and 1 + }; + + // The rapid fire shooter's targets for a player in primaryZone (Zone1Vol..Zone8Vol), as the live script builds + // them: the three target groups around that zone, each sorted in the sweep direction, without the first and last + // target of the middle group + static std::vector BuildRapidFireTargets(const std::string& primaryZone, bool clockwise, + const std::function(const std::string& group)>& groupTargets); + + // A player touched one of the arena's zone volumes (group Zone1Vol..Zone8Vol, AggroVol or TeleVol) + void OnZoneVolumeEntered(Entity* self, Entity* player, const std::string& volumeGroup); + float PlayAnimAndReturnTime(Entity* self, const std::u16string& animId); void OnTimerDone(Entity* self, std::string timerName) override; @@ -89,8 +108,7 @@ private: std::vector hatchList = {}; //Global list maintaining a record of all the eggs we have prepped to hatch for a wave const std::string defaultFacingZone = "Zone3Vol"; //Maintains a default facing to ensure appropriate Spider Boss positioning for teleported players - std::vector inZoneTable = {}; //Keeps track through player ID index what quadrant of the zone the player is in now - std::vector fromZoneTable = {}; //Keeps track through player ID index what quadrant of the zone the player was last in + std::map inZoneTable; //Keeps track through player ID index what quadrant of the zone the player is in now const float defaultAnimPause = 2.5f; //Default period of time to pause between missing animation actions const std::u16string spiderJeerAnim = u"taunt"; //Animation Spider Boss plays to antagonize the player @@ -120,7 +138,7 @@ private: const unsigned int instanceMissionID = 1941; //Achievement to update for beating the instanced Boss //Establishes a link for the Spider to identify rapid fire targets based on zone reference - const std::map> rapidFireTargetTable = { + static inline const std::map> rapidFireTargetTable = { {"Zone1Vol", {"Zone8Targets", "Zone1Targets", "Zone2Targets"}}, {"Zone2Vol", {"Zone1Targets", "Zone2Targets", "Zone3Targets"}}, {"Zone3Vol", {"Zone2Targets", "Zone3Targets", "Zone4Targets"}}, diff --git a/dScripts/02_server/Map/Property/AG_Small/ZoneAgProperty.cpp b/dScripts/02_server/Map/Property/AG_Small/ZoneAgProperty.cpp index 80dea74c4..6e8c69f04 100644 --- a/dScripts/02_server/Map/Property/AG_Small/ZoneAgProperty.cpp +++ b/dScripts/02_server/Map/Property/AG_Small/ZoneAgProperty.cpp @@ -1,5 +1,6 @@ #include "ZoneAgProperty.h" #include "GeneralUtils.h" +#include "BossSpiderQueenEnemyServer.h" #include "EntityManager.h" #include "Character.h" #include "Entity.h" @@ -135,6 +136,32 @@ void ZoneAgProperty::ProcessGroupObjects(Entity* self, const std::string& group) spiderBoss->SetVar(varName, objects.front()->GetObjectID()); } +void ZoneAgProperty::ProcessZoneVolumes(Entity* self) { + // Tells the Spider Queen which of the arena's zone volumes each player walks into, so her rapid fire sweeps + // start where the player is + for (const auto& group : self->GetVar>(ZoneVolumesGroup)) { + const auto volumes = Game::entityManager->GetEntitiesInGroup(group); + if (volumes.empty()) { + // Not spawned yet: look again shortly + self->AddTimer("ProcessGroupObj_ZoneVolumes", 0.3f); + return; + } + + auto* const volume = volumes.front(); + if (volume->GetVar(u"spiderBossSensor")) continue; // already reporting + volume->SetVar(u"spiderBossSensor", true); + + volume->AddCollisionPhantomCallback([zoneID = self->GetObjectID(), group](Entity* target) { + auto* const zone = Game::entityManager->GetEntity(zoneID); + if (!zone) return; + auto* const spiderBoss = Game::entityManager->GetEntity(zone->GetVar(u"SpiderBossID")); + if (!spiderBoss) return; + auto* const bossScript = dynamic_cast(spiderBoss->GetScript()); + if (bossScript) bossScript->OnZoneVolumeEntered(spiderBoss, target, group); + }); + } +} + void ZoneAgProperty::SpawnSpots(Entity* self) { for (const auto& spot : self->GetVar>(ROFTargetsSpawner)) { ActivateSpawner(spot); @@ -313,6 +340,8 @@ void ZoneAgProperty::BaseTimerDone(Entity* self, const std::string& timerName) { ProcessGroupObjects(self, self->GetVar(LandTargetGroup)); } else if (timerName == "ProcessGroupObj_ScreamEmitter") { ProcessGroupObjects(self, self->GetVar(SpiderScreamGroup)); + } else if (timerName == "ProcessGroupObj_ZoneVolumes") { + ProcessZoneVolumes(self); } } @@ -431,7 +460,7 @@ void ZoneAgProperty::BaseOnFireEventServerSide(Entity* self, Entity* sender, std ProcessGroupObjects(self, self->GetVar(LandTargetGroup)); ProcessGroupObjects(self, self->GetVar(SpiderScreamGroup)); - // ProcessGroupObjects(self, groups.ZoneVolumes); + ProcessZoneVolumes(self); } else if (args == "CheckForPropertyOwner") { sender->SetNetworkVar(u"PropertyOwnerID", std::to_string(self->GetVar(u"PropertyOwner"))); } else if (args == "ClearProperty") { diff --git a/dScripts/02_server/Map/Property/AG_Small/ZoneAgProperty.h b/dScripts/02_server/Map/Property/AG_Small/ZoneAgProperty.h index 578f60a99..0b9ce7bf4 100644 --- a/dScripts/02_server/Map/Property/AG_Small/ZoneAgProperty.h +++ b/dScripts/02_server/Map/Property/AG_Small/ZoneAgProperty.h @@ -31,6 +31,7 @@ public: void LoadProperty(Entity* self); void ProcessGroupObjects(Entity* self, const std::string& group); + void ProcessZoneVolumes(Entity* self); void SpawnSpots(Entity* self) override; void KillSpots(Entity* self) override; diff --git a/dScripts/02_server/Map/Property/CMakeLists.txt b/dScripts/02_server/Map/Property/CMakeLists.txt index b4085cfb9..a6c8f72fa 100644 --- a/dScripts/02_server/Map/Property/CMakeLists.txt +++ b/dScripts/02_server/Map/Property/CMakeLists.txt @@ -24,4 +24,5 @@ target_precompile_headers(dScriptsServerMapProperty REUSE_FROM dScriptsBase) target_include_directories(dScriptsServerMapProperty PUBLIC "." "AG_Med" "AG_Small" - "NS_Med") + "NS_Med" + "../../Enemy/AG") # the AG property zone reports zone volumes to the Spider Queen diff --git a/tests/dGameTests/SpiderQueenTests.cpp b/tests/dGameTests/SpiderQueenTests.cpp index c8f2719d9..2b98f9325 100644 --- a/tests/dGameTests/SpiderQueenTests.cpp +++ b/tests/dGameTests/SpiderQueenTests.cpp @@ -112,6 +112,63 @@ TEST_F(SpiderQueenTest, TheMeleeSmashLocksTheSpecialsForItsLength) { EXPECT_FALSE(boss->GetBoolean(u"bSpecialQueued")); } +namespace { + // The arena's 16 rapid fire targets as the AG property map places them: target n has CWOrder n and CWOrder2 + // (n + 8) mod 16 (1 to 16); ZoneNTargets holds the targets 2N-1, 2N and 2N+1 (target 1 again after 16), so + // neighbouring groups share their edge target. The object ID is 100 + CWOrder. + std::vector ArenaGroup(const std::string& group) { + const int32_t zone = group[4] - '0'; + std::vector targets; + for (int32_t n = 2 * zone - 1; n <= 2 * zone + 1; n++) { + const int32_t cwOrder = (n - 1) % 16 + 1; + targets.push_back({ 100 + cwOrder, cwOrder, (cwOrder + 7) % 16 + 1 }); + } + // The sweep sorts them; hand them over out of order + std::ranges::reverse(targets); + return targets; + } + + std::vector Ids(const std::vector& cwOrders) { + std::vector ids; + for (const auto order : cwOrders) ids.push_back(100 + order); + return ids; + } +} + +TEST_F(SpiderQueenTest, RapidFireSweepsTheThreeZonesAroundThePlayerClockwise) { + EXPECT_EQ(BossSpiderQueenEnemyServer::BuildRapidFireTargets("Zone5Vol", true, ArenaGroup), Ids({ 7, 8, 9, 10, 11, 12, 13 })); +} + +TEST_F(SpiderQueenTest, RapidFireSweepsCounterClockwise) { + EXPECT_EQ(BossSpiderQueenEnemyServer::BuildRapidFireTargets("Zone5Vol", false, ArenaGroup), Ids({ 13, 12, 11, 10, 9, 8, 7 })); +} + +TEST_F(SpiderQueenTest, RapidFireSweepsOverTheEdgeBetweenZonesEightAndOne) { + EXPECT_EQ(BossSpiderQueenEnemyServer::BuildRapidFireTargets("Zone1Vol", true, ArenaGroup), Ids({ 15, 16, 1, 2, 3, 4, 5 })); + EXPECT_EQ(BossSpiderQueenEnemyServer::BuildRapidFireTargets("Zone8Vol", false, ArenaGroup), Ids({ 3, 2, 1, 16, 15, 14, 13 })); + EXPECT_EQ(BossSpiderQueenEnemyServer::BuildRapidFireTargets("Zone2Vol", true, ArenaGroup), Ids({ 1, 2, 3, 4, 5, 6, 7 })); +} + +TEST_F(SpiderQueenTest, ZoneVolumesTellTheBossWhereThePlayerIs) { + for (const auto& group : { "Zone1Vol", "Zone2Vol", "Zone3Vol", "Zone4Vol", "Zone5Vol", "Zone6Vol", "Zone7Vol", "Zone8Vol", "AggroVol", "TeleVol" }) { + Add(0x2000 + static_cast(entities.size()), 14400, { group }); + } + zone->SetVar(u"SpiderBossID", BOSS); + zoneScript.ProcessZoneVolumes(zone); + zoneScript.ProcessZoneVolumes(zone); // asked again: each volume reports once + for (const auto& entity : entities) { + if (entity->GetGroups().size() == 1 && entity->GetGroups()[0].ends_with("Vol")) { + EXPECT_TRUE(entity->GetVar(u"spiderBossSensor")); + } + } +} + +TEST_F(SpiderQueenTest, ZoneVolumesWaitForTheVolumesToSpawn) { + zoneScript.ProcessZoneVolumes(zone); + zone->Update(0.0f); + EXPECT_TRUE(zone->HasTimer("ProcessGroupObj_ZoneVolumes")); +} + TEST_F(SpiderQueenTest, NoSpecialsBeforeTheFirstWave) { // Stage 1 has no special attack; the skill manager does nothing const auto packets = Capture([&] { bossScript.SpiderSkillManager(boss, true); });