Compare commits

..

8 Commits

Author SHA1 Message Date
David Markowitz
a1891955e2 fix: leave team when fully logged out (#2007)
* feat: spawner weights

* remove ref

* default weights to 1

* fix: remove team member if they've logged out

tested that if i logout, after 20 seconds the team member is removed.
2026-06-20 02:50:49 -07:00
David Markowitz
7456d6b5c1 feat: spawner weights (#2006)
* feat: spawner weights

* remove ref

* default weights to 1
2026-06-19 19:08:15 -07:00
David Markowitz
308412f46e fix: enemies snapping to the incorrect position if they had a path and trying to use the path if they were aggro'd to an enemy (#2005)
* fix: enemies snapping to the incorrect position if they had a path

tested that ags enemies no longer snap backwards a large amount

* fix: move the home point so we can aggro correctly
2026-06-19 02:12:47 -07:00
David Markowitz
56504d9447 fix: add range checks to npc combat skill behavior (#2003)
* fix: add range checks to npc combat skill behavior

tested that all enemies now cast skills smartly based on range to targets, and do not cast skills if they are out of range.

fixes an issue where the spider queen could attack you outside the normal range

fixes an issue where entering happy flower caused you to need to restart the client

fixes #965

* feedback
2026-06-19 01:27:49 -05:00
David Markowitz
ce9d4e823c feat: enemies now use weights on their attacks (#2004)
* feat: enemies now use weights on their attacks

tested that 8 times out of 10, in close range, spiders did a web attack instead of a melee attack, vs the prior behavior of always following a pattern

fixes #2002

* feedback
2026-06-19 01:27:14 -05:00
David Markowitz
0f17e1de3b feat: enemy npc pathing (#2000)
* feat: enemy npc pathing

they live 🎉
tested that enemies path all around the world should they have a path configured.
tested that the admiral in gf (at the first camp) paths now.
fixes #1546

* feedback
2026-06-17 23:07:36 -07:00
David Markowitz
c898356eba fix: enemies not interrupting QB's when they do damage (#1998)
tested that stromlings in AG now correctly interrupt quickbuilds if the player takes damage
2026-06-16 09:49:56 -05:00
David Markowitz
79bb48d3bc feat: implement a bunch of basic scripts that don't really do anything (#1999)
* feat: implement a bunch of basic scripts that don't really do anything

None of these do anything noticeable or break anything

* fixes
2026-06-16 09:49:30 -05:00
18 changed files with 380 additions and 149 deletions

View File

@@ -105,15 +105,7 @@ void PlayerContainer::RemovePlayer(const LWOOBJID playerID) {
auto* team = TeamContainer::GetTeam(playerID);
if (team != nullptr) {
const auto memberName = GeneralUtils::UTF8ToUTF16(player.playerName);
for (const auto memberId : team->memberIDs) {
const auto& otherMember = GetPlayerData(memberId);
if (!otherMember) continue;
TeamContainer::SendTeamSetOffWorldFlag(otherMember, playerID, { 0, 0, 0 });
}
TeamContainer::RemoveMember(team, playerID, false, false, true);
}
ChatWeb::SendWSPlayerUpdate(player, eActivityType::PlayerLoggedOut);

View File

@@ -289,6 +289,14 @@ struct LwoNameValue {
this->Erase(GeneralUtils::ASCIIToUTF16(key));
}
ValueType::iterator find(const ValueType::key_type& key) {
return this->values.find(key);
}
ValueType::const_iterator find(const ValueType::key_type& key) const {
return this->values.find(key);
}
LwoNameValue() = default;
LwoNameValue(const LwoNameValue& other) {

View File

@@ -38,3 +38,11 @@ std::vector<CDObjectSkills> CDObjectSkillsTable::Query(std::function<bool(CDObje
return data;
}
std::vector<CDObjectSkills> CDObjectSkillsTable::Get(const LOT lot) const {
std::vector<CDObjectSkills> toReturn;
for (const auto& entry : GetEntries()) {
if (entry.objectTemplate == lot) toReturn.push_back(entry);
}
return toReturn;
}

View File

@@ -4,12 +4,13 @@
#include "CDTable.h"
#include <cstdint>
#include <vector>
struct CDObjectSkills {
uint32_t objectTemplate; //!< The LOT of the item
uint32_t skillID; //!< The Skill ID of the object
uint32_t castOnType; //!< ???
uint32_t AICombatWeight; //!< ???
int32_t AICombatWeight; //!< ???
};
class CDObjectSkillsTable : public CDTable<CDObjectSkillsTable, std::vector<CDObjectSkills>> {
@@ -17,5 +18,6 @@ public:
void LoadValuesFromDatabase();
// Queries the table with a custom "where" clause
std::vector<CDObjectSkills> Query(std::function<bool(CDObjectSkills)> predicate);
std::vector<CDObjectSkills> Get(const LOT lot) const;
};

View File

@@ -5,20 +5,40 @@
void NpcCombatSkillBehavior::Calculate(BehaviorContext* context, RakNet::BitStream& bit_stream, BehaviorBranchContext branch) {
context->skillTime = this->m_npcSkillTime;
const auto* const targetEntity = Game::entityManager->GetEntity(branch.target);
const auto* const sourceEntity = Game::entityManager->GetEntity(context->caster);
for (auto* behavior : this->m_behaviors) {
behavior->Calculate(context, bit_stream, branch);
bool cast = true;
// Check that the target is within the cast range
if (targetEntity && sourceEntity && this->m_maxRange != 0.0f) {
const auto targetPos = targetEntity->GetPosition();
const auto sourcePos = sourceEntity->GetPosition();
const auto distance = NiPoint3::DistanceSquared(targetPos, sourcePos);
cast = distance >= this->m_minRange && distance <= this->m_maxRange;
}
if (cast) {
for (auto* behavior : this->m_behaviors) {
behavior->Calculate(context, bit_stream, branch);
}
} else {
// We failed to find a valid target, do not continue the behavior
context->foundTarget = false;
}
}
void NpcCombatSkillBehavior::Load() {
this->m_npcSkillTime = GetFloat("npc skill time");
this->m_minRange = GetFloat("min range") * 0.9f; // Make the min and max 10% smaller to account for server/client position disagreements
this->m_minRange *= this->m_minRange;
this->m_maxRange = GetFloat("max range") * 0.9f; // Make the min and max 10% smaller to account for server/client position disagreements
this->m_maxRange *= this->m_maxRange;
const auto parameters = GetParameterNames();
for (const auto& parameter : parameters) {
if (parameter.first.rfind("behavior", 0) == 0) {
auto* action = GetAction(parameter.second);
for (const auto& [parameter, value] : parameters) {
if (parameter.rfind("behavior", 0) == 0) {
auto* action = GetAction(value);
this->m_behaviors.push_back(action);
}

View File

@@ -8,6 +8,9 @@ public:
float m_npcSkillTime;
float m_maxRange{};
float m_minRange{};
/*
* Inherited
*/

View File

@@ -25,7 +25,7 @@ void VerifyBehavior::Calculate(BehaviorContext* context, RakNet::BitStream& bitS
const auto distance = Vector3::DistanceSquared(self->GetPosition(), entity->GetPosition());
if (distance > this->m_range * this->m_range) {
if (distance > this->m_range) {
success = false;
}
} else if (this->m_blockCheck) {
@@ -57,4 +57,5 @@ void VerifyBehavior::Load() {
this->m_action = GetAction("action");
this->m_range = GetFloat("range");
this->m_range = this->m_range * this->m_range * 0.9f; // Range checks are slightly smaller than the actual range to account for client/server discrepancies
}

View File

@@ -13,6 +13,8 @@
#include "CDClientDatabase.h"
#include "CDClientManager.h"
#include "CDObjectSkillsTable.h"
#include "CDSkillBehaviorTable.h"
#include "DestroyableComponent.h"
#include <algorithm>
@@ -43,7 +45,7 @@ BaseCombatAIComponent::BaseCombatAIComponent(Entity* parent, const int32_t compo
//Grab the aggro information from BaseCombatAI:
auto componentQuery = CDClientDatabase::CreatePreppedStmt(
"SELECT aggroRadius, tetherSpeed, pursuitSpeed, softTetherRadius, hardTetherRadius FROM BaseCombatAIComponent WHERE id = ?;");
"SELECT aggroRadius, tetherSpeed, pursuitSpeed, softTetherRadius, hardTetherRadius, minRoundLength, maxRoundLength, combatRoundLength FROM BaseCombatAIComponent WHERE id = ?;");
componentQuery.bind(1, static_cast<int>(componentID));
auto componentResult = componentQuery.execQuery();
@@ -63,44 +65,37 @@ BaseCombatAIComponent::BaseCombatAIComponent(Entity* parent, const int32_t compo
if (!componentResult.fieldIsNull("hardTetherRadius"))
m_HardTetherRadius = componentResult.getFloatField("hardTetherRadius");
}
componentResult.finalize();
m_MinRoundLength = componentResult.getFloatField("minRoundLength");
m_MaxRoundLength = componentResult.getFloatField("maxRoundLength");
m_CombatRoundLength = componentResult.getFloatField("combatRoundLength");
}
// Get aggro and tether radius from settings and use this if it is present. Only overwrite the
// radii if it is greater than the one in the database.
if (m_Parent) {
auto aggroRadius = m_Parent->GetVar<float>(u"aggroRadius");
m_AggroRadius = aggroRadius != 0 ? aggroRadius : m_AggroRadius;
auto tetherRadius = m_Parent->GetVar<float>(u"tetherRadius");
m_HardTetherRadius = tetherRadius != 0 ? tetherRadius : m_HardTetherRadius;
}
m_AggroRadius = m_Parent->HasVar(u"aggroRadius") ? m_Parent->GetVar<float>(u"aggroRadius") : m_AggroRadius;
m_HardTetherRadius = m_Parent->HasVar(u"tetherRadius") ? m_Parent->GetVar<float>(u"tetherRadius") : m_HardTetherRadius;
/*
* Find skills
*/
auto skillQuery = CDClientDatabase::CreatePreppedStmt(
"SELECT skillID, cooldown, behaviorID FROM SkillBehavior WHERE skillID IN (SELECT skillID FROM ObjectSkills WHERE objectTemplate = ?);");
skillQuery.bind(1, static_cast<int>(parent->GetLOT()));
for (const auto objectSkill : CDClientManager::GetTable<CDObjectSkillsTable>()->Get(parent->GetLOT())) {
const auto skillBehavior = CDClientManager::GetTable<CDSkillBehaviorTable>()->GetSkillByID(objectSkill.skillID);
if (skillBehavior.skillID == objectSkill.skillID) {
const auto skillId = skillBehavior.skillID;
auto result = skillQuery.execQuery();
const auto abilityCooldown = skillBehavior.cooldown;
while (!result.eof()) {
const auto skillId = static_cast<uint32_t>(result.getIntField("skillID"));
const auto behaviorId = skillBehavior.behaviorID;
const auto abilityCooldown = static_cast<float>(result.getFloatField("cooldown"));
const auto combatWeight = objectSkill.AICombatWeight;
const auto behaviorId = static_cast<uint32_t>(result.getIntField("behaviorID"));
auto* behavior = Behavior::CreateBehavior(behaviorId);
auto* behavior = Behavior::CreateBehavior(behaviorId);
AiSkillEntry entry = { .skillId = skillId, .cooldown = 0.0f, .abilityCooldown = abilityCooldown, .behavior = behavior, .combatWeight = combatWeight };
std::stringstream behaviorQuery;
AiSkillEntry entry = { skillId, 0, abilityCooldown, behavior };
m_SkillEntries.push_back(entry);
result.nextRow();
m_SkillEntries.push_back(entry);
}
}
Stun(1.0f);
@@ -210,8 +205,10 @@ void BaseCombatAIComponent::Update(const float deltaTime) {
}
if (stunnedThisFrame) {
m_MovementAI->Stop();
if (!m_MovementAI->IsPaused()) m_MovementAI->Pause();
// in this case we just become unstunned so check if we paused and resume if we did
if (!m_Stunned && m_MovementAI->IsPaused()) m_MovementAI->Resume();
return;
}
@@ -246,10 +243,12 @@ void BaseCombatAIComponent::Update(const float deltaTime) {
void BaseCombatAIComponent::CalculateCombat(const float deltaTime) {
bool hasSkillToCast = false;
int32_t maxSkillWeights = 0;
for (auto& entry : m_SkillEntries) {
if (entry.cooldown > 0.0f) {
entry.cooldown -= deltaTime;
} else {
maxSkillWeights += entry.combatWeight;
hasSkillToCast = true;
}
}
@@ -317,12 +316,14 @@ void BaseCombatAIComponent::CalculateCombat(const float deltaTime) {
SetAiState(AiState::aggro);
} else {
SetAiState(AiState::idle);
if (m_MovementAI) m_MovementAI->SetMaxSpeed(1.0f);
}
if (!hasSkillToCast) return;
if (m_Target == LWOOBJID_EMPTY) {
SetAiState(AiState::idle);
if (m_MovementAI) m_MovementAI->SetMaxSpeed(1.0f);
return;
}
@@ -333,14 +334,23 @@ void BaseCombatAIComponent::CalculateCombat(const float deltaTime) {
LookAt(target->GetPosition());
}
for (auto i = 0; i < m_SkillEntries.size(); ++i) {
auto entry = m_SkillEntries.at(i);
// Roll to find which skill we'll try to cast
auto randomizedWeight = GeneralUtils::GenerateRandomNumber<int32_t>(0, maxSkillWeights);
if (entry.cooldown > 0) {
for (auto& entry : m_SkillEntries) {
// Skill isn't cooled off yet
if (entry.cooldown > 0.0f) {
continue;
}
const auto result = skillComponent->CalculateBehavior(entry.skillId, entry.behavior->m_behaviorId, LWOOBJID_EMPTY);
randomizedWeight -= entry.combatWeight;
// if the weight is still greater than 0 continue to the next rolled skill
if (randomizedWeight > 0) {
continue;
}
const auto result = skillComponent->CalculateBehavior(entry.skillId, entry.behavior->m_behaviorId, GetTarget());
if (result.success) {
if (m_MovementAI != nullptr) {
@@ -355,8 +365,6 @@ void BaseCombatAIComponent::CalculateCombat(const float deltaTime) {
entry.cooldown = entry.abilityCooldown + m_SkillTime;
m_SkillEntries[i] = entry;
break;
}
}
@@ -618,6 +626,11 @@ void BaseCombatAIComponent::Wander() {
return;
}
// If we have a path to follow we should almost certainly do that instead of wandering.
if (m_MovementAI->HasPath()) {
return;
}
m_MovementAI->SetHaltDistance(0);
const auto& info = m_MovementAI->GetInfo();
@@ -746,8 +759,8 @@ void BaseCombatAIComponent::SetTetherSpeed(float value) {
m_TetherSpeed = value;
}
void BaseCombatAIComponent::Stun(const float time) {
if (m_StunImmune || m_StunTime > time) {
void BaseCombatAIComponent::Stun(const float time, const bool force) {
if (!force && (m_StunImmune || m_StunTime > time)) {
return;
}
@@ -862,12 +875,12 @@ bool BaseCombatAIComponent::MsgGetObjectReportInfo(GameMessages::GetObjectReport
// roundInfo.PushDebug<AMFDoubleValue>("Combat Start Delay") = m_CombatStartDelay;
std::string curState;
switch (m_State) {
case idle: curState = "Idling"; break;
case aggro: curState = "Aggroed"; break;
case tether: curState = "Returning to Tether"; break;
case spawn: curState = "Spawn"; break;
case dead: curState = "Dead"; break;
default: curState = "Unknown or Undefined"; break;
case idle: curState = "Idling"; break;
case aggro: curState = "Aggroed"; break;
case tether: curState = "Returning to Tether"; break;
case spawn: curState = "Spawn"; break;
case dead: curState = "Dead"; break;
default: curState = "Unknown or Undefined"; break;
}
cmptType.PushDebug<AMFStringValue>("Current Combat State") = curState;
@@ -905,8 +918,16 @@ bool BaseCombatAIComponent::MsgGetObjectReportInfo(GameMessages::GetObjectReport
}
auto& ignoredThreats = cmptType.PushDebug("Temp Ignored Threats");
for (const auto& [id, threat] : m_ThreatEntries) {
for (const auto& [id, threat] : m_RemovedThreatList) {
ignoredThreats.PushDebug<AMFDoubleValue>(std::to_string(id) + " - Time") = threat;
}
auto& skillInfo = cmptType.PushDebug("Skill Info");
for (const auto& skill : m_SkillEntries) {
auto& skillDebug = skillInfo.PushDebug("Skill ID " + std::to_string(skill.skillId));
skillDebug.PushDebug<AMFDoubleValue>("Cooldown") = skill.cooldown;
skillDebug.PushDebug<AMFDoubleValue>("Ability Cooldown") = skill.abilityCooldown;
skillDebug.PushDebug<AMFIntValue>("AI Combat Weight") = skill.combatWeight;
}
return true;
}

View File

@@ -33,13 +33,15 @@ enum class AiState : uint32_t {
*/
struct AiSkillEntry
{
uint32_t skillId;
uint32_t skillId{};
float cooldown;
float cooldown{};
float abilityCooldown;
float abilityCooldown{};
Behavior* behavior;
Behavior* behavior{};
int32_t combatWeight{};
};
/**
@@ -181,8 +183,9 @@ public:
/**
* Stuns the entity for a certain amount of time, will not work if the entity is stun immune
* @param time the time to stun the entity, if stunnable
* @param force whether or not to force the stun and ignore checks
*/
void Stun(float time);
void Stun(float time, const bool force = false);
/**
* Gets the radius that will cause this entity to get aggro'd, causing a target chase
@@ -236,6 +239,8 @@ public:
bool MsgGetObjectReportInfo(GameMessages::GetObjectReportInfo& reportInfo);
void SetStartingPosition(const NiPoint3& pos) { m_StartPosition = pos; }
private:
/**
* Returns the current target or the target that currently is the largest threat to this entity
@@ -394,9 +399,17 @@ private:
*/
bool m_DirtyStateOrTarget = false;
// Min amount of time to remain as in combat after casting a skill
float m_MinRoundLength = 0.0f;
// max amount of time to remain as in combat after casting a skill
float m_MaxRoundLength = 0.0f;
// The amount of time the entity will be forced to tether for
float m_ForcedTetherTime = 0.0f;
float m_CombatRoundLength = 0.0f;
// The amount of time a removed threat will be ignored for.
std::map<LWOOBJID, float> m_RemovedThreatList;

View File

@@ -19,6 +19,12 @@
#include "Amf3.h"
#include "dNavMesh.h"
#include "eWaypointCommandType.h"
#include "StringifiedEnum.h"
#include "SkillComponent.h"
#include "GeneralUtils.h"
#include "RenderComponent.h"
#include "InventoryComponent.h"
namespace {
/**
@@ -31,8 +37,6 @@ MovementAIComponent::MovementAIComponent(Entity* parent, const int32_t component
m_Info = info;
m_AtFinalWaypoint = true;
m_BaseCombatAI = nullptr;
m_BaseCombatAI = m_Parent->GetComponent<BaseCombatAIComponent>();
//Try and fix the insane values:
@@ -60,7 +64,7 @@ MovementAIComponent::MovementAIComponent(Entity* parent, const int32_t component
RegisterMsg(&MovementAIComponent::OnGetObjectReportInfo);
if (!m_Parent->GetComponent<BaseCombatAIComponent>()) SetPath(m_Parent->GetVarAsString(u"attached_path"));
SetPath(m_Parent->GetVarAsString(u"attached_path"));
}
void MovementAIComponent::SetPath(const std::string pathName) {
@@ -125,7 +129,11 @@ void MovementAIComponent::Update(const float deltaTime) {
m_TimeTravelled += deltaTime;
SetPosition(ApproximateLocation());
const auto approxPos = ApproximateLocation();
SetPosition(approxPos);
// Set the AIs new home based on where our current waypoint is IF we're idle, that way we can return to this
// when resuming the pathing after losing aggro while moving the aggro hitbox with us
if (m_BaseCombatAI && m_BaseCombatAI->GetState() == AiState::idle) m_BaseCombatAI->SetStartingPosition(approxPos);
if (m_TimeTravelled < m_TimeToTravel) return;
m_TimeTravelled = 0.0f;
@@ -160,32 +168,34 @@ void MovementAIComponent::Update(const float deltaTime) {
SetRotation(QuatUtils::LookAt(source, m_NextWaypoint));
}
} else {
// Check if there are more waypoints in the queue, if so set our next destination to the next waypoint
const auto waypointNum = m_IsBounced ? m_CurrentPath.size() : m_CurrentPathWaypointCount - m_CurrentPath.size() - 1;
if (m_CurrentPath.empty()) {
if (m_Path) {
if (m_Path->pathBehavior == PathBehavior::Loop) {
SetPath(m_Path->pathWaypoints);
} else if (m_Path->pathBehavior == PathBehavior::Bounce) {
m_IsBounced = !m_IsBounced;
std::vector<PathWaypoint> waypoints = m_Path->pathWaypoints;
if (m_IsBounced) std::ranges::reverse(waypoints);
SetPath(waypoints);
} else if (m_Path->pathBehavior == PathBehavior::Once) {
m_Parent->GetScript()->OnWaypointReached(m_Parent, waypointNum);
// Only try to renew or continue the path if we're in the idle or spawn state and we actually have a combatAI component
if (!m_BaseCombatAI || (m_BaseCombatAI && m_BaseCombatAI->GetState() == AiState::idle)) {
// Check if there are more waypoints in the queue, if so set our next destination to the next waypoint
const auto waypointNum = m_IsBounced ? m_CurrentPath.size() : m_CurrentPathWaypointCount - m_CurrentPath.size() - 1;
RunWaypointCommands(waypointNum);
if (m_CurrentPath.empty()) {
if (m_Path) {
if (m_Path->pathBehavior == PathBehavior::Loop) {
SetPath(m_Path->pathWaypoints);
} else if (m_Path->pathBehavior == PathBehavior::Bounce) {
m_IsBounced = !m_IsBounced;
std::vector<PathWaypoint> waypoints = m_Path->pathWaypoints;
if (m_IsBounced) std::ranges::reverse(waypoints);
SetPath(waypoints);
} else if (m_Path->pathBehavior == PathBehavior::Once) {
// In this case we intended to follow a path and once we've followed it we camp there, otherwise we'd just wander home again.
Stop();
return;
}
} else {
Stop();
return;
}
} else {
m_Parent->GetScript()->OnWaypointReached(m_Parent, waypointNum);
Stop();
return;
}
} else {
m_Parent->GetScript()->OnWaypointReached(m_Parent, waypointNum);
SetDestination(m_CurrentPath.top().position);
SetDestination(m_CurrentPath.top().position);
m_CurrentPath.pop();
m_CurrentPath.pop();
}
}
}
@@ -212,8 +222,7 @@ NiPoint3 MovementAIComponent::GetCurrentWaypoint() const {
NiPoint3 MovementAIComponent::ApproximateLocation() const {
auto source = m_SourcePosition;
if (AtFinalWaypoint()) return source;
if (AtFinalWaypoint()) return m_Parent->GetPosition();
auto destination = m_NextWaypoint;
@@ -423,7 +432,69 @@ NiPoint3 MovementAIComponent::GetDestination() const {
void MovementAIComponent::SetMaxSpeed(const float value) {
if (value == m_MaxSpeed) return;
m_MaxSpeed = value;
m_Acceleration = value / 5;
m_Acceleration = value / 5.0f;
}
void MovementAIComponent::RunWaypointCommands(uint32_t waypointNum) {
m_Parent->GetScript()->OnWaypointReached(m_Parent, waypointNum);
if (!m_Path || waypointNum >= m_Path->pathWaypoints.size()) return;
const auto& commands = m_Path->pathWaypoints[waypointNum].commands;
for (const auto& [command, data] : commands) {
LOG_DEBUG("%s %s %s", StringifiedEnum::ToString(command).data(), m_Path->pathName.c_str(), data.c_str());
const auto dataSplit = GeneralUtils::SplitString(data, ',');
switch (command) {
case eWaypointCommandType::INVALID: break;
case eWaypointCommandType::BOUNCE: break;
case eWaypointCommandType::STOP: Pause(); break;
case eWaypointCommandType::GROUP_EMOTE: break;
case eWaypointCommandType::SET_VARIABLE: break; // Empty in the client
case eWaypointCommandType::CAST_SKILL: {
const auto skill = GeneralUtils::TryParse<uint32_t>(data);
if (skill) {
auto* const skillComponent = m_Parent->GetComponent<SkillComponent>();
if (skillComponent) skillComponent->CastSkill(skill.value());
}
break;
}
case eWaypointCommandType::EQUIP_INVENTORY: {
auto* const inventoryComponent = m_Parent->GetComponent<InventoryComponent>();
if (inventoryComponent) {
// items should always exist
auto* const item = inventoryComponent->GetInventory(eInventoryType::ITEMS)->FindItemBySlot(0);
if (item) inventoryComponent->EquipItem(item);
}
break;
}
case eWaypointCommandType::UNEQUIP_INVENTORY: {
auto* const inventoryComponent = m_Parent->GetComponent<InventoryComponent>();
if (inventoryComponent) {
// items should always exist
auto* const item = inventoryComponent->GetInventory(eInventoryType::ITEMS)->FindItemBySlot(0);
if (item) inventoryComponent->UnEquipItem(item);
}
break;
}
case eWaypointCommandType::DELAY: {
// Pause(GeneralUtils::TryParse<float>(data).value_or(0.0f));
break;
}
case eWaypointCommandType::EMOTE: {
// m_Delay = RenderComponent::GetAnimationTime(m_Parent, data);
// const auto emoteID = GeneralUtils::TryParse<uint32_t>(data);
// if (emoteID) GameMessages::SendPlayEmote(m_Parent->GetObjectID(), emoteID.value(), LWOOBJID_EMPTY, UNASSIGNED_SYSTEM_ADDRESS);
break;
}
case eWaypointCommandType::TELEPORT: break;
case eWaypointCommandType::PATH_SPEED: m_BaseSpeed = GetBaseSpeed(m_Parent->GetLOT()) * GeneralUtils::TryParse<float>(data).value_or(1.0f); break;
case eWaypointCommandType::REMOVE_NPC: break;
case eWaypointCommandType::CHANGE_WAYPOINT: SetPath(dataSplit[0]); break;
case eWaypointCommandType::DELETE_SELF: break;
case eWaypointCommandType::KILL_SELF: m_Parent->Smash(); break;
case eWaypointCommandType::SPAWN_OBJECT: break;
case eWaypointCommandType::PLAY_SOUND: break;
}
}
}
bool MovementAIComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInfo& reportInfo) {
@@ -453,6 +524,7 @@ bool MovementAIComponent::OnGetObjectReportInfo(GameMessages::GetObjectReportInf
movementInfo.PushDebug<AMFBoolValue>("Lock Rotation") = m_LockRotation;
movementInfo.PushDebug<AMFBoolValue>("Paused") = m_Paused;
movementInfo.PushDebug<AMFDoubleValue>("Pulling To Point") = m_PullingToPoint;
movementInfo.PushDebug<AMFBoolValue>("At Final Waypoint") = m_AtFinalWaypoint;
auto& pullPointInfo = movementInfo.PushDebug("Pull Point");
pullPointInfo.PushDebug<AMFDoubleValue>("X") = m_PullPoint.x;

View File

@@ -212,8 +212,16 @@ public:
bool IsPaused() const { return m_Paused; }
bool OnGetObjectReportInfo(GameMessages::GetObjectReportInfo& reportInfo);
bool HasPath() const { return m_Path != nullptr; }
private:
/**
* @brief
* Runs the commands on a waypoint if a path exists
*/
void RunWaypointCommands(uint32_t waypointNum);
/**
* Sets the current position of the entity
* @param value the position to set

View File

@@ -16,6 +16,7 @@
#include "eReplicaComponentType.h"
#include "RenderComponent.h"
#include "PlayerManager.h"
#include "eStateChangeType.h"
#include <vector>
@@ -48,10 +49,30 @@ void BossSpiderQueenEnemyServer::OnStartup(Entity* self) {
combat->SetStunImmune(true);
m_CurrentBossStage = 1;
ToggleAttacking(*self, false);
self->SetProximityRadius(65.0f, "AggroRadius");
// Obtain faction and collision group to save for subsequent resets
}
void BossSpiderQueenEnemyServer::OnProximityUpdate(Entity* self, Entity* entering, std::string name, std::string status) {
if (name != "AggroRadius" || !entering || !entering->IsPlayer()) return;
auto playerCount = self->GetVar<int32_t>(u"player_count");
if (status == "ENTER") {
if (playerCount == 0) {
ToggleAttacking(*self, true);
}
playerCount++;
} else if (status == "LEAVE") {
playerCount--;
if (playerCount == 0) {
ToggleAttacking(*self, false);
}
}
self->SetVar<int32_t>(u"player_count", playerCount);
}
void BossSpiderQueenEnemyServer::OnDie(Entity* self, Entity* killer) {
if (Game::zoneManager->GetZoneID().GetMapID() == instanceZoneID && killer) {
for (const auto& player : PlayerManager::GetAllPlayers()) {
@@ -71,6 +92,7 @@ void BossSpiderQueenEnemyServer::OnDie(Entity* self, Entity* killer) {
self->SetPosition({ 10000, 0, 10000 });
Game::entityManager->SerializeEntity(self);
ToggleAttacking(*self, false);
controller->OnFireEventServerSide(self, "ClearProperty");
}
@@ -634,3 +656,19 @@ float BossSpiderQueenEnemyServer::PlayAnimAndReturnTime(Entity* self, const std:
return animTimer;
}
void BossSpiderQueenEnemyServer::ToggleAttacking(Entity& self, bool on) {
const auto stoppedFlag = self.GetVarAs<bool>(u"stoppedFlag");
if (!on) {
if (stoppedFlag) return;
self.SetVar(u"stoppedFlag", true);
combat->Stun(100000.0f, true); // forcibly stun so we stop attacking people trying to put on armor
} else {
if (!stoppedFlag) return;
self.SetVar(u"stoppedFlag", false);
combat->Stun(0.0f, true); // forcibly turn off the stun we put on above
}
}

View File

@@ -46,7 +46,10 @@ public:
void OnTimerDone(Entity* self, std::string timerName) override;
void OnProximityUpdate(Entity* self, Entity* entering, std::string name, std::string status);
private:
void ToggleAttacking(Entity& self, bool on);
//Regular variables:
DestroyableComponent* destroyable = nullptr;
ControllablePhysicsComponent* controllable = nullptr;

View File

@@ -14,7 +14,7 @@ void VisToggleNotifierServer::OnMissionDialogueOK(Entity* self, Entity* target,
auto spawners = Game::zoneManager->GetSpawnersByName(itr->second);
if (spawners.empty()) return;
for (const auto spawner : spawners) {
auto spawnedObjIds = spawner->GetSpawnedObjectIDs();
const auto& spawnedObjIds = spawner->GetSpawnedObjectIDs();
for (const auto& objId : spawnedObjIds) {
GameMessages::SendNotifyClientObject(objId, u"SetVisibility", visible);
}

View File

@@ -1019,6 +1019,7 @@ void HandlePacket(Packet* packet) {
if (user) {
Character* c = user->GetLastUsedChar();
if (c != nullptr) {
if (Game::entityManager->GetEntity(c->GetObjectID())) return;
std::u16string username = GeneralUtils::ASCIIToUTF16(c->GetName());
Game::server->GetReplicaManager()->AddParticipant(packet->systemAddress);

View File

@@ -6,8 +6,10 @@
#include <functional>
#include "GeneralUtils.h"
#include "dZoneManager.h"
#include <algorithm>
#include <ranges>
Spawner::Spawner(const SpawnerInfo info) {
Spawner::Spawner(const SpawnerInfo& info) {
m_Info = info;
m_Active = m_Info.activeOnLoad && info.spawnActivator;
m_EntityInfo = EntityInfo();
@@ -62,10 +64,6 @@ Spawner::Spawner(const SpawnerInfo info) {
}
}
Spawner::~Spawner() {
}
Entity* Spawner::Spawn() {
std::vector<SpawnerNode*> freeNodes;
for (SpawnerNode* node : m_Info.nodes) {
@@ -77,9 +75,25 @@ Entity* Spawner::Spawn() {
return Spawn(freeNodes);
}
Entity* Spawner::Spawn(std::vector<SpawnerNode*> freeNodes, const bool force) {
if (force || ((m_Entities.size() < m_Info.amountMaintained) && (freeNodes.size() > 0) && (m_AmountSpawned < m_Info.maxToSpawn || m_Info.maxToSpawn == -1))) {
SpawnerNode* spawnNode = freeNodes[GeneralUtils::GenerateRandomNumber<int>(0, freeNodes.size() - 1)];
Entity* Spawner::Spawn(const std::vector<SpawnerNode*>& freeNodes, const bool force) {
Entity* spawnedEntity = nullptr;
if (force || ((m_Entities.size() < m_Info.amountMaintained) && !freeNodes.empty() && (m_AmountSpawned < m_Info.maxToSpawn || m_Info.maxToSpawn == -1))) {
// first sum the weights we were provided
int32_t spawnWeight = 0;
for (const auto* const node : freeNodes) spawnWeight += node->weight;
auto chosenWeight = GeneralUtils::GenerateRandomNumber<int32_t>(1, spawnWeight);
// Default to 0 incase something goes wrong in this calc
// Roll the spawner nodes based on their weights, higher weights = more likely to spawn
SpawnerNode* spawnNode = freeNodes[0];
for (auto* const node : freeNodes) {
chosenWeight -= node->weight;
if (chosenWeight <= 0) {
spawnNode = node;
break; // we rolled a spawner
}
}
++m_AmountSpawned;
m_EntityInfo.pos = spawnNode->position;
m_EntityInfo.rot = spawnNode->rotation;
@@ -93,26 +107,24 @@ Entity* Spawner::Spawn(std::vector<SpawnerNode*> freeNodes, const bool force) {
m_EntityInfo.spawnerID = m_Info.spawnerID;
}
Entity* rezdE = Game::entityManager->CreateEntity(m_EntityInfo, nullptr);
spawnedEntity = Game::entityManager->CreateEntity(m_EntityInfo, nullptr);
rezdE->GetGroups() = m_Info.groups;
spawnedEntity->GetGroups() = m_Info.groups;
Game::entityManager->ConstructEntity(rezdE);
Game::entityManager->ConstructEntity(spawnedEntity);
m_Entities.insert({ rezdE->GetObjectID(), spawnNode });
spawnNode->entities.push_back(rezdE->GetObjectID());
m_Entities[spawnedEntity->GetObjectID()] = spawnNode;
spawnNode->entities.push_back(spawnedEntity->GetObjectID());
if (m_Entities.size() == m_Info.amountMaintained) {
m_NeedsUpdate = false;
}
for (const auto& cb : m_EntitySpawnedCallbacks) {
cb(rezdE);
cb(spawnedEntity);
}
return rezdE;
}
return nullptr;
return spawnedEntity;
}
void Spawner::AddSpawnedEntityDieCallback(std::function<void()> callback) {
@@ -148,18 +160,18 @@ void Spawner::SoftReset() {
m_NeedsUpdate = true;
}
void Spawner::SetRespawnTime(float time) {
void Spawner::SetRespawnTime(const float time) {
m_Info.respawnTime = time;
for (size_t i = 0; i < m_WaitTimes.size(); ++i) {
m_WaitTimes[i] = 0;
};
}
m_Start = true;
m_NeedsUpdate = true;
}
void Spawner::SetNumToMaintain(int32_t value) {
void Spawner::SetNumToMaintain(const int32_t value) {
m_Info.amountMaintained = value;
}
@@ -177,15 +189,8 @@ void Spawner::Update(const float deltaTime) {
return;
}
if (!m_NeedsUpdate) return;
if (!m_Active) return;
//if (m_Info.noTimedSpawn) return;
if (m_Info.spawnsOnSmash) {
if (!m_SpawnSmashFoundGroup) {
if (!m_NeedsUpdate || !m_Active || m_Info.spawnsOnSmash) return;
}
return;
}
for (size_t i = 0; i < m_WaitTimes.size(); ) {
m_WaitTimes[i] += deltaTime;
if (m_WaitTimes[i] >= m_Info.respawnTime) {
@@ -198,22 +203,21 @@ void Spawner::Update(const float deltaTime) {
}
}
std::vector<LWOOBJID> Spawner::GetSpawnedObjectIDs() const {
const std::vector<LWOOBJID> Spawner::GetSpawnedObjectIDs() const {
std::vector<LWOOBJID> ids;
ids.reserve(m_Entities.size());
for (const auto& [objId, spawnerNode] : m_Entities) {
for (const auto objId : m_Entities | std::views::keys) {
ids.push_back(objId);
}
return ids;
}
void Spawner::NotifyOfEntityDeath(const LWOOBJID& objectID) {
for (std::function<void()> cb : m_SpawnedEntityDieCallbacks) {
for (const auto& cb : m_SpawnedEntityDieCallbacks) {
cb();
}
m_NeedsUpdate = true;
//m_RespawnTime = 10.0f;
m_WaitTimes.push_back(0.0f);
SpawnerNode* node;
@@ -221,9 +225,7 @@ void Spawner::NotifyOfEntityDeath(const LWOOBJID& objectID) {
if (it != m_Entities.end()) node = it->second;
else return;
if (!node) {
return;
}
if (!node) return;
for (size_t i = 0; i < node->entities.size();) {
if (node->entities[i] && node->entities[i] == objectID)
@@ -249,6 +251,6 @@ void Spawner::Activate() {
}
}
void Spawner::SetSpawnLot(LOT lot) {
void Spawner::SetSpawnLot(const LOT lot) {
m_EntityInfo.lot = lot;
}

View File

@@ -11,12 +11,41 @@
#include "LDFFormat.h"
#include "EntityInfo.h"
/**
* Any given spawner owns a certain number of spawner nodes
* these nodes are where entities are actually spawned
* The first spawner nodes waypoint in any given network contains the base config for all the spawner nodes
* Then each spawner node after the first may contain duplicate settings which override the base ones
* If spawner node 1 has an attached_path of "1", then all spawner nodes in this spawner network will have
* an attached_path of "1".
* Each spawner node can also specify attached_path of any other value and it will override the one provided by node 1.
* If a spawner node does NOT provide an override, the first one will be used
* I have no clue why the nodes are pointers, beats me
* sn = SpawnerNode
* Spawner
* ----------------
* | sn |
* | sn |
* | sn |
* | |
* | sn |
* | sn |
* -----------------
*/
struct SpawnerNode {
// This spawner nodes position in the world
NiPoint3 position = NiPoint3Constant::ZERO;
// The rotation of this spawner in the world
NiQuaternion rotation = QuatUtils::IDENTITY;
// This spawners nodes ID in this spawner network
uint32_t nodeID = 0;
// The max number of entities that can be spawned by this node
uint32_t nodeMax = 1;
// The weight (chance) this spawner node has. Higher is more common
int32_t weight = 1;
// The IDs of entities spawned by this spawner node
std::vector<LWOOBJID> entities;
// The config of all entities spawned by this node
LwoNameValue config;
};
@@ -45,11 +74,10 @@ struct SpawnerInfo {
class Spawner {
public:
Spawner(SpawnerInfo info);
~Spawner();
Spawner(const SpawnerInfo& info);
Entity* Spawn();
Entity* Spawn(std::vector<SpawnerNode*> freeNodes, bool force = false);
Entity* Spawn(const std::vector<SpawnerNode*>& freeNodes, bool force = false);
void Update(float deltaTime);
void NotifyOfEntityDeath(const LWOOBJID& objectID);
void Activate();
@@ -57,16 +85,16 @@ public:
int32_t GetAmountSpawned() { return m_AmountSpawned; };
std::string GetName() { return m_Info.name; };
std::vector<std::string> GetGroups() { return m_Info.groups; };
void AddSpawnedEntityDieCallback(std::function<void()> callback);
void AddEntitySpawnedCallback(std::function<void(Entity*)> callback);
void SetSpawnLot(LOT lot);
void AddSpawnedEntityDieCallback(const std::function<void()> callback);
void AddEntitySpawnedCallback(const std::function<void(Entity*)> callback);
void SetSpawnLot(const LOT lot);
void Reset();
void DestroyAllEntities();
void SoftReset();
void SetRespawnTime(float time);
void SetNumToMaintain(int32_t value);
void SetRespawnTime(const float time);
void SetNumToMaintain(const int32_t value);
bool GetIsSpawnSmashGroup() const { return m_SpawnSmashFoundGroup; };
std::vector<LWOOBJID> GetSpawnedObjectIDs() const;
const std::vector<LWOOBJID> GetSpawnedObjectIDs() const;
SpawnerInfo m_Info;
bool m_Active = true;

View File

@@ -101,36 +101,47 @@ void Zone::LoadZoneIntoMemory() {
m_Paths.reserve(pathCount);
for (uint32_t i = 0; i < pathCount; ++i) LoadPath(file);
for (Path path : m_Paths) {
for (const Path& path : m_Paths) {
if (path.pathType != PathType::Spawner) continue;
SpawnerInfo info = SpawnerInfo();
for (PathWaypoint waypoint : path.pathWaypoints) {
SpawnerInfo info{};
for (size_t i = 0; i < path.pathWaypoints.size(); i++) {
const auto& waypoint = path.pathWaypoints[i];
SpawnerNode* node = new SpawnerNode();
node->position = waypoint.position;
node->rotation = waypoint.rotation;
node->nodeID = 0;
node->config = waypoint.config;
node->config = path.pathWaypoints[0].config;
// All spawner waypoints get the config data of the first waypoint, but then we
// overwrite settings on this waypoint if we have another one defined of the same name
if (i != 0) {
for (const auto& [key, value] : waypoint.config) {
node->config.ParseInsert(value->GetString());
}
}
for (const auto& data : waypoint.config | std::views::values) {
if (!data) continue;
if (data->GetKey() == u"spawner_node_id") {
node->nodeID = std::stoi(data->GetValueAsString());
node->nodeID = GeneralUtils::TryParse(data->GetValueAsString(), 0);
} else if (data->GetKey() == u"spawner_max_per_node") {
node->nodeMax = std::stoi(data->GetValueAsString());
node->nodeMax = GeneralUtils::TryParse(data->GetValueAsString(), 0);
} else if (data->GetKey() == u"groupID") { // Load object group
std::string groupStr = data->GetValueAsString();
info.groups = GeneralUtils::SplitString(groupStr, ';');
info.groups = GeneralUtils::SplitString(data->GetValueAsString(), ';');
if (info.groups.back().empty()) info.groups.erase(info.groups.end() - 1);
} else if (data->GetKey() == u"grpNameQBShowBricks") {
if (data->GetValueAsString().empty()) continue;
/*std::string groupStr = data->GetValueAsString();
info.groups.push_back(groupStr);*/
info.grpNameQBShowBricks = data->GetValueAsString();
} else if (data->GetKey() == u"spawner_name") {
info.name = data->GetValueAsString();
} else if (data->GetKey() == u"weight") {
node->weight = GeneralUtils::TryParse(data->GetValueAsString(), 1);
if (node->weight <= 0) {
LOG("Found a spawner with a weight of <= 0, is this intentional? %s:%i", info.name.c_str(), node->nodeID);
node->weight = 1;
}
}
}
info.nodes.push_back(node);
}
info.templateID = path.spawner.spawnedLOT;