diff --git a/mudlet_clock_example.lua b/mudlet_clock_example.lua new file mode 100644 index 000000000..8d9cee8b7 --- /dev/null +++ b/mudlet_clock_example.lua @@ -0,0 +1,294 @@ +--[[ + MMapper GMCP Clock Integration for Mudlet + + This script receives clock data from MMapper via GMCP and can be used + as a foundation for day/night cycles, moon tracking, and other time-based + features in your Mudlet GUI. + + Installation: + 1. Save this file or copy its contents + 2. In Mudlet, create a new Script + 3. Paste this code into the script + 4. Save and the script will auto-register + + Usage: + The clock data will automatically update when MMapper sends it. + You can access the data via: mmapper.clock +]] + +-- Initialize namespace +mmapper = mmapper or {} +mmapper.clock = mmapper.clock or {} + +-- Month name mappings (0-indexed) +mmapper.clock.WESTRON_MONTHS = { + [0] = "Afteryule", + [1] = "Solmath", + [2] = "Rethe", + [3] = "Astron", + [4] = "Thrimidge", + [5] = "Forelithe", + [6] = "Afterlithe", + [7] = "Wedmath", + [8] = "Halimath", + [9] = "Winterfilth", + [10] = "Blotmath", + [11] = "Foreyule" +} + +mmapper.clock.SINDARIN_MONTHS = { + [0] = "Narwain", + [1] = "Ninui", + [2] = "Gwaeron", + [3] = "Gwirith", + [4] = "Lothron", + [5] = "Norui", + [6] = "Cerveth", + [7] = "Urui", + [8] = "Ivanneth", + [9] = "Narbeleth", + [10] = "Hithui", + [11] = "Girithron" +} + +-- Weekday names (0-indexed, 0 = Sunday) +mmapper.clock.WESTRON_WEEKDAYS = { + [0] = "Sunday", + [1] = "Monday", + [2] = "Trewsday", + [3] = "Hevensday", + [4] = "Mersday", + [5] = "Highday", + [6] = "Sterday" +} + +mmapper.clock.SINDARIN_WEEKDAYS = { + [0] = "Oranor", + [1] = "Orithil", + [2] = "Orgaladhad", + [3] = "Ormenel", + [4] = "Orbelain", + [5] = "Oraearon", + [6] = "Orgilion" +} + +-- State variables +mmapper.clock.data = nil +mmapper.clock.lastUpdate = nil +mmapper.clock.previousTimeOfDay = nil + +-- Helper function to calculate weekday +function mmapper.clock.getWeekday(year, month, day) + -- MUME calendar: 360 days per year, 30 days per month + local totalDays = (year - 2850) * 360 + month * 30 + day + return totalDays % 7 +end + +-- Helper function to format time as string +function mmapper.clock.formatTime(data, useWestron) + if not data then return "Unknown time" end + + local monthNames = useWestron and mmapper.clock.WESTRON_MONTHS or mmapper.clock.SINDARIN_MONTHS + local weekdayNames = useWestron and mmapper.clock.WESTRON_WEEKDAYS or mmapper.clock.SINDARIN_WEEKDAYS + + local weekday = mmapper.clock.getWeekday(data.year, data.month, data.day) + local monthName = monthNames[data.month] or "Unknown" + local weekdayName = weekdayNames[weekday] or "Unknown" + + local hour = data.hour + local minute = data.minute + local ampm = "am" + + if hour == 0 then + hour = 12 + elseif hour == 12 then + ampm = "pm" + elseif hour > 12 then + hour = hour - 12 + ampm = "pm" + end + + return string.format("%d:%02d%s on %s, the %d%s of %s, year %d of the Third Age", + hour, minute, ampm, + weekdayName, + data.day + 1, -- Display as 1-30 instead of 0-29 + mmapper.clock.getDaySuffix(data.day + 1), + monthName, + data.year + ) +end + +-- Helper for day suffix (1st, 2nd, 3rd, etc.) +function mmapper.clock.getDaySuffix(day) + if day == 1 or day == 21 then return "st" + elseif day == 2 or day == 22 then return "nd" + elseif day == 3 or day == 23 then return "rd" + else return "th" end +end + +-- Main GMCP handler +function mmapper.clock.handleTimeInfo(event, ...) + -- Get the GMCP data + local data = gmcp.MUME and gmcp.MUME.Time and gmcp.MUME.Time.Info + + if not data then + cecho("\nError: MUME.Time.Info data not found in GMCP\n") + return + end + + -- Store the data + mmapper.clock.data = data + mmapper.clock.lastUpdate = os.time() + + -- Detect time of day transitions + if mmapper.clock.previousTimeOfDay and mmapper.clock.previousTimeOfDay ~= data.timeOfDay then + mmapper.clock.onTimeOfDayChange(data.timeOfDay) + end + mmapper.clock.previousTimeOfDay = data.timeOfDay + + -- Raise custom events that your scripts can listen for + raiseEvent("MMapperClockUpdate", data) + + -- Optional: Debug output (comment out if you don't want it) + -- mmapper.clock.debug(data) +end + +-- Called when time of day changes (dawn, day, dusk, night) +function mmapper.clock.onTimeOfDayChange(newTimeOfDay) + -- You can customize these messages or add actions + if newTimeOfDay == "dawn" then + cecho("\nThe sun rises in the east, casting long shadows across the land.\n") + -- Example: Update your GUI, change colors, etc. + + elseif newTimeOfDay == "day" then + cecho("\nThe sun climbs higher into the sky.\n") + + elseif newTimeOfDay == "dusk" then + cecho("\nThe sun sets in the west, painting the sky in shades of orange and red.\n") + + elseif newTimeOfDay == "night" then + cecho("\nDarkness falls as the sun disappears below the horizon.\n") + end + + -- Raise event for other scripts + raiseEvent("MMapperTimeOfDayChanged", newTimeOfDay) +end + +-- Debug output function +function mmapper.clock.debug(data) + local output = { + "\n===== MMapper Clock Update =====", + string.format("Time: %s", mmapper.clock.formatTime(data, true)), + string.format("Season: %s", data.season or "unknown"), + string.format("Time of Day: %s", data.timeOfDay or "unknown"), + string.format("Moon Phase: %s (%d/12 illumination)", + data.moonPhase or "unknown", data.moonLevel or 0), + string.format("Moon Visibility: %s", data.moonVisibility or "unknown"), + string.format("Dawn: %d:00, Dusk: %d:00", + data.dawnHour or 0, data.duskHour or 0), + string.format("Precision: %s", data.precision or "unknown"), + "===============================" + } + + for _, line in ipairs(output) do + cecho(line .. "\n") + end +end + +-- Command to display current time +function mmapper.clock.showTime() + if not mmapper.clock.data then + cecho("\nNo clock data available yet. Make sure MMapper is connected and GMCP is enabled.\n") + return + end + + local data = mmapper.clock.data + + cecho("\n╔══════════════════════════════════════════════╗\n") + cecho(" MUME Time & Weather ║\n") + cecho("╠══════════════════════════════════════════════╣\n") + cecho(string.format("Time: %-33s║\n", + mmapper.clock.formatTime(data, true))) + cecho(string.format("Season: %-33s║\n", + data.season or "unknown")) + cecho(string.format("Period: %-33s║\n", + data.timeOfDay or "unknown")) + cecho("╠══════════════════════════════════════════════╣\n") + cecho(string.format("Moon: %-33s║\n", + data.moonPhase or "unknown")) + cecho(string.format("Light: %d/12 %-26s║\n", + data.moonLevel or 0, "(" .. (data.moonVisibility or "unknown") .. ")")) + cecho("╠══════════════════════════════════════════════╣\n") + cecho(string.format("Dawn at %02d:00, Dusk at %02d:00 ║\n", + data.dawnHour or 0, data.duskHour or 0)) + cecho("╚══════════════════════════════════════════════╝\n") +end + +-- Enable GMCP module (run once on profile load) +function mmapper.clock.enableGMCP() + if not gmod then + cecho("\nError: gmod not available. Make sure GMCP is enabled in Mudlet.\n") + return false + end + + gmod.enableModule("Client", "MUME.Time 1") + cecho("\nMMapper GMCP Clock module enabled!\n") + cecho("Type 'mmtime' to see the current time.\n") + return true +end + +-- Register the event handler +if mmapper.clock.eventHandler then + killAnonymousEventHandler(mmapper.clock.eventHandler) +end +mmapper.clock.eventHandler = registerAnonymousEventHandler("gmcp.MUME.Time.Info", "mmapper.clock.handleTimeInfo") + +-- Create alias to show time +if mmapper.clock.timeAlias then + killAlias(mmapper.clock.timeAlias) +end +mmapper.clock.timeAlias = tempAlias("^mmtime$", [[mmapper.clock.showTime()]]) + +-- Enable GMCP support +tempTimer(1, function() mmapper.clock.enableGMCP() end) + +-- Notify user +cecho("\nMMapper Clock Integration loaded successfully!\n") +cecho("Waiting for clock data from MMapper...\n") +cecho("Type 'mmtime' to display the current time once data is received.\n") + +--[[ + INTEGRATION WITH YOUR IMP GUI: + + To integrate with your existing day/night script, you can: + + 1. Listen for the MMapperClockUpdate event: + registerAnonymousEventHandler("MMapperClockUpdate", "yourFunctionName") + + 2. Access clock data anytime via: + mmapper.clock.data.timeOfDay -- "dawn", "day", "dusk", or "night" + mmapper.clock.data.season -- "winter", "spring", "summer", "autumn" + mmapper.clock.data.moonPhase -- Moon phase name + mmapper.clock.data.moonLevel -- Moon illumination (0-12) + + 3. Listen for time of day changes: + registerAnonymousEventHandler("MMapperTimeOfDayChanged", "yourTransitionHandler") + + 4. Example integration: + + function updateDayNightCycle() + if not mmapper.clock.data then return end + + local tod = mmapper.clock.data.timeOfDay + + if tod == "night" then + -- Apply night theme + setYourGUITheme("dark") + else + -- Apply day theme + setYourGUITheme("light") + end + end + + registerAnonymousEventHandler("MMapperClockUpdate", "updateDayNightCycle") +]] diff --git a/src/clock/mumeclock.cpp b/src/clock/mumeclock.cpp index 42149d382..c06eb9903 100644 --- a/src/clock/mumeclock.cpp +++ b/src/clock/mumeclock.cpp @@ -221,6 +221,13 @@ void MumeClock::parseMumeTime(const QString &mumeTime, const int64_t secsSinceEp qWarning() << "Calculated week day does not match MUME"; } m_mumeStartEpoch = newStartEpoch; + + // Check if season changed and emit signal + const auto currentSeason = capturedMoment.toSeason(); + if (currentSeason != m_lastSeasonEmitted && currentSeason != MumeSeasonEnum::UNKNOWN) { + m_lastSeasonEmitted = currentSeason; + emit sig_seasonChanged(currentSeason); + } } void MumeClock::onUserGmcp(const GmcpMessage &msg) @@ -319,6 +326,13 @@ void MumeClock::parseWeather(const MumeTimeEnum time, int64_t secsSinceEpoch) if (time != MumeTimeEnum::UNKNOWN || m_precision >= MumeClockPrecisionEnum::HOUR) { m_precision = MumeClockPrecisionEnum::MINUTE; } + + // Check if season changed and emit signal + const auto currentSeason = moment.toSeason(); + if (currentSeason != m_lastSeasonEmitted && currentSeason != MumeSeasonEnum::UNKNOWN) { + m_lastSeasonEmitted = currentSeason; + emit sig_seasonChanged(currentSeason); + } } void MumeClock::parseClockTime(const QString &clockTime) @@ -357,6 +371,13 @@ void MumeClock::parseClockTime(const QString &clockTime, const int64_t secsSince log("Synchronized with clock in room (" + QString::number(newStartEpoch - m_mumeStartEpoch) + " seconds from previous)"); m_mumeStartEpoch = newStartEpoch; + + // Check if season changed and emit signal + const auto currentSeason = moment.toSeason(); + if (currentSeason != m_lastSeasonEmitted && currentSeason != MumeSeasonEnum::UNKNOWN) { + m_lastSeasonEmitted = currentSeason; + emit sig_seasonChanged(currentSeason); + } } void MumeClock::parseMSSP(const MsspTime &msspTime) diff --git a/src/clock/mumeclock.h b/src/clock/mumeclock.h index f9cc86bfb..360212cfe 100644 --- a/src/clock/mumeclock.h +++ b/src/clock/mumeclock.h @@ -159,9 +159,11 @@ class NODISCARD_QOBJECT MumeClock final : public QObject private: void onUserGmcp(const GmcpMessage &msg); + MumeSeasonEnum m_lastSeasonEmitted = MumeSeasonEnum::UNKNOWN; signals: void sig_log(const QString &, const QString &); + void sig_seasonChanged(MumeSeasonEnum newSeason); public slots: void parseMumeTime(const QString &mumeTime); diff --git a/src/configuration/configuration.cpp b/src/configuration/configuration.cpp index 1541e3de1..3000e80d3 100644 --- a/src/configuration/configuration.cpp +++ b/src/configuration/configuration.cpp @@ -7,6 +7,7 @@ #include "configuration.h" #include "../global/utils.h" +#include "../map/infomark.h" #include #include @@ -52,6 +53,25 @@ NODISCARD const char *getPlatformEditor() } } +NODISCARD TextureSetEnum intToTextureSet(int value) +{ + switch (value) { + case 0: + return TextureSetEnum::CLASSIC; + case 1: + return TextureSetEnum::MODERN; + case 2: + return TextureSetEnum::CUSTOM; + default: + return TextureSetEnum::MODERN; // Default to Modern + } +} + +NODISCARD int textureSetToInt(TextureSetEnum value) +{ + return static_cast(value); +} + } // namespace Configuration::Configuration() @@ -194,10 +214,12 @@ ConstString GRP_ACCOUNT = "Account"; ConstString GRP_AUTO_LOAD_WORLD = "Auto load world"; ConstString GRP_AUTO_LOG = "Auto log"; ConstString GRP_CANVAS = "Canvas"; +ConstString GRP_COMMS = "Communications"; ConstString GRP_CONNECTION = "Connection"; ConstString GRP_FINDROOMS_DIALOG = "FindRooms Dialog"; ConstString GRP_GENERAL = "General"; ConstString GRP_GROUP_MANAGER = "Group Manager"; +ConstString GRP_HOTKEYS = "Hotkeys"; ConstString GRP_INFOMARKS_DIALOG = "InfoMarks Dialog"; ConstString GRP_INTEGRATED_MUD_CLIENT = "Integrated Mud Client"; ConstString GRP_MUME_CLIENT_PROTOCOL = "Mume client protocol"; @@ -227,9 +249,12 @@ ConstString KEY_CONNECTION_NORMAL_COLOR = "Connection normal color"; ConstString KEY_CORRECT_POSITION_BONUS = "correct position bonus"; ConstString KEY_DISPLAY_XP_STATUS = "Display XP status bar widget"; ConstString KEY_DISPLAY_CLOCK = "Display clock"; +ConstString KEY_GMCP_BROADCAST_CLOCK = "GMCP broadcast clock"; +ConstString KEY_GMCP_BROADCAST_INTERVAL = "GMCP broadcast interval"; ConstString KEY_DRAW_DOOR_NAMES = "Draw door names"; ConstString KEY_DRAW_NOT_MAPPED_EXITS = "Draw not mapped exits"; ConstString KEY_DRAW_UPPER_LAYERS_TEXTURED = "Draw upper layers textured"; +ConstString KEY_LAYER_TRANSPARENCY = "Layer transparency"; ConstString KEY_EMOJI_ENCODE = "encode emoji"; ConstString KEY_EMOJI_DECODE = "decode emoji"; ConstString KEY_EMULATED_EXITS = "Emulated Exits"; @@ -256,6 +281,73 @@ ConstString KEY_3D_FOV = "canvas.advanced.fov"; ConstString KEY_3D_VERTICAL_ANGLE = "canvas.advanced.verticalAngle"; ConstString KEY_3D_HORIZONTAL_ANGLE = "canvas.advanced.horizontalAngle"; ConstString KEY_3D_LAYER_HEIGHT = "canvas.advanced.layerHeight"; +ConstString KEY_BACKGROUND_IMAGE_ENABLED = "canvas.advanced.backgroundImageEnabled"; +ConstString KEY_BACKGROUND_IMAGE_PATH = "canvas.advanced.backgroundImagePath"; +ConstString KEY_BACKGROUND_IMAGE_FIT_MODE = "canvas.advanced.backgroundFitMode"; +ConstString KEY_BACKGROUND_IMAGE_OPACITY = "canvas.advanced.backgroundOpacity"; +ConstString KEY_BACKGROUND_IMAGE_FOCUSED_SCALE = "canvas.advanced.backgroundFocusedScale"; +ConstString KEY_BACKGROUND_IMAGE_FOCUSED_OFFSET_X = "canvas.advanced.backgroundFocusedOffsetX"; +ConstString KEY_BACKGROUND_IMAGE_FOCUSED_OFFSET_Y = "canvas.advanced.backgroundFocusedOffsetY"; +ConstString KEY_VISIBLE_MARKER_GENERIC = "canvas.visibleMarkers.generic"; +ConstString KEY_VISIBLE_MARKER_HERB = "canvas.visibleMarkers.herb"; +ConstString KEY_VISIBLE_MARKER_RIVER = "canvas.visibleMarkers.river"; +ConstString KEY_VISIBLE_MARKER_PLACE = "canvas.visibleMarkers.place"; +ConstString KEY_VISIBLE_MARKER_MOB = "canvas.visibleMarkers.mob"; +ConstString KEY_VISIBLE_MARKER_COMMENT = "canvas.visibleMarkers.comment"; +ConstString KEY_VISIBLE_MARKER_ROAD = "canvas.visibleMarkers.road"; +ConstString KEY_VISIBLE_MARKER_OBJECT = "canvas.visibleMarkers.object"; +ConstString KEY_VISIBLE_MARKER_ACTION = "canvas.visibleMarkers.action"; +ConstString KEY_VISIBLE_MARKER_LOCALITY = "canvas.visibleMarkers.locality"; +ConstString KEY_VISIBLE_CONNECTIONS = "canvas.visibilityFilter.connections"; + +// Hotkey configuration keys +ConstString KEY_HOTKEY_FILE_OPEN = "hotkeys.fileOpen"; +ConstString KEY_HOTKEY_FILE_SAVE = "hotkeys.fileSave"; +ConstString KEY_HOTKEY_FILE_RELOAD = "hotkeys.fileReload"; +ConstString KEY_HOTKEY_FILE_QUIT = "hotkeys.fileQuit"; +ConstString KEY_HOTKEY_EDIT_UNDO = "hotkeys.editUndo"; +ConstString KEY_HOTKEY_EDIT_REDO = "hotkeys.editRedo"; +ConstString KEY_HOTKEY_EDIT_PREFERENCES = "hotkeys.editPreferences"; +ConstString KEY_HOTKEY_EDIT_PREFERENCES_ALT = "hotkeys.editPreferencesAlt"; +ConstString KEY_HOTKEY_EDIT_FIND_ROOMS = "hotkeys.editFindRooms"; +ConstString KEY_HOTKEY_EDIT_ROOM = "hotkeys.editRoom"; +ConstString KEY_HOTKEY_VIEW_ZOOM_IN = "hotkeys.viewZoomIn"; +ConstString KEY_HOTKEY_VIEW_ZOOM_OUT = "hotkeys.viewZoomOut"; +ConstString KEY_HOTKEY_VIEW_ZOOM_RESET = "hotkeys.viewZoomReset"; +ConstString KEY_HOTKEY_VIEW_LAYER_UP = "hotkeys.viewLayerUp"; +ConstString KEY_HOTKEY_VIEW_LAYER_DOWN = "hotkeys.viewLayerDown"; +ConstString KEY_HOTKEY_VIEW_LAYER_RESET = "hotkeys.viewLayerReset"; +ConstString KEY_HOTKEY_VIEW_RADIAL_TRANSPARENCY = "hotkeys.viewRadialTransparency"; +ConstString KEY_HOTKEY_VIEW_STATUS_BAR = "hotkeys.viewStatusBar"; +ConstString KEY_HOTKEY_VIEW_SCROLL_BARS = "hotkeys.viewScrollBars"; +ConstString KEY_HOTKEY_VIEW_MENU_BAR = "hotkeys.viewMenuBar"; +ConstString KEY_HOTKEY_VIEW_ALWAYS_ON_TOP = "hotkeys.viewAlwaysOnTop"; +ConstString KEY_HOTKEY_PANEL_LOG = "hotkeys.panelLog"; +ConstString KEY_HOTKEY_PANEL_CLIENT = "hotkeys.panelClient"; +ConstString KEY_HOTKEY_PANEL_GROUP = "hotkeys.panelGroup"; +ConstString KEY_HOTKEY_PANEL_ROOM = "hotkeys.panelRoom"; +ConstString KEY_HOTKEY_PANEL_ADVENTURE = "hotkeys.panelAdventure"; +ConstString KEY_HOTKEY_PANEL_COMMS = "hotkeys.panelComms"; +ConstString KEY_HOTKEY_PANEL_DESCRIPTION = "hotkeys.panelDescription"; +ConstString KEY_HOTKEY_MODE_MOVE_MAP = "hotkeys.modeMoveMap"; +ConstString KEY_HOTKEY_MODE_RAYPICK = "hotkeys.modeRaypick"; +ConstString KEY_HOTKEY_MODE_SELECT_ROOMS = "hotkeys.modeSelectRooms"; +ConstString KEY_HOTKEY_MODE_SELECT_MARKERS = "hotkeys.modeSelectMarkers"; +ConstString KEY_HOTKEY_MODE_SELECT_CONNECTION = "hotkeys.modeSelectConnection"; +ConstString KEY_HOTKEY_MODE_CREATE_MARKER = "hotkeys.modeCreateMarker"; +ConstString KEY_HOTKEY_MODE_CREATE_ROOM = "hotkeys.modeCreateRoom"; +ConstString KEY_HOTKEY_MODE_CREATE_CONNECTION = "hotkeys.modeCreateConnection"; +ConstString KEY_HOTKEY_MODE_CREATE_ONEWAY_CONNECTION = "hotkeys.modeCreateOnewayConnection"; +ConstString KEY_HOTKEY_ROOM_CREATE = "hotkeys.roomCreate"; +ConstString KEY_HOTKEY_ROOM_MOVE_UP = "hotkeys.roomMoveUp"; +ConstString KEY_HOTKEY_ROOM_MOVE_DOWN = "hotkeys.roomMoveDown"; +ConstString KEY_HOTKEY_ROOM_MERGE_UP = "hotkeys.roomMergeUp"; +ConstString KEY_HOTKEY_ROOM_MERGE_DOWN = "hotkeys.roomMergeDown"; +ConstString KEY_HOTKEY_ROOM_DELETE = "hotkeys.roomDelete"; +ConstString KEY_HOTKEY_ROOM_CONNECT_NEIGHBORS = "hotkeys.roomConnectNeighbors"; +ConstString KEY_HOTKEY_ROOM_MOVE_TO_SELECTED = "hotkeys.roomMoveToSelected"; +ConstString KEY_HOTKEY_ROOM_UPDATE_SELECTED = "hotkeys.roomUpdateSelected"; + ConstString KEY_LAST_MAP_LOAD_DIRECTORY = "Last map load directory"; ConstString KEY_LINES_OF_INPUT_HISTORY = "Lines of input history"; ConstString KEY_LINES_OF_PEEK_PREVIEW = "Lines of peek preview"; @@ -270,6 +362,8 @@ ConstString KEY_PROXY_CONNECTION_STATUS = "Proxy connection status"; ConstString KEY_PROXY_LISTENS_ON_ANY_INTERFACE = "Proxy listens on any interface"; ConstString KEY_RELATIVE_PATH_ACCEPTANCE = "relative path acceptance"; ConstString KEY_RESOURCES_DIRECTORY = "canvas.resourcesDir"; +ConstString KEY_TEXTURE_SET = "canvas.textureSet"; +ConstString KEY_ENABLE_SEASONAL_TEXTURES = "canvas.enableSeasonalTextures"; ConstString KEY_MUME_REMOTE_PORT = "Remote port number"; ConstString KEY_REMEMBER_LOGIN = "remember login"; ConstString KEY_ROOM_CREATION_PENALTY = "room creation penalty"; @@ -441,6 +535,8 @@ NODISCARD static uint16_t sanitizeUint16(const int input, const uint16_t default GROUP_CALLBACK(callback, GRP_GENERAL, general); \ GROUP_CALLBACK(callback, GRP_CONNECTION, connection); \ GROUP_CALLBACK(callback, GRP_CANVAS, canvas); \ + GROUP_CALLBACK(callback, GRP_HOTKEYS, hotkeys); \ + GROUP_CALLBACK(callback, GRP_COMMS, comms); \ GROUP_CALLBACK(callback, GRP_ACCOUNT, account); \ GROUP_CALLBACK(callback, GRP_AUTO_LOAD_WORLD, autoLoad); \ GROUP_CALLBACK(callback, GRP_AUTO_LOG, autoLog); \ @@ -565,7 +661,7 @@ void Configuration::ConnectionSettings::read(const QSettings &conf) } // closest well-known color is "Outer Space" -static constexpr const std::string_view DEFAULT_BGCOLOR = "#2E3436"; +static constexpr const std::string_view DEFAULT_BGCOLOR = "#161f21"; // closest well-known color is "Dusty Gray" static constexpr const std::string_view DEFAULT_DARK_COLOR = "#A19494"; // closest well-known color is "Cold Turkey" @@ -587,11 +683,14 @@ void Configuration::CanvasSettings::read(const QSettings &conf) .append(DEFAULT_MMAPPER_SUBDIR) .append(DEFAULT_RESOURCES_SUBDIR)) .toString(); + textureSet = intToTextureSet(conf.value(KEY_TEXTURE_SET, 1).toInt()); // Default: MODERN + enableSeasonalTextures = conf.value(KEY_ENABLE_SEASONAL_TEXTURES, true).toBool(); showMissingMapId.set(conf.value(KEY_SHOW_MISSING_MAP_ID, true).toBool()); showUnsavedChanges.set(conf.value(KEY_SHOW_UNSAVED_CHANGES, true).toBool()); showUnmappedExits.set(conf.value(KEY_DRAW_NOT_MAPPED_EXITS, true).toBool()); drawUpperLayersTextured = conf.value(KEY_DRAW_UPPER_LAYERS_TEXTURED, false).toBool(); drawDoorNames = conf.value(KEY_DRAW_DOOR_NAMES, true).toBool(); + layerTransparency = conf.value(KEY_LAYER_TRANSPARENCY, 1.0).toFloat(); backgroundColor = lookupColor(KEY_BACKGROUND_COLOR, DEFAULT_BGCOLOR); connectionNormalColor = lookupColor(KEY_CONNECTION_NORMAL_COLOR, Colors::white.toHex()); roomDarkColor = lookupColor(KEY_ROOM_DARK_COLOR, DEFAULT_DARK_COLOR); @@ -605,6 +704,30 @@ void Configuration::CanvasSettings::read(const QSettings &conf) advanced.verticalAngle.set(conf.value(KEY_3D_VERTICAL_ANGLE, 450).toInt()); advanced.horizontalAngle.set(conf.value(KEY_3D_HORIZONTAL_ANGLE, 0).toInt()); advanced.layerHeight.set(conf.value(KEY_3D_LAYER_HEIGHT, 15).toInt()); + + // Load background image settings + advanced.useBackgroundImage = conf.value(KEY_BACKGROUND_IMAGE_ENABLED, false).toBool(); + advanced.backgroundImagePath = conf.value(KEY_BACKGROUND_IMAGE_PATH, "").toString(); + advanced.backgroundFitMode = conf.value(KEY_BACKGROUND_IMAGE_FIT_MODE, 0).toInt(); + advanced.backgroundOpacity = conf.value(KEY_BACKGROUND_IMAGE_OPACITY, 1.0f).toFloat(); + advanced.backgroundFocusedScale = conf.value(KEY_BACKGROUND_IMAGE_FOCUSED_SCALE, 1.0f).toFloat(); + advanced.backgroundFocusedOffsetX = conf.value(KEY_BACKGROUND_IMAGE_FOCUSED_OFFSET_X, 0.0f) + .toFloat(); + advanced.backgroundFocusedOffsetY = conf.value(KEY_BACKGROUND_IMAGE_FOCUSED_OFFSET_Y, 0.0f) + .toFloat(); + + // Load visible markers settings + visibilityFilter.generic.set(conf.value(KEY_VISIBLE_MARKER_GENERIC, true).toBool()); + visibilityFilter.herb.set(conf.value(KEY_VISIBLE_MARKER_HERB, true).toBool()); + visibilityFilter.river.set(conf.value(KEY_VISIBLE_MARKER_RIVER, true).toBool()); + visibilityFilter.place.set(conf.value(KEY_VISIBLE_MARKER_PLACE, true).toBool()); + visibilityFilter.mob.set(conf.value(KEY_VISIBLE_MARKER_MOB, true).toBool()); + visibilityFilter.comment.set(conf.value(KEY_VISIBLE_MARKER_COMMENT, true).toBool()); + visibilityFilter.road.set(conf.value(KEY_VISIBLE_MARKER_ROAD, true).toBool()); + visibilityFilter.object.set(conf.value(KEY_VISIBLE_MARKER_OBJECT, true).toBool()); + visibilityFilter.action.set(conf.value(KEY_VISIBLE_MARKER_ACTION, true).toBool()); + visibilityFilter.locality.set(conf.value(KEY_VISIBLE_MARKER_LOCALITY, true).toBool()); + visibilityFilter.connections.set(conf.value(KEY_VISIBLE_CONNECTIONS, true).toBool()); } void Configuration::AccountSettings::read(const QSettings &conf) @@ -691,11 +814,15 @@ void Configuration::GroupManagerSettings::read(const QSettings &conf) npcSortBottom = conf.value(KEY_GROUP_NPC_SORT_BOTTOM, false).toBool(); } +Configuration::MumeClockSettings::MumeClockSettings() = default; + void Configuration::MumeClockSettings::read(const QSettings &conf) { // NOTE: old values might be stored as int32 startEpoch = conf.value(KEY_MUME_START_EPOCH, 1517443173).toLongLong(); display = conf.value(KEY_DISPLAY_CLOCK, true).toBool(); + gmcpBroadcast.set(conf.value(KEY_GMCP_BROADCAST_CLOCK, true).toBool()); + gmcpBroadcastInterval.set(conf.value(KEY_GMCP_BROADCAST_INTERVAL, 2500).toInt()); } void Configuration::AdventurePanelSettings::read(const QSettings &conf) @@ -770,11 +897,14 @@ NODISCARD static auto getQColorName(const XNamedColor &color) void Configuration::CanvasSettings::write(QSettings &conf) const { conf.setValue(KEY_RESOURCES_DIRECTORY, resourcesDirectory); + conf.setValue(KEY_TEXTURE_SET, textureSetToInt(textureSet)); + conf.setValue(KEY_ENABLE_SEASONAL_TEXTURES, enableSeasonalTextures); conf.setValue(KEY_SHOW_MISSING_MAP_ID, showMissingMapId.get()); conf.setValue(KEY_SHOW_UNSAVED_CHANGES, showUnsavedChanges.get()); conf.setValue(KEY_DRAW_NOT_MAPPED_EXITS, showUnmappedExits.get()); conf.setValue(KEY_DRAW_UPPER_LAYERS_TEXTURED, drawUpperLayersTextured); conf.setValue(KEY_DRAW_DOOR_NAMES, drawDoorNames); + conf.setValue(KEY_LAYER_TRANSPARENCY, layerTransparency); conf.setValue(KEY_BACKGROUND_COLOR, getQColorName(backgroundColor)); conf.setValue(KEY_ROOM_DARK_COLOR, getQColorName(roomDarkColor)); conf.setValue(KEY_ROOM_DARK_LIT_COLOR, getQColorName(roomDarkLitColor)); @@ -788,6 +918,239 @@ void Configuration::CanvasSettings::write(QSettings &conf) const conf.setValue(KEY_3D_VERTICAL_ANGLE, advanced.verticalAngle.get()); conf.setValue(KEY_3D_HORIZONTAL_ANGLE, advanced.horizontalAngle.get()); conf.setValue(KEY_3D_LAYER_HEIGHT, advanced.layerHeight.get()); + + // Save background image settings + conf.setValue(KEY_BACKGROUND_IMAGE_ENABLED, advanced.useBackgroundImage); + conf.setValue(KEY_BACKGROUND_IMAGE_PATH, advanced.backgroundImagePath); + conf.setValue(KEY_BACKGROUND_IMAGE_FIT_MODE, advanced.backgroundFitMode); + conf.setValue(KEY_BACKGROUND_IMAGE_OPACITY, advanced.backgroundOpacity); + conf.setValue(KEY_BACKGROUND_IMAGE_FOCUSED_SCALE, advanced.backgroundFocusedScale); + conf.setValue(KEY_BACKGROUND_IMAGE_FOCUSED_OFFSET_X, advanced.backgroundFocusedOffsetX); + conf.setValue(KEY_BACKGROUND_IMAGE_FOCUSED_OFFSET_Y, advanced.backgroundFocusedOffsetY); + + // Save visible markers settings + conf.setValue(KEY_VISIBLE_MARKER_GENERIC, visibilityFilter.generic.get()); + conf.setValue(KEY_VISIBLE_MARKER_HERB, visibilityFilter.herb.get()); + conf.setValue(KEY_VISIBLE_MARKER_RIVER, visibilityFilter.river.get()); + conf.setValue(KEY_VISIBLE_MARKER_PLACE, visibilityFilter.place.get()); + conf.setValue(KEY_VISIBLE_MARKER_MOB, visibilityFilter.mob.get()); + conf.setValue(KEY_VISIBLE_MARKER_COMMENT, visibilityFilter.comment.get()); + conf.setValue(KEY_VISIBLE_MARKER_ROAD, visibilityFilter.road.get()); + conf.setValue(KEY_VISIBLE_MARKER_OBJECT, visibilityFilter.object.get()); + conf.setValue(KEY_VISIBLE_MARKER_ACTION, visibilityFilter.action.get()); + conf.setValue(KEY_VISIBLE_MARKER_LOCALITY, visibilityFilter.locality.get()); + conf.setValue(KEY_VISIBLE_CONNECTIONS, visibilityFilter.connections.get()); +} + +void Configuration::Hotkeys::read(const QSettings &conf) +{ + // File operations + fileOpen.set(conf.value(KEY_HOTKEY_FILE_OPEN, "Ctrl+O").toString()); + fileSave.set(conf.value(KEY_HOTKEY_FILE_SAVE, "Ctrl+S").toString()); + fileReload.set(conf.value(KEY_HOTKEY_FILE_RELOAD, "Ctrl+R").toString()); + fileQuit.set(conf.value(KEY_HOTKEY_FILE_QUIT, "Ctrl+Q").toString()); + + // Edit operations + editUndo.set(conf.value(KEY_HOTKEY_EDIT_UNDO, "Ctrl+Z").toString()); + editRedo.set(conf.value(KEY_HOTKEY_EDIT_REDO, "Ctrl+Y").toString()); + editPreferences.set(conf.value(KEY_HOTKEY_EDIT_PREFERENCES, "Ctrl+P").toString()); + editPreferencesAlt.set(conf.value(KEY_HOTKEY_EDIT_PREFERENCES_ALT, "Esc").toString()); + editFindRooms.set(conf.value(KEY_HOTKEY_EDIT_FIND_ROOMS, "Ctrl+F").toString()); + editRoom.set(conf.value(KEY_HOTKEY_EDIT_ROOM, "Ctrl+E").toString()); + + // View operations + viewZoomIn.set(conf.value(KEY_HOTKEY_VIEW_ZOOM_IN, "").toString()); + viewZoomOut.set(conf.value(KEY_HOTKEY_VIEW_ZOOM_OUT, "").toString()); + viewZoomReset.set(conf.value(KEY_HOTKEY_VIEW_ZOOM_RESET, "Ctrl+0").toString()); + viewLayerUp.set(conf.value(KEY_HOTKEY_VIEW_LAYER_UP, "").toString()); + viewLayerDown.set(conf.value(KEY_HOTKEY_VIEW_LAYER_DOWN, "").toString()); + viewLayerReset.set(conf.value(KEY_HOTKEY_VIEW_LAYER_RESET, "").toString()); + + // View toggles + viewRadialTransparency.set(conf.value(KEY_HOTKEY_VIEW_RADIAL_TRANSPARENCY, "").toString()); + viewStatusBar.set(conf.value(KEY_HOTKEY_VIEW_STATUS_BAR, "").toString()); + viewScrollBars.set(conf.value(KEY_HOTKEY_VIEW_SCROLL_BARS, "").toString()); + viewMenuBar.set(conf.value(KEY_HOTKEY_VIEW_MENU_BAR, "").toString()); + viewAlwaysOnTop.set(conf.value(KEY_HOTKEY_VIEW_ALWAYS_ON_TOP, "").toString()); + + // Side panels + panelLog.set(conf.value(KEY_HOTKEY_PANEL_LOG, "Ctrl+L").toString()); + panelClient.set(conf.value(KEY_HOTKEY_PANEL_CLIENT, "").toString()); + panelGroup.set(conf.value(KEY_HOTKEY_PANEL_GROUP, "").toString()); + panelRoom.set(conf.value(KEY_HOTKEY_PANEL_ROOM, "").toString()); + panelAdventure.set(conf.value(KEY_HOTKEY_PANEL_ADVENTURE, "").toString()); + panelComms.set(conf.value(KEY_HOTKEY_PANEL_COMMS, "").toString()); + panelDescription.set(conf.value(KEY_HOTKEY_PANEL_DESCRIPTION, "").toString()); + + // Mouse modes + modeMoveMap.set(conf.value(KEY_HOTKEY_MODE_MOVE_MAP, "").toString()); + modeRaypick.set(conf.value(KEY_HOTKEY_MODE_RAYPICK, "").toString()); + modeSelectRooms.set(conf.value(KEY_HOTKEY_MODE_SELECT_ROOMS, "").toString()); + modeSelectMarkers.set(conf.value(KEY_HOTKEY_MODE_SELECT_MARKERS, "").toString()); + modeSelectConnection.set(conf.value(KEY_HOTKEY_MODE_SELECT_CONNECTION, "").toString()); + modeCreateMarker.set(conf.value(KEY_HOTKEY_MODE_CREATE_MARKER, "").toString()); + modeCreateRoom.set(conf.value(KEY_HOTKEY_MODE_CREATE_ROOM, "").toString()); + modeCreateConnection.set(conf.value(KEY_HOTKEY_MODE_CREATE_CONNECTION, "").toString()); + modeCreateOnewayConnection.set( + conf.value(KEY_HOTKEY_MODE_CREATE_ONEWAY_CONNECTION, "").toString()); + + // Room operations + roomCreate.set(conf.value(KEY_HOTKEY_ROOM_CREATE, "").toString()); + roomMoveUp.set(conf.value(KEY_HOTKEY_ROOM_MOVE_UP, "").toString()); + roomMoveDown.set(conf.value(KEY_HOTKEY_ROOM_MOVE_DOWN, "").toString()); + roomMergeUp.set(conf.value(KEY_HOTKEY_ROOM_MERGE_UP, "").toString()); + roomMergeDown.set(conf.value(KEY_HOTKEY_ROOM_MERGE_DOWN, "").toString()); + roomDelete.set(conf.value(KEY_HOTKEY_ROOM_DELETE, "Del").toString()); + roomConnectNeighbors.set(conf.value(KEY_HOTKEY_ROOM_CONNECT_NEIGHBORS, "").toString()); + roomMoveToSelected.set(conf.value(KEY_HOTKEY_ROOM_MOVE_TO_SELECTED, "").toString()); + roomUpdateSelected.set(conf.value(KEY_HOTKEY_ROOM_UPDATE_SELECTED, "").toString()); +} + +void Configuration::Hotkeys::write(QSettings &conf) const +{ + // File operations + conf.setValue(KEY_HOTKEY_FILE_OPEN, fileOpen.get()); + conf.setValue(KEY_HOTKEY_FILE_SAVE, fileSave.get()); + conf.setValue(KEY_HOTKEY_FILE_RELOAD, fileReload.get()); + conf.setValue(KEY_HOTKEY_FILE_QUIT, fileQuit.get()); + + // Edit operations + conf.setValue(KEY_HOTKEY_EDIT_UNDO, editUndo.get()); + conf.setValue(KEY_HOTKEY_EDIT_REDO, editRedo.get()); + conf.setValue(KEY_HOTKEY_EDIT_PREFERENCES, editPreferences.get()); + conf.setValue(KEY_HOTKEY_EDIT_PREFERENCES_ALT, editPreferencesAlt.get()); + conf.setValue(KEY_HOTKEY_EDIT_FIND_ROOMS, editFindRooms.get()); + conf.setValue(KEY_HOTKEY_EDIT_ROOM, editRoom.get()); + + // View operations + conf.setValue(KEY_HOTKEY_VIEW_ZOOM_IN, viewZoomIn.get()); + conf.setValue(KEY_HOTKEY_VIEW_ZOOM_OUT, viewZoomOut.get()); + conf.setValue(KEY_HOTKEY_VIEW_ZOOM_RESET, viewZoomReset.get()); + conf.setValue(KEY_HOTKEY_VIEW_LAYER_UP, viewLayerUp.get()); + conf.setValue(KEY_HOTKEY_VIEW_LAYER_DOWN, viewLayerDown.get()); + conf.setValue(KEY_HOTKEY_VIEW_LAYER_RESET, viewLayerReset.get()); + + // View toggles + conf.setValue(KEY_HOTKEY_VIEW_RADIAL_TRANSPARENCY, viewRadialTransparency.get()); + conf.setValue(KEY_HOTKEY_VIEW_STATUS_BAR, viewStatusBar.get()); + conf.setValue(KEY_HOTKEY_VIEW_SCROLL_BARS, viewScrollBars.get()); + conf.setValue(KEY_HOTKEY_VIEW_MENU_BAR, viewMenuBar.get()); + conf.setValue(KEY_HOTKEY_VIEW_ALWAYS_ON_TOP, viewAlwaysOnTop.get()); + + // Side panels + conf.setValue(KEY_HOTKEY_PANEL_LOG, panelLog.get()); + conf.setValue(KEY_HOTKEY_PANEL_CLIENT, panelClient.get()); + conf.setValue(KEY_HOTKEY_PANEL_GROUP, panelGroup.get()); + conf.setValue(KEY_HOTKEY_PANEL_ROOM, panelRoom.get()); + conf.setValue(KEY_HOTKEY_PANEL_ADVENTURE, panelAdventure.get()); + conf.setValue(KEY_HOTKEY_PANEL_COMMS, panelComms.get()); + conf.setValue(KEY_HOTKEY_PANEL_DESCRIPTION, panelDescription.get()); + + // Mouse modes + conf.setValue(KEY_HOTKEY_MODE_MOVE_MAP, modeMoveMap.get()); + conf.setValue(KEY_HOTKEY_MODE_RAYPICK, modeRaypick.get()); + conf.setValue(KEY_HOTKEY_MODE_SELECT_ROOMS, modeSelectRooms.get()); + conf.setValue(KEY_HOTKEY_MODE_SELECT_MARKERS, modeSelectMarkers.get()); + conf.setValue(KEY_HOTKEY_MODE_SELECT_CONNECTION, modeSelectConnection.get()); + conf.setValue(KEY_HOTKEY_MODE_CREATE_MARKER, modeCreateMarker.get()); + conf.setValue(KEY_HOTKEY_MODE_CREATE_ROOM, modeCreateRoom.get()); + conf.setValue(KEY_HOTKEY_MODE_CREATE_CONNECTION, modeCreateConnection.get()); + conf.setValue(KEY_HOTKEY_MODE_CREATE_ONEWAY_CONNECTION, modeCreateOnewayConnection.get()); + + // Room operations + conf.setValue(KEY_HOTKEY_ROOM_CREATE, roomCreate.get()); + conf.setValue(KEY_HOTKEY_ROOM_MOVE_UP, roomMoveUp.get()); + conf.setValue(KEY_HOTKEY_ROOM_MOVE_DOWN, roomMoveDown.get()); + conf.setValue(KEY_HOTKEY_ROOM_MERGE_UP, roomMergeUp.get()); + conf.setValue(KEY_HOTKEY_ROOM_MERGE_DOWN, roomMergeDown.get()); + conf.setValue(KEY_HOTKEY_ROOM_DELETE, roomDelete.get()); + conf.setValue(KEY_HOTKEY_ROOM_CONNECT_NEIGHBORS, roomConnectNeighbors.get()); + conf.setValue(KEY_HOTKEY_ROOM_MOVE_TO_SELECTED, roomMoveToSelected.get()); + conf.setValue(KEY_HOTKEY_ROOM_UPDATE_SELECTED, roomUpdateSelected.get()); +} + +void Configuration::CommsSettings::read(const QSettings &conf) +{ + // Communication colors + tellColor.set(conf.value(tellColor.getName(), QColor(32, 108, 9)).value()); + whisperColor.set(conf.value(whisperColor.getName(), QColor(103, 135, 149)).value()); + groupColor.set(conf.value(groupColor.getName(), QColor(15, 123, 255)).value()); + askColor.set(conf.value(askColor.getName(), QColor(Qt::yellow)).value()); + sayColor.set(conf.value(sayColor.getName(), QColor(80, 173, 199)).value()); + emoteColor.set(conf.value(emoteColor.getName(), QColor(203, 37, 111)).value()); + socialColor.set(conf.value(socialColor.getName(), QColor(217, 140, 151)).value()); + yellColor.set(conf.value(yellColor.getName(), QColor(176, 80, 189)).value()); + narrateColor.set(conf.value(narrateColor.getName(), QColor(119, 197, 203)).value()); + prayColor.set(conf.value(prayColor.getName(), QColor(173, 216, 230)).value()); + shoutColor.set(conf.value(shoutColor.getName(), QColor(160, 9, 198)).value()); + singColor.set(conf.value(singColor.getName(), QColor(144, 238, 144)).value()); + backgroundColor.set(conf.value(backgroundColor.getName(), QColor(22, 31, 33)).value()); + + // Font styling options + yellAllCaps.set(conf.value(yellAllCaps.getName(), true).toBool()); + whisperItalic.set(conf.value(whisperItalic.getName(), true).toBool()); + emoteItalic.set(conf.value(emoteItalic.getName(), true).toBool()); + + // Display options + showTimestamps.set(conf.value(showTimestamps.getName(), false).toBool()); + saveLogOnExit.set(conf.value(saveLogOnExit.getName(), false).toBool()); + logDirectory.set(conf.value(logDirectory.getName(), QString("")).toString()); + + // Talker colors + talkerYouColor.set(conf.value(talkerYouColor.getName(), QColor(228, 250, 255)).value()); + talkerPlayerColor.set( + conf.value(talkerPlayerColor.getName(), QColor(255, 187, 16)).value()); + talkerNpcColor.set(conf.value(talkerNpcColor.getName(), QColor(25, 138, 23)).value()); + talkerAllyColor.set(conf.value(talkerAllyColor.getName(), QColor(33, 166, 255)).value()); + talkerNeutralColor.set( + conf.value(talkerNeutralColor.getName(), QColor(166, 168, 168)).value()); + talkerEnemyColor.set(conf.value(talkerEnemyColor.getName(), QColor(173, 7, 37)).value()); + + // Tab muting (filters) + muteDirectTab.set(conf.value(muteDirectTab.getName(), false).toBool()); + muteLocalTab.set(conf.value(muteLocalTab.getName(), false).toBool()); + muteGlobalTab.set(conf.value(muteGlobalTab.getName(), false).toBool()); +} + +void Configuration::CommsSettings::write(QSettings &conf) const +{ + // Communication colors + conf.setValue(tellColor.getName(), tellColor.get()); + conf.setValue(whisperColor.getName(), whisperColor.get()); + conf.setValue(groupColor.getName(), groupColor.get()); + conf.setValue(askColor.getName(), askColor.get()); + conf.setValue(sayColor.getName(), sayColor.get()); + conf.setValue(emoteColor.getName(), emoteColor.get()); + conf.setValue(socialColor.getName(), socialColor.get()); + conf.setValue(yellColor.getName(), yellColor.get()); + conf.setValue(narrateColor.getName(), narrateColor.get()); + conf.setValue(prayColor.getName(), prayColor.get()); + conf.setValue(shoutColor.getName(), shoutColor.get()); + conf.setValue(singColor.getName(), singColor.get()); + conf.setValue(backgroundColor.getName(), backgroundColor.get()); + + // Font styling options + conf.setValue(yellAllCaps.getName(), yellAllCaps.get()); + conf.setValue(whisperItalic.getName(), whisperItalic.get()); + conf.setValue(emoteItalic.getName(), emoteItalic.get()); + + // Display options + conf.setValue(showTimestamps.getName(), showTimestamps.get()); + conf.setValue(saveLogOnExit.getName(), saveLogOnExit.get()); + conf.setValue(logDirectory.getName(), logDirectory.get()); + + // Talker colors + conf.setValue(talkerYouColor.getName(), talkerYouColor.get()); + conf.setValue(talkerPlayerColor.getName(), talkerPlayerColor.get()); + conf.setValue(talkerNpcColor.getName(), talkerNpcColor.get()); + conf.setValue(talkerAllyColor.getName(), talkerAllyColor.get()); + conf.setValue(talkerNeutralColor.getName(), talkerNeutralColor.get()); + conf.setValue(talkerEnemyColor.getName(), talkerEnemyColor.get()); + + // Tab muting (filters) + conf.setValue(muteDirectTab.getName(), muteDirectTab.get()); + conf.setValue(muteLocalTab.getName(), muteLocalTab.get()); + conf.setValue(muteGlobalTab.getName(), muteGlobalTab.get()); } void Configuration::AccountSettings::write(QSettings &conf) const @@ -862,6 +1225,8 @@ void Configuration::MumeClockSettings::write(QSettings &conf) const // Note: There's no QVariant(int64_t) constructor. conf.setValue(KEY_MUME_START_EPOCH, static_cast(startEpoch)); conf.setValue(KEY_DISPLAY_CLOCK, display); + conf.setValue(KEY_GMCP_BROADCAST_CLOCK, gmcpBroadcast.get()); + conf.setValue(KEY_GMCP_BROADCAST_INTERVAL, gmcpBroadcastInterval.get()); } void Configuration::AdventurePanelSettings::write(QSettings &conf) const @@ -994,6 +1359,118 @@ void Configuration::CanvasSettings::Advanced::registerChangeCallback( layerHeight.registerChangeCallback(lifetime, callback); } +Configuration::CanvasSettings::VisibilityFilter::VisibilityFilter() = default; + +bool Configuration::CanvasSettings::VisibilityFilter::isVisible(InfomarkClassEnum markerClass) const +{ + switch (markerClass) { + case InfomarkClassEnum::GENERIC: + return generic.get(); + case InfomarkClassEnum::HERB: + return herb.get(); + case InfomarkClassEnum::RIVER: + return river.get(); + case InfomarkClassEnum::PLACE: + return place.get(); + case InfomarkClassEnum::MOB: + return mob.get(); + case InfomarkClassEnum::COMMENT: + return comment.get(); + case InfomarkClassEnum::ROAD: + return road.get(); + case InfomarkClassEnum::OBJECT: + return object.get(); + case InfomarkClassEnum::ACTION: + return action.get(); + case InfomarkClassEnum::LOCALITY: + return locality.get(); + } + return true; // Default to visible for unknown types +} + +void Configuration::CanvasSettings::VisibilityFilter::setVisible(InfomarkClassEnum markerClass, + bool visible) +{ + switch (markerClass) { + case InfomarkClassEnum::GENERIC: + generic.set(visible); + break; + case InfomarkClassEnum::HERB: + herb.set(visible); + break; + case InfomarkClassEnum::RIVER: + river.set(visible); + break; + case InfomarkClassEnum::PLACE: + place.set(visible); + break; + case InfomarkClassEnum::MOB: + mob.set(visible); + break; + case InfomarkClassEnum::COMMENT: + comment.set(visible); + break; + case InfomarkClassEnum::ROAD: + road.set(visible); + break; + case InfomarkClassEnum::OBJECT: + object.set(visible); + break; + case InfomarkClassEnum::ACTION: + action.set(visible); + break; + case InfomarkClassEnum::LOCALITY: + locality.set(visible); + break; + } +} + +void Configuration::CanvasSettings::VisibilityFilter::showAll() +{ + generic.set(true); + herb.set(true); + river.set(true); + place.set(true); + mob.set(true); + comment.set(true); + road.set(true); + object.set(true); + action.set(true); + locality.set(true); + connections.set(true); +} + +void Configuration::CanvasSettings::VisibilityFilter::hideAll() +{ + generic.set(false); + herb.set(false); + river.set(false); + place.set(false); + mob.set(false); + comment.set(false); + road.set(false); + object.set(false); + action.set(false); + locality.set(false); + connections.set(false); +} + +void Configuration::CanvasSettings::VisibilityFilter::registerChangeCallback( + const ChangeMonitor::Lifetime &lifetime, const ChangeMonitor::Function &callback) +{ + generic.registerChangeCallback(lifetime, callback); + herb.registerChangeCallback(lifetime, callback); + river.registerChangeCallback(lifetime, callback); + place.registerChangeCallback(lifetime, callback); + mob.registerChangeCallback(lifetime, callback); + comment.registerChangeCallback(lifetime, callback); + road.registerChangeCallback(lifetime, callback); + object.registerChangeCallback(lifetime, callback); + action.registerChangeCallback(lifetime, callback); + locality.registerChangeCallback(lifetime, callback); + connections.registerChangeCallback(lifetime, callback); +} + void setEnteredMain() { g_thread = std::this_thread::get_id(); diff --git a/src/configuration/configuration.h b/src/configuration/configuration.h index 58d1d1e9f..d83f60ce5 100644 --- a/src/configuration/configuration.h +++ b/src/configuration/configuration.h @@ -29,6 +29,9 @@ #undef TRANSPARENT // Bad dog, Microsoft; bad dog!!! +// Forward declaration for InfomarkClassEnum +enum class InfomarkClassEnum : uint8_t; + #define SUBGROUP() \ friend class Configuration; \ void read(const QSettings &conf); \ @@ -79,6 +82,7 @@ class NODISCARD Configuration final char prefixChar = char_consts::C_UNDERSCORE; bool encodeEmoji = true; bool decodeEmoji = true; + bool enableYellFallbackParsing = true; // Parse yells from game text when GMCP unavailable private: SUBGROUP(); @@ -143,6 +147,10 @@ class NODISCARD Configuration final bool trilinearFiltering = false; bool softwareOpenGL = false; QString resourcesDirectory; + TextureSetEnum textureSet = TextureSetEnum::MODERN; + bool enableSeasonalTextures = true; + float layerTransparency = 1.0f; // 0.0 = only focused layer, 1.0 = maximum transparency + bool enableRadialTransparency = true; // Enable radial transparency zones on upper layers // not saved yet: bool drawCharBeacons = true; @@ -159,12 +167,21 @@ class NODISCARD Configuration final NamedConfig autoTilt{"MMAPPER_AUTO_TILT", true}; NamedConfig printPerfStats{"MMAPPER_GL_PERFSTATS", IS_DEBUG_BUILD}; + // Background image settings + bool useBackgroundImage = false; + QString backgroundImagePath; + int backgroundFitMode = 0; // BackgroundFitModeEnum::FIT + float backgroundOpacity = 1.0f; + float backgroundFocusedScale = 1.0f; // Scale factor for FOCUSED mode (0.1 to 10.0) + float backgroundFocusedOffsetX = 0.0f; // X offset for FOCUSED mode (-1000 to 1000) + float backgroundFocusedOffsetY = 0.0f; // Y offset for FOCUSED mode (-1000 to 1000) + // 5..90 degrees FixedPoint<1> fov{50, 900, 765}; // 0..90 degrees FixedPoint<1> verticalAngle{0, 900, 450}; - // -45..45 degrees - FixedPoint<1> horizontalAngle{-450, 450, 0}; + // -180..180 degrees (full rotation) + FixedPoint<1> horizontalAngle{-1800, 1800, 0}; // 1..10 rooms FixedPoint<1> layerHeight{10, 100, 15}; @@ -175,10 +192,147 @@ class NODISCARD Configuration final Advanced(); } advanced; + struct NODISCARD VisibilityFilter final + { + NamedConfig generic{"VISIBLE_MARKER_GENERIC", true}; + NamedConfig herb{"VISIBLE_MARKER_HERB", true}; + NamedConfig river{"VISIBLE_MARKER_RIVER", true}; + NamedConfig place{"VISIBLE_MARKER_PLACE", true}; + NamedConfig mob{"VISIBLE_MARKER_MOB", true}; + NamedConfig comment{"VISIBLE_MARKER_COMMENT", true}; + NamedConfig road{"VISIBLE_MARKER_ROAD", true}; + NamedConfig object{"VISIBLE_MARKER_OBJECT", true}; + NamedConfig action{"VISIBLE_MARKER_ACTION", true}; + NamedConfig locality{"VISIBLE_MARKER_LOCALITY", true}; + NamedConfig connections{"VISIBLE_CONNECTIONS", true}; + + public: + NODISCARD bool isVisible(InfomarkClassEnum markerClass) const; + void setVisible(InfomarkClassEnum markerClass, bool visible); + NODISCARD bool isConnectionsVisible() const { return connections.get(); } + void setConnectionsVisible(bool visible) { connections.set(visible); } + void showAll(); + void hideAll(); + void registerChangeCallback(const ChangeMonitor::Lifetime &lifetime, + const ChangeMonitor::Function &callback); + + VisibilityFilter(); + } visibilityFilter; + private: SUBGROUP(); } canvas; + struct NODISCARD Hotkeys final + { + // File operations + NamedConfig fileOpen{"HOTKEY_FILE_OPEN", "Ctrl+O"}; + NamedConfig fileSave{"HOTKEY_FILE_SAVE", "Ctrl+S"}; + NamedConfig fileReload{"HOTKEY_FILE_RELOAD", "Ctrl+R"}; + NamedConfig fileQuit{"HOTKEY_FILE_QUIT", "Ctrl+Q"}; + + // Edit operations + NamedConfig editUndo{"HOTKEY_EDIT_UNDO", "Ctrl+Z"}; + NamedConfig editRedo{"HOTKEY_EDIT_REDO", "Ctrl+Y"}; + NamedConfig editPreferences{"HOTKEY_EDIT_PREFERENCES", "Ctrl+P"}; + NamedConfig editPreferencesAlt{"HOTKEY_EDIT_PREFERENCES_ALT", "Esc"}; + NamedConfig editFindRooms{"HOTKEY_EDIT_FIND_ROOMS", "Ctrl+F"}; + NamedConfig editRoom{"HOTKEY_EDIT_ROOM", "Ctrl+E"}; + + // View operations + NamedConfig viewZoomIn{"HOTKEY_VIEW_ZOOM_IN", ""}; + NamedConfig viewZoomOut{"HOTKEY_VIEW_ZOOM_OUT", ""}; + NamedConfig viewZoomReset{"HOTKEY_VIEW_ZOOM_RESET", "Ctrl+0"}; + NamedConfig viewLayerUp{"HOTKEY_VIEW_LAYER_UP", ""}; + NamedConfig viewLayerDown{"HOTKEY_VIEW_LAYER_DOWN", ""}; + NamedConfig viewLayerReset{"HOTKEY_VIEW_LAYER_RESET", ""}; + + // View toggles + NamedConfig viewRadialTransparency{"HOTKEY_VIEW_RADIAL_TRANSPARENCY", ""}; + NamedConfig viewStatusBar{"HOTKEY_VIEW_STATUS_BAR", ""}; + NamedConfig viewScrollBars{"HOTKEY_VIEW_SCROLL_BARS", ""}; + NamedConfig viewMenuBar{"HOTKEY_VIEW_MENU_BAR", ""}; + NamedConfig viewAlwaysOnTop{"HOTKEY_VIEW_ALWAYS_ON_TOP", ""}; + + // Side panels + NamedConfig panelLog{"HOTKEY_PANEL_LOG", "Ctrl+L"}; + NamedConfig panelClient{"HOTKEY_PANEL_CLIENT", ""}; + NamedConfig panelGroup{"HOTKEY_PANEL_GROUP", ""}; + NamedConfig panelRoom{"HOTKEY_PANEL_ROOM", ""}; + NamedConfig panelAdventure{"HOTKEY_PANEL_ADVENTURE", ""}; + NamedConfig panelDescription{"HOTKEY_PANEL_DESCRIPTION", ""}; + NamedConfig panelComms{"HOTKEY_PANEL_COMMS", ""}; + + // Mouse modes + NamedConfig modeMoveMap{"HOTKEY_MODE_MOVE_MAP", ""}; + NamedConfig modeRaypick{"HOTKEY_MODE_RAYPICK", ""}; + NamedConfig modeSelectRooms{"HOTKEY_MODE_SELECT_ROOMS", ""}; + NamedConfig modeSelectMarkers{"HOTKEY_MODE_SELECT_MARKERS", ""}; + NamedConfig modeSelectConnection{"HOTKEY_MODE_SELECT_CONNECTION", ""}; + NamedConfig modeCreateMarker{"HOTKEY_MODE_CREATE_MARKER", ""}; + NamedConfig modeCreateRoom{"HOTKEY_MODE_CREATE_ROOM", ""}; + NamedConfig modeCreateConnection{"HOTKEY_MODE_CREATE_CONNECTION", ""}; + NamedConfig modeCreateOnewayConnection{"HOTKEY_MODE_CREATE_ONEWAY_CONNECTION", ""}; + + // Room operations + NamedConfig roomCreate{"HOTKEY_ROOM_CREATE", ""}; + NamedConfig roomMoveUp{"HOTKEY_ROOM_MOVE_UP", ""}; + NamedConfig roomMoveDown{"HOTKEY_ROOM_MOVE_DOWN", ""}; + NamedConfig roomMergeUp{"HOTKEY_ROOM_MERGE_UP", ""}; + NamedConfig roomMergeDown{"HOTKEY_ROOM_MERGE_DOWN", ""}; + NamedConfig roomDelete{"HOTKEY_ROOM_DELETE", "Del"}; + NamedConfig roomConnectNeighbors{"HOTKEY_ROOM_CONNECT_NEIGHBORS", ""}; + NamedConfig roomMoveToSelected{"HOTKEY_ROOM_MOVE_TO_SELECTED", ""}; + NamedConfig roomUpdateSelected{"HOTKEY_ROOM_UPDATE_SELECTED", ""}; + + private: + SUBGROUP(); + } hotkeys; + + struct NODISCARD CommsSettings final + { + // Colors for each communication type + NamedConfig tellColor{"COMMS_TELL_COLOR", QColor(Qt::cyan)}; + NamedConfig whisperColor{"COMMS_WHISPER_COLOR", QColor(135, 206, 250)}; // Light sky blue + NamedConfig groupColor{"COMMS_GROUP_COLOR", QColor(Qt::green)}; + NamedConfig askColor{"COMMS_ASK_COLOR", QColor(Qt::yellow)}; + NamedConfig sayColor{"COMMS_SAY_COLOR", QColor(Qt::white)}; + NamedConfig emoteColor{"COMMS_EMOTE_COLOR", QColor(Qt::magenta)}; + NamedConfig socialColor{"COMMS_SOCIAL_COLOR", QColor(255, 182, 193)}; // Light pink + NamedConfig yellColor{"COMMS_YELL_COLOR", QColor(Qt::red)}; + NamedConfig narrateColor{"COMMS_NARRATE_COLOR", QColor(255, 165, 0)}; // Orange + NamedConfig prayColor{"COMMS_PRAY_COLOR", QColor(173, 216, 230)}; // Light blue + NamedConfig shoutColor{"COMMS_SHOUT_COLOR", QColor(139, 0, 0)}; // Dark red + NamedConfig singColor{"COMMS_SING_COLOR", QColor(144, 238, 144)}; // Light green + NamedConfig backgroundColor{"COMMS_BG_COLOR", QColor(Qt::black)}; + + // Talker colors (based on GMCP Comm.Channel talker-type) + NamedConfig talkerYouColor{"COMMS_TALKER_YOU_COLOR", QColor(255, 215, 0)}; // Gold + NamedConfig talkerPlayerColor{"COMMS_TALKER_PLAYER_COLOR", QColor(Qt::white)}; + NamedConfig talkerNpcColor{"COMMS_TALKER_NPC_COLOR", QColor(192, 192, 192)}; // Silver/Gray + NamedConfig talkerAllyColor{"COMMS_TALKER_ALLY_COLOR", QColor(0, 255, 0)}; // Bright green + NamedConfig talkerNeutralColor{"COMMS_TALKER_NEUTRAL_COLOR", QColor(255, 255, 0)}; // Yellow + NamedConfig talkerEnemyColor{"COMMS_TALKER_ENEMY_COLOR", QColor(255, 0, 0)}; // Red + + // Font styling options + NamedConfig yellAllCaps{"COMMS_YELL_ALL_CAPS", true}; + NamedConfig whisperItalic{"COMMS_WHISPER_ITALIC", true}; + NamedConfig emoteItalic{"COMMS_EMOTE_ITALIC", true}; + + // Display options + NamedConfig showTimestamps{"COMMS_SHOW_TIMESTAMPS", false}; + NamedConfig saveLogOnExit{"COMMS_SAVE_LOG_ON_EXIT", false}; + NamedConfig logDirectory{"COMMS_LOG_DIR", ""}; + + // Tab muting (acts as a filter) + NamedConfig muteDirectTab{"COMMS_MUTE_DIRECT", false}; + NamedConfig muteLocalTab{"COMMS_MUTE_LOCAL", false}; + NamedConfig muteGlobalTab{"COMMS_MUTE_GLOBAL", false}; + + private: + SUBGROUP(); + } comms; + #define XFOREACH_NAMED_COLOR_OPTIONS(X) \ X(BACKGROUND, BACKGROUND_NAME) \ X(CONNECTION_NORMAL, CONNECTION_NORMAL_NAME) \ @@ -300,6 +454,10 @@ class NODISCARD Configuration final { int64_t startEpoch = 0; bool display = false; + NamedConfig gmcpBroadcast{"GMCP_BROADCAST_CLOCK", true}; // Enable GMCP clock broadcasting + NamedConfig gmcpBroadcastInterval{"GMCP_BROADCAST_INTERVAL", 2500}; // Update interval in milliseconds (default: 2.5 seconds = 1 MUME minute) + + MumeClockSettings(); private: SUBGROUP(); diff --git a/src/observer/gameobserver.cpp b/src/observer/gameobserver.cpp index a15d0a8b1..7d4d6ca9f 100644 --- a/src/observer/gameobserver.cpp +++ b/src/observer/gameobserver.cpp @@ -34,3 +34,8 @@ void GameObserver::observeToggledEchoMode(const bool echo) { sig2_toggledEchoMode.invoke(echo); } + +void GameObserver::observeRawGameText(const QString &text) +{ + sig2_rawGameText.invoke(text); +} diff --git a/src/observer/gameobserver.h b/src/observer/gameobserver.h index dc3097a3e..91e5114ef 100644 --- a/src/observer/gameobserver.h +++ b/src/observer/gameobserver.h @@ -19,6 +19,7 @@ class NODISCARD_QOBJECT GameObserver final Signal2 sig2_sentToUserGmcp; Signal2 sig2_toggledEchoMode; + Signal2 sig2_rawGameText; // raw text from MUD parser (for fallback parsing) public: void observeConnected(); @@ -26,4 +27,5 @@ class NODISCARD_QOBJECT GameObserver final void observeSentToUser(const QString &ba); void observeSentToUserGmcp(const GmcpMessage &m); void observeToggledEchoMode(bool echo); + void observeRawGameText(const QString &text); }; diff --git a/src/parser/mumexmlparser-gmcp.cpp b/src/parser/mumexmlparser-gmcp.cpp index 9606d0b3f..1e5efcb68 100644 --- a/src/parser/mumexmlparser-gmcp.cpp +++ b/src/parser/mumexmlparser-gmcp.cpp @@ -357,6 +357,11 @@ void MumeXmlParser::parseGmcpCharVitals(const JsonObj &obj) qWarning().noquote() << "prompt has unknown weather flag:" << *weather; } } + + // Immediately update MapData with new prompt flags for real-time lighting updates + // This ensures the map updates when light status changes (e.g., covering/uncovering light) + // even without moving or looking + m_mapData.setPromptFlags(promptFlags); } void MumeXmlParser::parseGmcpEventMoved(const JsonObj &obj) diff --git a/src/parser/mumexmlparser.cpp b/src/parser/mumexmlparser.cpp index f36408576..0e86113b1 100644 --- a/src/parser/mumexmlparser.cpp +++ b/src/parser/mumexmlparser.cpp @@ -123,6 +123,9 @@ void MumeXmlParser::parse(const TelnetData &data, const bool isGoAhead) if (!m_lineToUser.isEmpty()) { sendToUser(SendToUserSourceEnum::FromMud, m_lineToUser, isGoAhead); + // Emit raw game text for fallback parsing (e.g., yells when GMCP unavailable) + emit sig_rawGameText(m_lineToUser); + // Simplify the output and run actions QString tempStr = m_lineToUser; tempStr = normalizeStringCopy(tempStr.trimmed()); diff --git a/src/parser/mumexmlparser.h b/src/parser/mumexmlparser.h index 4f0c82425..6cc796425 100644 --- a/src/parser/mumexmlparser.h +++ b/src/parser/mumexmlparser.h @@ -85,6 +85,9 @@ class NODISCARD_QOBJECT MumeXmlParser final : public MumeXmlParserBase ParserCommonData &parserCommonData); ~MumeXmlParser() final; +signals: + void sig_rawGameText(const QString &text); + private: void parse(const TelnetData &, bool isGoAhead); diff --git a/src/preferences/mumeprotocolpage.cpp b/src/preferences/mumeprotocolpage.cpp index ad48f4b71..9bb276999 100644 --- a/src/preferences/mumeprotocolpage.cpp +++ b/src/preferences/mumeprotocolpage.cpp @@ -30,6 +30,14 @@ MumeProtocolPage::MumeProtocolPage(QWidget *parent) &QAbstractButton::clicked, this, &MumeProtocolPage::slot_externalEditorBrowseButtonClicked); + connect(ui->gmcpBroadcastCheckBox, + &QCheckBox::stateChanged, + this, + &MumeProtocolPage::slot_gmcpBroadcastCheckBoxChanged); + connect(ui->gmcpIntervalSpinBox, + QOverload::of(&QSpinBox::valueChanged), + this, + &MumeProtocolPage::slot_gmcpIntervalSpinBoxChanged); } MumeProtocolPage::~MumeProtocolPage() @@ -45,6 +53,12 @@ void MumeProtocolPage::slot_loadConfig() ui->externalEditorCommand->setText(settings.externalRemoteEditorCommand); ui->externalEditorCommand->setEnabled(!settings.internalRemoteEditor); ui->externalEditorBrowseButton->setEnabled(!settings.internalRemoteEditor); + + // Load GMCP clock broadcasting settings + const auto &clockSettings = getConfig().mumeClock; + ui->gmcpBroadcastCheckBox->setChecked(clockSettings.gmcpBroadcast.get()); + ui->gmcpIntervalSpinBox->setValue(clockSettings.gmcpBroadcastInterval.get()); + ui->gmcpIntervalSpinBox->setEnabled(clockSettings.gmcpBroadcast.get()); } void MumeProtocolPage::slot_internalEditorRadioButtonChanged(bool /*unused*/) @@ -74,3 +88,15 @@ void MumeProtocolPage::slot_externalEditorBrowseButtonClicked(bool /*unused*/) command = quotedFileName; } } + +void MumeProtocolPage::slot_gmcpBroadcastCheckBoxChanged(int /*unused*/) +{ + const bool enabled = ui->gmcpBroadcastCheckBox->isChecked(); + setConfig().mumeClock.gmcpBroadcast.set(enabled); + ui->gmcpIntervalSpinBox->setEnabled(enabled); +} + +void MumeProtocolPage::slot_gmcpIntervalSpinBoxChanged(int value) +{ + setConfig().mumeClock.gmcpBroadcastInterval.set(value); +} diff --git a/src/preferences/mumeprotocolpage.h b/src/preferences/mumeprotocolpage.h index 583d528d9..d39411f93 100644 --- a/src/preferences/mumeprotocolpage.h +++ b/src/preferences/mumeprotocolpage.h @@ -31,4 +31,6 @@ public slots: void slot_internalEditorRadioButtonChanged(bool); void slot_externalEditorCommandTextChanged(QString); void slot_externalEditorBrowseButtonClicked(bool); + void slot_gmcpBroadcastCheckBoxChanged(int); + void slot_gmcpIntervalSpinBoxChanged(int); }; diff --git a/src/preferences/mumeprotocolpage.ui b/src/preferences/mumeprotocolpage.ui index e509334ef..9ad46ad41 100644 --- a/src/preferences/mumeprotocolpage.ui +++ b/src/preferences/mumeprotocolpage.ui @@ -120,6 +120,66 @@ + + + + GMCP Clock Broadcasting + + + + 9 + + + 9 + + + 9 + + + 9 + + + 6 + + + + + Broadcast clock to connected clients (GMCP MUME.Time) + + + true + + + + + + + Update interval (ms): + + + + + + + 500 + + + 60000 + + + 500 + + + 2500 + + + How often to send clock updates to clients (default: 2500ms = 1 MUME minute) + + + + + + @@ -140,6 +200,8 @@ internalEditorRadioButton externalEditorRadioButton externalEditorCommand + gmcpBroadcastCheckBox + gmcpIntervalSpinBox diff --git a/src/proxy/GmcpMessage.h b/src/proxy/GmcpMessage.h index 430c8c405..39c5c57c9 100644 --- a/src/proxy/GmcpMessage.h +++ b/src/proxy/GmcpMessage.h @@ -22,6 +22,8 @@ class ParseEvent; X(CHAR_STATUSVARS, CharStatusVars, "char.statusvars", "Char.StatusVars") \ X(CHAR_VITALS, CharVitals, "char.vitals", "Char.Vitals") \ X(CHAR_LOGIN, CharLogin, "char.login", "Char.Login") \ + X(COMM_CHANNEL_LIST, CommChannelList, "comm.channel.list", "Comm.Channel.List") \ + X(COMM_CHANNEL_TEXT, CommChannelText, "comm.channel.text", "Comm.Channel.Text") \ X(CORE_GOODBYE, CoreGoodbye, "core.goodbye", "Core.Goodbye") \ X(CORE_HELLO, CoreHello, "core.hello", "Core.Hello") \ X(CORE_SUPPORTS_ADD, CoreSupportsAdd, "core.supports.add", "Core.Supports.Add") \ @@ -48,6 +50,7 @@ class ParseEvent; "MUME.Client.CancelEdit") \ X(MUME_CLIENT_WRITE, MumeClientWrite, "mume.client.write", "MUME.Client.Write") \ X(MUME_CLIENT_XML, MumeClientXml, "mume.client.xml", "MUME.Client.XML") \ + X(MUME_TIME_INFO, MumeTimeInfo, "mume.time.info", "MUME.Time.Info") \ X(ROOM_CHARS_ADD, RoomCharsAdd, "room.chars.add", "Room.Chars.Add") \ X(ROOM_CHARS_REMOVE, RoomCharsRemove, "room.chars.remove", "Room.Chars.Remove") \ X(ROOM_CHARS_SET, RoomCharsSet, "room.chars.set", "Room.Chars.Set") \ @@ -66,7 +69,7 @@ enum class NODISCARD GmcpMessageTypeEnum { #define X_COUNT(...) +1 static constexpr const size_t NUM_GMCP_MESSAGES = XFOREACH_GMCP_MESSAGE_TYPE(X_COUNT); #undef X_COUNT -static_assert(NUM_GMCP_MESSAGES == 30); +static_assert(NUM_GMCP_MESSAGES == 33); DEFINE_ENUM_COUNT(GmcpMessageTypeEnum, NUM_GMCP_MESSAGES) namespace tags { diff --git a/src/proxy/GmcpModule.h b/src/proxy/GmcpModule.h index 90f03d26e..fef96936e 100644 --- a/src/proxy/GmcpModule.h +++ b/src/proxy/GmcpModule.h @@ -21,6 +21,7 @@ X(GROUP, Group, "group", "Group") \ X(EXTERNAL_DISCORD, ExternalDiscord, "external.discord", "External.Discord") \ X(MUME_CLIENT, MumeClient, "mume.client", "MUME.Client") \ + X(MUME_TIME, MumeTime, "mume.time", "MUME.Time") \ X(ROOM_CHARS, RoomChars, "room.chars", "Room.Chars") \ X(ROOM, Room, "room", "Room") \ /* define gmcp module types above */ @@ -35,7 +36,7 @@ enum class NODISCARD GmcpModuleTypeEnum { #define X_COUNT(...) +1 static constexpr const size_t NUM_GMCP_MODULES = XFOREACH_GMCP_MODULE_TYPE(X_COUNT); #undef X_COUNT -static_assert(NUM_GMCP_MODULES == 7); +static_assert(NUM_GMCP_MODULES == 8); DEFINE_ENUM_COUNT(GmcpModuleTypeEnum, NUM_GMCP_MODULES) namespace tags { diff --git a/src/proxy/UserTelnet.cpp b/src/proxy/UserTelnet.cpp index cb2a4e06c..e9189e181 100644 --- a/src/proxy/UserTelnet.cpp +++ b/src/proxy/UserTelnet.cpp @@ -159,8 +159,12 @@ void UserTelnet::onRelayEchoMode(const bool isDisabled) void UserTelnet::virt_receiveGmcpMessage(const GmcpMessage &msg) { - // Eat Core.Hello since MMapper sends its own to MUME + qDebug() << "UserTelnet received GMCP:" << msg.getName().getStdStringUtf8().c_str(); + + // Respond to Core.Hello by advertising MMapper's supported modules if (msg.isCoreHello()) { + qDebug() << " Responding to Core.Hello"; + sendSupportedGmcpModules(); return; } @@ -254,19 +258,30 @@ bool UserTelnet::virt_isGmcpModuleEnabled(const GmcpModuleTypeEnum &name) const void UserTelnet::receiveGmcpModule(const GmcpModule &mod, const bool enabled) { + qDebug() << "receiveGmcpModule:" << mod.getNormalizedName().c_str() + << "enabled:" << enabled << "isSupported:" << mod.isSupported(); + if (enabled) { if (!mod.hasVersion()) { throw std::runtime_error("missing version"); } m_gmcp.modules.insert(mod); if (mod.isSupported()) { + qDebug() << " Module IS supported, type:" << static_cast(mod.getType()); m_gmcp.supported[mod.getType()] = mod.getVersion(); + // Notify outputs that a supported module was enabled + qDebug() << " Calling onGmcpModuleEnabled callback"; + m_outputs.onGmcpModuleEnabled(mod.getType(), true); + } else { + qDebug() << " Module NOT supported by MMapper"; } } else { m_gmcp.modules.erase(mod); if (mod.isSupported()) { m_gmcp.supported[mod.getType()] = DEFAULT_GMCP_MODULE_VERSION; + // Notify outputs that a supported module was disabled + m_outputs.onGmcpModuleEnabled(mod.getType(), false); } } } @@ -282,3 +297,32 @@ void UserTelnet::resetGmcpModules() #undef X_CASE m_gmcp.modules.clear(); } + +void UserTelnet::sendSupportedGmcpModules() +{ + // Build list of modules that MMapper supports + std::ostringstream oss; + oss << "[ "; + bool comma = false; + +#define X_ADD_MODULE(UPPER_CASE, CamelCase, normalized, friendly) \ + if (comma) { \ + oss << ", "; \ + } \ + oss << "\"" << friendly << " 1\""; \ + comma = true; + + XFOREACH_GMCP_MODULE_TYPE(X_ADD_MODULE) +#undef X_ADD_MODULE + + oss << " ]"; + + // Send Core.Supports.Set to advertise MMapper's modules + const GmcpMessage supportMsg(GmcpMessageTypeEnum::CORE_SUPPORTS_SET, + GmcpJson{std::move(oss).str()}); + sendGmcpMessage(supportMsg); + + if (getDebug()) { + qDebug() << "Sent MMapper supported GMCP modules to client"; + } +} diff --git a/src/proxy/UserTelnet.h b/src/proxy/UserTelnet.h index 8cf7f4fbd..8c3bfa280 100644 --- a/src/proxy/UserTelnet.h +++ b/src/proxy/UserTelnet.h @@ -29,6 +29,10 @@ struct NODISCARD UserTelnetOutputs { virt_onRelayTermTypeFromUserToMud(bytes); } + void onGmcpModuleEnabled(const GmcpModuleTypeEnum type, const bool enabled) + { + virt_onGmcpModuleEnabled(type, enabled); + } private: virtual void virt_onAnalyzeUserStream(const RawBytes &, bool) = 0; @@ -36,6 +40,7 @@ struct NODISCARD UserTelnetOutputs virtual void virt_onRelayGmcpFromUserToMud(const GmcpMessage &) = 0; virtual void virt_onRelayNawsFromUserToMud(int, int) = 0; virtual void virt_onRelayTermTypeFromUserToMud(const TelnetTermTypeBytes &) = 0; + virtual void virt_onGmcpModuleEnabled(GmcpModuleTypeEnum, bool) = 0; }; class NODISCARD UserTelnet final : public AbstractTelnet @@ -68,6 +73,7 @@ class NODISCARD UserTelnet final : public AbstractTelnet private: void receiveGmcpModule(const GmcpModule &, bool); void resetGmcpModules(); + void sendSupportedGmcpModules(); public: void onAnalyzeUserStream(const TelnetIacBytes &); diff --git a/src/proxy/proxy.cpp b/src/proxy/proxy.cpp index f4df058f7..1a8c9abee 100644 --- a/src/proxy/proxy.cpp +++ b/src/proxy/proxy.cpp @@ -37,11 +37,14 @@ #include #include +#include +#include #include #include #include #include #include +#include using mmqt::makeQPointer; @@ -164,6 +167,9 @@ Proxy::~Proxy() sendNewlineToUser(); sendStatusToUser("MMapper proxy is shutting down."); + // Stop clock broadcaster + stopClockBroadcaster(); + { qDebug() << "disconnecting mud socket..."; getMudSocket().disconnectFromHost(); @@ -400,6 +406,19 @@ void Proxy::allocUserTelnet() // forwarded (to mud) getMudTelnet().onRelayTermType(bytes); } + void virt_onGmcpModuleEnabled(const GmcpModuleTypeEnum type, const bool enabled) final + { + // Check if MUME.Time module was enabled/disabled + if (type == GmcpModuleTypeEnum::MUME_TIME) { + if (enabled) { + qDebug() << "MUME.Time module enabled by client, starting broadcaster"; + getProxy().startClockBroadcaster(); + } else { + qDebug() << "MUME.Time module disabled by client, stopping broadcaster"; + getProxy().stopClockBroadcaster(); + } + } + } }; auto &pipe = getPipeline(); @@ -851,6 +870,26 @@ void Proxy::init() QObject::connect(&m_mapData, &MapData::sig_onForcedPositionChange, this, [this]() { getMudParser().onForcedPositionChange(); }); + + // Connect parser's raw game text signal to GameObserver for fallback parsing + QObject::connect(&getMudParser(), &MumeXmlParser::sig_rawGameText, this, [this](const QString &text) { + getGameObserver().observeRawGameText(text); + }); + + // Register change callbacks for GMCP broadcast settings + setConfig().mumeClock.gmcpBroadcast.registerChangeCallback(m_lifetime, [this]() { + qDebug() << "GMCP broadcast setting changed, restarting broadcaster..."; + stopClockBroadcaster(); + startClockBroadcaster(); + }); + + setConfig().mumeClock.gmcpBroadcastInterval.registerChangeCallback(m_lifetime, [this]() { + qDebug() << "GMCP broadcast interval changed, restarting broadcaster..."; + stopClockBroadcaster(); + startClockBroadcaster(); + }); + + // Clock broadcaster will be started automatically when client enables MUME.Time module }; allocPipelineObjects(); @@ -873,6 +912,190 @@ void Proxy::gmcpToUser(const GmcpMessage &msg) getUserTelnet().onGmcpToUser(msg); } +void Proxy::startClockBroadcaster() +{ + const auto &config = getConfig(); + + qDebug() << "=== startClockBroadcaster called ==="; + qDebug() << " gmcpBroadcast config:" << config.mumeClock.gmcpBroadcast.get(); + qDebug() << " MUME_TIME enabled:" << isUserGmcpModuleEnabled(GmcpModuleTypeEnum::MUME_TIME); + + // Only start if GMCP broadcasting is enabled and client supports MUME.Time module + if (!config.mumeClock.gmcpBroadcast.get() || !isUserGmcpModuleEnabled(GmcpModuleTypeEnum::MUME_TIME)) { + qDebug() << " NOT starting (requirements not met)"; + return; + } + + qDebug() << " Creating/starting broadcaster..."; + + // Create timer if it doesn't exist + if (m_clockBroadcastTimer == nullptr) { + m_clockBroadcastTimer = new QTimer(this); + QObject::connect(m_clockBroadcastTimer, &QTimer::timeout, this, &Proxy::broadcastClockInfo); + qDebug() << " Created new timer"; + } + + // Set interval and start + m_clockBroadcastTimer->setInterval(config.mumeClock.gmcpBroadcastInterval.get()); + m_clockBroadcastTimer->start(); + qDebug() << " Timer started, interval:" << config.mumeClock.gmcpBroadcastInterval.get() << "ms"; + + // Send initial update immediately + qDebug() << " Sending initial broadcast..."; + broadcastClockInfo(); + qDebug() << "=== startClockBroadcaster done ==="; +} + +void Proxy::stopClockBroadcaster() +{ + if (m_clockBroadcastTimer != nullptr && m_clockBroadcastTimer->isActive()) { + m_clockBroadcastTimer->stop(); + } +} + +void Proxy::broadcastClockInfo() +{ + qDebug() << "broadcastClockInfo called"; + + // Don't broadcast if client doesn't support MUME.Time + if (!isUserGmcpModuleEnabled(GmcpModuleTypeEnum::MUME_TIME)) { + qDebug() << " Client doesn't support MUME.Time, aborting"; + return; + } + + qDebug() << " Building clock data..."; + + // Get current MUME time + const MumeMoment moment = m_mumeClock.getMumeMoment(); + const auto precision = m_mumeClock.getPrecision(); + + // Get dawn/dusk hours for current month + const auto [dawnHour, duskHour] = MumeClock::getDawnDusk(moment.month); + + // Build JSON object + QJsonObject json; + json["year"] = moment.year; + json["month"] = moment.month; + json["day"] = moment.day; + json["hour"] = moment.hour; + json["minute"] = moment.minute; + + // Add precision level + switch (precision) { + case MumeClockPrecisionEnum::UNSET: + json["precision"] = "unset"; + break; + case MumeClockPrecisionEnum::DAY: + json["precision"] = "day"; + break; + case MumeClockPrecisionEnum::HOUR: + json["precision"] = "hour"; + break; + case MumeClockPrecisionEnum::MINUTE: + json["precision"] = "minute"; + break; + } + + // Add season + switch (moment.toSeason()) { + case MumeSeasonEnum::WINTER: + json["season"] = "winter"; + break; + case MumeSeasonEnum::SPRING: + json["season"] = "spring"; + break; + case MumeSeasonEnum::SUMMER: + json["season"] = "summer"; + break; + case MumeSeasonEnum::AUTUMN: + json["season"] = "autumn"; + break; + case MumeSeasonEnum::UNKNOWN: + json["season"] = "unknown"; + break; + } + + // Add time of day + switch (moment.toTimeOfDay()) { + case MumeTimeEnum::DAWN: + json["timeOfDay"] = "dawn"; + break; + case MumeTimeEnum::DAY: + json["timeOfDay"] = "day"; + break; + case MumeTimeEnum::DUSK: + json["timeOfDay"] = "dusk"; + break; + case MumeTimeEnum::NIGHT: + json["timeOfDay"] = "night"; + break; + case MumeTimeEnum::UNKNOWN: + json["timeOfDay"] = "unknown"; + break; + } + + // Add moon information + switch (moment.moonPhase()) { + case MumeMoonPhaseEnum::NEW_MOON: + json["moonPhase"] = "new_moon"; + break; + case MumeMoonPhaseEnum::WAXING_CRESCENT: + json["moonPhase"] = "waxing_crescent"; + break; + case MumeMoonPhaseEnum::FIRST_QUARTER: + json["moonPhase"] = "first_quarter"; + break; + case MumeMoonPhaseEnum::WAXING_GIBBOUS: + json["moonPhase"] = "waxing_gibbous"; + break; + case MumeMoonPhaseEnum::FULL_MOON: + json["moonPhase"] = "full_moon"; + break; + case MumeMoonPhaseEnum::WANING_GIBBOUS: + json["moonPhase"] = "waning_gibbous"; + break; + case MumeMoonPhaseEnum::THIRD_QUARTER: + json["moonPhase"] = "third_quarter"; + break; + case MumeMoonPhaseEnum::WANING_CRESCENT: + json["moonPhase"] = "waning_crescent"; + break; + case MumeMoonPhaseEnum::UNKNOWN: + json["moonPhase"] = "unknown"; + break; + } + + switch (moment.moonVisibility()) { + case MumeMoonVisibilityEnum::BRIGHT: + json["moonVisibility"] = "bright"; + break; + case MumeMoonVisibilityEnum::DIM: + json["moonVisibility"] = "dim"; + break; + case MumeMoonVisibilityEnum::INVISIBLE: + json["moonVisibility"] = "invisible"; + break; + case MumeMoonVisibilityEnum::UNKNOWN: + json["moonVisibility"] = "unknown"; + break; + } + + json["moonLevel"] = moment.moonLevel(); + json["dawnHour"] = dawnHour; + json["duskHour"] = duskHour; + json["syncEpoch"] = static_cast(m_mumeClock.getLastSyncEpoch()); + + // Convert to JSON string + const QJsonDocument doc(json); + const QString jsonString = QString::fromUtf8(doc.toJson(QJsonDocument::Compact)); + + // Create and send GMCP message + qDebug() << " Sending MUME.Time.Info:" << jsonString; + const GmcpMessage msg(GmcpMessageTypeEnum::MUME_TIME_INFO, GmcpJson{jsonString.toStdString()}); + gmcpToUser(msg); + qDebug() << " Broadcast sent"; +} + void Proxy::sendToMud(const QString &s) { // REVISIT: this bypasses game observer, but it also appears to be unused. diff --git a/src/proxy/proxy.h b/src/proxy/proxy.h index 131cbd466..f1c3460d3 100644 --- a/src/proxy/proxy.h +++ b/src/proxy/proxy.h @@ -152,6 +152,9 @@ class NODISCARD_QOBJECT Proxy final : public QObject // it outlives this object when the connection closes. QPointer m_remoteEdit; + // Clock GMCP broadcaster + QTimer *m_clockBroadcastTimer = nullptr; + enum class NODISCARD ServerStateEnum { Initialized, Offline, @@ -226,6 +229,11 @@ class NODISCARD_QOBJECT Proxy final : public QObject void gmcpToMud(const GmcpMessage &msg); void gmcpToUser(const GmcpMessage &msg); +private: + void startClockBroadcaster(); + void stopClockBroadcaster(); + void broadcastClockInfo(); + private: void sendToMud(const QString &s); void sendToUser(SendToUserSourceEnum source, const QString &ba);