diff --git a/README.md b/README.md index 8d238e117..546c07ee0 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ MMapper [![Code Coverage](https://codecov.io/gh/MUME/MMapper/branch/master/graph/badge.svg)](https://codecov.io/gh/MUME/MMapper) [![GitHub](https://img.shields.io/github/license/MUME/MMapper.svg)](https://github.com/MUME/MMapper/blob/master/COPYING.txt) [![snapcraft](https://snapcraft.io/mmapper/badge.svg)](https://snapcraft.io/mmapper) -[![Flathub Downloads](https://img.shields.io/flathub/downloads/org.mume.MMapper)](https://flathub.org/apps/org.mume.MMapper) [![MMapper Screenshot](/../master/appdata/screenshot1.png?raw=true "MMapper")
Download the latest version of MMapper](https://github.com/MUME/MMapper/releases) @@ -28,4 +27,5 @@ MMapper is a graphical mapping tool for the MUD (Multi-User Dungeon) game MUME ( To get started with MMapper, follow the [setup instructions](https://github.com/MUME/MMapper/wiki) in our wiki. It includes detailed steps for configuring MMapper with your MUME client and installing it on Linux, Windows, and macOS. Once installed, you'll be ready to start your mapping with ease. ## Contributing -We welcome contributions to MMapper! If you're interested in improving the tool, simply submit a pull request on GitHub. Your contributions will help improve the experience for all MUME players. You can also read the [BUILD.md](BUILD.md) to get started with building MMapper from source. +We welcome contributions to MMapper! If you're interested in improving the tool, simply submit a pull request on GitHub. Your contributions will help improve the experience for all MUME players. You can also check out the [build instructions](https://github.com/MUME/MMapper/wiki/Build) on the wiki to get started with building MMapper from source. +# trigger CI diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 585e85b69..f9d4ed37b 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -218,6 +218,8 @@ set(mmapper_SRCS group/mmapper2character.h group/mmapper2group.cpp group/mmapper2group.h + group/tokenmanager.cpp + group/tokenmanager.h logger/autologger.cpp logger/autologger.h mainwindow/DescriptionWidget.cpp @@ -648,6 +650,7 @@ add_executable(mmapper WIN32 MACOSX_BUNDLE ${mmapper_RCS} ${mmapper_QRC} ${mmapper_DATA} + display/GhostRegistry.h ) set(mm_libs mm_map mm_global) diff --git a/src/configuration/configuration.cpp b/src/configuration/configuration.cpp index 1541e3de1..2cc148e6d 100644 --- a/src/configuration/configuration.cpp +++ b/src/configuration/configuration.cpp @@ -240,6 +240,11 @@ ConstString KEY_GROUP_NPC_COLOR = "npc color"; ConstString KEY_GROUP_NPC_COLOR_OVERRIDE = "npc color override"; ConstString KEY_GROUP_NPC_SORT_BOTTOM = "npc sort bottom"; ConstString KEY_GROUP_NPC_HIDE = "npc hide"; +ConstString KEY_GROUP_SHOW_MAP_TOKENS = "show map tokens"; +ConstString KEY_GROUP_SHOW_NPC_GHOSTS = "show npc ghosts"; +ConstString KEY_GROUP_SHOW_TOKENS = "show tokens"; +ConstString KEY_GROUP_TOKEN_ICON_SIZE = "token icon size"; +ConstString KEY_GROUP_TOKEN_OVERRIDES = "token overrides"; ConstString KEY_AUTO_LOG = "Auto log"; ConstString KEY_AUTO_LOG_ASK_DELETE = "Auto log ask before deleting"; ConstString KEY_AUTO_LOG_CLEANUP_STRATEGY = "Auto log cleanup strategy"; @@ -689,6 +694,18 @@ void Configuration::GroupManagerSettings::read(const QSettings &conf) npcColorOverride = conf.value(KEY_GROUP_NPC_COLOR_OVERRIDE, false).toBool(); npcHide = conf.value(KEY_GROUP_NPC_HIDE, false).toBool(); npcSortBottom = conf.value(KEY_GROUP_NPC_SORT_BOTTOM, false).toBool(); + showTokens = conf.value(KEY_GROUP_SHOW_TOKENS, true).toBool(); + showMapTokens = conf.value(KEY_GROUP_SHOW_MAP_TOKENS, true).toBool(); + tokenIconSize = conf.value(KEY_GROUP_TOKEN_ICON_SIZE, 32).toInt(); + showNpcGhosts = conf.value(KEY_GROUP_SHOW_NPC_GHOSTS, true).toBool(); + + tokenOverrides.clear(); + QSettings &rw = const_cast(conf); + rw.beginGroup(KEY_GROUP_TOKEN_OVERRIDES); + const QStringList keys = rw.childKeys(); + for (const QString &k : keys) + tokenOverrides.insert(k, rw.value(k).toString()); + rw.endGroup(); } void Configuration::MumeClockSettings::read(const QSettings &conf) @@ -855,6 +872,16 @@ void Configuration::GroupManagerSettings::write(QSettings &conf) const conf.setValue(KEY_GROUP_NPC_COLOR_OVERRIDE, npcColorOverride); conf.setValue(KEY_GROUP_NPC_HIDE, npcHide); conf.setValue(KEY_GROUP_NPC_SORT_BOTTOM, npcSortBottom); + conf.setValue(KEY_GROUP_SHOW_TOKENS, showTokens); + conf.setValue(KEY_GROUP_SHOW_MAP_TOKENS, showMapTokens); + conf.setValue(KEY_GROUP_TOKEN_ICON_SIZE, tokenIconSize); + conf.setValue(KEY_GROUP_SHOW_NPC_GHOSTS, showNpcGhosts); + + conf.beginGroup(KEY_GROUP_TOKEN_OVERRIDES); + conf.remove(""); // wipe old map entries + for (auto it = tokenOverrides.cbegin(); it != tokenOverrides.cend(); ++it) + conf.setValue(it.key(), it.value()); + conf.endGroup(); } void Configuration::MumeClockSettings::write(QSettings &conf) const diff --git a/src/configuration/configuration.h b/src/configuration/configuration.h index 58d1d1e9f..f7f6fe0c6 100644 --- a/src/configuration/configuration.h +++ b/src/configuration/configuration.h @@ -291,6 +291,11 @@ class NODISCARD Configuration final bool npcColorOverride = false; bool npcSortBottom = false; bool npcHide = false; + bool showTokens = true; + bool showMapTokens = true; + int tokenIconSize = 32; + bool showNpcGhosts = true; + QMap tokenOverrides; private: SUBGROUP(); diff --git a/src/display/Characters.cpp b/src/display/Characters.cpp index cf0ea2fd0..2bc268878 100644 --- a/src/display/Characters.cpp +++ b/src/display/Characters.cpp @@ -6,12 +6,14 @@ #include "../configuration/configuration.h" #include "../group/CGroupChar.h" #include "../group/mmapper2group.h" +#include "../group/tokenmanager.h" #include "../map/room.h" #include "../map/roomid.h" #include "../mapdata/mapdata.h" #include "../mapdata/roomselection.h" #include "../opengl/OpenGL.h" #include "../opengl/OpenGLTypes.h" +#include "GhostRegistry.h" #include "MapCanvasData.h" #include "Textures.h" #include "mapcanvas.h" @@ -24,8 +26,11 @@ #include +#include #include +std::unordered_map g_ghosts; + static constexpr float CHAR_ARROW_LINE_WIDTH = 2.f; static constexpr float PATH_LINE_WIDTH = 4.f; static constexpr float PATH_POINT_SIZE = 8.f; @@ -48,7 +53,10 @@ bool CharacterBatch::isVisible(const Coordinate &c, float margin) const return m_mapScreen.isRoomVisible(c, margin); } -void CharacterBatch::drawCharacter(const Coordinate &c, const Color &color, bool fill) +void CharacterBatch::drawCharacter(const Coordinate &c, + const Color &color, + bool fill, + const QString &dispName) { const Configuration::CanvasSettings &settings = getConfig().canvas; @@ -99,7 +107,7 @@ void CharacterBatch::drawCharacter(const Coordinate &c, const Color &color, bool } const bool beacon = visible && !differentLayer && wantBeacons; - gl.drawBox(c, fill, beacon, isFar); + gl.drawBox(c, fill, beacon, isFar, dispName); } void CharacterBatch::drawPreSpammedPath(const Coordinate &c1, @@ -211,10 +219,8 @@ void CharacterBatch::CharFakeGL::drawQuadCommon(const glm::vec2 &in_a, } } -void CharacterBatch::CharFakeGL::drawBox(const Coordinate &coord, - bool fill, - bool beacon, - const bool isFar) +void CharacterBatch::CharFakeGL::drawBox( + const Coordinate &coord, bool fill, bool beacon, const bool isFar, const QString &dispName) { const bool dontFillRotatedQuads = true; const bool shrinkRotatedQuads = false; // REVISIT: make this a user option? @@ -274,6 +280,40 @@ void CharacterBatch::CharFakeGL::drawBox(const Coordinate &coord, addTransformed(c); addTransformed(d); + // ── NEW: also queue a token quad (drawn under coloured overlay) ── + if (!dispName.isEmpty() && getConfig().groupManager.showMapTokens) { + const Color &tokenColor = color; // inherits alpha from caller + const auto &mtx = m_stack.top().modelView; + + auto pushVert = [this, &tokenColor, &mtx](const glm::vec2 &roomPos, + const glm::vec2 &uv) { + const auto tmp = mtx * glm::vec4(roomPos, 0.f, 1.f); + m_charTokenQuads.emplace_back(tokenColor, uv, glm::vec3{tmp / tmp.w}); + }; + + // Scale the room quad around its center to 85% + static constexpr float kTokenScale = 0.85f; + const glm::vec2 center = 0.5f * (a + c); // midpoint of opposite corners + const auto scaleAround = [&](const glm::vec2 &p) { + return center + (p - center) * kTokenScale; + }; + + const glm::vec2 sa = scaleAround(a); + const glm::vec2 sb = scaleAround(b); + const glm::vec2 sc = scaleAround(c); + const glm::vec2 sd = scaleAround(d); + + // Keep full UVs so the whole texture shows on the smaller quad + pushVert(sa, {0.f, 0.f}); // lower-left + pushVert(sb, {1.f, 0.f}); // lower-right + pushVert(sc, {1.f, 1.f}); // upper-right + pushVert(sd, {0.f, 1.f}); // upper-left + + QString key = TokenManager::overrideFor(dispName); + key = key.isEmpty() ? canonicalTokenKey(dispName) : canonicalTokenKey(key); + m_charTokenKeys.emplace_back(key); + } + if (beacon) { drawQuadCommon(a, b, c, d, QuadOptsEnum::BEACON); } @@ -312,6 +352,37 @@ void CharacterBatch::CharFakeGL::reallyDrawCharacters(OpenGL &gl, const MapCanva gl.renderColoredQuads(m_charBeaconQuads, blended_noDepth.withCulling(CullingEnum::FRONT)); } + // ── draw map-tokens underneath the coloured overlay ── + for (size_t q = 0; q < m_charTokenKeys.size(); ++q) { + const size_t base = q * 4; + if (base + 3 >= m_charTokenQuads.size()) + break; + + const QString &key = m_charTokenKeys[q]; + MMTextureId id = tokenManager().textureIdFor(key); + + if (id == INVALID_MM_TEXTURE_ID) { + QPixmap px = tokenManager().getToken(key); + id = tokenManager().uploadNow(key, px); + } + + if (id == INVALID_MM_TEXTURE_ID) + continue; + + SharedMMTexture tex = tokenManager().textureById(id); + if (tex) + gl.setTextureLookup(id, tex); + + if (id == INVALID_MM_TEXTURE_ID) + continue; + + gl.renderColoredTexturedQuads({m_charTokenQuads[base + 0], + m_charTokenQuads[base + 1], + m_charTokenQuads[base + 2], + m_charTokenQuads[base + 3]}, + blended_noDepth.withTexture0(id)); + } + if (!m_charRoomQuads.empty()) { gl.renderColoredTexturedQuads(m_charRoomQuads, blended_noDepth.withTexture0(textures.char_room_sel->getId())); @@ -335,6 +406,9 @@ void CharacterBatch::CharFakeGL::reallyDrawCharacters(OpenGL &gl, const MapCanva gl.renderFont3d(textures.char_arrows, m_screenSpaceArrows); m_screenSpaceArrows.clear(); } + + m_charTokenQuads.clear(); + m_charTokenKeys.clear(); } void CharacterBatch::CharFakeGL::reallyDrawPaths(OpenGL &gl) @@ -383,9 +457,16 @@ void MapCanvas::paintCharacters() } CharacterBatch characterBatch{m_mapScreen, m_currentLayer, getTotalScaleFactor()}; + const CGroupChar *playerChar = nullptr; + for (const auto &pCharacter : m_groupManager.selectAll()) { + if (pCharacter->isYou()) { // ← ‘isYou()’ marks the local player + playerChar = pCharacter.get(); + break; + } + } // IIFE to abuse return to avoid duplicate else branches - [this, &characterBatch]() { + [this, &characterBatch, playerChar]() { if (const std::optional opt_pos = m_data.getCurrentRoomId()) { const auto &id = opt_pos.value(); if (const auto room = m_data.findRoomHandle(id)) { @@ -397,7 +478,11 @@ void MapCanvas::paintCharacters() // paint char current position const Color color{getConfig().groupManager.color}; - characterBatch.drawCharacter(pos, color); + characterBatch + .drawCharacter(pos, + color, + /*fill =*/true, + /*name =*/playerChar ? playerChar->getDisplayName() : QString()); // paint prespam const auto prespam = m_data.getPath(id, m_prespammedPath.getQueue()); @@ -417,40 +502,73 @@ void MapCanvas::paintCharacters() void MapCanvas::drawGroupCharacters(CharacterBatch &batch) { - if (m_data.isEmpty()) { + if (m_data.isEmpty()) return; - } - RoomIdSet drawnRoomIds; const Map &map = m_data.getCurrentMap(); - for (const auto &pCharacter : m_groupManager.selectAll()) { - // Omit player so that they know group members are below them - if (pCharacter->isYou()) - continue; - const CGroupChar &character = deref(pCharacter); + /* Find the player's room once */ + const CGroupChar *playerChar = nullptr; + ServerRoomId playerRoomSid = INVALID_SERVER_ROOMID; + RoomId playerRoomId = INVALID_ROOMID; + for (const auto &p : m_groupManager.selectAll()) { + if (p->isYou()) { + playerChar = p.get(); + playerRoomSid = p->getServerId(); + if (const auto r = map.findRoomHandle(p->getServerId())) + playerRoomId = r.getId(); + break; + } + } - const auto &r = [&character, &map]() -> RoomHandle { - const ServerRoomId srvId = character.getServerId(); - if (srvId != INVALID_SERVER_ROOMID) { - if (const auto &room = map.findRoomHandle(srvId)) { - return room; - } - } - return RoomHandle{}; - }(); + RoomIdSet drawnRoomIds; - // Do not draw the character if they're in an "Unknown" room - if (!r) { + for (const auto &pCharacter : m_groupManager.selectAll()) { + const CGroupChar &character = *pCharacter; + if (character.isYou()) continue; - } + const auto r = map.findRoomHandle(character.getServerId()); + if (!r) + continue; // skip Unknown rooms const RoomId id = r.getId(); - const auto &pos = r.getPosition(); - const auto color = Color{character.getColor()}; + const Coordinate &pos = r.getPosition(); + const Color col = Color{character.getColor()}; const bool fill = !drawnRoomIds.contains(id); - batch.drawCharacter(pos, color, fill); + const bool showToken = (id != playerRoomId); + + const QString tokenKey = showToken ? character.getDisplayName() + : QString(); // empty → no token + + batch.drawCharacter(pos, col, fill, tokenKey); drawnRoomIds.insert(id); } + /* ---------- draw (and purge) ghost tokens ------------------------------ */ + if (!getConfig().groupManager.showNpcGhosts) { + g_ghosts.clear(); // purge any stale registry entries + } else + for (auto it = g_ghosts.begin(); it != g_ghosts.end(); /* ++ in body */) { + ServerRoomId ghostSid = it->first; // map key is the room id + const QString tokenKey = it->second.tokenKey; + + if (ghostSid == playerRoomSid) { // player in same room → purge + it = g_ghosts.erase(it); + continue; + } + + /* use ghostSid here ▾ instead of ghostInfo.roomSid */ + if (const auto h = map.findRoomHandle(ghostSid)) { + const Coordinate &pos = h.getPosition(); + + QColor tint(Qt::white); + tint.setAlphaF(0.50f); + Color col(tint); + + const bool fill = !drawnRoomIds.contains(h.getId()); + batch.drawCharacter(pos, col, fill, tokenKey /*, 0.9f */); + drawnRoomIds.insert(h.getId()); + } + ++it; + } } diff --git a/src/display/Characters.h b/src/display/Characters.h index da59bb5ab..fde011808 100644 --- a/src/display/Characters.h +++ b/src/display/Characters.h @@ -102,6 +102,8 @@ class NODISCARD CharacterBatch final MatrixStack m_stack; std::vector m_charTris; std::vector m_charBeaconQuads; + std::vector m_charTokenQuads; + std::vector m_charTokenKeys; std::vector m_charLines; std::vector m_pathPoints; std::vector m_pathLineVerts; @@ -149,7 +151,8 @@ class NODISCARD CharacterBatch final m = glm::translate(m, v); } void drawArrow(bool fill, bool beacon); - void drawBox(const Coordinate &coord, bool fill, bool beacon, bool isFar); + void drawBox( + const Coordinate &coord, bool fill, bool beacon, bool isFar, const QString &dispName); void addScreenSpaceArrow(const glm::vec3 &pos, float degrees, const Color &color, bool fill); // with blending, without depth; always size 4 @@ -215,7 +218,10 @@ class NODISCARD CharacterBatch final NODISCARD bool isVisible(const Coordinate &c, float margin) const; public: - void drawCharacter(const Coordinate &coordinate, const Color &color, bool fill = true); + void drawCharacter(const Coordinate &coordinate, + const Color &color, + bool fill = true, + const QString &dispName = QString()); void drawPreSpammedPath(const Coordinate &coordinate, const std::vector &path, diff --git a/src/display/GhostRegistry.h b/src/display/GhostRegistry.h new file mode 100644 index 000000000..d90acb685 --- /dev/null +++ b/src/display/GhostRegistry.h @@ -0,0 +1,13 @@ +#pragma once +#include "../map/roomid.h" // ServerRoomId + +#include + +#include + +struct GhostInfo +{ + QString tokenKey; // icon to draw (display name) +}; + +extern std::unordered_map g_ghosts; diff --git a/src/group/CGroupChar.cpp b/src/group/CGroupChar.cpp index 832adcc59..c22e15451 100644 --- a/src/group/CGroupChar.cpp +++ b/src/group/CGroupChar.cpp @@ -326,3 +326,13 @@ bool CGroupChar::setScore(const QString &textHP, const QString &textMana, const #undef X_SCORE return updated; } + +QString CGroupChar::getDisplayName() const +{ + if (getLabel().isEmpty() + || getName().getStdStringViewUtf8() == getLabel().getStdStringViewUtf8()) { + return getName().toQString(); + } else { + return QString("%1 (%2)").arg(getName().toQString(), getLabel().toQString()); + } +} diff --git a/src/group/CGroupChar.h b/src/group/CGroupChar.h index c93b72ad2..59077c11a 100644 --- a/src/group/CGroupChar.h +++ b/src/group/CGroupChar.h @@ -61,6 +61,9 @@ class NODISCARD CGroupChar final : public std::enable_shared_from_this #include #include #include #include #include +#include +#include +#include #include #include +#include #include #include #include @@ -30,6 +37,10 @@ #include #include +extern const QString kForceFallback; + +static_assert(GROUP_COLUMN_COUNT == static_cast(ColumnTypeEnum::ROOM_NAME) + 1, "# of columns"); + static constexpr const char *GROUP_MIME_TYPE = "application/vnd.mm_groupchar.row"; namespace { // anonymous @@ -217,6 +228,35 @@ GroupModel::GroupModel(QObject *const parent) : QAbstractTableModel(parent) {} +namespace { // anonymous – helpers local to this file +static void insertNewCharactersInto(GroupVector &dest, + bool npcSortBottom, + const GroupVector &newPlayers, + const GroupVector &newNpcs, + const GroupVector &newAll) +{ + auto eraseGhosts = [](const GroupVector &vec) { + if (!getConfig().groupManager.showNpcGhosts) + return; + for (const auto &p : vec) + g_ghosts.erase(deref(p).getServerId()); + }; + if (npcSortBottom) { + // players go before first NPC already present + auto firstNpc = std::find_if(dest.begin(), dest.end(), [](const SharedGroupChar &c) { + return c && c->isNpc(); + }); + dest.insert(firstNpc, newPlayers.begin(), newPlayers.end()); + eraseGhosts(newPlayers); + dest.insert(dest.end(), newNpcs.begin(), newNpcs.end()); + eraseGhosts(newNpcs); + } else { + dest.insert(dest.end(), newAll.begin(), newAll.end()); + eraseGhosts(newAll); + } +} +} // namespace + void GroupModel::setCharacters(const GroupVector &newGameChars) { DECL_TIMER(t, __FUNCTION__); @@ -253,6 +293,10 @@ void GroupModel::setCharacters(const GroupVector &newGameChars) // Identify truly new characters and categorize them as NPC or player for (const auto &pGameChar : newGameChars) { const auto &gameChar = deref(pGameChar); + + if (getConfig().groupManager.showNpcGhosts) + g_ghosts.erase(gameChar.getServerId()); + if (existingIds.find(gameChar.getId()) == existingIds.end()) { allTrulyNewCharsInOriginalOrder.push_back(pGameChar); if (gameChar.isNpc()) { @@ -263,37 +307,11 @@ void GroupModel::setCharacters(const GroupVector &newGameChars) } } - // Insert the newly identified characters into the resulting list based on configuration. - if (getConfig().groupManager.npcSortBottom) { - // Find the insertion point for new players: before the first NPC in the preserved list. - auto itPlayerInsertPos = resultingCharacterList.begin(); - while (itPlayerInsertPos != resultingCharacterList.end()) { - if (*itPlayerInsertPos && (*itPlayerInsertPos)->isNpc()) { - break; - } - ++itPlayerInsertPos; - } - - // Insert truly new players at the determined position. - if (!trulyNewPlayers.empty()) { - resultingCharacterList.insert(itPlayerInsertPos, - trulyNewPlayers.begin(), - trulyNewPlayers.end()); - } - // Insert truly new NPCs at the end of the list. - if (!trulyNewNpcs.empty()) { - resultingCharacterList.insert(resultingCharacterList.end(), - trulyNewNpcs.begin(), - trulyNewNpcs.end()); - } - } else { - // If no special NPC sorting, just append all truly new characters in their original order. - if (!allTrulyNewCharsInOriginalOrder.empty()) { - resultingCharacterList.insert(resultingCharacterList.end(), - allTrulyNewCharsInOriginalOrder.begin(), - allTrulyNewCharsInOriginalOrder.end()); - } - } + insertNewCharactersInto(resultingCharacterList, + getConfig().groupManager.npcSortBottom, + trulyNewPlayers, + trulyNewNpcs, + allTrulyNewCharsInOriginalOrder); beginResetModel(); m_characters = std::move(resultingCharacterList); @@ -342,8 +360,14 @@ void GroupModel::insertCharacter(const SharedGroupChar &newCharacter) void GroupModel::removeCharacterById(const GroupId charId) { const int index = findIndexById(charId); - if (index == -1) { + if (index == -1) return; + + SharedGroupChar &c = m_characters[static_cast(index)]; + + /*** NEW: store a ghost entry if this row is a mount ***/ + if (getConfig().groupManager.showNpcGhosts && c->isNpc()) { + g_ghosts[c->getServerId()] = {c->getDisplayName()}; } beginRemoveRows(QModelIndex(), index, index); @@ -424,100 +448,128 @@ NODISCARD static QStringView getPrettyName(const CharacterAffectEnum affect) #undef X_CASE } -QVariant GroupModel::dataForCharacter(const SharedGroupChar &pCharacter, - const ColumnTypeEnum column, - const int role) const +/*───────────────────── complexity helpers ─────────────────────*/ +namespace { + +static QString formatStatHelper(int num, int den, ColumnTypeEnum col, bool isNpc) { - const CGroupChar &character = deref(pCharacter); + if (col == ColumnTypeEnum::HP_PERCENT || col == ColumnTypeEnum::MANA_PERCENT + || col == ColumnTypeEnum::MOVES_PERCENT) { + if (den == 0) + return {}; + int pct = static_cast(100.0 * double(num) / double(den)); + return QString("%1%").arg(pct); + } - const auto formatStat = - [](int numerator, int denomenator, ColumnTypeEnum statColumn) -> QString { - if (denomenator == 0 - && (statColumn == ColumnTypeEnum::HP_PERCENT - || statColumn == ColumnTypeEnum::MANA_PERCENT - || statColumn == ColumnTypeEnum::MOVES_PERCENT)) { - return QLatin1String(""); - } - // The NPC check for ratio is handled below in the switch statement - if ((numerator == 0 && denomenator == 0) - && (statColumn == ColumnTypeEnum::HP || statColumn == ColumnTypeEnum::MANA - || statColumn == ColumnTypeEnum::MOVES)) { - return QLatin1String(""); - } + if (col == ColumnTypeEnum::HP || col == ColumnTypeEnum::MANA || col == ColumnTypeEnum::MOVES) { + // hide “0/0” for NPCs -- same behaviour as before + if (isNpc && num == 0 && den == 0) + return {}; + return QString("%1/%2").arg(num).arg(den); + } + return {}; +} - switch (statColumn) { - case ColumnTypeEnum::HP_PERCENT: - case ColumnTypeEnum::MANA_PERCENT: - case ColumnTypeEnum::MOVES_PERCENT: { - int percentage = static_cast(100.0 * static_cast(numerator) - / static_cast(denomenator)); - return QString("%1%").arg(percentage); - } - case ColumnTypeEnum::HP: - case ColumnTypeEnum::MANA: - case ColumnTypeEnum::MOVES: - return QString("%1/%2").arg(numerator).arg(denomenator); - default: - case ColumnTypeEnum::NAME: - case ColumnTypeEnum::STATE: - case ColumnTypeEnum::ROOM_NAME: - return QLatin1String(""); +/// Big switch for DisplayRole, extracted out of dataForCharacter +static QVariant makeDisplayRole(const CGroupChar &ch, ColumnTypeEnum c, TokenManager *tm) +{ + switch (c) { + case ColumnTypeEnum::CHARACTER_TOKEN: + return tm ? QIcon(tm->getToken(ch.getDisplayName())) : QVariant(); + case ColumnTypeEnum::NAME: + if (ch.getLabel().isEmpty() + || ch.getName().getStdStringViewUtf8() == ch.getLabel().getStdStringViewUtf8()) { + return ch.getName().toQString(); + } else { + return QString("%1 (%2)").arg(ch.getName().toQString(), ch.getLabel().toQString()); } + case ColumnTypeEnum::HP_PERCENT: + return formatStatHelper(ch.getHits(), ch.getMaxHits(), c, false); + case ColumnTypeEnum::MANA_PERCENT: + return formatStatHelper(ch.getMana(), ch.getMaxMana(), c, false); + case ColumnTypeEnum::MOVES_PERCENT: + return formatStatHelper(ch.getMoves(), ch.getMaxMoves(), c, false); + case ColumnTypeEnum::HP: + return formatStatHelper(ch.getHits(), + ch.getMaxHits(), + c, + ch.getType() == CharacterTypeEnum::NPC); + case ColumnTypeEnum::MANA: + return formatStatHelper(ch.getMana(), + ch.getMaxMana(), + c, + ch.getType() == CharacterTypeEnum::NPC); + case ColumnTypeEnum::MOVES: + return formatStatHelper(ch.getMoves(), + ch.getMaxMoves(), + c, + ch.getType() == CharacterTypeEnum::NPC); + case ColumnTypeEnum::STATE: + return QVariant::fromValue(GroupStateData(ch.getColor(), ch.getPosition(), ch.getAffects())); + case ColumnTypeEnum::ROOM_NAME: + return ch.getRoomName().isEmpty() ? QStringLiteral("Somewhere") + : ch.getRoomName().toQString(); + default: + return QVariant(); + } +} + +/// Big switch for ToolTipRole, extracted out as well +static QVariant makeTooltipRole(const CGroupChar &ch, ColumnTypeEnum c, bool useStatFmt) +{ + auto statTip = [&](int n, int d) -> QVariant { + return useStatFmt ? formatStatHelper(n, d, c, false) : QVariant(); }; - // Map column to data + switch (c) { + case ColumnTypeEnum::CHARACTER_TOKEN: + return QVariant(); + case ColumnTypeEnum::HP_PERCENT: + return statTip(ch.getHits(), ch.getMaxHits()); + case ColumnTypeEnum::MANA_PERCENT: + return statTip(ch.getMana(), ch.getMaxMana()); + case ColumnTypeEnum::MOVES_PERCENT: + return statTip(ch.getMoves(), ch.getMaxMoves()); + case ColumnTypeEnum::STATE: { + QString pretty = getPrettyName(ch.getPosition()).toString(); + for (const CharacterAffectEnum a : ALL_CHARACTER_AFFECTS) + if (ch.getAffects().contains(a)) + pretty.append(", ").append(getPrettyName(a)); + return pretty; + } + case ColumnTypeEnum::ROOM_NAME: + if (ch.getServerId() != INVALID_SERVER_ROOMID) + return QString::number(ch.getServerId().asUint32()); + return QVariant(); + default: + return QVariant(); + } +} + +} // anonymous namespace +/*──────────────────────────────────────────────────────────────────*/ + +QVariant GroupModel::dataForCharacter(const SharedGroupChar &pCharacter, + const ColumnTypeEnum column, + const int role) const +{ + const CGroupChar &character = deref(pCharacter); + switch (role) { + /* display / icons ---------------------------------------------------- */ + case Qt::DecorationRole: case Qt::DisplayRole: - switch (column) { - case ColumnTypeEnum::NAME: - if (character.getLabel().isEmpty() - || character.getName().getStdStringViewUtf8() - == character.getLabel().getStdStringViewUtf8()) { - return character.getName().toQString(); - } else { - return QString("%1 (%2)").arg(character.getName().toQString(), - character.getLabel().toQString()); - } - case ColumnTypeEnum::HP_PERCENT: - return formatStat(character.getHits(), character.getMaxHits(), column); - case ColumnTypeEnum::MANA_PERCENT: - return formatStat(character.getMana(), character.getMaxMana(), column); - case ColumnTypeEnum::MOVES_PERCENT: - return formatStat(character.getMoves(), character.getMaxMoves(), column); - case ColumnTypeEnum::HP: - if (character.getType() == CharacterTypeEnum::NPC) { - return QLatin1String(""); - } else { - return formatStat(character.getHits(), character.getMaxHits(), column); - } - case ColumnTypeEnum::MANA: - if (character.getType() == CharacterTypeEnum::NPC) { - return QLatin1String(""); - } else { - return formatStat(character.getMana(), character.getMaxMana(), column); - } - case ColumnTypeEnum::MOVES: - if (character.getType() == CharacterTypeEnum::NPC) { - return QLatin1String(""); - } else { - return formatStat(character.getMoves(), character.getMaxMoves(), column); - } - case ColumnTypeEnum::STATE: - return QVariant::fromValue(GroupStateData(character.getColor(), - character.getPosition(), - character.getAffects())); - case ColumnTypeEnum::ROOM_NAME: - if (character.getRoomName().isEmpty()) { - return QStringLiteral("Somewhere"); - } else { - return character.getRoomName().toQString(); - } - default: - qWarning() << "Unsupported column" << static_cast(column); - break; - } - break; + return makeDisplayRole(character, column, m_tokenManager); + /* tooltips ----------------------------------------------------------- */ + case Qt::ToolTipRole: + return makeTooltipRole(character, + column, + (column == ColumnTypeEnum::HP_PERCENT + || column == ColumnTypeEnum::MANA_PERCENT + || column == ColumnTypeEnum::MOVES_PERCENT)); + + /* colours & alignment ------------------------------------------------ */ case Qt::BackgroundRole: return character.getColor(); @@ -526,51 +578,11 @@ QVariant GroupModel::dataForCharacter(const SharedGroupChar &pCharacter, case Qt::TextAlignmentRole: if (column != ColumnTypeEnum::NAME && column != ColumnTypeEnum::ROOM_NAME) { - // NOTE: There's no QVariant(AlignmentFlag) constructor. + // QVariant(AlignmentFlag) ctor doesn’t exist; return static_cast(Qt::AlignCenter); } break; - case Qt::ToolTipRole: { - const auto getRatioTooltip = [&](int numerator, int denomenator) -> QVariant { - if (character.getType() == CharacterTypeEnum::NPC) { - return QVariant(); - } else { - return formatStat(numerator, denomenator, column); - } - }; - - switch (column) { - case ColumnTypeEnum::HP_PERCENT: - return getRatioTooltip(character.getHits(), character.getMaxHits()); - case ColumnTypeEnum::MANA_PERCENT: - return getRatioTooltip(character.getMana(), character.getMaxMana()); - case ColumnTypeEnum::MOVES_PERCENT: - return getRatioTooltip(character.getMoves(), character.getMaxMoves()); - case ColumnTypeEnum::STATE: { - QString prettyName; - prettyName += getPrettyName(character.getPosition()); - for (const CharacterAffectEnum affect : ALL_CHARACTER_AFFECTS) { - if (character.getAffects().contains(affect)) { - prettyName.append(QStringLiteral(", ")).append(getPrettyName(affect)); - } - } - return prettyName; - } - case ColumnTypeEnum::NAME: - case ColumnTypeEnum::HP: - case ColumnTypeEnum::MANA: - case ColumnTypeEnum::MOVES: - break; - case ColumnTypeEnum::ROOM_NAME: - if (character.getServerId() != INVALID_SERVER_ROOMID) { - return QString("%1").arg(character.getServerId().asUint32()); - } - break; - } - break; - } - default: break; } @@ -584,12 +596,14 @@ QVariant GroupModel::data(const QModelIndex &index, int role) const return QVariant(); } - if (index.row() >= 0 && index.row() < static_cast(m_characters.size())) { - const SharedGroupChar &character = m_characters.at(static_cast(index.row())); - return dataForCharacter(character, static_cast(index.column()), role); + if (index.row() < 0 || index.row() >= static_cast(m_characters.size())) { + return QVariant(); } - return QVariant(); + const SharedGroupChar &character = m_characters.at(static_cast(index.row())); + const ColumnTypeEnum column = static_cast(index.column()); + + return dataForCharacter(character, column, role); } QVariant GroupModel::headerData(int section, Qt::Orientation orientation, int role) const @@ -711,6 +725,7 @@ GroupWidget::GroupWidget(Mmapper2Group *const group, MapData *const md, QWidget } else { m_model.setCharacters({}); } + m_model.setTokenManager(&tokenManager()); auto *layout = new QVBoxLayout(this); layout->setAlignment(Qt::AlignTop); @@ -738,9 +753,11 @@ GroupWidget::GroupWidget(Mmapper2Group *const group, MapData *const md, QWidget layout->addWidget(m_table); // Minimize row height - m_table->verticalHeader()->setDefaultSectionSize( - m_table->verticalHeader()->minimumSectionSize()); + const int icon = getConfig().groupManager.tokenIconSize; + const int row = std::max(icon, m_table->fontMetrics().height() + 4); + m_table->verticalHeader()->setDefaultSectionSize(row); + m_table->setIconSize(QSize(icon, icon)); m_center = new QAction(QIcon(":/icons/roomfind.png"), tr("&Center"), this); connect(m_center, &QAction::triggered, this, [this]() { // Center map on the clicked character @@ -773,33 +790,65 @@ GroupWidget::GroupWidget(Mmapper2Group *const group, MapData *const md, QWidget } }); - connect(m_table, &QAbstractItemView::clicked, this, [this](const QModelIndex &proxyIndex) { - if (!proxyIndex.isValid()) { + // ── Set-icon action ─────────────────────────────────────────── + m_setIcon = new QAction(QIcon(":/icons/group-set-icon.png"), tr("Set &Icon…"), this); + + connect(m_setIcon, &QAction::triggered, this, [this]() { + if (!selectedCharacter) // safety return; + + // 1. Character name (key) + const QString charName = selectedCharacter->getDisplayName().trimmed(); + + // 2. Tokens folder (= /tokens ) + const QString tokensDir = QDir(getConfig().canvas.resourcesDirectory).filePath("tokens"); + + if (!QDir(tokensDir).exists()) { + QMessageBox::information(this, + tr("Tokens folder not found"), + tr("No 'tokens' folder was found at:\n%1\n\n" + "Create a folder named 'tokens' inside that directory, " + "put your images there, then restart MMapper.") + .arg(tokensDir)); + return; // abort setting an icon } - QModelIndex sourceIndex = m_proxyModel->mapToSource(proxyIndex); + const QString file = QFileDialog::getOpenFileName(this, + tr("Choose icon for %1").arg(charName), + tokensDir, + tr("Images (*.png *.jpg *.bmp *.svg)")); + + if (file.isEmpty()) + return; // user cancelled + + // 3. store only the basename (without path / extension) + const QString base = QFileInfo(file).completeBaseName(); + setConfig().groupManager.tokenOverrides[charName] = base; + + // 4. immediately refresh this widget + slot_updateLabels(); + emit sig_characterUpdated(selectedCharacter); + }); + + m_useDefaultIcon = new QAction(QIcon(":/icons/group-clear-icon.png"), + tr("&Use default icon"), + this); - if (!sourceIndex.isValid()) { + connect(m_useDefaultIcon, &QAction::triggered, this, [this]() { + if (!selectedCharacter) return; - } - selectedCharacter = m_model.getCharacter(sourceIndex.row()); - if (selectedCharacter) { - // Build Context menu - m_center->setText( - QString("&Center on %1").arg(selectedCharacter->getName().toQString())); - m_recolor->setText(QString("&Recolor %1").arg(selectedCharacter->getName().toQString())); - m_center->setDisabled(!selectedCharacter->isYou() - && selectedCharacter->getServerId() == INVALID_SERVER_ROOMID); - - QMenu contextMenu(tr("Context menu"), this); - contextMenu.addAction(m_center); - contextMenu.addAction(m_recolor); - contextMenu.exec(QCursor::pos()); - } + const QString charName = selectedCharacter->getDisplayName().trimmed(); + + // store the sentinel so TokenManager shows char-room-sel.png + setConfig().groupManager.tokenOverrides[charName] = kForceFallback; + + slot_updateLabels(); // live refresh + emit sig_characterUpdated(selectedCharacter); }); + connect(m_table, &QAbstractItemView::clicked, this, &GroupWidget::showContextMenu); + connect(m_group, &Mmapper2Group::sig_characterAdded, this, &GroupWidget::slot_onCharacterAdded); connect(m_group, &Mmapper2Group::sig_characterRemoved, @@ -841,6 +890,19 @@ void GroupWidget::updateColumnVisibility() const bool hide_mana = !one_character_had_mana(); m_table->setColumnHidden(static_cast(ColumnTypeEnum::MANA), hide_mana); m_table->setColumnHidden(static_cast(ColumnTypeEnum::MANA_PERCENT), hide_mana); + + const bool hide_tokens = !getConfig().groupManager.showTokens; + m_table->setColumnHidden(static_cast(ColumnTypeEnum::CHARACTER_TOKEN), hide_tokens); + + // Apply current icon-size preference every time settings change + { + const int icon = getConfig().groupManager.tokenIconSize; + m_table->setIconSize(QSize(icon, icon)); + + QFontMetrics fm = m_table->fontMetrics(); + int row = std::max(icon, fm.height() + 4); + m_table->verticalHeader()->setDefaultSectionSize(row); + } } void GroupWidget::slot_onCharacterAdded(SharedGroupChar character) @@ -861,6 +923,7 @@ void GroupWidget::slot_onCharacterUpdated(SharedGroupChar character) { assert(character); m_model.updateCharacter(character); + updateColumnVisibility(); } void GroupWidget::slot_onGroupReset(const GroupVector &newCharacterList) @@ -868,3 +931,45 @@ void GroupWidget::slot_onGroupReset(const GroupVector &newCharacterList) m_model.setCharacters(newCharacterList); updateColumnVisibility(); } + +void GroupWidget::slot_updateLabels() +{ + m_model.resetModel(); // This re-fetches characters and refreshes the table +} + +// ───────────────────────── context-menu helpers ───────────────────────── +void GroupWidget::showContextMenu(const QModelIndex &proxyIndex) +{ + if (!proxyIndex.isValid()) + return; + + QModelIndex src = m_proxyModel->mapToSource(proxyIndex); + if (!src.isValid()) + return; + + selectedCharacter = m_model.getCharacter(src.row()); + if (!selectedCharacter) + return; + + buildAndExecMenu(); +} + +void GroupWidget::buildAndExecMenu() +{ + const CGroupChar &c = deref(selectedCharacter); + + m_center->setText(QString("&Center on %1").arg(c.getName().toQString())); + m_center->setDisabled(!c.isYou() && c.getServerId() == INVALID_SERVER_ROOMID); + + m_recolor->setText(QString("&Recolor %1").arg(c.getName().toQString())); + m_setIcon->setText(QString("&Set icon for %1…").arg(c.getName().toQString())); + m_useDefaultIcon->setText(QString("&Use default icon for %1").arg(c.getName().toQString())); + + QMenu menu(tr("Context menu"), this); + menu.addAction(m_center); + menu.addAction(m_recolor); + menu.addAction(m_setIcon); + menu.addAction(m_useDefaultIcon); + menu.exec(QCursor::pos()); +} +// ───────────────────────────────────────────────────────────────────────── diff --git a/src/group/groupwidget.h b/src/group/groupwidget.h index ed76fc73f..ea6319df2 100644 --- a/src/group/groupwidget.h +++ b/src/group/groupwidget.h @@ -5,6 +5,7 @@ #include "CGroupChar.h" #include "mmapper2character.h" +#include "tokenmanager.h" #include #include @@ -73,6 +74,7 @@ class NODISCARD_QOBJECT GroupDelegate final : public QStyledItemDelegate }; #define XFOREACH_COLUMNTYPE(X) \ + X(CHARACTER_TOKEN, character_token, CharacterToken, "Icon") \ X(NAME, name, Name, "Name") \ X(HP_PERCENT, hp_percent, HpPercent, "HP") \ X(MANA_PERCENT, mana_percent, ManaPercent, "Mana") \ @@ -103,6 +105,7 @@ class NODISCARD_QOBJECT GroupModel final : public QAbstractTableModel private: GroupVector m_characters; bool m_mapLoaded = false; + TokenManager *m_tokenManager = nullptr; public: explicit GroupModel(QObject *parent = nullptr); @@ -117,6 +120,7 @@ class NODISCARD_QOBJECT GroupModel final : public QAbstractTableModel void insertCharacter(const SharedGroupChar &newCharacter); void removeCharacterById(GroupId charId); void updateCharacter(const SharedGroupChar &updatedCharacter); + void setTokenManager(TokenManager *manager) { m_tokenManager = manager; } void resetModel(); private: @@ -154,12 +158,17 @@ class NODISCARD_QOBJECT GroupWidget final : public QWidget MapData *m_map = nullptr; GroupProxyModel *m_proxyModel = nullptr; GroupModel m_model; + // TokenManager tokenManager; void updateColumnVisibility(); + void showContextMenu(const QModelIndex &proxyIndex); + void buildAndExecMenu(); private: QAction *m_center = nullptr; QAction *m_recolor = nullptr; + QAction *m_setIcon = nullptr; + QAction *m_useDefaultIcon = nullptr; SharedGroupChar selectedCharacter; public: @@ -172,6 +181,7 @@ class NODISCARD_QOBJECT GroupWidget final : public QWidget signals: void sig_kickCharacter(const QString &); void sig_center(glm::vec2); + void sig_characterUpdated(SharedGroupChar character); public slots: void slot_mapUnloaded() { m_model.setMapLoaded(false); } @@ -182,4 +192,5 @@ private slots: void slot_onCharacterRemoved(GroupId characterId); void slot_onCharacterUpdated(SharedGroupChar character); void slot_onGroupReset(const GroupVector &newCharacterList); + void slot_updateLabels(); }; diff --git a/src/group/tokenmanager.cpp b/src/group/tokenmanager.cpp new file mode 100644 index 000000000..31dbb905e --- /dev/null +++ b/src/group/tokenmanager.cpp @@ -0,0 +1,229 @@ +#include "tokenmanager.h" + +#include "../configuration/configuration.h" +#include "../display/Textures.h" +#include "../opengl/OpenGL.h" +#include "../opengl/OpenGLTypes.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +/// Return a cached pixmap (or load & cache it). Null QPixmap if load fails. +static QPixmap fetchPixmap(const QString &path) +{ + QPixmap px; + if (QPixmapCache::find(path, &px)) + return px; + if (px.load(path)) { + QPixmapCache::insert(path, px); + return px; + } + return {}; // null means load failed +} + +/// Case-insensitive lookup: “mount_pony” matches “Mount_Pony” +static QString matchAvailableKey(const QMap &files, const QString &resolvedKey) +{ + for (const QString &k : files.keys()) + if (k.compare(resolvedKey, Qt::CaseInsensitive) == 0) + return k; + return {}; +} + +} // namespace + +const QString kForceFallback(QStringLiteral("__force_fallback__")); + +static QString normalizeKey(QString key) +{ + static const QRegularExpression nonWordReg(QStringLiteral("[^a-z0-9_]+")); + + key = key.toLower(); + key.replace(nonWordReg, QStringLiteral("_")); + return key; +} + +QString TokenManager::overrideFor(const QString &displayName) +{ + const auto &over = getConfig().groupManager.tokenOverrides; + auto it = over.constFind(displayName.trimmed()); + return (it != over.constEnd()) ? it.value() : QString(); +} + +static SharedMMTexture makeTextureFromPixmap(const QPixmap &px) +{ + using QT = QOpenGLTexture; + + auto mmtex = MMTexture::alloc( + QT::Target2D, + [&px](QT &tex) { tex.setData(px.toImage().mirrored()); }, + /*forbidUpdates = */ true); + + auto *tex = mmtex->get(); + tex->setWrapMode(QT::ClampToEdge); + tex->setMinMagFilters(QT::Linear, QT::Linear); + + const MMTextureId internalId = allocateTextureId(); + mmtex->setId(internalId); + + return mmtex; +} + +TokenManager::TokenManager() +{ + scanDirectories(); +} + +void TokenManager::scanDirectories() +{ + m_availableFiles.clear(); + m_watcher.removePaths(m_watcher.files()); + m_watcher.removePaths(m_watcher.directories()); + + const QString tokensDir = getConfig().canvas.resourcesDirectory + "/tokens"; + + QDir dir(tokensDir); + if (!dir.exists()) { + qWarning() << "TokenManager: 'tokens' directory not found at:" << tokensDir; + return; + } + + m_watcher.addPath(tokensDir); + + QList supportedFormats = QImageReader::supportedImageFormats(); + QSet formats(supportedFormats.begin(), supportedFormats.end()); + + QDirIterator it(tokensDir, QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + QString path = it.next(); + QFileInfo info(path); + QString suffix = info.suffix().toLower(); + + if (formats.contains(suffix.toUtf8())) { + QString key = normalizeKey(info.baseName()); + if (!m_availableFiles.contains(key)) { + m_availableFiles.insert(key, path); + m_watcher.addPath(path); + } + } + } +} + +QPixmap TokenManager::getToken(const QString &key) +{ + // 0. ensure built-in fallback is ready + if (m_fallbackPixmap.isNull()) + m_fallbackPixmap.load(":/pixmaps/char-room-sel.png"); + + if (key == kForceFallback) + return m_fallbackPixmap; + + // 1. resolve overrides and normalise key + const QString lookup = overrideFor(key).isEmpty() ? key : overrideFor(key); + QString resolvedKey = normalizeKey(lookup); + if (resolvedKey.isEmpty()) { + qWarning() << "TokenManager: empty key — defaulting to 'blank_character'"; + resolvedKey = "blank_character"; + } + + // 2. fast path: cached path ➜ cached pixmap + if (m_tokenPathCache.contains(resolvedKey)) { + const QString &path = m_tokenPathCache[resolvedKey]; + if (QPixmap px = fetchPixmap(path); !px.isNull()) + return px; + qWarning() << "TokenManager: cached path invalid:" << path; + } + + // 3. search tokens directory + const QString matchedKey = matchAvailableKey(m_availableFiles, resolvedKey); + if (!matchedKey.isEmpty()) { + const QString &path = m_availableFiles.value(matchedKey); + m_tokenPathCache[resolvedKey] = path; + if (QPixmap px = fetchPixmap(path); !px.isNull()) + return px; + qWarning() << "TokenManager: failed to load image:" << path; + } else { + qWarning() << "TokenManager: no match for key:" << resolvedKey; + } + + // 4. user-defined fallback (AppData/tokens/blank_character.png) + const QString userFallback = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) + + "/tokens/blank_character.png"; + if (QFile::exists(userFallback)) { + m_tokenPathCache[resolvedKey] = userFallback; + return fetchPixmap(userFallback); + } + + // 5. built-in fallback resource + const QString resFallback = ":/pixmaps/char-room-sel.png"; + m_tokenPathCache[resolvedKey] = resFallback; + return m_fallbackPixmap; +} + +const QMap &TokenManager::availableFiles() const +{ + return m_availableFiles; +} + +TokenManager &tokenManager() +{ + static TokenManager instance; // created on first call (post-QGuiApp) + return instance; +} + +MMTextureId TokenManager::textureIdFor(const QString &key) +{ + if (m_textureCache.contains(key)) + return m_textureCache.value(key); + + /* NOT current – do NOT try to upload, just remember we need to. */ + if (!m_pendingUploads.contains(key)) + m_pendingUploads.append(key); + return INVALID_MM_TEXTURE_ID; +} + +QString canonicalTokenKey(const QString &name) +{ + return normalizeKey(name); // reuse the existing static helper +} + +MMTextureId TokenManager::uploadNow(const QString &key, const QPixmap &px) +{ + SharedMMTexture tex = makeTextureFromPixmap(px); + MMTextureId id = tex->getId(); + + if (id == INVALID_MM_TEXTURE_ID) + return id; + + m_ownedTextures.push_back(std::move(tex)); + m_textureCache.insert(key, id); + return id; +} + +// keep tex alive + cache the id +void TokenManager::rememberUpload(const QString &key, MMTextureId id, SharedMMTexture tex) +{ + if (id == INVALID_MM_TEXTURE_ID) + return; + + m_ownedTextures.push_back(std::move(tex)); + m_textureCache.insert(key, id); +} + +// retrieve pointer later +SharedMMTexture TokenManager::textureById(MMTextureId id) const +{ + for (const auto &ptr : m_ownedTextures) + if (ptr->getId() == id) + return ptr; + return {}; +} diff --git a/src/group/tokenmanager.h b/src/group/tokenmanager.h new file mode 100644 index 000000000..c8b704020 --- /dev/null +++ b/src/group/tokenmanager.h @@ -0,0 +1,52 @@ +#ifndef TOKENMANAGER_H +#define TOKENMANAGER_H + +#include "../opengl/OpenGLTypes.h" // MMTextureId forward-declared here + +#include + +#include +#include +#include +#include +#include +#include + +class MMTexture; // forward +using SharedMMTexture = std::shared_ptr; // … +QString canonicalTokenKey(const QString &name); + +class TokenManager +{ +public: + TokenManager(); + + QPixmap getToken(const QString &key); + static QString overrideFor(const QString &displayName); + const QMap &availableFiles() const; + + MMTextureId textureIdFor(const QString &key); // ← per-token cache + MMTextureId uploadNow(const QString &key, const QPixmap &px); + QList m_pendingUploads; + + void rememberUpload(const QString &key, MMTextureId id, SharedMMTexture tex); + + SharedMMTexture textureById(MMTextureId id) const; + +private: + void scanDirectories(); + + QMap m_availableFiles; + QFileSystemWatcher m_watcher; + mutable QMap m_tokenPathCache; + QPixmap m_fallbackPixmap; + + QHash m_textureCache; // key → GL id + QVector m_ownedTextures; // keep textures alive +}; + +// sentinel +extern const QString kForceFallback; +TokenManager &tokenManager(); + +#endif diff --git a/src/mainwindow/mainwindow.cpp b/src/mainwindow/mainwindow.cpp index 939d3660f..a467f879d 100644 --- a/src/mainwindow/mainwindow.cpp +++ b/src/mainwindow/mainwindow.cpp @@ -185,6 +185,10 @@ MainWindow::MainWindow() addDockWidget(Qt::TopDockWidgetArea, m_dockDialogGroup); m_dockDialogGroup->setWidget(m_groupWidget); connect(m_groupWidget, &GroupWidget::sig_center, m_mapWindow, &MapWindow::slot_centerOnWorldPos); + auto *canvas = getCanvas(); + connect(m_groupWidget, &GroupWidget::sig_characterUpdated, canvas, [canvas](SharedGroupChar) { + canvas->slot_requestUpdate(); + }); // View -> Side Panels -> Room Panel (Mobs) m_roomManager = new RoomManager(this); diff --git a/src/preferences/grouppage.cpp b/src/preferences/grouppage.cpp index f9f3a988a..042bab4a9 100644 --- a/src/preferences/grouppage.cpp +++ b/src/preferences/grouppage.cpp @@ -36,7 +36,30 @@ GroupPage::GroupPage(QWidget *const parent) setConfig().groupManager.npcHide = checked; emit sig_groupSettingsChanged(); }); - + ui->showTokensCheckbox->setChecked(getConfig().groupManager.showTokens); + connect(ui->showTokensCheckbox, &QCheckBox::stateChanged, this, [this](int checked) { + setConfig().groupManager.showTokens = checked; + emit sig_groupSettingsChanged(); + }); + ui->showMapTokensCheckbox->setChecked(getConfig().groupManager.showMapTokens); + connect(ui->showMapTokensCheckbox, &QCheckBox::stateChanged, this, [this](int checked) { + setConfig().groupManager.showMapTokens = checked; + emit sig_groupSettingsChanged(); // refresh map instantly + }); + ui->tokenSizeComboBox->setCurrentText(QString::number(getConfig().groupManager.tokenIconSize) + + " px"); + + connect(ui->tokenSizeComboBox, &QComboBox::currentTextChanged, this, [this](const QString &txt) { + // strip " px" and convert to int + int value = txt.section(' ', 0, 0).toInt(); + setConfig().groupManager.tokenIconSize = value; + emit sig_groupSettingsChanged(); // live update + }); + ui->chkShowNpcGhosts->setChecked(getConfig().groupManager.showNpcGhosts); + connect(ui->chkShowNpcGhosts, &QCheckBox::stateChanged, this, [this](int checked) { + setConfig().groupManager.showNpcGhosts = checked; + emit sig_groupSettingsChanged(); + }); slot_loadConfig(); } @@ -60,6 +83,10 @@ void GroupPage::slot_loadConfig() ui->npcSortBottomCheckbox->setChecked(settings.npcSortBottom); ui->npcHideCheckbox->setChecked(settings.npcHide); + ui->showTokensCheckbox->setChecked(settings.showTokens); + ui->showMapTokensCheckbox->setChecked(settings.showMapTokens); + ui->chkShowNpcGhosts->setChecked(settings.showNpcGhosts); + ui->tokenSizeComboBox->setCurrentText(QString::number(settings.tokenIconSize) + " px"); } void GroupPage::slot_chooseColor() diff --git a/src/preferences/grouppage.h b/src/preferences/grouppage.h index 866ff13be..3dbc4a13b 100644 --- a/src/preferences/grouppage.h +++ b/src/preferences/grouppage.h @@ -29,6 +29,7 @@ class NODISCARD_QOBJECT GroupPage final : public QWidget signals: void sig_groupSettingsChanged(); + void sig_showTokensChanged(bool); public slots: void slot_loadConfig(); diff --git a/src/preferences/grouppage.ui b/src/preferences/grouppage.ui index e2ac696f4..7f5b57004 100644 --- a/src/preferences/grouppage.ui +++ b/src/preferences/grouppage.ui @@ -7,9 +7,14 @@ 0 0 329 - 262 + 359 + + + .AppleSystemUIFont + + Form @@ -20,19 +25,6 @@ Appearance - - - - Override NPC color: - - - true - - - npcOverrideColorPushButton - - - @@ -46,8 +38,21 @@ - - + + + + true + + + + + + false + + + + + @@ -63,10 +68,29 @@ - - + + - Select + Override NPC color: + + + true + + + npcOverrideColorPushButton + + + + + + + true + + + + + + false @@ -77,6 +101,75 @@ + + + + + + + + + + + Dude, Where's my Horse? + + + + + + + Show Images on Map + + + + + + + Show GM Character Images + + + + + + + Select + + + + + + + 1 + + + + 16 px + + + + + 32 px + + + + + 48 px + + + + + 64 px + + + + + + + + GM Character Image Size: + + + @@ -86,10 +179,10 @@ Filtering and Order - - + + - Sort NPCs to bottom + Hide NPCs @@ -106,10 +199,10 @@ - - + + - Hide NPCs + Sort NPCs to bottom