mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-03-22 23:30:14 +00:00
Add transport system, fix NPC spawning, and improve water rendering
Transport System (Phases 1-7): - Implement TransportManager with Catmull-Rom spline path interpolation - Add WMO dynamic transforms for moving transport instances - Implement player attachment via world position composition - Add test transport with circular path around Stormwind harbor - Add /transport board and /transport leave console commands - Reuse taxi flight spline system and external follow camera mode NPC Spawn Fixes: - Add smart ocean spawn filter: blocks land creatures at high altitude over water (Z>50) - Allow legitimate water creatures at sea level (Z≤50) to spawn correctly - Fixes Elder Grey Bears, Highland Striders, and Plainscreepers spawning over ocean - Snap online creatures to terrain height when valid ground exists NpcManager Removal: - Remove deprecated NpcManager (offline mode no longer supported) - Delete npc_manager.hpp and npc_manager.cpp - Simplify NPC animation callbacks to use only creatureInstances_ map - Move NPC callbacks to game initialization in application.cpp Water Rendering: - Fix tile seam gaps caused by per-vertex wave randomization - Add distance-based blending: seamless waves up close (<150u), grid effect far away (>400u) - Smooth transition between seamless and grid modes (150-400 unit range) - Preserves aesthetic grid pattern at horizon while eliminating gaps when swimming
This commit is contained in:
parent
c91e0bb916
commit
2e923311d0
13 changed files with 711 additions and 1079 deletions
|
|
@ -88,6 +88,7 @@ set(WOWEE_SOURCES
|
|||
|
||||
# Game
|
||||
src/game/game_handler.cpp
|
||||
src/game/transport_manager.cpp
|
||||
src/game/world.cpp
|
||||
src/game/player.cpp
|
||||
src/game/entity.cpp
|
||||
|
|
@ -95,7 +96,6 @@ set(WOWEE_SOURCES
|
|||
src/game/world_packets.cpp
|
||||
src/game/character.cpp
|
||||
src/game/zone_manager.cpp
|
||||
src/game/npc_manager.cpp
|
||||
src/game/inventory.cpp
|
||||
|
||||
# Audio
|
||||
|
|
@ -199,7 +199,6 @@ set(WOWEE_HEADERS
|
|||
include/game/entity.hpp
|
||||
include/game/opcodes.hpp
|
||||
include/game/zone_manager.hpp
|
||||
include/game/npc_manager.hpp
|
||||
include/game/inventory.hpp
|
||||
include/game/spell_defines.hpp
|
||||
include/game/group_defines.hpp
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ namespace wowee {
|
|||
namespace rendering { class Renderer; }
|
||||
namespace ui { class UIManager; }
|
||||
namespace auth { class AuthHandler; }
|
||||
namespace game { class GameHandler; class World; class NpcManager; }
|
||||
namespace game { class GameHandler; class World; }
|
||||
namespace pipeline { class AssetManager; }
|
||||
namespace audio { enum class VoiceType; }
|
||||
|
||||
|
|
@ -79,7 +79,6 @@ private:
|
|||
void render();
|
||||
void setupUICallbacks();
|
||||
void spawnPlayerCharacter();
|
||||
void spawnNpcs();
|
||||
std::string getPlayerModelPath() const;
|
||||
static const char* mapIdToName(uint32_t mapId);
|
||||
void loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float z);
|
||||
|
|
@ -93,6 +92,7 @@ private:
|
|||
void buildGameObjectDisplayLookups();
|
||||
std::string getGameObjectModelPathForDisplayId(uint32_t displayId) const;
|
||||
audio::VoiceType detectVoiceTypeFromDisplayId(uint32_t displayId) const;
|
||||
void setupTestTransport(); // Test transport boat for development
|
||||
|
||||
static Application* instance;
|
||||
|
||||
|
|
@ -102,7 +102,6 @@ private:
|
|||
std::unique_ptr<auth::AuthHandler> authHandler;
|
||||
std::unique_ptr<game::GameHandler> gameHandler;
|
||||
std::unique_ptr<game::World> world;
|
||||
std::unique_ptr<game::NpcManager> npcManager;
|
||||
std::unique_ptr<pipeline::AssetManager> assetManager;
|
||||
|
||||
AppState state = AppState::AUTHENTICATION;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@
|
|||
#include <unordered_set>
|
||||
#include <map>
|
||||
|
||||
namespace wowee::game {
|
||||
class TransportManager;
|
||||
}
|
||||
|
||||
namespace wowee {
|
||||
namespace network { class WorldSocket; class Packet; }
|
||||
|
||||
|
|
@ -483,6 +487,16 @@ public:
|
|||
bool isOnTransport() const { return playerTransportGuid_ != 0; }
|
||||
uint64_t getPlayerTransportGuid() const { return playerTransportGuid_; }
|
||||
glm::vec3 getPlayerTransportOffset() const { return playerTransportOffset_; }
|
||||
glm::vec3 getComposedWorldPosition(); // Compose transport transform * local offset
|
||||
TransportManager* getTransportManager() { return transportManager_.get(); }
|
||||
void setPlayerOnTransport(uint64_t transportGuid, const glm::vec3& localOffset) {
|
||||
playerTransportGuid_ = transportGuid;
|
||||
playerTransportOffset_ = localOffset;
|
||||
}
|
||||
void clearPlayerTransport() {
|
||||
playerTransportGuid_ = 0;
|
||||
playerTransportOffset_ = glm::vec3(0.0f);
|
||||
}
|
||||
|
||||
// Cooldowns
|
||||
float getSpellCooldown(uint32_t spellId) const;
|
||||
|
|
@ -972,6 +986,7 @@ private:
|
|||
std::unordered_set<uint64_t> transportGuids_; // GUIDs of known transport GameObjects
|
||||
uint64_t playerTransportGuid_ = 0; // Transport the player is riding (0 = none)
|
||||
glm::vec3 playerTransportOffset_ = glm::vec3(0.0f); // Player offset on transport
|
||||
std::unique_ptr<TransportManager> transportManager_; // Transport movement manager
|
||||
std::vector<uint32_t> knownSpells;
|
||||
std::unordered_map<uint32_t, float> spellCooldowns; // spellId -> remaining seconds
|
||||
uint8_t castCount = 0;
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <glm/glm.hpp>
|
||||
|
||||
namespace wowee {
|
||||
namespace pipeline { class AssetManager; }
|
||||
namespace rendering { class CharacterRenderer; }
|
||||
namespace rendering { class TerrainManager; }
|
||||
namespace game {
|
||||
|
||||
class EntityManager;
|
||||
|
||||
struct NpcSpawnDef {
|
||||
std::string mapName;
|
||||
uint32_t entry = 0;
|
||||
std::string name;
|
||||
std::string m2Path;
|
||||
uint32_t level;
|
||||
uint32_t health;
|
||||
glm::vec3 canonicalPosition; // WoW canonical coords (+X north, +Y west, +Z up)
|
||||
bool inputIsServerCoords = false; // if true, input XYZ are server/wire order
|
||||
float rotation; // radians around Z
|
||||
float scale;
|
||||
bool isCritter; // critters don't do humanoid emotes
|
||||
uint32_t faction = 0; // faction template ID from creature_template
|
||||
uint32_t npcFlags = 0; // NPC interaction flags from creature_template
|
||||
};
|
||||
|
||||
struct NpcInstance {
|
||||
uint64_t guid;
|
||||
uint32_t renderInstanceId;
|
||||
float emoteTimer; // countdown to next random emote
|
||||
float emoteEndTimer; // countdown until emote animation finishes
|
||||
bool isEmoting;
|
||||
bool isCritter;
|
||||
};
|
||||
|
||||
class NpcManager {
|
||||
public:
|
||||
void clear(rendering::CharacterRenderer* cr, EntityManager* em);
|
||||
void initialize(pipeline::AssetManager* am,
|
||||
rendering::CharacterRenderer* cr,
|
||||
EntityManager& em,
|
||||
const std::string& mapName,
|
||||
const glm::vec3& playerCanonical,
|
||||
const rendering::TerrainManager* terrainManager);
|
||||
void update(float deltaTime, rendering::CharacterRenderer* cr);
|
||||
|
||||
uint32_t findRenderInstanceId(uint64_t guid) const;
|
||||
|
||||
private:
|
||||
std::vector<NpcSpawnDef> loadSpawnDefsFromFile(const std::string& path) const;
|
||||
std::vector<NpcSpawnDef> loadSpawnDefsFromAzerothCoreDb(
|
||||
const std::string& basePath,
|
||||
const std::string& mapName,
|
||||
const glm::vec3& playerCanonical,
|
||||
pipeline::AssetManager* am) const;
|
||||
|
||||
void loadCreatureModel(pipeline::AssetManager* am,
|
||||
rendering::CharacterRenderer* cr,
|
||||
const std::string& m2Path,
|
||||
uint32_t modelId);
|
||||
|
||||
std::vector<NpcInstance> npcs;
|
||||
std::unordered_map<std::string, uint32_t> loadedModels; // path -> modelId
|
||||
uint64_t nextGuid = 0xF1300000DEAD0001ULL;
|
||||
uint32_t nextModelId = 100;
|
||||
};
|
||||
|
||||
} // namespace game
|
||||
} // namespace wowee
|
||||
73
include/game/transport_manager.hpp
Normal file
73
include/game/transport_manager.hpp
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <glm/glm.hpp>
|
||||
#include <glm/gtc/quaternion.hpp>
|
||||
|
||||
namespace wowee::rendering {
|
||||
class WMORenderer;
|
||||
}
|
||||
|
||||
namespace wowee::game {
|
||||
|
||||
struct TransportPath {
|
||||
uint32_t pathId;
|
||||
std::vector<glm::vec3> waypoints; // Position keyframes
|
||||
std::vector<glm::quat> rotations; // Optional rotation keyframes
|
||||
bool looping;
|
||||
float speed; // units/sec (default 18.0f like taxi)
|
||||
};
|
||||
|
||||
struct ActiveTransport {
|
||||
uint64_t guid; // Entity GUID
|
||||
uint32_t wmoInstanceId; // WMO renderer instance ID
|
||||
uint32_t pathId; // Current path
|
||||
size_t currentSegment; // Current waypoint index
|
||||
float segmentProgress; // Distance along segment
|
||||
glm::vec3 position; // Current world position
|
||||
glm::quat rotation; // Current world rotation
|
||||
glm::mat4 transform; // Cached world transform
|
||||
glm::mat4 invTransform; // Cached inverse for collision
|
||||
|
||||
// Player attachment (single-player for now)
|
||||
bool playerOnBoard;
|
||||
glm::vec3 playerLocalOffset;
|
||||
|
||||
// Optional deck boundaries
|
||||
glm::vec3 deckMin;
|
||||
glm::vec3 deckMax;
|
||||
bool hasDeckBounds;
|
||||
};
|
||||
|
||||
class TransportManager {
|
||||
public:
|
||||
TransportManager();
|
||||
~TransportManager();
|
||||
|
||||
void setWMORenderer(rendering::WMORenderer* renderer) { wmoRenderer_ = renderer; }
|
||||
|
||||
void update(float deltaTime);
|
||||
void registerTransport(uint64_t guid, uint32_t wmoInstanceId, uint32_t pathId);
|
||||
void unregisterTransport(uint64_t guid);
|
||||
|
||||
ActiveTransport* getTransport(uint64_t guid);
|
||||
glm::vec3 getPlayerWorldPosition(uint64_t transportGuid, const glm::vec3& localOffset);
|
||||
glm::mat4 getTransportInvTransform(uint64_t transportGuid);
|
||||
|
||||
void loadPathFromNodes(uint32_t pathId, const std::vector<glm::vec3>& waypoints, bool looping = true, float speed = 18.0f);
|
||||
void setDeckBounds(uint64_t guid, const glm::vec3& min, const glm::vec3& max);
|
||||
|
||||
private:
|
||||
void updateTransportMovement(ActiveTransport& transport, float deltaTime);
|
||||
glm::vec3 interpolatePath(const TransportPath& path, size_t segmentIdx, float t);
|
||||
glm::quat calculateOrientation(const TransportPath& path, size_t segmentIdx, float t);
|
||||
void updateTransformMatrices(ActiveTransport& transport);
|
||||
|
||||
std::unordered_map<uint64_t, ActiveTransport> transports_;
|
||||
std::unordered_map<uint32_t, TransportPath> paths_;
|
||||
rendering::WMORenderer* wmoRenderer_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace wowee::game
|
||||
|
|
@ -82,6 +82,13 @@ public:
|
|||
*/
|
||||
void setInstancePosition(uint32_t instanceId, const glm::vec3& position);
|
||||
|
||||
/**
|
||||
* Update the full transform of an existing instance (for moving transports)
|
||||
* @param instanceId Instance to update
|
||||
* @param transform World transform matrix
|
||||
*/
|
||||
void setInstanceTransform(uint32_t instanceId, const glm::mat4& transform);
|
||||
|
||||
/**
|
||||
* Remove WMO instance
|
||||
* @param instanceId Instance to remove
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@
|
|||
#include "ui/ui_manager.hpp"
|
||||
#include "auth/auth_handler.hpp"
|
||||
#include "game/game_handler.hpp"
|
||||
#include "game/transport_manager.hpp"
|
||||
#include "game/world.hpp"
|
||||
#include "game/npc_manager.hpp"
|
||||
#include "pipeline/asset_manager.hpp"
|
||||
#include <SDL2/SDL.h>
|
||||
#include <GL/glew.h>
|
||||
|
|
@ -409,12 +409,6 @@ void Application::update(float deltaTime) {
|
|||
auto w2 = std::chrono::high_resolution_clock::now();
|
||||
worldTime += std::chrono::duration<float, std::milli>(w2 - w1).count();
|
||||
|
||||
auto s1 = std::chrono::high_resolution_clock::now();
|
||||
// Spawn NPCs once when entering world
|
||||
spawnNpcs();
|
||||
auto s2 = std::chrono::high_resolution_clock::now();
|
||||
spawnTime += std::chrono::duration<float, std::milli>(s2 - s1).count();
|
||||
|
||||
auto cq1 = std::chrono::high_resolution_clock::now();
|
||||
// Process deferred online creature spawns (throttled)
|
||||
processCreatureSpawnQueue();
|
||||
|
|
@ -432,9 +426,6 @@ void Application::update(float deltaTime) {
|
|||
mountTime += std::chrono::duration<float, std::milli>(m2 - m1).count();
|
||||
|
||||
auto nm1 = std::chrono::high_resolution_clock::now();
|
||||
if (npcManager && renderer && renderer->getCharacterRenderer()) {
|
||||
npcManager->update(deltaTime, renderer->getCharacterRenderer());
|
||||
}
|
||||
auto nm2 = std::chrono::high_resolution_clock::now();
|
||||
npcMgrTime += std::chrono::duration<float, std::milli>(nm2 - nm1).count();
|
||||
|
||||
|
|
@ -493,6 +484,8 @@ void Application::update(float deltaTime) {
|
|||
|
||||
// Sync character render position ↔ canonical WoW coords each frame
|
||||
if (renderer && gameHandler) {
|
||||
bool onTransport = gameHandler->isOnTransport();
|
||||
|
||||
if (onTaxi) {
|
||||
auto playerEntity = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid());
|
||||
if (playerEntity) {
|
||||
|
|
@ -503,6 +496,18 @@ void Application::update(float deltaTime) {
|
|||
float yawDeg = glm::degrees(playerEntity->getOrientation()) + 90.0f;
|
||||
renderer->setCharacterYaw(yawDeg);
|
||||
}
|
||||
} else if (onTransport) {
|
||||
// Transport mode: compose world position from transport transform + local offset
|
||||
glm::vec3 canonical = gameHandler->getComposedWorldPosition();
|
||||
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
|
||||
renderer->getCharacterPosition() = renderPos;
|
||||
// Update camera follow target
|
||||
if (renderer->getCameraController()) {
|
||||
glm::vec3* followTarget = renderer->getCameraController()->getFollowTargetMutable();
|
||||
if (followTarget) {
|
||||
*followTarget = renderPos;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
glm::vec3 renderPos = renderer->getCharacterPosition();
|
||||
glm::vec3 canonical = core::coords::renderToCanonical(renderPos);
|
||||
|
|
@ -1487,93 +1492,6 @@ void Application::loadEquippedWeapons() {
|
|||
}
|
||||
}
|
||||
|
||||
void Application::spawnNpcs() {
|
||||
if (npcsSpawned) return;
|
||||
LOG_INFO("spawnNpcs: checking preconditions...");
|
||||
if (!assetManager || !assetManager->isInitialized()) {
|
||||
LOG_INFO("spawnNpcs: assetManager not ready");
|
||||
return;
|
||||
}
|
||||
if (!renderer || !renderer->getCharacterRenderer() || !renderer->getCamera()) {
|
||||
LOG_INFO("spawnNpcs: renderer not ready");
|
||||
return;
|
||||
}
|
||||
if (!gameHandler) {
|
||||
LOG_INFO("spawnNpcs: gameHandler not ready");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_INFO("spawnNpcs: spawning NPCs...");
|
||||
if (npcManager) {
|
||||
npcManager->clear(renderer->getCharacterRenderer(), &gameHandler->getEntityManager());
|
||||
}
|
||||
npcManager = std::make_unique<game::NpcManager>();
|
||||
glm::vec3 playerSpawnGL = renderer->getCharacterPosition();
|
||||
glm::vec3 playerCanonical = core::coords::renderToCanonical(playerSpawnGL);
|
||||
LOG_INFO("spawnNpcs: player position GL=(", playerSpawnGL.x, ",", playerSpawnGL.y, ",", playerSpawnGL.z,
|
||||
") canonical=(", playerCanonical.x, ",", playerCanonical.y, ",", playerCanonical.z, ")");
|
||||
std::string mapName = "Azeroth";
|
||||
if (auto* minimap = renderer->getMinimap()) {
|
||||
mapName = minimap->getMapName();
|
||||
}
|
||||
|
||||
npcManager->initialize(assetManager.get(),
|
||||
renderer->getCharacterRenderer(),
|
||||
gameHandler->getEntityManager(),
|
||||
mapName,
|
||||
playerCanonical,
|
||||
renderer->getTerrainManager());
|
||||
|
||||
// If the player WoW position hasn't been set by the server yet (offline mode),
|
||||
// derive it from the camera so targeting distance calculations work.
|
||||
const auto& movement = gameHandler->getMovementInfo();
|
||||
if (movement.x == 0.0f && movement.y == 0.0f && movement.z == 0.0f) {
|
||||
glm::vec3 canonical = playerCanonical;
|
||||
gameHandler->setPosition(canonical.x, canonical.y, canonical.z);
|
||||
}
|
||||
|
||||
// Set NPC animation callbacks (works for both single-player and online creatures)
|
||||
if (gameHandler && npcManager) {
|
||||
auto* npcMgr = npcManager.get();
|
||||
auto* cr = renderer->getCharacterRenderer();
|
||||
auto* app = this;
|
||||
gameHandler->setNpcDeathCallback([npcMgr, cr, app](uint64_t guid) {
|
||||
uint32_t instanceId = npcMgr->findRenderInstanceId(guid);
|
||||
if (instanceId == 0) {
|
||||
auto it = app->creatureInstances_.find(guid);
|
||||
if (it != app->creatureInstances_.end()) instanceId = it->second;
|
||||
}
|
||||
if (instanceId != 0 && cr) {
|
||||
cr->playAnimation(instanceId, 1, false); // animation ID 1 = Death
|
||||
}
|
||||
});
|
||||
gameHandler->setNpcRespawnCallback([npcMgr, cr, app](uint64_t guid) {
|
||||
uint32_t instanceId = npcMgr->findRenderInstanceId(guid);
|
||||
if (instanceId == 0) {
|
||||
auto it = app->creatureInstances_.find(guid);
|
||||
if (it != app->creatureInstances_.end()) instanceId = it->second;
|
||||
}
|
||||
if (instanceId != 0 && cr) {
|
||||
cr->playAnimation(instanceId, 0, true); // animation ID 0 = Idle
|
||||
}
|
||||
});
|
||||
gameHandler->setNpcSwingCallback([npcMgr, cr, app](uint64_t guid) {
|
||||
uint32_t instanceId = npcMgr->findRenderInstanceId(guid);
|
||||
if (instanceId == 0) {
|
||||
auto it = app->creatureInstances_.find(guid);
|
||||
if (it != app->creatureInstances_.end()) instanceId = it->second;
|
||||
}
|
||||
if (instanceId != 0 && cr) {
|
||||
cr->playAnimation(instanceId, 16, false); // animation ID 16 = Attack1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
npcsSpawned = true;
|
||||
LOG_INFO("NPCs spawned for in-game session");
|
||||
}
|
||||
|
||||
|
||||
void Application::buildFactionHostilityMap(uint8_t playerRace) {
|
||||
if (!assetManager || !assetManager->isInitialized() || !gameHandler) return;
|
||||
|
||||
|
|
@ -1901,6 +1819,36 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
|
|||
loadingScreen.shutdown();
|
||||
}
|
||||
|
||||
// Set up test transport (development feature)
|
||||
setupTestTransport();
|
||||
|
||||
// Set up NPC animation callbacks (for online creatures)
|
||||
if (gameHandler && renderer && renderer->getCharacterRenderer()) {
|
||||
auto* cr = renderer->getCharacterRenderer();
|
||||
auto* app = this;
|
||||
|
||||
gameHandler->setNpcDeathCallback([cr, app](uint64_t guid) {
|
||||
auto it = app->creatureInstances_.find(guid);
|
||||
if (it != app->creatureInstances_.end() && cr) {
|
||||
cr->playAnimation(it->second, 1, false); // animation ID 1 = Death
|
||||
}
|
||||
});
|
||||
|
||||
gameHandler->setNpcRespawnCallback([cr, app](uint64_t guid) {
|
||||
auto it = app->creatureInstances_.find(guid);
|
||||
if (it != app->creatureInstances_.end() && cr) {
|
||||
cr->playAnimation(it->second, 0, true); // animation ID 0 = Idle
|
||||
}
|
||||
});
|
||||
|
||||
gameHandler->setNpcSwingCallback([cr, app](uint64_t guid) {
|
||||
auto it = app->creatureInstances_.find(guid);
|
||||
if (it != app->creatureInstances_.end() && cr) {
|
||||
cr->playAnimation(it->second, 16, false); // animation ID 16 = Attack1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set game state
|
||||
setState(AppState::IN_GAME);
|
||||
}
|
||||
|
|
@ -2167,9 +2115,6 @@ bool Application::getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, fl
|
|||
auto it = creatureInstances_.find(guid);
|
||||
if (it != creatureInstances_.end()) instanceId = it->second;
|
||||
}
|
||||
if (instanceId == 0 && npcManager) {
|
||||
instanceId = npcManager->findRenderInstanceId(guid);
|
||||
}
|
||||
if (instanceId == 0) return false;
|
||||
|
||||
return renderer->getCharacterRenderer()->getInstanceBounds(instanceId, outCenter, outRadius);
|
||||
|
|
@ -2380,6 +2325,25 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
|
|||
// Convert canonical → render coordinates
|
||||
glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z));
|
||||
|
||||
// Smart filtering for bad spawn data:
|
||||
// - If over ocean AND at continental height (Z > 50): bad data, skip
|
||||
// - If over ocean AND near sea level (Z <= 50): water creature, allow
|
||||
// - If over land: snap to terrain height
|
||||
if (renderer->getTerrainManager()) {
|
||||
auto terrainH = renderer->getTerrainManager()->getHeightAt(renderPos.x, renderPos.y);
|
||||
if (!terrainH) {
|
||||
// No terrain at this X,Y position (ocean/void)
|
||||
if (z > 50.0f) {
|
||||
// High altitude over ocean = bad spawn data (e.g., bears at Z=94 over water)
|
||||
return;
|
||||
}
|
||||
// Low altitude = probably legitimate water creature, allow spawn at original Z
|
||||
} else {
|
||||
// Valid terrain found - snap to terrain height
|
||||
renderPos.z = *terrainH + 0.1f;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert canonical WoW orientation (0=north) -> render yaw (0=west)
|
||||
float renderYaw = orientation + glm::radians(90.0f);
|
||||
|
||||
|
|
@ -3147,5 +3111,130 @@ void Application::updateQuestMarkers() {
|
|||
}
|
||||
}
|
||||
|
||||
void Application::setupTestTransport() {
|
||||
if (!gameHandler || !renderer || !assetManager) return;
|
||||
|
||||
auto* transportManager = gameHandler->getTransportManager();
|
||||
auto* wmoRenderer = renderer->getWMORenderer();
|
||||
if (!transportManager || !wmoRenderer) return;
|
||||
|
||||
LOG_INFO("========================================");
|
||||
LOG_INFO(" SETTING UP TEST TRANSPORT");
|
||||
LOG_INFO("========================================");
|
||||
|
||||
// Connect transport manager to WMO renderer
|
||||
transportManager->setWMORenderer(wmoRenderer);
|
||||
|
||||
// Define a simple circular path around Stormwind harbor (canonical coordinates)
|
||||
// These coordinates are approximate - adjust based on actual harbor layout
|
||||
std::vector<glm::vec3> harborPath = {
|
||||
{-8833.0f, 628.0f, 94.0f}, // Start point (Stormwind harbor)
|
||||
{-8900.0f, 650.0f, 94.0f}, // Move west
|
||||
{-8950.0f, 700.0f, 94.0f}, // Northwest
|
||||
{-8950.0f, 780.0f, 94.0f}, // North
|
||||
{-8900.0f, 830.0f, 94.0f}, // Northeast
|
||||
{-8833.0f, 850.0f, 94.0f}, // East
|
||||
{-8766.0f, 830.0f, 94.0f}, // Southeast
|
||||
{-8716.0f, 780.0f, 94.0f}, // South
|
||||
{-8716.0f, 700.0f, 94.0f}, // Southwest
|
||||
{-8766.0f, 650.0f, 94.0f}, // Back to start direction
|
||||
};
|
||||
|
||||
// Register the path with transport manager
|
||||
uint32_t pathId = 1;
|
||||
float speed = 12.0f; // 12 units/sec (slower than taxi for a leisurely boat ride)
|
||||
transportManager->loadPathFromNodes(pathId, harborPath, true, speed);
|
||||
LOG_INFO("Registered transport path ", pathId, " with ", harborPath.size(), " waypoints, speed=", speed);
|
||||
|
||||
// Try to load a transport WMO model
|
||||
// Common transport WMOs: Transportship.wmo (generic ship)
|
||||
std::string transportWmoPath = "Transports\\Transportship\\Transportship.wmo";
|
||||
|
||||
auto wmoData = assetManager->readFile(transportWmoPath);
|
||||
if (wmoData.empty()) {
|
||||
LOG_WARNING("Could not load transport WMO: ", transportWmoPath);
|
||||
LOG_INFO("Trying alternative: Boat transport");
|
||||
transportWmoPath = "Transports\\Boat\\Boat.wmo";
|
||||
wmoData = assetManager->readFile(transportWmoPath);
|
||||
}
|
||||
|
||||
if (wmoData.empty()) {
|
||||
LOG_WARNING("No transport WMO found - test transport disabled");
|
||||
LOG_INFO("Available transport WMOs are typically in Transports\\ directory");
|
||||
return;
|
||||
}
|
||||
|
||||
// Load WMO model
|
||||
pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData);
|
||||
LOG_INFO("Transport WMO root loaded: ", transportWmoPath, " nGroups=", wmoModel.nGroups);
|
||||
|
||||
// Load WMO groups
|
||||
int loadedGroups = 0;
|
||||
if (wmoModel.nGroups > 0) {
|
||||
std::string basePath = transportWmoPath.substr(0, transportWmoPath.size() - 4);
|
||||
|
||||
for (uint32_t gi = 0; gi < wmoModel.nGroups; gi++) {
|
||||
char groupSuffix[16];
|
||||
snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.wmo", gi);
|
||||
std::string groupPath = basePath + groupSuffix;
|
||||
std::vector<uint8_t> groupData = assetManager->readFile(groupPath);
|
||||
|
||||
if (!groupData.empty()) {
|
||||
pipeline::WMOLoader::loadGroup(groupData, wmoModel, gi);
|
||||
loadedGroups++;
|
||||
} else {
|
||||
LOG_WARNING(" Failed to load WMO group ", gi, " for: ", basePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedGroups == 0 && wmoModel.nGroups > 0) {
|
||||
LOG_WARNING("Failed to load any WMO groups for transport");
|
||||
return;
|
||||
}
|
||||
|
||||
// Load WMO into renderer
|
||||
uint32_t wmoModelId = 99999; // Use high ID to avoid conflicts
|
||||
if (!wmoRenderer->loadModel(wmoModel, wmoModelId)) {
|
||||
LOG_WARNING("Failed to load transport WMO model into renderer");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create WMO instance at first waypoint (convert canonical to render coords)
|
||||
glm::vec3 startCanonical = harborPath[0];
|
||||
glm::vec3 startRender = core::coords::canonicalToRender(startCanonical);
|
||||
|
||||
uint32_t wmoInstanceId = wmoRenderer->createInstance(wmoModelId, startRender,
|
||||
glm::vec3(0.0f, 0.0f, 0.0f), 1.0f);
|
||||
|
||||
if (wmoInstanceId == 0) {
|
||||
LOG_WARNING("Failed to create transport WMO instance");
|
||||
return;
|
||||
}
|
||||
|
||||
// Register transport with transport manager
|
||||
uint64_t transportGuid = 0x1000000000000001ULL; // Fake GUID for test
|
||||
transportManager->registerTransport(transportGuid, wmoInstanceId, pathId);
|
||||
|
||||
// Optional: Set deck bounds (rough estimate for a ship deck)
|
||||
transportManager->setDeckBounds(transportGuid,
|
||||
glm::vec3(-15.0f, -30.0f, 0.0f),
|
||||
glm::vec3(15.0f, 30.0f, 10.0f));
|
||||
|
||||
LOG_INFO("========================================");
|
||||
LOG_INFO("Test transport registered:");
|
||||
LOG_INFO(" GUID: 0x", std::hex, transportGuid, std::dec);
|
||||
LOG_INFO(" WMO Instance: ", wmoInstanceId);
|
||||
LOG_INFO(" Path: ", pathId, " (", harborPath.size(), " waypoints)");
|
||||
LOG_INFO(" Speed: ", speed, " units/sec");
|
||||
LOG_INFO("========================================");
|
||||
LOG_INFO("");
|
||||
LOG_INFO("To board the transport, use console command:");
|
||||
LOG_INFO(" /transport board");
|
||||
LOG_INFO("To disembark:");
|
||||
LOG_INFO(" /transport leave");
|
||||
LOG_INFO("========================================");
|
||||
}
|
||||
|
||||
} // namespace core
|
||||
} // namespace wowee
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
#include "game/game_handler.hpp"
|
||||
#include "game/transport_manager.hpp"
|
||||
#include "game/opcodes.hpp"
|
||||
#include "network/world_socket.hpp"
|
||||
#include "network/packet.hpp"
|
||||
|
|
@ -28,6 +29,9 @@ namespace game {
|
|||
GameHandler::GameHandler() {
|
||||
LOG_DEBUG("GameHandler created");
|
||||
|
||||
// Initialize transport manager
|
||||
transportManager_ = std::make_unique<TransportManager>();
|
||||
|
||||
// Default spells always available
|
||||
knownSpells.push_back(6603); // Attack
|
||||
knownSpells.push_back(8690); // Hearthstone
|
||||
|
|
@ -305,6 +309,11 @@ void GameHandler::update(float deltaTime) {
|
|||
auto taxiEnd = std::chrono::high_resolution_clock::now();
|
||||
taxiTime += std::chrono::duration<float, std::milli>(taxiEnd - taxiStart).count();
|
||||
|
||||
// Update transport manager
|
||||
if (transportManager_) {
|
||||
transportManager_->update(deltaTime);
|
||||
}
|
||||
|
||||
// Distance check timing
|
||||
auto distanceStart = std::chrono::high_resolution_clock::now();
|
||||
|
||||
|
|
@ -6767,5 +6776,13 @@ void GameHandler::loadCharacterConfig() {
|
|||
}
|
||||
}
|
||||
|
||||
glm::vec3 GameHandler::getComposedWorldPosition() {
|
||||
if (playerTransportGuid_ != 0 && transportManager_) {
|
||||
return transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_);
|
||||
}
|
||||
// Not on transport, return normal movement position
|
||||
return glm::vec3(movementInfo.x, movementInfo.y, movementInfo.z);
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
} // namespace wowee
|
||||
|
|
|
|||
|
|
@ -1,889 +0,0 @@
|
|||
#include "game/npc_manager.hpp"
|
||||
#include "game/entity.hpp"
|
||||
#include <unordered_set>
|
||||
#include "core/coordinates.hpp"
|
||||
#include "pipeline/asset_manager.hpp"
|
||||
#include "pipeline/m2_loader.hpp"
|
||||
#include "pipeline/dbc_loader.hpp"
|
||||
#include "rendering/character_renderer.hpp"
|
||||
#include "rendering/terrain_manager.hpp"
|
||||
#include "core/logger.hpp"
|
||||
#include <random>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
|
||||
namespace wowee {
|
||||
namespace game {
|
||||
|
||||
void NpcManager::clear(rendering::CharacterRenderer* cr, EntityManager* em) {
|
||||
for (const auto& npc : npcs) {
|
||||
if (cr) {
|
||||
cr->removeInstance(npc.renderInstanceId);
|
||||
}
|
||||
if (em) {
|
||||
em->removeEntity(npc.guid);
|
||||
}
|
||||
}
|
||||
npcs.clear();
|
||||
loadedModels.clear();
|
||||
}
|
||||
|
||||
// Random emote animation IDs (humanoid only)
|
||||
static const uint32_t EMOTE_ANIMS[] = { 60, 66, 67, 70 }; // Talk, Bow, Wave, Laugh
|
||||
static constexpr int NUM_EMOTE_ANIMS = 4;
|
||||
|
||||
static float randomFloat(float lo, float hi) {
|
||||
static std::mt19937 rng(std::random_device{}());
|
||||
std::uniform_real_distribution<float> dist(lo, hi);
|
||||
return dist(rng);
|
||||
}
|
||||
|
||||
static std::string toLowerStr(const std::string& s) {
|
||||
std::string out = s;
|
||||
for (char& c : out) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
return out;
|
||||
}
|
||||
|
||||
static std::string trim(const std::string& s) {
|
||||
size_t b = 0;
|
||||
while (b < s.size() && std::isspace(static_cast<unsigned char>(s[b]))) b++;
|
||||
size_t e = s.size();
|
||||
while (e > b && std::isspace(static_cast<unsigned char>(s[e - 1]))) e--;
|
||||
return s.substr(b, e - b);
|
||||
}
|
||||
|
||||
static std::string normalizeMapName(const std::string& raw) {
|
||||
std::string n = toLowerStr(trim(raw));
|
||||
n.erase(std::remove_if(n.begin(), n.end(), [](char c) { return c == ' ' || c == '_'; }), n.end());
|
||||
return n;
|
||||
}
|
||||
|
||||
static bool mapNamesEquivalent(const std::string& a, const std::string& b) {
|
||||
std::string na = normalizeMapName(a);
|
||||
std::string nb = normalizeMapName(b);
|
||||
if (na == nb) return true;
|
||||
// Azeroth world aliases seen across systems/UI.
|
||||
auto isAzerothAlias = [](const std::string& n) {
|
||||
return n == "azeroth" || n == "easternkingdoms" || n == "easternkingdom";
|
||||
};
|
||||
return isAzerothAlias(na) && isAzerothAlias(nb);
|
||||
}
|
||||
|
||||
static bool parseVec2Csv(const char* raw, float& x, float& y) {
|
||||
if (!raw || !*raw) return false;
|
||||
std::string s(raw);
|
||||
std::replace(s.begin(), s.end(), ';', ',');
|
||||
std::stringstream ss(s);
|
||||
std::string a, b;
|
||||
if (!std::getline(ss, a, ',')) return false;
|
||||
if (!std::getline(ss, b, ',')) return false;
|
||||
try {
|
||||
x = std::stof(trim(a));
|
||||
y = std::stof(trim(b));
|
||||
return true;
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static bool parseFloatEnv(const char* raw, float& out) {
|
||||
if (!raw || !*raw) return false;
|
||||
try {
|
||||
out = std::stof(trim(raw));
|
||||
return true;
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static int mapNameToId(const std::string& mapName) {
|
||||
std::string n = normalizeMapName(mapName);
|
||||
if (n == "azeroth" || n == "easternkingdoms" || n == "easternkingdom") return 0;
|
||||
if (n == "kalimdor") return 1;
|
||||
if (n == "outland" || n == "expansion01") return 530;
|
||||
if (n == "northrend") return 571;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static bool parseInsertTuples(const std::string& line, std::vector<std::string>& outTuples) {
|
||||
outTuples.clear();
|
||||
size_t valuesPos = line.find("VALUES");
|
||||
if (valuesPos == std::string::npos) valuesPos = line.find("values");
|
||||
if (valuesPos == std::string::npos) return false;
|
||||
|
||||
bool inQuote = false;
|
||||
int depth = 0;
|
||||
size_t tupleStart = std::string::npos;
|
||||
for (size_t i = valuesPos; i < line.size(); i++) {
|
||||
char c = line[i];
|
||||
if (c == '\'' && (i == 0 || line[i - 1] != '\\')) inQuote = !inQuote;
|
||||
if (inQuote) continue;
|
||||
if (c == '(') {
|
||||
if (depth == 0) tupleStart = i + 1;
|
||||
depth++;
|
||||
} else if (c == ')') {
|
||||
depth--;
|
||||
if (depth == 0 && tupleStart != std::string::npos && i > tupleStart) {
|
||||
outTuples.push_back(line.substr(tupleStart, i - tupleStart));
|
||||
tupleStart = std::string::npos;
|
||||
}
|
||||
}
|
||||
}
|
||||
return !outTuples.empty();
|
||||
}
|
||||
|
||||
static std::vector<std::string> splitCsvTuple(const std::string& tuple) {
|
||||
std::vector<std::string> cols;
|
||||
std::string cur;
|
||||
bool inQuote = false;
|
||||
for (size_t i = 0; i < tuple.size(); i++) {
|
||||
char c = tuple[i];
|
||||
if (c == '\'' && (i == 0 || tuple[i - 1] != '\\')) {
|
||||
inQuote = !inQuote;
|
||||
cur.push_back(c);
|
||||
continue;
|
||||
}
|
||||
if (c == ',' && !inQuote) {
|
||||
cols.push_back(trim(cur));
|
||||
cur.clear();
|
||||
continue;
|
||||
}
|
||||
cur.push_back(c);
|
||||
}
|
||||
if (!cur.empty()) cols.push_back(trim(cur));
|
||||
return cols;
|
||||
}
|
||||
|
||||
static std::string unquoteSqlString(const std::string& s) {
|
||||
if (s.size() >= 2 && s.front() == '\'' && s.back() == '\'') {
|
||||
return s.substr(1, s.size() - 2);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
static glm::vec3 toCanonicalSpawn(const NpcSpawnDef& s, bool swapXY, float rotDeg,
|
||||
float pivotX, float pivotY, float dx, float dy) {
|
||||
glm::vec3 canonical = s.inputIsServerCoords
|
||||
? core::coords::serverToCanonical(s.canonicalPosition)
|
||||
: s.canonicalPosition;
|
||||
if (swapXY) std::swap(canonical.x, canonical.y);
|
||||
|
||||
if (std::abs(rotDeg) > 0.001f) {
|
||||
float rad = rotDeg * (3.1415926535f / 180.0f);
|
||||
float c = std::cos(rad);
|
||||
float s = std::sin(rad);
|
||||
float x = canonical.x - pivotX;
|
||||
float y = canonical.y - pivotY;
|
||||
canonical.x = pivotX + x * c - y * s;
|
||||
canonical.y = pivotY + x * s + y * c;
|
||||
}
|
||||
|
||||
canonical.x += dx;
|
||||
canonical.y += dy;
|
||||
return canonical;
|
||||
}
|
||||
|
||||
// Look up texture variants for a creature M2 using CreatureDisplayInfo.dbc
|
||||
// Returns up to 3 texture variant names (for type 1, 2, 3 texture slots)
|
||||
static std::vector<std::string> lookupTextureVariants(
|
||||
pipeline::AssetManager* am, const std::string& m2Path) {
|
||||
std::vector<std::string> variants;
|
||||
|
||||
auto modelDataDbc = am->loadDBC("CreatureModelData.dbc");
|
||||
auto displayInfoDbc = am->loadDBC("CreatureDisplayInfo.dbc");
|
||||
if (!modelDataDbc || !displayInfoDbc) return variants;
|
||||
|
||||
// CreatureModelData stores .mdx paths; convert our .m2 path for matching
|
||||
std::string mdxPath = m2Path;
|
||||
if (mdxPath.size() > 3) {
|
||||
mdxPath = mdxPath.substr(0, mdxPath.size() - 3) + ".mdx";
|
||||
}
|
||||
std::string mdxLower = toLowerStr(mdxPath);
|
||||
|
||||
// Find model ID from CreatureModelData (col 0 = ID, col 2 = modelName)
|
||||
uint32_t creatureModelId = 0;
|
||||
for (uint32_t r = 0; r < modelDataDbc->getRecordCount(); r++) {
|
||||
std::string dbcModel = modelDataDbc->getString(r, 2);
|
||||
if (toLowerStr(dbcModel) == mdxLower) {
|
||||
creatureModelId = modelDataDbc->getUInt32(r, 0);
|
||||
LOG_INFO("NpcManager: DBC match for '", m2Path,
|
||||
"' -> CreatureModelData ID ", creatureModelId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (creatureModelId == 0) return variants;
|
||||
|
||||
// Find first CreatureDisplayInfo entry for this model
|
||||
// Col 0=ID, 1=ModelID, 6=TextureVariation_1, 7=TextureVariation_2, 8=TextureVariation_3
|
||||
for (uint32_t r = 0; r < displayInfoDbc->getRecordCount(); r++) {
|
||||
if (displayInfoDbc->getUInt32(r, 1) == creatureModelId) {
|
||||
std::string v1 = displayInfoDbc->getString(r, 6);
|
||||
std::string v2 = displayInfoDbc->getString(r, 7);
|
||||
std::string v3 = displayInfoDbc->getString(r, 8);
|
||||
if (!v1.empty()) variants.push_back(v1);
|
||||
if (!v2.empty()) variants.push_back(v2);
|
||||
if (!v3.empty()) variants.push_back(v3);
|
||||
LOG_INFO("NpcManager: DisplayInfo textures: '", v1, "', '", v2, "', '", v3, "'");
|
||||
break;
|
||||
}
|
||||
}
|
||||
return variants;
|
||||
}
|
||||
|
||||
void NpcManager::loadCreatureModel(pipeline::AssetManager* am,
|
||||
rendering::CharacterRenderer* cr,
|
||||
const std::string& m2Path,
|
||||
uint32_t modelId) {
|
||||
auto m2Data = am->readFile(m2Path);
|
||||
if (m2Data.empty()) {
|
||||
LOG_WARNING("NpcManager: failed to read M2 file: ", m2Path);
|
||||
return;
|
||||
}
|
||||
|
||||
auto model = pipeline::M2Loader::load(m2Data);
|
||||
|
||||
// Derive skin path: replace .m2 with 00.skin
|
||||
std::string skinPath = m2Path;
|
||||
if (skinPath.size() > 3) {
|
||||
skinPath = skinPath.substr(0, skinPath.size() - 3) + "00.skin";
|
||||
}
|
||||
auto skinData = am->readFile(skinPath);
|
||||
if (!skinData.empty()) {
|
||||
pipeline::M2Loader::loadSkin(skinData, model);
|
||||
}
|
||||
|
||||
if (!model.isValid()) {
|
||||
LOG_WARNING("NpcManager: invalid model: ", m2Path);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load external .anim files for sequences without flag 0x20
|
||||
std::string basePath = m2Path.substr(0, m2Path.size() - 3); // remove ".m2"
|
||||
for (uint32_t si = 0; si < model.sequences.size(); si++) {
|
||||
if (!(model.sequences[si].flags & 0x20)) {
|
||||
char animFileName[256];
|
||||
snprintf(animFileName, sizeof(animFileName),
|
||||
"%s%04u-%02u.anim",
|
||||
basePath.c_str(),
|
||||
model.sequences[si].id,
|
||||
model.sequences[si].variationIndex);
|
||||
auto animFileData = am->readFile(animFileName);
|
||||
if (!animFileData.empty()) {
|
||||
pipeline::M2Loader::loadAnimFile(m2Data, animFileData, si, model);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Resolve creature skin textures ---
|
||||
// Extract model directory: "Creature\Wolf\" from "Creature\Wolf\Wolf.m2"
|
||||
size_t lastSlash = m2Path.find_last_of("\\/");
|
||||
std::string modelDir = (lastSlash != std::string::npos)
|
||||
? m2Path.substr(0, lastSlash + 1) : "";
|
||||
|
||||
// Extract model base name: "Wolf" from "Creature\Wolf\Wolf.m2"
|
||||
std::string modelFileName = (lastSlash != std::string::npos)
|
||||
? m2Path.substr(lastSlash + 1) : m2Path;
|
||||
std::string modelBaseName = modelFileName.substr(0, modelFileName.size() - 3); // remove ".m2"
|
||||
|
||||
// Log existing texture info
|
||||
for (size_t ti = 0; ti < model.textures.size(); ti++) {
|
||||
LOG_INFO("NpcManager: ", m2Path, " tex[", ti, "] type=",
|
||||
model.textures[ti].type, " file='", model.textures[ti].filename, "'");
|
||||
}
|
||||
|
||||
// Check if any textures need resolution
|
||||
// Type 11 = creature skin 1, type 12 = creature skin 2, type 13 = creature skin 3
|
||||
// Type 1 = character body skin (also possible on some creature models)
|
||||
auto needsResolve = [](uint32_t t) {
|
||||
return t == 11 || t == 12 || t == 13 || t == 1 || t == 2 || t == 3;
|
||||
};
|
||||
|
||||
bool needsVariants = false;
|
||||
for (const auto& tex : model.textures) {
|
||||
if (needsResolve(tex.type) && tex.filename.empty()) {
|
||||
needsVariants = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (needsVariants) {
|
||||
// Try DBC-based lookup first
|
||||
auto variants = lookupTextureVariants(am, m2Path);
|
||||
|
||||
// Fill in unresolved textures from DBC variants
|
||||
// Creature skin types map: type 11 -> variant[0], type 12 -> variant[1], type 13 -> variant[2]
|
||||
// Also type 1 -> variant[0] as fallback
|
||||
for (auto& tex : model.textures) {
|
||||
if (!needsResolve(tex.type) || !tex.filename.empty()) continue;
|
||||
|
||||
// Determine which variant index this texture type maps to
|
||||
size_t varIdx = 0;
|
||||
if (tex.type == 11 || tex.type == 1) varIdx = 0;
|
||||
else if (tex.type == 12 || tex.type == 2) varIdx = 1;
|
||||
else if (tex.type == 13 || tex.type == 3) varIdx = 2;
|
||||
|
||||
std::string resolved;
|
||||
|
||||
if (varIdx < variants.size() && !variants[varIdx].empty()) {
|
||||
// DBC variant: <ModelDir>\<Variant>.blp
|
||||
resolved = modelDir + variants[varIdx] + ".blp";
|
||||
if (!am->fileExists(resolved)) {
|
||||
LOG_WARNING("NpcManager: DBC texture not found: ", resolved);
|
||||
resolved.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback heuristics if DBC didn't provide a texture
|
||||
if (resolved.empty()) {
|
||||
// Try <ModelDir>\<ModelName>Skin.blp
|
||||
std::string skinTry = modelDir + modelBaseName + "Skin.blp";
|
||||
if (am->fileExists(skinTry)) {
|
||||
resolved = skinTry;
|
||||
} else {
|
||||
// Try <ModelDir>\<ModelName>.blp
|
||||
std::string altTry = modelDir + modelBaseName + ".blp";
|
||||
if (am->fileExists(altTry)) {
|
||||
resolved = altTry;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolved.empty()) {
|
||||
tex.filename = resolved;
|
||||
LOG_INFO("NpcManager: resolved type-", tex.type,
|
||||
" texture -> '", resolved, "'");
|
||||
} else {
|
||||
LOG_WARNING("NpcManager: could not resolve type-", tex.type,
|
||||
" texture for ", m2Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cr->loadModel(model, modelId);
|
||||
LOG_INFO("NpcManager: loaded model id=", modelId, " path=", m2Path,
|
||||
" verts=", model.vertices.size(), " bones=", model.bones.size(),
|
||||
" anims=", model.sequences.size(), " textures=", model.textures.size());
|
||||
}
|
||||
|
||||
std::vector<NpcSpawnDef> NpcManager::loadSpawnDefsFromFile(const std::string& path) const {
|
||||
std::vector<NpcSpawnDef> out;
|
||||
std::string resolvedPath;
|
||||
const std::string candidates[] = {
|
||||
path,
|
||||
"./" + path,
|
||||
"../" + path,
|
||||
"../../" + path,
|
||||
"../../../" + path
|
||||
};
|
||||
for (const auto& c : candidates) {
|
||||
if (std::filesystem::exists(c)) {
|
||||
resolvedPath = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (resolvedPath.empty()) {
|
||||
// Try relative to executable location.
|
||||
std::error_code ec;
|
||||
std::filesystem::path exe = std::filesystem::read_symlink("/proc/self/exe", ec);
|
||||
if (!ec) {
|
||||
std::filesystem::path dir = exe.parent_path();
|
||||
for (int i = 0; i < 5 && !dir.empty(); i++) {
|
||||
std::filesystem::path candidate = dir / path;
|
||||
if (std::filesystem::exists(candidate)) {
|
||||
resolvedPath = candidate.string();
|
||||
break;
|
||||
}
|
||||
dir = dir.parent_path();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedPath.empty()) {
|
||||
LOG_WARNING("NpcManager: spawn CSV not found at ", path, " (or nearby relative paths)");
|
||||
return out;
|
||||
}
|
||||
|
||||
std::ifstream in(resolvedPath);
|
||||
if (!in.is_open()) return out;
|
||||
|
||||
std::string line;
|
||||
int lineNo = 0;
|
||||
while (std::getline(in, line)) {
|
||||
lineNo++;
|
||||
line = trim(line);
|
||||
if (line.empty() || line[0] == '#') continue;
|
||||
|
||||
std::vector<std::string> cols;
|
||||
std::stringstream ss(line);
|
||||
std::string tok;
|
||||
while (std::getline(ss, tok, ',')) {
|
||||
cols.push_back(trim(tok));
|
||||
}
|
||||
|
||||
if (cols.size() != 11 && cols.size() != 12) {
|
||||
LOG_WARNING("NpcManager: bad NPC CSV row at ", resolvedPath, ":", lineNo,
|
||||
" (expected 11 or 12 columns)");
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
NpcSpawnDef def;
|
||||
def.mapName = cols[0];
|
||||
def.name = cols[1];
|
||||
def.m2Path = cols[2];
|
||||
def.level = static_cast<uint32_t>(std::stoul(cols[3]));
|
||||
def.health = static_cast<uint32_t>(std::stoul(cols[4]));
|
||||
def.canonicalPosition.x = std::stof(cols[5]);
|
||||
def.canonicalPosition.y = std::stof(cols[6]);
|
||||
def.canonicalPosition.z = std::stof(cols[7]);
|
||||
def.rotation = std::stof(cols[8]);
|
||||
def.scale = std::stof(cols[9]);
|
||||
def.isCritter = (cols[10] == "1" || toLowerStr(cols[10]) == "true");
|
||||
if (cols.size() == 12) {
|
||||
std::string space = toLowerStr(cols[11]);
|
||||
def.inputIsServerCoords = (space == "server" || space == "wire");
|
||||
}
|
||||
|
||||
if (def.mapName.empty() || def.name.empty() || def.m2Path.empty()) continue;
|
||||
out.push_back(std::move(def));
|
||||
} catch (const std::exception&) {
|
||||
LOG_WARNING("NpcManager: failed parsing NPC CSV row at ", resolvedPath, ":", lineNo);
|
||||
}
|
||||
}
|
||||
|
||||
LOG_INFO("NpcManager: loaded ", out.size(), " spawn defs from ", resolvedPath);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<NpcSpawnDef> NpcManager::loadSpawnDefsFromAzerothCoreDb(
|
||||
const std::string& basePath,
|
||||
const std::string& mapName,
|
||||
const glm::vec3& playerCanonical,
|
||||
pipeline::AssetManager* am) const {
|
||||
std::vector<NpcSpawnDef> out;
|
||||
if (!am) return out;
|
||||
|
||||
std::filesystem::path base(basePath);
|
||||
std::filesystem::path creaturePath = base / "creature.sql";
|
||||
std::filesystem::path tmplPath = base / "creature_template.sql";
|
||||
if (!std::filesystem::exists(creaturePath) || !std::filesystem::exists(tmplPath)) {
|
||||
// Allow passing .../sql or repo root as WOW_DB_BASE_PATH.
|
||||
std::filesystem::path alt = base / "base";
|
||||
if (std::filesystem::exists(alt / "creature.sql") && std::filesystem::exists(alt / "creature_template.sql")) {
|
||||
base = alt;
|
||||
creaturePath = base / "creature.sql";
|
||||
tmplPath = base / "creature_template.sql";
|
||||
} else {
|
||||
alt = base / "sql" / "base";
|
||||
if (std::filesystem::exists(alt / "creature.sql") && std::filesystem::exists(alt / "creature_template.sql")) {
|
||||
base = alt;
|
||||
creaturePath = base / "creature.sql";
|
||||
tmplPath = base / "creature_template.sql";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!std::filesystem::exists(creaturePath) || !std::filesystem::exists(tmplPath)) {
|
||||
return out;
|
||||
}
|
||||
|
||||
struct TemplateRow {
|
||||
std::string name;
|
||||
uint32_t level = 1;
|
||||
uint32_t health = 100;
|
||||
std::string m2Path;
|
||||
uint32_t faction = 0;
|
||||
uint32_t npcFlags = 0;
|
||||
};
|
||||
std::unordered_map<uint32_t, TemplateRow> templates;
|
||||
|
||||
// Build displayId -> modelId lookup.
|
||||
std::unordered_map<uint32_t, uint32_t> displayToModel;
|
||||
if (auto cdi = am->loadDBC("CreatureDisplayInfo.dbc"); cdi && cdi->isLoaded()) {
|
||||
for (uint32_t i = 0; i < cdi->getRecordCount(); i++) {
|
||||
displayToModel[cdi->getUInt32(i, 0)] = cdi->getUInt32(i, 1);
|
||||
}
|
||||
}
|
||||
std::unordered_map<uint32_t, std::string> modelToPath;
|
||||
if (auto cmd = am->loadDBC("CreatureModelData.dbc"); cmd && cmd->isLoaded()) {
|
||||
for (uint32_t i = 0; i < cmd->getRecordCount(); i++) {
|
||||
std::string mdx = cmd->getString(i, 2);
|
||||
if (mdx.empty()) continue;
|
||||
std::string p = mdx;
|
||||
if (p.size() >= 4) p = p.substr(0, p.size() - 4) + ".m2";
|
||||
modelToPath[cmd->getUInt32(i, 0)] = p;
|
||||
}
|
||||
}
|
||||
|
||||
auto processInsertStatements =
|
||||
[](std::ifstream& in, const std::function<bool(const std::vector<std::string>&)>& onTuple) {
|
||||
std::string line;
|
||||
std::string stmt;
|
||||
std::vector<std::string> tuples;
|
||||
while (std::getline(in, line)) {
|
||||
if (stmt.empty()) {
|
||||
// Skip non-INSERT lines early.
|
||||
if (line.find("INSERT INTO") == std::string::npos &&
|
||||
line.find("insert into") == std::string::npos) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!stmt.empty()) stmt.push_back('\n');
|
||||
stmt += line;
|
||||
if (line.find(';') == std::string::npos) continue;
|
||||
|
||||
if (parseInsertTuples(stmt, tuples)) {
|
||||
for (const auto& t : tuples) {
|
||||
if (!onTuple(splitCsvTuple(t))) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
stmt.clear();
|
||||
}
|
||||
};
|
||||
|
||||
// Parse creature_template.sql: entry, modelid1(displayId), name, minlevel, faction, npcflag.
|
||||
{
|
||||
std::ifstream in(tmplPath);
|
||||
processInsertStatements(in, [&](const std::vector<std::string>& cols) {
|
||||
if (cols.size() < 19) return true;
|
||||
try {
|
||||
uint32_t entry = static_cast<uint32_t>(std::stoul(cols[0]));
|
||||
uint32_t displayId = static_cast<uint32_t>(std::stoul(cols[6]));
|
||||
std::string name = unquoteSqlString(cols[10]);
|
||||
uint32_t minLevel = static_cast<uint32_t>(std::stoul(cols[14]));
|
||||
uint32_t faction = static_cast<uint32_t>(std::stoul(cols[17]));
|
||||
uint32_t npcflag = static_cast<uint32_t>(std::stoul(cols[18]));
|
||||
TemplateRow tr;
|
||||
tr.name = name.empty() ? ("Creature " + std::to_string(entry)) : name;
|
||||
tr.level = std::max(1u, minLevel);
|
||||
tr.health = 150 + tr.level * 35;
|
||||
tr.faction = faction;
|
||||
tr.npcFlags = npcflag;
|
||||
auto itModel = displayToModel.find(displayId);
|
||||
if (itModel != displayToModel.end()) {
|
||||
auto itPath = modelToPath.find(itModel->second);
|
||||
if (itPath != modelToPath.end()) tr.m2Path = itPath->second;
|
||||
}
|
||||
templates[entry] = std::move(tr);
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
int targetMap = mapNameToId(mapName);
|
||||
constexpr float kRadius = 2200.0f;
|
||||
constexpr size_t kMaxSpawns = 220;
|
||||
std::ifstream in(creaturePath);
|
||||
processInsertStatements(in, [&](const std::vector<std::string>& cols) {
|
||||
if (cols.size() < 16) return true;
|
||||
try {
|
||||
uint32_t entry = static_cast<uint32_t>(std::stoul(cols[1]));
|
||||
int mapId = static_cast<int>(std::stol(cols[2]));
|
||||
if (mapId != targetMap) return true;
|
||||
|
||||
float sx = std::stof(cols[7]);
|
||||
float sy = std::stof(cols[8]);
|
||||
float sz = std::stof(cols[9]);
|
||||
float o = std::stof(cols[10]);
|
||||
uint32_t curhealth = static_cast<uint32_t>(std::stoul(cols[14]));
|
||||
|
||||
// AzerothCore DB uses client/canonical coordinates.
|
||||
glm::vec3 canonical = glm::vec3(sx, sy, sz);
|
||||
float dx = canonical.x - playerCanonical.x;
|
||||
float dy = canonical.y - playerCanonical.y;
|
||||
if (dx * dx + dy * dy > kRadius * kRadius) return true;
|
||||
|
||||
NpcSpawnDef def;
|
||||
def.mapName = mapName;
|
||||
auto it = templates.find(entry);
|
||||
if (it != templates.end()) {
|
||||
def.entry = entry;
|
||||
def.name = it->second.name;
|
||||
def.level = it->second.level;
|
||||
def.health = std::max(it->second.health, curhealth);
|
||||
def.m2Path = it->second.m2Path;
|
||||
def.faction = it->second.faction;
|
||||
def.npcFlags = it->second.npcFlags;
|
||||
} else {
|
||||
def.entry = entry;
|
||||
def.name = "Creature " + std::to_string(entry);
|
||||
def.level = 1;
|
||||
def.health = std::max(100u, curhealth);
|
||||
}
|
||||
if (def.m2Path.empty()) {
|
||||
def.m2Path = "Creature\\HumanMalePeasant\\HumanMalePeasant.m2";
|
||||
}
|
||||
def.canonicalPosition = canonical;
|
||||
def.inputIsServerCoords = false;
|
||||
def.rotation = o;
|
||||
def.scale = 1.0f;
|
||||
def.isCritter = (def.level <= 1 || def.health <= 50);
|
||||
out.push_back(std::move(def));
|
||||
if (out.size() >= kMaxSpawns) return false;
|
||||
} catch (const std::exception&) {
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
LOG_INFO("NpcManager: loaded ", out.size(), " nearby creature spawns from AzerothCore DB at ", basePath);
|
||||
return out;
|
||||
}
|
||||
|
||||
void NpcManager::initialize(pipeline::AssetManager* am,
|
||||
rendering::CharacterRenderer* cr,
|
||||
EntityManager& em,
|
||||
const std::string& mapName,
|
||||
const glm::vec3& playerCanonical,
|
||||
const rendering::TerrainManager* terrainManager) {
|
||||
if (!am || !am->isInitialized() || !cr) {
|
||||
LOG_WARNING("NpcManager: cannot initialize — missing AssetManager or CharacterRenderer");
|
||||
return;
|
||||
}
|
||||
|
||||
float globalDx = 0.0f;
|
||||
float globalDy = 0.0f;
|
||||
bool hasGlobalOffset = parseVec2Csv(std::getenv("WOW_NPC_OFFSET"), globalDx, globalDy);
|
||||
float globalRotDeg = 0.0f;
|
||||
parseFloatEnv(std::getenv("WOW_NPC_ROT_DEG"), globalRotDeg);
|
||||
bool swapXY = false;
|
||||
if (const char* swap = std::getenv("WOW_NPC_SWAP_XY")) {
|
||||
std::string v = toLowerStr(trim(swap));
|
||||
swapXY = (v == "1" || v == "true" || v == "yes");
|
||||
}
|
||||
float pivotX = playerCanonical.x;
|
||||
float pivotY = playerCanonical.y;
|
||||
parseVec2Csv(std::getenv("WOW_NPC_PIVOT"), pivotX, pivotY);
|
||||
|
||||
if (hasGlobalOffset || swapXY || std::abs(globalRotDeg) > 0.001f) {
|
||||
LOG_INFO("NpcManager: transform overrides swapXY=", swapXY,
|
||||
" rotDeg=", globalRotDeg,
|
||||
" pivot=(", pivotX, ", ", pivotY, ")",
|
||||
" offset=(", globalDx, ", ", globalDy, ")");
|
||||
}
|
||||
|
||||
std::vector<NpcSpawnDef> spawnDefs;
|
||||
std::string dbBasePath;
|
||||
if (const char* dbBase = std::getenv("WOW_DB_BASE_PATH")) {
|
||||
dbBasePath = dbBase;
|
||||
} else if (std::filesystem::exists("assets/sql")) {
|
||||
dbBasePath = "assets/sql";
|
||||
}
|
||||
if (!dbBasePath.empty()) {
|
||||
auto dbDefs = loadSpawnDefsFromAzerothCoreDb(dbBasePath, mapName, playerCanonical, am);
|
||||
if (!dbDefs.empty()) spawnDefs = std::move(dbDefs);
|
||||
}
|
||||
if (spawnDefs.empty()) {
|
||||
LOG_WARNING("NpcManager: no spawn defs found (DB required for single-player)");
|
||||
}
|
||||
|
||||
// Spawn only nearby placements on current map.
|
||||
std::vector<const NpcSpawnDef*> active;
|
||||
active.reserve(spawnDefs.size());
|
||||
constexpr float kSpawnRadius = 2200.0f;
|
||||
int mapSkipped = 0;
|
||||
for (const auto& s : spawnDefs) {
|
||||
if (!mapNamesEquivalent(mapName, s.mapName)) {
|
||||
mapSkipped++;
|
||||
continue;
|
||||
}
|
||||
glm::vec3 c = toCanonicalSpawn(s, swapXY, globalRotDeg, pivotX, pivotY, globalDx, globalDy);
|
||||
float distX = c.x - playerCanonical.x;
|
||||
float distY = c.y - playerCanonical.y;
|
||||
if (distX * distX + distY * distY > kSpawnRadius * kSpawnRadius) continue;
|
||||
active.push_back(&s);
|
||||
}
|
||||
|
||||
if (active.empty()) {
|
||||
LOG_INFO("NpcManager: no static NPC placements near player on map ", mapName,
|
||||
" (mapSkipped=", mapSkipped, ")");
|
||||
return;
|
||||
}
|
||||
|
||||
// Load each unique M2 model once
|
||||
for (const auto* s : active) {
|
||||
const std::string path = s->m2Path;
|
||||
if (loadedModels.find(path) == loadedModels.end()) {
|
||||
uint32_t mid = nextModelId++;
|
||||
loadCreatureModel(am, cr, path, mid);
|
||||
loadedModels[path] = mid;
|
||||
}
|
||||
}
|
||||
|
||||
// Build faction hostility lookup from FactionTemplate.dbc + Faction.dbc
|
||||
std::unordered_map<uint32_t, bool> factionHostile;
|
||||
{
|
||||
auto ftDbc = am->loadDBC("FactionTemplate.dbc");
|
||||
auto fDbc = am->loadDBC("Faction.dbc");
|
||||
if (ftDbc && ftDbc->isLoaded()) {
|
||||
// Build hostile parent factions from Faction.dbc base reputation
|
||||
std::unordered_set<uint32_t> hostileParentFactions;
|
||||
if (fDbc && fDbc->isLoaded()) {
|
||||
for (uint32_t i = 0; i < fDbc->getRecordCount(); i++) {
|
||||
uint32_t factionId = fDbc->getUInt32(i, 0);
|
||||
for (int slot = 0; slot < 4; slot++) {
|
||||
uint32_t raceMask = fDbc->getUInt32(i, 2 + slot);
|
||||
if (raceMask & 0x1) { // Human race bit
|
||||
int32_t baseRep = fDbc->getInt32(i, 10 + slot);
|
||||
if (baseRep < 0) hostileParentFactions.insert(factionId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uint32_t playerFriendGroup = 0, playerEnemyGroup = 0, playerFactionId = 0;
|
||||
for (uint32_t i = 0; i < ftDbc->getRecordCount(); i++) {
|
||||
if (ftDbc->getUInt32(i, 0) == 1) {
|
||||
playerFriendGroup = ftDbc->getUInt32(i, 4) | ftDbc->getUInt32(i, 3);
|
||||
playerEnemyGroup = ftDbc->getUInt32(i, 5);
|
||||
playerFactionId = ftDbc->getUInt32(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (uint32_t i = 0; i < ftDbc->getRecordCount(); i++) {
|
||||
uint32_t id = ftDbc->getUInt32(i, 0);
|
||||
uint32_t parentFaction = ftDbc->getUInt32(i, 1);
|
||||
uint32_t factionGroup = ftDbc->getUInt32(i, 3);
|
||||
uint32_t friendGroup = ftDbc->getUInt32(i, 4);
|
||||
uint32_t enemyGroup = ftDbc->getUInt32(i, 5);
|
||||
|
||||
bool hostile = (enemyGroup & playerFriendGroup) != 0
|
||||
|| (factionGroup & playerEnemyGroup) != 0;
|
||||
if (!hostile && (factionGroup & 8) != 0) hostile = true;
|
||||
if (!hostile && playerFactionId > 0) {
|
||||
for (int e = 6; e <= 9; e++) {
|
||||
if (ftDbc->getUInt32(i, e) == playerFactionId) { hostile = true; break; }
|
||||
}
|
||||
}
|
||||
if (!hostile && parentFaction > 0 && hostileParentFactions.count(parentFaction)) {
|
||||
hostile = true;
|
||||
}
|
||||
if (hostile && (friendGroup & playerFriendGroup) != 0) {
|
||||
hostile = false;
|
||||
}
|
||||
factionHostile[id] = hostile;
|
||||
}
|
||||
LOG_INFO("NpcManager: loaded ", ftDbc->getRecordCount(), " faction templates");
|
||||
} else {
|
||||
LOG_WARNING("NpcManager: FactionTemplate.dbc not available, all NPCs default to hostile");
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn each NPC instance
|
||||
for (const auto* sPtr : active) {
|
||||
const auto& s = *sPtr;
|
||||
const std::string path = s.m2Path;
|
||||
|
||||
auto it = loadedModels.find(path);
|
||||
if (it == loadedModels.end()) continue; // model failed to load
|
||||
|
||||
uint32_t modelId = it->second;
|
||||
|
||||
glm::vec3 canonical = toCanonicalSpawn(s, swapXY, globalRotDeg, pivotX, pivotY, globalDx, globalDy);
|
||||
glm::vec3 glPos = core::coords::canonicalToRender(canonical);
|
||||
// Keep authored indoor Z for named NPCs; terrain snap is mainly for critters/outdoor fauna.
|
||||
if (terrainManager && s.isCritter) {
|
||||
if (auto h = terrainManager->getHeightAt(glPos.x, glPos.y)) {
|
||||
glPos.z = *h + 0.05f;
|
||||
}
|
||||
}
|
||||
|
||||
// Create render instance
|
||||
uint32_t instanceId = cr->createInstance(modelId, glPos,
|
||||
glm::vec3(0.0f, 0.0f, s.rotation), s.scale);
|
||||
if (instanceId == 0) {
|
||||
LOG_WARNING("NpcManager: failed to create instance for ", s.name);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Play idle animation (anim ID 0)
|
||||
cr->playAnimation(instanceId, 0, true);
|
||||
|
||||
// Assign unique GUID
|
||||
uint64_t guid = nextGuid++;
|
||||
|
||||
// Create entity in EntityManager
|
||||
auto unit = std::make_shared<Unit>(guid);
|
||||
unit->setName(s.name);
|
||||
unit->setLevel(s.level);
|
||||
unit->setHealth(s.health);
|
||||
unit->setMaxHealth(s.health);
|
||||
if (s.entry != 0) {
|
||||
unit->setEntry(s.entry);
|
||||
}
|
||||
unit->setNpcFlags(s.npcFlags);
|
||||
unit->setFactionTemplate(s.faction);
|
||||
|
||||
// Determine hostility from faction template
|
||||
auto fIt = factionHostile.find(s.faction);
|
||||
unit->setHostile(fIt != factionHostile.end() ? fIt->second : false);
|
||||
|
||||
// Store canonical WoW coordinates for targeting/server compatibility
|
||||
glm::vec3 spawnCanonical = core::coords::renderToCanonical(glPos);
|
||||
unit->setPosition(spawnCanonical.x, spawnCanonical.y, spawnCanonical.z, s.rotation);
|
||||
|
||||
em.addEntity(guid, unit);
|
||||
|
||||
// Track NPC instance
|
||||
NpcInstance npc{};
|
||||
npc.guid = guid;
|
||||
npc.renderInstanceId = instanceId;
|
||||
npc.emoteTimer = randomFloat(5.0f, 15.0f);
|
||||
npc.emoteEndTimer = 0.0f;
|
||||
npc.isEmoting = false;
|
||||
npc.isCritter = s.isCritter;
|
||||
npcs.push_back(npc);
|
||||
}
|
||||
|
||||
LOG_INFO("NpcManager: initialized ", npcs.size(), " NPCs with ",
|
||||
loadedModels.size(), " unique models");
|
||||
}
|
||||
|
||||
uint32_t NpcManager::findRenderInstanceId(uint64_t guid) const {
|
||||
for (const auto& npc : npcs) {
|
||||
if (npc.guid == guid) return npc.renderInstanceId;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void NpcManager::update(float deltaTime, rendering::CharacterRenderer* cr) {
|
||||
if (!cr) return;
|
||||
|
||||
for (auto& npc : npcs) {
|
||||
// Critters just idle — no emotes
|
||||
if (npc.isCritter) continue;
|
||||
|
||||
if (npc.isEmoting) {
|
||||
npc.emoteEndTimer -= deltaTime;
|
||||
if (npc.emoteEndTimer <= 0.0f) {
|
||||
// Return to idle
|
||||
cr->playAnimation(npc.renderInstanceId, 0, true);
|
||||
npc.isEmoting = false;
|
||||
npc.emoteTimer = randomFloat(5.0f, 15.0f);
|
||||
}
|
||||
} else {
|
||||
npc.emoteTimer -= deltaTime;
|
||||
if (npc.emoteTimer <= 0.0f) {
|
||||
// Play random emote
|
||||
int idx = static_cast<int>(randomFloat(0.0f, static_cast<float>(NUM_EMOTE_ANIMS) - 0.01f));
|
||||
uint32_t emoteAnim = EMOTE_ANIMS[idx];
|
||||
cr->playAnimation(npc.renderInstanceId, emoteAnim, false);
|
||||
npc.isEmoting = true;
|
||||
npc.emoteEndTimer = randomFloat(2.0f, 4.0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
} // namespace wowee
|
||||
297
src/game/transport_manager.cpp
Normal file
297
src/game/transport_manager.cpp
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
#include "game/transport_manager.hpp"
|
||||
#include "rendering/wmo_renderer.hpp"
|
||||
#include <glm/gtc/matrix_transform.hpp>
|
||||
#include <glm/gtx/quaternion.hpp>
|
||||
#include <cmath>
|
||||
#include <iostream>
|
||||
|
||||
namespace wowee::game {
|
||||
|
||||
TransportManager::TransportManager() = default;
|
||||
TransportManager::~TransportManager() = default;
|
||||
|
||||
void TransportManager::update(float deltaTime) {
|
||||
for (auto& [guid, transport] : transports_) {
|
||||
updateTransportMovement(transport, deltaTime);
|
||||
}
|
||||
}
|
||||
|
||||
void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId, uint32_t pathId) {
|
||||
auto pathIt = paths_.find(pathId);
|
||||
if (pathIt == paths_.end()) {
|
||||
std::cerr << "TransportManager: Path " << pathId << " not found for transport " << guid << std::endl;
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& path = pathIt->second;
|
||||
if (path.waypoints.empty()) {
|
||||
std::cerr << "TransportManager: Path " << pathId << " has no waypoints" << std::endl;
|
||||
return;
|
||||
}
|
||||
|
||||
ActiveTransport transport;
|
||||
transport.guid = guid;
|
||||
transport.wmoInstanceId = wmoInstanceId;
|
||||
transport.pathId = pathId;
|
||||
transport.currentSegment = 0;
|
||||
transport.segmentProgress = 0.0f;
|
||||
transport.position = path.waypoints[0];
|
||||
transport.rotation = glm::quat(1.0f, 0.0f, 0.0f, 0.0f); // Identity quaternion
|
||||
transport.playerOnBoard = false;
|
||||
transport.playerLocalOffset = glm::vec3(0.0f);
|
||||
transport.hasDeckBounds = false;
|
||||
|
||||
updateTransformMatrices(transport);
|
||||
|
||||
transports_[guid] = transport;
|
||||
|
||||
std::cout << "TransportManager: Registered transport " << guid
|
||||
<< " at path " << pathId << " with " << path.waypoints.size() << " waypoints" << std::endl;
|
||||
}
|
||||
|
||||
void TransportManager::unregisterTransport(uint64_t guid) {
|
||||
transports_.erase(guid);
|
||||
std::cout << "TransportManager: Unregistered transport " << guid << std::endl;
|
||||
}
|
||||
|
||||
ActiveTransport* TransportManager::getTransport(uint64_t guid) {
|
||||
auto it = transports_.find(guid);
|
||||
if (it != transports_.end()) {
|
||||
return &it->second;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
glm::vec3 TransportManager::getPlayerWorldPosition(uint64_t transportGuid, const glm::vec3& localOffset) {
|
||||
auto* transport = getTransport(transportGuid);
|
||||
if (!transport) {
|
||||
return localOffset; // Fallback
|
||||
}
|
||||
|
||||
glm::vec4 localPos(localOffset, 1.0f);
|
||||
glm::vec4 worldPos = transport->transform * localPos;
|
||||
return glm::vec3(worldPos);
|
||||
}
|
||||
|
||||
glm::mat4 TransportManager::getTransportInvTransform(uint64_t transportGuid) {
|
||||
auto* transport = getTransport(transportGuid);
|
||||
if (!transport) {
|
||||
return glm::mat4(1.0f); // Identity fallback
|
||||
}
|
||||
return transport->invTransform;
|
||||
}
|
||||
|
||||
void TransportManager::loadPathFromNodes(uint32_t pathId, const std::vector<glm::vec3>& waypoints, bool looping, float speed) {
|
||||
if (waypoints.empty()) {
|
||||
std::cerr << "TransportManager: Cannot load empty path " << pathId << std::endl;
|
||||
return;
|
||||
}
|
||||
|
||||
TransportPath path;
|
||||
path.pathId = pathId;
|
||||
path.waypoints = waypoints;
|
||||
path.looping = looping;
|
||||
path.speed = speed;
|
||||
|
||||
paths_[pathId] = path;
|
||||
|
||||
std::cout << "TransportManager: Loaded path " << pathId
|
||||
<< " with " << waypoints.size() << " waypoints, "
|
||||
<< "looping=" << looping << ", speed=" << speed << std::endl;
|
||||
}
|
||||
|
||||
void TransportManager::setDeckBounds(uint64_t guid, const glm::vec3& min, const glm::vec3& max) {
|
||||
auto* transport = getTransport(guid);
|
||||
if (!transport) {
|
||||
std::cerr << "TransportManager: Cannot set deck bounds for unknown transport " << guid << std::endl;
|
||||
return;
|
||||
}
|
||||
|
||||
transport->deckMin = min;
|
||||
transport->deckMax = max;
|
||||
transport->hasDeckBounds = true;
|
||||
}
|
||||
|
||||
void TransportManager::updateTransportMovement(ActiveTransport& transport, float deltaTime) {
|
||||
auto pathIt = paths_.find(transport.pathId);
|
||||
if (pathIt == paths_.end()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& path = pathIt->second;
|
||||
if (path.waypoints.size() < 2) {
|
||||
return; // Need at least 2 waypoints to move
|
||||
}
|
||||
|
||||
// Calculate segment length
|
||||
glm::vec3 p0 = path.waypoints[transport.currentSegment];
|
||||
size_t nextIdx = (transport.currentSegment + 1) % path.waypoints.size();
|
||||
glm::vec3 p1 = path.waypoints[nextIdx];
|
||||
float segmentLength = glm::distance(p0, p1);
|
||||
|
||||
if (segmentLength < 0.001f) {
|
||||
// Zero-length segment, skip to next
|
||||
transport.currentSegment = nextIdx;
|
||||
transport.segmentProgress = 0.0f;
|
||||
return;
|
||||
}
|
||||
|
||||
// Update progress
|
||||
float distanceThisFrame = path.speed * deltaTime;
|
||||
transport.segmentProgress += distanceThisFrame;
|
||||
|
||||
// Check if we've completed this segment
|
||||
while (transport.segmentProgress >= segmentLength) {
|
||||
transport.segmentProgress -= segmentLength;
|
||||
transport.currentSegment = nextIdx;
|
||||
|
||||
// Check for path completion
|
||||
if (!path.looping && transport.currentSegment >= path.waypoints.size() - 1) {
|
||||
// Reached end of non-looping path
|
||||
transport.currentSegment = path.waypoints.size() - 1;
|
||||
transport.segmentProgress = 0.0f;
|
||||
transport.position = path.waypoints[transport.currentSegment];
|
||||
updateTransformMatrices(transport);
|
||||
return;
|
||||
}
|
||||
|
||||
// Update for next segment
|
||||
p0 = path.waypoints[transport.currentSegment];
|
||||
nextIdx = (transport.currentSegment + 1) % path.waypoints.size();
|
||||
p1 = path.waypoints[nextIdx];
|
||||
segmentLength = glm::distance(p0, p1);
|
||||
|
||||
if (segmentLength < 0.001f) {
|
||||
transport.segmentProgress = 0.0f;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Interpolate position
|
||||
float t = transport.segmentProgress / segmentLength;
|
||||
transport.position = interpolatePath(path, transport.currentSegment, t);
|
||||
|
||||
// Calculate orientation from path tangent
|
||||
transport.rotation = calculateOrientation(path, transport.currentSegment, t);
|
||||
|
||||
// Update transform matrices
|
||||
updateTransformMatrices(transport);
|
||||
|
||||
// Update WMO instance position
|
||||
if (wmoRenderer_) {
|
||||
wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform);
|
||||
}
|
||||
}
|
||||
|
||||
glm::vec3 TransportManager::interpolatePath(const TransportPath& path, size_t segmentIdx, float t) {
|
||||
// Catmull-Rom spline interpolation (same as taxi flights)
|
||||
size_t numPoints = path.waypoints.size();
|
||||
|
||||
// Get 4 control points for Catmull-Rom
|
||||
size_t p0Idx = (segmentIdx == 0) ? (path.looping ? numPoints - 1 : 0) : segmentIdx - 1;
|
||||
size_t p1Idx = segmentIdx;
|
||||
size_t p2Idx = (segmentIdx + 1) % numPoints;
|
||||
size_t p3Idx = (segmentIdx + 2) % numPoints;
|
||||
|
||||
// If non-looping and at boundaries, clamp indices
|
||||
if (!path.looping) {
|
||||
if (segmentIdx == 0) p0Idx = 0;
|
||||
if (segmentIdx >= numPoints - 2) p3Idx = numPoints - 1;
|
||||
if (segmentIdx >= numPoints - 1) p2Idx = numPoints - 1;
|
||||
}
|
||||
|
||||
glm::vec3 p0 = path.waypoints[p0Idx];
|
||||
glm::vec3 p1 = path.waypoints[p1Idx];
|
||||
glm::vec3 p2 = path.waypoints[p2Idx];
|
||||
glm::vec3 p3 = path.waypoints[p3Idx];
|
||||
|
||||
// Catmull-Rom spline formula
|
||||
float t2 = t * t;
|
||||
float t3 = t2 * t;
|
||||
|
||||
glm::vec3 result = 0.5f * (
|
||||
(2.0f * p1) +
|
||||
(-p0 + p2) * t +
|
||||
(2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * t2 +
|
||||
(-p0 + 3.0f * p1 - 3.0f * p2 + p3) * t3
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
glm::quat TransportManager::calculateOrientation(const TransportPath& path, size_t segmentIdx, float t) {
|
||||
// Calculate tangent vector for orientation
|
||||
size_t numPoints = path.waypoints.size();
|
||||
|
||||
// Get 4 control points
|
||||
size_t p0Idx = (segmentIdx == 0) ? (path.looping ? numPoints - 1 : 0) : segmentIdx - 1;
|
||||
size_t p1Idx = segmentIdx;
|
||||
size_t p2Idx = (segmentIdx + 1) % numPoints;
|
||||
size_t p3Idx = (segmentIdx + 2) % numPoints;
|
||||
|
||||
if (!path.looping) {
|
||||
if (segmentIdx == 0) p0Idx = 0;
|
||||
if (segmentIdx >= numPoints - 2) p3Idx = numPoints - 1;
|
||||
if (segmentIdx >= numPoints - 1) p2Idx = numPoints - 1;
|
||||
}
|
||||
|
||||
glm::vec3 p0 = path.waypoints[p0Idx];
|
||||
glm::vec3 p1 = path.waypoints[p1Idx];
|
||||
glm::vec3 p2 = path.waypoints[p2Idx];
|
||||
glm::vec3 p3 = path.waypoints[p3Idx];
|
||||
|
||||
// Tangent of Catmull-Rom spline (derivative)
|
||||
float t2 = t * t;
|
||||
glm::vec3 tangent = 0.5f * (
|
||||
(-p0 + p2) +
|
||||
(2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * 2.0f * t +
|
||||
(-p0 + 3.0f * p1 - 3.0f * p2 + p3) * 3.0f * t2
|
||||
);
|
||||
|
||||
// Normalize tangent
|
||||
float tangentLength = glm::length(tangent);
|
||||
if (tangentLength < 0.001f) {
|
||||
// Fallback to simple direction
|
||||
tangent = p2 - p1;
|
||||
tangentLength = glm::length(tangent);
|
||||
}
|
||||
|
||||
if (tangentLength < 0.001f) {
|
||||
return glm::quat(1.0f, 0.0f, 0.0f, 0.0f); // Identity
|
||||
}
|
||||
|
||||
tangent /= tangentLength;
|
||||
|
||||
// Calculate rotation from forward direction
|
||||
// WoW forward is typically +Y, but we'll use the tangent as forward
|
||||
glm::vec3 forward = tangent;
|
||||
glm::vec3 up(0.0f, 0.0f, 1.0f); // WoW Z is up
|
||||
|
||||
// If forward is nearly vertical, use different up vector
|
||||
if (std::abs(forward.z) > 0.99f) {
|
||||
up = glm::vec3(0.0f, 1.0f, 0.0f);
|
||||
}
|
||||
|
||||
glm::vec3 right = glm::normalize(glm::cross(up, forward));
|
||||
up = glm::cross(forward, right);
|
||||
|
||||
// Build rotation matrix and convert to quaternion
|
||||
glm::mat3 rotMat;
|
||||
rotMat[0] = right;
|
||||
rotMat[1] = forward;
|
||||
rotMat[2] = up;
|
||||
|
||||
return glm::quat_cast(rotMat);
|
||||
}
|
||||
|
||||
void TransportManager::updateTransformMatrices(ActiveTransport& transport) {
|
||||
// Build transform matrix: translate * rotate * scale
|
||||
glm::mat4 translation = glm::translate(glm::mat4(1.0f), transport.position);
|
||||
glm::mat4 rotation = glm::mat4_cast(transport.rotation);
|
||||
glm::mat4 scale = glm::scale(glm::mat4(1.0f), glm::vec3(1.0f)); // No scaling for transports
|
||||
|
||||
transport.transform = translation * rotation * scale;
|
||||
transport.invTransform = glm::inverse(transport.transform);
|
||||
}
|
||||
|
||||
} // namespace wowee::game
|
||||
|
|
@ -39,6 +39,7 @@ bool WaterRenderer::initialize() {
|
|||
uniform float waveAmp;
|
||||
uniform float waveFreq;
|
||||
uniform float waveSpeed;
|
||||
uniform vec3 viewPos;
|
||||
|
||||
out vec3 FragPos;
|
||||
out vec3 Normal;
|
||||
|
|
@ -48,19 +49,30 @@ bool WaterRenderer::initialize() {
|
|||
void main() {
|
||||
vec3 pos = aPos;
|
||||
|
||||
// Pseudo-random phase offsets to break up regular pattern
|
||||
// Distance from camera for LOD blending
|
||||
float dist = length(viewPos - aPos);
|
||||
float gridBlend = smoothstep(150.0, 400.0, dist); // 0=close (seamless), 1=far (grid effect)
|
||||
|
||||
// Seamless waves (continuous across tiles)
|
||||
float w1_seamless = sin((aPos.x + time * waveSpeed) * waveFreq) * waveAmp;
|
||||
float w2_seamless = cos((aPos.y - time * (waveSpeed * 0.78)) * (waveFreq * 0.82)) * (waveAmp * 0.72);
|
||||
float w3_seamless = sin((aPos.x * 1.7 - time * waveSpeed * 1.3 + aPos.y * 0.3) * waveFreq * 2.1) * (waveAmp * 0.35);
|
||||
float w4_seamless = cos((aPos.y * 1.4 + time * waveSpeed * 0.9 + aPos.x * 0.2) * waveFreq * 1.8) * (waveAmp * 0.28);
|
||||
|
||||
// Grid effect waves (per-vertex randomization for distance view)
|
||||
float hash1 = fract(sin(dot(aPos.xy, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
float hash2 = fract(sin(dot(aPos.xy, vec2(93.9898, 67.345))) * 27153.5328);
|
||||
float w1_grid = sin((aPos.x + time * waveSpeed + hash1 * 6.28) * waveFreq) * waveAmp;
|
||||
float w2_grid = cos((aPos.y - time * (waveSpeed * 0.78) + hash2 * 6.28) * (waveFreq * 0.82)) * (waveAmp * 0.72);
|
||||
float w3_grid = sin((aPos.x * 1.7 - time * waveSpeed * 1.3 + hash1 * 3.14) * waveFreq * 2.1) * (waveAmp * 0.35);
|
||||
float w4_grid = cos((aPos.y * 1.4 + time * waveSpeed * 0.9 + hash2 * 3.14) * waveFreq * 1.8) * (waveAmp * 0.28);
|
||||
|
||||
// Multiple wave octaves with randomized phases for natural variation
|
||||
float w1 = sin((aPos.x + time * waveSpeed + hash1 * 6.28) * waveFreq) * waveAmp;
|
||||
float w2 = cos((aPos.y - time * (waveSpeed * 0.78) + hash2 * 6.28) * (waveFreq * 0.82)) * (waveAmp * 0.72);
|
||||
|
||||
// Add higher frequency detail waves (smaller amplitude)
|
||||
float w3 = sin((aPos.x * 1.7 - time * waveSpeed * 1.3 + hash1 * 3.14) * waveFreq * 2.1) * (waveAmp * 0.35);
|
||||
float w4 = cos((aPos.y * 1.4 + time * waveSpeed * 0.9 + hash2 * 3.14) * waveFreq * 1.8) * (waveAmp * 0.28);
|
||||
|
||||
float wave = w1 + w2 + w3 + w4;
|
||||
// Blend between seamless (close) and grid (far)
|
||||
float wave = mix(
|
||||
w1_seamless + w2_seamless + w3_seamless + w4_seamless,
|
||||
w1_grid + w2_grid + w3_grid + w4_grid,
|
||||
gridBlend
|
||||
);
|
||||
pos.z += wave;
|
||||
|
||||
FragPos = vec3(model * vec4(pos, 1.0));
|
||||
|
|
|
|||
|
|
@ -554,6 +554,49 @@ void WMORenderer::setInstancePosition(uint32_t instanceId, const glm::vec3& posi
|
|||
rebuildSpatialIndex();
|
||||
}
|
||||
|
||||
void WMORenderer::setInstanceTransform(uint32_t instanceId, const glm::mat4& transform) {
|
||||
auto idxIt = instanceIndexById.find(instanceId);
|
||||
if (idxIt == instanceIndexById.end()) return;
|
||||
auto& inst = instances[idxIt->second];
|
||||
|
||||
// Decompose transform to position/rotation/scale
|
||||
inst.position = glm::vec3(transform[3]);
|
||||
|
||||
// Extract rotation (assuming uniform scale)
|
||||
glm::mat3 rotationMatrix(transform);
|
||||
float scaleX = glm::length(glm::vec3(transform[0]));
|
||||
float scaleY = glm::length(glm::vec3(transform[1]));
|
||||
float scaleZ = glm::length(glm::vec3(transform[2]));
|
||||
inst.scale = scaleX; // Assume uniform scale
|
||||
|
||||
if (scaleX > 0.0001f) rotationMatrix[0] /= scaleX;
|
||||
if (scaleY > 0.0001f) rotationMatrix[1] /= scaleY;
|
||||
if (scaleZ > 0.0001f) rotationMatrix[2] /= scaleZ;
|
||||
|
||||
inst.rotation = glm::vec3(0.0f); // Euler angles not directly used, so zero them
|
||||
|
||||
// Update model matrix and bounds
|
||||
inst.modelMatrix = transform;
|
||||
inst.invModelMatrix = glm::inverse(transform);
|
||||
|
||||
auto modelIt = loadedModels.find(inst.modelId);
|
||||
if (modelIt != loadedModels.end()) {
|
||||
const ModelData& model = modelIt->second;
|
||||
transformAABB(inst.modelMatrix, model.boundingBoxMin, model.boundingBoxMax,
|
||||
inst.worldBoundsMin, inst.worldBoundsMax);
|
||||
inst.worldGroupBounds.clear();
|
||||
inst.worldGroupBounds.reserve(model.groups.size());
|
||||
for (const auto& group : model.groups) {
|
||||
glm::vec3 gMin, gMax;
|
||||
transformAABB(inst.modelMatrix, group.boundingBoxMin, group.boundingBoxMax, gMin, gMax);
|
||||
gMin -= glm::vec3(0.5f);
|
||||
gMax += glm::vec3(0.5f);
|
||||
inst.worldGroupBounds.emplace_back(gMin, gMax);
|
||||
}
|
||||
}
|
||||
rebuildSpatialIndex();
|
||||
}
|
||||
|
||||
void WMORenderer::removeInstance(uint32_t instanceId) {
|
||||
auto it = std::find_if(instances.begin(), instances.end(),
|
||||
[instanceId](const WMOInstance& inst) { return inst.id == instanceId; });
|
||||
|
|
|
|||
|
|
@ -1758,6 +1758,51 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) {
|
|||
return;
|
||||
}
|
||||
|
||||
// /transport board — board test transport
|
||||
if (cmdLower == "transport board") {
|
||||
auto* tm = gameHandler.getTransportManager();
|
||||
if (tm) {
|
||||
// Test transport GUID
|
||||
uint64_t testTransportGuid = 0x1000000000000001ULL;
|
||||
// Place player at center of deck (rough estimate)
|
||||
glm::vec3 deckCenter(0.0f, 0.0f, 5.0f);
|
||||
gameHandler.setPlayerOnTransport(testTransportGuid, deckCenter);
|
||||
game::MessageChatData msg;
|
||||
msg.type = game::ChatType::SYSTEM;
|
||||
msg.language = game::ChatLanguage::UNIVERSAL;
|
||||
msg.message = "Boarded test transport. Use '/transport leave' to disembark.";
|
||||
gameHandler.addLocalChatMessage(msg);
|
||||
} else {
|
||||
game::MessageChatData msg;
|
||||
msg.type = game::ChatType::SYSTEM;
|
||||
msg.language = game::ChatLanguage::UNIVERSAL;
|
||||
msg.message = "Transport system not available.";
|
||||
gameHandler.addLocalChatMessage(msg);
|
||||
}
|
||||
chatInputBuffer[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
// /transport leave — disembark from transport
|
||||
if (cmdLower == "transport leave") {
|
||||
if (gameHandler.isOnTransport()) {
|
||||
gameHandler.clearPlayerTransport();
|
||||
game::MessageChatData msg;
|
||||
msg.type = game::ChatType::SYSTEM;
|
||||
msg.language = game::ChatLanguage::UNIVERSAL;
|
||||
msg.message = "Disembarked from transport.";
|
||||
gameHandler.addLocalChatMessage(msg);
|
||||
} else {
|
||||
game::MessageChatData msg;
|
||||
msg.type = game::ChatType::SYSTEM;
|
||||
msg.language = game::ChatLanguage::UNIVERSAL;
|
||||
msg.message = "You are not on a transport.";
|
||||
gameHandler.addLocalChatMessage(msg);
|
||||
}
|
||||
chatInputBuffer[0] = '\0';
|
||||
return;
|
||||
}
|
||||
|
||||
// Chat channel slash commands
|
||||
// If used without a message (e.g. just "/s"), switch the chat type dropdown
|
||||
bool isChannelCommand = false;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue