mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-03-22 23:30:14 +00:00
Compare commits
50 commits
a87d62abf8
...
8f08d75748
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f08d75748 | ||
|
|
499638142e | ||
|
|
85767187b1 | ||
|
|
0487d2eda6 | ||
|
|
863faf9b54 | ||
|
|
952f36b732 | ||
|
|
ebd9cf5542 | ||
|
|
64439673ce | ||
|
|
8f3f1b21af | ||
|
|
27213c1d40 | ||
|
|
1cd8e53b2f | ||
|
|
61adb4a803 | ||
|
|
862d743f87 | ||
|
|
d4bf8c871e | ||
|
|
d58c55ce8d | ||
|
|
f855327054 | ||
|
|
367b48af6b | ||
|
|
13c096f3e9 | ||
|
|
1108aa9ae6 | ||
|
|
022d387d95 | ||
|
|
acf99354b3 | ||
|
|
d3159791de | ||
|
|
e4fd4b4e6d | ||
|
|
74d5984ee2 | ||
|
|
de5c122307 | ||
|
|
1d9dc6dcae | ||
|
|
0089b3a160 | ||
|
|
e029e8649f | ||
|
|
d52c49c9fa | ||
|
|
b832940509 | ||
|
|
c5a6979d69 | ||
|
|
dd38026b23 | ||
|
|
9b60108fa6 | ||
|
|
ebaf95cc42 | ||
|
|
f8f57411f2 | ||
|
|
793c2b5611 | ||
|
|
4c1bc842bc | ||
|
|
9b092782c9 | ||
|
|
18d0e6a252 | ||
|
|
fb8c251a82 | ||
|
|
758ca76bd3 | ||
|
|
a1edddd1f0 | ||
|
|
e68ffbc711 | ||
|
|
470421879a | ||
|
|
7b3578420a | ||
|
|
91535fa9ae | ||
|
|
a728952058 | ||
|
|
5684b16721 | ||
|
|
f5d23a3a12 | ||
|
|
1bf4c2442a |
40 changed files with 3241 additions and 259 deletions
|
|
@ -14,6 +14,7 @@
|
|||
"UNIT_FIELD_DISPLAYID": 131,
|
||||
"UNIT_FIELD_MOUNTDISPLAYID": 133,
|
||||
"UNIT_FIELD_AURAS": 50,
|
||||
"UNIT_FIELD_AURAFLAGS": 98,
|
||||
"UNIT_NPC_FLAGS": 147,
|
||||
"UNIT_DYNAMIC_FLAGS": 143,
|
||||
"UNIT_FIELD_RESISTANCES": 154,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"UNIT_FIELD_DISPLAYID": 131,
|
||||
"UNIT_FIELD_MOUNTDISPLAYID": 133,
|
||||
"UNIT_FIELD_AURAS": 50,
|
||||
"UNIT_FIELD_AURAFLAGS": 98,
|
||||
"UNIT_NPC_FLAGS": 147,
|
||||
"UNIT_DYNAMIC_FLAGS": 143,
|
||||
"UNIT_FIELD_RESISTANCES": 154,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@
|
|||
"PLAYER_FIELD_BANKBAG_SLOT_1": 458,
|
||||
"PLAYER_SKILL_INFO_START": 636,
|
||||
"PLAYER_EXPLORED_ZONES_START": 1041,
|
||||
"PLAYER_CHOSEN_TITLE": 1349,
|
||||
"GAMEOBJECT_DISPLAYID": 8,
|
||||
"ITEM_FIELD_STACK_COUNT": 14,
|
||||
"ITEM_FIELD_DURABILITY": 60,
|
||||
|
|
|
|||
|
|
@ -10,9 +10,7 @@ layout(push_constant) uniform PushConstants {
|
|||
} pc;
|
||||
|
||||
void main() {
|
||||
// Undo the vertex shader Y flip (postprocess.vert flips for Vulkan overlay,
|
||||
// but we need standard UV coords for texture sampling)
|
||||
vec2 tc = vec2(TexCoord.x, 1.0 - TexCoord.y);
|
||||
vec2 tc = TexCoord;
|
||||
|
||||
vec2 texelSize = pc.params.xy;
|
||||
float sharpness = pc.params.z;
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -21,9 +21,7 @@ vec3 fsrFetch(vec2 p, vec2 off) {
|
|||
}
|
||||
|
||||
void main() {
|
||||
// Undo the vertex shader Y flip (postprocess.vert flips for Vulkan overlay,
|
||||
// but we need standard UV coords for texture sampling)
|
||||
vec2 tc = vec2(TexCoord.x, 1.0 - TexCoord.y);
|
||||
vec2 tc = TexCoord;
|
||||
|
||||
// Map output pixel to input space
|
||||
vec2 pp = tc * fsr.con2.xy; // output pixel position
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
// FXAA 3.11 — Fast Approximate Anti-Aliasing post-process pass.
|
||||
// Reads the resolved scene color and outputs a smoothed result.
|
||||
// Push constant: rcpFrame = vec2(1/width, 1/height).
|
||||
// Push constant: rcpFrame = vec2(1/width, 1/height), sharpness (0=off, 2=max), desaturate (1=ghost grayscale).
|
||||
|
||||
layout(set = 0, binding = 0) uniform sampler2D uScene;
|
||||
|
||||
|
|
@ -10,7 +10,9 @@ layout(location = 0) in vec2 TexCoord;
|
|||
layout(location = 0) out vec4 outColor;
|
||||
|
||||
layout(push_constant) uniform PC {
|
||||
vec2 rcpFrame;
|
||||
vec2 rcpFrame;
|
||||
float sharpness; // 0 = no sharpen, 2 = max (matches FSR2 RCAS range)
|
||||
float desaturate; // 1 = full grayscale (ghost mode), 0 = normal color
|
||||
} pc;
|
||||
|
||||
// Quality tuning
|
||||
|
|
@ -128,5 +130,26 @@ void main() {
|
|||
if ( horzSpan) finalUV.y += pixelOffsetFinal * lengthSign;
|
||||
if (!horzSpan) finalUV.x += pixelOffsetFinal * lengthSign;
|
||||
|
||||
outColor = vec4(texture(uScene, finalUV).rgb, 1.0);
|
||||
vec3 fxaaResult = texture(uScene, finalUV).rgb;
|
||||
|
||||
// Post-FXAA contrast-adaptive sharpening (unsharp mask).
|
||||
// Counteracts FXAA's sub-pixel blur when sharpness > 0.
|
||||
if (pc.sharpness > 0.0) {
|
||||
vec2 r = pc.rcpFrame;
|
||||
vec3 blur = (texture(uScene, uv + vec2(-r.x, 0)).rgb
|
||||
+ texture(uScene, uv + vec2( r.x, 0)).rgb
|
||||
+ texture(uScene, uv + vec2(0, -r.y)).rgb
|
||||
+ texture(uScene, uv + vec2(0, r.y)).rgb) * 0.25;
|
||||
// scale sharpness from [0,2] to a modest [0, 0.3] boost factor
|
||||
float s = pc.sharpness * 0.15;
|
||||
fxaaResult = clamp(fxaaResult + s * (fxaaResult - blur), 0.0, 1.0);
|
||||
}
|
||||
|
||||
// Ghost mode: desaturate to grayscale (with a slight cool blue tint).
|
||||
if (pc.desaturate > 0.5) {
|
||||
float gray = dot(fxaaResult, vec3(0.299, 0.587, 0.114));
|
||||
fxaaResult = mix(fxaaResult, vec3(gray, gray, gray * 1.05), pc.desaturate);
|
||||
}
|
||||
|
||||
outColor = vec4(fxaaResult, 1.0);
|
||||
}
|
||||
|
|
|
|||
BIN
assets/shaders/fxaa.frag.spv
Normal file
BIN
assets/shaders/fxaa.frag.spv
Normal file
Binary file not shown.
25
assets/shaders/m2_ribbon.frag.glsl
Normal file
25
assets/shaders/m2_ribbon.frag.glsl
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
#version 450
|
||||
|
||||
// M2 ribbon emitter fragment shader.
|
||||
// Samples the ribbon texture, multiplied by vertex color and alpha.
|
||||
// Uses additive blending (pipeline-level) for magic/spell trails.
|
||||
|
||||
layout(set = 1, binding = 0) uniform sampler2D uTexture;
|
||||
|
||||
layout(location = 0) in vec3 vColor;
|
||||
layout(location = 1) in float vAlpha;
|
||||
layout(location = 2) in vec2 vUV;
|
||||
layout(location = 3) in float vFogFactor;
|
||||
|
||||
layout(location = 0) out vec4 outColor;
|
||||
|
||||
void main() {
|
||||
vec4 tex = texture(uTexture, vUV);
|
||||
// For additive ribbons alpha comes from texture luminance; multiply by vertex alpha.
|
||||
float a = tex.a * vAlpha;
|
||||
if (a < 0.01) discard;
|
||||
vec3 rgb = tex.rgb * vColor;
|
||||
// Ribbons fade slightly with fog (additive blend attenuated toward black = invisible in fog).
|
||||
rgb *= vFogFactor;
|
||||
outColor = vec4(rgb, a);
|
||||
}
|
||||
BIN
assets/shaders/m2_ribbon.frag.spv
Normal file
BIN
assets/shaders/m2_ribbon.frag.spv
Normal file
Binary file not shown.
43
assets/shaders/m2_ribbon.vert.glsl
Normal file
43
assets/shaders/m2_ribbon.vert.glsl
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#version 450
|
||||
|
||||
// M2 ribbon emitter vertex shader.
|
||||
// Ribbon geometry is generated CPU-side as a triangle strip.
|
||||
// Vertex format: pos(3) + color(3) + alpha(1) + uv(2) = 9 floats.
|
||||
|
||||
layout(set = 0, binding = 0) uniform PerFrame {
|
||||
mat4 view;
|
||||
mat4 projection;
|
||||
mat4 lightSpaceMatrix;
|
||||
vec4 lightDir;
|
||||
vec4 lightColor;
|
||||
vec4 ambientColor;
|
||||
vec4 viewPos;
|
||||
vec4 fogColor;
|
||||
vec4 fogParams;
|
||||
vec4 shadowParams;
|
||||
};
|
||||
|
||||
layout(location = 0) in vec3 aPos;
|
||||
layout(location = 1) in vec3 aColor;
|
||||
layout(location = 2) in float aAlpha;
|
||||
layout(location = 3) in vec2 aUV;
|
||||
|
||||
layout(location = 0) out vec3 vColor;
|
||||
layout(location = 1) out float vAlpha;
|
||||
layout(location = 2) out vec2 vUV;
|
||||
layout(location = 3) out float vFogFactor;
|
||||
|
||||
void main() {
|
||||
vec4 worldPos = vec4(aPos, 1.0);
|
||||
vec4 viewPos4 = view * worldPos;
|
||||
gl_Position = projection * viewPos4;
|
||||
|
||||
float dist = length(viewPos4.xyz);
|
||||
float fogStart = fogParams.x;
|
||||
float fogEnd = fogParams.y;
|
||||
vFogFactor = clamp((fogEnd - dist) / max(fogEnd - fogStart, 0.001), 0.0, 1.0);
|
||||
|
||||
vColor = aColor;
|
||||
vAlpha = aAlpha;
|
||||
vUV = aUV;
|
||||
}
|
||||
BIN
assets/shaders/m2_ribbon.vert.spv
Normal file
BIN
assets/shaders/m2_ribbon.vert.spv
Normal file
Binary file not shown.
|
|
@ -6,5 +6,7 @@ void main() {
|
|||
// Fullscreen triangle trick: 3 vertices, no vertex buffer
|
||||
TexCoord = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2);
|
||||
gl_Position = vec4(TexCoord * 2.0 - 1.0, 0.0, 1.0);
|
||||
TexCoord.y = 1.0 - TexCoord.y; // flip Y for Vulkan
|
||||
// No Y-flip: scene textures use Vulkan convention (v=0 at top),
|
||||
// and NDC y=-1 already maps to framebuffer top, so the triangle
|
||||
// naturally samples the correct row without any inversion.
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -271,6 +271,7 @@ private:
|
|||
};
|
||||
std::unordered_map<uint32_t, std::string> gameObjectDisplayIdToPath_;
|
||||
std::unordered_map<uint32_t, uint32_t> gameObjectDisplayIdModelCache_; // displayId → M2 modelId
|
||||
std::unordered_set<uint32_t> gameObjectDisplayIdFailedCache_; // displayIds that permanently fail to load
|
||||
std::unordered_map<uint32_t, uint32_t> gameObjectDisplayIdWmoCache_; // displayId → WMO modelId
|
||||
std::unordered_map<uint64_t, GameObjectInstanceInfo> gameObjectInstances_; // guid → instance info
|
||||
struct PendingTransportMove {
|
||||
|
|
|
|||
|
|
@ -339,6 +339,16 @@ public:
|
|||
// Inspection
|
||||
void inspectTarget();
|
||||
|
||||
struct InspectArenaTeam {
|
||||
uint32_t teamId = 0;
|
||||
uint8_t type = 0; // bracket size: 2, 3, or 5
|
||||
uint32_t weekGames = 0;
|
||||
uint32_t weekWins = 0;
|
||||
uint32_t seasonGames = 0;
|
||||
uint32_t seasonWins = 0;
|
||||
std::string name;
|
||||
uint32_t personalRating = 0;
|
||||
};
|
||||
struct InspectResult {
|
||||
uint64_t guid = 0;
|
||||
std::string playerName;
|
||||
|
|
@ -348,6 +358,7 @@ public:
|
|||
uint8_t activeTalentGroup = 0;
|
||||
std::array<uint32_t, 19> itemEntries{}; // 0=head…18=ranged
|
||||
std::array<uint16_t, 19> enchantIds{}; // permanent enchant per slot (0 = none)
|
||||
std::vector<InspectArenaTeam> arenaTeams; // from MSG_INSPECT_ARENA_TEAMS (WotLK)
|
||||
};
|
||||
const InspectResult* getInspectResult() const {
|
||||
return inspectResult_.guid ? &inspectResult_ : nullptr;
|
||||
|
|
@ -394,11 +405,22 @@ public:
|
|||
std::chrono::steady_clock::time_point inviteReceivedTime{};
|
||||
};
|
||||
|
||||
// Available BG list (populated by SMSG_BATTLEFIELD_LIST)
|
||||
struct AvailableBgInfo {
|
||||
uint32_t bgTypeId = 0;
|
||||
bool isRegistered = false;
|
||||
bool isHoliday = false;
|
||||
uint32_t minLevel = 0;
|
||||
uint32_t maxLevel = 0;
|
||||
std::vector<uint32_t> instanceIds;
|
||||
};
|
||||
|
||||
// Battleground
|
||||
bool hasPendingBgInvite() const;
|
||||
void acceptBattlefield(uint32_t queueSlot = 0xFFFFFFFF);
|
||||
void declineBattlefield(uint32_t queueSlot = 0xFFFFFFFF);
|
||||
const std::array<BgQueueSlot, 3>& getBgQueues() const { return bgQueues_; }
|
||||
const std::vector<AvailableBgInfo>& getAvailableBgs() const { return availableBgs_; }
|
||||
|
||||
// BG scoreboard (MSG_PVP_LOG_DATA)
|
||||
struct BgPlayerScore {
|
||||
|
|
@ -411,11 +433,18 @@ public:
|
|||
uint32_t bonusHonor = 0;
|
||||
std::vector<std::pair<std::string, uint32_t>> bgStats; // BG-specific fields
|
||||
};
|
||||
struct ArenaTeamScore {
|
||||
std::string teamName;
|
||||
uint32_t ratingChange = 0; // signed delta packed as uint32
|
||||
uint32_t newRating = 0;
|
||||
};
|
||||
struct BgScoreboardData {
|
||||
std::vector<BgPlayerScore> players;
|
||||
bool hasWinner = false;
|
||||
uint8_t winner = 0; // 0=Horde, 1=Alliance
|
||||
bool isArena = false;
|
||||
// Arena-only fields (valid when isArena=true)
|
||||
ArenaTeamScore arenaTeams[2]; // team 0 = first, team 1 = second
|
||||
};
|
||||
void requestPvpLog();
|
||||
const BgScoreboardData* getBgScoreboard() const {
|
||||
|
|
@ -472,6 +501,24 @@ public:
|
|||
// GM Ticket
|
||||
void submitGmTicket(const std::string& text);
|
||||
void deleteGmTicket();
|
||||
void requestGmTicket(); ///< Send CMSG_GMTICKET_GETTICKET to query open ticket
|
||||
|
||||
// GM ticket status accessors
|
||||
bool hasActiveGmTicket() const { return gmTicketActive_; }
|
||||
const std::string& getGmTicketText() const { return gmTicketText_; }
|
||||
bool isGmSupportAvailable() const { return gmSupportAvailable_; }
|
||||
float getGmTicketWaitHours() const { return gmTicketWaitHours_; }
|
||||
|
||||
// Battlefield Manager (Wintergrasp)
|
||||
bool hasBfMgrInvite() const { return bfMgrInvitePending_; }
|
||||
bool isInBfMgrZone() const { return bfMgrActive_; }
|
||||
uint32_t getBfMgrZoneId() const { return bfMgrZoneId_; }
|
||||
void acceptBfMgrInvite();
|
||||
void declineBfMgrInvite();
|
||||
|
||||
// WotLK Calendar
|
||||
uint32_t getCalendarPendingInvites() const { return calendarPendingInvites_; }
|
||||
void requestCalendar(); ///< Send CMSG_CALENDAR_GET_CALENDAR to the server
|
||||
void queryGuildInfo(uint32_t guildId);
|
||||
void createGuild(const std::string& guildName);
|
||||
void addGuildRank(const std::string& rankName);
|
||||
|
|
@ -1059,8 +1106,10 @@ public:
|
|||
/** Distance (yards) from ghost to corpse, or -1 if no corpse data. */
|
||||
float getCorpseDistance() const {
|
||||
if (corpseMapId_ == 0 || currentMapId_ != corpseMapId_) return -1.0f;
|
||||
float dx = movementInfo.x - corpseX_;
|
||||
float dy = movementInfo.y - corpseY_;
|
||||
// movementInfo is canonical (x=north=server_y, y=west=server_x);
|
||||
// corpse coords are raw server (x=west, y=north) — swap to compare.
|
||||
float dx = movementInfo.x - corpseY_;
|
||||
float dy = movementInfo.y - corpseX_;
|
||||
float dz = movementInfo.z - corpseZ_;
|
||||
return std::sqrt(dx*dx + dy*dy + dz*dz);
|
||||
}
|
||||
|
|
@ -1247,6 +1296,29 @@ public:
|
|||
};
|
||||
const std::vector<ArenaTeamStats>& getArenaTeamStats() const { return arenaTeamStats_; }
|
||||
|
||||
// ---- Arena Team Roster ----
|
||||
struct ArenaTeamMember {
|
||||
uint64_t guid = 0;
|
||||
std::string name;
|
||||
bool online = false;
|
||||
uint32_t weekGames = 0;
|
||||
uint32_t weekWins = 0;
|
||||
uint32_t seasonGames = 0;
|
||||
uint32_t seasonWins = 0;
|
||||
uint32_t personalRating = 0;
|
||||
};
|
||||
struct ArenaTeamRoster {
|
||||
uint32_t teamId = 0;
|
||||
std::vector<ArenaTeamMember> members;
|
||||
};
|
||||
// Returns roster for the given teamId, or nullptr if not yet received
|
||||
const ArenaTeamRoster* getArenaTeamRoster(uint32_t teamId) const {
|
||||
for (const auto& r : arenaTeamRosters_) {
|
||||
if (r.teamId == teamId) return &r;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// ---- Phase 5: Loot ----
|
||||
void lootTarget(uint64_t guid);
|
||||
void lootItem(uint8_t slotIndex);
|
||||
|
|
@ -1421,12 +1493,115 @@ public:
|
|||
};
|
||||
const std::array<RuneSlot, 6>& getPlayerRunes() const { return playerRunes_; }
|
||||
|
||||
// Talent-driven spell modifiers (SMSG_SET_FLAT_SPELL_MODIFIER / SMSG_SET_PCT_SPELL_MODIFIER)
|
||||
// SpellModOp matches WotLK SpellModOp enum (server-side).
|
||||
enum class SpellModOp : uint8_t {
|
||||
Damage = 0,
|
||||
Duration = 1,
|
||||
Threat = 2,
|
||||
Effect1 = 3,
|
||||
Charges = 4,
|
||||
Range = 5,
|
||||
Radius = 6,
|
||||
CritChance = 7,
|
||||
AllEffects = 8,
|
||||
NotLoseCastingTime = 9,
|
||||
CastingTime = 10,
|
||||
Cooldown = 11,
|
||||
Effect2 = 12,
|
||||
IgnoreArmor = 13,
|
||||
Cost = 14,
|
||||
CritDamageBonus = 15,
|
||||
ResistMissChance = 16,
|
||||
JumpTargets = 17,
|
||||
ChanceOfSuccess = 18,
|
||||
ActivationTime = 19,
|
||||
Efficiency = 20,
|
||||
MultipleValue = 21,
|
||||
ResistDispelChance = 22,
|
||||
Effect3 = 23,
|
||||
BonusMultiplier = 24,
|
||||
ProcPerMinute = 25,
|
||||
ValueMultiplier = 26,
|
||||
ResistPushback = 27,
|
||||
MechanicDuration = 28,
|
||||
StartCooldown = 29,
|
||||
PeriodicBonus = 30,
|
||||
AttackPower = 31,
|
||||
};
|
||||
static constexpr int SPELL_MOD_OP_COUNT = 32;
|
||||
|
||||
// Key: (SpellModOp, groupIndex) — value: accumulated flat or pct modifier
|
||||
// pct values are stored in integer percent (e.g. -20 means -20% reduction).
|
||||
struct SpellModKey {
|
||||
SpellModOp op;
|
||||
uint8_t group;
|
||||
bool operator==(const SpellModKey& o) const {
|
||||
return op == o.op && group == o.group;
|
||||
}
|
||||
};
|
||||
struct SpellModKeyHash {
|
||||
std::size_t operator()(const SpellModKey& k) const {
|
||||
return std::hash<uint32_t>()(
|
||||
(static_cast<uint32_t>(static_cast<uint8_t>(k.op)) << 8) | k.group);
|
||||
}
|
||||
};
|
||||
|
||||
// Returns the sum of all flat modifiers for a given op across all groups.
|
||||
// (Callers that need per-group resolution can use getSpellFlatMods() directly.)
|
||||
int32_t getSpellFlatMod(SpellModOp op) const {
|
||||
int32_t total = 0;
|
||||
for (const auto& [k, v] : spellFlatMods_)
|
||||
if (k.op == op) total += v;
|
||||
return total;
|
||||
}
|
||||
// Returns the sum of all pct modifiers for a given op across all groups (in %).
|
||||
int32_t getSpellPctMod(SpellModOp op) const {
|
||||
int32_t total = 0;
|
||||
for (const auto& [k, v] : spellPctMods_)
|
||||
if (k.op == op) total += v;
|
||||
return total;
|
||||
}
|
||||
|
||||
// Convenience: apply flat+pct modifier to a base value.
|
||||
// result = (base + flatMod) * (1.0 + pctMod/100.0), clamped to >= 0.
|
||||
static int32_t applySpellMod(int32_t base, int32_t flat, int32_t pct) {
|
||||
int64_t v = static_cast<int64_t>(base) + flat;
|
||||
if (pct != 0) v = v + (v * pct + 50) / 100; // round half-up
|
||||
return static_cast<int32_t>(v < 0 ? 0 : v);
|
||||
}
|
||||
|
||||
struct FactionStandingInit {
|
||||
uint8_t flags = 0;
|
||||
int32_t standing = 0;
|
||||
};
|
||||
// Faction flag bitmask constants (from Faction.dbc ReputationFlags / SMSG_INITIALIZE_FACTIONS)
|
||||
static constexpr uint8_t FACTION_FLAG_VISIBLE = 0x01; // shown in reputation list
|
||||
static constexpr uint8_t FACTION_FLAG_AT_WAR = 0x02; // player is at war
|
||||
static constexpr uint8_t FACTION_FLAG_HIDDEN = 0x04; // never shown
|
||||
static constexpr uint8_t FACTION_FLAG_INVISIBLE_FORCED = 0x08;
|
||||
static constexpr uint8_t FACTION_FLAG_PEACE_FORCED = 0x10;
|
||||
|
||||
const std::vector<FactionStandingInit>& getInitialFactions() const { return initialFactions_; }
|
||||
const std::unordered_map<uint32_t, int32_t>& getFactionStandings() const { return factionStandings_; }
|
||||
|
||||
// Returns true if the player has "at war" toggled for the faction at repListId
|
||||
bool isFactionAtWar(uint32_t repListId) const {
|
||||
if (repListId >= initialFactions_.size()) return false;
|
||||
return (initialFactions_[repListId].flags & FACTION_FLAG_AT_WAR) != 0;
|
||||
}
|
||||
// Returns true if the faction is visible in the reputation list
|
||||
bool isFactionVisible(uint32_t repListId) const {
|
||||
if (repListId >= initialFactions_.size()) return false;
|
||||
const uint8_t f = initialFactions_[repListId].flags;
|
||||
if (f & FACTION_FLAG_HIDDEN) return false;
|
||||
if (f & FACTION_FLAG_INVISIBLE_FORCED) return false;
|
||||
return (f & FACTION_FLAG_VISIBLE) != 0;
|
||||
}
|
||||
// Returns the faction ID for a given repListId (0 if unknown)
|
||||
uint32_t getFactionIdByRepListId(uint32_t repListId) const;
|
||||
// Returns the repListId for a given faction ID (0xFFFFFFFF if not found)
|
||||
uint32_t getRepListIdByFactionId(uint32_t factionId) const;
|
||||
// Shaman totems (4 slots: 0=Earth, 1=Fire, 2=Water, 3=Air)
|
||||
struct TotemSlot {
|
||||
uint32_t spellId = 0;
|
||||
|
|
@ -1505,6 +1680,14 @@ public:
|
|||
void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); }
|
||||
const std::unordered_set<uint32_t>& getEarnedAchievements() const { return earnedAchievements_; }
|
||||
|
||||
// Title system — earned title bits and the currently displayed title
|
||||
const std::unordered_set<uint32_t>& getKnownTitleBits() const { return knownTitleBits_; }
|
||||
int32_t getChosenTitleBit() const { return chosenTitleBit_; }
|
||||
/// Returns the formatted title string for a given bit (replaces %s with player name), or empty.
|
||||
std::string getFormattedTitle(uint32_t bit) const;
|
||||
/// Send CMSG_SET_TITLE to activate a title (bit >= 0) or clear it (bit = -1).
|
||||
void sendSetTitle(int32_t bit);
|
||||
|
||||
// Area discovery callback — fires when SMSG_EXPLORATION_EXPERIENCE is received
|
||||
using AreaDiscoveryCallback = std::function<void(const std::string& areaName, uint32_t xpGained)>;
|
||||
void setAreaDiscoveryCallback(AreaDiscoveryCallback cb) { areaDiscoveryCallback_ = std::move(cb); }
|
||||
|
|
@ -1540,6 +1723,12 @@ public:
|
|||
auto it = achievementPointsCache_.find(id);
|
||||
return (it != achievementPointsCache_.end()) ? it->second : 0u;
|
||||
}
|
||||
/// Returns the set of achievement IDs earned by an inspected player (via SMSG_RESPOND_INSPECT_ACHIEVEMENTS).
|
||||
/// Returns nullptr if no inspect data is available for the given GUID.
|
||||
const std::unordered_set<uint32_t>* getInspectedPlayerAchievements(uint64_t guid) const {
|
||||
auto it = inspectedPlayerAchievements_.find(guid);
|
||||
return (it != inspectedPlayerAchievements_.end()) ? &it->second : nullptr;
|
||||
}
|
||||
|
||||
// Server-triggered music callback — fires when SMSG_PLAY_MUSIC is received.
|
||||
// The soundId corresponds to a SoundEntries.dbc record. The receiver is
|
||||
|
|
@ -1594,6 +1783,10 @@ public:
|
|||
using TaxiFlightStartCallback = std::function<void()>;
|
||||
void setTaxiFlightStartCallback(TaxiFlightStartCallback cb) { taxiFlightStartCallback_ = std::move(cb); }
|
||||
|
||||
// Callback fired when server sends SMSG_OPEN_LFG_DUNGEON_FINDER (open dungeon finder UI)
|
||||
using OpenLfgCallback = std::function<void()>;
|
||||
void setOpenLfgCallback(OpenLfgCallback cb) { openLfgCallback_ = std::move(cb); }
|
||||
|
||||
bool isMounted() const { return currentMountDisplayId_ != 0; }
|
||||
bool isHostileAttacker(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; }
|
||||
float getServerRunSpeed() const { return serverRunSpeed_; }
|
||||
|
|
@ -2006,6 +2199,7 @@ private:
|
|||
|
||||
// ---- Other player movement (MSG_MOVE_* from server) ----
|
||||
void handleOtherPlayerMovement(network::Packet& packet);
|
||||
void handleMoveSetSpeed(network::Packet& packet);
|
||||
|
||||
// ---- Phase 5 handlers ----
|
||||
void handleLootResponse(network::Packet& packet);
|
||||
|
|
@ -2072,6 +2266,7 @@ private:
|
|||
void handleInstanceDifficulty(network::Packet& packet);
|
||||
void handleArenaTeamCommandResult(network::Packet& packet);
|
||||
void handleArenaTeamQueryResponse(network::Packet& packet);
|
||||
void handleArenaTeamRoster(network::Packet& packet);
|
||||
void handleArenaTeamInvite(network::Packet& packet);
|
||||
void handleArenaTeamEvent(network::Packet& packet);
|
||||
void handleArenaTeamStats(network::Packet& packet);
|
||||
|
|
@ -2428,6 +2623,10 @@ private:
|
|||
// ---- Battleground queue state ----
|
||||
std::array<BgQueueSlot, 3> bgQueues_{};
|
||||
|
||||
// ---- Available battleground list (SMSG_BATTLEFIELD_LIST) ----
|
||||
std::vector<AvailableBgInfo> availableBgs_;
|
||||
void handleBattlefieldList(network::Packet& packet);
|
||||
|
||||
// Instance difficulty
|
||||
uint32_t instanceDifficulty_ = 0;
|
||||
bool instanceIsHeroic_ = false;
|
||||
|
|
@ -2446,7 +2645,9 @@ private:
|
|||
std::vector<InstanceLockout> instanceLockouts_;
|
||||
|
||||
// Arena team stats (indexed by team slot, updated by SMSG_ARENA_TEAM_STATS)
|
||||
std::vector<ArenaTeamStats> arenaTeamStats_;
|
||||
std::vector<ArenaTeamStats> arenaTeamStats_;
|
||||
// Arena team rosters (updated by SMSG_ARENA_TEAM_ROSTER)
|
||||
std::vector<ArenaTeamRoster> arenaTeamRosters_;
|
||||
|
||||
// BG scoreboard (MSG_PVP_LOG_DATA)
|
||||
BgScoreboardData bgScoreboard_;
|
||||
|
|
@ -2478,6 +2679,10 @@ private:
|
|||
std::unordered_map<uint32_t, int32_t> factionStandings_;
|
||||
// Faction name cache (factionId → name), populated lazily from Faction.dbc
|
||||
std::unordered_map<uint32_t, std::string> factionNameCache_;
|
||||
// repListId → factionId mapping (populated with factionNameCache)
|
||||
std::unordered_map<uint32_t, uint32_t> factionRepListToId_;
|
||||
// factionId → repListId reverse mapping
|
||||
std::unordered_map<uint32_t, uint32_t> factionIdToRepList_;
|
||||
bool factionNameCacheLoaded_ = false;
|
||||
void loadFactionNameCache();
|
||||
std::string getFactionName(uint32_t factionId) const;
|
||||
|
|
@ -2734,6 +2939,10 @@ private:
|
|||
std::unordered_map<uint32_t, std::string> titleNameCache_;
|
||||
bool titleNameCacheLoaded_ = false;
|
||||
void loadTitleNameCache();
|
||||
// Set of title bit-indices known to the player (from SMSG_TITLE_EARNED).
|
||||
std::unordered_set<uint32_t> knownTitleBits_;
|
||||
// Currently selected title bit, or -1 for no title. Updated from PLAYER_CHOSEN_TITLE.
|
||||
int32_t chosenTitleBit_ = -1;
|
||||
|
||||
// Achievement caches (lazy-loaded from Achievement.dbc on first earned event)
|
||||
std::unordered_map<uint32_t, std::string> achievementNameCache_;
|
||||
|
|
@ -2749,6 +2958,11 @@ private:
|
|||
std::unordered_map<uint32_t, uint64_t> criteriaProgress_;
|
||||
void handleAllAchievementData(network::Packet& packet);
|
||||
|
||||
// Per-player achievement data from SMSG_RESPOND_INSPECT_ACHIEVEMENTS
|
||||
// Key: inspected player's GUID; value: set of earned achievement IDs
|
||||
std::unordered_map<uint64_t, std::unordered_set<uint32_t>> inspectedPlayerAchievements_;
|
||||
void handleRespondInspectAchievements(network::Packet& packet);
|
||||
|
||||
// Area name cache (lazy-loaded from WorldMapArea.dbc; maps AreaTable ID → display name)
|
||||
std::unordered_map<uint32_t, std::string> areaNameCache_;
|
||||
bool areaNameCacheLoaded_ = false;
|
||||
|
|
@ -2878,6 +3092,7 @@ private:
|
|||
TaxiPrecacheCallback taxiPrecacheCallback_;
|
||||
TaxiOrientationCallback taxiOrientationCallback_;
|
||||
TaxiFlightStartCallback taxiFlightStartCallback_;
|
||||
OpenLfgCallback openLfgCallback_;
|
||||
uint32_t currentMountDisplayId_ = 0;
|
||||
uint32_t mountAuraSpellId_ = 0; // Spell ID of the aura that caused mounting (for CMSG_CANCEL_AURA fallback)
|
||||
float serverRunSpeed_ = 7.0f;
|
||||
|
|
@ -2952,6 +3167,25 @@ private:
|
|||
|
||||
// ---- Quest completion callback ----
|
||||
QuestCompleteCallback questCompleteCallback_;
|
||||
|
||||
// ---- GM Ticket state (SMSG_GMTICKET_GETTICKET / SMSG_GMTICKET_SYSTEMSTATUS) ----
|
||||
bool gmTicketActive_ = false; ///< True when an open ticket exists on the server
|
||||
std::string gmTicketText_; ///< Text of the open ticket (from SMSG_GMTICKET_GETTICKET)
|
||||
float gmTicketWaitHours_ = 0.0f; ///< Server-estimated wait time in hours
|
||||
bool gmSupportAvailable_ = true; ///< GM support system online (SMSG_GMTICKET_SYSTEMSTATUS)
|
||||
|
||||
// ---- Battlefield Manager state (WotLK Wintergrasp / outdoor battlefields) ----
|
||||
bool bfMgrInvitePending_ = false; ///< True when an entry/queue invite is pending acceptance
|
||||
bool bfMgrActive_ = false; ///< True while the player is inside an outdoor battlefield
|
||||
uint32_t bfMgrZoneId_ = 0; ///< Zone ID of the pending/active battlefield
|
||||
|
||||
// ---- WotLK Calendar: pending invite counter ----
|
||||
uint32_t calendarPendingInvites_ = 0; ///< Unacknowledged calendar invites (SMSG_CALENDAR_SEND_NUM_PENDING)
|
||||
|
||||
// ---- Spell modifiers (SMSG_SET_FLAT_SPELL_MODIFIER / SMSG_SET_PCT_SPELL_MODIFIER) ----
|
||||
// Keyed by (SpellModOp, groupIndex); cleared on logout/character change.
|
||||
std::unordered_map<SpellModKey, int32_t, SpellModKeyHash> spellFlatMods_;
|
||||
std::unordered_map<SpellModKey, int32_t, SpellModKeyHash> spellPctMods_;
|
||||
};
|
||||
|
||||
} // namespace game
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ enum class UF : uint16_t {
|
|||
UNIT_FIELD_DISPLAYID,
|
||||
UNIT_FIELD_MOUNTDISPLAYID,
|
||||
UNIT_FIELD_AURAS, // Start of aura spell ID array (48 consecutive uint32 slots, classic/vanilla only)
|
||||
UNIT_FIELD_AURAFLAGS, // Aura flags packed 4-per-uint32 (12 uint32 slots); 0x01=cancelable,0x02=harmful,0x04=helpful
|
||||
UNIT_NPC_FLAGS,
|
||||
UNIT_DYNAMIC_FLAGS,
|
||||
UNIT_FIELD_RESISTANCES, // Physical armor (index 0 of the resistance array)
|
||||
|
|
@ -56,6 +57,7 @@ enum class UF : uint16_t {
|
|||
PLAYER_FIELD_BANKBAG_SLOT_1,
|
||||
PLAYER_SKILL_INFO_START,
|
||||
PLAYER_EXPLORED_ZONES_START,
|
||||
PLAYER_CHOSEN_TITLE, // Active title index (-1 = no title)
|
||||
|
||||
// GameObject fields
|
||||
GAMEOBJECT_DISPLAYID,
|
||||
|
|
|
|||
|
|
@ -1448,6 +1448,12 @@ public:
|
|||
static network::Packet build(uint64_t targetGuid);
|
||||
};
|
||||
|
||||
/** CMSG_QUERY_INSPECT_ACHIEVEMENTS packet builder (WotLK 3.3.5a) */
|
||||
class QueryInspectAchievementsPacket {
|
||||
public:
|
||||
static network::Packet build(uint64_t targetGuid);
|
||||
};
|
||||
|
||||
/** CMSG_NAME_QUERY packet builder */
|
||||
class NameQueryPacket {
|
||||
public:
|
||||
|
|
@ -2727,5 +2733,13 @@ public:
|
|||
static network::Packet build(uint64_t petGuid, const std::string& name, uint8_t isDeclined = 0);
|
||||
};
|
||||
|
||||
/** CMSG_SET_TITLE packet builder.
|
||||
* titleBit >= 0: activate the title with that bit index.
|
||||
* titleBit == -1: clear the current title (show no title). */
|
||||
class SetTitlePacket {
|
||||
public:
|
||||
static network::Packet build(int32_t titleBit);
|
||||
};
|
||||
|
||||
} // namespace game
|
||||
} // namespace wowee
|
||||
|
|
|
|||
|
|
@ -165,6 +165,29 @@ struct M2ParticleEmitter {
|
|||
bool enabled = true;
|
||||
};
|
||||
|
||||
// Ribbon emitter definition parsed from M2 (WotLK format)
|
||||
struct M2RibbonEmitter {
|
||||
int32_t ribbonId = 0;
|
||||
uint32_t bone = 0; // Bone that drives the ribbon spine
|
||||
glm::vec3 position{0.0f}; // Offset from bone pivot
|
||||
|
||||
uint16_t textureIndex = 0; // First texture lookup index
|
||||
uint16_t materialIndex = 0; // First material lookup index (blend mode)
|
||||
|
||||
// Animated tracks
|
||||
M2AnimationTrack colorTrack; // RGB 0..1
|
||||
M2AnimationTrack alphaTrack; // float 0..1 (stored as fixed16 on disk)
|
||||
M2AnimationTrack heightAboveTrack; // Half-width above bone
|
||||
M2AnimationTrack heightBelowTrack; // Half-width below bone
|
||||
M2AnimationTrack visibilityTrack; // 0=hidden, 1=visible
|
||||
|
||||
float edgesPerSecond = 15.0f; // How many edge points are generated per second
|
||||
float edgeLifetime = 0.5f; // Seconds before edges expire
|
||||
float gravity = 0.0f; // Downward pull on edges per s²
|
||||
uint16_t textureRows = 1;
|
||||
uint16_t textureCols = 1;
|
||||
};
|
||||
|
||||
// Complete M2 model structure
|
||||
struct M2Model {
|
||||
// Model metadata
|
||||
|
|
@ -213,6 +236,9 @@ struct M2Model {
|
|||
// Particle emitters
|
||||
std::vector<M2ParticleEmitter> particleEmitters;
|
||||
|
||||
// Ribbon emitters
|
||||
std::vector<M2RibbonEmitter> ribbonEmitters;
|
||||
|
||||
// Collision mesh (simplified geometry for physics)
|
||||
std::vector<glm::vec3> collisionVertices;
|
||||
std::vector<uint16_t> collisionIndices; // 3 per triangle
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ private:
|
|||
|
||||
// Mouse settings
|
||||
float mouseSensitivity = 0.2f;
|
||||
bool invertMouse = false;
|
||||
bool invertMouse = true;
|
||||
bool mouseButtonDown = false;
|
||||
bool leftMouseDown = false;
|
||||
bool rightMouseDown = false;
|
||||
|
|
@ -186,7 +186,7 @@ private:
|
|||
static constexpr float COLLISION_FOCUS_RADIUS_THIRD_PERSON = 20.0f; // Reduced for performance
|
||||
static constexpr float COLLISION_FOCUS_RADIUS_FREE_FLY = 20.0f;
|
||||
static constexpr float MIN_PITCH = -88.0f; // Look almost straight down
|
||||
static constexpr float MAX_PITCH = 35.0f; // Limited upward look
|
||||
static constexpr float MAX_PITCH = 88.0f; // Look almost straight up (WoW standard)
|
||||
glm::vec3* followTarget = nullptr;
|
||||
glm::vec3 smoothedCamPos = glm::vec3(0.0f); // For smooth camera movement
|
||||
float smoothedCollisionDist_ = -1.0f; // Asymmetrically-smoothed WMO collision limit (-1 = uninitialised)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
#include <deque>
|
||||
#include <string>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
|
|
@ -130,6 +131,11 @@ struct M2ModelGPU {
|
|||
std::vector<VkTexture*> particleTextures; // Resolved Vulkan textures per emitter
|
||||
std::vector<VkDescriptorSet> particleTexSets; // Pre-allocated descriptor sets per emitter (stable, avoids per-frame alloc)
|
||||
|
||||
// Ribbon emitter data (kept from M2Model)
|
||||
std::vector<pipeline::M2RibbonEmitter> ribbonEmitters;
|
||||
std::vector<VkTexture*> ribbonTextures; // Resolved texture per ribbon emitter
|
||||
std::vector<VkDescriptorSet> ribbonTexSets; // Descriptor sets per ribbon emitter
|
||||
|
||||
// Texture transform data for UV animation
|
||||
std::vector<pipeline::M2TextureTransform> textureTransforms;
|
||||
std::vector<uint16_t> textureTransformLookup;
|
||||
|
|
@ -180,6 +186,19 @@ struct M2Instance {
|
|||
std::vector<float> emitterAccumulators; // fractional particle counter per emitter
|
||||
std::vector<M2Particle> particles;
|
||||
|
||||
// Ribbon emitter state
|
||||
struct RibbonEdge {
|
||||
glm::vec3 worldPos; // Spine world position when this edge was born
|
||||
glm::vec3 color; // Interpolated color at birth
|
||||
float alpha; // Interpolated alpha at birth
|
||||
float heightAbove;// Half-width above spine
|
||||
float heightBelow;// Half-width below spine
|
||||
float age; // Seconds since spawned
|
||||
};
|
||||
// One deque of edges per ribbon emitter on this instance
|
||||
std::vector<std::deque<RibbonEdge>> ribbonEdges;
|
||||
std::vector<float> ribbonEdgeAccumulators; // fractional edge counter per emitter
|
||||
|
||||
// Cached model flags (set at creation to avoid per-frame hash lookups)
|
||||
bool cachedHasAnimation = false;
|
||||
bool cachedDisableAnimation = false;
|
||||
|
|
@ -295,6 +314,11 @@ public:
|
|||
*/
|
||||
void renderSmokeParticles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet);
|
||||
|
||||
/**
|
||||
* Render M2 ribbon emitters (spell trails / wing effects)
|
||||
*/
|
||||
void renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSet);
|
||||
|
||||
void setInstancePosition(uint32_t instanceId, const glm::vec3& position);
|
||||
void setInstanceTransform(uint32_t instanceId, const glm::mat4& transform);
|
||||
void setInstanceAnimationFrozen(uint32_t instanceId, bool frozen);
|
||||
|
|
@ -374,6 +398,11 @@ private:
|
|||
VkPipeline smokePipeline_ = VK_NULL_HANDLE; // Smoke particles
|
||||
VkPipelineLayout smokePipelineLayout_ = VK_NULL_HANDLE;
|
||||
|
||||
// Ribbon pipelines (additive + alpha-blend)
|
||||
VkPipeline ribbonPipeline_ = VK_NULL_HANDLE; // Alpha-blend ribbons
|
||||
VkPipeline ribbonAdditivePipeline_ = VK_NULL_HANDLE; // Additive ribbons
|
||||
VkPipelineLayout ribbonPipelineLayout_ = VK_NULL_HANDLE;
|
||||
|
||||
// Descriptor set layouts
|
||||
VkDescriptorSetLayout materialSetLayout_ = VK_NULL_HANDLE; // set 1
|
||||
VkDescriptorSetLayout boneSetLayout_ = VK_NULL_HANDLE; // set 2
|
||||
|
|
@ -385,6 +414,12 @@ private:
|
|||
static constexpr uint32_t MAX_MATERIAL_SETS = 8192;
|
||||
static constexpr uint32_t MAX_BONE_SETS = 8192;
|
||||
|
||||
// Dynamic ribbon vertex buffer (CPU-written triangle strip)
|
||||
static constexpr size_t MAX_RIBBON_VERTS = 2048; // 9 floats each
|
||||
::VkBuffer ribbonVB_ = VK_NULL_HANDLE;
|
||||
VmaAllocation ribbonVBAlloc_ = VK_NULL_HANDLE;
|
||||
void* ribbonVBMapped_ = nullptr;
|
||||
|
||||
// Dynamic particle buffers
|
||||
::VkBuffer smokeVB_ = VK_NULL_HANDLE;
|
||||
VmaAllocation smokeVBAlloc_ = VK_NULL_HANDLE;
|
||||
|
|
@ -535,6 +570,7 @@ private:
|
|||
glm::vec3 interpFBlockVec3(const pipeline::M2FBlock& fb, float lifeRatio);
|
||||
void emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt);
|
||||
void updateParticles(M2Instance& inst, float dt);
|
||||
void updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt);
|
||||
|
||||
// Helper to allocate descriptor sets
|
||||
VkDescriptorSet allocateMaterialSet();
|
||||
|
|
|
|||
|
|
@ -638,6 +638,8 @@ private:
|
|||
bool terrainEnabled = true;
|
||||
bool terrainLoaded = false;
|
||||
|
||||
bool ghostMode_ = false; // set each frame from gameHandler->isPlayerGhost()
|
||||
|
||||
// CPU timing stats (last frame/update).
|
||||
double lastUpdateMs = 0.0;
|
||||
double lastRenderMs = 0.0;
|
||||
|
|
|
|||
|
|
@ -69,6 +69,12 @@ public:
|
|||
*/
|
||||
bool loadModel(const pipeline::WMOModel& model, uint32_t id);
|
||||
|
||||
/**
|
||||
* Check if a WMO model is currently resident in the renderer
|
||||
* @param id WMO model identifier
|
||||
*/
|
||||
bool isModelLoaded(uint32_t id) const;
|
||||
|
||||
/**
|
||||
* Unload WMO model and free GPU resources
|
||||
* @param id WMO model identifier
|
||||
|
|
|
|||
|
|
@ -158,6 +158,8 @@ private:
|
|||
ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f);
|
||||
bool chatWindowPosInit_ = false;
|
||||
ImVec2 questTrackerPos_ = ImVec2(-1.0f, -1.0f); // <0 = use default
|
||||
ImVec2 questTrackerSize_ = ImVec2(220.0f, 200.0f); // saved size
|
||||
float questTrackerRightOffset_ = -1.0f; // pixels from right edge; <0 = use default
|
||||
bool questTrackerPosInit_ = false;
|
||||
bool showEscapeMenu = false;
|
||||
bool showEscapeSettingsNotice = false;
|
||||
|
|
@ -361,6 +363,7 @@ private:
|
|||
void renderGuildInvitePopup(game::GameHandler& gameHandler);
|
||||
void renderReadyCheckPopup(game::GameHandler& gameHandler);
|
||||
void renderBgInvitePopup(game::GameHandler& gameHandler);
|
||||
void renderBfMgrInvitePopup(game::GameHandler& gameHandler);
|
||||
void renderLfgProposalPopup(game::GameHandler& gameHandler);
|
||||
void renderChatBubbles(game::GameHandler& gameHandler);
|
||||
void renderMailWindow(game::GameHandler& gameHandler);
|
||||
|
|
@ -429,8 +432,17 @@ private:
|
|||
char achievementSearchBuf_[128] = {};
|
||||
void renderAchievementWindow(game::GameHandler& gameHandler);
|
||||
|
||||
// Titles window
|
||||
bool showTitlesWindow_ = false;
|
||||
void renderTitlesWindow(game::GameHandler& gameHandler);
|
||||
|
||||
// Equipment Set Manager window
|
||||
bool showEquipSetWindow_ = false;
|
||||
void renderEquipSetWindow(game::GameHandler& gameHandler);
|
||||
|
||||
// GM Ticket window
|
||||
bool showGmTicketWindow_ = false;
|
||||
bool showGmTicketWindow_ = false;
|
||||
bool gmTicketWindowWasOpen_ = false; ///< Previous frame state; used to fire one-shot query
|
||||
char gmTicketBuf_[2048] = {};
|
||||
void renderGmTicketWindow(game::GameHandler& gameHandler);
|
||||
|
||||
|
|
@ -633,6 +645,7 @@ public:
|
|||
uint32_t str = 0, uint32_t agi = 0, uint32_t sta = 0,
|
||||
uint32_t intel = 0, uint32_t spi = 0);
|
||||
void triggerAchievementToast(uint32_t achievementId, std::string name = {});
|
||||
void openDungeonFinder() { showDungeonFinder_ = true; }
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
|
|
|
|||
|
|
@ -2550,6 +2550,11 @@ void Application::setupUICallbacks() {
|
|||
}
|
||||
});
|
||||
|
||||
// Open dungeon finder callback — server sends SMSG_OPEN_LFG_DUNGEON_FINDER
|
||||
gameHandler->setOpenLfgCallback([this]() {
|
||||
if (uiManager) uiManager->getGameScreen().openDungeonFinder();
|
||||
});
|
||||
|
||||
// Creature move callback (online mode) - update creature positions
|
||||
gameHandler->setCreatureMoveCallback([this](uint64_t guid, float x, float y, float z, uint32_t durationMs) {
|
||||
if (!renderer || !renderer->getCharacterRenderer()) return;
|
||||
|
|
@ -4108,6 +4113,8 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
|
|||
|
||||
gameObjectInstances_.clear();
|
||||
gameObjectDisplayIdModelCache_.clear();
|
||||
gameObjectDisplayIdWmoCache_.clear();
|
||||
gameObjectDisplayIdFailedCache_.clear();
|
||||
|
||||
// Force player character re-spawn on new map
|
||||
playerCharacterSpawned = false;
|
||||
|
|
@ -4458,7 +4465,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
|
|||
glm::vec3 worldPos = glm::vec3(worldMatrix[3]);
|
||||
|
||||
uint32_t doodadModelId = static_cast<uint32_t>(std::hash<std::string>{}(m2Path));
|
||||
m2Renderer->loadModel(m2Model, doodadModelId);
|
||||
if (!m2Renderer->loadModel(m2Model, doodadModelId)) continue;
|
||||
uint32_t doodadInstId = m2Renderer->createInstanceWithMatrix(doodadModelId, worldMatrix, worldPos);
|
||||
if (doodadInstId) m2Renderer->setSkipCollision(doodadInstId, true);
|
||||
loadedDoodads++;
|
||||
|
|
@ -6606,7 +6613,7 @@ void Application::spawnOnlinePlayer(uint64_t guid,
|
|||
}
|
||||
|
||||
pipeline::M2Model model = pipeline::M2Loader::load(m2Data);
|
||||
if (!model.isValid() || model.vertices.empty()) {
|
||||
if (model.vertices.empty()) {
|
||||
LOG_WARNING("spawnOnlinePlayer: failed to parse M2: ", m2Path);
|
||||
return;
|
||||
}
|
||||
|
|
@ -6618,6 +6625,12 @@ void Application::spawnOnlinePlayer(uint64_t guid,
|
|||
pipeline::M2Loader::loadSkin(skinData, model);
|
||||
}
|
||||
|
||||
// After skin loading, full model must be valid (vertices + indices)
|
||||
if (!model.isValid()) {
|
||||
LOG_WARNING("spawnOnlinePlayer: failed to load skin for M2: ", m2Path);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load only core external animations (stand/walk/run) to avoid stalls
|
||||
for (uint32_t si = 0; si < model.sequences.size(); si++) {
|
||||
if (!(model.sequences[si].flags & 0x20)) {
|
||||
|
|
@ -7103,8 +7116,15 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t
|
|||
auto itCache = gameObjectDisplayIdWmoCache_.find(displayId);
|
||||
if (itCache != gameObjectDisplayIdWmoCache_.end()) {
|
||||
modelId = itCache->second;
|
||||
loadedAsWmo = true;
|
||||
} else {
|
||||
// Only use cached entry if the model is still resident in the renderer
|
||||
if (wmoRenderer->isModelLoaded(modelId)) {
|
||||
loadedAsWmo = true;
|
||||
} else {
|
||||
gameObjectDisplayIdWmoCache_.erase(itCache);
|
||||
modelId = 0;
|
||||
}
|
||||
}
|
||||
if (!loadedAsWmo && modelId == 0) {
|
||||
auto wmoData = assetManager->readFile(modelPath);
|
||||
if (!wmoData.empty()) {
|
||||
pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData);
|
||||
|
|
@ -7229,6 +7249,11 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t
|
|||
auto* m2Renderer = renderer->getM2Renderer();
|
||||
if (!m2Renderer) return;
|
||||
|
||||
// Skip displayIds that permanently failed to load (e.g. empty/unsupported M2s).
|
||||
// Without this guard the same empty model is re-parsed every frame, causing
|
||||
// sustained log spam and wasted CPU.
|
||||
if (gameObjectDisplayIdFailedCache_.count(displayId)) return;
|
||||
|
||||
uint32_t modelId = 0;
|
||||
auto itCache = gameObjectDisplayIdModelCache_.find(displayId);
|
||||
if (itCache != gameObjectDisplayIdModelCache_.end()) {
|
||||
|
|
@ -7247,12 +7272,14 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t
|
|||
auto m2Data = assetManager->readFile(modelPath);
|
||||
if (m2Data.empty()) {
|
||||
LOG_WARNING("Failed to read gameobject M2: ", modelPath);
|
||||
gameObjectDisplayIdFailedCache_.insert(displayId);
|
||||
return;
|
||||
}
|
||||
|
||||
pipeline::M2Model model = pipeline::M2Loader::load(m2Data);
|
||||
if (model.vertices.empty()) {
|
||||
LOG_WARNING("Failed to parse gameobject M2: ", modelPath);
|
||||
gameObjectDisplayIdFailedCache_.insert(displayId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -7264,6 +7291,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t
|
|||
|
||||
if (!m2Renderer->loadModel(model, modelId)) {
|
||||
LOG_WARNING("Failed to load gameobject model: ", modelPath);
|
||||
gameObjectDisplayIdFailedCache_.insert(displayId);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -8184,6 +8212,13 @@ void Application::processPendingTransportDoodads() {
|
|||
auto startTime = std::chrono::steady_clock::now();
|
||||
static constexpr float kDoodadBudgetMs = 4.0f;
|
||||
|
||||
// Batch all GPU uploads into a single async command buffer submission so that
|
||||
// N doodads with multiple textures each don't each block on vkQueueSubmit +
|
||||
// vkWaitForFences. Without batching, 30+ doodads × several textures = hundreds
|
||||
// of sync GPU submits → the 490ms stall that preceded the VK_ERROR_DEVICE_LOST.
|
||||
auto* vkCtx = renderer->getVkContext();
|
||||
if (vkCtx) vkCtx->beginUploadBatch();
|
||||
|
||||
size_t budgetLeft = MAX_TRANSPORT_DOODADS_PER_FRAME;
|
||||
for (auto it = pendingTransportDoodadBatches_.begin();
|
||||
it != pendingTransportDoodadBatches_.end() && budgetLeft > 0;) {
|
||||
|
|
@ -8227,7 +8262,7 @@ void Application::processPendingTransportDoodads() {
|
|||
}
|
||||
if (!m2Model.isValid()) continue;
|
||||
|
||||
m2Renderer->loadModel(m2Model, doodadModelId);
|
||||
if (!m2Renderer->loadModel(m2Model, doodadModelId)) continue;
|
||||
uint32_t m2InstanceId = m2Renderer->createInstance(doodadModelId, glm::vec3(0.0f), glm::vec3(0.0f), 1.0f);
|
||||
if (m2InstanceId == 0) continue;
|
||||
m2Renderer->setSkipCollision(m2InstanceId, true);
|
||||
|
|
@ -8251,6 +8286,9 @@ void Application::processPendingTransportDoodads() {
|
|||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize the upload batch — submit all GPU copies in one shot (async, no wait).
|
||||
if (vkCtx) vkCtx->endUploadBatch();
|
||||
}
|
||||
|
||||
void Application::processPendingMount() {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1031,6 +1031,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
|
|||
|
||||
// Legacy UPDATE_OBJECT spline layout used by many servers:
|
||||
// timePassed, duration, splineId, durationMod, durationModNext,
|
||||
// [ANIMATION: animType(1)+animTime(4) if SPLINEFLAG_ANIMATION(0x00400000)],
|
||||
// verticalAccel, effectStartTime, pointCount, points, splineMode, endPoint.
|
||||
const size_t legacyStart = packet.getReadPos();
|
||||
if (!bytesAvailable(12 + 8 + 8 + 4)) return false;
|
||||
|
|
@ -1039,6 +1040,12 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
|
|||
/*uint32_t splineId =*/ packet.readUInt32();
|
||||
/*float durationMod =*/ packet.readFloat();
|
||||
/*float durationModNext =*/ packet.readFloat();
|
||||
// Animation flag inserts 5 bytes (uint8 type + int32 time) before verticalAccel
|
||||
if (splineFlags & 0x00400000) { // SPLINEFLAG_ANIMATION
|
||||
if (!bytesAvailable(5)) return false;
|
||||
packet.readUInt8(); // animationType
|
||||
packet.readUInt32(); // animTime
|
||||
}
|
||||
/*float verticalAccel =*/ packet.readFloat();
|
||||
/*uint32_t effectStartTime =*/ packet.readUInt32();
|
||||
uint32_t pointCount = packet.readUInt32();
|
||||
|
|
@ -1722,6 +1729,15 @@ network::Packet InspectPacket::build(uint64_t targetGuid) {
|
|||
return packet;
|
||||
}
|
||||
|
||||
network::Packet QueryInspectAchievementsPacket::build(uint64_t targetGuid) {
|
||||
// CMSG_QUERY_INSPECT_ACHIEVEMENTS: uint64 targetGuid + uint8 unk (always 0)
|
||||
network::Packet packet(wireOpcode(Opcode::CMSG_QUERY_INSPECT_ACHIEVEMENTS));
|
||||
packet.writeUInt64(targetGuid);
|
||||
packet.writeUInt8(0); // unk / achievementSlot — always 0 for WotLK
|
||||
LOG_DEBUG("Built CMSG_QUERY_INSPECT_ACHIEVEMENTS: target=0x", std::hex, targetGuid, std::dec);
|
||||
return packet;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Server Info Commands
|
||||
// ============================================================
|
||||
|
|
@ -3225,17 +3241,25 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda
|
|||
data.totalDamage = static_cast<int32_t>(packet.readUInt32());
|
||||
data.subDamageCount = packet.readUInt8();
|
||||
|
||||
// Cap subDamageCount to prevent OOM (each entry is 20 bytes: 4+4+4+4+4)
|
||||
if (data.subDamageCount > 64) {
|
||||
LOG_WARNING("AttackerStateUpdate: subDamageCount capped (requested=", (int)data.subDamageCount, ")");
|
||||
data.subDamageCount = 64;
|
||||
// Cap subDamageCount: each entry is 20 bytes. If the claimed count
|
||||
// exceeds what the remaining bytes can hold, a GUID was mis-parsed
|
||||
// (off by one byte), causing the school-mask byte to be read as count.
|
||||
// In that case silently clamp to the number of full entries that fit.
|
||||
{
|
||||
size_t remaining = packet.getSize() - packet.getReadPos();
|
||||
size_t maxFit = remaining / 20;
|
||||
if (data.subDamageCount > maxFit) {
|
||||
data.subDamageCount = static_cast<uint8_t>(maxFit > 0 ? 1 : 0);
|
||||
} else if (data.subDamageCount > 64) {
|
||||
data.subDamageCount = 64;
|
||||
}
|
||||
}
|
||||
if (data.subDamageCount == 0) return false;
|
||||
|
||||
data.subDamages.reserve(data.subDamageCount);
|
||||
for (uint8_t i = 0; i < data.subDamageCount; ++i) {
|
||||
// Each sub-damage entry needs 20 bytes: schoolMask(4) + damage(4) + intDamage(4) + absorbed(4) + resisted(4)
|
||||
if (packet.getSize() - packet.getReadPos() < 20) {
|
||||
LOG_WARNING("AttackerStateUpdate: truncated subDamage at index ", (int)i, "/", (int)data.subDamageCount);
|
||||
data.subDamageCount = i;
|
||||
break;
|
||||
}
|
||||
|
|
@ -3250,21 +3274,25 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda
|
|||
|
||||
// Validate victimState + overkill fields (8 bytes)
|
||||
if (packet.getSize() - packet.getReadPos() < 8) {
|
||||
LOG_WARNING("AttackerStateUpdate: truncated victimState/overkill");
|
||||
data.victimState = 0;
|
||||
data.overkill = 0;
|
||||
return !data.subDamages.empty();
|
||||
}
|
||||
|
||||
data.victimState = packet.readUInt32();
|
||||
data.overkill = static_cast<int32_t>(packet.readUInt32());
|
||||
// WotLK (AzerothCore): two unknown uint32 fields follow victimState before overkill.
|
||||
// Older parsers omitted these, reading overkill from the wrong offset.
|
||||
auto rem = [&]() { return packet.getSize() - packet.getReadPos(); };
|
||||
if (rem() >= 4) packet.readUInt32(); // unk1 (always 0)
|
||||
if (rem() >= 4) packet.readUInt32(); // unk2 (melee spell ID, 0 for auto-attack)
|
||||
data.overkill = (rem() >= 4) ? static_cast<int32_t>(packet.readUInt32()) : -1;
|
||||
|
||||
// Read blocked amount (optional, 4 bytes)
|
||||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||||
data.blocked = packet.readUInt32();
|
||||
} else {
|
||||
data.blocked = 0;
|
||||
}
|
||||
// hitInfo-conditional fields: HITINFO_BLOCK(0x2000), RAGE_GAIN(0x20000), FAKE_DAMAGE(0x40)
|
||||
if ((data.hitInfo & 0x2000) && rem() >= 4) data.blocked = packet.readUInt32();
|
||||
else data.blocked = 0;
|
||||
// RAGE_GAIN and FAKE_DAMAGE both add a uint32 we can skip
|
||||
if ((data.hitInfo & 0x20000) && rem() >= 4) packet.readUInt32(); // rage gain
|
||||
if ((data.hitInfo & 0x40) && rem() >= 4) packet.readUInt32(); // fake damage total
|
||||
|
||||
LOG_DEBUG("Melee hit: ", data.totalDamage, " damage",
|
||||
data.isCrit() ? " (CRIT)" : "",
|
||||
|
|
@ -3962,13 +3990,27 @@ network::Packet LootReleasePacket::build(uint64_t lootGuid) {
|
|||
|
||||
bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data, bool isWotlkFormat) {
|
||||
data = LootResponseData{};
|
||||
if (packet.getSize() - packet.getReadPos() < 14) {
|
||||
LOG_WARNING("LootResponseParser: packet too short");
|
||||
size_t avail = packet.getSize() - packet.getReadPos();
|
||||
|
||||
// Minimum is guid(8)+lootType(1) = 9 bytes. Servers send a short packet with
|
||||
// lootType=0 (LOOT_NONE) when loot is unavailable (e.g. chest not yet opened,
|
||||
// needs a key, or another player is looting). We treat this as an empty-loot
|
||||
// signal and return false so the caller knows not to open the loot window.
|
||||
if (avail < 9) {
|
||||
LOG_WARNING("LootResponseParser: packet too short (", avail, " bytes)");
|
||||
return false;
|
||||
}
|
||||
|
||||
data.lootGuid = packet.readUInt64();
|
||||
data.lootType = packet.readUInt8();
|
||||
|
||||
// Short failure packet — no gold/item data follows.
|
||||
avail = packet.getSize() - packet.getReadPos();
|
||||
if (avail < 5) {
|
||||
LOG_DEBUG("LootResponseParser: lootType=", (int)data.lootType, " (empty/failure response)");
|
||||
return false;
|
||||
}
|
||||
|
||||
data.gold = packet.readUInt32();
|
||||
uint8_t itemCount = packet.readUInt8();
|
||||
|
||||
|
|
@ -5429,5 +5471,12 @@ network::Packet PetRenamePacket::build(uint64_t petGuid, const std::string& name
|
|||
return p;
|
||||
}
|
||||
|
||||
network::Packet SetTitlePacket::build(int32_t titleBit) {
|
||||
// CMSG_SET_TITLE: int32 titleBit (-1 = remove active title)
|
||||
network::Packet p(wireOpcode(Opcode::CMSG_SET_TITLE));
|
||||
p.writeUInt32(static_cast<uint32_t>(titleBit));
|
||||
return p;
|
||||
}
|
||||
|
||||
} // namespace game
|
||||
} // namespace wowee
|
||||
|
|
|
|||
|
|
@ -1258,6 +1258,125 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
|
|||
} // end size check
|
||||
}
|
||||
|
||||
// Parse ribbon emitters (WotLK only; vanilla format TBD).
|
||||
// WotLK M2RibbonEmitter = 0xAC (172) bytes per entry.
|
||||
static constexpr uint32_t RIBBON_SIZE_WOTLK = 0xAC;
|
||||
if (header.nRibbonEmitters > 0 && header.ofsRibbonEmitters > 0 &&
|
||||
header.nRibbonEmitters < 64 && header.version >= 264) {
|
||||
|
||||
if (static_cast<size_t>(header.ofsRibbonEmitters) +
|
||||
static_cast<size_t>(header.nRibbonEmitters) * RIBBON_SIZE_WOTLK <= m2Data.size()) {
|
||||
|
||||
// Build sequence flags for parseAnimTrack
|
||||
std::vector<uint32_t> ribSeqFlags;
|
||||
ribSeqFlags.reserve(model.sequences.size());
|
||||
for (const auto& seq : model.sequences) {
|
||||
ribSeqFlags.push_back(seq.flags);
|
||||
}
|
||||
|
||||
for (uint32_t ri = 0; ri < header.nRibbonEmitters; ri++) {
|
||||
uint32_t base = header.ofsRibbonEmitters + ri * RIBBON_SIZE_WOTLK;
|
||||
|
||||
M2RibbonEmitter rib;
|
||||
rib.ribbonId = readValue<int32_t>(m2Data, base + 0x00);
|
||||
rib.bone = readValue<uint32_t>(m2Data, base + 0x04);
|
||||
rib.position.x = readValue<float>(m2Data, base + 0x08);
|
||||
rib.position.y = readValue<float>(m2Data, base + 0x0C);
|
||||
rib.position.z = readValue<float>(m2Data, base + 0x10);
|
||||
|
||||
// textureIndices M2Array (0x14): count + offset → first element = texture lookup index
|
||||
{
|
||||
uint32_t nTex = readValue<uint32_t>(m2Data, base + 0x14);
|
||||
uint32_t ofsTex = readValue<uint32_t>(m2Data, base + 0x18);
|
||||
if (nTex > 0 && ofsTex + sizeof(uint16_t) <= m2Data.size()) {
|
||||
rib.textureIndex = readValue<uint16_t>(m2Data, ofsTex);
|
||||
}
|
||||
}
|
||||
|
||||
// materialIndices M2Array (0x1C): count + offset → first element = material index
|
||||
{
|
||||
uint32_t nMat = readValue<uint32_t>(m2Data, base + 0x1C);
|
||||
uint32_t ofsMat = readValue<uint32_t>(m2Data, base + 0x20);
|
||||
if (nMat > 0 && ofsMat + sizeof(uint16_t) <= m2Data.size()) {
|
||||
rib.materialIndex = readValue<uint16_t>(m2Data, ofsMat);
|
||||
}
|
||||
}
|
||||
|
||||
// colorTrack M2TrackDisk at 0x24 (vec3 RGB 0..1)
|
||||
if (base + 0x24 + sizeof(M2TrackDisk) <= m2Data.size()) {
|
||||
M2TrackDisk disk = readValue<M2TrackDisk>(m2Data, base + 0x24);
|
||||
parseAnimTrack(m2Data, disk, rib.colorTrack, TrackType::VEC3, ribSeqFlags);
|
||||
}
|
||||
|
||||
// alphaTrack M2TrackDisk at 0x38 (fixed16: int16/32767)
|
||||
// Same nested-array layout as parseAnimTrack but keys are int16.
|
||||
if (base + 0x38 + sizeof(M2TrackDisk) <= m2Data.size()) {
|
||||
M2TrackDisk disk = readValue<M2TrackDisk>(m2Data, base + 0x38);
|
||||
auto& track = rib.alphaTrack;
|
||||
track.interpolationType = disk.interpolationType;
|
||||
track.globalSequence = disk.globalSequence;
|
||||
uint32_t nSeqs = disk.nTimestamps;
|
||||
if (nSeqs > 0 && nSeqs <= 4096) {
|
||||
track.sequences.resize(nSeqs);
|
||||
for (uint32_t s = 0; s < nSeqs; s++) {
|
||||
if (s < ribSeqFlags.size() && !(ribSeqFlags[s] & 0x20)) continue;
|
||||
uint32_t tsHdr = disk.ofsTimestamps + s * 8;
|
||||
uint32_t keyHdr = disk.ofsKeys + s * 8;
|
||||
if (tsHdr + 8 > m2Data.size() || keyHdr + 8 > m2Data.size()) continue;
|
||||
uint32_t tsCount = readValue<uint32_t>(m2Data, tsHdr);
|
||||
uint32_t tsOfs = readValue<uint32_t>(m2Data, tsHdr + 4);
|
||||
uint32_t kCount = readValue<uint32_t>(m2Data, keyHdr);
|
||||
uint32_t kOfs = readValue<uint32_t>(m2Data, keyHdr + 4);
|
||||
if (tsCount == 0 || kCount == 0) continue;
|
||||
if (tsOfs + tsCount * 4 > m2Data.size()) continue;
|
||||
if (kOfs + kCount * sizeof(int16_t) > m2Data.size()) continue;
|
||||
track.sequences[s].timestamps = readArray<uint32_t>(m2Data, tsOfs, tsCount);
|
||||
auto raw = readArray<int16_t>(m2Data, kOfs, kCount);
|
||||
track.sequences[s].floatValues.reserve(raw.size());
|
||||
for (auto v : raw) {
|
||||
track.sequences[s].floatValues.push_back(
|
||||
static_cast<float>(v) / 32767.0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// heightAboveTrack M2TrackDisk at 0x4C (float)
|
||||
if (base + 0x4C + sizeof(M2TrackDisk) <= m2Data.size()) {
|
||||
M2TrackDisk disk = readValue<M2TrackDisk>(m2Data, base + 0x4C);
|
||||
parseAnimTrack(m2Data, disk, rib.heightAboveTrack, TrackType::FLOAT, ribSeqFlags);
|
||||
}
|
||||
|
||||
// heightBelowTrack M2TrackDisk at 0x60 (float)
|
||||
if (base + 0x60 + sizeof(M2TrackDisk) <= m2Data.size()) {
|
||||
M2TrackDisk disk = readValue<M2TrackDisk>(m2Data, base + 0x60);
|
||||
parseAnimTrack(m2Data, disk, rib.heightBelowTrack, TrackType::FLOAT, ribSeqFlags);
|
||||
}
|
||||
|
||||
rib.edgesPerSecond = readValue<float>(m2Data, base + 0x74);
|
||||
rib.edgeLifetime = readValue<float>(m2Data, base + 0x78);
|
||||
rib.gravity = readValue<float>(m2Data, base + 0x7C);
|
||||
rib.textureRows = readValue<uint16_t>(m2Data, base + 0x80);
|
||||
rib.textureCols = readValue<uint16_t>(m2Data, base + 0x82);
|
||||
if (rib.textureRows == 0) rib.textureRows = 1;
|
||||
if (rib.textureCols == 0) rib.textureCols = 1;
|
||||
|
||||
// Clamp to sane values
|
||||
if (rib.edgesPerSecond < 1.0f || rib.edgesPerSecond > 200.0f) rib.edgesPerSecond = 15.0f;
|
||||
if (rib.edgeLifetime < 0.05f || rib.edgeLifetime > 10.0f) rib.edgeLifetime = 0.5f;
|
||||
|
||||
// visibilityTrack M2TrackDisk at 0x98 (uint8, treat as float 0/1)
|
||||
if (base + 0x98 + sizeof(M2TrackDisk) <= m2Data.size()) {
|
||||
M2TrackDisk disk = readValue<M2TrackDisk>(m2Data, base + 0x98);
|
||||
parseAnimTrack(m2Data, disk, rib.visibilityTrack, TrackType::FLOAT, ribSeqFlags);
|
||||
}
|
||||
|
||||
model.ribbonEmitters.push_back(std::move(rib));
|
||||
}
|
||||
core::Logger::getInstance().debug(" Ribbon emitters: ", model.ribbonEmitters.size());
|
||||
}
|
||||
}
|
||||
|
||||
// Read collision mesh (bounding triangles/vertices/normals)
|
||||
if (header.nBoundingVertices > 0 && header.ofsBoundingVertices > 0) {
|
||||
struct Vec3Disk { float x, y, z; };
|
||||
|
|
|
|||
|
|
@ -1903,7 +1903,9 @@ void CameraController::processMouseMotion(const SDL_MouseMotionEvent& event) {
|
|||
|
||||
// Directly update stored yaw/pitch (no lossy forward-vector derivation)
|
||||
yaw -= event.xrel * mouseSensitivity;
|
||||
float invert = invertMouse ? -1.0f : 1.0f;
|
||||
// SDL yrel > 0 = mouse moved DOWN. In WoW, mouse-down = look down = pitch decreases.
|
||||
// invertMouse flips to flight-sim style (mouse-down = look up).
|
||||
float invert = invertMouse ? 1.0f : -1.0f;
|
||||
pitch += event.yrel * mouseSensitivity * invert;
|
||||
|
||||
// WoW-style pitch limits: can look almost straight down, limited upward
|
||||
|
|
|
|||
|
|
@ -540,6 +540,54 @@ bool M2Renderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout
|
|||
.build(device);
|
||||
}
|
||||
|
||||
// --- Build ribbon pipelines ---
|
||||
// Vertex format: pos(3) + color(3) + alpha(1) + uv(2) = 9 floats = 36 bytes
|
||||
{
|
||||
rendering::VkShaderModule ribVert, ribFrag;
|
||||
ribVert.loadFromFile(device, "assets/shaders/m2_ribbon.vert.spv");
|
||||
ribFrag.loadFromFile(device, "assets/shaders/m2_ribbon.frag.spv");
|
||||
if (ribVert.isValid() && ribFrag.isValid()) {
|
||||
// Reuse particleTexLayout_ for set 1 (single texture sampler)
|
||||
VkDescriptorSetLayout ribLayouts[] = {perFrameLayout, particleTexLayout_};
|
||||
VkPipelineLayoutCreateInfo lci{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO};
|
||||
lci.setLayoutCount = 2;
|
||||
lci.pSetLayouts = ribLayouts;
|
||||
vkCreatePipelineLayout(device, &lci, nullptr, &ribbonPipelineLayout_);
|
||||
|
||||
VkVertexInputBindingDescription rBind{};
|
||||
rBind.binding = 0;
|
||||
rBind.stride = 9 * sizeof(float);
|
||||
rBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
|
||||
|
||||
std::vector<VkVertexInputAttributeDescription> rAttrs = {
|
||||
{0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // pos
|
||||
{1, 0, VK_FORMAT_R32G32B32_SFLOAT, 3 * sizeof(float)}, // color
|
||||
{2, 0, VK_FORMAT_R32_SFLOAT, 6 * sizeof(float)}, // alpha
|
||||
{3, 0, VK_FORMAT_R32G32_SFLOAT, 7 * sizeof(float)}, // uv
|
||||
};
|
||||
|
||||
auto buildRibbonPipeline = [&](VkPipelineColorBlendAttachmentState blend) -> VkPipeline {
|
||||
return PipelineBuilder()
|
||||
.setShaders(ribVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT),
|
||||
ribFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT))
|
||||
.setVertexInput({rBind}, rAttrs)
|
||||
.setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP)
|
||||
.setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE)
|
||||
.setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL)
|
||||
.setColorBlendAttachment(blend)
|
||||
.setMultisample(vkCtx_->getMsaaSamples())
|
||||
.setLayout(ribbonPipelineLayout_)
|
||||
.setRenderPass(mainPass)
|
||||
.setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR})
|
||||
.build(device);
|
||||
};
|
||||
|
||||
ribbonPipeline_ = buildRibbonPipeline(PipelineBuilder::blendAlpha());
|
||||
ribbonAdditivePipeline_ = buildRibbonPipeline(PipelineBuilder::blendAdditive());
|
||||
}
|
||||
ribVert.destroy(); ribFrag.destroy();
|
||||
}
|
||||
|
||||
// Clean up shader modules
|
||||
m2Vert.destroy(); m2Frag.destroy();
|
||||
particleVert.destroy(); particleFrag.destroy();
|
||||
|
|
@ -570,6 +618,11 @@ bool M2Renderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout
|
|||
bci.size = MAX_GLOW_SPRITES * 9 * sizeof(float);
|
||||
vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, &glowVB_, &glowVBAlloc_, &allocInfo);
|
||||
glowVBMapped_ = allocInfo.pMappedData;
|
||||
|
||||
// Ribbon vertex buffer — triangle strip: pos(3)+color(3)+alpha(1)+uv(2)=9 floats/vert
|
||||
bci.size = MAX_RIBBON_VERTS * 9 * sizeof(float);
|
||||
vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, &ribbonVB_, &ribbonVBAlloc_, &allocInfo);
|
||||
ribbonVBMapped_ = allocInfo.pMappedData;
|
||||
}
|
||||
|
||||
// --- Create white fallback texture ---
|
||||
|
|
@ -666,10 +719,11 @@ void M2Renderer::shutdown() {
|
|||
whiteTexture_.reset();
|
||||
glowTexture_.reset();
|
||||
|
||||
// Clean up particle buffers
|
||||
// Clean up particle/ribbon buffers
|
||||
if (smokeVB_) { vmaDestroyBuffer(alloc, smokeVB_, smokeVBAlloc_); smokeVB_ = VK_NULL_HANDLE; }
|
||||
if (m2ParticleVB_) { vmaDestroyBuffer(alloc, m2ParticleVB_, m2ParticleVBAlloc_); m2ParticleVB_ = VK_NULL_HANDLE; }
|
||||
if (glowVB_) { vmaDestroyBuffer(alloc, glowVB_, glowVBAlloc_); glowVB_ = VK_NULL_HANDLE; }
|
||||
if (ribbonVB_) { vmaDestroyBuffer(alloc, ribbonVB_, ribbonVBAlloc_); ribbonVB_ = VK_NULL_HANDLE; }
|
||||
smokeParticles.clear();
|
||||
|
||||
// Destroy pipelines
|
||||
|
|
@ -681,10 +735,13 @@ void M2Renderer::shutdown() {
|
|||
destroyPipeline(particlePipeline_);
|
||||
destroyPipeline(particleAdditivePipeline_);
|
||||
destroyPipeline(smokePipeline_);
|
||||
destroyPipeline(ribbonPipeline_);
|
||||
destroyPipeline(ribbonAdditivePipeline_);
|
||||
|
||||
if (pipelineLayout_) { vkDestroyPipelineLayout(device, pipelineLayout_, nullptr); pipelineLayout_ = VK_NULL_HANDLE; }
|
||||
if (particlePipelineLayout_) { vkDestroyPipelineLayout(device, particlePipelineLayout_, nullptr); particlePipelineLayout_ = VK_NULL_HANDLE; }
|
||||
if (smokePipelineLayout_) { vkDestroyPipelineLayout(device, smokePipelineLayout_, nullptr); smokePipelineLayout_ = VK_NULL_HANDLE; }
|
||||
if (ribbonPipelineLayout_) { vkDestroyPipelineLayout(device, ribbonPipelineLayout_, nullptr); ribbonPipelineLayout_ = VK_NULL_HANDLE; }
|
||||
|
||||
// Destroy descriptor pools and layouts
|
||||
if (materialDescPool_) { vkDestroyDescriptorPool(device, materialDescPool_, nullptr); materialDescPool_ = VK_NULL_HANDLE; }
|
||||
|
|
@ -719,6 +776,11 @@ void M2Renderer::destroyModelGPU(M2ModelGPU& model) {
|
|||
if (pSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &pSet); pSet = VK_NULL_HANDLE; }
|
||||
}
|
||||
model.particleTexSets.clear();
|
||||
// Free ribbon texture descriptor sets
|
||||
for (auto& rSet : model.ribbonTexSets) {
|
||||
if (rSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &rSet); rSet = VK_NULL_HANDLE; }
|
||||
}
|
||||
model.ribbonTexSets.clear();
|
||||
}
|
||||
|
||||
void M2Renderer::destroyInstanceBones(M2Instance& inst) {
|
||||
|
|
@ -882,8 +944,9 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
|
|||
|
||||
bool hasGeometry = !model.vertices.empty() && !model.indices.empty();
|
||||
bool hasParticles = !model.particleEmitters.empty();
|
||||
if (!hasGeometry && !hasParticles) {
|
||||
LOG_WARNING("M2 model has no geometry and no particles: ", model.name);
|
||||
bool hasRibbons = !model.ribbonEmitters.empty();
|
||||
if (!hasGeometry && !hasParticles && !hasRibbons) {
|
||||
LOG_WARNING("M2 model has no renderable content: ", model.name);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -1345,6 +1408,43 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
|
|||
}
|
||||
}
|
||||
|
||||
// Copy ribbon emitter data and resolve textures
|
||||
gpuModel.ribbonEmitters = model.ribbonEmitters;
|
||||
if (!model.ribbonEmitters.empty()) {
|
||||
VkDevice device = vkCtx_->getDevice();
|
||||
gpuModel.ribbonTextures.resize(model.ribbonEmitters.size(), whiteTexture_.get());
|
||||
gpuModel.ribbonTexSets.resize(model.ribbonEmitters.size(), VK_NULL_HANDLE);
|
||||
for (size_t ri = 0; ri < model.ribbonEmitters.size(); ri++) {
|
||||
// Resolve texture via textureLookup table
|
||||
uint16_t texLookupIdx = model.ribbonEmitters[ri].textureIndex;
|
||||
uint32_t texIdx = (texLookupIdx < model.textureLookup.size())
|
||||
? model.textureLookup[texLookupIdx] : UINT32_MAX;
|
||||
if (texIdx < allTextures.size() && allTextures[texIdx] != nullptr) {
|
||||
gpuModel.ribbonTextures[ri] = allTextures[texIdx];
|
||||
}
|
||||
// Allocate descriptor set (reuse particleTexLayout_ = single sampler)
|
||||
if (particleTexLayout_ && materialDescPool_) {
|
||||
VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO};
|
||||
ai.descriptorPool = materialDescPool_;
|
||||
ai.descriptorSetCount = 1;
|
||||
ai.pSetLayouts = &particleTexLayout_;
|
||||
if (vkAllocateDescriptorSets(device, &ai, &gpuModel.ribbonTexSets[ri]) == VK_SUCCESS) {
|
||||
VkTexture* tex = gpuModel.ribbonTextures[ri];
|
||||
VkDescriptorImageInfo imgInfo = tex->descriptorInfo();
|
||||
VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET};
|
||||
write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET;
|
||||
write.dstSet = gpuModel.ribbonTexSets[ri];
|
||||
write.dstBinding = 0;
|
||||
write.descriptorCount = 1;
|
||||
write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER;
|
||||
write.pImageInfo = &imgInfo;
|
||||
vkUpdateDescriptorSets(device, 1, &write, 0, nullptr);
|
||||
}
|
||||
}
|
||||
}
|
||||
LOG_DEBUG(" Ribbon emitters loaded: ", model.ribbonEmitters.size());
|
||||
}
|
||||
|
||||
// Copy texture transform data for UV animation
|
||||
gpuModel.textureTransforms = model.textureTransforms;
|
||||
gpuModel.textureTransformLookup = model.textureTransformLookup;
|
||||
|
|
@ -2241,6 +2341,9 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::
|
|||
if (!instance.cachedModel) continue;
|
||||
emitParticles(instance, *instance.cachedModel, deltaTime);
|
||||
updateParticles(instance, deltaTime);
|
||||
if (!instance.cachedModel->ribbonEmitters.empty()) {
|
||||
updateRibbons(instance, *instance.cachedModel, deltaTime);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -3375,6 +3478,214 @@ void M2Renderer::updateParticles(M2Instance& inst, float dt) {
|
|||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ribbon emitter simulation
|
||||
// ---------------------------------------------------------------------------
|
||||
void M2Renderer::updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt) {
|
||||
const auto& emitters = gpu.ribbonEmitters;
|
||||
if (emitters.empty()) return;
|
||||
|
||||
// Grow per-instance state arrays if needed
|
||||
if (inst.ribbonEdges.size() != emitters.size()) {
|
||||
inst.ribbonEdges.resize(emitters.size());
|
||||
}
|
||||
if (inst.ribbonEdgeAccumulators.size() != emitters.size()) {
|
||||
inst.ribbonEdgeAccumulators.resize(emitters.size(), 0.0f);
|
||||
}
|
||||
|
||||
for (size_t ri = 0; ri < emitters.size(); ri++) {
|
||||
const auto& em = emitters[ri];
|
||||
auto& edges = inst.ribbonEdges[ri];
|
||||
auto& accum = inst.ribbonEdgeAccumulators[ri];
|
||||
|
||||
// Determine bone world position for spine
|
||||
glm::vec3 spineWorld = inst.position;
|
||||
if (em.bone < inst.boneMatrices.size()) {
|
||||
glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f);
|
||||
spineWorld = glm::vec3(inst.modelMatrix * inst.boneMatrices[em.bone] * local);
|
||||
} else {
|
||||
glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f);
|
||||
spineWorld = glm::vec3(inst.modelMatrix * local);
|
||||
}
|
||||
|
||||
// Evaluate animated tracks (use first available sequence key, or fallback value)
|
||||
auto getFloatVal = [&](const pipeline::M2AnimationTrack& track, float fallback) -> float {
|
||||
for (const auto& seq : track.sequences) {
|
||||
if (!seq.floatValues.empty()) return seq.floatValues[0];
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
auto getVec3Val = [&](const pipeline::M2AnimationTrack& track, glm::vec3 fallback) -> glm::vec3 {
|
||||
for (const auto& seq : track.sequences) {
|
||||
if (!seq.vec3Values.empty()) return seq.vec3Values[0];
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
float visibility = getFloatVal(em.visibilityTrack, 1.0f);
|
||||
float heightAbove = getFloatVal(em.heightAboveTrack, 0.5f);
|
||||
float heightBelow = getFloatVal(em.heightBelowTrack, 0.5f);
|
||||
glm::vec3 color = getVec3Val(em.colorTrack, glm::vec3(1.0f));
|
||||
float alpha = getFloatVal(em.alphaTrack, 1.0f);
|
||||
|
||||
// Age existing edges and remove expired ones
|
||||
for (auto& e : edges) {
|
||||
e.age += dt;
|
||||
// Apply gravity
|
||||
if (em.gravity != 0.0f) {
|
||||
e.worldPos.z -= em.gravity * dt * dt * 0.5f;
|
||||
}
|
||||
}
|
||||
while (!edges.empty() && edges.front().age >= em.edgeLifetime) {
|
||||
edges.pop_front();
|
||||
}
|
||||
|
||||
// Emit new edges based on edgesPerSecond
|
||||
if (visibility > 0.5f) {
|
||||
accum += em.edgesPerSecond * dt;
|
||||
while (accum >= 1.0f) {
|
||||
accum -= 1.0f;
|
||||
M2Instance::RibbonEdge e;
|
||||
e.worldPos = spineWorld;
|
||||
e.color = color;
|
||||
e.alpha = alpha;
|
||||
e.heightAbove = heightAbove;
|
||||
e.heightBelow = heightBelow;
|
||||
e.age = 0.0f;
|
||||
edges.push_back(e);
|
||||
// Cap trail length
|
||||
if (edges.size() > 128) edges.pop_front();
|
||||
}
|
||||
} else {
|
||||
accum = 0.0f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ribbon rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
void M2Renderer::renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) {
|
||||
if (!ribbonPipeline_ || !ribbonAdditivePipeline_ || !ribbonVB_ || !ribbonVBMapped_) return;
|
||||
|
||||
// Build camera right vector for billboard orientation
|
||||
// For ribbons we orient the quad strip along the spine with screen-space up.
|
||||
// Simple approach: use world-space Z=up for the ribbon cross direction.
|
||||
const glm::vec3 upWorld(0.0f, 0.0f, 1.0f);
|
||||
|
||||
float* dst = static_cast<float*>(ribbonVBMapped_);
|
||||
size_t written = 0;
|
||||
|
||||
struct DrawCall {
|
||||
VkDescriptorSet texSet;
|
||||
VkPipeline pipeline;
|
||||
uint32_t firstVertex;
|
||||
uint32_t vertexCount;
|
||||
};
|
||||
std::vector<DrawCall> draws;
|
||||
|
||||
for (const auto& inst : instances) {
|
||||
if (!inst.cachedModel) continue;
|
||||
const auto& gpu = *inst.cachedModel;
|
||||
if (gpu.ribbonEmitters.empty()) continue;
|
||||
|
||||
for (size_t ri = 0; ri < gpu.ribbonEmitters.size(); ri++) {
|
||||
if (ri >= inst.ribbonEdges.size()) continue;
|
||||
const auto& edges = inst.ribbonEdges[ri];
|
||||
if (edges.size() < 2) continue;
|
||||
|
||||
const auto& em = gpu.ribbonEmitters[ri];
|
||||
|
||||
// Select blend pipeline based on material blend mode
|
||||
bool additive = false;
|
||||
if (em.materialIndex < gpu.batches.size()) {
|
||||
additive = (gpu.batches[em.materialIndex].blendMode >= 3);
|
||||
}
|
||||
VkPipeline pipe = additive ? ribbonAdditivePipeline_ : ribbonPipeline_;
|
||||
|
||||
// Descriptor set for texture
|
||||
VkDescriptorSet texSet = (ri < gpu.ribbonTexSets.size())
|
||||
? gpu.ribbonTexSets[ri] : VK_NULL_HANDLE;
|
||||
if (!texSet) continue;
|
||||
|
||||
uint32_t firstVert = static_cast<uint32_t>(written);
|
||||
|
||||
// Emit triangle strip: 2 verts per edge (top + bottom)
|
||||
for (size_t ei = 0; ei < edges.size(); ei++) {
|
||||
if (written + 2 > MAX_RIBBON_VERTS) break;
|
||||
const auto& e = edges[ei];
|
||||
float t = (em.edgeLifetime > 0.0f)
|
||||
? 1.0f - (e.age / em.edgeLifetime) : 1.0f;
|
||||
float a = e.alpha * t;
|
||||
float u = static_cast<float>(ei) / static_cast<float>(edges.size() - 1);
|
||||
|
||||
// Top vertex (above spine along upWorld)
|
||||
glm::vec3 top = e.worldPos + upWorld * e.heightAbove;
|
||||
dst[written * 9 + 0] = top.x;
|
||||
dst[written * 9 + 1] = top.y;
|
||||
dst[written * 9 + 2] = top.z;
|
||||
dst[written * 9 + 3] = e.color.r;
|
||||
dst[written * 9 + 4] = e.color.g;
|
||||
dst[written * 9 + 5] = e.color.b;
|
||||
dst[written * 9 + 6] = a;
|
||||
dst[written * 9 + 7] = u;
|
||||
dst[written * 9 + 8] = 0.0f; // v = top
|
||||
written++;
|
||||
|
||||
// Bottom vertex (below spine)
|
||||
glm::vec3 bot = e.worldPos - upWorld * e.heightBelow;
|
||||
dst[written * 9 + 0] = bot.x;
|
||||
dst[written * 9 + 1] = bot.y;
|
||||
dst[written * 9 + 2] = bot.z;
|
||||
dst[written * 9 + 3] = e.color.r;
|
||||
dst[written * 9 + 4] = e.color.g;
|
||||
dst[written * 9 + 5] = e.color.b;
|
||||
dst[written * 9 + 6] = a;
|
||||
dst[written * 9 + 7] = u;
|
||||
dst[written * 9 + 8] = 1.0f; // v = bottom
|
||||
written++;
|
||||
}
|
||||
|
||||
uint32_t vertCount = static_cast<uint32_t>(written) - firstVert;
|
||||
if (vertCount >= 4) {
|
||||
draws.push_back({texSet, pipe, firstVert, vertCount});
|
||||
} else {
|
||||
// Rollback if too few verts
|
||||
written = firstVert;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (draws.empty() || written == 0) return;
|
||||
|
||||
VkExtent2D ext = vkCtx_->getSwapchainExtent();
|
||||
VkViewport vp{};
|
||||
vp.x = 0; vp.y = 0;
|
||||
vp.width = static_cast<float>(ext.width);
|
||||
vp.height = static_cast<float>(ext.height);
|
||||
vp.minDepth = 0.0f; vp.maxDepth = 1.0f;
|
||||
VkRect2D sc{};
|
||||
sc.offset = {0, 0};
|
||||
sc.extent = ext;
|
||||
vkCmdSetViewport(cmd, 0, 1, &vp);
|
||||
vkCmdSetScissor(cmd, 0, 1, &sc);
|
||||
|
||||
VkPipeline lastPipe = VK_NULL_HANDLE;
|
||||
for (const auto& dc : draws) {
|
||||
if (dc.pipeline != lastPipe) {
|
||||
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, dc.pipeline);
|
||||
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
|
||||
ribbonPipelineLayout_, 0, 1, &perFrameSet, 0, nullptr);
|
||||
lastPipe = dc.pipeline;
|
||||
}
|
||||
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
|
||||
ribbonPipelineLayout_, 1, 1, &dc.texSet, 0, nullptr);
|
||||
VkDeviceSize offset = 0;
|
||||
vkCmdBindVertexBuffers(cmd, 0, 1, &ribbonVB_, &offset);
|
||||
vkCmdDraw(cmd, dc.vertexCount, 1, dc.firstVertex, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) {
|
||||
if (!particlePipeline_ || !m2ParticleVB_) return;
|
||||
|
||||
|
|
@ -4505,6 +4816,8 @@ void M2Renderer::recreatePipelines() {
|
|||
if (particlePipeline_) { vkDestroyPipeline(device, particlePipeline_, nullptr); particlePipeline_ = VK_NULL_HANDLE; }
|
||||
if (particleAdditivePipeline_) { vkDestroyPipeline(device, particleAdditivePipeline_, nullptr); particleAdditivePipeline_ = VK_NULL_HANDLE; }
|
||||
if (smokePipeline_) { vkDestroyPipeline(device, smokePipeline_, nullptr); smokePipeline_ = VK_NULL_HANDLE; }
|
||||
if (ribbonPipeline_) { vkDestroyPipeline(device, ribbonPipeline_, nullptr); ribbonPipeline_ = VK_NULL_HANDLE; }
|
||||
if (ribbonAdditivePipeline_) { vkDestroyPipeline(device, ribbonAdditivePipeline_, nullptr); ribbonAdditivePipeline_ = VK_NULL_HANDLE; }
|
||||
|
||||
// --- Load shaders ---
|
||||
rendering::VkShaderModule m2Vert, m2Frag;
|
||||
|
|
@ -4624,6 +4937,46 @@ void M2Renderer::recreatePipelines() {
|
|||
.build(device);
|
||||
}
|
||||
|
||||
// --- Ribbon pipelines ---
|
||||
{
|
||||
rendering::VkShaderModule ribVert, ribFrag;
|
||||
ribVert.loadFromFile(device, "assets/shaders/m2_ribbon.vert.spv");
|
||||
ribFrag.loadFromFile(device, "assets/shaders/m2_ribbon.frag.spv");
|
||||
if (ribVert.isValid() && ribFrag.isValid()) {
|
||||
VkVertexInputBindingDescription rBind{};
|
||||
rBind.binding = 0;
|
||||
rBind.stride = 9 * sizeof(float);
|
||||
rBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;
|
||||
|
||||
std::vector<VkVertexInputAttributeDescription> rAttrs = {
|
||||
{0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0},
|
||||
{1, 0, VK_FORMAT_R32G32B32_SFLOAT, 3 * sizeof(float)},
|
||||
{2, 0, VK_FORMAT_R32_SFLOAT, 6 * sizeof(float)},
|
||||
{3, 0, VK_FORMAT_R32G32_SFLOAT, 7 * sizeof(float)},
|
||||
};
|
||||
|
||||
auto buildRibbonPipeline = [&](VkPipelineColorBlendAttachmentState blend) -> VkPipeline {
|
||||
return PipelineBuilder()
|
||||
.setShaders(ribVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT),
|
||||
ribFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT))
|
||||
.setVertexInput({rBind}, rAttrs)
|
||||
.setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP)
|
||||
.setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE)
|
||||
.setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL)
|
||||
.setColorBlendAttachment(blend)
|
||||
.setMultisample(vkCtx_->getMsaaSamples())
|
||||
.setLayout(ribbonPipelineLayout_)
|
||||
.setRenderPass(mainPass)
|
||||
.setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR})
|
||||
.build(device);
|
||||
};
|
||||
|
||||
ribbonPipeline_ = buildRibbonPipeline(PipelineBuilder::blendAlpha());
|
||||
ribbonAdditivePipeline_ = buildRibbonPipeline(PipelineBuilder::blendAdditive());
|
||||
}
|
||||
ribVert.destroy(); ribFrag.destroy();
|
||||
}
|
||||
|
||||
m2Vert.destroy(); m2Frag.destroy();
|
||||
particleVert.destroy(); particleFrag.destroy();
|
||||
smokeVert.destroy(); smokeFrag.destroy();
|
||||
|
|
|
|||
|
|
@ -3865,7 +3865,13 @@ void Renderer::setFSREnabled(bool enabled) {
|
|||
if (fsr_.enabled == enabled) return;
|
||||
fsr_.enabled = enabled;
|
||||
|
||||
if (!enabled) {
|
||||
if (enabled) {
|
||||
// FSR1 upscaling renders its own AA — disable MSAA to avoid redundant work
|
||||
if (vkCtx && vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT) {
|
||||
pendingMsaaSamples_ = VK_SAMPLE_COUNT_1_BIT;
|
||||
msaaChangePending_ = true;
|
||||
}
|
||||
} else {
|
||||
// Defer destruction to next beginFrame() — can't destroy mid-render
|
||||
fsr_.needsRecreate = true;
|
||||
}
|
||||
|
|
@ -4962,11 +4968,11 @@ bool Renderer::initFXAAResources() {
|
|||
write.pImageInfo = &imgInfo;
|
||||
vkUpdateDescriptorSets(device, 1, &write, 0, nullptr);
|
||||
|
||||
// Pipeline layout — push constant holds vec2 rcpFrame
|
||||
// Pipeline layout — push constant holds vec4(rcpFrame.xy, sharpness, pad)
|
||||
VkPushConstantRange pc{};
|
||||
pc.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT;
|
||||
pc.offset = 0;
|
||||
pc.size = 8; // vec2
|
||||
pc.size = 16; // vec4
|
||||
VkPipelineLayoutCreateInfo plCI{};
|
||||
plCI.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO;
|
||||
plCI.setLayoutCount = 1;
|
||||
|
|
@ -5038,19 +5044,31 @@ void Renderer::renderFXAAPass() {
|
|||
vkCmdBindDescriptorSets(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
|
||||
fxaa_.pipelineLayout, 0, 1, &fxaa_.descSet, 0, nullptr);
|
||||
|
||||
// Push rcpFrame = vec2(1/width, 1/height)
|
||||
float rcpFrame[2] = {
|
||||
// Pass rcpFrame + sharpness + desaturate (vec4, 16 bytes).
|
||||
// When FSR2/FSR3 is active alongside FXAA, forward FSR2's sharpness so the
|
||||
// post-FXAA unsharp-mask step restores the crispness that FXAA's blur removes.
|
||||
float sharpness = fsr2_.enabled ? fsr2_.sharpness : 0.0f;
|
||||
float pc[4] = {
|
||||
1.0f / static_cast<float>(ext.width),
|
||||
1.0f / static_cast<float>(ext.height)
|
||||
1.0f / static_cast<float>(ext.height),
|
||||
sharpness,
|
||||
ghostMode_ ? 1.0f : 0.0f // desaturate: 1=ghost grayscale, 0=normal
|
||||
};
|
||||
vkCmdPushConstants(currentCmd, fxaa_.pipelineLayout,
|
||||
VK_SHADER_STAGE_FRAGMENT_BIT, 0, 8, rcpFrame);
|
||||
VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16, pc);
|
||||
|
||||
vkCmdDraw(currentCmd, 3, 1, 0, 0); // fullscreen triangle
|
||||
}
|
||||
|
||||
void Renderer::setFXAAEnabled(bool enabled) {
|
||||
if (fxaa_.enabled == enabled) return;
|
||||
// FXAA is a post-process AA pass intended to supplement FSR temporal output.
|
||||
// It conflicts with MSAA (which resolves AA during the scene render pass), so
|
||||
// refuse to enable FXAA when hardware MSAA is active.
|
||||
if (enabled && vkCtx && vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT) {
|
||||
LOG_INFO("FXAA: blocked while MSAA is active — disable MSAA first");
|
||||
return;
|
||||
}
|
||||
fxaa_.enabled = enabled;
|
||||
if (!enabled) {
|
||||
fxaa_.needsRecreate = true; // defer destruction to next beginFrame()
|
||||
|
|
@ -5074,6 +5092,9 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
|
|||
lastWMORenderMs = 0.0;
|
||||
lastM2RenderMs = 0.0;
|
||||
|
||||
// Cache ghost state for use in overlay and FXAA passes this frame.
|
||||
ghostMode_ = (gameHandler && gameHandler->isPlayerGhost());
|
||||
|
||||
uint32_t frameIdx = vkCtx->getCurrentFrame();
|
||||
VkDescriptorSet perFrameSet = perFrameDescSets[frameIdx];
|
||||
const glm::mat4& view = camera ? camera->getViewMatrix() : glm::mat4(1.0f);
|
||||
|
|
@ -5138,6 +5159,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
|
|||
m2Renderer->render(cmd, perFrameSet, *camera);
|
||||
m2Renderer->renderSmokeParticles(cmd, perFrameSet);
|
||||
m2Renderer->renderM2Particles(cmd, perFrameSet);
|
||||
m2Renderer->renderM2Ribbons(cmd, perFrameSet);
|
||||
vkEndCommandBuffer(cmd);
|
||||
return std::chrono::duration<double, std::milli>(
|
||||
std::chrono::steady_clock::now() - t0).count();
|
||||
|
|
@ -5219,6 +5241,12 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
|
|||
renderOverlay(tint, cmd);
|
||||
}
|
||||
}
|
||||
// Ghost mode desaturation overlay (non-FXAA path approximation).
|
||||
// When FXAA is active the FXAA shader applies true per-pixel desaturation;
|
||||
// otherwise a high-opacity gray overlay gives a similar washed-out effect.
|
||||
if (ghostMode_ && overlayPipeline && !fxaa_.enabled) {
|
||||
renderOverlay(glm::vec4(0.5f, 0.5f, 0.55f, 0.82f), cmd);
|
||||
}
|
||||
if (minimap && minimap->isEnabled() && camera && window) {
|
||||
glm::vec3 minimapCenter = camera->getPosition();
|
||||
if (cameraController && cameraController->isThirdPerson())
|
||||
|
|
@ -5228,14 +5256,14 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
|
|||
if (cameraController) {
|
||||
float facingRad = glm::radians(characterYaw);
|
||||
glm::vec3 facingFwd(std::cos(facingRad), std::sin(facingRad), 0.0f);
|
||||
minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y);
|
||||
// atan2(-x,y) = canonical yaw (0=North); negate for shader convention.
|
||||
minimapPlayerOrientation = -std::atan2(-facingFwd.x, facingFwd.y);
|
||||
hasMinimapPlayerOrientation = true;
|
||||
} else if (gameHandler) {
|
||||
// Server orientation is in WoW space: π/2 = North, 0 = East.
|
||||
// Minimap arrow expects render space: 0 = North, π/2 = East.
|
||||
// Convert: minimap_angle = server_orientation - π/2
|
||||
minimapPlayerOrientation = gameHandler->getMovementInfo().orientation
|
||||
- static_cast<float>(M_PI_2);
|
||||
// movementInfo.orientation is canonical yaw: 0=North, π/2=East.
|
||||
// Minimap shader: arrowRotation=0 points up (North), positive rotates CW
|
||||
// (π/2=West, -π/2=East). Correct mapping: arrowRotation = -canonical_yaw.
|
||||
minimapPlayerOrientation = -gameHandler->getMovementInfo().orientation;
|
||||
hasMinimapPlayerOrientation = true;
|
||||
}
|
||||
minimap->render(cmd, *camera, minimapCenter,
|
||||
|
|
@ -5317,6 +5345,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
|
|||
m2Renderer->render(currentCmd, perFrameSet, *camera);
|
||||
m2Renderer->renderSmokeParticles(currentCmd, perFrameSet);
|
||||
m2Renderer->renderM2Particles(currentCmd, perFrameSet);
|
||||
m2Renderer->renderM2Ribbons(currentCmd, perFrameSet);
|
||||
lastM2RenderMs = std::chrono::duration<double, std::milli>(
|
||||
std::chrono::steady_clock::now() - m2Start).count();
|
||||
}
|
||||
|
|
@ -5351,6 +5380,10 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
|
|||
renderOverlay(tint);
|
||||
}
|
||||
}
|
||||
// Ghost mode desaturation overlay (non-FXAA path approximation).
|
||||
if (ghostMode_ && overlayPipeline && !fxaa_.enabled) {
|
||||
renderOverlay(glm::vec4(0.5f, 0.5f, 0.55f, 0.82f));
|
||||
}
|
||||
if (minimap && minimap->isEnabled() && camera && window) {
|
||||
glm::vec3 minimapCenter = camera->getPosition();
|
||||
if (cameraController && cameraController->isThirdPerson())
|
||||
|
|
@ -5360,14 +5393,14 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
|
|||
if (cameraController) {
|
||||
float facingRad = glm::radians(characterYaw);
|
||||
glm::vec3 facingFwd(std::cos(facingRad), std::sin(facingRad), 0.0f);
|
||||
minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y);
|
||||
// atan2(-x,y) = canonical yaw (0=North); negate for shader convention.
|
||||
minimapPlayerOrientation = -std::atan2(-facingFwd.x, facingFwd.y);
|
||||
hasMinimapPlayerOrientation = true;
|
||||
} else if (gameHandler) {
|
||||
// Server orientation is in WoW space: π/2 = North, 0 = East.
|
||||
// Minimap arrow expects render space: 0 = North, π/2 = East.
|
||||
// Convert: minimap_angle = server_orientation - π/2
|
||||
minimapPlayerOrientation = gameHandler->getMovementInfo().orientation
|
||||
- static_cast<float>(M_PI_2);
|
||||
// movementInfo.orientation is canonical yaw: 0=North, π/2=East.
|
||||
// Minimap shader: arrowRotation=0 points up (North), positive rotates CW
|
||||
// (π/2=West, -π/2=East). Correct mapping: arrowRotation = -canonical_yaw.
|
||||
minimapPlayerOrientation = -gameHandler->getMovementInfo().orientation;
|
||||
hasMinimapPlayerOrientation = true;
|
||||
}
|
||||
minimap->render(currentCmd, *camera, minimapCenter,
|
||||
|
|
|
|||
|
|
@ -1377,6 +1377,10 @@ void TerrainManager::unloadTile(int x, int y) {
|
|||
// Water may have already been loaded in TERRAIN phase, so clean it up.
|
||||
for (auto fit = finalizingTiles_.begin(); fit != finalizingTiles_.end(); ++fit) {
|
||||
if (fit->pending && fit->pending->coord == coord) {
|
||||
// If terrain chunks were already uploaded, free their descriptor sets
|
||||
if (fit->terrainMeshDone && terrainRenderer) {
|
||||
terrainRenderer->removeTile(x, y);
|
||||
}
|
||||
// If past TERRAIN phase, water was already loaded — remove it
|
||||
if (fit->phase != FinalizationPhase::TERRAIN && waterRenderer) {
|
||||
waterRenderer->removeTile(x, y);
|
||||
|
|
|
|||
|
|
@ -805,6 +805,10 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) {
|
|||
return true;
|
||||
}
|
||||
|
||||
bool WMORenderer::isModelLoaded(uint32_t id) const {
|
||||
return loadedModels.find(id) != loadedModels.end();
|
||||
}
|
||||
|
||||
void WMORenderer::unloadModel(uint32_t id) {
|
||||
auto it = loadedModels.find(id);
|
||||
if (it == loadedModels.end()) {
|
||||
|
|
@ -2063,6 +2067,18 @@ void WMORenderer::getVisibleGroupsViaPortals(const ModelData& model,
|
|||
return;
|
||||
}
|
||||
|
||||
// If the camera group has no portal refs, it's a dead-end group (utility/transition group).
|
||||
// Fall back to showing all groups to avoid the rest of the WMO going invisible.
|
||||
if (cameraGroup < static_cast<int>(model.groupPortalRefs.size())) {
|
||||
auto [portalStart, portalCount] = model.groupPortalRefs[cameraGroup];
|
||||
if (portalCount == 0) {
|
||||
for (size_t gi = 0; gi < model.groups.size(); gi++) {
|
||||
outVisibleGroups.insert(static_cast<uint32_t>(gi));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// BFS through portals from camera's group
|
||||
std::vector<bool> visited(model.groups.size(), false);
|
||||
std::vector<uint32_t> queue;
|
||||
|
|
|
|||
|
|
@ -842,45 +842,47 @@ void WorldMap::render(const glm::vec3& playerRenderPos, int screenWidth, int scr
|
|||
|
||||
if (!zones.empty()) updateExploration(playerRenderPos);
|
||||
|
||||
if (open) {
|
||||
if (input.isKeyJustPressed(SDL_SCANCODE_M) ||
|
||||
input.isKeyJustPressed(SDL_SCANCODE_ESCAPE)) {
|
||||
open = false;
|
||||
return;
|
||||
// game_screen owns the open/close toggle (via showWorldMap_ + TOGGLE_WORLD_MAP keybinding).
|
||||
// render() is only called when showWorldMap_ is true, so treat each call as "should be open".
|
||||
if (!open) {
|
||||
// First time shown: load zones and navigate to player's location.
|
||||
open = true;
|
||||
if (zones.empty()) loadZonesFromDBC();
|
||||
|
||||
int bestContinent = findBestContinentForPlayer(playerRenderPos);
|
||||
if (bestContinent >= 0 && bestContinent != continentIdx) {
|
||||
continentIdx = bestContinent;
|
||||
compositedIdx = -1;
|
||||
}
|
||||
|
||||
int playerZone = findZoneForPlayer(playerRenderPos);
|
||||
if (playerZone >= 0 && continentIdx >= 0 &&
|
||||
zoneBelongsToContinent(playerZone, continentIdx)) {
|
||||
loadZoneTextures(playerZone);
|
||||
requestComposite(playerZone);
|
||||
currentIdx = playerZone;
|
||||
viewLevel = ViewLevel::ZONE;
|
||||
} else if (continentIdx >= 0) {
|
||||
loadZoneTextures(continentIdx);
|
||||
requestComposite(continentIdx);
|
||||
currentIdx = continentIdx;
|
||||
viewLevel = ViewLevel::CONTINENT;
|
||||
}
|
||||
}
|
||||
|
||||
// ESC closes the map; game_screen will sync showWorldMap_ via wm->isOpen() next frame.
|
||||
if (input.isKeyJustPressed(SDL_SCANCODE_ESCAPE)) {
|
||||
open = false;
|
||||
return;
|
||||
}
|
||||
|
||||
{
|
||||
auto& io = ImGui::GetIO();
|
||||
float wheelDelta = io.MouseWheel;
|
||||
if (std::abs(wheelDelta) < 0.001f)
|
||||
wheelDelta = input.getMouseWheelDelta();
|
||||
if (wheelDelta > 0.0f) zoomIn(playerRenderPos);
|
||||
else if (wheelDelta < 0.0f) zoomOut();
|
||||
} else {
|
||||
auto& io = ImGui::GetIO();
|
||||
if (!io.WantCaptureKeyboard && input.isKeyJustPressed(SDL_SCANCODE_M)) {
|
||||
open = true;
|
||||
if (zones.empty()) loadZonesFromDBC();
|
||||
|
||||
int bestContinent = findBestContinentForPlayer(playerRenderPos);
|
||||
if (bestContinent >= 0 && bestContinent != continentIdx) {
|
||||
continentIdx = bestContinent;
|
||||
compositedIdx = -1;
|
||||
}
|
||||
|
||||
int playerZone = findZoneForPlayer(playerRenderPos);
|
||||
if (playerZone >= 0 && continentIdx >= 0 &&
|
||||
zoneBelongsToContinent(playerZone, continentIdx)) {
|
||||
loadZoneTextures(playerZone);
|
||||
requestComposite(playerZone);
|
||||
currentIdx = playerZone;
|
||||
viewLevel = ViewLevel::ZONE;
|
||||
} else if (continentIdx >= 0) {
|
||||
loadZoneTextures(continentIdx);
|
||||
requestComposite(continentIdx);
|
||||
currentIdx = continentIdx;
|
||||
viewLevel = ViewLevel::CONTINENT;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return;
|
||||
|
|
|
|||
|
|
@ -687,6 +687,7 @@ void GameScreen::render(game::GameHandler& gameHandler) {
|
|||
renderGuildInvitePopup(gameHandler);
|
||||
renderReadyCheckPopup(gameHandler);
|
||||
renderBgInvitePopup(gameHandler);
|
||||
renderBfMgrInvitePopup(gameHandler);
|
||||
renderLfgProposalPopup(gameHandler);
|
||||
renderGuildRoster(gameHandler);
|
||||
renderSocialFrame(gameHandler);
|
||||
|
|
@ -710,6 +711,8 @@ void GameScreen::render(game::GameHandler& gameHandler) {
|
|||
renderWhoWindow(gameHandler);
|
||||
renderCombatLog(gameHandler);
|
||||
renderAchievementWindow(gameHandler);
|
||||
renderTitlesWindow(gameHandler);
|
||||
renderEquipSetWindow(gameHandler);
|
||||
renderGmTicketWindow(gameHandler);
|
||||
renderInspectWindow(gameHandler);
|
||||
renderBookWindow(gameHandler);
|
||||
|
|
@ -2333,6 +2336,12 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) {
|
|||
showAchievementWindow_ = !showAchievementWindow_;
|
||||
}
|
||||
|
||||
// Toggle Titles window with H (hero/title screen — no conflicting keybinding)
|
||||
if (input.isKeyJustPressed(SDL_SCANCODE_H) && !ImGui::GetIO().WantCaptureKeyboard) {
|
||||
showTitlesWindow_ = !showTitlesWindow_;
|
||||
}
|
||||
|
||||
|
||||
// Action bar keys (1-9, 0, -, =)
|
||||
static const SDL_Scancode actionBarKeys[] = {
|
||||
SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4,
|
||||
|
|
@ -6374,6 +6383,9 @@ void GameScreen::renderWorldMap(game::GameHandler& gameHandler) {
|
|||
int screenW = window ? window->getWidth() : 1280;
|
||||
int screenH = window ? window->getHeight() : 720;
|
||||
wm->render(playerPos, screenW, screenH);
|
||||
|
||||
// Sync showWorldMap_ if the map closed itself (e.g. ESC key inside the overlay).
|
||||
if (!wm->isOpen()) showWorldMap_ = false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
|
@ -7899,18 +7911,25 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) {
|
|||
|
||||
float screenH = ImGui::GetIO().DisplaySize.y > 0.0f ? ImGui::GetIO().DisplaySize.y : 720.0f;
|
||||
|
||||
// Default position: top-right, below minimap + buff bar space
|
||||
if (!questTrackerPosInit_ || questTrackerPos_.x < 0.0f) {
|
||||
questTrackerPos_ = ImVec2(screenW - TRACKER_W - RIGHT_MARGIN, 320.0f);
|
||||
// Default position: top-right, below minimap + buff bar space.
|
||||
// questTrackerRightOffset_ stores pixels from the right edge so the tracker
|
||||
// stays anchored to the right side when the window is resized.
|
||||
if (!questTrackerPosInit_ || questTrackerRightOffset_ < 0.0f) {
|
||||
questTrackerRightOffset_ = TRACKER_W + RIGHT_MARGIN; // default: right-aligned
|
||||
questTrackerPos_.y = 320.0f;
|
||||
questTrackerPosInit_ = true;
|
||||
}
|
||||
// Recompute X from right offset every frame (handles window resize)
|
||||
questTrackerPos_.x = screenW - questTrackerRightOffset_;
|
||||
|
||||
ImGui::SetNextWindowPos(questTrackerPos_, ImGuiCond_Always);
|
||||
ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0), ImGuiCond_Always);
|
||||
ImGui::SetNextWindowSize(questTrackerSize_, ImGuiCond_FirstUseEver);
|
||||
|
||||
ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration |
|
||||
ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar |
|
||||
ImGuiWindowFlags_NoScrollbar |
|
||||
ImGuiWindowFlags_NoCollapse |
|
||||
ImGuiWindowFlags_NoNav |
|
||||
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoBringToFrontOnFocus;
|
||||
ImGuiWindowFlags_NoBringToFrontOnFocus;
|
||||
|
||||
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.55f));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 6.0f));
|
||||
|
|
@ -7926,7 +7945,7 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) {
|
|||
: ImVec4(1.0f, 1.0f, 0.85f, 1.0f);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, titleCol);
|
||||
if (ImGui::Selectable(q.title.c_str(), false,
|
||||
ImGuiSelectableFlags_DontClosePopups, ImVec2(TRACKER_W - 12.0f, 0))) {
|
||||
ImGuiSelectableFlags_DontClosePopups, ImVec2(ImGui::GetContentRegionAvail().x, 0))) {
|
||||
questLogScreen.openAndSelectQuest(q.questId);
|
||||
}
|
||||
if (ImGui::IsItemHovered() && !ImGui::IsPopupOpen("##QTCtx")) {
|
||||
|
|
@ -8049,15 +8068,28 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) {
|
|||
}
|
||||
}
|
||||
|
||||
// Capture position after drag
|
||||
ImVec2 newPos = ImGui::GetWindowPos();
|
||||
// Capture position and size after drag/resize
|
||||
ImVec2 newPos = ImGui::GetWindowPos();
|
||||
ImVec2 newSize = ImGui::GetWindowSize();
|
||||
bool changed = false;
|
||||
|
||||
// Clamp within screen
|
||||
newPos.x = std::clamp(newPos.x, 0.0f, screenW - newSize.x);
|
||||
newPos.y = std::clamp(newPos.y, 0.0f, screenH - 40.0f);
|
||||
|
||||
if (std::abs(newPos.x - questTrackerPos_.x) > 0.5f ||
|
||||
std::abs(newPos.y - questTrackerPos_.y) > 0.5f) {
|
||||
newPos.x = std::clamp(newPos.x, 0.0f, screenW - TRACKER_W);
|
||||
newPos.y = std::clamp(newPos.y, 0.0f, screenH - 40.0f);
|
||||
questTrackerPos_ = newPos;
|
||||
saveSettings();
|
||||
// Update right offset so resizes keep the new position anchored
|
||||
questTrackerRightOffset_ = screenW - newPos.x;
|
||||
changed = true;
|
||||
}
|
||||
if (std::abs(newSize.x - questTrackerSize_.x) > 0.5f ||
|
||||
std::abs(newSize.y - questTrackerSize_.y) > 0.5f) {
|
||||
questTrackerSize_ = newSize;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) saveSettings();
|
||||
}
|
||||
ImGui::End();
|
||||
|
||||
|
|
@ -10935,6 +10967,63 @@ void GameScreen::renderBgInvitePopup(game::GameHandler& gameHandler) {
|
|||
ImGui::PopStyleColor(3);
|
||||
}
|
||||
|
||||
void GameScreen::renderBfMgrInvitePopup(game::GameHandler& gameHandler) {
|
||||
// Only shown on WotLK servers (outdoor battlefields like Wintergrasp use the BF Manager)
|
||||
if (!gameHandler.hasBfMgrInvite()) return;
|
||||
|
||||
auto* window = core::Application::getInstance().getWindow();
|
||||
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
|
||||
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
|
||||
|
||||
ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 190.0f, screenH / 2.0f - 55.0f), ImGuiCond_Always);
|
||||
ImGui::SetNextWindowSize(ImVec2(380.0f, 0.0f), ImGuiCond_Always);
|
||||
|
||||
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.10f, 0.20f, 0.96f));
|
||||
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 1.0f, 1.0f));
|
||||
ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.15f, 0.15f, 0.45f, 1.0f));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f);
|
||||
|
||||
const ImGuiWindowFlags flags =
|
||||
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse;
|
||||
|
||||
if (ImGui::Begin("Battlefield", nullptr, flags)) {
|
||||
// Resolve zone name for Wintergrasp (zoneId 4197)
|
||||
uint32_t zoneId = gameHandler.getBfMgrZoneId();
|
||||
const char* zoneName = nullptr;
|
||||
if (zoneId == 4197) zoneName = "Wintergrasp";
|
||||
else if (zoneId == 5095) zoneName = "Tol Barad";
|
||||
|
||||
if (zoneName) {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", zoneName);
|
||||
} else {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "Outdoor Battlefield");
|
||||
}
|
||||
ImGui::Spacing();
|
||||
ImGui::TextWrapped("You are invited to join the outdoor battlefield. Do you want to enter?");
|
||||
ImGui::Spacing();
|
||||
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.5f, 0.15f, 1.0f));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.7f, 0.2f, 1.0f));
|
||||
if (ImGui::Button("Enter Battlefield", ImVec2(178, 28))) {
|
||||
gameHandler.acceptBfMgrInvite();
|
||||
}
|
||||
ImGui::PopStyleColor(2);
|
||||
|
||||
ImGui::SameLine();
|
||||
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.15f, 0.15f, 1.0f));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 1.0f));
|
||||
if (ImGui::Button("Decline", ImVec2(175, 28))) {
|
||||
gameHandler.declineBfMgrInvite();
|
||||
}
|
||||
ImGui::PopStyleColor(2);
|
||||
}
|
||||
ImGui::End();
|
||||
|
||||
ImGui::PopStyleVar();
|
||||
ImGui::PopStyleColor(3);
|
||||
}
|
||||
|
||||
void GameScreen::renderLfgProposalPopup(game::GameHandler& gameHandler) {
|
||||
using LfgState = game::GameHandler::LfgState;
|
||||
if (gameHandler.getLfgState() != LfgState::Proposal) return;
|
||||
|
|
@ -11842,11 +11931,11 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) {
|
|||
ImGui::EndTabItem();
|
||||
}
|
||||
|
||||
// ---- Arena tab (WotLK: shows per-team rating/record) ----
|
||||
// ---- Arena tab (WotLK: shows per-team rating/record + roster) ----
|
||||
const auto& arenaStats = gameHandler.getArenaTeamStats();
|
||||
if (!arenaStats.empty()) {
|
||||
if (ImGui::BeginTabItem("Arena")) {
|
||||
ImGui::BeginChild("##ArenaList", ImVec2(200, 200), false);
|
||||
ImGui::BeginChild("##ArenaList", ImVec2(0, 0), false);
|
||||
|
||||
for (size_t ai = 0; ai < arenaStats.size(); ++ai) {
|
||||
const auto& ts = arenaStats[ai];
|
||||
|
|
@ -11875,6 +11964,49 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) {
|
|||
? ts.seasonGames - ts.seasonWins : 0;
|
||||
ImGui::Text("Season: %u W / %u L", ts.seasonWins, seasLosses);
|
||||
|
||||
// Roster members (from SMSG_ARENA_TEAM_ROSTER)
|
||||
const auto* roster = gameHandler.getArenaTeamRoster(ts.teamId);
|
||||
if (roster && !roster->members.empty()) {
|
||||
ImGui::Spacing();
|
||||
ImGui::TextDisabled("-- Roster (%zu members) --",
|
||||
roster->members.size());
|
||||
// Column headers
|
||||
ImGui::Columns(4, "##arenaRosterCols", false);
|
||||
ImGui::SetColumnWidth(0, 110.0f);
|
||||
ImGui::SetColumnWidth(1, 60.0f);
|
||||
ImGui::SetColumnWidth(2, 60.0f);
|
||||
ImGui::SetColumnWidth(3, 60.0f);
|
||||
ImGui::TextDisabled("Name"); ImGui::NextColumn();
|
||||
ImGui::TextDisabled("Rating"); ImGui::NextColumn();
|
||||
ImGui::TextDisabled("Week"); ImGui::NextColumn();
|
||||
ImGui::TextDisabled("Season"); ImGui::NextColumn();
|
||||
ImGui::Separator();
|
||||
|
||||
for (const auto& m : roster->members) {
|
||||
// Name coloured green (online) or grey (offline)
|
||||
if (m.online)
|
||||
ImGui::TextColored(ImVec4(0.4f,1.0f,0.4f,1.0f),
|
||||
"%s", m.name.c_str());
|
||||
else
|
||||
ImGui::TextDisabled("%s", m.name.c_str());
|
||||
ImGui::NextColumn();
|
||||
|
||||
ImGui::Text("%u", m.personalRating);
|
||||
ImGui::NextColumn();
|
||||
|
||||
uint32_t wL = m.weekGames > m.weekWins
|
||||
? m.weekGames - m.weekWins : 0;
|
||||
ImGui::Text("%uW/%uL", m.weekWins, wL);
|
||||
ImGui::NextColumn();
|
||||
|
||||
uint32_t sL = m.seasonGames > m.seasonWins
|
||||
? m.seasonGames - m.seasonWins : 0;
|
||||
ImGui::Text("%uW/%uL", m.seasonWins, sL);
|
||||
ImGui::NextColumn();
|
||||
}
|
||||
ImGui::Columns(1);
|
||||
}
|
||||
|
||||
ImGui::Unindent(8.0f);
|
||||
|
||||
if (ai + 1 < arenaStats.size())
|
||||
|
|
@ -15443,7 +15575,9 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) {
|
|||
float sinB = 0.0f;
|
||||
if (minimap->isRotateWithCamera()) {
|
||||
glm::vec3 fwd = camera->getForward();
|
||||
bearing = std::atan2(-fwd.x, fwd.y);
|
||||
// Render space: +X=West, +Y=North. Camera fwd=(cos(yaw),sin(yaw)).
|
||||
// Clockwise bearing from North: atan2(fwd.y, -fwd.x).
|
||||
bearing = std::atan2(fwd.y, -fwd.x);
|
||||
cosB = std::cos(bearing);
|
||||
sinB = std::sin(bearing);
|
||||
}
|
||||
|
|
@ -15481,7 +15615,7 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) {
|
|||
// The player is always at centerX, centerY on the minimap.
|
||||
// Draw a yellow arrow pointing in the player's facing direction.
|
||||
glm::vec3 fwd = camera->getForward();
|
||||
float facing = std::atan2(-fwd.x, fwd.y); // bearing relative to north
|
||||
float facing = std::atan2(fwd.y, -fwd.x); // clockwise bearing from North
|
||||
float cosF = std::cos(facing - bearing);
|
||||
float sinF = std::sin(facing - bearing);
|
||||
float arrowLen = 8.0f;
|
||||
|
|
@ -16846,9 +16980,11 @@ void GameScreen::saveSettings() {
|
|||
out << "extended_zoom=" << (pendingExtendedZoom ? 1 : 0) << "\n";
|
||||
out << "fov=" << pendingFov << "\n";
|
||||
|
||||
// Quest tracker position
|
||||
out << "quest_tracker_x=" << questTrackerPos_.x << "\n";
|
||||
// Quest tracker position/size
|
||||
out << "quest_tracker_right_offset=" << questTrackerRightOffset_ << "\n";
|
||||
out << "quest_tracker_y=" << questTrackerPos_.y << "\n";
|
||||
out << "quest_tracker_w=" << questTrackerSize_.x << "\n";
|
||||
out << "quest_tracker_h=" << questTrackerSize_.y << "\n";
|
||||
|
||||
// Chat
|
||||
out << "chat_active_tab=" << activeChatTab_ << "\n";
|
||||
|
|
@ -16994,15 +17130,25 @@ void GameScreen::loadSettings() {
|
|||
if (auto* camera = renderer->getCamera()) camera->setFov(pendingFov);
|
||||
}
|
||||
}
|
||||
// Quest tracker position
|
||||
// Quest tracker position/size
|
||||
else if (key == "quest_tracker_x") {
|
||||
questTrackerPos_.x = std::stof(val);
|
||||
// Legacy: ignore absolute X (right_offset supersedes it)
|
||||
(void)val;
|
||||
}
|
||||
else if (key == "quest_tracker_right_offset") {
|
||||
questTrackerRightOffset_ = std::stof(val);
|
||||
questTrackerPosInit_ = true;
|
||||
}
|
||||
else if (key == "quest_tracker_y") {
|
||||
questTrackerPos_.y = std::stof(val);
|
||||
questTrackerPosInit_ = true;
|
||||
}
|
||||
else if (key == "quest_tracker_w") {
|
||||
questTrackerSize_.x = std::max(100.0f, std::stof(val));
|
||||
}
|
||||
else if (key == "quest_tracker_h") {
|
||||
questTrackerSize_.y = std::max(60.0f, std::stof(val));
|
||||
}
|
||||
// Chat
|
||||
else if (key == "chat_active_tab") activeChatTab_ = std::clamp(std::stoi(val), 0, 3);
|
||||
else if (key == "chat_timestamps") chatShowTimestamps_ = (std::stoi(val) != 0);
|
||||
|
|
@ -20107,9 +20253,15 @@ void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) {
|
|||
|
||||
// ─── GM Ticket Window ─────────────────────────────────────────────────────────
|
||||
void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) {
|
||||
// Fire a one-shot query when the window first becomes visible
|
||||
if (showGmTicketWindow_ && !gmTicketWindowWasOpen_) {
|
||||
gameHandler.requestGmTicket();
|
||||
}
|
||||
gmTicketWindowWasOpen_ = showGmTicketWindow_;
|
||||
|
||||
if (!showGmTicketWindow_) return;
|
||||
|
||||
ImGui::SetNextWindowSize(ImVec2(400, 260), ImGuiCond_FirstUseEver);
|
||||
ImGui::SetNextWindowSize(ImVec2(440, 320), ImGuiCond_FirstUseEver);
|
||||
ImGui::SetNextWindowPos(ImVec2(300, 200), ImGuiCond_FirstUseEver);
|
||||
|
||||
if (!ImGui::Begin("GM Ticket", &showGmTicketWindow_,
|
||||
|
|
@ -20118,10 +20270,33 @@ void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Show GM support availability
|
||||
if (!gameHandler.isGmSupportAvailable()) {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "GM support is currently unavailable.");
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
// Show existing open ticket if any
|
||||
if (gameHandler.hasActiveGmTicket()) {
|
||||
ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "You have an open GM ticket.");
|
||||
const std::string& existingText = gameHandler.getGmTicketText();
|
||||
if (!existingText.empty()) {
|
||||
ImGui::TextWrapped("Current ticket: %s", existingText.c_str());
|
||||
}
|
||||
float waitHours = gameHandler.getGmTicketWaitHours();
|
||||
if (waitHours > 0.0f) {
|
||||
char waitBuf[64];
|
||||
std::snprintf(waitBuf, sizeof(waitBuf), "Estimated wait: %.1f hours", waitHours);
|
||||
ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.4f, 1.0f), "%s", waitBuf);
|
||||
}
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
ImGui::TextWrapped("Describe your issue and a Game Master will contact you.");
|
||||
ImGui::Spacing();
|
||||
ImGui::InputTextMultiline("##gmticket_body", gmTicketBuf_, sizeof(gmTicketBuf_),
|
||||
ImVec2(-1, 160));
|
||||
ImVec2(-1, 120));
|
||||
ImGui::Spacing();
|
||||
|
||||
bool hasText = (gmTicketBuf_[0] != '\0');
|
||||
|
|
@ -20138,8 +20313,11 @@ void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) {
|
|||
showGmTicketWindow_ = false;
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Delete Ticket", ImVec2(100, 0))) {
|
||||
gameHandler.deleteGmTicket();
|
||||
if (gameHandler.hasActiveGmTicket()) {
|
||||
if (ImGui::Button("Delete Ticket", ImVec2(110, 0))) {
|
||||
gameHandler.deleteGmTicket();
|
||||
showGmTicketWindow_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::End();
|
||||
|
|
@ -20255,7 +20433,8 @@ void GameScreen::renderBgScoreboard(game::GameHandler& gameHandler) {
|
|||
ImGui::SetNextWindowSize(ImVec2(600, 400), ImGuiCond_FirstUseEver);
|
||||
ImGui::SetNextWindowPos(ImVec2(150, 100), ImGuiCond_FirstUseEver);
|
||||
|
||||
const char* title = "Battleground Score###BgScore";
|
||||
const char* title = data && data->isArena ? "Arena Score###BgScore"
|
||||
: "Battleground Score###BgScore";
|
||||
if (!ImGui::Begin(title, &showBgScoreboard_, ImGuiWindowFlags_NoCollapse)) {
|
||||
ImGui::End();
|
||||
return;
|
||||
|
|
@ -20263,16 +20442,46 @@ void GameScreen::renderBgScoreboard(game::GameHandler& gameHandler) {
|
|||
|
||||
if (!data) {
|
||||
ImGui::TextDisabled("No score data yet.");
|
||||
ImGui::TextDisabled("Use /score to request the scoreboard while in a battleground.");
|
||||
ImGui::TextDisabled("Use /score to request the scoreboard while in a battleground or arena.");
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
|
||||
// Arena team rating banner (shown only for arenas)
|
||||
if (data->isArena) {
|
||||
for (int t = 0; t < 2; ++t) {
|
||||
const auto& at = data->arenaTeams[t];
|
||||
if (at.teamName.empty()) continue;
|
||||
int32_t ratingDelta = static_cast<int32_t>(at.ratingChange);
|
||||
ImVec4 teamCol = (t == 0) ? ImVec4(1.0f, 0.35f, 0.35f, 1.0f) // team 0: red
|
||||
: ImVec4(0.4f, 0.6f, 1.0f, 1.0f); // team 1: blue
|
||||
ImGui::TextColored(teamCol, "%s", at.teamName.c_str());
|
||||
ImGui::SameLine();
|
||||
char ratingBuf[32];
|
||||
if (ratingDelta >= 0)
|
||||
std::snprintf(ratingBuf, sizeof(ratingBuf), "Rating: %u (+%d)", at.newRating, ratingDelta);
|
||||
else
|
||||
std::snprintf(ratingBuf, sizeof(ratingBuf), "Rating: %u (%d)", at.newRating, ratingDelta);
|
||||
ImGui::TextDisabled("%s", ratingBuf);
|
||||
}
|
||||
ImGui::Separator();
|
||||
}
|
||||
|
||||
// Winner banner
|
||||
if (data->hasWinner) {
|
||||
const char* winnerStr = (data->winner == 1) ? "Alliance" : "Horde";
|
||||
ImVec4 winnerColor = (data->winner == 1) ? ImVec4(0.4f, 0.6f, 1.0f, 1.0f)
|
||||
: ImVec4(1.0f, 0.35f, 0.35f, 1.0f);
|
||||
const char* winnerStr;
|
||||
ImVec4 winnerColor;
|
||||
if (data->isArena) {
|
||||
// For arenas, winner byte 0/1 refers to team index in arenaTeams[]
|
||||
const auto& winTeam = data->arenaTeams[data->winner & 1];
|
||||
winnerStr = winTeam.teamName.empty() ? "Team 1" : winTeam.teamName.c_str();
|
||||
winnerColor = (data->winner == 0) ? ImVec4(1.0f, 0.35f, 0.35f, 1.0f)
|
||||
: ImVec4(0.4f, 0.6f, 1.0f, 1.0f);
|
||||
} else {
|
||||
winnerStr = (data->winner == 1) ? "Alliance" : "Horde";
|
||||
winnerColor = (data->winner == 1) ? ImVec4(0.4f, 0.6f, 1.0f, 1.0f)
|
||||
: ImVec4(1.0f, 0.35f, 0.35f, 1.0f);
|
||||
}
|
||||
float textW = ImGui::CalcTextSize(winnerStr).x + ImGui::CalcTextSize(" Victory!").x;
|
||||
ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - textW) * 0.5f);
|
||||
ImGui::TextColored(winnerColor, "%s", winnerStr);
|
||||
|
|
@ -20642,6 +20851,161 @@ void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) {
|
|||
ImGui::EndChild();
|
||||
}
|
||||
|
||||
// Arena teams (WotLK — from MSG_INSPECT_ARENA_TEAMS)
|
||||
if (!result->arenaTeams.empty()) {
|
||||
ImGui::Separator();
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), "Arena Teams");
|
||||
ImGui::Spacing();
|
||||
for (const auto& team : result->arenaTeams) {
|
||||
const char* bracket = (team.type == 2) ? "2v2"
|
||||
: (team.type == 3) ? "3v3"
|
||||
: (team.type == 5) ? "5v5" : "?v?";
|
||||
ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.9f, 1.0f),
|
||||
"[%s] %s", bracket, team.name.c_str());
|
||||
ImGui::SameLine();
|
||||
ImGui::TextColored(ImVec4(0.4f, 0.85f, 1.0f, 1.0f),
|
||||
" Rating: %u", team.personalRating);
|
||||
if (team.weekGames > 0 || team.seasonGames > 0) {
|
||||
ImGui::TextDisabled(" Week: %u/%u Season: %u/%u",
|
||||
team.weekWins, team.weekGames,
|
||||
team.seasonWins, team.seasonGames);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
// ─── Titles Window ────────────────────────────────────────────────────────────
|
||||
void GameScreen::renderTitlesWindow(game::GameHandler& gameHandler) {
|
||||
if (!showTitlesWindow_) return;
|
||||
|
||||
ImGui::SetNextWindowSize(ImVec2(320, 400), ImGuiCond_FirstUseEver);
|
||||
ImGui::SetNextWindowPos(ImVec2(240, 170), ImGuiCond_FirstUseEver);
|
||||
|
||||
if (!ImGui::Begin("Titles", &showTitlesWindow_)) {
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& knownBits = gameHandler.getKnownTitleBits();
|
||||
const int32_t chosen = gameHandler.getChosenTitleBit();
|
||||
|
||||
if (knownBits.empty()) {
|
||||
ImGui::TextDisabled("No titles earned yet.");
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
|
||||
ImGui::TextUnformatted("Select a title to display:");
|
||||
ImGui::Separator();
|
||||
|
||||
// "No Title" option
|
||||
bool noTitle = (chosen < 0);
|
||||
if (ImGui::Selectable("(No Title)", noTitle)) {
|
||||
if (!noTitle) gameHandler.sendSetTitle(-1);
|
||||
}
|
||||
if (noTitle) {
|
||||
ImGui::SameLine();
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "<-- active");
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
// Sort known bits for stable display order
|
||||
std::vector<uint32_t> sortedBits(knownBits.begin(), knownBits.end());
|
||||
std::sort(sortedBits.begin(), sortedBits.end());
|
||||
|
||||
ImGui::BeginChild("##titlelist", ImVec2(0, 0), false);
|
||||
for (uint32_t bit : sortedBits) {
|
||||
const std::string title = gameHandler.getFormattedTitle(bit);
|
||||
const std::string display = title.empty()
|
||||
? ("Title #" + std::to_string(bit)) : title;
|
||||
|
||||
bool isActive = (chosen >= 0 && static_cast<uint32_t>(chosen) == bit);
|
||||
ImGui::PushID(static_cast<int>(bit));
|
||||
|
||||
if (isActive) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f));
|
||||
}
|
||||
if (ImGui::Selectable(display.c_str(), isActive)) {
|
||||
if (!isActive) gameHandler.sendSetTitle(static_cast<int32_t>(bit));
|
||||
}
|
||||
if (isActive) {
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("<-- active");
|
||||
}
|
||||
|
||||
ImGui::PopID();
|
||||
}
|
||||
ImGui::EndChild();
|
||||
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
// ─── Equipment Set Manager Window ─────────────────────────────────────────────
|
||||
void GameScreen::renderEquipSetWindow(game::GameHandler& gameHandler) {
|
||||
if (!showEquipSetWindow_) return;
|
||||
|
||||
ImGui::SetNextWindowSize(ImVec2(280, 320), ImGuiCond_FirstUseEver);
|
||||
ImGui::SetNextWindowPos(ImVec2(260, 180), ImGuiCond_FirstUseEver);
|
||||
|
||||
if (!ImGui::Begin("Equipment Sets##equipsets", &showEquipSetWindow_)) {
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& sets = gameHandler.getEquipmentSets();
|
||||
|
||||
if (sets.empty()) {
|
||||
ImGui::TextDisabled("No equipment sets saved.");
|
||||
ImGui::Spacing();
|
||||
ImGui::TextWrapped("Create equipment sets in-game using the default WoW equipment manager (Shift+click the Equipment Sets button).");
|
||||
ImGui::End();
|
||||
return;
|
||||
}
|
||||
|
||||
ImGui::TextUnformatted("Click a set to equip it:");
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
|
||||
ImGui::BeginChild("##equipsetlist", ImVec2(0, 0), false);
|
||||
for (const auto& set : sets) {
|
||||
ImGui::PushID(static_cast<int>(set.setId));
|
||||
|
||||
// Icon placeholder (use a coloured square if no icon texture available)
|
||||
ImVec2 iconSize(32.0f, 32.0f);
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.20f, 0.10f, 1.0f));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.40f, 0.30f, 0.15f, 1.0f));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.60f, 0.45f, 0.20f, 1.0f));
|
||||
if (ImGui::Button("##icon", iconSize)) {
|
||||
gameHandler.useEquipmentSet(set.setId);
|
||||
}
|
||||
ImGui::PopStyleColor(3);
|
||||
|
||||
if (ImGui::IsItemHovered()) {
|
||||
ImGui::SetTooltip("Equip set: %s", set.name.c_str());
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
|
||||
// Name and equip button
|
||||
ImGui::BeginGroup();
|
||||
ImGui::TextUnformatted(set.name.c_str());
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.20f, 0.35f, 0.15f, 1.0f));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.30f, 0.50f, 0.22f, 1.0f));
|
||||
if (ImGui::SmallButton("Equip")) {
|
||||
gameHandler.useEquipmentSet(set.setId);
|
||||
}
|
||||
ImGui::PopStyleColor(2);
|
||||
ImGui::EndGroup();
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::PopID();
|
||||
}
|
||||
ImGui::EndChild();
|
||||
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1420,10 +1420,13 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) {
|
|||
|
||||
ImGui::BeginChild("##ReputationList", ImVec2(0, 0), true);
|
||||
|
||||
// Sort factions alphabetically by name
|
||||
// Sort: watched faction first, then alphabetically by name
|
||||
uint32_t watchedFactionId = gameHandler.getWatchedFactionId();
|
||||
std::vector<std::pair<uint32_t, int32_t>> sortedFactions(standings.begin(), standings.end());
|
||||
std::sort(sortedFactions.begin(), sortedFactions.end(),
|
||||
[&](const auto& a, const auto& b) {
|
||||
if (a.first == watchedFactionId) return true;
|
||||
if (b.first == watchedFactionId) return false;
|
||||
const std::string& na = gameHandler.getFactionNamePublic(a.first);
|
||||
const std::string& nb = gameHandler.getFactionNamePublic(b.first);
|
||||
return na < nb;
|
||||
|
|
@ -1435,10 +1438,25 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) {
|
|||
const std::string& factionName = gameHandler.getFactionNamePublic(factionId);
|
||||
const char* displayName = factionName.empty() ? "Unknown Faction" : factionName.c_str();
|
||||
|
||||
// Faction name + tier label on same line
|
||||
// Determine at-war status via repListId lookup
|
||||
uint32_t repListId = gameHandler.getRepListIdByFactionId(factionId);
|
||||
bool atWar = (repListId != 0xFFFFFFFFu) && gameHandler.isFactionAtWar(repListId);
|
||||
bool isWatched = (factionId == watchedFactionId);
|
||||
|
||||
// Faction name + tier label on same line; mark at-war and watched factions
|
||||
ImGui::TextColored(tier.color, "[%s]", tier.name);
|
||||
ImGui::SameLine(90.0f);
|
||||
ImGui::Text("%s", displayName);
|
||||
if (atWar) {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", displayName);
|
||||
ImGui::SameLine();
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "(At War)");
|
||||
} else if (isWatched) {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 1.0f), "%s", displayName);
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("(Tracked)");
|
||||
} else {
|
||||
ImGui::Text("%s", displayName);
|
||||
}
|
||||
|
||||
// Progress bar showing position within current tier
|
||||
float ratio = 0.0f;
|
||||
|
|
@ -1594,6 +1612,8 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
|
|||
// Secondary stat sums from extraStats
|
||||
int32_t itemAP = 0, itemSP = 0, itemHit = 0, itemCrit = 0, itemHaste = 0;
|
||||
int32_t itemResil = 0, itemExpertise = 0, itemMp5 = 0, itemHp5 = 0;
|
||||
int32_t itemDefense = 0, itemDodge = 0, itemParry = 0, itemBlock = 0, itemBlockVal = 0;
|
||||
int32_t itemArmorPen = 0, itemSpellPen = 0;
|
||||
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) {
|
||||
const auto& slot = inventory.getEquipSlot(static_cast<game::EquipSlot>(s));
|
||||
if (slot.empty()) continue;
|
||||
|
|
@ -1604,15 +1624,22 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
|
|||
itemSpi += slot.item.spirit;
|
||||
for (const auto& es : slot.item.extraStats) {
|
||||
switch (es.statType) {
|
||||
case 16: case 17: case 18: case 31: itemHit += es.statValue; break;
|
||||
case 19: case 20: case 21: case 32: itemCrit += es.statValue; break;
|
||||
case 28: case 29: case 30: case 36: itemHaste += es.statValue; break;
|
||||
case 35: itemResil += es.statValue; break;
|
||||
case 12: itemDefense += es.statValue; break;
|
||||
case 13: itemDodge += es.statValue; break;
|
||||
case 14: itemParry += es.statValue; break;
|
||||
case 15: itemBlock += es.statValue; break;
|
||||
case 16: case 17: case 18: case 31: itemHit += es.statValue; break;
|
||||
case 19: case 20: case 21: case 32: itemCrit += es.statValue; break;
|
||||
case 28: case 29: case 30: case 36: itemHaste += es.statValue; break;
|
||||
case 35: itemResil += es.statValue; break;
|
||||
case 37: itemExpertise += es.statValue; break;
|
||||
case 38: case 39: itemAP += es.statValue; break;
|
||||
case 41: case 42: case 45: itemSP += es.statValue; break;
|
||||
case 43: itemMp5 += es.statValue; break;
|
||||
case 46: itemHp5 += es.statValue; break;
|
||||
case 38: case 39: itemAP += es.statValue; break;
|
||||
case 41: case 42: case 45: itemSP += es.statValue; break;
|
||||
case 43: itemMp5 += es.statValue; break;
|
||||
case 44: itemArmorPen += es.statValue; break;
|
||||
case 46: itemHp5 += es.statValue; break;
|
||||
case 47: itemSpellPen += es.statValue; break;
|
||||
case 48: itemBlockVal += es.statValue; break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
|
@ -1699,7 +1726,9 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
|
|||
|
||||
// Secondary stats from equipped items
|
||||
bool hasSecondary = itemAP || itemSP || itemHit || itemCrit || itemHaste ||
|
||||
itemResil || itemExpertise || itemMp5 || itemHp5;
|
||||
itemResil || itemExpertise || itemMp5 || itemHp5 ||
|
||||
itemDefense || itemDodge || itemParry || itemBlock || itemBlockVal ||
|
||||
itemArmorPen || itemSpellPen;
|
||||
if (hasSecondary) {
|
||||
ImGui::Spacing();
|
||||
ImGui::Separator();
|
||||
|
|
@ -1708,15 +1737,22 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
|
|||
ImGui::TextColored(green, "+%d %s", val, name);
|
||||
}
|
||||
};
|
||||
renderSecondary("Attack Power", itemAP);
|
||||
renderSecondary("Spell Power", itemSP);
|
||||
renderSecondary("Hit Rating", itemHit);
|
||||
renderSecondary("Crit Rating", itemCrit);
|
||||
renderSecondary("Haste Rating", itemHaste);
|
||||
renderSecondary("Resilience", itemResil);
|
||||
renderSecondary("Expertise", itemExpertise);
|
||||
renderSecondary("Mana per 5 sec", itemMp5);
|
||||
renderSecondary("Health per 5 sec",itemHp5);
|
||||
renderSecondary("Attack Power", itemAP);
|
||||
renderSecondary("Spell Power", itemSP);
|
||||
renderSecondary("Hit Rating", itemHit);
|
||||
renderSecondary("Crit Rating", itemCrit);
|
||||
renderSecondary("Haste Rating", itemHaste);
|
||||
renderSecondary("Resilience", itemResil);
|
||||
renderSecondary("Expertise", itemExpertise);
|
||||
renderSecondary("Defense Rating", itemDefense);
|
||||
renderSecondary("Dodge Rating", itemDodge);
|
||||
renderSecondary("Parry Rating", itemParry);
|
||||
renderSecondary("Block Rating", itemBlock);
|
||||
renderSecondary("Block Value", itemBlockVal);
|
||||
renderSecondary("Armor Penetration",itemArmorPen);
|
||||
renderSecondary("Spell Penetration",itemSpellPen);
|
||||
renderSecondary("Mana per 5 sec", itemMp5);
|
||||
renderSecondary("Health per 5 sec", itemHp5);
|
||||
}
|
||||
|
||||
// Elemental resistances from server update fields
|
||||
|
|
|
|||
|
|
@ -22,15 +22,15 @@ void KeybindingManager::initializeDefaults() {
|
|||
bindings_[static_cast<int>(Action::TOGGLE_SPELLBOOK)] = ImGuiKey_P; // WoW standard key
|
||||
bindings_[static_cast<int>(Action::TOGGLE_TALENTS)] = ImGuiKey_N; // WoW standard key
|
||||
bindings_[static_cast<int>(Action::TOGGLE_QUESTS)] = ImGuiKey_L;
|
||||
bindings_[static_cast<int>(Action::TOGGLE_MINIMAP)] = ImGuiKey_M;
|
||||
bindings_[static_cast<int>(Action::TOGGLE_MINIMAP)] = ImGuiKey_None; // minimap is always visible; no default toggle
|
||||
bindings_[static_cast<int>(Action::TOGGLE_SETTINGS)] = ImGuiKey_Escape;
|
||||
bindings_[static_cast<int>(Action::TOGGLE_CHAT)] = ImGuiKey_Enter;
|
||||
bindings_[static_cast<int>(Action::TOGGLE_GUILD_ROSTER)] = ImGuiKey_O;
|
||||
bindings_[static_cast<int>(Action::TOGGLE_DUNGEON_FINDER)] = ImGuiKey_J; // Originally I, reassigned to avoid conflict
|
||||
bindings_[static_cast<int>(Action::TOGGLE_WORLD_MAP)] = ImGuiKey_W;
|
||||
bindings_[static_cast<int>(Action::TOGGLE_WORLD_MAP)] = ImGuiKey_M; // WoW standard: M opens world map
|
||||
bindings_[static_cast<int>(Action::TOGGLE_NAMEPLATES)] = ImGuiKey_V;
|
||||
bindings_[static_cast<int>(Action::TOGGLE_RAID_FRAMES)] = ImGuiKey_F; // Reassigned from R (now camera reset)
|
||||
bindings_[static_cast<int>(Action::TOGGLE_QUEST_LOG)] = ImGuiKey_Q;
|
||||
bindings_[static_cast<int>(Action::TOGGLE_QUEST_LOG)] = ImGuiKey_None; // Q conflicts with strafe-left; quest log accessible via TOGGLE_QUESTS (L)
|
||||
bindings_[static_cast<int>(Action::TOGGLE_ACHIEVEMENTS)] = ImGuiKey_Y; // WoW standard key (Shift+Y in retail)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -525,7 +525,7 @@ void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandle
|
|||
|
||||
// Resource cost + cast time on same row (WoW style)
|
||||
if (!info->isPassive()) {
|
||||
// Left: resource cost
|
||||
// Left: resource cost (with talent flat/pct modifier applied)
|
||||
char costBuf[64] = "";
|
||||
if (info->manaCost > 0) {
|
||||
const char* powerName = "Mana";
|
||||
|
|
@ -535,16 +535,26 @@ void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandle
|
|||
case 4: powerName = "Focus"; break;
|
||||
default: break;
|
||||
}
|
||||
std::snprintf(costBuf, sizeof(costBuf), "%u %s", info->manaCost, powerName);
|
||||
// Apply SMSG_SET_FLAT/PCT_SPELL_MODIFIER Cost modifier (SpellModOp::Cost = 14)
|
||||
int32_t flatCost = gameHandler.getSpellFlatMod(game::GameHandler::SpellModOp::Cost);
|
||||
int32_t pctCost = gameHandler.getSpellPctMod(game::GameHandler::SpellModOp::Cost);
|
||||
uint32_t displayCost = static_cast<uint32_t>(
|
||||
game::GameHandler::applySpellMod(static_cast<int32_t>(info->manaCost), flatCost, pctCost));
|
||||
std::snprintf(costBuf, sizeof(costBuf), "%u %s", displayCost, powerName);
|
||||
}
|
||||
|
||||
// Right: cast time
|
||||
// Right: cast time (with talent CastingTime modifier applied)
|
||||
char castBuf[32] = "";
|
||||
if (info->castTimeMs == 0) {
|
||||
std::snprintf(castBuf, sizeof(castBuf), "Instant cast");
|
||||
} else {
|
||||
float secs = info->castTimeMs / 1000.0f;
|
||||
std::snprintf(castBuf, sizeof(castBuf), "%.1f sec cast", secs);
|
||||
// Apply SpellModOp::CastingTime (10) modifiers
|
||||
int32_t flatCT = gameHandler.getSpellFlatMod(game::GameHandler::SpellModOp::CastingTime);
|
||||
int32_t pctCT = gameHandler.getSpellPctMod(game::GameHandler::SpellModOp::CastingTime);
|
||||
int32_t modCT = game::GameHandler::applySpellMod(
|
||||
static_cast<int32_t>(info->castTimeMs), flatCT, pctCT);
|
||||
float secs = static_cast<float>(modCT) / 1000.0f;
|
||||
std::snprintf(castBuf, sizeof(castBuf), "%.1f sec cast", secs > 0.0f ? secs : 0.0f);
|
||||
}
|
||||
|
||||
if (costBuf[0] || castBuf[0]) {
|
||||
|
|
|
|||
|
|
@ -201,20 +201,23 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab
|
|||
return a->column < b->column;
|
||||
});
|
||||
|
||||
// Find grid dimensions
|
||||
uint8_t maxRow = 0, maxCol = 0;
|
||||
// Find grid dimensions — use int to avoid uint8_t wrap-around infinite loops
|
||||
int maxRow = 0, maxCol = 0;
|
||||
for (const auto* talent : talents) {
|
||||
maxRow = std::max(maxRow, talent->row);
|
||||
maxCol = std::max(maxCol, talent->column);
|
||||
maxRow = std::max(maxRow, (int)talent->row);
|
||||
maxCol = std::max(maxCol, (int)talent->column);
|
||||
}
|
||||
// Sanity-cap to prevent runaway loops from corrupt/unexpected DBC data
|
||||
maxRow = std::min(maxRow, 15);
|
||||
maxCol = std::min(maxCol, 15);
|
||||
// WoW talent grids are always 4 columns wide
|
||||
if (maxCol < 3) maxCol = 3;
|
||||
|
||||
const float iconSize = 40.0f;
|
||||
const float spacing = 8.0f;
|
||||
const float cellSize = iconSize + spacing;
|
||||
const float gridWidth = (maxCol + 1) * cellSize + spacing;
|
||||
const float gridHeight = (maxRow + 1) * cellSize + spacing;
|
||||
const float gridWidth = (float)(maxCol + 1) * cellSize + spacing;
|
||||
const float gridHeight = (float)(maxRow + 1) * cellSize + spacing;
|
||||
|
||||
// Points in this tree
|
||||
uint32_t pointsInTree = 0;
|
||||
|
|
@ -300,7 +303,7 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab
|
|||
if (fromIt == talentPositions.end() || toIt == talentPositions.end()) continue;
|
||||
|
||||
uint8_t prereqRank = gameHandler.getTalentRank(talent->prereqTalent[i]);
|
||||
bool met = prereqRank >= talent->prereqRank[i];
|
||||
bool met = prereqRank > talent->prereqRank[i]; // storage 1-indexed, DBC 0-indexed
|
||||
ImU32 lineCol = met ? IM_COL32(100, 220, 100, 200) : IM_COL32(120, 120, 120, 150);
|
||||
|
||||
ImVec2 from = fromIt->second.center;
|
||||
|
|
@ -322,8 +325,8 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab
|
|||
}
|
||||
|
||||
// Render talent icons
|
||||
for (uint8_t row = 0; row <= maxRow; ++row) {
|
||||
for (uint8_t col = 0; col <= maxCol; ++col) {
|
||||
for (int row = 0; row <= maxRow; ++row) {
|
||||
for (int col = 0; col <= maxCol; ++col) {
|
||||
const game::GameHandler::TalentEntry* talent = nullptr;
|
||||
for (const auto* t : talents) {
|
||||
if (t->row == row && t->column == col) {
|
||||
|
|
@ -371,7 +374,7 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler,
|
|||
for (int i = 0; i < 3; ++i) {
|
||||
if (talent.prereqTalent[i] != 0) {
|
||||
uint8_t prereqRank = gameHandler.getTalentRank(talent.prereqTalent[i]);
|
||||
if (prereqRank < talent.prereqRank[i]) {
|
||||
if (prereqRank <= talent.prereqRank[i]) { // storage 1-indexed, DBC 0-indexed
|
||||
prereqsMet = false;
|
||||
canLearn = false;
|
||||
break;
|
||||
|
|
@ -538,14 +541,15 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler,
|
|||
if (!prereq || prereq->rankSpells[0] == 0) continue;
|
||||
|
||||
uint8_t prereqCurrentRank = gameHandler.getTalentRank(talent.prereqTalent[i]);
|
||||
bool met = prereqCurrentRank >= talent.prereqRank[i];
|
||||
bool met = prereqCurrentRank > talent.prereqRank[i]; // storage 1-indexed, DBC 0-indexed
|
||||
ImVec4 pColor = met ? ImVec4(0.3f, 0.9f, 0.3f, 1) : ImVec4(1.0f, 0.3f, 0.3f, 1);
|
||||
|
||||
const std::string& prereqName = gameHandler.getSpellName(prereq->rankSpells[0]);
|
||||
ImGui::Spacing();
|
||||
const uint8_t reqRankDisplay = talent.prereqRank[i] + 1u; // DBC 0-indexed → display 1-indexed
|
||||
ImGui::TextColored(pColor, "Requires %u point%s in %s",
|
||||
talent.prereqRank[i],
|
||||
talent.prereqRank[i] > 1 ? "s" : "",
|
||||
reqRankDisplay,
|
||||
reqRankDisplay > 1 ? "s" : "",
|
||||
prereqName.empty() ? "prerequisite" : prereqName.c_str());
|
||||
}
|
||||
|
||||
|
|
@ -570,16 +574,10 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler,
|
|||
ImGui::EndTooltip();
|
||||
}
|
||||
|
||||
// Handle click
|
||||
// Handle click — currentRank is 1-indexed (0=not learned, 1=rank1, ...)
|
||||
// CMSG_LEARN_TALENT requestedRank must equal current count of learned ranks (same value)
|
||||
if (clicked && canLearn && prereqsMet) {
|
||||
const auto& learned = gameHandler.getLearnedTalents();
|
||||
uint8_t desiredRank;
|
||||
if (learned.find(talent.talentId) == learned.end()) {
|
||||
desiredRank = 0; // First rank (0-indexed on wire)
|
||||
} else {
|
||||
desiredRank = currentRank; // currentRank is already the next 0-indexed rank to learn
|
||||
}
|
||||
gameHandler.learnTalent(talent.talentId, desiredRank);
|
||||
gameHandler.learnTalent(talent.talentId, currentRank);
|
||||
}
|
||||
|
||||
ImGui::PopID();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue