diff --git a/code/client/src/core/modules/human.cpp b/code/client/src/core/modules/human.cpp index aa0e714..1be90b5 100644 --- a/code/client/src/core/modules/human.cpp +++ b/code/client/src/core/modules/human.cpp @@ -12,6 +12,7 @@ #include "sdk/reflection/ue4_reflection.h" #include "shared/modules/mount_records.hpp" +#include "shared/modules/spell_records.hpp" #include "shared/rpc/set_appearance.h" #include @@ -27,6 +28,7 @@ #include #include #include +#include namespace { using namespace HogwartsMP::Core::UE4; @@ -320,6 +322,206 @@ namespace { CallUFunction(wandActor, "K2_AttachToComponent", &att); return wandActor; } + + // The local player's combat-AnimBP (BipedCharacter_Retargeted_AnimBP) instance — found off the pawn's + // first AnimBP-driven SkeletalMesh whose instance exposes FullBodyState (the robe/hair ABPs don't), + // cached per pawn. Local-only. Null if not found. + UObjectBase *LocalBodyAnimInstance(void *pawn) { + static void *cachedPawn = nullptr; + static UObjectBase *bodyMesh = nullptr; + // Rescan while the result is still null (the body AnimBP may not exist on the first lookup) — only + // a positive find is cached. Otherwise a too-early lookup would latch null for the pawn's lifetime. + if (pawn != cachedPawn || !bodyMesh) { + cachedPawn = pawn; + bodyMesh = nullptr; + if (auto *arr = HogwartsMP::Core::gGlobals.objectArray) { + const int total = arr->GetObjectArrayNum(); + for (int i = 0; i < total; ++i) { + auto *item = arr->IndexToObject(i); + if (!item || !item->Object) { + continue; + } + auto *obj = item->Object; + const auto cls = narrow(obj->GetClass()->GetFName()); + if (cls != "SkeletalMeshComponent" && cls != "SkeletalMeshComponentBudgeted") { + continue; + } + auto *o1 = obj->GetOuter(); + if (!o1 || (o1 != pawn && o1->GetOuter() != pawn)) { + continue; + } + if (ReadByteProperty(obj, "AnimationMode") != 0 /*AnimBlueprint*/) { + continue; + } + auto *inst = ReadObjectProperty(obj, "AnimScriptInstance"); + if (inst && FindPropertyInChain(inst->GetClass(), "FullBodyState")) { + bodyMesh = obj; + break; + } + } + } + } + return bodyMesh ? ReadObjectProperty(bodyMesh, "AnimScriptInstance") : nullptr; + } + + // Combat-AnimBP FullBodyState values (mapped by watching the local body anim instance): 7 = spell + // cast (full-body, rooted). Neither is a montage on the source player, so the AnimBP state is the handle. + constexpr int kCastFullBodyState = 7; + bool DetectCast(void *pawn) { + auto *inst = LocalBodyAnimInstance(pawn); + return inst && ReadByteProperty(inst, "FullBodyState") == kCastFullBodyState; + } + + // The local player's WandTool (the GetActiveSpellTool holder), cached per pawn: prefer one whose outer + // chain reaches the pawn, else the first non-CDO holder. Shared by the spell-record + Lumos reads. + UObjectBase *WandToolFor(void *pawn) { + static void *cachedPawn = nullptr; + static UObjectBase *wandTool = nullptr; + // Rescan while still null (the tool may not exist on the first lookup); only a hit is cached. + if (pawn != cachedPawn || !wandTool) { + cachedPawn = pawn; + wandTool = nullptr; + if (auto *arr = HogwartsMP::Core::gGlobals.objectArray) { + const int total = arr->GetObjectArrayNum(); + UObjectBase *anyHolder = nullptr; + for (int i = 0; i < total; ++i) { + auto *item = arr->IndexToObject(i); + if (!item || !item->Object) { + continue; + } + auto *obj = item->Object; + const auto cn = narrow(obj->GetClass()->GetFName()); + if (cn == "Function" || cn == "Class") { + continue; + } + if (narrow(obj->GetFName()).rfind("Default__", 0) == 0) { + continue; + } + if (!FindFunctionInChain(obj, "GetActiveSpellTool")) { + continue; + } + if (!anyHolder) { + anyHolder = obj; + } + bool reaches = false; + for (auto *p = obj->GetOuter(); p; p = p->GetOuter()) { + if (p == pawn) { + reaches = true; + break; + } + } + if (reaches) { + wandTool = obj; + break; + } + } + if (!wandTool) { + wandTool = anyHolder; + } + } + } + return wandTool; + } + + // The local player's active spell tool (WandTool.GetActiveSpellTool()), or null. + UObjectBase *ActiveSpellTool(void *pawn) { + auto *wand = WandToolFor(pawn); + if (!wand) { + return nullptr; + } + struct { + UObjectBase *ReturnValue; + } tool {nullptr}; + CallUFunction(wand, "GetActiveSpellTool", &tool); + return tool.ReturnValue; + } + + // The active spell's SpellToolRecord asset path for the local player (empty if unavailable) — published + // (mapped to an allowlist id) so the proxy knows which spell a combat cast is. + std::string ActiveSpellRecordPath(void *pawn) { + auto *tool = ActiveSpellTool(pawn); + if (!tool) { + return {}; + } + struct { + UObjectBase *ReturnValue; + } rec {nullptr}; + CallUFunction(tool, "GetSpellToolRecord", &rec); + return rec.ReturnValue ? AssetPath(rec.ReturnValue) : std::string {}; + } + + // The local player's aim pitch (deg, clamped to a signed byte) from the controller's look rotation — + // the body never pitches on foot, so a cast aimed up/down would replay flat without its own wire field. + int8_t LocalAimPitch(void *playerController) { + Rot3f rot {}; + if (!CallUFunction(playerController, "GetControlRotation", &rot)) { + return 0; + } + const float pitch = std::clamp(NormalizeAxisDeg(rot.Pitch), -90.f, 90.f); + return static_cast(std::lround(pitch)); + } + + // Fire a real spell from the proxy via SpellHelper::CastSpell (the proven replay path). FX on, cast anim + // OFF (the proxy plays our own montage), bOnlyHitTarget on + null target to keep it cosmetic. Param + // block written by offset into a zeroed buffer (robust for the ~19-param fn). + void CastSpellOnProxy(void *instigator, UObjectBase *record, const float src[3], const float tgt[3]) { + if (!instigator || !record) { + return; + } + static UObjectBase *helper = nullptr; + if (!helper) { + if (auto *arr = HogwartsMP::Core::gGlobals.objectArray) { + const int total = arr->GetObjectArrayNum(); + for (int i = 0; i < total; ++i) { + auto *item = arr->IndexToObject(i); + if (item && item->Object && narrow(item->Object->GetClass()->GetFName()) == "SpellHelper") { + helper = item->Object; + break; + } + } + } + } + if (!helper) { + return; + } + auto *fn = reinterpret_cast(FindFunctionInChain(helper, "CastSpell")); + if (!fn) { + return; + } + uint8_t buf[512] = {0}; + for (FField *f = reinterpret_cast(fn)->ChildProperties; f; f = f->Next) { + auto *p = static_cast(f); + const auto name = narrow(p->GetFName()); + const int off = p->GetOffset_ForInternal(); + if (name == "InInstigator") { + *reinterpret_cast(buf + off) = reinterpret_cast(instigator); + } + else if (name == "SpellToolRecord") { + *reinterpret_cast(buf + off) = record; + } + else if (name == "SourceLocation") { + auto *fl = reinterpret_cast(buf + off); + fl[0] = src[0]; + fl[1] = src[1]; + fl[2] = src[2]; + } + else if (name == "TargetLocation") { + auto *fl = reinterpret_cast(buf + off); + fl[0] = tgt[0]; + fl[1] = tgt[1]; + fl[2] = tgt[2]; + } + else if (name == "SpellLevel") { + *reinterpret_cast(buf + off) = 1; + } + else if (narrow(p->GetClass()->GetFName()) == "BoolProperty" && + (name == "bPlayMuzzleFX" || name == "bPlayImpactFX" || name == "bOnlyHitTarget")) { + auto *bp = static_cast(f); + buf[off + bp->ByteOffset] |= bp->ByteMask; + } + } + CallUFunction(helper, "CastSpell", buf); + } } // namespace namespace HogwartsMP::Core::Modules { @@ -402,6 +604,13 @@ namespace HogwartsMP::Core::Modules { data.mountId = mounted ? Shared::Modules::MountClassId(mountClass) : 0; SetFlag(Shared::Modules::HumanSync::InAir, !mounted && DetectInAir(pc->Pawn)); + // Spell cast (on-foot only): the Cast flag + which spell (1-based allowlist id) + the aim pitch, so + // the proxy can replay the montage + fire the real spell aimed up/down. 0 when not casting. + const bool casting = !mounted && DetectCast(pc->Pawn); + SetFlag(Shared::Modules::HumanSync::Cast, casting); + data.spellId = casting ? Shared::Modules::SpellRecordId(ActiveSpellRecordPath(pc->Pawn).c_str()) : 0; + data.aimPitch = casting ? LocalAimPitch(pc) : 0; + // World velocity — only while mounted (remotes dead-reckon the broom from it; the on-foot snapshot // path ignores it). Zeroed on foot so the value stops changing and its delta Field goes quiet. if (mounted) { @@ -529,9 +738,13 @@ namespace HogwartsMP::Core::Modules { _timeAccum = 0.0f; _havePacketTime = false; // next packet is treated as the first — clean resume, no huge-dt sample } - // Gait runs on foot only (the broom owns the mounted pose), and not before the async CCC mesh is built. - if (!_mounted && ProxyReadyToDrive()) { - UpdateGait(); + // Anim driving, once the async CCC mesh is built. Gait is on-foot only (the broom owns the mounted + // pose); the cast montage/VFX no-ops itself while mounted. + if (ProxyReadyToDrive()) { + if (!_mounted) { + UpdateGait(); + } + UpdateCast(); } } @@ -677,6 +890,48 @@ namespace HogwartsMP::Core::Modules { return true; } + // Play the cast montage on the proxy when the synced Cast flag rises, then fire the real spell (VFX) + // from the proxy aimed by its synced facing-yaw + aimPitch. Combat casts are full-body (FullBodyState + // ==7), so the montage goes into DefaultSlot over the running locomotion AnimBP. Native clip loaded by + // path, cached + GC-rooted. Skipped while mounted. + void ClientHuman::UpdateCast() { + const bool casting = IsCasting(); + if (casting && !_castLast && _mesh && !_mounted) { + static UObjectBase *clip = [] { + auto *c = LoadAnimSequence(L"/Game/Animation/Human/Hu_Cmbt_Atk_Cast_Fwd_01_anm.Hu_Cmbt_Atk_Cast_Fwd_01_anm"); + RootObject(c); // pin against GC — else the cached ptr dangles after a collection + return c; + }(); + if (clip) { + PlaySlotMontageOnSkin(_mesh, clip, L"DefaultSlot"); + } + // Fire the real spell VFX/projectile if a spell id was synced: resolve the allowlist id to its + // DA_*SpellRecord path, load it, and SpellHelper::CastSpell from the proxy. + if (const char *recPath = Shared::Modules::SpellRecordPath(data.spellId)) { + if (auto *actor = AliveActor(_actor, _actorIndex)) { + const std::wstring wpath(recPath, recPath + std::strlen(recPath)); + static auto *objCls = FindUClass("Class /Script/CoreUObject.Object"); + if (auto *record = objCls ? LoadObjectByPath(objCls, wpath.c_str()) : nullptr) { + const auto loc = GetActorPos(actor); + const auto rot = GetActorRot(actor); + // Rebuild the aim direction from synced facing-yaw + aimPitch (the body never + // pitches on foot). forward = (cosP·cosY, cosP·sinY, sinP), matching UE. + constexpr float kDegToRad = glm::pi() / 180.f; + const float pitch = static_cast(data.aimPitch) * kDegToRad; + const float yaw = rot.Yaw * kDegToRad; + const float cp = std::cos(pitch); + const Vec3f fwd {cp * std::cos(yaw), cp * std::sin(yaw), std::sin(pitch)}; + constexpr float kCastHeight = 50.f; // ~chest height above the synced capsule centre + const float src[3] = {loc.X, loc.Y, loc.Z + kCastHeight}; + const float tgt[3] = {loc.X + fwd.X * 1500.f, loc.Y + fwd.Y * 1500.f, loc.Z + kCastHeight + fwd.Z * 1500.f}; + CastSpellOnProxy(actor, record, src, tgt); + } + } + } + } + _castLast = casting; + } + void ClientHuman::DeallocReplica(MafiaNet::Connection_RM3 *) { if (_isLocal) { if (Human::GetLocal() == this) { diff --git a/code/client/src/core/modules/human.h b/code/client/src/core/modules/human.h index de93dc6..68bfc77 100644 --- a/code/client/src/core/modules/human.h +++ b/code/client/src/core/modules/human.h @@ -46,6 +46,9 @@ namespace HogwartsMP::Core::Modules { // Async-spawn readiness gate: true once CharacterMesh0's body mesh is built (driving before then // T-poses/crashes). bool ProxyReadyToDrive(); + // Proxy spell cast: on the synced Cast flag's rising edge, play the cast montage + fire the real + // spell (VFX) from the proxy, aimed by synced facing-yaw + aimPitch. + void UpdateCast(); bool _isLocal = false; @@ -101,6 +104,9 @@ namespace HogwartsMP::Core::Modules { bool _havePacketTime = false; bool _abpTickInit = false; + // Last-seen synced Cast flag, for the rising-edge cast trigger. + bool _castLast = false; + // Local-player appearance send state: the CacheCCD pointer last harvested (rebuild detection) and // the content signature last sent (change detection). void *_lastCacheCcd = nullptr; diff --git a/code/client/src/sdk/reflection/ue4_reflection.cpp b/code/client/src/sdk/reflection/ue4_reflection.cpp index fd8e933..a834803 100644 --- a/code/client/src/sdk/reflection/ue4_reflection.cpp +++ b/code/client/src/sdk/reflection/ue4_reflection.cpp @@ -439,4 +439,31 @@ namespace HogwartsMP::Core::UE4 { } in {x, y, 0.f}; // FVector InBlendInput (UE4.27 floats) return CallUFunction(inst, "SetBlendSpaceInput", &in); } + + bool PlaySlotMontageOnSkin(UObjectBase *skin, UObjectBase *asset, const wchar_t *slotName, + float blendIn, float blendOut, float playRate) { + if (!skin || !asset) { + return false; + } + // The slot lives on the component's running AnimBlueprint instance (not the component). + auto *inst = ReadObjectProperty(skin, "AnimScriptInstance"); + if (!inst) { + return false; + } + // UAnimInstance::PlaySlotAnimationAsDynamicMontage(Asset, SlotNodeName, BlendIn, BlendOut, + // PlayRate, LoopCount, BlendOutTriggerTime, InTimeToStartMontageAt) -> UAnimMontage*. LoopCount 1 = once. + struct { + UObjectBase *Asset; + FName SlotNodeName; + float BlendInTime; + float BlendOutTime; + float InPlayRate; + int32_t LoopCount; + float BlendOutTriggerTime; + float InTimeToStartMontageAt; + UObjectBase *ReturnValue; + } p {asset, MakeFName(slotName), blendIn, blendOut, playRate, 1, -1.0f, 0.0f, nullptr}; + CallUFunction(inst, "PlaySlotAnimationAsDynamicMontage", &p); + return p.ReturnValue != nullptr; + } } // namespace HogwartsMP::Core::UE4 diff --git a/code/client/src/sdk/reflection/ue4_reflection.h b/code/client/src/sdk/reflection/ue4_reflection.h index 9dbd93a..dd08809 100644 --- a/code/client/src/sdk/reflection/ue4_reflection.h +++ b/code/client/src/sdk/reflection/ue4_reflection.h @@ -116,4 +116,10 @@ namespace HogwartsMP::Core::UE4 { // Set a single-node blendspace's input coordinate (X = speed for the 1D move space). Returns false // if the mesh has no single-node instance or SetBlendSpaceInput isn't reflected in this build. bool SetBlendSpaceInputOnSkin(UObjectBase *skin, float x, float y); + + // Play `asset` as a one-shot dynamic montage into a named anim slot on the mesh's AnimBlueprint + // instance (overlays the running locomotion AnimBP — used for cast/dodge montages). Returns false if + // there's no AnimBP instance or the montage didn't start. + bool PlaySlotMontageOnSkin(UObjectBase *skin, UObjectBase *asset, const wchar_t *slotName, + float blendIn = 0.25f, float blendOut = 0.25f, float playRate = 1.0f); } // namespace HogwartsMP::Core::UE4 diff --git a/code/server/src/core/builtins/human.cpp b/code/server/src/core/builtins/human.cpp index d377781..4152c9e 100644 --- a/code/server/src/core/builtins/human.cpp +++ b/code/server/src/core/builtins/human.cpp @@ -18,6 +18,8 @@ #include +#include +#include #include namespace HogwartsMP::Scripting { @@ -213,6 +215,14 @@ namespace HogwartsMP::Scripting { } } + void Human::SetCasting(bool casting, double spellId, double aimPitch) { + if (auto *e = ResolveHuman(GetId())) { + e->SetFlag(Shared::Modules::HumanSync::Cast, casting); + e->data.spellId = casting ? static_cast(spellId) : 0; + e->data.aimPitch = casting ? static_cast(std::lround(std::clamp(aimPitch, -90.0, 90.0))) : 0; + } + } + v8pp::class_ &Human::GetClass(v8::Isolate *isolate) { auto it = _classes.find(isolate); if (it != _classes.end()) { @@ -232,6 +242,7 @@ namespace HogwartsMP::Scripting { .function("setInAir", &Human::SetInAir) .function("setMounted", &Human::SetMounted) .function("setVelocity", &Human::SetVelocity) + .function("setCasting", &Human::SetCasting) .function("emit", &Human::Emit) .function("setData", &Human::SetData) .function("hasData", &Human::HasData) diff --git a/code/server/src/core/builtins/human.h b/code/server/src/core/builtins/human.h index cc0c9e1..e886ae5 100644 --- a/code/server/src/core/builtins/human.h +++ b/code/server/src/core/builtins/human.h @@ -33,6 +33,10 @@ namespace HogwartsMP::Scripting { void SetMounted(bool mounted, double mountId); void SetVelocity(double x, double y, double z); + // Set/clear the casting state + spell allowlist id + aim pitch (deg, clamped ±90; relayed so the + // proxy plays the cast montage + fires the real spell at that vertical angle). For /castnpcs. + void SetCasting(bool casting, double spellId, double aimPitch); + // Emit a named event to this player's client scripts (Core.Events). payloadJson is sent as-is // and JSON.parsed on the client into the handler's single argument; pass JSON text. void Emit(std::string eventName, std::string payloadJson); diff --git a/code/shared/game/human.h b/code/shared/game/human.h index ffeb5a7..99ed20f 100644 --- a/code/shared/game/human.h +++ b/code/shared/game/human.h @@ -39,6 +39,9 @@ namespace HogwartsMP::Shared { bool IsMounted() const { return (stateFlags & Modules::HumanSync::Mounted) != 0; } + bool IsCasting() const { + return (stateFlags & Modules::HumanSync::Cast) != 0; + } void SetFlag(Modules::HumanSync::StateFlag flag, bool on) { stateFlags = on ? (stateFlags | flag) : (stateFlags & ~flag); } diff --git a/code/shared/modules/spell_records.hpp b/code/shared/modules/spell_records.hpp new file mode 100644 index 0000000..7c136a5 --- /dev/null +++ b/code/shared/modules/spell_records.hpp @@ -0,0 +1,112 @@ +#pragma once + +// APPEND-ONLY: wire spell ids are 1-based indices into kSpellRecords (id 0 = none/unknown). Do NOT +// reorder or insert — that shifts ids and breaks cross-version compat. To add spells, append at the end +// and regenerate. The proxy can only load a record in this allowlist (no arbitrary asset loads). + +#include +#include +#include + +namespace HogwartsMP::Shared::Modules { + inline constexpr auto kSpellRecords = std::to_array({ + "/Game/Gameplay/ToolSet/Spells/Accio/DA_AccioSpellRecord.DA_AccioSpellRecord", + "/Game/Gameplay/ToolSet/Spells/AMBossKiller/DA_AMBossKillerSpellRecord.DA_AMBossKillerSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Apparition/DA_ApparitionSpellRecord.DA_ApparitionSpellRecord", + "/Game/Gameplay/ToolSet/Spells/ArrestoMomentum/DA_ArrestoMomentumSpellRecord.DA_ArrestoMomentumSpellRecord", + "/Game/Gameplay/ToolSet/Spells/AvadaKedavra/DA_AvadaKedavraSpellRecord.DA_AvadaKedavraSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Confringo/DA_ConfringoSpellRecord.DA_ConfringoSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Confundo/DA_ConfundoSpellRecord.DA_ConfundoSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Conjuration/DA_ConjurationSpellRecord.DA_ConjurationSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Crucio/DA_CrucioSpellRecord.DA_CrucioSpellRecord", + "/Game/Gameplay/ToolSet/Spells/DarkWizardSpells/DA_ProtegoSpellRecord_Boss.DA_ProtegoSpellRecord_Boss", + "/Game/Gameplay/ToolSet/Spells/DarkWizardSpells/DA_ProtegoSpellRecord_DW.DA_ProtegoSpellRecord_DW", + "/Game/Gameplay/ToolSet/Spells/Depulso/DA_DepulsoSpellRecord.DA_DepulsoSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Depulso/DA_DepulsoSpellRecordDH.DA_DepulsoSpellRecordDH", + "/Game/Gameplay/ToolSet/Spells/Descendo/DA_DescendoSpellRecord.DA_DescendoSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Descendo/DA_DescendoSpellRecordDH.DA_DescendoSpellRecordDH", + "/Game/Gameplay/ToolSet/Spells/Diffindo/DA_DiffindoSpellRecord.DA_DiffindoSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Diffindo/DA_DiffindoSpellRecordDH.DA_DiffindoSpellRecordDH", + "/Game/Gameplay/ToolSet/Spells/Disillusionment/DA_DisillusionmentSpellRecord.DA_DisillusionmentSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Disillusionment/DA_DistractionSpellRecordDH.DA_DistractionSpellRecordDH", + "/Game/Gameplay/ToolSet/Spells/Disillusionment/DA_InvisibilitySpellRecordDH.DA_InvisibilitySpellRecordDH", + "/Game/Gameplay/ToolSet/Spells/Dragon/DA_AttackSpellRecord_DragonBolt.DA_AttackSpellRecord_DragonBolt", + "/Game/Gameplay/ToolSet/Spells/Dragon/DA_AttackSpellRecord_DragonBolt_AOE.DA_AttackSpellRecord_DragonBolt_AOE", + "/Game/Gameplay/ToolSet/Spells/Dragon/DA_AttackSpellRecord_DragonFire_Instakill.DA_AttackSpellRecord_DragonFire_Instakill", + "/Game/Gameplay/ToolSet/Spells/Episkey/DA_EpiskeySpellRecord.DA_EpiskeySpellRecord", + "/Game/Gameplay/ToolSet/Spells/Expelliarmus/DA_ExpelliarmusSpellRecord.DA_ExpelliarmusSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Expulso/DA_ExpulsoSpellRecord.DA_ExpulsoSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Expulso/DA_ExpulsoSpellRecordDH.DA_ExpulsoSpellRecordDH", + "/Game/Gameplay/ToolSet/Spells/FiendFyre/DA_FiendFyreSimpleSpellRecord.DA_FiendFyreSimpleSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Flipendo/DA_FlipendoSpellRecord.DA_FlipendoSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Glacius/DA_DWGlaciusSpellRecord.DA_DWGlaciusSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Glacius/DA_GlaciusSpellRecord.DA_GlaciusSpellRecord", + "/Game/Gameplay/ToolSet/Spells/GoblinSpells/DA_Attack1SpellRecord_Goblin.DA_Attack1SpellRecord_Goblin", + "/Game/Gameplay/ToolSet/Spells/GoblinSpells/DA_Attack2SpellRecord_Goblin.DA_Attack2SpellRecord_Goblin", + "/Game/Gameplay/ToolSet/Spells/GoblinSpells/DA_Attack3SpellRecord_Goblin.DA_Attack3SpellRecord_Goblin", + "/Game/Gameplay/ToolSet/Spells/GoblinSpells/DA_Goblin_Shielded_ProtegoSpellRecord.DA_Goblin_Shielded_ProtegoSpellRecord", + "/Game/Gameplay/ToolSet/Spells/GoblinSpells/DA_SpellRecord_Chieftain_Protego.DA_SpellRecord_Chieftain_Protego", + "/Game/Gameplay/ToolSet/Spells/GoblinSpells/DA_SpellRecord_Goblin_AOE.DA_SpellRecord_Goblin_AOE", + "/Game/Gameplay/ToolSet/Spells/GoblinSpells/DA_SpellRecord_Socrerer_Protego.DA_SpellRecord_Socrerer_Protego", + "/Game/Gameplay/ToolSet/Spells/GoblinSpells/DA_SpellRecord_Sorcerer_Close.DA_SpellRecord_Sorcerer_Close", + "/Game/Gameplay/ToolSet/Spells/GoblinSpells/DA_SpellRecord_Sorcerer_Combo.DA_SpellRecord_Sorcerer_Combo", + "/Game/Gameplay/ToolSet/Spells/GoblinSpells/DA_SpellRecord_Sorcerer_Flame.DA_SpellRecord_Sorcerer_Flame", + "/Game/Gameplay/ToolSet/Spells/Imperious/DA_ImperiusSpellRecord.DA_ImperiusSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Incendio/DA_IncendioSpellRecord.DA_IncendioSpellRecord", + "/Game/Gameplay/ToolSet/Spells/InteractionGeneral/DA_InteractionGeneralSpellRecord.DA_InteractionGeneralSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Levioso/DA_LeviosoSpellRecord.DA_LeviosoSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Lumos/DA_LumosSpellRecord.DA_LumosSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Lumos/DA_LumosSpellRecordNPC.DA_LumosSpellRecordNPC", + "/Game/Gameplay/ToolSet/Spells/Obliviate/DA_ObliviateSpellRecord.DA_ObliviateSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Oppugno/DA_OppugnoSpellRecord.DA_OppugnoSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Petrificus/DA_DWPetrificusSpellRecord.DA_DWPetrificusSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Petrificus/DA_PetrificusSpellRecord.DA_PetrificusSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Protego/DA_ProtegoSpellRecord.DA_ProtegoSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Protego/DA_ProtegoSpellRecordDH.DA_ProtegoSpellRecordDH", + "/Game/Gameplay/ToolSet/Spells/Ranrak/DA_AttackSpellRecord_RanrakBossFireBreath.DA_AttackSpellRecord_RanrakBossFireBreath", + "/Game/Gameplay/ToolSet/Spells/Ranrak/DA_AttackSpellRecord_RanrakBossFireBreathSweep.DA_AttackSpellRecord_RanrakBossFireBreathSweep", + "/Game/Gameplay/ToolSet/Spells/Ranrak/DA_AttackSpellRecord_RanrakBossP1.DA_AttackSpellRecord_RanrakBossP1", + "/Game/Gameplay/ToolSet/Spells/Ranrak/DA_AttackSpellRecord_RanrakBossP2.DA_AttackSpellRecord_RanrakBossP2", + "/Game/Gameplay/ToolSet/Spells/Ranrak/DA_AttackSpellRecord_RanrakBossPulse.DA_AttackSpellRecord_RanrakBossPulse", + "/Game/Gameplay/ToolSet/Spells/Reparo/DA_ReparoSpellRecord.DA_ReparoSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Revelio/DA_RevelioSpellRecord.DA_RevelioSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Spider/DA_AttackSpellRecord_Spider_Venomous.DA_AttackSpellRecord_Spider_Venomous", + "/Game/Gameplay/ToolSet/Spells/Spider/DA_AttackSpellRecord_Spider_Woodlouse.DA_AttackSpellRecord_Spider_Woodlouse", + "/Game/Gameplay/ToolSet/Spells/Spider/DA_SpellRecord_Spider_BurrowAoe.DA_SpellRecord_Spider_BurrowAoe", + "/Game/Gameplay/ToolSet/Spells/Spider/DA_SpellRecord_Spider_Woodlouse_Sniper_SpitWebs.DA_SpellRecord_Spider_Woodlouse_Sniper_SpitWebs", + "/Game/Gameplay/ToolSet/Spells/StealthTakedown/DA_StealthTakedownSpellRecord.DA_StealthTakedownSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Stupefy/DA_StingingSpellRecord.DA_StingingSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Stupefy/DA_StupefyHeavySpellRecord.DA_StupefyHeavySpellRecord", + "/Game/Gameplay/ToolSet/Spells/Stupefy/DA_StupefySpecialSendSpellRecord.DA_StupefySpecialSendSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Stupefy/DA_StupefySpellRecord.DA_StupefySpellRecord", + "/Game/Gameplay/ToolSet/Spells/Stupefy/DA_StupefySpellRecordDH.DA_StupefySpellRecordDH", + "/Game/Gameplay/ToolSet/Spells/TombProtector/DA_AttackSpellRecord_TombProtector_Missile.DA_AttackSpellRecord_TombProtector_Missile", + "/Game/Gameplay/ToolSet/Spells/TombProtector/DA_AttackSpellRecord_TombProtector_Stomp.DA_AttackSpellRecord_TombProtector_Stomp", + "/Game/Gameplay/ToolSet/Spells/Transformation/DA_TransformationOverlandSpellRecord.DA_TransformationOverlandSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Transformation/DA_TransformationSpellRecord.DA_TransformationSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Troll/DA_AttackSpellRecord_Troll.DA_AttackSpellRecord_Troll", + "/Game/Gameplay/ToolSet/Spells/Vanishment/DA_VanishmentSpellRecord.DA_VanishmentSpellRecord", + "/Game/Gameplay/ToolSet/Spells/VenomousTentacula/DA_AttackSpellRecord_VenomousTentacula.DA_AttackSpellRecord_VenomousTentacula", + "/Game/Gameplay/ToolSet/Spells/VFXTest/DA_FXBeamTestSpellRecord.DA_FXBeamTestSpellRecord", + "/Game/Gameplay/ToolSet/Spells/VFXTest/DA_FXTestSpellRecord.DA_FXTestSpellRecord", + "/Game/Gameplay/ToolSet/Spells/Wingardium/DA_WingardiumSpellRecord.DA_WingardiumSpellRecord", + }); + + // Asset path -> 1-based wire id (0 if not in the allowlist). Linear scan (~80, called once per cast). + inline uint8_t SpellRecordId(std::string_view path) { + if (path.empty()) { + return 0; + } + for (std::size_t i = 0; i < kSpellRecords.size(); ++i) { + if (kSpellRecords[i] == path) { + return static_cast(i + 1); + } + } + return 0; + } + + // Wire id -> asset path (nullptr if 0/out of range; null-terminated literal otherwise). + inline const char *SpellRecordPath(uint8_t id) { + return (id >= 1 && id <= kSpellRecords.size()) ? kSpellRecords[id - 1].data() : nullptr; + } +} // namespace HogwartsMP::Shared::Modules diff --git a/resources/gamemode/server/main.js b/resources/gamemode/server/main.js index 1165408..2d567ad 100644 --- a/resources/gamemode/server/main.js +++ b/resources/gamemode/server/main.js @@ -78,6 +78,7 @@ const npcs = []; const MAX_NPCS = 20; let npcWalkTimer = null; let npcBroomTimer = null; +let npcCastTimer = null; Events.on("playerConnect", (player) => { const visits = bumpVisitCount(); @@ -299,6 +300,33 @@ Events.on("chatCommand", (player, message, command, args) => { break; } + case "castnpcs": { + if (npcCastTimer) { + clearInterval(npcCastTimer); + npcCastTimer = null; + for (const npc of npcs) npc.setCasting(false, 0, 0); + player.sendChat("[DEV] NPC casting stopped"); + break; + } + if (npcs.length === 0) { + player.sendChat("[DEV] No NPCs to cast — use /spawnnpc first"); + break; + } + // Toggle the Cast flag on/off each second: each rising edge makes the proxy play the cast + // montage + fire the real spell (id 6 = Confringo, a visible fire blast). Optional /castnpcs + // (default 25, +up) sets the aim angle so the vertical-aim sync can be eyeballed with + // a single client — otherwise the NPC fires dead level. + const pitch = args.length >= 1 ? parseFloat(args[0]) : 25; + const aim = isNaN(pitch) ? 0 : pitch; + let casting = false; + npcCastTimer = setInterval(() => { + casting = !casting; + for (const npc of npcs) npc.setCasting(casting, casting ? 6 : 0, casting ? aim : 0); + }, 1000); + player.sendChat(`[DEV] ${npcs.length} NPC(s) casting Confringo on a loop at ${aim}° (run /castnpcs again to stop)`); + break; + } + case "clearnpcs": { if (npcWalkTimer) { clearInterval(npcWalkTimer); @@ -308,6 +336,10 @@ Events.on("chatCommand", (player, message, command, args) => { clearInterval(npcBroomTimer); npcBroomTimer = null; } + if (npcCastTimer) { + clearInterval(npcCastTimer); + npcCastTimer = null; + } for (const npc of npcs) { npc.destroy(); }