Compare commits

...

105 commits

Author SHA1 Message Date
Kelsi
a90f2acd26 feat: populate unitAurasCache_ from SMSG_PARTY_MEMBER_STATS aura block
Some checks are pending
Build / Build (arm64) (push) Waiting to run
Build / Build (x86-64) (push) Waiting to run
Build / Build (macOS arm64) (push) Waiting to run
Build / Build (windows-arm64) (push) Waiting to run
Build / Build (windows-x86-64) (push) Waiting to run
Security / CodeQL (C/C++) (push) Waiting to run
Security / Semgrep (push) Waiting to run
Security / Sanitizer Build (ASan/UBSan) (push) Waiting to run
SMSG_PARTY_MEMBER_STATS includes a 64-bit aura presence mask + per-slot
spellId/flags that was previously read and discarded. Now populate
unitAurasCache_[memberGuid] from this data so party frame debuff dots
show even when no dedicated SMSG_AURA_UPDATE has been received for that
unit. For Classic/TBC (no flags byte), infer debuff status from dispel
type — any spell with a non-zero dispel type is treated as a debuff.
2026-03-12 12:07:46 -07:00
Kelsi
562fc13a6a fix: wire TOGGLE_CHARACTER_SCREEN (C) and TOGGLE_BAGS (B) keybindings
Both actions were defined in KeybindingManager but never wired to the
input handling block — C had no effect and B did nothing. Connect them:
- C toggles inventoryScreen's character panel (equipment slots view)
- B opens all separate bags, or falls back to toggling the unified view
2026-03-12 12:05:05 -07:00
Kelsi
79c0887db2 feat: add BG scoreboard (MSG_PVP_LOG_DATA) and fix TBC aura cache for party frames
- Parse MSG_PVP_LOG_DATA to populate BgScoreboardData (players, KB, deaths,
  HKs, honor, BG-specific stats, winner)
- Add /score command to request the scorecard while in a battleground
- Render sortable per-player table with team color-coding and self-highlight
- Refresh button re-requests live data from server
- Fix TBC SMSG_INIT/SET_EXTRA_AURA_INFO_OBSOLETE to populate unitAurasCache_
  for all GUIDs (not just player/target), mirroring WotLK aura update behavior
  so party frame debuff dots work on TBC servers
2026-03-12 12:02:59 -07:00
Kelsi
a4c23b7fa2 feat: add per-unit aura cache and dispellable debuff indicators on party frames
Extend the aura tracking system to cache auras for any unit (not just
player and current target), so healers can see dispellable debuffs on
party members. Colored 8px dots appear below the power bar:
  Magic=blue, Curse=purple, Disease=brown, Poison=green
One dot per dispel type; non-dispellable auras are suppressed.
Cache is populated via existing SMSG_AURA_UPDATE/SMSG_AURA_UPDATE_ALL
handling and cleared on world exit.
2026-03-12 11:44:30 -07:00
Kelsi
9c276d1072 feat: add encounter-level DPS/HPS tracking to DPS meter
Track cumulative player damage/healing for the full combat encounter
using the persistent CombatLog, shown alongside the existing 2.5s
rolling window. The encounter row appears after 3s of combat and
persists post-combat until the next engagement, giving a stable
full-fight average rather than the spiky per-window reading.
2026-03-12 11:40:31 -07:00
Kelsi
a336bebbe5 feat: add debuff dot indicators on hostile target nameplate
Show small colored squares below the health bar of the current
hostile target indicating player-applied auras. Colors map to
dispel types from Spell.dbc: blue=Magic, purple=Curse,
yellow=Disease, green=Poison, grey=other/physical.

Dots are positioned below the cast bar if one is active,
otherwise directly below the health bar. They are clipped
to the nameplate width and only rendered for the targeted
hostile unit to keep the display readable.
2026-03-12 11:31:45 -07:00
Kelsi
f3e399e0ff feat: show unread message count on chat tabs
Each non-General chat tab now shows an unread count in parentheses
(e.g. "Whispers (3)") when messages arrive while that tab is inactive.
The counter clears when the tab is selected. The General tab is excluded
since it shows all messages anyway.
2026-03-12 11:23:01 -07:00
Kelsi
db1f111054 feat: add Guild chat tab and fix Trade/LFG tab index after insertion
Inserts a dedicated "Guild" tab between Whispers and Trade/LFG that
shows guild, officer, and guild achievement messages. Updates the
Trade/LFG channel-name filter from hardcoded index 3 to 4 to match
the new tab order.
2026-03-12 11:21:12 -07:00
Kelsi
afcd6f2db3 feat: add CHANNEL option to chat type dropdown with channel picker
Adds an 11th chat type "CHANNEL" to the dropdown, displaying a secondary
combo box populated from the player's joined channels. Typing /1, /2 etc.
in the input now also auto-switches the dropdown to CHANNEL mode and
selects the corresponding channel. Input text is colored cyan for channel
messages to visually distinguish them from other chat types.
2026-03-12 11:16:42 -07:00
Kelsi
20fef40b7b feat: show area trigger messages as screen banners
SMSG_AREA_TRIGGER_MESSAGE events (dungeon enter messages, objective
triggers, etc.) were previously only appended to chat. Now they also
appear as animated slide-up toasts in the lower-center of the screen:
blue-bordered dark panel with light-blue text, 4.5s lifetime with
35ms slide-in/out animation. Up to 4 simultaneous toasts stack
vertically. Messages still go to chat as before.
2026-03-12 11:06:40 -07:00
Kelsi
9fe7bbf826 feat: show lootable corpse diamonds on minimap
Dead units with UNIT_DYNFLAG_LOOTABLE (0x0001) set are rendered as small
yellow-green diamonds on the minimap, distinct from live NPC dots. A hover
tooltip shows the unit name. Uses the dynamic flags already tracked by the
update-object parser, so no new server data is needed.
2026-03-12 11:04:10 -07:00
Kelsi
661f7e3e8d feat: add persistent combat log window (/combatlog or /cl)
Stores up to 500 combat events in a rolling deque alongside the existing
floating combat text. Events are populated via the existing addCombatText()
call site, resolving attacker/target names from the entity manager and
player name cache at event time.

- CombatLogEntry struct in spell_defines.hpp (type, amount, spellId,
  isPlayerSource, timestamp, sourceName, targetName)
- getCombatLog() / clearCombatLog() accessors on GameHandler
- renderCombatLog() in GameScreen: scrollable two-column table (Time +
  Event), color-coded by event category, with Damage/Healing/Misc filter
  checkboxes, auto-scroll toggle, and Clear button
- /combatlog (/cl) chat command toggles the window
2026-03-12 11:00:10 -07:00
Kelsi
36d40905e1 feat: add in-window search bar to who results window
Add a search field with "Search" button directly in the who results
window so players can query without using the chat box. Pressing Enter
in the search field also triggers a new /who query.
2026-03-12 10:45:31 -07:00
Kelsi
367390a852 feat: add /who results window with sortable player table
Store structured WhoEntry data from SMSG_WHO responses and show them
in a dedicated popup window with Name/Guild/Level/Class/Zone columns.
Right-click on any row to Whisper, Invite, Add Friend, or Ignore.
Window auto-opens when /who or /whois is typed; shows online count
in the title bar. Results persist until the next /who query.
2026-03-12 10:41:18 -07:00
Kelsi
2f0fe302bc feat: add Trade, Duel, and Inspect to nameplate player context menu 2026-03-12 10:27:51 -07:00
Kelsi
1f7f1076ca feat: add Shaman totem frame with element-colored duration bars
- Renders below pet frame, visible only for Shaman (class 7)
- Shows each active totem (Earth/Fire/Water/Air) with its spell name
  and a colored countdown progress bar
- Colored element dot (brown/red/blue/light-blue) identifies element
- Only rendered when at least one totem is active
2026-03-12 10:14:44 -07:00
Kelsi
ef08366efc feat: add /use and /equip slash commands for inventory items by name
- /use <item name>  — searches backpack then bags (case-insensitive),
  calls useItemBySlot() / useItemInBag() for the first match
- /equip <item name> — same search, calls autoEquipItemBySlot() /
  autoEquipItemInBag() for the first match
- Both commands print an error if the item is not found
- Added both to tab-autocomplete list and /help output
2026-03-12 10:06:11 -07:00
Kelsi
611946bd3e feat: add Trade and Set Note to social frame friends context menu
- "Trade" option initiates a trade via existing initiateTrade(guid) API
  (only shown for online friends with a known GUID)
- "Set Note" option opens an inline popup with InputText pre-filled with
  the current note; Enter or OK saves via setFriendNote(), Esc/Cancel discards
2026-03-12 10:01:35 -07:00
Kelsi
71b0a18238 feat: implement quest sharing with party via CMSG_PUSHQUESTTOPARTY 2026-03-12 09:56:38 -07:00
Kelsi
e781ede5b2 feat: implement /chathelp command with channel-specific help text 2026-03-12 09:45:03 -07:00
Kelsi
88c3cfe7ab feat: show pet happiness bar in pet frame for hunter pets 2026-03-12 09:43:23 -07:00
Kelsi
84b31d3bbd feat: show spell name in proc trigger combat text when spellId is known 2026-03-12 09:41:45 -07:00
Kelsi
aaae07e477 feat: implement /wts and /wtb trade channel shortcuts 2026-03-12 09:38:29 -07:00
Kelsi
ec93981a9d feat: add /stopfollow and /zone slash commands, update /help 2026-03-12 09:36:14 -07:00
Kelsi
aaa3649975 feat: implement /cast slash command with rank support 2026-03-12 09:30:59 -07:00
Kelsi
c1a090a17c feat: show kick target name and reason in LFG vote-kick UI
Parse the optional reason and target name strings from
SMSG_LFG_BOOT_PROPOSAL_UPDATE and display them in the Dungeon
Finder vote-kick section. Strings are cleared when the vote ends.
2026-03-12 09:09:41 -07:00
Kelsi
d8d59dcdc8 feat: show live per-player responses in ready check popup
Track each player's ready/not-ready response as MSG_RAID_READY_CHECK_CONFIRM
packets arrive. Display a color-coded table (green=Ready, red=Not Ready) in
the ready check popup so the raid leader can see who has responded in real
time. Results clear when a new check starts or finishes.
2026-03-12 09:07:37 -07:00
Kelsi
09d4a6ab41 feat: add pet stance indicator and Passive/Defensive/Aggressive buttons to pet frame
Show Psv/Def/Agg stance buttons (color-coded blue/green/red) above the
pet action bar. Active stance is highlighted; clicking sends CMSG_PET_ACTION
with the server-provided slot value for correct packet format, falling back
to the wire-protocol action ID if the slot is not in the action bar.
Also label stance slots 1/4/6 in the action bar as Psv/Def/Agg with
proper full-name tooltips.
2026-03-12 09:05:28 -07:00
Kelsi
fb8377f3ca fix: dismiss loot roll popup when any player wins, not only when we win
SMSG_LOOT_ROLL_WON signals the roll contest is over for this slot;
clear pendingLootRollActive_ unconditionally so the popup does not
linger if a different group member wins while we have not yet voted.
2026-03-12 09:00:31 -07:00
Kelsi
7acaa4d301 feat: show live roll results from group members in loot roll popup
Track each player's roll (need/greed/disenchant/pass + value) as
SMSG_LOOT_ROLL packets arrive while our roll window is open. Display
a color-coded table in the popup: green=need, blue=greed,
purple=disenchant, gray=pass. Roll value hidden for pass.
2026-03-12 08:59:38 -07:00
Kelsi
8a24638ced fix: use uint64_t for chat tab typeMask to avoid UB for ChatType values >= 32, add missing boss/party chat types to Combat tab 2026-03-12 08:54:29 -07:00
Kelsi
eaf60b4f79 feat: apply class color to target-of-target frame player names 2026-03-12 08:50:14 -07:00
Kelsi
ee7de739fc fix: include class name in /who response chat messages 2026-03-12 08:49:18 -07:00
Kelsi
641f943268 feat: inspect window shows class-colored name, class label, and average item level 2026-03-12 08:47:59 -07:00
Kelsi
0b14141e97 refactor: consolidate all class name/color lookups to shared helpers, remove 4 duplicate class tables 2026-03-12 08:46:26 -07:00
Kelsi
30b821f7ba refactor: replace duplicate class color switch in social frame with classColorVec4 helper, color friend names by class 2026-03-12 08:44:08 -07:00
Kelsi
339ee4dbba refactor: use classColorVec4 helper in guild roster, color member names by class 2026-03-12 08:42:55 -07:00
Kelsi
b5131b19a3 feat: color minimap party dots by class instead of uniform blue 2026-03-12 08:40:54 -07:00
Kelsi
f4a31fef2a feat: apply class colors to focus frame player name
Player entities shown in the focus frame now display their canonical
WoW class color (from UNIT_FIELD_BYTES_0), consistent with how player
names are now colored in the target frame, party frame, raid frame,
and nameplates.
2026-03-12 08:37:14 -07:00
Kelsi
c73da1629b refactor: use classColorVec4 helper in player frame
Replace the duplicated 10-case switch in renderPlayerFrame with a call
to the shared classColorVec4() helper, keeping the single source of truth
for Blizzard class colors.
2026-03-12 08:35:47 -07:00
Kelsi
41d121df1d refactor: consolidate class color lookups into shared helpers
Add classColorVec4(), classColorU32(), and entityClassId() to the
anonymous namespace so the canonical Blizzard class colors are defined
in exactly one place. Refactor the three existing class color blocks
(party frame, raid frame, nameplates) to use these helpers. Also apply
class colors to player names in the target frame.
2026-03-12 08:33:34 -07:00
Kelsi
8f68d1efb9 feat: show WoW class colors on player nameplates
Player nameplates previously used a flat cyan for all players. Now they
display the canonical Blizzard class color (Warrior=#C79C6E,
Paladin=#F58CBA, Hunter=#ABD473, etc.) read from UNIT_FIELD_BYTES_0.
This makes it easy to identify player classes at a glance in the world,
especially useful in PvP and group content. NPC nameplates keep the
existing red (hostile) / yellow (friendly) coloring.
2026-03-12 08:30:27 -07:00
Kelsi
d6d70f62c7 feat: apply WoW class colors to raid frame member names
Same class color logic as the party frame: read UNIT_FIELD_BYTES_0 byte 1
from the entity manager to determine each member's class, then draw their
name in the canonical Blizzard class color. Dead/offline members keep the
gray color since their status is more important than their class identity.
2026-03-12 08:29:17 -07:00
Kelsi
25e90acf27 feat: color party frame member names by WoW class
Uses UNIT_FIELD_BYTES_0 (byte 1) from the entity's update fields to
determine each party member's class when they are loaded in the world,
and applies canonical WoW class colors to their name in the 5-man
party frame. Falls back to gold (leader) or light gray (others) when
the entity is not currently loaded. All 10 classes (Warrior, Paladin,
Hunter, Rogue, Priest, Death Knight, Shaman, Mage, Warlock, Druid)
use the standard Blizzard-matching hex values.
2026-03-12 08:28:08 -07:00
Kelsi
2fbf3f28e6 feat: use colored coin display for quest reward money in quest log 2026-03-12 08:21:52 -07:00
Kelsi
92ce7459bb feat: add dispel-type border colors to target frame debuffs (magic/curse/disease/poison) 2026-03-12 08:20:14 -07:00
Kelsi
dedafdf443 feat: use colored coin display for sell price in item tooltips 2026-03-12 08:19:07 -07:00
Kelsi
06c8c26b8a feat: extend colored coin display to item tooltip, quests, AH, guild bank, buyback, taxi 2026-03-12 08:15:46 -07:00
Kelsi
6649f1583a feat: use colored coin display in loot window gold amount 2026-03-12 08:13:03 -07:00
Kelsi
b5567b362f feat: add colored gold/silver/copper text in vendor, trainer, and mail windows 2026-03-12 08:10:17 -07:00
Kelsi
8a9248be79 feat: add spell icons to target and focus frame cast bars 2026-03-12 08:03:43 -07:00
Kelsi
844a0002dc feat: improve combat text with drop shadows and larger crit size
Switch combat float text from ImGui::TextColored to draw list rendering
for drop shadows on all entries (readability over complex backgrounds).
Critical hit/heal events render at 1.35× normal font size for visual
impact, matching the WoW combat feedback convention.
2026-03-12 08:00:27 -07:00
Kelsi
0674dc9ec2 feat: add spell icons to boss and party member cast bars
Consistent with the player cast bar, show the spell icon (12×12 for
boss, 10×10 for party) to the left of each cast bar progress widget.
Falls back gracefully to the icon-less layout when no icon is found.
2026-03-12 07:58:36 -07:00
Kelsi
e41788c63f feat: display current zone name inside minimap top edge
Shows the player's current zone name (from server zone ID via
ZoneManager) as a golden label at the top of the minimap circle.
Gracefully absent when zone ID is 0 (loading screens, undetected zones).
2026-03-12 07:56:59 -07:00
Kelsi
6cd8dc0d9d feat: show spell icon in cast bar alongside progress bar
Display the casting spell's icon (20×20) to the left of the progress
bar using the existing getSpellIcon DBC lookup. Falls back gracefully
to the icon-less layout when no icon is available (e.g. before DBC
load or for unknown spells).
2026-03-12 07:52:47 -07:00
Kelsi
d9ce056e13 feat: add zone name labels and hover coordinates to world map
- Draw zone name text centered in each zone rect on the continent view;
  only rendered when the rect is large enough to fit the label without
  crowding (explored zones get gold text, unexplored get dim grey)
- Show WoW coordinates under the cursor when hovering the map image in
  continent or zone view, bottom-right corner of the map panel
2026-03-12 07:45:51 -07:00
Kelsi
dd8e09c2d9 feat: show hovered world coordinates in minimap tooltip 2026-03-12 07:41:22 -07:00
Kelsi
ddb8f06c3a feat: add class-color and zone tooltip to friends tab in guild roster 2026-03-12 07:39:11 -07:00
Kelsi
d26bac0221 feat: store and display enchant indicators in inspect window gear list 2026-03-12 07:37:29 -07:00
Kelsi
7cb4887011 feat: add threat status bar and pulling-aggro alert to threat window 2026-03-12 07:32:28 -07:00
Kelsi
bab66cfa35 feat: display mail expiry date and urgency warnings in mailbox 2026-03-12 07:28:18 -07:00
Kelsi
344556c639 feat: show class name with class color and zone tooltip in social frame friends list 2026-03-12 07:25:56 -07:00
Kelsi
c25f7b0e52 feat: store and display achievement earn dates in achievement window tooltip 2026-03-12 07:22:36 -07:00
Kelsi
381fc54c89 feat: add hover tooltips to quest kill objective minimap markers 2026-03-12 07:18:11 -07:00
Kelsi
9a08edae09 feat: add Low Health Vignette toggle in Settings > Interface
The persistent red-edge vignette (below 20% HP) now has an on/off
checkbox under Settings > Interface > Screen Effects, alongside the
existing Damage Flash toggle. The preference is persisted to settings.cfg.
2026-03-12 07:15:08 -07:00
Kelsi
8cba8033ba feat: add tooltip to XP bar showing XP to level and rested details
Hovering the XP bar now shows a breakdown: current XP, XP remaining
to the next level, rested bonus amount in XP and as a percentage of
a full level, and whether the player is currently resting.
2026-03-12 07:12:02 -07:00
Kelsi
8858edde05 fix: correct chat bubble Y-coordinate projection
The camera bakes the Vulkan Y-flip into the projection matrix, so no
extra Y-inversion is needed when converting NDC to screen pixels. This
matches the convention used by the nameplate and minimap marker code.
The old formula double-flipped Y, causing chat bubbles to appear at
mirrored positions (e.g. below characters instead of above their heads).
2026-03-12 07:10:45 -07:00
Kelsi
17022b9b40 feat: add persistent low-health vignette when HP below 20%
Screen edges pulse red (at ~1.5 Hz) whenever the player is alive but
below 20% HP, with intensity scaling inversely with remaining health.
Complements the existing on-hit damage flash by providing continuous
danger awareness during sustained low-HP situations.
2026-03-12 07:07:46 -07:00
Kelsi
b8e7fee9e7 feat: add quest kill objective markers on minimap
Live NPCs that match active tracked quest kill objectives are now shown
on the minimap as gold circles with an 'x' mark, making it easier to
spot remaining quest targets at a glance without needing to open the map.
Only shows targets for incomplete objectives in tracked quests.
2026-03-12 07:04:45 -07:00
Kelsi
92361c37df feat: out-of-range indicator on party and raid frames
Party members beyond 40 yards show a gray desaturated health bar
with 'OOR' text instead of HP values. Raid frame cells get a dark
overlay and gray health bar when a member is out of range. Range is
computed from the server-reported posX/posY in SMSG_PARTY_MEMBER_STATS
vs the local player entity position.
2026-03-12 06:58:42 -07:00
Kelsi
d817e4144c feat: debuff dispel-type border coloring in buff bar
Read DispelType from Spell.dbc (new field in all expansion DBC layouts)
and use it to color debuff icon borders: magic=blue, curse=purple,
disease=brown, poison=green, other=red. Buffs remain green-bordered.
Adds getSpellDispelType() to GameHandler for lazy cache lookup.
2026-03-12 06:55:16 -07:00
Kelsi
9a21e19486 feat: highlight chat messages that mention the local player
When a chat message contains the player's character name, the message
is rendered with a golden highlight background and bright yellow text.
A whisper notification sound plays (at most once per new-message scan)
to alert the player. Outgoing whispers and system messages are
excluded from mention detection.
2026-03-12 06:45:27 -07:00
Kelsi
c14b338a92 feat: add Tab autocomplete for slash commands in chat input
Pressing Tab while typing a slash command cycles through all matching
commands (e.g. /em<Tab> → /emote, /emote<Tab> → /emote again).
Unambiguous matches append a trailing space. Repeated Tab presses
cycle forward through all matches. History navigation (Up/Down)
resets the autocomplete session.
2026-03-12 06:38:10 -07:00
Kelsi
68251b647d feat: add spell/quest/achievement hyperlink rendering in chat
Extend chat renderTextWithLinks to handle |Hspell:, |Hquest:, and
|Hachievement: link types in addition to |Hitem:. Spell links show
a small icon and tooltip via renderSpellInfoTooltip; quest links
open the quest log on click; achievement links show a tooltip.
Also wire assetMgr into renderChatWindow for icon lookup.
2026-03-12 06:30:30 -07:00
Kelsi
e8fe53650b feat: add respawn countdown timer to the death dialog
The "You are dead." dialog now shows a "Release in M:SS" countdown
tracking time elapsed since death. The countdown runs from 6 minutes
(WoW's forced-release window) and disappears once it reaches zero.
Timer resets automatically when the player is no longer dead.
2026-03-12 06:14:18 -07:00
Kelsi
39bf8fb01e feat: play audio notification when a whisper is received
Adds UiSoundManager::playWhisperReceived() which uses the dedicated
Whisper_TellMale/Female.wav files (or falls back to iSelectTarget.wav
if absent). The sound is triggered once per new incoming CHAT_MSG_WHISPER
message by scanning new chat history entries in the raid warning overlay
update loop.
2026-03-12 06:12:37 -07:00
Kelsi
bcd984c1c5 feat: sort buff/debuff icons by remaining duration
Both the player buff bar and target frame aura display now sort auras
so that shorter-duration (more urgent) buffs/debuffs appear first.
Permanent auras (no duration) sort to the end. In the target frame,
debuffs are sorted before buffs. In the player buff bar, the existing
buffs-first / debuffs-second pass ordering is preserved, with
ascending duration sort within each group.
2026-03-12 06:08:26 -07:00
Kelsi
3e8f03c7b7 feat: add PROC_TRIGGER floating combat text for spell procs
Handles SMSG_SPELL_CHANCE_PROC_LOG (previously silently ignored) to
display gold "PROC!" floating text when the player triggers a spell
proc. Reads caster/target packed GUIDs and spell ID from the packet
header; skips variable-length effect payload.

Adds CombatTextEntry::PROC_TRIGGER type with gold color rendering,
visible alongside existing damage/heal/energize floating numbers.
2026-03-12 06:06:41 -07:00
Kelsi
10ad246e29 feat: grey out action bar item slots when item is not in inventory
Item slots on the action bar now display a dark grey tint when the
item is no longer in the player's backpack, bags, or equipment slots.
This mirrors WoW's visual feedback for consumed or missing items,
matching the priority chain: cooldown > GCD > out-of-range >
insufficient-power > item-missing.
2026-03-12 06:03:04 -07:00
Kelsi
39634f442b feat: add insufficient-power tint to action bar spell slots
Spell icons now render with a purple desaturated tint when the player
lacks enough mana/rage/energy/runic power to cast them. Power cost and
type are read from Spell.dbc via the spellbook's DBC cache. The spell
tooltip also shows "Not enough power" in purple when applicable.

Priority: cooldown > GCD > out-of-range > insufficient-power so states
don't conflict. Adds SpellbookScreen::getSpellPowerInfo() as a public
DBC accessor.
2026-03-12 06:01:42 -07:00
Kelsi
8081a43d85 feat: add out-of-range tint to action bar spell slots
Ranged spell icons dim to a red tint when the current target is farther
than the spell's max range (read from SpellRange.dbc via spellbook data).
Melee/self spells (max range ≤ 5 yd or unknown) are excluded. The
spell tooltip also shows "Out of range" in red when applicable.

Adds SpellbookScreen::getSpellMaxRange() as a public accessor so
game_screen can query DBC range data without duplicating DBC loading.
2026-03-12 05:57:45 -07:00
Kelsi
bc5a7867a9 feat: zone entry toast and unspent talent points indicator
- Zone entry toast: centered slide-down banner when entering a new
  zone (tracks renderer's zone name, fires on change)
- Talent indicator: pulsing green '! N Talent Points Available' below
  minimap alongside existing New Mail / BG queue indicators
2026-03-12 05:44:25 -07:00
Kelsi
b6a43d6ce7 feat: track and visualize global cooldown (GCD) on action bar
- GameHandler tracks GCD in gcdTotal_/gcdStartedAt_ (time-based)
- SMSG_SPELL_COOLDOWN: spellId=0 entries (<=2s) are treated as GCD
- castSpell(): optimistically starts 1.5s GCD client-side on cast
- Action bar: non-cooldown slots show subtle dark sweep + dim tint
  during the GCD window, matching WoW standard behavior
2026-03-12 05:38:13 -07:00
Kelsi
61c0b91e39 feat: show weather icon next to zone name above minimap
Appends a color-coded Unicode weather symbol to the zone name:
- Rain (type 1): blue ⛆ when intensity > 5%
- Snow (type 2): ice-blue ❄ when intensity > 5%
- Storm/Fog (type 3): gray ☁ when intensity > 5%
Symbol is hidden when weather is clear or absent.
2026-03-12 05:34:56 -07:00
Kelsi
8fd9b6afc9 feat: add pulsing combat status indicators to player and target frames
- Player frame shows pulsing red [Combat] badge next to level when in combat
- Target frame shows pulsing [Attacking] badge when engaged with target
- Both pulse at 4Hz and include hover tooltips for clarity
2026-03-12 05:28:47 -07:00
Kelsi
162fd790ef feat: add right-click context menu to minimap
Adds a WoW-style popup on right-click within the minimap circle with:
- Zoom In / Zoom Out controls
- Rotate with Camera toggle (with checkmark state)
- Square Shape toggle (with checkmark state)
- Show NPC Dots toggle (with checkmark state)
2026-03-12 05:25:46 -07:00
Kelsi
f5d67c3c7f feat: add Shift+hover item comparison in vendor window
Extend renderItemTooltip(ItemQueryResponseData) to accept an optional
Inventory* parameter. When Shift is held and an equipped item in the
same slot exists, show: equipped item name, item level diff (▲/▼/=),
and stat diffs for Armor/Str/Agi/Sta/Int/Spi. Pass the player's
inventory from the vendor window hover handler to enable this.
2026-03-12 05:20:44 -07:00
Kelsi
5827a8fcdd feat: add Shaman totem bar in player frame
Store active totem state (slot, spellId, duration, placedAt) from
SMSG_TOTEM_CREATED. Render 4 element slots (Earth/Fire/Water/Air) as
color-coded duration bars in the player frame for Shamans (class 7).
Shows countdown seconds, element letter when inactive, and tooltip
with spell name + remaining time on hover.
2026-03-12 05:16:43 -07:00
Kelsi
8efdaed7e4 feat: add gold glow when action bar spell comes off cooldown
When a spell's cooldown expires, its action bar slot briefly animates
with a pulsing gold border (4 pulses over 1.5 seconds, fading out) to
draw attention that the ability is ready again. Uses per-slot state
tracking with static maps inside the render lambda.
2026-03-12 05:12:58 -07:00
Kelsi
c35bf8d953 feat: add duel countdown overlay (3-2-1-Fight!)
Parse SMSG_DUEL_COUNTDOWN to get the countdown duration, track the
start time, and render a large centered countdown overlay. Numbers
display in pulsing gold; transitions to pulsing red 'Fight!' for the
last 0.5 seconds. Countdown clears on SMSG_DUEL_COMPLETE.
2026-03-12 05:06:14 -07:00
Kelsi
29a989e1f4 feat: add reputation bar above XP bar
Show a color-coded reputation progress bar for the most recently gained
faction above the XP bar. The bar is auto-shown when any faction rep
changes (watchedFactionId_ tracks the last changed faction). Colors
follow WoW conventions: red=Hated/Hostile, orange=Unfriendly,
yellow=Neutral, green=Friendly, blue=Honored, purple=Revered,
gold=Exalted. Tooltip shows exact standing values on hover.
2026-03-12 05:03:03 -07:00
Kelsi
0a03bf9028 feat: add cast bar to pet frame
Show an orange cast bar in the pet frame when the pet is casting a
spell, matching the party frame cast bar pattern. Displays spell name
and time remaining; falls back to 'Casting...' when spell name is
unavailable from Spell.dbc.
2026-03-12 04:59:24 -07:00
Kelsi
b682e8c686 feat: add countdown timer to loot roll popup
Show a color-coded progress bar (green→yellow→pulsing red) in the loot
roll window indicating time remaining to make a roll decision. The
countdown duration is read from SMSG_LOOT_START_ROLL (or defaults to
60s for the SMSG_LOOT_ROLL path). Remaining seconds are displayed on
the bar itself.
2026-03-12 04:57:36 -07:00
Kelsi
b34bf39746 feat: add quest completion toast notification
When a quest is turned in (SMSG_QUESTGIVER_QUEST_COMPLETE), a gold-bordered
toast slides in from the right showing "Quest Complete" header with the quest
title, consistent with the rep change and achievement toast systems.
2026-03-12 04:53:03 -07:00
Kelsi
71df1ccf6f feat: apply WoW class colors to guild roster class column
Online guild members now show their class name in the standard WoW
class color in the guild roster table, matching the familiar in-game
appearance. Offline members retain the dimmed gray style.
2026-03-12 04:46:25 -07:00
Kelsi
102b34db2f feat: apply buff expiry color warning to target aura timers
Target frame debuff/buff timers now use the same urgency colors as
the player buff bar: white >30s, orange <30s, pulsing red <10s.
2026-03-12 04:44:48 -07:00
Kelsi
fb6e7c7b57 feat: color-code quest tracker objectives green when complete
Completed kill/item objectives now display in green instead of gray,
giving an immediate visual cue about which objectives are done vs.
still in progress on the on-screen quest tracker.
2026-03-12 04:42:48 -07:00
Kelsi
271518ee08 feat: use WoW standard class colors for player name in player frame
Player name in the unit frame now shows in the official WoW class
color (warrior=tan, paladin=pink, hunter=green, rogue=yellow,
priest=white, DK=red, shaman=blue, mage=cyan, warlock=purple,
druid=orange) matching the familiar in-game appearance.
2026-03-12 04:39:38 -07:00
Kelsi
f04a5c8f3e feat: add buff expiry color warning on timer overlay
Buff/debuff countdown timers now change color as expiry approaches:
white (>30s) → orange (<30s) → pulsing red (<10s). This gives players
a clear visual cue to reapply important buffs before they fall off.
2026-03-12 04:34:00 -07:00
Kelsi
1b3dc52563 feat: improve party frame dead/offline member display
Dead party members now show a gray "Dead" progress bar instead of
"0/2000" health values, and offline members show a dimmed "Offline"
bar. The power bar is suppressed for both states to reduce clutter.
2026-03-12 04:31:01 -07:00
Kelsi
7475a4fff3 feat: add persistent coordinate display below minimap
Always-visible player coordinates (X, Y in canonical WoW space) rendered
as warm-yellow text on a semi-transparent pill just below the minimap
circle, eliminating the need to hover for position info.
2026-03-12 04:27:26 -07:00
Kelsi
2e504232ec feat: add item icons and full tooltips to inspect window gear list 2026-03-12 04:24:37 -07:00
Kelsi
10e9e94a73 feat: add interrupt pulse to nameplate cast bars for hostile casters 2026-03-12 04:21:33 -07:00
Kelsi
b8141262d2 feat: add low mana pulse and interrupt alert on cast bars
- Mana bar pulses dim blue when below 20% (matches health bar low-hp pulse)
- Target, focus, and boss cast bars pulse orange when cast is > 80% complete,
  signalling the closing interrupt window across all frame types
2026-03-12 04:18:39 -07:00
Kelsi
3014c79c1f feat: show item stack count on action bar slots
Consumable items (potions, food, etc.) on the action bar now show their
remaining stack count in the bottom-right corner of the icon. Shows red
when count is 1 (last one), white otherwise. Counts across all bag slots.
2026-03-12 04:12:07 -07:00
18 changed files with 4004 additions and 283 deletions

View file

@ -2,7 +2,8 @@
"Spell": {
"ID": 0, "Attributes": 5, "IconID": 117,
"Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1,
"CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33
"CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33,
"DispelType": 4
},
"SpellRange": { "MaxRange": 2 },
"ItemDisplayInfo": {

View file

@ -2,7 +2,8 @@
"Spell": {
"ID": 0, "Attributes": 5, "IconID": 124,
"Name": 127, "Tooltip": 154, "Rank": 136, "SchoolMask": 215,
"CastingTimeIndex": 22, "PowerType": 35, "ManaCost": 36, "RangeIndex": 40
"CastingTimeIndex": 22, "PowerType": 35, "ManaCost": 36, "RangeIndex": 40,
"DispelType": 3
},
"SpellRange": { "MaxRange": 4 },
"ItemDisplayInfo": {

View file

@ -2,7 +2,8 @@
"Spell": {
"ID": 0, "Attributes": 5, "IconID": 117,
"Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1,
"CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33
"CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33,
"DispelType": 4
},
"SpellRange": { "MaxRange": 2 },
"ItemDisplayInfo": {

View file

@ -2,7 +2,8 @@
"Spell": {
"ID": 0, "Attributes": 4, "IconID": 133,
"Name": 136, "Tooltip": 139, "Rank": 153, "SchoolMask": 225,
"PowerType": 14, "ManaCost": 39, "CastingTimeIndex": 47, "RangeIndex": 49
"PowerType": 14, "ManaCost": 39, "CastingTimeIndex": 47, "RangeIndex": 49,
"DispelType": 2
},
"SpellRange": { "MaxRange": 4 },
"ItemDisplayInfo": {

View file

@ -75,6 +75,9 @@ public:
void playTargetSelect();
void playTargetDeselect();
// Chat notifications
void playWhisperReceived();
private:
struct UISample {
std::string path;
@ -122,6 +125,7 @@ private:
std::vector<UISample> errorSounds_;
std::vector<UISample> selectTargetSounds_;
std::vector<UISample> deselectTargetSounds_;
std::vector<UISample> whisperSounds_;
// State tracking
float volumeScale_ = 1.0f;

View file

@ -214,6 +214,9 @@ public:
void setMaxPower(uint32_t p) { maxPowers[powerType < 7 ? powerType : 0] = p; }
void setMaxPowerByType(uint8_t type, uint32_t p) { if (type < 7) maxPowers[type] = p; }
uint32_t getPowerByType(uint8_t type) const { return type < 7 ? powers[type] : 0; }
uint32_t getMaxPowerByType(uint8_t type) const { return type < 7 ? maxPowers[type] : 0; }
uint8_t getPowerType() const { return powerType; }
void setPowerType(uint8_t t) { powerType = t; }

View file

@ -339,7 +339,8 @@ public:
uint32_t unspentTalents = 0;
uint8_t talentGroups = 0;
uint8_t activeTalentGroup = 0;
std::array<uint32_t, 19> itemEntries{}; // 0=head…18=ranged
std::array<uint32_t, 19> itemEntries{}; // 0=head…18=ranged
std::array<uint16_t, 19> enchantIds{}; // permanent enchant per slot (0 = none)
};
const InspectResult* getInspectResult() const {
return inspectResult_.guid ? &inspectResult_ : nullptr;
@ -352,6 +353,19 @@ public:
uint32_t getTotalTimePlayed() const { return totalTimePlayed_; }
uint32_t getLevelTimePlayed() const { return levelTimePlayed_; }
// Who results (structured, from last SMSG_WHO response)
struct WhoEntry {
std::string name;
std::string guildName;
uint32_t level = 0;
uint32_t classId = 0;
uint32_t raceId = 0;
uint32_t zoneId = 0;
};
const std::vector<WhoEntry>& getWhoResults() const { return whoResults_; }
uint32_t getWhoOnlineCount() const { return whoOnlineCount_; }
std::string getWhoAreaName(uint32_t zoneId) const { return getAreaName(zoneId); }
// Social commands
void addFriend(const std::string& playerName, const std::string& note = "");
void removeFriend(const std::string& playerName);
@ -379,6 +393,28 @@ public:
void declineBattlefield(uint32_t queueSlot = 0xFFFFFFFF);
const std::array<BgQueueSlot, 3>& getBgQueues() const { return bgQueues_; }
// BG scoreboard (MSG_PVP_LOG_DATA)
struct BgPlayerScore {
uint64_t guid = 0;
std::string name;
uint8_t team = 0; // 0=Horde, 1=Alliance
uint32_t killingBlows = 0;
uint32_t deaths = 0;
uint32_t honorableKills = 0;
uint32_t bonusHonor = 0;
std::vector<std::pair<std::string, uint32_t>> bgStats; // BG-specific fields
};
struct BgScoreboardData {
std::vector<BgPlayerScore> players;
bool hasWinner = false;
uint8_t winner = 0; // 0=Horde, 1=Alliance
bool isArena = false;
};
void requestPvpLog();
const BgScoreboardData* getBgScoreboard() const {
return bgScoreboard_.players.empty() ? nullptr : &bgScoreboard_;
}
// Network latency (milliseconds, updated each PONG response)
uint32_t getLatencyMs() const { return lastLatency; }
@ -401,6 +437,7 @@ public:
// Follow/Assist
void followTarget();
void cancelFollow(); // Stop following current target
void assistTarget();
// PvP
@ -457,11 +494,16 @@ public:
uint64_t getPetitionNpcGuid() const { return petitionNpcGuid_; }
// Ready check
struct ReadyCheckResult {
std::string name;
bool ready = false;
};
void initiateReadyCheck();
void respondToReadyCheck(bool ready);
bool hasPendingReadyCheck() const { return pendingReadyCheck_; }
void dismissReadyCheck() { pendingReadyCheck_ = false; }
const std::string& getReadyCheckInitiator() const { return readyCheckInitiator_; }
const std::vector<ReadyCheckResult>& getReadyCheckResults() const { return readyCheckResults_; }
// Duel
void forfeitDuel();
@ -516,6 +558,19 @@ public:
const std::vector<CombatTextEntry>& getCombatText() const { return combatText; }
void updateCombatText(float deltaTime);
// Combat log (persistent rolling history, max MAX_COMBAT_LOG entries)
const std::deque<CombatLogEntry>& getCombatLog() const { return combatLog_; }
void clearCombatLog() { combatLog_.clear(); }
// Area trigger messages (SMSG_AREA_TRIGGER_MESSAGE) — drained by UI each frame
bool hasAreaTriggerMsg() const { return !areaTriggerMsgs_.empty(); }
std::string popAreaTriggerMsg() {
if (areaTriggerMsgs_.empty()) return {};
std::string msg = areaTriggerMsgs_.front();
areaTriggerMsgs_.pop_front();
return msg;
}
// Threat
struct ThreatEntry {
uint64_t victimGuid = 0;
@ -672,6 +727,11 @@ public:
// Auras
const std::vector<AuraSlot>& getPlayerAuras() const { return playerAuras; }
const std::vector<AuraSlot>& getTargetAuras() const { return targetAuras; }
// Per-unit aura cache (populated for party members and any unit we receive updates for)
const std::vector<AuraSlot>* getUnitAuras(uint64_t guid) const {
auto it = unitAurasCache_.find(guid);
return (it != unitAurasCache_.end()) ? &it->second : nullptr;
}
// Completed quests (populated from SMSG_QUERY_QUESTS_COMPLETED_RESPONSE)
bool isQuestCompleted(uint32_t questId) const { return completedQuests_.count(questId) > 0; }
@ -743,6 +803,17 @@ public:
float getGameTime() const { return gameTime_; }
float getTimeSpeed() const { return timeSpeed_; }
// Global Cooldown (GCD) — set when the server sends a spellId=0 cooldown entry
float getGCDRemaining() const {
if (gcdTotal_ <= 0.0f) return 0.0f;
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - gcdStartedAt_).count() / 1000.0f;
float rem = gcdTotal_ - elapsed;
return rem > 0.0f ? rem : 0.0f;
}
float getGCDTotal() const { return gcdTotal_; }
bool isGCDActive() const { return getGCDRemaining() > 0.0f; }
// Weather state (updated by SMSG_WEATHER)
// weatherType: 0=clear, 1=rain, 2=snow, 3=storm/fog
uint32_t getWeatherType() const { return weatherType_; }
@ -1035,6 +1106,14 @@ public:
const std::string& getDuelChallengerName() const { return duelChallengerName_; }
void acceptDuel();
// forfeitDuel() already declared at line ~399
// Returns remaining duel countdown seconds, or 0 if no active countdown
float getDuelCountdownRemaining() const {
if (duelCountdownMs_ == 0) return 0.0f;
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - duelCountdownStartedAt_).count();
float rem = (static_cast<float>(duelCountdownMs_) - static_cast<float>(elapsed)) / 1000.0f;
return rem > 0.0f ? rem : 0.0f;
}
// ---- Instance lockouts ----
struct InstanceLockout {
@ -1095,10 +1174,12 @@ public:
uint32_t getLfgProposalId() const { return lfgProposalId_; }
int32_t getLfgAvgWaitSec() const { return lfgAvgWaitSec_; }
uint32_t getLfgTimeInQueueMs() const { return lfgTimeInQueueMs_; }
uint32_t getLfgBootVotes() const { return lfgBootVotes_; }
uint32_t getLfgBootTotal() const { return lfgBootTotal_; }
uint32_t getLfgBootTimeLeft() const { return lfgBootTimeLeft_; }
uint32_t getLfgBootNeeded() const { return lfgBootNeeded_; }
uint32_t getLfgBootVotes() const { return lfgBootVotes_; }
uint32_t getLfgBootTotal() const { return lfgBootTotal_; }
uint32_t getLfgBootTimeLeft() const { return lfgBootTimeLeft_; }
uint32_t getLfgBootNeeded() const { return lfgBootNeeded_; }
const std::string& getLfgBootTargetName() const { return lfgBootTargetName_; }
const std::string& getLfgBootReason() const { return lfgBootReason_; }
// ---- Arena Team Stats ----
struct ArenaTeamStats {
@ -1129,6 +1210,15 @@ public:
uint32_t itemId = 0;
std::string itemName;
uint8_t itemQuality = 0;
uint32_t rollCountdownMs = 60000; // Duration of roll window in ms
std::chrono::steady_clock::time_point rollStartedAt{};
struct PlayerRollResult {
std::string playerName;
uint8_t rollNum = 0;
uint8_t rollType = 0; // 0=need,1=greed,2=disenchant,96=pass
};
std::vector<PlayerRollResult> playerRolls; // live roll results from group members
};
bool hasPendingLootRoll() const { return pendingLootRollActive_; }
const LootRollEntry& getPendingLootRoll() const { return pendingLootRoll_; }
@ -1215,6 +1305,7 @@ public:
};
const std::vector<QuestLogEntry>& getQuestLog() const { return questLog_; }
void abandonQuest(uint32_t questId);
void shareQuestWithParty(uint32_t questId); // CMSG_PUSHQUESTTOPARTY
bool requestQuestQuery(uint32_t questId, bool force = false);
bool isQuestTracked(uint32_t questId) const { return trackedQuestIds_.count(questId) > 0; }
void setQuestTracked(uint32_t questId, bool tracked) {
@ -1267,7 +1358,29 @@ public:
};
const std::vector<FactionStandingInit>& getInitialFactions() const { return initialFactions_; }
const std::unordered_map<uint32_t, int32_t>& getFactionStandings() const { return factionStandings_; }
// Shaman totems (4 slots: 0=Earth, 1=Fire, 2=Water, 3=Air)
struct TotemSlot {
uint32_t spellId = 0;
uint32_t durationMs = 0;
std::chrono::steady_clock::time_point placedAt{};
bool active() const { return spellId != 0 && remainingMs() > 0; }
float remainingMs() const {
if (spellId == 0 || durationMs == 0) return 0.0f;
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - placedAt).count();
float rem = static_cast<float>(durationMs) - static_cast<float>(elapsed);
return rem > 0.0f ? rem : 0.0f;
}
};
static constexpr int NUM_TOTEM_SLOTS = 4;
const TotemSlot& getTotemSlot(int slot) const {
static TotemSlot empty;
return (slot >= 0 && slot < NUM_TOTEM_SLOTS) ? activeTotemSlots_[slot] : empty;
}
const std::string& getFactionNamePublic(uint32_t factionId) const;
uint32_t getWatchedFactionId() const { return watchedFactionId_; }
void setWatchedFactionId(uint32_t id) { watchedFactionId_ = id; }
uint32_t getLastContactListMask() const { return lastContactListMask_; }
uint32_t getLastContactListCount() const { return lastContactListCount_; }
bool isServerMovementAllowed() const { return serverMovementAllowed_; }
@ -1297,6 +1410,11 @@ public:
void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); }
const std::unordered_set<uint32_t>& getEarnedAchievements() const { return earnedAchievements_; }
const std::unordered_map<uint32_t, uint64_t>& getCriteriaProgress() const { return criteriaProgress_; }
/// Returns the WoW PackedTime earn date for an achievement, or 0 if unknown.
uint32_t getAchievementDate(uint32_t id) const {
auto it = achievementDates_.find(id);
return (it != achievementDates_.end()) ? it->second : 0u;
}
/// Returns the name of an achievement by ID, or empty string if unknown.
const std::string& getAchievementName(uint32_t id) const {
auto it = achievementNameCache_.find(id);
@ -1330,6 +1448,10 @@ public:
using RepChangeCallback = std::function<void(const std::string& factionName, int32_t delta, int32_t standing)>;
void setRepChangeCallback(RepChangeCallback cb) { repChangeCallback_ = std::move(cb); }
// Quest turn-in completion callback
using QuestCompleteCallback = std::function<void(uint32_t questId, const std::string& questTitle)>;
void setQuestCompleteCallback(QuestCompleteCallback cb) { questCompleteCallback_ = std::move(cb); }
// Mount state
using MountCallback = std::function<void(uint32_t mountDisplayId)>; // 0 = dismount
void setMountCallback(MountCallback cb) { mountCallback_ = std::move(cb); }
@ -1539,6 +1661,8 @@ public:
const std::string& getSpellName(uint32_t spellId) const;
const std::string& getSpellRank(uint32_t spellId) const;
const std::string& getSkillLineName(uint32_t spellId) const;
/// Returns the DispelType for a spell (0=none,1=magic,2=curse,3=disease,4=poison,5+=other)
uint8_t getSpellDispelType(uint32_t spellId) const;
struct TrainerTab {
std::string name;
@ -1819,6 +1943,7 @@ private:
void handleArenaTeamEvent(network::Packet& packet);
void handleArenaTeamStats(network::Packet& packet);
void handleArenaError(network::Packet& packet);
void handlePvpLogData(network::Packet& packet);
// ---- Bank handlers ----
void handleShowBank(network::Packet& packet);
@ -2065,6 +2190,9 @@ private:
float autoAttackFacingSyncTimer_ = 0.0f; // Periodic facing sync while meleeing
std::unordered_set<uint64_t> hostileAttackers_;
std::vector<CombatTextEntry> combatText;
static constexpr size_t MAX_COMBAT_LOG = 500;
std::deque<CombatLogEntry> combatLog_;
std::deque<std::string> areaTriggerMsgs_;
// unitGuid → sorted threat list (descending by threat value)
std::unordered_map<uint64_t, std::vector<ThreatEntry>> threatLists_;
@ -2147,6 +2275,7 @@ private:
std::array<ActionBarSlot, ACTION_BAR_SLOTS> actionBar{};
std::vector<AuraSlot> playerAuras;
std::vector<AuraSlot> targetAuras;
std::unordered_map<uint64_t, std::vector<AuraSlot>> unitAurasCache_; // per-unit aura cache
uint64_t petGuid_ = 0;
uint32_t petActionSlots_[10] = {}; // SMSG_PET_SPELLS action bar (10 slots)
uint8_t petCommand_ = 1; // 0=stay,1=follow,2=attack,3=dismiss
@ -2177,6 +2306,9 @@ private:
// Arena team stats (indexed by team slot, updated by SMSG_ARENA_TEAM_STATS)
std::vector<ArenaTeamStats> arenaTeamStats_;
// BG scoreboard (MSG_PVP_LOG_DATA)
BgScoreboardData bgScoreboard_;
// Instance encounter boss units (slots 0-4 from SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT)
std::array<uint64_t, kMaxEncounterSlots> encounterUnitGuids_ = {}; // 0 = empty slot
@ -2190,12 +2322,15 @@ private:
uint32_t lfgBootTotal_ = 0; // total votes cast
uint32_t lfgBootTimeLeft_ = 0; // seconds remaining
uint32_t lfgBootNeeded_ = 0; // votes needed to kick
std::string lfgBootTargetName_; // name of player being voted on
std::string lfgBootReason_; // reason given for kick
// Ready check state
bool pendingReadyCheck_ = false;
uint32_t readyCheckReadyCount_ = 0;
uint32_t readyCheckNotReadyCount_ = 0;
std::string readyCheckInitiator_;
std::vector<ReadyCheckResult> readyCheckResults_; // per-player status live during check
// Faction standings (factionId → absolute standing value)
std::unordered_map<uint32_t, int32_t> factionStandings_;
@ -2229,6 +2364,10 @@ private:
uint32_t totalTimePlayed_ = 0;
uint32_t levelTimePlayed_ = 0;
// Who results (last SMSG_WHO response)
std::vector<WhoEntry> whoResults_;
uint32_t whoOnlineCount_ = 0;
// Trade state
TradeStatus tradeStatus_ = TradeStatus::None;
uint64_t tradePeerGuid_= 0;
@ -2238,11 +2377,16 @@ private:
uint64_t myTradeGold_ = 0;
uint64_t peerTradeGold_ = 0;
// Shaman totem state
TotemSlot activeTotemSlots_[NUM_TOTEM_SLOTS];
// Duel state
bool pendingDuelRequest_ = false;
uint64_t duelChallengerGuid_= 0;
uint64_t duelFlagGuid_ = 0;
std::string duelChallengerName_;
uint32_t duelCountdownMs_ = 0; // 0 = no active countdown
std::chrono::steady_clock::time_point duelCountdownStartedAt_{};
// ---- Guild state ----
std::string guildName_;
@ -2434,7 +2578,7 @@ private:
// Trainer
bool trainerWindowOpen_ = false;
TrainerListData currentTrainerList_;
struct SpellNameEntry { std::string name; std::string rank; uint32_t schoolMask = 0; };
struct SpellNameEntry { std::string name; std::string rank; uint32_t schoolMask = 0; uint8_t dispelType = 0; };
std::unordered_map<uint32_t, SpellNameEntry> spellNameCache_;
bool spellNameCacheLoaded_ = false;
@ -2444,6 +2588,8 @@ private:
void loadAchievementNameCache();
// Set of achievement IDs earned by the player (populated from SMSG_ALL_ACHIEVEMENT_DATA)
std::unordered_set<uint32_t> earnedAchievements_;
// Earn dates: achievementId → WoW PackedTime (from SMSG_ACHIEVEMENT_EARNED / SMSG_ALL_ACHIEVEMENT_DATA)
std::unordered_map<uint32_t, uint32_t> achievementDates_;
// Criteria progress: criteriaId → current value (from SMSG_CRITERIA_UPDATE)
std::unordered_map<uint32_t, uint64_t> criteriaProgress_;
void handleAllAchievementData(network::Packet& packet);
@ -2518,6 +2664,10 @@ private:
float timeSpeed_ = 0.0166f; // Time scale (default: 1 game day = 1 real hour)
void handleLoginSetTimeSpeed(network::Packet& packet);
// ---- Global Cooldown (GCD) ----
float gcdTotal_ = 0.0f;
std::chrono::steady_clock::time_point gcdStartedAt_{};
// ---- Weather state (SMSG_WEATHER) ----
uint32_t weatherType_ = 0; // 0=clear, 1=rain, 2=snow, 3=storm
float weatherIntensity_ = 0.0f; // 0.0 to 1.0
@ -2630,6 +2780,10 @@ private:
// ---- Reputation change callback ----
RepChangeCallback repChangeCallback_;
uint32_t watchedFactionId_ = 0; // auto-set to most recently changed faction
// ---- Quest completion callback ----
QuestCompleteCallback questCompleteCallback_;
};
} // namespace game

View file

@ -1,6 +1,7 @@
#pragma once
#include <cstdint>
#include <ctime>
#include <string>
#include <vector>
@ -51,7 +52,7 @@ struct CombatTextEntry {
enum Type : uint8_t {
MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK,
CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL,
ENERGIZE, XP_GAIN, IMMUNE, ABSORB, RESIST
ENERGIZE, XP_GAIN, IMMUNE, ABSORB, RESIST, PROC_TRIGGER
};
Type type;
int32_t amount = 0;
@ -63,6 +64,19 @@ struct CombatTextEntry {
bool isExpired() const { return age >= LIFETIME; }
};
/**
* Persistent combat log entry (stored in a rolling deque, survives beyond floating-text lifetime)
*/
struct CombatLogEntry {
CombatTextEntry::Type type = CombatTextEntry::MELEE_DAMAGE;
int32_t amount = 0;
uint32_t spellId = 0;
bool isPlayerSource = false;
time_t timestamp = 0; // Wall-clock time (std::time(nullptr))
std::string sourceName; // Resolved display name of attacker/caster
std::string targetName; // Resolved display name of victim/target
};
/**
* Spell cooldown entry received from server
*/

View file

@ -46,21 +46,32 @@ private:
char chatInputBuffer[512] = "";
char whisperTargetBuffer[256] = "";
bool chatInputActive = false;
int selectedChatType = 0; // 0=SAY, 1=YELL, 2=PARTY, 3=GUILD, 4=WHISPER
int selectedChatType = 0; // 0=SAY, 1=YELL, 2=PARTY, 3=GUILD, 4=WHISPER, ..., 10=CHANNEL
int lastChatType = 0; // Track chat type changes
int selectedChannelIdx = 0; // Index into joinedChannels_ when selectedChatType==10
bool chatInputMoveCursorToEnd = false;
// Chat sent-message history (Up/Down arrow recall)
std::vector<std::string> chatSentHistory_;
int chatHistoryIdx_ = -1; // -1 = not browsing history
// Tab-completion state for slash commands
std::string chatTabPrefix_; // prefix captured on first Tab press
std::vector<std::string> chatTabMatches_; // matching command list
int chatTabMatchIdx_ = -1; // active match index (-1 = inactive)
// Mention notification: plays a sound when the player's name appears in chat
size_t chatMentionSeenCount_ = 0; // how many messages have been scanned for mentions
// Chat tabs
int activeChatTab_ = 0;
struct ChatTab {
std::string name;
uint32_t typeMask; // bitmask of ChatType values to show
uint64_t typeMask; // bitmask of ChatType values to show (64-bit: types go up to 84)
};
std::vector<ChatTab> chatTabs_;
std::vector<int> chatTabUnread_; // unread message count per tab (0 = none)
size_t chatTabSeenCount_ = 0; // how many history messages have been processed
void initChatTabs();
bool shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const;
@ -75,6 +86,7 @@ private:
uint32_t lastPlayerHp_ = 0; // Previous frame HP for damage flash detection
float damageFlashAlpha_ = 0.0f; // Screen edge flash intensity (fades to 0)
bool damageFlashEnabled_ = true;
bool lowHealthVignetteEnabled_ = true; // Persistent pulsing red vignette below 20% HP
float levelUpFlashAlpha_ = 0.0f; // Golden level-up burst effect (fades to 0)
uint32_t levelUpDisplayLevel_ = 0; // Level shown in level-up text
@ -100,6 +112,29 @@ private:
std::vector<RepToastEntry> repToasts_;
bool repChangeCallbackSet_ = false;
static constexpr float kRepToastLifetime = 3.5f;
// Quest completion toast: slide-in when a quest is turned in
struct QuestCompleteToastEntry { uint32_t questId = 0; std::string title; float age = 0.0f; };
std::vector<QuestCompleteToastEntry> questCompleteToasts_;
bool questCompleteCallbackSet_ = false;
static constexpr float kQuestCompleteToastLifetime = 4.0f;
// Zone entry toast: brief banner when entering a new zone
struct ZoneToastEntry { std::string zoneName; float age = 0.0f; };
std::vector<ZoneToastEntry> zoneToasts_;
struct AreaTriggerToast { std::string text; float age = 0.0f; };
std::vector<AreaTriggerToast> areaTriggerToasts_;
void renderAreaTriggerToasts(float deltaTime, game::GameHandler& gameHandler);
std::string lastKnownZone_;
static constexpr float kZoneToastLifetime = 3.0f;
// Death screen: elapsed time since the death dialog first appeared
float deathElapsed_ = 0.0f;
bool deathTimerRunning_ = false;
// WoW forces release after ~6 minutes; show countdown until then
static constexpr float kForcedReleaseSec = 360.0f;
void renderZoneToasts(float deltaTime);
bool showPlayerInfo = false;
bool showSocialFrame_ = false; // O key toggles social/friends list
bool showGuildRoster_ = false;
@ -255,6 +290,7 @@ private:
* Render pet frame (below player frame when player has an active pet)
*/
void renderPetFrame(game::GameHandler& gameHandler);
void renderTotemFrame(game::GameHandler& gameHandler);
/**
* Process targeting input (Tab, Escape, click)
@ -275,6 +311,7 @@ private:
void renderActionBar(game::GameHandler& gameHandler);
void renderBagBar(game::GameHandler& gameHandler);
void renderXpBar(game::GameHandler& gameHandler);
void renderRepBar(game::GameHandler& gameHandler);
void renderCastBar(game::GameHandler& gameHandler);
void renderMirrorTimers(game::GameHandler& gameHandler);
void renderCombatText(game::GameHandler& gameHandler);
@ -283,8 +320,10 @@ private:
void renderBossFrames(game::GameHandler& gameHandler);
void renderUIErrors(game::GameHandler& gameHandler, float deltaTime);
void renderRepToasts(float deltaTime);
void renderQuestCompleteToasts(float deltaTime);
void renderGroupInvitePopup(game::GameHandler& gameHandler);
void renderDuelRequestPopup(game::GameHandler& gameHandler);
void renderDuelCountdown(game::GameHandler& gameHandler);
void renderLootRollPopup(game::GameHandler& gameHandler);
void renderTradeRequestPopup(game::GameHandler& gameHandler);
void renderTradeWindow(game::GameHandler& gameHandler);
@ -364,6 +403,14 @@ private:
int bagBarPickedSlot_ = -1; // Visual drag in progress (-1 = none)
int bagBarDragSource_ = -1; // Mouse pressed on this slot, waiting for drag or click (-1 = none)
// Who Results window
bool showWhoWindow_ = false;
void renderWhoWindow(game::GameHandler& gameHandler);
// Combat Log window
bool showCombatLog_ = false;
void renderCombatLog(game::GameHandler& gameHandler);
// Instance Lockouts window
bool showInstanceLockouts_ = false;
@ -387,6 +434,10 @@ private:
// Threat window
bool showThreatWindow_ = false;
void renderThreatWindow(game::GameHandler& gameHandler);
// BG scoreboard window
bool showBgScoreboard_ = false;
void renderBgScoreboard(game::GameHandler& gameHandler);
uint8_t lfgRoles_ = 0x08; // default: DPS (0x02=tank, 0x04=healer, 0x08=dps)
uint32_t lfgSelectedDungeon_ = 861; // default: random dungeon (entry 861 = Random Dungeon WotLK)
@ -477,6 +528,9 @@ private:
bool showDPSMeter_ = false;
float dpsCombatAge_ = 0.0f; // seconds in current combat (for accurate early-combat DPS)
bool dpsWasInCombat_ = false;
float dpsEncounterDamage_ = 0.0f; // total player damage this combat
float dpsEncounterHeal_ = 0.0f; // total player healing this combat
size_t dpsLogSeenCount_ = 0; // log entries already scanned
public:
void triggerDing(uint32_t newLevel);

View file

@ -96,7 +96,7 @@ private:
std::unordered_map<uint32_t, VkDescriptorSet> iconCache_;
public:
VkDescriptorSet getItemIcon(uint32_t displayInfoId);
void renderItemTooltip(const game::ItemQueryResponseData& info);
void renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory = nullptr);
private:
// Character model preview

View file

@ -54,6 +54,16 @@ public:
uint32_t getDragSpellId() const { return dragSpellId_; }
void consumeDragSpell() { draggingSpell_ = false; dragSpellId_ = 0; dragSpellIconTex_ = VK_NULL_HANDLE; }
/// Returns the max range in yards for a spell (0 if self-cast, unknown, or melee).
/// Triggers DBC load if needed. Used by the action bar for out-of-range tinting.
uint32_t getSpellMaxRange(uint32_t spellId, pipeline::AssetManager* assetManager);
/// Returns the power cost and type for a spell (cost=0 if unknown/free).
/// powerType: 0=mana, 1=rage, 2=focus, 3=energy, 6=runic power.
/// Triggers DBC load if needed. Used by the action bar for insufficient-power tinting.
void getSpellPowerInfo(uint32_t spellId, pipeline::AssetManager* assetManager,
uint32_t& outCost, uint32_t& outPowerType);
/// Returns a WoW spell link string if the user shift-clicked a spell, then clears it.
std::string getAndClearPendingChatLink() {
std::string out = std::move(pendingChatSpellLink_);

View file

@ -122,6 +122,14 @@ bool UiSoundManager::initialize(pipeline::AssetManager* assets) {
deselectTargetSounds_.resize(1);
loadSound("Sound\\Interface\\iDeselectTarget.wav", deselectTargetSounds_[0], assets);
// Whisper notification (falls back to iSelectTarget if the dedicated file is absent)
whisperSounds_.resize(1);
if (!loadSound("Sound\\Interface\\Whisper_TellMale.wav", whisperSounds_[0], assets)) {
if (!loadSound("Sound\\Interface\\Whisper_TellFemale.wav", whisperSounds_[0], assets)) {
whisperSounds_ = selectTargetSounds_;
}
}
LOG_INFO("UISoundManager: Window sounds - Bag: ", (bagOpenLoaded && bagCloseLoaded) ? "YES" : "NO",
", QuestLog: ", (questLogOpenLoaded && questLogCloseLoaded) ? "YES" : "NO",
", CharSheet: ", (charSheetOpenLoaded && charSheetCloseLoaded) ? "YES" : "NO");
@ -225,5 +233,8 @@ void UiSoundManager::playError() { playSound(errorSounds_); }
void UiSoundManager::playTargetSelect() { playSound(selectTargetSounds_); }
void UiSoundManager::playTargetDeselect() { playSound(deselectTargetSounds_); }
// Chat notifications
void UiSoundManager::playWhisperReceived() { playSound(whisperSounds_); }
} // namespace audio
} // namespace wowee

View file

@ -2028,7 +2028,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
/*uint32_t randSuffix =*/ packet.readUInt32();
/*uint32_t randProp =*/ packet.readUInt32();
}
/*uint32_t countdown =*/ packet.readUInt32();
uint32_t countdown = packet.readUInt32();
/*uint8_t voteMask =*/ packet.readUInt8();
// Trigger the roll popup for local player
pendingLootRollActive_ = true;
@ -2038,6 +2038,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
auto* info = getItemInfo(itemId);
pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId);
pendingLootRoll_.itemQuality = info ? static_cast<uint8_t>(info->quality) : 0;
pendingLootRoll_.rollCountdownMs = (countdown > 0 && countdown <= 120000) ? countdown : 60000;
pendingLootRoll_.rollStartedAt = std::chrono::steady_clock::now();
LOG_INFO("SMSG_LOOT_START_ROLL: item=", itemId, " (", pendingLootRoll_.itemName,
") slot=", slot);
break;
@ -3059,6 +3061,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
uint32_t spellId = packet.readUInt32();
LOG_DEBUG("SMSG_TOTEM_CREATED: slot=", (int)slot,
" spellId=", spellId, " duration=", duration, "ms");
if (slot < NUM_TOTEM_SLOTS) {
activeTotemSlots_[slot].spellId = spellId;
activeTotemSlots_[slot].durationMs = duration;
activeTotemSlots_[slot].placedAt = std::chrono::steady_clock::now();
}
break;
}
case Opcode::SMSG_AREA_SPIRIT_HEALER_TIME: {
@ -3142,6 +3149,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
readyCheckReadyCount_ = 0;
readyCheckNotReadyCount_ = 0;
readyCheckInitiator_.clear();
readyCheckResults_.clear();
if (packet.getSize() - packet.getReadPos() >= 8) {
uint64_t initiatorGuid = packet.readUInt64();
auto entity = entityManager.getEntity(initiatorGuid);
@ -3175,7 +3183,14 @@ void GameHandler::handlePacket(network::Packet& packet) {
auto ent = entityManager.getEntity(respGuid);
if (ent) rname = std::static_pointer_cast<game::Unit>(ent)->getName();
}
// Track per-player result for live popup display
if (!rname.empty()) {
bool found = false;
for (auto& r : readyCheckResults_) {
if (r.name == rname) { r.ready = (isReady != 0); found = true; break; }
}
if (!found) readyCheckResults_.push_back({ rname, isReady != 0 });
char rbuf[128];
std::snprintf(rbuf, sizeof(rbuf), "%s is %s.", rname.c_str(), isReady ? "Ready" : "Not Ready");
addSystemChatMessage(rbuf);
@ -3191,6 +3206,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
pendingReadyCheck_ = false;
readyCheckReadyCount_ = 0;
readyCheckNotReadyCount_ = 0;
readyCheckResults_.clear();
break;
}
case Opcode::SMSG_RAID_INSTANCE_INFO:
@ -3212,9 +3228,16 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_DUEL_INBOUNDS:
// Re-entered the duel area; no special action needed.
break;
case Opcode::SMSG_DUEL_COUNTDOWN:
// Countdown timer — no action needed; server also sends UNIT_FIELD_FLAGS update.
case Opcode::SMSG_DUEL_COUNTDOWN: {
// uint32 countdown in milliseconds (typically 3000 ms)
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t ms = packet.readUInt32();
duelCountdownMs_ = (ms > 0 && ms <= 30000) ? ms : 3000;
duelCountdownStartedAt_ = std::chrono::steady_clock::now();
LOG_INFO("SMSG_DUEL_COUNTDOWN: ", duelCountdownMs_, " ms");
}
break;
}
case Opcode::SMSG_PARTYKILLLOG: {
// uint64 killerGuid + uint64 victimGuid
if (packet.getSize() - packet.getReadPos() < 16) break;
@ -3544,6 +3567,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
delta > 0 ? "increased" : "decreased",
std::abs(delta));
addSystemChatMessage(buf);
watchedFactionId_ = factionId;
if (repChangeCallback_) repChangeCallback_(name, delta, standing);
}
LOG_DEBUG("SMSG_SET_FACTION_STANDING: faction=", factionId, " standing=", standing);
@ -3853,7 +3877,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() >= 4) {
/*uint32_t len =*/ packet.readUInt32();
std::string msg = packet.readString();
if (!msg.empty()) addSystemChatMessage(msg);
if (!msg.empty()) {
addSystemChatMessage(msg);
areaTriggerMsgs_.push_back(msg);
}
}
break;
}
@ -4381,6 +4408,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
for (auto it = questLog_.begin(); it != questLog_.end(); ++it) {
if (it->questId == questId) {
// Fire toast callback before erasing
if (questCompleteCallback_) {
questCompleteCallback_(questId, it->title);
}
questLog_.erase(it);
LOG_INFO(" Removed quest ", questId, " from quest log");
break;
@ -4957,7 +4988,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
handleArenaError(packet);
break;
case Opcode::MSG_PVP_LOG_DATA:
LOG_INFO("Received MSG_PVP_LOG_DATA");
handlePvpLogData(packet);
break;
case Opcode::MSG_INSPECT_ARENA_TEAMS:
LOG_INFO("Received MSG_INSPECT_ARENA_TEAMS");
@ -5176,6 +5207,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
std::vector<AuraSlot>* auraList = nullptr;
if (auraTargetGuid == playerGuid) auraList = &playerAuras;
else if (auraTargetGuid == targetGuid) auraList = &targetAuras;
else if (auraTargetGuid != 0) auraList = &unitAurasCache_[auraTargetGuid];
if (auraList && isInit) auraList->clear();
@ -5559,9 +5591,28 @@ void GameHandler::handlePacket(network::Packet& packet) {
packet.setReadPos(packet.getSize());
break;
}
case Opcode::SMSG_SPELL_CHANCE_PROC_LOG: {
// Format (all expansions): PackedGuid target + PackedGuid caster + uint32 spellId + ...
if (packet.getSize() - packet.getReadPos() < 3) {
packet.setReadPos(packet.getSize()); break;
}
/*uint64_t targetGuid =*/ UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 2) {
packet.setReadPos(packet.getSize()); break;
}
uint64_t procCasterGuid = UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 4) {
packet.setReadPos(packet.getSize()); break;
}
uint32_t procSpellId = packet.readUInt32();
// Show a "PROC!" floating text when the player triggers the proc
if (procCasterGuid == playerGuid && procSpellId > 0)
addCombatText(CombatTextEntry::PROC_TRIGGER, 0, procSpellId, true);
packet.setReadPos(packet.getSize());
break;
}
case Opcode::SMSG_SPELLINSTAKILLLOG:
case Opcode::SMSG_SPELLLOGEXECUTE:
case Opcode::SMSG_SPELL_CHANCE_PROC_LOG:
case Opcode::SMSG_SPELL_CHANCE_RESIST_PUSHBACK:
case Opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS:
packet.setReadPos(packet.getSize());
@ -6658,6 +6709,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) {
actionBar = {};
playerAuras.clear();
targetAuras.clear();
unitAurasCache_.clear();
unitCastStates_.clear();
petGuid_ = 0;
playerXp_ = 0;
@ -10209,6 +10261,15 @@ void GameHandler::followTarget() {
LOG_INFO("Following target: ", targetName, " (GUID: 0x", std::hex, targetGuid, std::dec, ")");
}
void GameHandler::cancelFollow() {
if (followTargetGuid_ == 0) {
addSystemChatMessage("You are not following anyone.");
return;
}
followTargetGuid_ = 0;
addSystemChatMessage("You stop following.");
}
void GameHandler::assistTarget() {
if (state != WorldState::IN_WORLD) {
LOG_WARNING("Cannot assist: not in world");
@ -10465,6 +10526,7 @@ void GameHandler::handleDuelComplete(network::Packet& packet) {
uint8_t started = packet.readUInt8();
// started=1: duel began, started=0: duel was cancelled before starting
pendingDuelRequest_ = false;
duelCountdownMs_ = 0; // clear countdown once duel is resolved
if (!started) {
addSystemChatMessage("The duel was cancelled.");
}
@ -11300,6 +11362,7 @@ void GameHandler::handleInspectResults(network::Packet& packet) {
}
// Parse enchantment slot mask + enchant IDs
std::array<uint16_t, 19> enchantIds{};
bytesLeft = packet.getSize() - packet.getReadPos();
if (bytesLeft >= 4) {
uint32_t slotMask = packet.readUInt32();
@ -11307,7 +11370,7 @@ void GameHandler::handleInspectResults(network::Packet& packet) {
if (slotMask & (1u << slot)) {
bytesLeft = packet.getSize() - packet.getReadPos();
if (bytesLeft < 2) break;
packet.readUInt16(); // enchantId
enchantIds[slot] = packet.readUInt16();
}
}
}
@ -11319,6 +11382,7 @@ void GameHandler::handleInspectResults(network::Packet& packet) {
inspectResult_.unspentTalents = unspentTalents;
inspectResult_.talentGroups = talentGroupCount;
inspectResult_.activeTalentGroup = activeTalentGroup;
inspectResult_.enchantIds = enchantIds;
// Merge any gear we already have from a prior inspect request
auto gearIt = inspectedPlayerItemEntries_.find(guid);
@ -12101,6 +12165,21 @@ void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint
entry.age = 0.0f;
entry.isPlayerSource = isPlayerSource;
combatText.push_back(entry);
// Persistent combat log
CombatLogEntry log;
log.type = type;
log.amount = amount;
log.spellId = spellId;
log.isPlayerSource = isPlayerSource;
log.timestamp = std::time(nullptr);
std::string pname(lookupName(playerGuid));
std::string tname((targetGuid != 0) ? lookupName(targetGuid) : std::string());
log.sourceName = isPlayerSource ? pname : tname;
log.targetName = isPlayerSource ? tname : pname;
if (combatLog_.size() >= MAX_COMBAT_LOG)
combatLog_.pop_front();
combatLog_.push_back(std::move(log));
}
void GameHandler::updateCombatText(float deltaTime) {
@ -13061,11 +13140,19 @@ void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) {
lfgBootTimeLeft_ = timeLeft;
lfgBootNeeded_ = votesNeeded;
// Optional: reason string and target name (null-terminated) follow the fixed fields
if (packet.getSize() - packet.getReadPos() > 0)
lfgBootReason_ = packet.readString();
if (packet.getSize() - packet.getReadPos() > 0)
lfgBootTargetName_ = packet.readString();
if (inProgress) {
lfgState_ = LfgState::Boot;
} else {
// Boot vote ended — return to InDungeon state regardless of outcome
lfgBootVotes_ = lfgBootTotal_ = lfgBootTimeLeft_ = lfgBootNeeded_ = 0;
lfgBootTargetName_.clear();
lfgBootReason_.clear();
lfgState_ = LfgState::InDungeon;
if (myAnswer) {
addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed.");
@ -13075,7 +13162,8 @@ void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) {
}
LOG_INFO("SMSG_LFG_BOOT_PROPOSAL_UPDATE: inProgress=", inProgress,
" bootVotes=", bootVotes, "/", totalVotes);
" bootVotes=", bootVotes, "/", totalVotes,
" target=", lfgBootTargetName_, " reason=", lfgBootReason_);
}
void GameHandler::handleLfgTeleportDenied(network::Packet& packet) {
@ -13380,6 +13468,80 @@ void GameHandler::handleArenaError(network::Packet& packet) {
LOG_INFO("Arena error: ", error, " - ", msg);
}
void GameHandler::requestPvpLog() {
if (state != WorldState::IN_WORLD || !socket) return;
// MSG_PVP_LOG_DATA is bidirectional: client sends an empty packet to request
network::Packet pkt(wireOpcode(Opcode::MSG_PVP_LOG_DATA));
socket->send(pkt);
LOG_INFO("Requested PvP log data");
}
void GameHandler::handlePvpLogData(network::Packet& packet) {
auto remaining = [&]() { return packet.getSize() - packet.getReadPos(); };
if (remaining() < 1) return;
bgScoreboard_ = BgScoreboardData{};
bgScoreboard_.isArena = (packet.readUInt8() != 0);
if (bgScoreboard_.isArena) {
// Skip arena-specific header (two teams × (rating change uint32 + name string + 5×uint32))
// Rather than hardcoding arena parse we skip gracefully up to playerCount
// Each arena team block: uint32 + string + uint32*5 — variable length due to string.
// Skip by scanning for the uint32 playerCount heuristically; simply consume rest.
packet.setReadPos(packet.getSize());
return;
}
if (remaining() < 4) return;
uint32_t playerCount = packet.readUInt32();
bgScoreboard_.players.reserve(playerCount);
for (uint32_t i = 0; i < playerCount && remaining() >= 13; ++i) {
BgPlayerScore ps;
ps.guid = packet.readUInt64();
ps.team = packet.readUInt8();
ps.killingBlows = packet.readUInt32();
ps.honorableKills = packet.readUInt32();
ps.deaths = packet.readUInt32();
ps.bonusHonor = packet.readUInt32();
// Resolve player name from entity manager
{
auto ent = entityManager.getEntity(ps.guid);
if (ent && (ent->getType() == game::ObjectType::PLAYER ||
ent->getType() == game::ObjectType::UNIT)) {
auto u = std::static_pointer_cast<game::Unit>(ent);
if (!u->getName().empty()) ps.name = u->getName();
}
}
// BG-specific stat blocks: uint32 count + N × (string fieldName + uint32 value)
if (remaining() < 4) { bgScoreboard_.players.push_back(std::move(ps)); break; }
uint32_t statCount = packet.readUInt32();
for (uint32_t s = 0; s < statCount && remaining() >= 5; ++s) {
std::string fieldName;
while (remaining() > 0) {
char c = static_cast<char>(packet.readUInt8());
if (c == '\0') break;
fieldName += c;
}
uint32_t val = (remaining() >= 4) ? packet.readUInt32() : 0;
ps.bgStats.emplace_back(std::move(fieldName), val);
}
bgScoreboard_.players.push_back(std::move(ps));
}
if (remaining() >= 1) {
bgScoreboard_.hasWinner = (packet.readUInt8() != 0);
if (bgScoreboard_.hasWinner && remaining() >= 1)
bgScoreboard_.winner = packet.readUInt8();
}
LOG_INFO("PvP log: ", bgScoreboard_.players.size(), " players, hasWinner=",
bgScoreboard_.hasWinner, " winner=", (int)bgScoreboard_.winner);
}
void GameHandler::handleOtherPlayerMovement(network::Packet& packet) {
// Server relays MSG_MOVE_* for other players: packed GUID (WotLK) or full uint64 (TBC/Classic)
const bool otherMoveTbc = isClassicLikeExpansion() || isActiveExpansion("tbc");
@ -14099,6 +14261,10 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) {
: CastSpellPacket::build(spellId, target, ++castCount);
socket->send(packet);
LOG_INFO("Casting spell: ", spellId, " on 0x", std::hex, target, std::dec);
// Optimistically start GCD immediately on cast — server will confirm or override
gcdTotal_ = 1.5f;
gcdStartedAt_ = std::chrono::steady_clock::now();
}
void GameHandler::cancelCast() {
@ -14457,6 +14623,14 @@ void GameHandler::handleSpellCooldown(network::Packet& packet) {
uint32_t cooldownMs = packet.readUInt32();
float seconds = cooldownMs / 1000.0f;
// spellId=0 is the Global Cooldown marker (server sends it for GCD triggers)
if (spellId == 0 && cooldownMs > 0 && cooldownMs <= 2000) {
gcdTotal_ = seconds;
gcdStartedAt_ = std::chrono::steady_clock::now();
continue;
}
spellCooldowns[spellId] = seconds;
for (auto& slot : actionBar) {
bool match = (slot.type == ActionBarSlot::SPELL && slot.id == spellId)
@ -14497,6 +14671,10 @@ void GameHandler::handleAuraUpdate(network::Packet& packet, bool isAll) {
} else if (data.guid == targetGuid) {
auraList = &targetAuras;
}
// Also maintain a per-unit cache for any unit (party members, etc.)
if (data.guid != 0 && data.guid != playerGuid && data.guid != targetGuid) {
auraList = &unitAurasCache_[data.guid];
}
if (auraList) {
if (isAll) {
@ -14915,20 +15093,34 @@ void GameHandler::handlePartyMemberStats(network::Packet& packet, bool isFull) {
if (updateFlags & 0x0200) { // AURAS
if (remaining() >= 8) {
uint64_t auraMask = packet.readUInt64();
// Collect aura updates for this member and store in unitAurasCache_
// so party frame debuff dots can use them.
std::vector<AuraSlot> newAuras;
for (int i = 0; i < 64; ++i) {
if (auraMask & (uint64_t(1) << i)) {
AuraSlot a;
a.level = static_cast<uint8_t>(i); // use slot index
if (isWotLK) {
// WotLK: uint32 spellId + uint8 auraFlags
if (remaining() < 5) break;
packet.readUInt32();
packet.readUInt8();
a.spellId = packet.readUInt32();
a.flags = packet.readUInt8();
} else {
// Classic/TBC: uint16 spellId only
// Classic/TBC: uint16 spellId only; negative auras not indicated here
if (remaining() < 2) break;
packet.readUInt16();
a.spellId = packet.readUInt16();
// Infer negative/positive from dispel type: non-zero dispel → debuff
uint8_t dt = getSpellDispelType(a.spellId);
if (dt > 0) a.flags = 0x80; // mark as debuff
}
if (a.spellId != 0) newAuras.push_back(a);
}
}
// Populate unitAurasCache_ for this member (merge: keep existing per-GUID data
// only if we already have a richer source; otherwise replace with stats data)
if (memberGuid != 0 && memberGuid != playerGuid && memberGuid != targetGuid) {
unitAurasCache_[memberGuid] = std::move(newAuras);
}
}
}
if (updateFlags & 0x0400) { // PET_GUID
@ -16016,6 +16208,28 @@ void GameHandler::abandonQuest(uint32_t questId) {
gossipPois_.end());
}
void GameHandler::shareQuestWithParty(uint32_t questId) {
if (state != WorldState::IN_WORLD || !socket) {
addSystemChatMessage("Cannot share quest: not in world.");
return;
}
if (!isInGroup()) {
addSystemChatMessage("You must be in a group to share a quest.");
return;
}
network::Packet pkt(wireOpcode(Opcode::CMSG_PUSHQUESTTOPARTY));
pkt.writeUInt32(questId);
socket->send(pkt);
// Local feedback: find quest title
for (const auto& q : questLog_) {
if (q.questId == questId && !q.title.empty()) {
addSystemChatMessage("Sharing quest: " + q.title);
return;
}
}
addSystemChatMessage("Quest shared.");
}
void GameHandler::handleQuestRequestItems(network::Packet& packet) {
QuestRequestItemsData data;
if (!QuestRequestItemsParser::parse(packet, data)) {
@ -16932,6 +17146,14 @@ void GameHandler::loadSpellNameCache() {
if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { schoolEnumField = f; hasSchoolEnum = true; }
}
// DispelType field (0=none,1=magic,2=curse,3=disease,4=poison,5=stealth,…)
uint32_t dispelField = 0xFFFFFFFF;
bool hasDispelField = false;
if (spellL) {
uint32_t f = spellL->field("DispelType");
if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { dispelField = f; hasDispelField = true; }
}
uint32_t count = dbc->getRecordCount();
for (uint32_t i = 0; i < count; ++i) {
uint32_t id = dbc->getUInt32(i, spellL ? (*spellL)["ID"] : 0);
@ -16939,7 +17161,7 @@ void GameHandler::loadSpellNameCache() {
std::string name = dbc->getString(i, spellL ? (*spellL)["Name"] : 136);
std::string rank = dbc->getString(i, spellL ? (*spellL)["Rank"] : 153);
if (!name.empty()) {
SpellNameEntry entry{std::move(name), std::move(rank), 0};
SpellNameEntry entry{std::move(name), std::move(rank), 0, 0};
if (hasSchoolMask) {
entry.schoolMask = dbc->getUInt32(i, schoolMaskField);
} else if (hasSchoolEnum) {
@ -16948,6 +17170,9 @@ void GameHandler::loadSpellNameCache() {
uint32_t e = dbc->getUInt32(i, schoolEnumField);
entry.schoolMask = (e < 7) ? enumToBitmask[e] : 0;
}
if (hasDispelField) {
entry.dispelType = static_cast<uint8_t>(dbc->getUInt32(i, dispelField));
}
spellNameCache_[id] = std::move(entry);
}
}
@ -17141,6 +17366,12 @@ const std::string& GameHandler::getSpellRank(uint32_t spellId) const {
return (it != spellNameCache_.end()) ? it->second.rank : EMPTY_STRING;
}
uint8_t GameHandler::getSpellDispelType(uint32_t spellId) const {
const_cast<GameHandler*>(this)->loadSpellNameCache();
auto it = spellNameCache_.find(spellId);
return (it != spellNameCache_.end()) ? it->second.dispelType : 0;
}
const std::string& GameHandler::getSkillLineName(uint32_t spellId) const {
auto slIt = spellToSkillLine_.find(spellId);
if (slIt == spellToSkillLine_.end()) return EMPTY_STRING;
@ -18233,13 +18464,15 @@ void GameHandler::handleWho(network::Packet& packet) {
LOG_INFO("WHO response: ", displayCount, " players displayed, ", onlineCount, " total online");
// Store structured results for the who-results window
whoResults_.clear();
whoOnlineCount_ = onlineCount;
if (displayCount == 0) {
addSystemChatMessage("No players found.");
return;
}
addSystemChatMessage(std::to_string(onlineCount) + " player(s) online:");
for (uint32_t i = 0; i < displayCount; ++i) {
if (packet.getReadPos() >= packet.getSize()) break;
std::string playerName = packet.readString();
@ -18254,17 +18487,16 @@ void GameHandler::handleWho(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() >= 4)
zoneId = packet.readUInt32();
std::string msg = " " + playerName;
if (!guildName.empty())
msg += " <" + guildName + ">";
msg += " - Level " + std::to_string(level);
if (zoneId != 0) {
std::string zoneName = getAreaName(zoneId);
if (!zoneName.empty())
msg += " [" + zoneName + "]";
}
// Store structured entry
WhoEntry entry;
entry.name = playerName;
entry.guildName = guildName;
entry.level = level;
entry.classId = classId;
entry.raceId = raceId;
entry.zoneId = zoneId;
whoResults_.push_back(std::move(entry));
addSystemChatMessage(msg);
LOG_INFO(" ", playerName, " (", guildName, ") Lv", level, " Class:", classId,
" Race:", raceId, " Zone:", zoneId);
}
@ -19921,12 +20153,15 @@ void GameHandler::handleLootRoll(network::Packet& packet) {
pendingLootRoll_.objectGuid = objectGuid;
pendingLootRoll_.slot = slot;
pendingLootRoll_.itemId = itemId;
pendingLootRoll_.playerRolls.clear();
// Ensure item info is in cache; query if not
queryItemInfo(itemId, 0);
// Look up item name from cache
auto* info = getItemInfo(itemId);
pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId);
pendingLootRoll_.itemQuality = info ? static_cast<uint8_t>(info->quality) : 0;
pendingLootRoll_.rollCountdownMs = 60000;
pendingLootRoll_.rollStartedAt = std::chrono::steady_clock::now();
LOG_INFO("SMSG_LOOT_ROLL: need/greed prompt for item=", itemId,
" (", pendingLootRoll_.itemName, ") slot=", slot);
return;
@ -19943,6 +20178,28 @@ void GameHandler::handleLootRoll(network::Packet& packet) {
}
if (rollerName.empty()) rollerName = "Someone";
// Track in the live roll list while our popup is open for the same item
if (pendingLootRollActive_ &&
pendingLootRoll_.objectGuid == objectGuid &&
pendingLootRoll_.slot == slot) {
bool found = false;
for (auto& r : pendingLootRoll_.playerRolls) {
if (r.playerName == rollerName) {
r.rollNum = rollNum;
r.rollType = rollType;
found = true;
break;
}
}
if (!found) {
LootRollEntry::PlayerRollResult prr;
prr.playerName = rollerName;
prr.rollNum = rollNum;
prr.rollType = rollType;
pendingLootRoll_.playerRolls.push_back(std::move(prr));
}
}
auto* info = getItemInfo(itemId);
std::string iName = info ? info->name : std::to_string(itemId);
@ -19997,10 +20254,8 @@ void GameHandler::handleLootRollWon(network::Packet& packet) {
winnerName.c_str(), iName.c_str(), rollName, static_cast<int>(rollNum));
addSystemChatMessage(buf);
// Clear pending roll if it was ours
if (pendingLootRollActive_ && winnerGuid == playerGuid) {
pendingLootRollActive_ = false;
}
// Dismiss roll popup — roll contest is over regardless of who won
pendingLootRollActive_ = false;
LOG_INFO("SMSG_LOOT_ROLL_WON: winner=", winnerName, " item=", itemId,
" roll=", rollName, "(", rollNum, ")");
}
@ -20057,7 +20312,7 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) {
uint64_t guid = packet.readUInt64();
uint32_t achievementId = packet.readUInt32();
/*uint32_t date =*/ packet.readUInt32(); // PackedTime — not displayed
uint32_t earnDate = packet.readUInt32(); // WoW PackedTime bitfield
loadAchievementNameCache();
auto nameIt = achievementNameCache_.find(achievementId);
@ -20076,6 +20331,7 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) {
addSystemChatMessage(buf);
earnedAchievements_.insert(achievementId);
achievementDates_[achievementId] = earnDate;
if (achievementEarnedCallback_) {
achievementEarnedCallback_(achievementId, achName);
}
@ -20116,14 +20372,16 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) {
void GameHandler::handleAllAchievementData(network::Packet& packet) {
loadAchievementNameCache();
earnedAchievements_.clear();
achievementDates_.clear();
// Parse achievement entries (id + packedDate pairs, sentinel 0xFFFFFFFF)
while (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t id = packet.readUInt32();
if (id == 0xFFFFFFFF) break;
if (packet.getSize() - packet.getReadPos() < 4) break;
/*uint32_t date =*/ packet.readUInt32();
uint32_t date = packet.readUInt32();
earnedAchievements_.insert(id);
achievementDates_[id] = date;
}
// Parse criteria block: id + uint64 counter + uint32 date + uint32 flags, sentinel 0xFFFFFFFF

View file

@ -12,6 +12,7 @@
#include "core/logger.hpp"
#include <imgui.h>
#include <cmath>
#include <cstdio>
#include <algorithm>
#include <limits>
@ -1016,6 +1017,40 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi
}
}
// Hover coordinate display — show WoW coordinates under cursor
if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD) {
auto& io = ImGui::GetIO();
ImVec2 mp = io.MousePos;
if (mp.x >= imgMin.x && mp.x <= imgMin.x + displayW &&
mp.y >= imgMin.y && mp.y <= imgMin.y + displayH) {
float mu = (mp.x - imgMin.x) / displayW;
float mv = (mp.y - imgMin.y) / displayH;
const auto& zone = zones[currentIdx];
float left = zone.locLeft, right = zone.locRight;
float top = zone.locTop, bottom = zone.locBottom;
if (zone.areaID == 0) {
float l, r, t, b;
getContinentProjectionBounds(currentIdx, l, r, t, b);
left = l; right = r; top = t; bottom = b;
// Undo the kVOffset applied during renderPosToMapUV for continent
constexpr float kVOffset = -0.15f;
mv -= kVOffset;
}
float hWowX = left - mu * (left - right);
float hWowY = top - mv * (top - bottom);
char coordBuf[32];
snprintf(coordBuf, sizeof(coordBuf), "%.0f, %.0f", hWowX, hWowY);
ImVec2 coordSz = ImGui::CalcTextSize(coordBuf);
float cx = imgMin.x + displayW - coordSz.x - 8.0f;
float cy = imgMin.y + displayH - coordSz.y - 8.0f;
drawList->AddText(ImVec2(cx + 1.0f, cy + 1.0f), IM_COL32(0, 0, 0, 180), coordBuf);
drawList->AddText(ImVec2(cx, cy), IM_COL32(220, 210, 150, 230), coordBuf);
}
}
// Continent view: clickable zone overlays
if (viewLevel == ViewLevel::CONTINENT && continentIdx >= 0) {
const auto& cont = zones[continentIdx];
@ -1080,6 +1115,23 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi
drawList->AddRect(ImVec2(sx0, sy0), ImVec2(sx1, sy1),
IM_COL32(255, 255, 255, 30), 0.0f, 0, 1.0f);
}
// Zone name label — only if the rect is large enough to fit it
if (!z.areaName.empty()) {
ImVec2 textSz = ImGui::CalcTextSize(z.areaName.c_str());
float rectW = sx1 - sx0;
float rectH = sy1 - sy0;
if (rectW > textSz.x + 4.0f && rectH > textSz.y + 2.0f) {
float tx = (sx0 + sx1) * 0.5f - textSz.x * 0.5f;
float ty = (sy0 + sy1) * 0.5f - textSz.y * 0.5f;
ImU32 labelCol = explored
? IM_COL32(255, 230, 150, 210)
: IM_COL32(160, 160, 160, 80);
drawList->AddText(ImVec2(tx + 1.0f, ty + 1.0f),
IM_COL32(0, 0, 0, 130), z.areaName.c_str());
drawList->AddText(ImVec2(tx, ty), labelCol, z.areaName.c_str());
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -72,6 +72,21 @@ const game::ItemSlot* findComparableEquipped(const game::Inventory& inventory, u
default: return nullptr;
}
}
void renderCoinsText(uint32_t g, uint32_t s, uint32_t c) {
bool any = false;
if (g > 0) {
ImGui::TextColored(ImVec4(1.00f, 0.82f, 0.00f, 1.0f), "%ug", g);
any = true;
}
if (s > 0 || g > 0) {
if (any) ImGui::SameLine(0, 3);
ImGui::TextColored(ImVec4(0.80f, 0.80f, 0.80f, 1.0f), "%us", s);
any = true;
}
if (any) ImGui::SameLine(0, 3);
ImGui::TextColored(ImVec4(0.72f, 0.45f, 0.20f, 1.0f), "%uc", c);
}
} // namespace
InventoryScreen::~InventoryScreen() {
@ -2197,7 +2212,8 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
uint32_t g = item.sellPrice / 10000;
uint32_t s = (item.sellPrice / 100) % 100;
uint32_t c = item.sellPrice % 100;
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c);
ImGui::TextDisabled("Sell:"); ImGui::SameLine(0, 4);
renderCoinsText(g, s, c);
}
// Shift-hover comparison with currently equipped equivalent.
@ -2321,7 +2337,7 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
// ---------------------------------------------------------------------------
// Tooltip overload for ItemQueryResponseData (used by loot window, etc.)
// ---------------------------------------------------------------------------
void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info) {
void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory) {
ImGui::BeginTooltip();
ImVec4 qColor = getQualityColor(static_cast<game::ItemQuality>(info.quality));
@ -2477,7 +2493,50 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info)
uint32_t g = info.sellPrice / 10000;
uint32_t s = (info.sellPrice / 100) % 100;
uint32_t c = info.sellPrice % 100;
ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c);
ImGui::TextDisabled("Sell:"); ImGui::SameLine(0, 4);
renderCoinsText(g, s, c);
}
// Shift-hover: compare with currently equipped item
if (inventory && ImGui::GetIO().KeyShift && info.inventoryType > 0) {
if (const game::ItemSlot* eq = findComparableEquipped(*inventory, static_cast<uint8_t>(info.inventoryType))) {
ImGui::Separator();
ImGui::TextDisabled("Equipped:");
VkDescriptorSet eqIcon = getItemIcon(eq->item.displayInfoId);
if (eqIcon) { ImGui::Image((ImTextureID)(uintptr_t)eqIcon, ImVec2(18.0f, 18.0f)); ImGui::SameLine(); }
ImGui::TextColored(getQualityColor(eq->item.quality), "%s", eq->item.name.c_str());
auto showDiff = [](const char* label, float nv, float ev) {
if (nv == 0.0f && ev == 0.0f) return;
float diff = nv - ev;
char buf[96];
if (diff > 0.0f) { std::snprintf(buf, sizeof(buf), "%s: %.0f (▲%.0f)", label, nv, diff); ImGui::TextColored(ImVec4(0.0f,1.0f,0.0f,1.0f), "%s", buf); }
else if (diff < 0.0f) { std::snprintf(buf, sizeof(buf), "%s: %.0f (▼%.0f)", label, nv, -diff); ImGui::TextColored(ImVec4(1.0f,0.3f,0.3f,1.0f), "%s", buf); }
else { std::snprintf(buf, sizeof(buf), "%s: %.0f (=)", label, nv); ImGui::TextColored(ImVec4(0.7f,0.7f,0.7f,1.0f), "%s", buf); }
};
float ilvlDiff = static_cast<float>(info.itemLevel) - static_cast<float>(eq->item.itemLevel);
if (info.itemLevel > 0 || eq->item.itemLevel > 0) {
char ilvlBuf[64];
if (ilvlDiff > 0) std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (▲%.0f)", info.itemLevel, ilvlDiff);
else if (ilvlDiff < 0) std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (▼%.0f)", info.itemLevel, -ilvlDiff);
else std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (=)", info.itemLevel);
ImVec4 ic = ilvlDiff > 0 ? ImVec4(0,1,0,1) : ilvlDiff < 0 ? ImVec4(1,0.3f,0.3f,1) : ImVec4(0.7f,0.7f,0.7f,1);
ImGui::TextColored(ic, "%s", ilvlBuf);
}
showDiff("Armor", static_cast<float>(info.armor), static_cast<float>(eq->item.armor));
showDiff("Str", static_cast<float>(info.strength), static_cast<float>(eq->item.strength));
showDiff("Agi", static_cast<float>(info.agility), static_cast<float>(eq->item.agility));
showDiff("Sta", static_cast<float>(info.stamina), static_cast<float>(eq->item.stamina));
showDiff("Int", static_cast<float>(info.intellect), static_cast<float>(eq->item.intellect));
showDiff("Spi", static_cast<float>(info.spirit), static_cast<float>(eq->item.spirit));
// Hint text
ImGui::TextDisabled("Hold Shift to compare");
}
} else if (info.inventoryType > 0) {
ImGui::TextDisabled("Hold Shift to compare");
}
ImGui::EndTooltip();

View file

@ -205,6 +205,21 @@ std::string cleanQuestTitleForUi(const std::string& raw, uint32_t questId) {
if (s.size() > 72) s = s.substr(0, 72) + "...";
return s;
}
void renderCoinsText(uint32_t g, uint32_t s, uint32_t c) {
bool any = false;
if (g > 0) {
ImGui::TextColored(ImVec4(1.00f, 0.82f, 0.00f, 1.0f), "%ug", g);
any = true;
}
if (s > 0 || g > 0) {
if (any) ImGui::SameLine(0, 3);
ImGui::TextColored(ImVec4(0.80f, 0.80f, 0.80f, 1.0f), "%us", s);
any = true;
}
if (any) ImGui::SameLine(0, 3);
ImGui::TextColored(ImVec4(0.72f, 0.45f, 0.20f, 1.0f), "%uc", c);
}
} // anonymous namespace
void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& invScreen) {
@ -362,6 +377,11 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv
if (ImGui::MenuItem(tracked ? "Untrack" : "Track")) {
gameHandler.setQuestTracked(q.questId, !tracked);
}
if (gameHandler.isInGroup() && !q.complete) {
if (ImGui::MenuItem("Share Quest")) {
gameHandler.shareQuestWithParty(q.questId);
}
}
if (!q.complete) {
ImGui::Separator();
if (ImGui::MenuItem("Abandon Quest")) {
@ -488,12 +508,7 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv
uint32_t rg = static_cast<uint32_t>(sel.rewardMoney) / 10000;
uint32_t rs = static_cast<uint32_t>(sel.rewardMoney % 10000) / 100;
uint32_t rc = static_cast<uint32_t>(sel.rewardMoney % 100);
if (rg > 0)
ImGui::Text("%ug %us %uc", rg, rs, rc);
else if (rs > 0)
ImGui::Text("%us %uc", rs, rc);
else
ImGui::Text("%uc", rc);
renderCoinsText(rg, rs, rc);
}
// Guaranteed reward items
@ -549,12 +564,19 @@ void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& inv
}
}
// Track / Abandon buttons
// Track / Share / Abandon buttons
ImGui::Separator();
bool isTracked = gameHandler.isQuestTracked(sel.questId);
if (ImGui::Button(isTracked ? "Untrack" : "Track", ImVec2(100.0f, 0.0f))) {
gameHandler.setQuestTracked(sel.questId, !isTracked);
}
if (gameHandler.isInGroup() && !sel.complete) {
ImGui::SameLine();
if (ImGui::Button("Share", ImVec2(80.0f, 0.0f))) {
gameHandler.shareQuestWithParty(sel.questId);
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Share this quest with your party");
}
if (!sel.complete) {
ImGui::SameLine();
if (ImGui::Button("Abandon Quest", ImVec2(150.0f, 0.0f))) {

View file

@ -203,6 +203,29 @@ std::string SpellbookScreen::lookupSpellName(uint32_t spellId, pipeline::AssetMa
return {};
}
uint32_t SpellbookScreen::getSpellMaxRange(uint32_t spellId, pipeline::AssetManager* assetManager) {
if (!dbcLoadAttempted) {
loadSpellDBC(assetManager);
}
auto it = spellData.find(spellId);
if (it != spellData.end()) return it->second.rangeIndex;
return 0;
}
void SpellbookScreen::getSpellPowerInfo(uint32_t spellId, pipeline::AssetManager* assetManager,
uint32_t& outCost, uint32_t& outPowerType) {
outCost = 0;
outPowerType = 0;
if (!dbcLoadAttempted) {
loadSpellDBC(assetManager);
}
auto it = spellData.find(spellId);
if (it != spellData.end()) {
outCost = it->second.manaCost;
outPowerType = it->second.powerType;
}
}
void SpellbookScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) {
if (iconDbLoaded) return;
iconDbLoaded = true;