您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Enhances MooMoo.io with mini-mods to level the playing field against cheaters whilst being fair to non-script users.
// ==UserScript== // @name MOOMOO.IO Utility Mod! (Scrollable Inventory, Wearables Hotbar, Typing Indicator, & More!) // @namespace https://greasyfork.org/users/137913 // @description Enhances MooMoo.io with mini-mods to level the playing field against cheaters whilst being fair to non-script users. // @license GNU GPLv3 with the condition: no auto-heal or instant kill features may be added to the licensed material. // @author TigerYT // @version 1.0.1 // @grant GM_info // @match *://moomoo.io/* // @match *://dev.moomoo.io/* // @match *://sandbox.moomoo.io/* // @icon https://www.google.com/s2/favicons?sz=64&domain=moomoo.io // @run-at document-start // ==/UserScript== /* Version numbers: A.B.C A = Added or made a major change to multiple mini-mods B = Added or made a major change to a feature (a whole mini-mod, or major parts within a mini-mod) C = Added patches */ (function() { 'use strict'; /** * Asynchronously retrieves the userscript's metadata object. * This is the recommended, universally compatible method. * * @returns {Promise<object>} A Promise that resolves with the script info object. */ const getGMInfo = async () => { // Modern API (Greasemonkey 4+) if (typeof GM !== 'undefined' && typeof GM.info === 'function') { return await GM.info; } // Legacy API (Tampermonkey, Violentmonkey, etc.) if (typeof GM_info !== 'undefined') { return GM_info; } // If neither is found, reject the promise throw new Error("getGMInfoAsync() Error: Userscript manager info object not found. Make sure you have '@grant GM_info' in your script's header."); } /** * @module Logger * @description A simple, configurable logger to prefix messages and avoid cluttering the console. * It respects the `DEBUG_MODE` flag in the main module's config. */ const Logger = { /** * Logs a standard message to the console if DEBUG_MODE is enabled. * @param {string} message - The primary message to log. * @param {...any} args - Additional arguments to pass directly to console.log. */ log: (message, ...args) => MooMooUtilityMod.config.DEBUG_MODE && console.log((args[0] && typeof args[0] == "string" && args[0].startsWith('color') ? '%c' : '') + `[Util-Mod] ${message}`, ...args), /** * Logs an informational message to the console if DEBUG_MODE is enabled. * @param {string} message - The primary message to log. * @param {...any} args - Additional arguments to pass directly to console.info. */ info: (message, ...args) => MooMooUtilityMod.config.DEBUG_MODE && console.info((args[0] && typeof args[0] == "string" && args[0].startsWith('color') ? '%c' : '') + `[Util-Mod] ${message}`, ...args), /** * Logs a warning message to the console if DEBUG_MODE is enabled. * @param {string} message - The primary message to log. * @param {...any} args - Additional arguments to pass directly to console.warn. */ warn: (message, ...args) => MooMooUtilityMod.config.DEBUG_MODE && console.warn((args[0] && typeof args[0] == "string" && args[0].startsWith('color') ? '%c' : '') + `[Util-Mod] ${message}`, ...args), /** * Logs an error message. This is always shown, regardless of the DEBUG_MODE setting. * @param {string} message - The primary message to log. * @param {...any} args - Additional arguments to pass directly to console.error. */ error: (message, ...args) => console.error((args[0] && typeof args[0] == "string" && args[0].startsWith('color') ? '%c' : '') + `[Util-Mod] ${message}`, ...args) }; /** * @module MooMooUtilityMod * @description The core of the utility mod. It manages shared state, data, network hooks, * and initializes all registered "minimods". */ const MooMooUtilityMod = { // --- CORE MOD PROPERTIES --- /** * @property {object} config - Holds user-configurable settings for the script. */ config: { /** @property {boolean} DEBUG_MODE - Set to true to see detailed logs in the console. */ DEBUG_MODE: true }, /** * @property {object} state - Holds the dynamic state of the script, changing as the user plays. */ state: { /** @property {boolean} enabled - Master toggle for the entire utility mod. Set to false to disable all features. */ enabled: true, /** @property {number} initTimestamp - The UNIX timestamp (in milliseconds) when the script was initiated. */ initTimestamp: Date.now(), /** @property {boolean} codecsReady - Tracks if the msgpack encoder and decoder instances have been successfully captured. */ codecsReady: false, /** @property {boolean} socketReady - Tracks if the game's WebSocket instance has been successfully captured. */ socketReady: false, /** @property {boolean} isSandbox - Tracks if the player is in sandbox mode, which affects item placement limits. */ isSandbox: window.location.host.startsWith('sandbox'), /** @property {WebSocket|null} gameSocket - A direct reference to the game's main WebSocket instance. */ gameSocket: null, /** @property {object|null} gameEncoder - A direct reference to the game's msgpack encoder instance. */ gameEncoder: null, /** @property {object|null} gameDecoder - A direct reference to the game's msgpack decoder instance. */ gameDecoder: null, /** @property {number} playerId - The client player's unique server-side ID, assigned upon joining a game. */ playerId: -1, /** @property {{food: number, wood: number, stone: number, gold: number}} playerResources - The player's current resource counts. */ playerResources: { food: 0, wood: 0, stone: 0, gold: 0 }, /** @property {Map<number, number>} playerPlacedItemCounts - Maps an item's limit group ID to the number of items placed from that group. */ playerPlacedItemCounts: new Map(), /** @property {boolean} playerHasRespawned - Tracks if the player has died and respawned, used to manage certain UI elements. */ playerHasRespawned: false, /** @property {Array<MutationObserver|ResizeObserver>} observers - Stores all active observers for easy disconnection and cleanup when the mod is disabled. */ observers: [], /** @property {Array<string>} focusableElementIds - A list of DOM element IDs that should block hotkeys when visible. Minimods can add to this list. */ focusableElementIds: [], }, /** * @property {object} data - Contains structured, static data about the game, such as items and packet definitions. */ data: { /** @property {Map<number, object>} _itemDataByServerId - A map for quickly looking up item data by its server-side ID. */ _itemDataByServerId: new Map(), /** @property {Map<number, object[]>} _itemDataBySlot - A map for grouping items by their action bar slot (e.g., Food, Walls, Spikes). */ _itemDataBySlot: new Map(), /** * @property {object} constants - A collection of named constants to avoid "magic values" in the code. * These are "universal" constants that multiple minimods may need access to. */ constants: { PACKET_TYPES: { USE_ITEM: 'F', EQUIP_ITEM: 'z', EQUIP_WEARABLE: 'c', CHAT: '6' }, PACKET_DATA: { WEARABLE_TYPES: { HAT: 'hat', ACCESSORY: 'accessory', }, STORE_ACTIONS: { ADD_ITEM: 'buy', UPDATE_EQUIPPED: 'equip', }, USE_ACTIONS: { START_USING: 1, STOP_USING: 0, } }, ITEM_TYPES: { PRIMARY_WEAPON: 0, SECONDARY_WEAPON: 1, FOOD: 2, WALL: 3, SPIKE: 4, WINDMILL: 5, FARM: 6, TRAP: 7, EXTRA: 8, SPAWN_PAD: 9 }, DOM: { // IDs UTILITY_MOD_STYLES: 'utilityModStyles', UTILITY_MOD_SCRIPTS: 'utilityModScripts', MENU_CONTAINER: 'menuContainer', MAIN_MENU: 'mainMenu', STORE_MENU: 'storeMenu', STORE_HOLDER: 'storeHolder', RESOURCE_DISPLAY: 'resDisplay', CHAT_HOLDER: 'chatHolder', CHAT_BOX: 'chatBox', ALLIANCE_MENU: 'allianceMenu', ACTION_BAR: 'actionBar', GAME_CANVAS: 'gameCanvas', GAME_UI: 'gameUI', DIED_TEXT: 'diedText', ENTER_GAME_BUTTON: 'enterGame', UPGRADE_HOLDER: 'upgradeHolder', UPGRADE_COUNTER: 'upgradeCounter', ITEM_INFO_HOLDER: 'itemInfoHolder', GAME_TITLE: 'gameName', LOADING_TEXT: 'loadingText', LOADING_INFO: 'loadingInfo', AD_HOLDER: 'promoImgHolder', WIDE_AD_CARD: 'wideAdCard', AD_CARD: 'adCard', RIGHT_CARD_HOLDER: 'rightCardHolder', MENU_CARD_HOLDER: 'menuCardHolder', SHUTDOWN_DISPLAY: 'shutdownDisplay', LINKS_CONTAINER: 'linksContainer2', // Selectors / Patterns / Classes ACTION_BAR_ITEM_REGEX: /^actionBarItem(\d+)$/, ACTION_BAR_ITEM_CLASS: '.actionBarItem', STORE_MENU_EXPANDED_CLASS: 'expanded', MENU_CARD_CLASS: 'menuCard', STORE_TAB_CLASS: 'storeTab', MENU_LINK_CLASS: 'menuLink', PASSTHROUGH_CLASS: 'passthrough', }, CSS: { DISPLAY_NONE: 'none', DISPLAY_FLEX: 'flex', DISPLAY_BLOCK: 'block', OPAQUE: 1, }, GAME_STATE: { INITIAL_SELECTED_ITEM_INDEX: 0, WEBSOCKET_STATE_OPEN: 1, // WebSocket.OPEN NO_SCROLL: 0, SCROLL_DOWN: 1, SCROLL_UP: -1, }, TIMEOUTS: { MANUAL_CODEC_SCAN: 2500, }, }, /** @property {object} _rawItems - The raw item database, grouped by category for readability before processing. */ _rawItems: { PRIMARY_WEAPONS: [ { id: 0, server_id: 0, name: "Tool Hammer" }, { id: 1, server_id: 1, name: "Hand Axe" }, { id: 3, server_id: 3, name: "Short Sword" }, { id: 4, server_id: 4, name: "Katana" }, { id: 5, server_id: 5, name: "Polearm" }, { id: 6, server_id: 6, name: "Bat" }, { id: 7, server_id: 7, name: "Daggers" }, { id: 8, server_id: 8, name: "Stick" }, { id: 2, server_id: 2, name: "Great Axe" }, ], SECONDARY_WEAPONS: [ { id: 9, server_id: 9, name: "Hunting Bow", cost: { wood: 4 } }, { id: 10, server_id: 10, name: "Great Hammer" }, { id: 11, server_id: 11, name: "Wooden Shield" }, { id: 12, server_id: 12, name: "Crossbow", cost: { wood: 5 } }, { id: 13, server_id: 13, name: "Repeater Crossbow", cost: { wood: 10 } }, { id: 14, server_id: 14, name: "MC Grabby" }, { id: 15, server_id: 15, name: "Musket", cost: { stone: 10 } }, ], FOOD: [ { id: 0, server_id: 16, name: "Apple", cost: { food: 10 } }, { id: 1, server_id: 17, name: "Cookie", cost: { food: 15 } }, { id: 2, server_id: 18, name: "Cheese", cost: { food: 25 } }, ], WALLS: [ { id: 3, server_id: 19, name: "Wood Wall", limitGroup: 1, limit: 30, cost: { wood: 10 } }, { id: 4, server_id: 20, name: "Stone Wall", limitGroup: 1, limit: 30, cost: { stone: 25 } }, { id: 5, server_id: 21, name: "Castle Wall", limitGroup: 1, limit: 30, cost: { stone: 35 } }, ], SPIKES: [ { id: 6, server_id: 22, name: "Spikes", limitGroup: 2, limit: 15, cost: { wood: 20, stone: 5 } }, { id: 7, server_id: 23, name: "Greater Spikes", limitGroup: 2, limit: 15, cost: { wood: 30, stone: 10 } }, { id: 8, server_id: 24, name: "Poison Spikes", limitGroup: 2, limit: 15, cost: { wood: 35, stone: 15 } }, { id: 9, server_id: 25, name: "Spinning Spikes", limitGroup: 2, limit: 15, cost: { wood: 30, stone: 20 } }, ], WINDMILLS: [ { id: 10, server_id: 26, name: "Windmill", limitGroup: 3, limit: 7, sandboxLimit: 299, cost: { wood: 50, stone: 10 } }, { id: 11, server_id: 27, name: "Faster Windmill", limitGroup: 3, limit: 7, sandboxLimit: 299, cost: { wood: 60, stone: 20 } }, { id: 12, server_id: 28, name: "Power Mill", limitGroup: 3, limit: 7, sandboxLimit: 299, cost: { wood: 100, stone: 50 } }, ], FARMS: [ { id: 13, server_id: 29, name: "Mine", limitGroup: 4, limit: 1, cost: { wood: 20, stone: 100 } }, { id: 14, server_id: 30, name: "Sapling", limitGroup: 5, limit: 2, cost: { wood: 150 } }, ], TRAPS: [ { id: 15, server_id: 31, name: "Pit Trap", limitGroup: 6, limit: 6, cost: { wood: 30, stone: 30 } }, { id: 16, server_id: 32, name: "Boost Pad", limitGroup: 7, limit: 12, sandboxLimit: 299, cost: { wood: 5, stone: 20 } }, ], EXTRAS: [ { id: 17, server_id: 33, name: "Turret", limitGroup: 8, limit: 2, cost: { wood: 200, stone: 150 } }, { id: 18, server_id: 34, name: "Platform", limitGroup: 9, limit: 12, cost: { wood: 20 } }, { id: 19, server_id: 35, name: "Healing Pad", limitGroup: 10, limit: 4, cost: { food: 10, wood: 30 } }, { id: 21, server_id: 37, name: "Blocker", limitGroup: 11, limit: 3, cost: { wood: 30, stone: 25 } }, { id: 22, server_id: 38, name: "Teleporter", limitGroup: 12, limit: 2, sandboxLimit: 299, cost: { wood: 60, stone: 60 } }, ], SPAWN_PADS: [ { id: 20, server_id: 36, name: "Spawn Pad", limitGroup: 13, limit: 1, cost: { wood: 100, stone: 100 } }, ], }, /** @property {object} _issueTemplates - Holds raw markdown for pre-filling GitHub issue bodies. */ _issueTemplates: {}, // MODIFIED: Will be populated by a fetch call. // NEW: Added URLs for fetching templates /** @property {object} _issueTemplateURLs - URLs to the raw issue templates on GitHub. */ _issueTemplateURLs: { featureRequest: 'https://raw.githubusercontent.com/TimChinye/UserScripts/main/.github/RAW_ISSUE_TEMPLATE/feature_request.md', bugReport: 'https://raw.githubusercontent.com/TimChinye/UserScripts/main/.github/RAW_ISSUE_TEMPLATE/bug_report.md' }, /** @property {object} _packetNames - Maps packet ID codes to human-readable names for logging. */ _packetNames: { 'io-init': 'Initial Connection', 'A': 'All Clans List', 'B': 'Disconnect', 'C': 'Setup Game', 'D': 'Add Player', 'E': 'Remove Player', 'G': 'Leaderboard Update', 'H': 'Load Game Objects', 'I': 'Update AI', 'J': 'Animate AI', 'K': 'Gather Animation', 'L': 'Wiggle Game Object', 'M': 'Shoot Turret', 'N': 'Update Player Value', 'O': 'Update Health', 'P': 'Client Player Death', 'Q': 'Kill Object', 'R': 'Kill Objects', 'S': 'Update Item Counts', 'T': 'Update Age', 'U': 'Update Upgrades', 'V': 'Update Items', 'X': 'Add Projectile', 'Y': 'Remove Projectile', 'Z': 'Server Shutdown Notice', 'a': 'Update Players', 'g': 'Add Alliance', '0': 'Ping Response', '1': 'Delete Alliance', '2': 'Alliance Notification', '3': 'Set Player Team', '4': 'Set Alliance Players', '5': 'Update Store Items', '6': 'Receive Chat', '7': 'Update Minimap', '8': 'Show Text', '9': 'Ping Map', }, /** @property {object} _packetFormatters - Maps packet IDs to functions that format raw packet data into structured objects for easier use and logging. */ _packetFormatters: { 'io-init': ([socketID]) => ({ socketID }), 'A': ([data]) => data, 'B': ([reason]) => ({ reason }), 'C': ([yourSID]) => ({ yourSID }), 'D': ([playerData, isYou]) => ({ id: playerData[0], sid: playerData[1], name: playerData[2], x: playerData[3], y: playerData[4], dir: playerData[5], health: playerData[6], maxHealth: playerData[7], scale: playerData[8], skinColor: playerData[9], isYou }), 'E': ([id]) => ({ id }), 'G': (data) => { const leaderboard = []; for (let i = 0; i < data.length; i += 3) leaderboard.push({ sid: data[i], name: data[i + 1], score: data[i + 2] }); return { leaderboard }; }, 'H': (data) => { const objects = []; for (let i = 0; i < data.length; i += 8) objects.push({ sid: data[i], x: data[i+1], y: data[i+2], dir: data[i+3], scale: data[i+4], type: data[i+5], itemID: data[i+6], ownerSID: data[i+7] }); return { objects }; }, 'I': (data) => { const ais = []; for (let i = 0; i < data.length; i += 7) ais.push({ sid: data[i], index: data[i+1], x: data[i+2], y: data[i+3], dir: data[i+4], health: data[i+5], nameIndex: data[i+6] }); return { ais }; }, 'J': ([sid]) => ({ sid }), 'K': ([sid, didHit, index]) => ({ sid, didHit, weaponIndex: index }), 'L': ([dir, sid]) => ({ dir, sid }), 'M': ([sid, dir]) => ({ sid, dir }), 'N': ([propertyName, value, updateView]) => ({ propertyName, value, updateView }), 'O': ([sid, newHealth]) => ({ sid, newHealth }), 'P': () => ({}), 'Q': ([sid]) => ({ sid }), 'R': ([sid]) => ({ sid }), 'S': ([groupID, count]) => ({ groupID, count }), 'T': ([xp, maxXP, age]) => ({ xp, maxXP, age }), 'U': ([points, age]) => ({ points, age }), 'V': ([items, isWeaponList]) => ({ items, isWeaponList }), 'X': ([x, y, dir, range, speed, index, layer, sid]) => ({ x, y, dir, range, speed, index, layer, sid }), 'Y': ([sid, newRange]) => ({ sid, newRange }), 'Z': ([countdown]) => ({ countdown }), 'a': (data) => { const players = []; for (let i = 0; i < data.length; i += 13) players.push({ sid: data[i], x: data[i+1], y: data[i+2], dir: data[i+3], buildIndex: data[i+4], weaponIndex: data[i+5], weaponVariant: data[i+6], team: data[i+7], isLeader: data[i+8], skinIndex: data[i+9], tailIndex: data[i+10], iconIndex: data[i+11], zIndex: data[i+12] }); return { players }; }, 'g': ([clanData]) => ({ newClan: clanData }), '0': () => ({}), '1': ([sid]) => ({ sid }), '2': ([sid, name]) => ({ sid, name }), '3': ([team, isOwner]) => ({ team, isOwner }), '4': (data) => { const members = []; for (let i = 0; i < data.length; i += 2) members.push({ sid: data[i], name: data[i+1] }); return { members }; }, '5': ([action, itemID, itemType]) => { const CoreC = window.MooMooUtilityMod.data.constants; return ({ itemType: itemType === 0 ? CoreC.PACKET_DATA.WEARABLE_TYPES.HAT : CoreC.PACKET_DATA.WEARABLE_TYPES.ACCESSORY, itemID, action: action === 0 ? CoreC.PACKET_DATA.STORE_ACTIONS.ADD_ITEM : CoreC.PACKET_DATA.STORE_ACTIONS.UPDATE_EQUIPPED }); }, '6': ([sid, message]) => ({ sid, message }), '7': (data) => ({ minimapData: data }), '8': ([x, y, value, type]) => ({ x, y, value, type }), '9': ([x, y]) => ({ x, y }) }, /** * Processes the raw item data from `_rawItems` into the lookup maps for efficient access. * This function is called once during the script's initialization. * @function * @returns {void} */ initialize() { const CoreC = this.constants; const itemTypes = { FOOD: { slot: 0, itemType: CoreC.ITEM_TYPES.FOOD }, WALLS: { slot: 1, itemType: CoreC.ITEM_TYPES.WALL }, SPIKES: { slot: 2, itemType: CoreC.ITEM_TYPES.SPIKE }, WINDMILLS: { slot: 3, itemType: CoreC.ITEM_TYPES.WINDMILL }, FARMS: { slot: 6, itemType: CoreC.ITEM_TYPES.FARM }, TRAPS: { slot: 4, itemType: CoreC.ITEM_TYPES.TRAP }, EXTRAS: { slot: 5, itemType: CoreC.ITEM_TYPES.EXTRA }, SPAWN_PADS: { slot: 7, itemType: CoreC.ITEM_TYPES.SPAWN_PAD }, PRIMARY_WEAPONS: { slot: 8, itemType: CoreC.ITEM_TYPES.PRIMARY_WEAPON }, SECONDARY_WEAPONS: { slot: 9, itemType: CoreC.ITEM_TYPES.SECONDARY_WEAPON }, }; for (const category in this._rawItems) { const { itemType, slot } = itemTypes[category]; this._rawItems[category].forEach(item => { const fullItemData = { ...item, itemType, slot, cost: { food: 0, wood: 0, stone: 0, gold: 0, ...item.cost } }; this._itemDataByServerId.set(fullItemData.server_id, fullItemData); if (!this._itemDataBySlot.has(fullItemData.slot)) { this._itemDataBySlot.set(fullItemData.slot, []); } this._itemDataBySlot.get(fullItemData.slot).push(fullItemData); }); } }, }, // --- PUBLIC UTILITY FUNCTIONS --- /** * Disables the entire utility mod, cleaning up all UI, styles, and event listeners. * @returns {void} */ disableMod() { if (!this.state.enabled) return; // Already disabled Logger.warn("Disabling MooMoo Utility Mod..."); this.state.enabled = false; // 1. Cleanup minimods first this.miniMods.forEach(mod => { if (typeof mod.cleanup === 'function') { Logger.log(`Cleaning up minimod: ${mod.name}`); try { mod.cleanup(); } catch (e) { Logger.error(`Error during cleanup of ${mod.name}:`, e); } } }); // 2. Cleanup core UI, styles, and observers const CoreC = this.data.constants; const style = document.getElementById(CoreC.DOM.UTILITY_MOD_STYLES); if (style) style.remove(); const titleElem = document.getElementById(CoreC.DOM.GAME_TITLE); if (titleElem) titleElem.innerHTML = 'MOOMOO.io'; const loadingInfo = document.getElementById(CoreC.DOM.LOADING_INFO); if (loadingInfo) loadingInfo.remove(); this.state.observers.forEach(obs => obs.disconnect()); this.state.observers.length = 0; // Clear the array // 3. Ensure all core UI element styles are unlocked this.waitForElementsToLoad({ mainMenu: CoreC.DOM.MAIN_MENU, menuCardHolder: CoreC.DOM.MENU_CARD_HOLDER, loadingText: CoreC.DOM.LOADING_TEXT, gameUI: CoreC.DOM.GAME_UI, diedText: CoreC.DOM.DIED_TEXT, }).then(elements => { this.unlockStyleUpdates("display", Object.values(elements)); }); Logger.warn("Mod disabled. Game reverted to vanilla state."); }, /** * Switches the UI to show the main menu. * @returns {void} */ goToMainMenu() { this.setUIState('showMenu'); }, /** * Switches the UI to show the in-game interface. * @returns {void} */ goToGamePlay() { this.setUIState('showGameplay'); }, /** * Extracts the server-side item ID from a DOM element's ID attribute. * @param {HTMLElement} itemElem - The action bar item element. * @returns {RegExpMatchArray|null} A match array or null. */ getItemIdFromElem(itemElem) { return itemElem.id.match(this.data.constants.DOM.ACTION_BAR_ITEM_REGEX); }, /** * Retrieves the full data object for an item from its corresponding DOM element. * @param {HTMLElement} itemElem - The action bar item element. * @returns {object|undefined} The item's data object. */ getItemFromElem(itemElem) { const match = this.getItemIdFromElem(itemElem); if (!match) return undefined; const serverItemId = parseInt(match[1]); return this.data._itemDataByServerId.get(serverItemId); }, /** * Checks if the player has sufficient resources to afford an item. * @param {object} itemData - The item's data object. * @returns {boolean} True if the player can afford the item. */ isAffordableItem(itemData) { if (!itemData || !itemData.cost) return true; // Free items are always affordable return this.state.playerResources.food >= itemData.cost.food && this.state.playerResources.wood >= itemData.cost.wood && this.state.playerResources.stone >= itemData.cost.stone; }, /** * Checks if an item element in the action bar is currently visible and represents a valid item. * @param {HTMLElement} itemElem - The action bar item element to check. * @returns {boolean} True if the item is available. */ isAvailableItem(itemElem) { const isVisible = itemElem.style.display !== this.data.constants.CSS.DISPLAY_NONE; if (!isVisible) return false; return !!this.getItemIdFromElem(itemElem); }, /** * Determines if an item can be equipped by checking its availability, affordability, and placement limits. * @param {HTMLElement} itemElem - The action bar item element to check. * @returns {boolean} True if all conditions are met. */ isEquippableItem(itemElem) { if (!this.isAvailableItem(itemElem)) return false; const itemData = this.getItemFromElem(itemElem); if (!itemData) return false; // Check 1: Resource affordability if (!this.isAffordableItem(itemData)) return false; // Check 2: Placement limit if (itemData.limitGroup) { const limit = this.state.isSandbox && itemData.sandboxLimit ? itemData.sandboxLimit : itemData.limit; const currentCount = this.state.playerPlacedItemCounts.get(itemData.limitGroup) || 0; if (currentCount >= limit) return false; } return true; // If both checks pass }, /** * Checks if a user input element is currently focused and visible. * @private * @returns {boolean} True if an input is focused. */ isInputFocused() { const CoreC = this.data.constants; const isVisible = (id) => { const elem = document.getElementById(id); return elem && window.getComputedStyle(elem).display !== CoreC.CSS.DISPLAY_NONE && window.getComputedStyle(elem).opacity == CoreC.CSS.OPAQUE; }; return this.state.focusableElementIds.some(isVisible); }, /** * Registers a DOM element ID as a "focusable" element. When this element is visible, * most hotkeys will be disabled to prevent conflicts with typing or UI interaction. * @param {string} elementId - The ID of the DOM element to register. * @returns {void} */ registerFocusableElement(elementId) { if (typeof elementId !== 'string' || !elementId) { Logger.error("registerFocusableElement: elementId must be a non-empty string."); return; } if (!this.state.focusableElementIds.includes(elementId)) { this.state.focusableElementIds.push(elementId); Logger.log(`Registered new focusable element: #${elementId}`); } }, /** * Observes an element until its computed style 'display' is not 'none'. * @param {HTMLElement} element - The HTML element to observe. * @returns {Promise<HTMLElement>} A promise that resolves with the element. */ waitForVisible(element) { if (!element) return Promise.reject(); // Define the condition check in one place to avoid repetition. const isDisplayBlock = () => window.getComputedStyle(element).display !== 'none'; // Handle the common case: If the element is already visible, resolve immediately. if (isDisplayBlock()) return Promise.resolve(element); // If not visible, return a promise that sets up the observer. return new Promise(resolve => { const observer = new MutationObserver(() => { // When any mutation occurs, re-run the check. if (isDisplayBlock()) { // Once the condition is met, clean up and resolve the promise. observer.disconnect(); resolve(element); } }); // Start observing the specific element for attribute changes observer.observe(element, { attributes: true }); this.state.observers.push(observer); }); }, /** * Waits for one or more elements to be present in the DOM. * * @overload * @param {string} elementId - The ID of the single element to wait for. * @returns {Promise<HTMLElement>} A promise that resolves with the found HTML element. * * @overload * @param {string[]} elementIds - An array of element IDs to wait for. * @returns {Promise<HTMLElement[]>} A promise that resolves with an array of the found HTML elements, in the same order as the input array. * * @overload * @param {Object<string, string>} elementMap - An object mapping variable names to element IDs. * @returns {Promise<Object<string, HTMLElement>>} A promise that resolves with an object of the found HTML elements, keyed by the provided variable names. */ waitForElementsToLoad(parameter, options = {}) { const { timeout = 5000 } = options; // Default timeout of 5 seconds. let inputType; let elementMap; // 1. Normalize Input (this part is largely the same, but slightly refined) if (typeof parameter === 'string') { inputType = 'string'; elementMap = { [parameter]: parameter }; } else if (Array.isArray(parameter)) { inputType = 'array'; // A more modern/declarative way to convert an array to a map elementMap = Object.fromEntries(parameter.map(id => [id, id])); } else if (typeof parameter === 'object' && parameter !== null && !Array.isArray(parameter)) { inputType = 'object'; elementMap = parameter; } else { return Promise.reject(new TypeError('Invalid argument. Must be a string, array of strings, or an object.')); } // 2. Core Waiting Logic (significantly improved) const corePromise = new Promise((resolve, reject) => { const foundElements = {}; // Use a Set for efficient lookup and deletion of keys we still need to find. const remainingKeys = new Set(Object.keys(elementMap)); let observer; // Declare here to be accessible in timeout const timeoutId = setTimeout(() => { observer?.disconnect(); // Stop observing on timeout const missingIds = Array.from(remainingKeys).map(key => elementMap[key]); reject(new Error(`Timed out after ${timeout}ms. Could not find elements with IDs: ${missingIds.join(', ')}`)); }, timeout); const checkElements = () => { // Only iterate over the keys of elements we haven't found yet. for (const key of remainingKeys) { const id = elementMap[key]; const element = document.getElementById(id); if (element) { foundElements[key] = element; remainingKeys.delete(key); // KEY IMPROVEMENT: Stop looking for this element. } } // If the set is empty, we've found everything. if (remainingKeys.size === 0) { clearTimeout(timeoutId); // Success, so clear the timeout. observer?.disconnect(); resolve(foundElements); } }; // Set up the observer to only call our efficient checker. observer = new MutationObserver(checkElements); // Perform an initial check in case elements are already on the page. checkElements(); // If the initial check didn't find everything, start observing. if (remainingKeys.size > 0) { observer.observe(document.body, { childList: true, subtree: true }); // Assuming 'this.state.observers' exists from your original context if (this.state && this.state.observers) { this.state.observers.push(observer); } } }); // 3. Format Output (unchanged, but now attached to the more robust promise) return corePromise.then(foundElements => { switch (inputType) { case 'string': return Object.values(foundElements)[0]; case 'array': return parameter.map(id => foundElements[id]); case 'object': return foundElements; default: throw new Error(`Internal Error: Unhandled inputType "${inputType}" in waitForElementsToLoad.`); } }); }, /** * Locks a CSS style property on an array of elements, preventing it from being * changed by JavaScript. A 'data-locked-styles' attribute is added to the element * for easy inspection, acting as the single source of truth for the lock state. * * @param {string} propertyName - The name of the style property to lock. * @param {HTMLElement[]} elements - An array of HTMLElements to affect. * @returns {void} */ lockStyleUpdates(propertyName, elements) { if (!Array.isArray(elements)) { console.error("Failed to lock style: `elements` must be an array."); return; } elements.forEach(element => { if (!(element instanceof HTMLElement)) { console.warn("Skipping item because it is not a valid HTMLElement:", element); return; } const lockedStyles = (element.getAttribute('data-locked-styles') || '').split(',').filter(Boolean); if (lockedStyles.includes(propertyName)) { return; // This property is already locked. } const styleObj = element.style; // Capture the value at the moment of locking. let currentValue = styleObj[propertyName]; Object.defineProperty(styleObj, propertyName, { // This MUST be true so we can 'delete' it later to unlock. configurable: true, enumerable: true, get() { return currentValue; }, set(newValue) { console.warn(`Blocked attempt to set locked property "${propertyName}" to "${newValue}" on`, element); // The set operation is completely ignored. } }); // Update the visible HTML attribute. lockedStyles.push(propertyName); element.setAttribute('data-locked-styles', lockedStyles.join(',')); }); }, /** * Unlocks a CSS style property on an array of elements, allowing to be changed by JavaScript. The 'data-locked-styles' * attribute is updated or removed. * * @param {string} propertyName - The name of the style property to unlock. * @param {HTMLElement[]} elements - An array of HTMLElements to affect. * @returns {void} */ unlockStyleUpdates(propertyName, elements) { if (!Array.isArray(elements)) { console.error("Failed to unlock style: `elements` must be an array."); return; } elements.forEach(element => { if (!(element instanceof HTMLElement)) { return; } const lockedStylesAttr = element.getAttribute('data-locked-styles'); if (!lockedStylesAttr || !lockedStylesAttr.includes(propertyName)) { return; // This property isn't locked on this element. } // --- The Key Step: Delete the override --- // This removes our custom get/set and reverts to the default prototype behavior. delete element.style[propertyName]; // Update the visible HTML attribute. const updatedLockedStyles = lockedStylesAttr.split(',').filter(p => p !== propertyName); if (updatedLockedStyles.length > 0) { element.setAttribute('data-locked-styles', updatedLockedStyles.join(',')); } else { element.removeAttribute('data-locked-styles'); } }); }, /** * Returns a Promise that resolves on the next animation frame. * @returns {Promise<void>} */ waitTillNextFrame() { return new Promise(resolve => requestAnimationFrame(resolve)); }, /** * Returns a Promise that resolves after a specified delay. * @param {number} ms - The delay in milliseconds. * @returns {Promise<void>} */ wait(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }, // --- CORE INTERNAL FUNCTIONS --- /** * A simple parser for the navigator.userAgent string. * @private * @returns {{name: string, version: string}} */ _getBrowserInfo() { const ua = navigator.userAgent; let match = ua.match(/(opera|chrome|safari|firefox|msie|trident(?=\/))\/?\s*(\d+)/i) || []; let temp; if (/trident/i.test(match[1])) { temp = /\brv[ :]+(\d+)/g.exec(ua) || []; return { name: 'IE', version: temp[1] || '' }; } if (match[1] === 'Chrome') { temp = ua.match(/\b(OPR|Edge)\/(\d+)/); if (temp != null) return { name: temp[1].replace('OPR', 'Opera'), version: temp[2] }; } match = match[2] ? [match[1], match[2]] : [navigator.appName, navigator.appVersion, '-?']; if ((temp = ua.match(/version\/(\d+)/i)) != null) match.splice(1, 1, temp[1]); return { name: match[0], version: match[1] }; }, /** * Encodes and sends a packet to the game server. * @param {string} type - The one-character packet identifier. * @param {any[]} data - The payload data for the packet. * @returns {void} */ sendGamePacket(type, data) { if (!this.state.enabled) return; // Already disabled, no need to proceed. const CoreC = this.data.constants; try { if (this.state.gameSocket && this.state.gameSocket.readyState === CoreC.GAME_STATE.WEBSOCKET_STATE_OPEN) { this.state.gameSocket.send(this.state.gameEncoder.encode([type, data])); } } catch (err) { Logger.error(`Failed to send packet [${type}]`, err); } }, /** * Intercepts and processes incoming WebSocket messages to track game state changes. * @param {MessageEvent} event - The WebSocket message event containing the raw game data. * @returns {void} */ handleSocketMessage(event) { if (!this.state.enabled || !this.state.gameDecoder) return; // Already disabled or already set up, no need to proceed. try { const [packetID, ...argsArr] = this.state.gameDecoder.decode(new Uint8Array(event.data)); const args = argsArr[0]; // The game nests args in another array for some reason const packetName = this.data._packetNames[packetID] || 'Unknown Packet'; const packetData = this.data._packetFormatters[packetID] ? this.data._packetFormatters[packetID](args) : { rawData: args }; // Dispatch the packet to all minimods this.miniMods.forEach(mod => { if (typeof mod.onPacket === 'function') { mod.onPacket(packetName, packetData, args); } }); switch (packetName) { case 'Client Player Death': { if (this.state.playerHasRespawned); // Do nothing else this.state.playerHasRespawned = true break; } case 'Server Shutdown Notice': { const { countdown } = packetData; const CoreC = this.data.constants; const shutdownDisplay = document.getElementById(CoreC.DOM.SHUTDOWN_DISPLAY); if (countdown < 0 || !shutdownDisplay) return; var minutes = Math.floor(countdown / 60); var seconds = countdown % 60; seconds = ("0" + seconds).slice(-2); shutdownDisplay.innerText = "Server restarting in " + minutes + ":" + seconds; shutdownDisplay.hidden = false; break; } } if (this.config.DEBUG_MODE) { // --- Log Every Packet --- /* Ignore List (mostly due to spam): { 'I': 'All Animals / NPCs State Update', 'a': 'All Players State Update', '0': 'Ping', '7': 'Unknown Periodic Event' 'H': 'Create Map Objects', 'G': 'Leaderboard Update', 'K': 'Player Attack Animation', 'L': 'Object Damaged', 'T': 'Player XP Update / Age Up', } */ // These four periodically spam, very quickly too. // const ignoredPackets = ['I', 'a', '0', '7', 'Z']; // Some of these are period, some aren't, all are very frequent. const ignoredPackets = ['I', 'a', '0', '7', 'Z', 'H', 'G', 'K', 'L', 'T']; if (ignoredPackets.includes(packetID.toString())) return; // Other people get hurt / heal around you quite often, it's a little annoying: // if (packetID.toString() === 'O' && packetData.playerID !== this.state.playerId) return; const dataString = Object.keys(packetData).length > 0 ? JSON.stringify(packetData) : '{}'; Logger.log(`Packet: ${packetName} (${packetID}) -> ${dataString}`, args); } } catch (e) { /* Ignore decoding errors for packets we don't care about */ if (this.config.DEBUG_MODE) Logger.error("Failed to decode packet:", event, e); } }, // --- INITIALIZATION & HOOKING --- // NEW: Fetches issue templates from GitHub to ensure they are always up-to-date. async getIssueTemplates() { Logger.log("Fetching issue templates from GitHub..."); const urls = this.data._issueTemplateURLs; try { // Fetch both templates concurrently for speed const [featureText, bugText] = await Promise.all([ fetch(urls.featureRequest).then(res => res.ok ? res.text() : ''), fetch(urls.bugReport).then(res => res.ok ? res.text() : '') ]); this.data._issueTemplates.featureRequest = featureText; this.data._issueTemplates.bugReport = bugText; if (featureText && bugText) { Logger.log("Successfully fetched issue templates.", "color: #4CAF50;"); } else { Logger.warn("One or more issue templates failed to load. Links will fall back to default."); } } catch (error) { Logger.error("Failed to fetch issue templates:", error); // Ensure the templates object is clean on error this.data._issueTemplates = { featureRequest: '', bugReport: '' }; } }, /** * Collects and injects CSS from the core mod and all registered mini-mods. * @returns {void} */ injectCSS() { const CoreC = this.data.constants; const allCSS = []; // Add core CSS const coreCSS = this.applyCoreCSS().trim(); if (coreCSS) { allCSS.push('/* --- Injecting Core Mod CSS --- */\n' + coreCSS); } // Add minimod CSS this.miniMods.forEach(mod => { if (mod && typeof mod.applyCSS === 'function') { const modCSS = mod.applyCSS().trim(); if (modCSS) { allCSS.push('/* --- Injecting "' + (mod.name || 'Unnamed Mod') + '" MiniMod CSS --- */\n' + modCSS); } } }); if (allCSS.length > 0) { const style = document.createElement('style'); style.id = CoreC.DOM.UTILITY_MOD_STYLES; style.textContent = allCSS.join('\n\n/* --- CSS Separator --- */\n\n'); document.head.append(style); Logger.log(`Injected CSS from core and ${this.miniMods.filter(m => typeof m.applyCSS === 'function' && m.applyCSS().trim()).length} mini-mod(s).`, "color: #4CAF50;"); } else { Logger.log("No CSS to inject."); } }, /** * Provides the CSS styles to be applied for the core mod. * @returns {string} The CSS string. */ applyCoreCSS() { const CoreC = this.data.constants; return ` #${CoreC.DOM.GAME_TITLE} { --text-shadow-colour: oklch(from currentColor calc(l * 0.82) c h); text-shadow: 0 1px 0 var(--text-shadow-colour), 0 2px 0 var(--text-shadow-colour), 0 3px 0 var(--text-shadow-colour), 0 4px 0 var(--text-shadow-colour), 0 5px 0 var(--text-shadow-colour), 0 6px 0 var(--text-shadow-colour), 0 7px 0 var(--text-shadow-colour), 0 8px 0 var(--text-shadow-colour), 0 9px 0 var(--text-shadow-colour); & > span { color: oklch(0.95 0.05 92.5); } } #${CoreC.DOM.LOADING_INFO} { color: #fff; text-align: center; font-size: 22.5px; } #${CoreC.DOM.AD_HOLDER} { display: block; & > .${CoreC.DOM.MENU_CARD_CLASS} { margin: 0; } } button.${CoreC.DOM.MENU_LINK_CLASS} { color: #a56dc8; text-decoration: none; background: none; border: none; padding: 0; cursor: pointer; &:hover { color: #795094; } } #${CoreC.DOM.MAIN_MENU}.${CoreC.DOM.PASSTHROUGH_CLASS} { pointer-events: none; #${CoreC.DOM.MENU_CONTAINER} ~ div { pointer-events: auto; } } `; }, /** * Updates the main menu screen. * @returns {void} */ updateMainMenu() { const CoreC = this.data.constants; this.waitForElementsToLoad(CoreC.DOM.GAME_TITLE).then((titleElem) => { if (!this.state.enabled || !window.gmInfo) return; titleElem.innerHTML = `MOOMOO<span>.</span>io`; const linksContainer = document.getElementById(CoreC.DOM.LINKS_CONTAINER); // --- MODIFIED: Dynamic Link Generation --- const gmInfo = window.gmInfo; const featureTemplate = this.data._issueTemplates.featureRequest; const bugTemplate = this.data._issueTemplates.bugReport; // 1. Define fallback URLs let featureRequestURL = 'https://github.com/TimChinye/UserScripts/issues/new?template=feature_request.md'; let bugReportURL = 'https://github.com/TimChinye/UserScripts/issues/new?template=bug_report.md'; // 2. If templates were fetched successfully, create pre-filled URLs if (featureTemplate && bugTemplate) { const scriptNameVersion = `${gmInfo.script.name} (v${gmInfo.script.version})`; const browserInfo = this._getBrowserInfo(); const environmentDetails = ` - **Browser Name:** <!-- (Required) e.g; Chrome, Firefox, Edge, Safari --> > ${browserInfo.name} - **Browser Version:** <!-- (Optional) e.g; 125.0 --> > ${browserInfo.version} - **Userscript Manager Name:** <!-- (Optional) e.g; Tampermonkey, Violentmonkey --> > ${gmInfo.scriptHandler} - **Userscript Manager Version:** <!-- (Optional) e.g; 5.1.1 --> > ${gmInfo.version} `.trim(); const featureBody = featureTemplate.replace('{{ SCRIPT_NAME_VERSION }}', scriptNameVersion); const bugBody = bugTemplate.replace('{{ SCRIPT_NAME_VERSION }}', scriptNameVersion).replace('{{ ENVIRONMENT_DETAILS }}', environmentDetails); featureRequestURL += `&body=${encodeURIComponent(featureBody)}`; bugReportURL += `&body=${encodeURIComponent(bugBody)}`; } // 3. Inject the final HTML with the correct URLs linksContainer.insertAdjacentHTML('beforebegin', ` <div id="linksContainer1"> <a href="https://greasyfork.org/en/scripts/463689/feedback" target="_blank" class="menuLink">Share Thoughts</a> | <a href="${featureRequestURL}" target="_blank" class="menuLink">Got an idea?</a> | <a href="${bugReportURL}" target="_blank" class="menuLink">Report a Bug</a> | <a href="https://github.com/TimChinye/UserScripts/commits/main/MooMoo.io%20Utility%20Mod/script.user.js" target="_blank" class="menuLink">v${gmInfo.script.version}</a> </div> `); linksContainer.firstElementChild.insertAdjacentHTML('afterend', ' | <a href="https://frvr.com/browse" target="_blank" class="menuLink">Other Games</a>'); }); }, /** * Manages the visibility of core game UI screens. * @param {'showError' | 'showGameplay' | 'showMenu'} state - The UI state to display. * @returns {void} */ setUIState(state) { const CoreC = this.data.constants; const elementIds = { mainMenu: CoreC.DOM.MAIN_MENU, menuCardHolder: CoreC.DOM.MENU_CARD_HOLDER, loadingText: CoreC.DOM.LOADING_TEXT, gameUI: CoreC.DOM.GAME_UI, diedText: CoreC.DOM.DIED_TEXT, }; this.waitForElementsToLoad(elementIds).then(elementsMap => { const domElements = Object.values(elementsMap); // Ensure styles are unlocked before changing them. this.unlockStyleUpdates("display", domElements); // Reset all to a blank slate. domElements.forEach(el => el.style.display = 'none'); elementsMap.mainMenu.classList.remove(CoreC.DOM.PASSTHROUGH_CLASS); // Show only the elements necessary for each screen switch (state) { case 'showMenu': elementsMap.mainMenu.style.display = 'block'; elementsMap.menuCardHolder.style.display = 'block'; break; case 'showGameplay': elementsMap.gameUI.style.display = 'block'; elementsMap.menuCardHolder.style.display = 'block'; break; case 'showError': elementsMap.mainMenu.style.display = 'block'; elementsMap.loadingText.style.display = 'block'; elementsMap.mainMenu.classList.add(CoreC.DOM.PASSTHROUGH_CLASS); if (this.state.enabled) { // Disable updating the element display types this.lockStyleUpdates("display", domElements); // Provide useful info to the user. const loadingInfo = document.getElementById(CoreC.DOM.LOADING_INFO); if (loadingInfo) elementsMap.loadingText.childNodes[0].nodeValue = `Re-attempting Connection...`; } break; default: Logger.error(`Invalid UI state provided: ${state}`); break; } }); }, /** * Updates the loading/error UI screen with a message to provide feedback during connection attempts. * @private * @param {string} message - The message to display. * @returns {void} */ _updateLoadingUI(message) { const CoreC = this.data.constants; // Inject custom info element for the reload logic const getLoadingInfoElem = () => document.getElementById(CoreC.DOM.LOADING_INFO); const menuContainer = document.getElementById(CoreC.DOM.MENU_CONTAINER); if (menuContainer && !getLoadingInfoElem()) { menuContainer.insertAdjacentHTML('beforeend', `<div id="${CoreC.DOM.LOADING_INFO}" style="display: none;"><br>${message}<br></div>`); const loadingText = document.getElementById(CoreC.DOM.LOADING_TEXT); const syncDisplayCallback = () => { const newDisplayStyle = window.getComputedStyle(loadingText).display; const loadingInfo = getLoadingInfoElem(); if (loadingInfo && loadingInfo.style.display !== newDisplayStyle) { loadingInfo.style.display = newDisplayStyle; } }; const observer = new MutationObserver(syncDisplayCallback); observer.observe(loadingText, { attributes: true, attributeFilter: ['style'] }); this.state.observers.push(observer); } }, /** * Handles the scenario where the script fails to hook codecs and prompts for a reload. * If reload prompt is cancelled, disables mod. * @private * @param {boolean} [afterGameEnter=false] - If true, indicates failure happened after entering the game. * @returns {Promise<void>} */ async handleHookFailureAndReload(afterGameEnter = false) { if (!this.state.enabled) return; // Already disabled, no need to proceed. const CoreC = this.data.constants; const { gameUI, mainMenu } = await this.waitForElementsToLoad({ gameUI: CoreC.DOM.GAME_UI, mainMenu: CoreC.DOM.MAIN_MENU }); if (afterGameEnter) await this.waitForVisible(gameUI); else await this.waitForVisible(mainMenu); Logger.error("All hooking methods failed. The script cannot function. Reloading..."); this._updateLoadingUI("Couldn't intercept in time. May be a network issue. Try not entering the game so fast."); this.setUIState('showError'); await this.wait(5000); const loadingInfo = document.getElementById(CoreC.DOM.LOADING_INFO); if (loadingInfo) { loadingInfo.append("If you cancel, you can play the game as normal - without the mod enabled."); await this.waitTillNextFrame(); await this.waitTillNextFrame(); } if (afterGameEnter || window.confirm("Are you sure you want to reload?")) window.location.reload(); // User cancelled the reload. Disable the mod and restore the UI - play like normal. Logger.warn("User cancelled reload. Disabling mod."); this.disableMod(); if (afterGameEnter) { this.setUIState('showGameplay'); } else { this.setUIState('showMenu'); } }, /** * Checks if codecs and WebSocket are ready and performs final setup. * @private * @returns {void} */ attemptFinalSetup() { if (!this.state.enabled || !this.state.codecsReady || !this.state.socketReady) return; // Already disabled or already set up, no need to proceed. Logger.log("Codecs and WebSocket are ready. Attaching all listeners.", "color: #ffb700;"); this.state.gameSocket.addEventListener('message', this.handleSocketMessage.bind(this)); this.miniMods.forEach(mod => { if (typeof mod.addEventListeners === 'function') mod.addEventListeners(); }); }, /** * Finds game's msgpack instances by hooking into Object.prototype. * @private * @param {string} propName - The unique property name to watch for. * @param {Function} onFound - The callback to execute when the object is found. * @returns {void} */ hookIntoPrototype(propName, onFound) { if (!this.state.enabled) return; // Already disabled, no need to proceed. Logger.log(`Setting up prototype hook for: ${propName}`); if (this.state?.codecsReady) return Logger.log("Both codecs found already, cancelling prototype hooks method.", "color: #4CAF50;"); const originalDesc = Object.getOwnPropertyDescriptor(Object.prototype, propName); Object.defineProperty(Object.prototype, propName, { set(value) { if (!MooMooUtilityMod.state.enabled) return; // Already disabled, no need to proceed. if (MooMooUtilityMod.state.codecsReady) return Logger.log("Both codecs found already, cancelling prototype hooks method.", "color: #4CAF50;"); // Restore the prototype to its original state *before* doing anything else. // This prevents unexpected side effects and race conditions within the hook itself. if (originalDesc) { Object.defineProperty(Object.prototype, propName, originalDesc); } else { delete Object.prototype[propName]; } // Now, apply the value to the current instance. this[propName] = value; // Check if this is the object we are looking for and trigger the callback. // We check for the function's existence to be more certain. const isFoundCodec = (targetPropName, codecOperation) => propName === targetPropName && typeof codecOperation === 'function'; if (isFoundCodec("initialBufferSize", this.encode) || isFoundCodec("maxStrLength", this.decode)) { Logger.log(`Hook successful for "${propName}". Object found.`, "color: #4CAF50;"); onFound(this); } }, configurable: true, }); }, /** * Sets up hooks to capture the game's msgpack encoder and decoder instances. * @private * @returns {void} */ initializeHooks() { // Set up prototype hooks for both encoder and decoder const onCodecFound = () => { if (this.state.gameEncoder && this.state.gameDecoder) { Logger.log(`Both msgpack codecs found via prototype hooks. ${Date.now() - this.state.initTimestamp}ms`, "color: #4CAF50;"); this.state.codecsReady = true; this.attemptFinalSetup(); } }; this.hookIntoPrototype("initialBufferSize", (obj) => { this.state.gameEncoder = obj; onCodecFound(); }); this.hookIntoPrototype("maxStrLength", (obj) => { this.state.gameDecoder = obj; onCodecFound(); }); }, /** * Intercepts and modifies the game's main script to expose codecs. * @private * @returns {void} */ interceptGameScript() { if (!this.state.enabled) return; // Already disabled, no need to proceed. Logger.log("Attempting to intercept and modify the game script..."); const CoreC = this.data.constants; const SCRIPT_SELECTOR = "/assets/index-eb87bff7.js"; const ENCODER_REGEX = /(this\.initialBufferSize=\w,)/; const ENCODER_EXPOSURE = `$1 (typeof Logger !== 'undefined' && Logger.log("✅ CAPTURED ENCODER!")), window.gameEncoder = this,`; const DECODER_REGEX = /(this\.maxStrLength=\w,)/; const DECODER_EXPOSURE = `$1 (typeof Logger !== 'undefined' && Logger.log("✅ CAPTURED DECODER!")), window.gameDecoder = this,`; /** * Attempts to find and modify the game script to expose the codecs. * If found, it disconnects the observer to prevent further attempts. * @param {MutationObserver} [observer] - The MutationObserver instance to disconnect if the script is found. */ const leaveBackdoorOpen = (observer) => { if (!this.state.enabled) return; // Already disabled, no need to proceed. if (this.state?.codecsReady) return Logger.log("Both codecs found already, cancelling prototype hooks method.", "color: #4CAF50;"); const targetScript = document.querySelector(`script[src*="${SCRIPT_SELECTOR}"]`); if (targetScript) { if (observer) observer.disconnect(); Logger.log(`Found game script: ${targetScript.src}`); targetScript.type = 'text/plain'; // Neutralize the original script fetch(targetScript.src) .then(res => res.text()) .then(scriptText => { if (!this.state.enabled) return; // Already disabled, no need to proceed. if (this.state?.codecsReady) return Logger.log("Both codecs found already, cancelling prototype hooks method.", "color: #4CAF50;"); let modifiedScript = scriptText .replace(/(customElements\.define\("altcha-widget".*"verify"\],!1\)\);)/, '') .replace(ENCODER_REGEX, ENCODER_EXPOSURE) .replace(DECODER_REGEX, DECODER_EXPOSURE); if (!modifiedScript.includes("window.gameEncoder") || !modifiedScript.includes("window.gameDecoder")) return Logger.error("Script injection failed! Regex patterns did not match."); const newScript = document.createElement('script'); newScript.id = CoreC.DOM.UTILITY_MOD_SCRIPTS; newScript.textContent = modifiedScript; // This is the function we want to run once the DOM is ready. const injectAndFinalize = () => { if (!this.state.enabled) return; // Already disabled, no need to proceed. if (this.state?.codecsReady) return Logger.log("Both codecs found already, cancelling prototype hooks method.", "color: #4CAF50;"); // Make sure this only runs once, in case of any edge cases. if (document.body.contains(newScript)) return; document.head.append(newScript); targetScript.remove(); Logger.log("Modified game script injected.", "color: #4CAF50;"); // Verify capture and finalize setup // Use setTimeout to allow the newly injected script to execute and populate the window object. setTimeout(() => { if (window.gameEncoder && window.gameDecoder) { Logger.log(`Codec interception successful! ${Date.now() - this.state.initTimestamp}ms`, "color: #4CAF50; font-weight: bold;"); this.state.gameEncoder = window.gameEncoder; this.state.gameDecoder = window.gameDecoder; this.state.codecsReady = true; this.attemptFinalSetup(); } else { Logger.error("Codecs were not found on window after injection."); } }, 0); }; // Check if the DOM is already loaded if (document.readyState === 'loading') { // DOM is not ready yet, so wait for the event document.addEventListener('DOMContentLoaded', injectAndFinalize); } else { // DOM is already ready, so execute the function immediately injectAndFinalize(); } }) .catch(err => { Logger.error("Failed to fetch or process game script:", err); }); } else { /* Fail silently */ }; } const observer = new MutationObserver((mutations, obs) => leaveBackdoorOpen(obs)); observer.observe(document.documentElement, { childList: true, subtree: true }); this.state.observers.push(observer); }, /** * Sets up a WebSocket proxy to capture the game's connection instance. * @private * @returns {void} */ setupWebSocketProxy() { const originalWebSocket = window.WebSocket; window.WebSocket = new Proxy(originalWebSocket, { construct: (target, args) => { const wsInstance = new target(...args); if (this.state.enabled) { if (this.state.gameEncoder && this.state.gameDecoder) { this.state.gameSocket = wsInstance; this.state.socketReady = true; Logger.log("Game WebSocket instance captured."); window.WebSocket = originalWebSocket; // Restore immediately this.attemptFinalSetup(); } else { // A final check. If by the time the WS is created NO method has worked, fail. console.error("WebSocket created but codecs were not found. All hooking methods have failed."); this.handleHookFailureAndReload(true); } } return wsInstance; } }); }, /** * Runs once the player has spawned, notifying minimods that the game is fully ready. * @private * @returns {void} */ onGameReady() { if (!this.state.enabled) return; // Already disabled, no need to proceed. try { // Notify minimods that the game is ready this.miniMods.forEach(mod => { if (typeof mod.onGameReady === 'function') mod.onGameReady(); }); const shutdownDisplay = document.getElementById(this.data.constants.DOM.SHUTDOWN_DISPLAY); if (shutdownDisplay) shutdownDisplay.hidden = false; } catch(e) { Logger.error("Failed during onGameReady setup.", e); } }, /** * The main entry point for the script. * @returns {void} */ init() { // Exposes the logger to the global window object for debugging purposes. window.Logger = Logger; getGMInfo().then((gmInfo) => { Logger.log(`--- MOOMOO.IO Utility Mod (v${gmInfo.script.version}) Initializing ---`, "color: #ffb700; font-weight: bold;"); window.gmInfo = gmInfo; }) // Attempts to find codecs by modifying the game script directly to open a backdoor. this.interceptGameScript(); // Typically succeeds 0.025x slower than mainMenu. // Set up hooks to intercept codecs as they enter the global scope. this.initializeHooks(); // Typically succeeds 0.5x slower than mainMenu. // Set up WebSocket proxy to capture the game's WebSocket instance. this.setupWebSocketProxy(); const CoreC = this.data.constants; // If codecs aren't found within a reasonable amount of time, assume failure and prompt for reload. this.waitForElementsToLoad({ mainMenu: this.data.constants.DOM.MAIN_MENU }).then(({ mainMenu }) => { // We use time until main menu is loaded & visible, to get a good baseline for CPU/Network speeds. this.waitForVisible(mainMenu).then(() => { setTimeout(() => { if (!this.state.enabled || this.state.codecsReady) return; // Already disabled Logger.error("Hooks failed to find codecs within the time limit."); this.handleHookFailureAndReload(); }, ((Date.now() - this.state.initTimestamp) + 250) * 2.5); // If no success after 1.5x the mainMenu, assume failure. }); }); // Initialize item data and lookups this.data.initialize(); // Inject styles immediately, as document.head is available early. this.injectCSS(); // Wait for the body to load, and get issue templates before trying to update main menu. this.getIssueTemplates().then(() => { this.updateMainMenu(); }); this.state.focusableElementIds = [CoreC.DOM.CHAT_HOLDER, CoreC.DOM.STORE_MENU, CoreC.DOM.ALLIANCE_MENU]; // Initialize all registered minimods this.miniMods.forEach(mod => { if (typeof mod.init === 'function') { Logger.log(`Initializing minimod: ${mod.name || 'Unnamed Mod'}`); try { mod.init(); } catch (e) { Logger.error(`Error during init of ${mod.name || 'Unnamed Mod'}:`, e); } } }); // Exposes the core to the global window object for debugging purposes. window.MooMooUtilityMod = this; }, // --- MINI-MOD MANAGEMENT --- /** @property {Array<object>} miniMods - A list of all registered sub-modules (minimods). */ miniMods: [], /** * Adds a mini-mod to the system. * @param {object} mod - The mini-mod object to register. * @returns {void} */ registerMod(mod) { this.miniMods.push(mod); mod.core = this; // Give the minimod a reference to the core Logger.log(`Registered minimod: ${mod.name || 'Unnamed Mod'}`); } }; /** * @module SettingsManagerMiniMod * @description Manages loading, saving, and displaying a UI for all mod settings. */ const SettingsManagerMiniMod = { /** @property {object|null} core - A reference to the core module, set upon registration. */ core: null, /** @property {string} name - The display name of the minimod. */ name: "Settings Manager", /** @property {object} constants - Constants specific to this minimod. */ constants: { LOCALSTORAGE_KEY: 'MooMooUtilMod_Settings', DOM: { MOD_CARD: 'modCard', LEFT_CARD_HOLDER: 'leftCardHolder', SETTINGS_LINK: 'settingsLink', CAPTCHA_INPUT: 'altcha', OTHER_INPUTS: 'otherInputs', SETTINGS_ICON: 'settingsIcon', SETTINGS_CATEGORY_CLASS: 'settings-category', SETTING_ITEM_CLASS: 'setting-item', SETTING_ITEM_CONTROL_CLASS: 'setting-item-control', KEYBIND_INPUT_CLASS: 'keybind-input', RESET_SETTING_BTN_CLASS: 'reset-setting-btn', RESET_ALL_BUTTON_CLASS: 'reset-all-button', }, TEXT: { MOD_SETTINGS_HEADER: 'Gameplay Settings', KEYBIND_FOCUS_TEXT: '...', RESET_ALL_CONFIRM: 'Are you sure you want to reset all mod settings to their defaults? The page will reload.', RESET_ALL_BUTTON_TEXT: 'Rexset All Settings', RESET_BUTTON_TITLE: 'Reset to default', RESET_BUTTON_TEXT: 'Reset', }, }, /** @property {object} state - Dynamic state for this minimod. */ state: { /** @property {object} savedSettings - Settings loaded from localStorage. */ savedSettings: {}, /** @property {object} defaultSettings - A temporary store of default settings for the reset feature. */ defaultSettings: {}, }, /** @property {object} config - Holds user-configurable settings. */ config: { /** @property {boolean} isPanelVisible - Toggle whether or not to show/hide the panel. */ isPanelVisible: true, }, // --- MINI-MOD LIFECYCLE & HOOKS --- /** * Initializes the settings panel creation. * @returns {void} */ init() { this.loadSettings(); this.createAndInjectSettingsCard(); this.applySettingsToAllMods(); }, /** * Returns the CSS rules required for styling the settings panel. * @returns {string} The complete CSS string. */ applyCSS() { const LocalC = this.constants; const CoreC = this.core.data.constants; return ` #${LocalC.DOM.LEFT_CARD_HOLDER} { display: inline-block; vertical-align: top; } :is(#${LocalC.DOM.LEFT_CARD_HOLDER}, #${CoreC.DOM.RIGHT_CARD_HOLDER}) > .${CoreC.DOM.MENU_CARD_CLASS} { min-height: 250px; } #${LocalC.DOM.MOD_CARD} { max-height: 250px; overflow-y: auto; -webkit-overflow-scrolling: touch; } #${LocalC.DOM.MOD_CARD} > .menuHeader { margin-bottom: 15px; } #${LocalC.DOM.MOD_CARD} .${LocalC.DOM.SETTINGS_CATEGORY_CLASS} { margin-bottom: 20px; & .menuHeader { font-size: 20px; color: #4a4a4a; } } #${LocalC.DOM.MOD_CARD} .${LocalC.DOM.SETTING_ITEM_CLASS} { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; font-size: 18px; /* Matched to game's .settingRadio */ color: #a8a8a8; } #${LocalC.DOM.MOD_CARD} .${LocalC.DOM.SETTING_ITEM_CONTROL_CLASS} { display: flex; align-items: center; gap: 8px; /* Space between input and reset button */ } #${LocalC.DOM.MOD_CARD} .${LocalC.DOM.SETTING_ITEM_CLASS} label { line-height: 1.2; width: 180px; color: #777777; & small { display: block; font-size: 14px; color: #c0c0c0; } } /* Match game's native input look */ #${LocalC.DOM.MOD_CARD} input[type="text"], #${LocalC.DOM.MOD_CARD} input[type="number"] { text-align: center; font-size: 14px; padding: 4px; border: none; outline: none; box-sizing: border-box; color: #4A4A4A; background-color: #e5e3e3; width: calc(8px + 3ch + 20px); border-radius: 4px; &[type="number"] { text-align: start; padding-left: 8px; } } #${LocalC.DOM.MOD_CARD} .${LocalC.DOM.KEYBIND_INPUT_CLASS} { cursor: pointer; text-transform: uppercase; &:focus { background-color: #d0d0d0; } } #${LocalC.DOM.MOD_CARD} .${LocalC.DOM.RESET_SETTING_BTN_CLASS} { font-size: 14px; color: #d0635c; cursor: pointer; &:hover { color: #984742; text-decoration: underline; } } /* Button styling to match game's .menuButton */ #${LocalC.DOM.MOD_CARD} .${LocalC.DOM.RESET_ALL_BUTTON_CLASS} { font-size: 18px; padding: 5px; margin-top: 10px; background-color: #f75d59; /* Red for reset/danger */ &:hover { background-color: #ea6b64; } } #${LocalC.DOM.OTHER_INPUTS} { height: 100%; display: flex; align-items: center; gap: 9px; > #${LocalC.DOM.SETTINGS_ICON} { display: block; height: 44px; aspect-ratio: 1 / 1; background: url('https://raw.githubusercontent.com/TimChinye/UserScripts/c76a1b7552434f093774949cfcbf4f57c37b6fdd/MooMoo.io%20Utility%20Mod/settings-icon.svg') center / 100% no-repeat; background-clip: border-box; opacity: 0.5; cursor: pointer; border: 4.5px solid transparent; &:hover { filter: opacity(0.75); } & ~ #altcha { flex: 1; order: -1; } } } `; }, /** * Cleans up all UI created by this minimod. * @returns {void} */ cleanup() { const LocalC = this.constants; // Restore the original structure this.config.isPanelVisible = false; this.removeSettingsCard(); const rightCardHolder = document.getElementById(LocalC.DOM.RIGHT_CARD_HOLDER); if (leftCardHolder) rightCardHolder.querySelector('.menuHeader:has(+ .settingRadio)').textContent = 'Settings'; // Remove the settings panel card const leftCardHolder = document.getElementById(LocalC.DOM.LEFT_CARD_HOLDER); if (leftCardHolder) leftCardHolder.remove(); // Unwrap captcha input const captchaElem = document.getElementById(LocalC.DOM.CAPTCHA_INPUT); if (captchaElem) { captchaElem.parentElement.before(captchaElem); captchaElem.parentElement.remove(); } }, // --- CORE LOGIC --- /** * Loads settings from localStorage into the state. * @returns {void} */ loadSettings() { try { const saved = localStorage.getItem(this.constants.LOCALSTORAGE_KEY); this.state.savedSettings = saved ? JSON.parse(saved) : {}; Logger.log("Settings loaded from localStorage.", "color: lightblue;"); } catch (e) { Logger.error("Failed to load settings from localStorage.", e); this.state.savedSettings = {}; } }, /** * Saves a single setting value to localStorage. * @param {string} key - The unique ID of the setting. * @param {any} value - The value to save. * @returns {void} */ saveSetting(key, value) { this.state.savedSettings[key] = value; localStorage.setItem(this.constants.LOCALSTORAGE_KEY, JSON.stringify(this.state.savedSettings)); }, /** * Applies all loaded settings to their respective mods' config objects. * @returns {void} */ applySettingsToAllMods() { this.state.defaultSettings = {}; // Clear previous defaults const allMods = this.core.miniMods; allMods.forEach(mod => { if (!mod.getSettings) return; const configObj = mod.name === 'Core' ? this.core.config : mod.config; mod.getSettings().forEach(setting => { // Store the default value before creating the input this.state.defaultSettings[setting.id] = mod.config[setting.configKey]; const savedValue = this.state.savedSettings[setting.id]; if (savedValue !== undefined) { configObj[setting.configKey] = savedValue; } }); }); Logger.log("Applied saved settings to all modules."); }, /** * Clears all mod settings from localStorage and reloads the page. * @returns {void} */ resetAllSettings() { localStorage.removeItem(this.constants.LOCALSTORAGE_KEY); this.state.savedSettings = {}; // Reload the page to apply all defaults cleanly window.location.reload(); }, // --- UI GENERATION --- /** * Rearranges the main menu to create and inject the settings card. * @returns {Promise<void>} */ createAndInjectSettingsCard() { const CoreC = this.core.data.constants; const LocalC = this.constants; const updatePanelVisibility = () => { if (this.config.isPanelVisible) this.showSettingsCard(); else this.removeSettingsCard(); } return this.core.waitForElementsToLoad(CoreC.DOM.MENU_CARD_HOLDER).then((menuCardHolder) => { const settingLabel = 'settings_panel_visible'; // Set panel visibility to it's previous state, using localStorage. const panelVisibility = this.state.savedSettings[settingLabel]; if (typeof panelVisibility === 'boolean') this.config.isPanelVisible = panelVisibility; const rightCardHolder = menuCardHolder.lastElementChild; if (!rightCardHolder) return; // Safety check rightCardHolder.querySelector('.menuHeader:has(+ .settingRadio)').textContent = 'Display Settings'; const leftCardHolder = rightCardHolder.cloneNode(true); leftCardHolder.id = LocalC.DOM.LEFT_CARD_HOLDER; const modCard = leftCardHolder.firstElementChild; modCard.id = LocalC.DOM.MOD_CARD; modCard.innerHTML = ''; // Clear the cloned content menuCardHolder.className = Date.now(); menuCardHolder.prepend(leftCardHolder); // Now that panel has been injected into the page, toggle visibility. updatePanelVisibility(); // Now that the card exists, populate it with the settings. this.populateSettingsPanel(modCard); const captchaElem = document.getElementById(LocalC.DOM.CAPTCHA_INPUT); captchaElem.insertAdjacentHTML('beforebegin', `<div id="${LocalC.DOM.OTHER_INPUTS}"><button id="${LocalC.DOM.SETTINGS_ICON}"></button></div>`); document.getElementById(LocalC.DOM.OTHER_INPUTS).append(captchaElem); document.getElementById(LocalC.DOM.SETTINGS_ICON).addEventListener('click', () => { this.config.isPanelVisible = !this.config.isPanelVisible; updatePanelVisibility(); this.saveSetting(settingLabel, this.config.isPanelVisible); // Update localStorage. }); }); }, /** * Removes the settings card from the DOM. * @returns {void} */ showSettingsCard() { const CoreC = this.core.data.constants; const LocalC = this.constants; const leftCardHolder = document.getElementById(LocalC.DOM.LEFT_CARD_HOLDER); if (leftCardHolder) leftCardHolder.style.removeProperty('display'); const menuCardHolder = document.getElementById(CoreC.DOM.MENU_CARD_HOLDER); const promoImgHolder = document.getElementById(CoreC.DOM.AD_HOLDER); const wideAdCard = document.getElementById(CoreC.DOM.WIDE_AD_CARD); const adCard = document.getElementById(CoreC.DOM.AD_CARD); if (menuCardHolder.previousElementSibling !== wideAdCard) menuCardHolder.before(wideAdCard); if (promoImgHolder.lastElementChild !== adCard) promoImgHolder.append(adCard); }, /** * Removes the settings card from the DOM. * @returns {void} */ removeSettingsCard() { const CoreC = this.core.data.constants; const LocalC = this.constants; const leftCardHolder = document.getElementById(LocalC.DOM.LEFT_CARD_HOLDER); if (leftCardHolder) leftCardHolder.style.setProperty('display', 'none'); const menuCardHolder = document.getElementById(CoreC.DOM.MENU_CARD_HOLDER); const promoImgHolder = document.getElementById(CoreC.DOM.AD_HOLDER); const wideAdCard = document.getElementById(CoreC.DOM.WIDE_AD_CARD); const adCard = document.getElementById(CoreC.DOM.AD_CARD); if (menuCardHolder.nextElementSibling !== wideAdCard) menuCardHolder.after(wideAdCard); if (menuCardHolder.firstElementChild !== adCard) menuCardHolder.prepend(adCard); }, /** * Fills the settings panel with inputs for all registered mods. * @param {HTMLElement} panel - The 'modCard' element to fill. * @returns {void} */ populateSettingsPanel(panel) { const LocalC = this.constants; panel.innerHTML = `<div class="menuHeader">${LocalC.TEXT.MOD_SETTINGS_HEADER}</div>`; const allMods = this.core.miniMods; allMods.forEach(mod => { if (!mod.getSettings) return; const settings = mod.getSettings(); if (settings.length === 0) return; const categoryDiv = document.createElement('div'); categoryDiv.className = LocalC.DOM.SETTINGS_CATEGORY_CLASS; categoryDiv.innerHTML = `<div class="menuHeader">${mod.name}</div>`; panel.append(categoryDiv); settings.forEach(setting => { const itemDiv = this.createSettingInput(setting, mod.config, mod); categoryDiv.append(itemDiv); }); }); const itemDiv = document.createElement('div'); itemDiv.className = `menuButton ${LocalC.DOM.RESET_ALL_BUTTON_CLASS}`; itemDiv.textContent = LocalC.TEXT.RESET_ALL_BUTTON_TEXT; itemDiv.addEventListener('click', () => { if (window.confirm(LocalC.TEXT.RESET_ALL_CONFIRM)) { this.resetAllSettings(); } }); panel.append(itemDiv); }, /** * Creates a single HTML input element for a given setting definition. * @param {object} setting - The setting definition object. * @param {object} config - The config object of the mod the setting belongs to. * @param {object} mod - The mod object itself. * @returns {HTMLElement} The generated setting item element. */ createSettingInput(setting, config, mod) { const LocalC = this.constants; const itemDiv = document.createElement('div'); itemDiv.className = LocalC.DOM.SETTING_ITEM_CLASS; let currentValue = config[setting.configKey]; let controlHtml = ''; switch (setting.type) { case 'checkbox': controlHtml = `<input type="checkbox" id="${setting.id}" ${currentValue ? 'checked' : ''}>`; break; case 'keybind': controlHtml = `<input type="text" class="${LocalC.DOM.KEYBIND_INPUT_CLASS}" id="${setting.id}" value="${currentValue}" readonly>`; break; case 'number': controlHtml = `<input type="number" id="${setting.id}" value="${currentValue}" min="${setting.min || 0}" max="${setting.max || 10000}" step="${setting.step || 1}">`; break; } // Add a reset button for settings that are not buttons const resetButtonHtml = `<div class="${LocalC.DOM.RESET_SETTING_BTN_CLASS}" title="${LocalC.TEXT.RESET_BUTTON_TITLE}">${LocalC.TEXT.RESET_BUTTON_TEXT}</div>`; itemDiv.innerHTML = ` <label for="${setting.id}"> ${setting.label} ${setting.desc ? `<small>${setting.desc}</small>` : ''} </label> <div class="${LocalC.DOM.SETTING_ITEM_CONTROL_CLASS}"> ${controlHtml} ${resetButtonHtml} </div> `; const input = itemDiv.querySelector('input'); const resetButton = itemDiv.querySelector(`.${LocalC.DOM.RESET_SETTING_BTN_CLASS}`); const updateSetting = (newValue) => { // Update the live config object config[setting.configKey] = newValue; // Save to local storage if it's not a temporary setting if (setting.save !== false) { this.saveSetting(setting.id, newValue); } // Trigger any immediate callback if (setting.onChange) { setting.onChange(newValue, this.core); } }; resetButton.addEventListener('click', () => { const defaultValue = this.state.defaultSettings[setting.id]; if (setting.type === 'checkbox') { input.checked = defaultValue; } else { input.value = defaultValue; } updateSetting(defaultValue); }); if (setting.type === 'keybind') { input.addEventListener('focus', () => input.value = LocalC.TEXT.KEYBIND_FOCUS_TEXT); input.addEventListener('blur', () => input.value = config[setting.configKey]); input.addEventListener('keydown', e => { e.preventDefault(); const key = e.key.toUpperCase(); input.value = key; updateSetting(key); input.blur(); }); } else { input.addEventListener('change', () => { const value = setting.type === 'checkbox' ? input.checked : (setting.type === 'number' ? Number(input.value) : input.value); updateSetting(value); }); } return itemDiv; }, }; /** * @module ScrollInventoryMiniMod * @description A minimod for Minecraft-style inventory selection with the scroll wheel and hotkeys. */ const ScrollInventoryMiniMod = { // --- MINI-MOD PROPERTIES --- /** @property {object|null} core - A reference to the core module. */ core: null, /** @property {string} name - The display name of the minimod. */ name: "Scrollable Inventory", /** @property {object} config - Holds user-configurable settings. */ config: { /** @property {boolean} enabled - Master switch for this minimod. */ enabled: true, /** @property {boolean} INVERT_SCROLL - If true, reverses the scroll direction for item selection. */ INVERT_SCROLL: false }, /** @property {object} constants - Constants specific to this minimod. */ constants: { HOTKEYS: { USE_FOOD: 'Q', }, CSS: { FILTER_EQUIPPABLE: 'grayscale(0) brightness(1)', FILTER_UNEQUIPPABLE: 'grayscale(1) brightness(0.75)', BORDER_NONE: 'none', SELECTION_BORDER_STYLE: '2px solid white', }, }, /** @property {object} state - Dynamic state for this minimod. */ state: { /** @property {number} selectedItemIndex - The current index within the filtered list of *equippable* items. */ selectedItemIndex: -1, /** @property {number} lastSelectedWeaponIndex - The index of the last selected weapon, used to auto-switch back after using a non-weapon item. */ lastSelectedWeaponIndex: -1, /** @property {object} boundHandlers - Stores bound event handler functions for easy addition and removal of listeners. */ boundHandlers: {}, }, /** * Defines the settings for this minimod. * @returns {Array<object>} An array of setting definition objects. */ getSettings() { return [ { id: 'scroll_inv_enabled', configKey: 'enabled', label: 'Enable Scroll Inventory', desc: 'Use scroll wheel to cycle through items.', type: 'checkbox' }, { id: 'scroll_inv_invert', configKey: 'INVERT_SCROLL', label: 'Invert Scroll Direction', desc: 'Changes which way the selection moves.', type: 'checkbox' } ]; }, // --- MINI-MOD LIFECYCLE & HOOKS --- /** * Handles incoming game packets to update the minimod's state. * @param {string} packetName - The human-readable name of the packet. * @param {object} packetData - The parsed data object from the packet. * @returns {void} */ onPacket(packetName, packetData) { if (!this.config.enabled) return; switch (packetName) { case 'Setup Game': { // Stores the client's player ID upon initial connection. this.core.state.playerId = packetData.yourSID; break; } case 'Add Player': { // When the client player spawns, trigger the core's onGameReady to finalize setup. if (this.core.state.playerId === packetData.sid && packetData.isYou) { this.core.onGameReady(); } break; } case 'Update Player Value': { // Updates player resource counts and refreshes equippable item states. // If a non-gold resource decreases, assume item usage and try to revert to the last selected weapon. const resourceType = packetData.propertyName; const oldAmount = this.core.state.playerResources[resourceType]; this.core.state.playerResources[resourceType] = packetData.value; if (resourceType !== 'points' && packetData.value < oldAmount) { this.state.selectedItemIndex = this.state.lastSelectedWeaponIndex; } this.refreshEquippableState(); break; } case 'Update Item Counts': { // Updates the count of placed items (e.g; walls, traps) and refreshes equippable states. // This is crucial for enforcing placement limits. const itemData = this.core.data._itemDataByServerId.get(packetData.groupID); if (itemData && itemData.limitGroup) { this.core.state.playerPlacedItemCounts.set(itemData.limitGroup, packetData.count); this.refreshEquippableState(); } break; } case 'Update Upgrades': { this.refreshEquippableState(); break; } } }, /** * Adds all necessary event listeners for this minimod. * @returns {void} */ addEventListeners() { if (!this.config.enabled) return; const CoreC = this.core.data.constants; this.state.boundHandlers.handleInventoryScroll = this.handleInventoryScroll.bind(this); this.state.boundHandlers.handleKeyPress = this.handleKeyPress.bind(this); this.state.boundHandlers.handleItemClick = this.handleItemClick.bind(this); document.addEventListener('wheel', this.state.boundHandlers.handleInventoryScroll, { passive: false }); document.addEventListener('keydown', this.state.boundHandlers.handleKeyPress); document.getElementById(CoreC.DOM.ACTION_BAR).addEventListener('click', this.state.boundHandlers.handleItemClick); }, /** * Cleans up by removing all event listeners added by this minimod. * @returns {void} */ cleanup() { const CoreC = this.core.data.constants; document.removeEventListener('wheel', this.state.boundHandlers.handleInventoryScroll); document.removeEventListener('keydown', this.state.boundHandlers.handleKeyPress); const actionBar = document.getElementById(CoreC.DOM.ACTION_BAR); if (actionBar) { actionBar.removeEventListener('click', this.state.boundHandlers.handleItemClick); } }, /** * Called when the player has spawned. Scrapes initial state and sets up UI. * @returns {void} */ onGameReady() { if (!this.config.enabled) return; const CoreC = this.core.data.constants; // Wait for Game UI to load before proceeding const gameUI = document.getElementById(CoreC.DOM.GAME_UI); this.core.waitForVisible(gameUI).then(() => { // Scrape initial state from the DOM const resElements = document.getElementById(CoreC.DOM.RESOURCE_DISPLAY).children; this.core.state.playerResources = { food: parseInt(resElements[0].textContent) || 0, wood: parseInt(resElements[1].textContent) || 0, stone: parseInt(resElements[2].textContent) || 0, gold: parseInt(resElements[3].textContent) || 0, }; // Set the initial selected item this.selectItemByIndex(CoreC.GAME_STATE.INITIAL_SELECTED_ITEM_INDEX); }); }, // --- EVENT HANDLERS --- /** * Handles the 'wheel' event to cycle through inventory items. * @param {WheelEvent} event - The DOM wheel event. * @returns {void} */ handleInventoryScroll(event) { if (!this.config.enabled) return; const CoreC = this.core.data.constants; if (this.core.isInputFocused() || !this.core.state.gameSocket || this.core.state.gameSocket.readyState !== CoreC.GAME_STATE.WEBSOCKET_STATE_OPEN) return; // Determine scroll direction and send to refresh selection UI function. let scrollDirection = event.deltaY > 0 ? CoreC.GAME_STATE.SCROLL_DOWN : CoreC.GAME_STATE.SCROLL_UP; if (this.config.INVERT_SCROLL) { scrollDirection *= -1; } this.refreshEquippableState(scrollDirection); }, /** * Handles keyboard shortcuts for direct item selection. * @param {KeyboardEvent} event - The DOM keyboard event. * @returns {void} */ handleKeyPress(event) { if (!this.config.enabled) return; const CoreC = this.core.data.constants; if (this.core.isInputFocused()) return; const pressedKey = event.key.toUpperCase(); const actionBar = document.getElementById(CoreC.DOM.ACTION_BAR); if (!actionBar) return; const availableItems = Array.from(actionBar.children).filter(el => this.core.isAvailableItem(el)); if (availableItems.length === 0) return; const isNumericHotkey = (key) => key >= '1' && key <= '9'; const isFoodHotkey = (key) => key === this.constants.HOTKEYS.USE_FOOD; const findFoodItem = (items) => items.find(el => this.core.getItemFromElem(el)?.itemType === CoreC.ITEM_TYPES.FOOD); let targetElement = null; if (isNumericHotkey(pressedKey)) { targetElement = availableItems[parseInt(pressedKey) - 1]; } else if (isFoodHotkey(pressedKey)) { targetElement = findFoodItem(availableItems); } if (targetElement && this.core.isEquippableItem(targetElement)) { const equippableItems = Array.from(actionBar.children).filter(el => this.core.isEquippableItem(el)); const newIndex = equippableItems.findIndex(el => el.id === targetElement.id); if (newIndex !== -1) { if (this.state.selectedItemIndex === newIndex) { // Switch to weapon instead. this.selectItemByIndex(this.state.lastSelectedWeaponIndex); } else this.selectItemByIndex(newIndex); } } }, /** * Handles direct item selection by clicking on an item in the action bar. * @param {MouseEvent} event - The DOM mouse event. * @returns {void} */ handleItemClick(event) { if (!this.config.enabled) return; if (this.core.isInputFocused()) return; const CoreC = this.core.data.constants; const clickedElement = event.target.closest(CoreC.DOM.ACTION_BAR_ITEM_CLASS); if (clickedElement && this.core.isEquippableItem(clickedElement)) { const actionBar = document.getElementById(CoreC.DOM.ACTION_BAR); if (!actionBar) return; const equippableItems = Array.from(actionBar.children).filter(el => this.core.isEquippableItem(el)); const newIndex = equippableItems.findIndex(el => el.id === clickedElement.id); if (newIndex !== -1) this.selectItemByIndex(newIndex); } }, // --- CORE LOGIC & UI MANIPULATION --- /** * The master function for refreshing the inventory selection state and UI. It recalculates the list * of equippable items, determines the new selection index, sends an equip packet if needed, and updates the UI. * @param {number} [scrollDirection=0] - The direction of scroll: 1 for down, -1 for up, 0 for no change. * @returns {void} */ refreshEquippableState(scrollDirection = this.core.data.constants.GAME_STATE.NO_SCROLL) { const CoreC = this.core.data.constants; const actionBar = document.getElementById(CoreC.DOM.ACTION_BAR); if (!actionBar) return; const equippableItems = Array.from(actionBar.children).filter(el => this.core.isEquippableItem(el)); if (equippableItems.length === 0) { Logger.warn("No equippable items available."); this.state.selectedItemIndex = -1; this.updateSelectionUI(null); return; } // Calculate new index, handling scrolling and list changes. this.state.selectedItemIndex = (this.state.selectedItemIndex + scrollDirection + equippableItems.length) % equippableItems.length; // Store the last active weapon's index. if (equippableItems[1]) { const secondEquippableItem = this.core.getItemFromElem(equippableItems[1]); if (this.state.selectedItemIndex <= CoreC.ITEM_TYPES.SECONDARY_WEAPON) { const isSingleWielder = secondEquippableItem?.itemType > CoreC.ITEM_TYPES.SECONDARY_WEAPON; this.state.lastSelectedWeaponIndex = isSingleWielder ? CoreC.ITEM_TYPES.PRIMARY_WEAPON : this.state.selectedItemIndex; } } const selectedElement = equippableItems[this.state.selectedItemIndex]; if (!selectedElement) return; // If we scrolled, send the equip packet. if (scrollDirection !== CoreC.GAME_STATE.NO_SCROLL) { const itemToEquip = this.core.getItemFromElem(selectedElement); if (itemToEquip) { const isWeapon = itemToEquip.itemType <= CoreC.ITEM_TYPES.SECONDARY_WEAPON; this.core.sendGamePacket(CoreC.PACKET_TYPES.EQUIP_ITEM, [itemToEquip.id, isWeapon]); } } this.updateSelectionUI(selectedElement); }, /** * Selects an item by its index in the list of equippable items. * @param {number} newIndex - The index of the item to select. * @returns {void} */ selectItemByIndex(newIndex) { const CoreC = this.core.data.constants; const actionBar = document.getElementById(CoreC.DOM.ACTION_BAR); if (!actionBar) return; const equippableItems = Array.from(actionBar.children).filter(el => this.core.isEquippableItem(el)); if (newIndex < 0 || newIndex >= equippableItems.length) return; this.state.selectedItemIndex = newIndex; this.refreshEquippableState(CoreC.GAME_STATE.NO_SCROLL); }, // --- UI & HELPER FUNCTIONS --- /** * Updates the action bar UI to highlight the selected item. * @param {HTMLElement|null} selectedItem - The element to highlight as selected. * @returns {void} */ updateSelectionUI(selectedItem) { const CoreC = this.core.data.constants; const LocalC = this.constants; const actionBar = document.getElementById(CoreC.DOM.ACTION_BAR); if (!actionBar) return; const allItems = Array.from(actionBar.children); allItems.forEach(item => { item.style.border = item === selectedItem ? LocalC.CSS.SELECTION_BORDER_STYLE : LocalC.CSS.BORDER_NONE; item.style.filter = this.core.isEquippableItem(item) ? LocalC.CSS.FILTER_EQUIPPABLE : LocalC.CSS.FILTER_UNEQUIPPABLE; }); } }; /** * @module WearablesToolbarMiniMod * @description A minimod that adds a clickable, draggable hotbar for equipping wearables. */ const WearablesToolbarMiniMod = { // --- MINI-MOD PROPERTIES --- /** @property {object|null} core - A reference to the core module. */ core: null, /** @property {string} name - The display name of the minimod. */ name: "Wearables Toolbar", /** @property {object} config - Holds user-configurable settings. */ config: { /** @property {boolean} enabled - Master switch for this minimod. */ enabled: true, /** @property {string} TOGGLE_KEY - The hotkey to show or hide the toolbar. */ TOGGLE_KEY: 'P', /** @property {boolean} START_HIDDEN - If true, the toolbar will be hidden on spawn. */ START_HIDDEN: false, /** @property {boolean} INSTA_PIN_FREE_HATS - If true, all owned wearables are pinned at the start. */ INSTA_PIN_FREE_HATS: false, /** @property {boolean} DRAGGABLE_ENABLED - If true, wearables can be reordered via drag-and-drop. */ DRAGGABLE_ENABLED: true, /** @property {boolean} AUTO_PIN_ON_BUY - If true, automatically pins a wearable upon purchase. */ AUTO_PIN_ON_BUY: false, }, /** @property {object} constants - Constants specific to this minimod. */ constants: { TEXT: { PIN: 'Pin', UNPIN: 'Unpin', EQUIP_BUTTON_TEXT: 'equip', }, DOM: { WEARABLES_TOOLBAR: 'wearablesToolbar', WEARABLES_HOTKEY: 'wearablesHotkey', WEARABLES_HATS: 'wearablesHats', WEARABLES_ACCESSORIES: 'wearablesAccessories', WEARABLES_GRID_CLASS: 'wearables-grid', WEARABLE_BUTTON_CLASS: 'wearable-btn', WEARABLE_BUTTON_ID_PREFIX: 'wearable-btn-', JOIN_ALLIANCE_BUTTON_CLASS: 'joinAlBtn', PIN_BUTTON_CLASS: 'pinBtn', WEARABLE_SELECTED_CLASS: 'selected', WEARABLE_DRAGGING_CLASS: 'dragging' }, CSS: { DRAGGING_OPACITY: '0.5', STORE_MENU_TRANSFORM: 'translateY(0px)', }, REGEX: { HAT_IMG: /hat_(\d+)\.png/, ACCESSORY_IMG: /access_(\d+)\.png/, }, URLS: { BASE_IMG: 'https://moomoo.io/img', HAT_IMG_PATH: '/hats/hat_', ACCESSORY_IMG_PATH: '/accessories/access_', IMG_EXT: '.png', }, TIMEOUTS: { DRAG_AND_DROP_VISIBILITY: 0, }, }, /** @property {object} state - Dynamic state for this minimod. */ state: { /** @property {boolean} isVisible - Whether the toolbar UI is currently shown. */ isVisible: true, /** @property {Set<number>} pinnedWearables - A set of wearable IDs that the user has pinned to the toolbar. */ pinnedWearables: new Set(), /** @property {HTMLElement|null} draggedItem - The wearable button element currently being dragged. */ draggedItem: null, /** @property {object} boundHandlers - Stores bound event handler functions for easy addition and removal of listeners. */ boundHandlers: {}, /** @property {Array<MutationObserver|ResizeObserver>} observers - Stores observers for dynamic UI adjustments and cleanup. */ observers: [] }, /** * Defines the settings for this minimod. * @returns {Array<object>} An array of setting definition objects. */ getSettings() { return [ { id: 'wearables_toolbar_enabled', configKey: 'enabled', label: 'Enable Wearables Toolbar', desc: 'Adds a draggable hotbar for accessories.', type: 'checkbox', onChange: (value) => this.toggleFeature(value) }, { id: 'wearables_toolbar_toggle_key', configKey: 'TOGGLE_KEY', label: 'Toggle Toolbar Key', desc: 'Press to show or hide the toolbar.', type: 'keybind', onChange: (value) => { const wearableHotkeyLabel = document.getElementById(this.constants.DOM.WEARABLES_HOTKEY); if (wearableHotkeyLabel) wearableHotkeyLabel.textContent = `(Press '${value}' to toggle)`; } }, { id: 'wearables_toolbar_start_hidden', configKey: 'START_HIDDEN', label: 'Start Hidden', desc: 'The toolbar will be hidden on spawn.', type: 'checkbox' }, { id: 'wearables_toolbar_insta_pin_free_hats', configKey: 'INSTA_PIN_FREE_HATS', label: 'Insta-pin Free Hats', desc: 'Automatically pins all the free hats, as soon as the game start.', type: 'checkbox' }, { id: 'wearables_toolbar_draggable', configKey: 'DRAGGABLE_ENABLED', label: 'Draggable Wearables', desc: 'Allow reordering wearables by dragging.', type: 'checkbox', onChange: (value) => this.toggleDraggable(value) }, { id: 'wearables_toolbar_auto_pin_on_buy', configKey: 'AUTO_PIN_ON_BUY', label: 'Auto-pin on Buy', desc: 'Automatically pin a wearable when you buy it.', type: 'checkbox' } ]; }, // --- MINI-MOD LIFECYCLE & HOOKS --- /** * Returns the CSS rules required for this minimod. * @returns {string} The complete CSS string. */ applyCSS() { const CoreC = this.core.data.constants; const LocalC = this.constants; return ` #${CoreC.DOM.STORE_MENU} { top: 20px; height: calc(100% - 240px); --extended-width: 80px; & .${CoreC.DOM.STORE_TAB_CLASS} { padding: 10px calc(10px + (var(--extended-width) / 4)); } & #${CoreC.DOM.STORE_HOLDER} { height: 100%; width: calc(400px + var(--extended-width)); } &.${CoreC.DOM.STORE_MENU_EXPANDED_CLASS} { top: 140px; height: calc(100% - 360px); } } .${LocalC.DOM.PIN_BUTTON_CLASS} { --text-color: hsl(from #80eefc calc(h + 215) s l); color: var(--text-color); padding-right: 5px; &:hover { color: hsl(from var(--text-color) h calc(s * 0.5) calc(l * 0.875)); } } #${CoreC.DOM.ITEM_INFO_HOLDER} { top: calc(20px + var(--top-offset, 0px)); } #${LocalC.DOM.WEARABLES_TOOLBAR} { position: absolute; left: 20px; top: 20px; padding: 7px 10px 5px; width: auto; max-width: 440px; background-color: rgba(0, 0, 0, 0.25); border-radius: 3px; pointer-events: all; & > h1 { margin: 0; color: #fff; font-size: 31px; font-weight: inherit; & > span { font-size: 0.5em; vertical-align: middle; } } } .${LocalC.DOM.WEARABLES_GRID_CLASS} { display: flex; flex-wrap: wrap; gap: 5px; justify-content: flex-start; } .${LocalC.DOM.WEARABLE_BUTTON_CLASS} { width: 40px; height: 40px; margin: 4px 0; border: 2px solid rgba(255, 255, 255, 0.25); background-size: contain; background-position: center; background-repeat: no-repeat; cursor: pointer; background-color: rgba(0, 0, 0, 0.125); border-radius: 4px; transition: all 0.2s ease; &:hover { background-color: rgba(255, 255, 255, 0.125); border-color: white; } &.${LocalC.DOM.WEARABLE_SELECTED_CLASS} { background-color: #5b9c52; border-color: lightgreen; box-shadow: 0 0 8px lightgreen; } &.${LocalC.DOM.WEARABLE_DRAGGING_CLASS} { opacity: ${LocalC.CSS.DRAGGING_OPACITY}; } } ` }, /** * Handles incoming game packets to update wearables. * @param {string} packetName - The human-readable name of the packet. * @param {object} packetData - The parsed data object from the packet. * @returns {void} */ onPacket(packetName, packetData) { if (!this.config.enabled) return; const CoreC = this.core.data.constants; switch (packetName) { case 'Update Store Items': { const { itemID, itemType, action } = packetData; if (action === CoreC.PACKET_DATA.STORE_ACTIONS.ADD_ITEM) { this.addWearableButton(itemID, itemType); // If auto-pin is on, pin the new item. if (this.config.AUTO_PIN_ON_BUY && !this.isWearablePinned(itemID)) { this.togglePin(itemID, itemType); } } else if (action === CoreC.PACKET_DATA.STORE_ACTIONS.UPDATE_EQUIPPED) { this.updateEquippedWearable(itemID, itemType); } break; } case 'Add Player': { if (packetData.isYou) { this.state.isVisible = !this.config.START_HIDDEN; } break; } } }, /** * Called when the player has spawned. Creates the toolbar UI. * @returns {void} */ onGameReady() { if (!this.config.enabled) return; const CoreC = this.core.data.constants; const LocalC = this.constants; if (!this.core.state.playerHasRespawned && !document.getElementById(LocalC.DOM.WEARABLES_TOOLBAR)) { // Wait for Game UI to load before proceeding const gameUI = document.getElementById(CoreC.DOM.GAME_UI); this.core.waitForVisible(gameUI).then(() => { this.createWearablesToolbarUI(); this.setupDynamicPositioning(); this.setupStoreMenuObservers(); if (this.config.INSTA_PIN_FREE_HATS) { this.instaPinFreeHats(); } }); } }, /** * Cleans up all UI elements, observers, and event listeners. * @returns {void} */ cleanup() { const CoreC = this.core.data.constants; const LocalC = this.constants; document.getElementById(CoreC.DOM.STORE_MENU)?.style.removeProperty('transform'); document.getElementById(LocalC.DOM.WEARABLES_TOOLBAR)?.remove(); document.querySelectorAll(`.${LocalC.DOM.PIN_BUTTON_CLASS}`).forEach((pinElem) => pinElem.remove()); document.removeEventListener('keydown', this.state.boundHandlers.handleKeyDown); this.state.observers.forEach(obs => obs.disconnect()); this.state.observers.length = 0; }, /** * Immediately shows or hides the feature based on the enabled state. * @param {boolean} isEnabled - The new enabled state. * @returns {void} */ toggleFeature(isEnabled) { const toolbar = document.getElementById(this.constants.DOM.WEARABLES_TOOLBAR); if (isEnabled) { // If enabling, but toolbar doesn't exist, try to create it if (!toolbar) this.onGameReady(); else toolbar.style.display = this.state.isVisible ? 'block' : 'none'; } else { if (toolbar) toolbar.style.display = 'none'; } }, // --- INITIAL UI SETUP --- /** * Creates the main HTML structure for the wearables toolbar. * @returns {void} */ createWearablesToolbarUI() { const LocalC = this.constants; const CoreC = this.core.data.constants; const container = document.createElement('div'); container.id = LocalC.DOM.WEARABLES_TOOLBAR; container.innerHTML = ` <h1>Wearables Toolbar <span id="${LocalC.DOM.WEARABLES_HOTKEY}">(Press '${this.config.TOGGLE_KEY}' to toggle)</span></h1> <div id="${LocalC.DOM.WEARABLES_HATS}" class="${LocalC.DOM.WEARABLES_GRID_CLASS}"></div> <div id="${LocalC.DOM.WEARABLES_ACCESSORIES}" class="${LocalC.DOM.WEARABLES_GRID_CLASS}"></div> `; // Apply start hidden setting container.style.display = this.state.isVisible ? CoreC.CSS.DISPLAY_BLOCK : CoreC.CSS.DISPLAY_NONE; document.getElementById(CoreC.DOM.GAME_UI).prepend(container); const hatsGrid = container.querySelector(`#${LocalC.DOM.WEARABLES_HATS}`); const accessoriesGrid = container.querySelector(`#${LocalC.DOM.WEARABLES_ACCESSORIES}`); hatsGrid.addEventListener('dragover', this.handleDragOver.bind(this)); accessoriesGrid.addEventListener('dragover', this.handleDragOver.bind(this)); this.state.boundHandlers.handleKeyDown = (e) => { if (!this.config.enabled || this.core.isInputFocused()) return; if (e.key.toUpperCase() === this.config.TOGGLE_KEY) { this.state.isVisible = !this.state.isVisible; container.style.display = this.state.isVisible ? CoreC.CSS.DISPLAY_BLOCK : CoreC.CSS.DISPLAY_NONE; } }; document.addEventListener('keydown', this.state.boundHandlers.handleKeyDown); }, /** * Sets up observers to dynamically position the toolbar and info box. * @returns {void} */ setupDynamicPositioning() { const LocalC = this.constants; const CoreC = this.core.data.constants; const toolbar = document.getElementById(LocalC.DOM.WEARABLES_TOOLBAR); const infoHolder = document.getElementById(CoreC.DOM.ITEM_INFO_HOLDER); if (!toolbar || !infoHolder) return Logger.warn("Could not find toolbar or info holder for dynamic positioning."); const updatePosition = () => { const isExpanded = infoHolder.offsetHeight > 0; infoHolder.style.setProperty('--top-offset', isExpanded ? `${toolbar.offsetHeight + 20}px` : '0px'); }; // Observer 1: Reacts to any change in the info holder's size (e.g; appearing/disappearing). const infoHolderObserver = new ResizeObserver(updatePosition); infoHolderObserver.observe(infoHolder); this.state.observers.push(infoHolderObserver); // Observer 2: Reacts to significant changes in the toolbar's height, // which happens when a new row of wearables is pinned. let lastToolbarHeight = toolbar.offsetHeight; const toolbarObserver = new ResizeObserver(() => { const currentHeight = toolbar.offsetHeight; // Only update if the height changes by 10px or more to avoid minor fluctuations. if (Math.abs(currentHeight - lastToolbarHeight) >= 10) { updatePosition(); lastToolbarHeight = currentHeight; // Update the last known height } }); toolbarObserver.observe(toolbar); this.state.observers.push(toolbarObserver); // Run once at the start to set the initial position. updatePosition(); }, /** * Sets up observers to adjust the store menu and inject pin buttons. * @returns {void} */ setupStoreMenuObservers() { const CoreC = this.core.data.constants; const LocalC = this.constants; const storeMenu = document.getElementById(CoreC.DOM.STORE_MENU); storeMenu.style.transform = LocalC.CSS.STORE_MENU_TRANSFORM; const upgradeHolder = document.getElementById(CoreC.DOM.UPGRADE_HOLDER); const upgradeCounter = document.getElementById(CoreC.DOM.UPGRADE_COUNTER); const initialCheck = () => { const upgradeHolderVisible = upgradeHolder.style.display === CoreC.CSS.DISPLAY_BLOCK; const upgradeCounterVisible = upgradeCounter.style.display === CoreC.CSS.DISPLAY_BLOCK; const isExpanded = upgradeHolderVisible && upgradeCounterVisible; storeMenu.classList.toggle(CoreC.DOM.STORE_MENU_EXPANDED_CLASS, isExpanded); }; initialCheck(); const upgradeObserver = new MutationObserver(initialCheck); upgradeObserver.observe(upgradeHolder, { attributes: true, attributeFilter: ['style'] }); upgradeObserver.observe(upgradeCounter, { attributes: true, attributeFilter: ['style'] }); this.state.observers.push(upgradeObserver); const storeMenuObserver = new MutationObserver((mutationsList) => { for (const mutation of mutationsList) { if (storeMenu.style.display === CoreC.CSS.DISPLAY_BLOCK && mutation.oldValue?.includes(`display: ${CoreC.CSS.DISPLAY_NONE}`)) { this.addPinButtons(); } } }); storeMenuObserver.observe(storeMenu, { attributes: true, attributeFilter: ['style'], attributeOldValue: true }); this.state.observers.push(storeMenuObserver); const storeHolderObserver = new MutationObserver(() => this.addPinButtons()); storeHolderObserver.observe(document.getElementById(CoreC.DOM.STORE_HOLDER), { childList: true }); this.state.observers.push(storeHolderObserver); }, // --- UI MANIPULATION & STATE UPDATES --- /** * Adds a new button for a specific wearable to the toolbar. * @param {number} id - The server-side ID of the wearable. * @param {string} type - The type of wearable ('hat' or 'accessory'). * @returns {void} */ addWearableButton(id, type) { const LocalC = this.constants; const CoreC = this.core.data.constants; const containerId = type === CoreC.PACKET_DATA.WEARABLE_TYPES.HAT ? LocalC.DOM.WEARABLES_HATS : LocalC.DOM.WEARABLES_ACCESSORIES; const container = document.getElementById(containerId); if (!container) return; const buttonId = `${LocalC.DOM.WEARABLE_BUTTON_ID_PREFIX}${type}-${id}`; if (document.getElementById(buttonId)) return; const btn = document.createElement('div'); btn.id = buttonId; btn.className = LocalC.DOM.WEARABLE_BUTTON_CLASS; btn.draggable = this.config.DRAGGABLE_ENABLED; btn.dataset.wearableId = id; const imagePath = type === CoreC.PACKET_DATA.WEARABLE_TYPES.HAT ? LocalC.URLS.HAT_IMG_PATH : LocalC.URLS.ACCESSORY_IMG_PATH; btn.style.backgroundImage = `url(${LocalC.URLS.BASE_IMG}${imagePath}${id}${LocalC.URLS.IMG_EXT})`; btn.title = `Item ID: ${id}`; btn.addEventListener('dragstart', () => { this.state.draggedItem = btn; setTimeout(() => btn.classList.add(LocalC.DOM.WEARABLE_DRAGGING_CLASS), LocalC.TIMEOUTS.DRAG_AND_DROP_VISIBILITY); }); btn.addEventListener('dragend', () => { setTimeout(() => { if (this.state.draggedItem) this.state.draggedItem.classList.remove(LocalC.DOM.WEARABLE_DRAGGING_CLASS); this.state.draggedItem = null; }, LocalC.TIMEOUTS.DRAG_AND_DROP_VISIBILITY); }); btn.addEventListener('click', () => { const isCurrentlySelected = btn.classList.contains(LocalC.DOM.WEARABLE_SELECTED_CLASS); const newItemId = isCurrentlySelected ? 0 : id; this.core.sendGamePacket(CoreC.PACKET_TYPES.EQUIP_WEARABLE, [0, newItemId, type === CoreC.PACKET_DATA.WEARABLE_TYPES.HAT ? 0 : 1]); }); container.append(btn); this.refreshToolbarVisibility(); }, /** * Updates the visual state of buttons to highlight the equipped wearable. * @param {number} id - The server-side ID of the newly equipped wearable. * @param {string} type - The type of wearable. * @returns {void} */ updateEquippedWearable(id, type) { const LocalC = this.constants; const CoreC = this.core.data.constants; const containerId = type === CoreC.PACKET_DATA.WEARABLE_TYPES.HAT ? LocalC.DOM.WEARABLES_HATS : LocalC.DOM.WEARABLES_ACCESSORIES; const container = document.getElementById(containerId); if (!container) return; const currentSelected = container.querySelector(`.${LocalC.DOM.WEARABLE_SELECTED_CLASS}`); if (currentSelected) currentSelected.classList.remove(LocalC.DOM.WEARABLE_SELECTED_CLASS); if (id > 0) { const buttonId = `${LocalC.DOM.WEARABLE_BUTTON_ID_PREFIX}${type}-${id}`; const newSelectedBtn = document.getElementById(buttonId); if (newSelectedBtn) newSelectedBtn.classList.add(LocalC.DOM.WEARABLE_SELECTED_CLASS); } }, /** * Hides or shows wearable buttons based on the pinned set. * @returns {void} */ refreshToolbarVisibility() { const LocalC = this.constants; const CoreC = this.core.data.constants; const allButtons = document.querySelectorAll(`.${LocalC.DOM.WEARABLE_BUTTON_CLASS}`); allButtons.forEach(btn => { const buttonId = parseInt(btn.dataset.wearableId); if (!isNaN(buttonId)) { btn.style.display = this.state.pinnedWearables.has(buttonId) ? CoreC.CSS.DISPLAY_BLOCK : CoreC.CSS.DISPLAY_NONE; } }); }, // --- PINNING LOGIC --- /** * Scans the store for all owned wearables and pins them if they aren't already. * This is triggered by the 'Start With All Pinned' setting. * @returns {void} */ instaPinFreeHats() { const CoreC = this.core.data.constants; const freeHats = [51, 50, 28, 29, 30, 36, 37, 38, 44, 35, 42, 43, 49]; freeHats.forEach((storeItemId) => { if (!this.isWearablePinned(storeItemId)) { this.togglePin(storeItemId, CoreC.PACKET_DATA.WEARABLE_TYPES.HAT); } }); this.refreshToolbarVisibility(); }, /** * Scans the store menu and adds "Pin" buttons to owned wearables. * @returns {void} */ addPinButtons() { const CoreC = this.core.data.constants; const LocalC = this.constants; const storeHolder = document.getElementById(CoreC.DOM.STORE_HOLDER); Array.from(storeHolder.children).forEach((storeItem) => { const wearableIcon = storeItem.querySelector('img'); const joinBtn = storeItem.querySelector('.' + LocalC.DOM.JOIN_ALLIANCE_BUTTON_CLASS); const hasPinButton = storeItem.querySelector(`.${LocalC.DOM.PIN_BUTTON_CLASS}`); const isNotEquipButton = !joinBtn || !joinBtn.textContent.toLowerCase().includes(LocalC.TEXT.EQUIP_BUTTON_TEXT); // Check if eligible for a new pin button. if (!wearableIcon || hasPinButton || isNotEquipButton) return; let id, type; const hatMatch = wearableIcon.src.match(LocalC.REGEX.HAT_IMG); const accMatch = wearableIcon.src.match(LocalC.REGEX.ACCESSORY_IMG); if (hatMatch) { id = parseInt(hatMatch[1]); type = CoreC.PACKET_DATA.WEARABLE_TYPES.HAT; } else if (accMatch) { id = parseInt(accMatch[1]); type = CoreC.PACKET_DATA.WEARABLE_TYPES.ACCESSORY; } else { return; // Not a wearable item } const pinButton = document.createElement('div'); pinButton.className = `${LocalC.DOM.JOIN_ALLIANCE_BUTTON_CLASS} ${LocalC.DOM.PIN_BUTTON_CLASS}`; pinButton.style.marginTop = '5px'; pinButton.textContent = this.isWearablePinned(id) ? LocalC.TEXT.UNPIN : LocalC.TEXT.PIN; pinButton.addEventListener('click', () => { pinButton.textContent = this.togglePin(id, type) ? LocalC.TEXT.UNPIN : LocalC.TEXT.PIN; this.refreshToolbarVisibility(); }); joinBtn.after(pinButton); }); }, /** * Checks if a wearable is currently pinned. * @param {number} id - The ID of the wearable. * @returns {boolean} True if pinned. */ isWearablePinned(id) { return this.state.pinnedWearables.has(id); }, /** * Toggles the pinned state of a wearable. * @param {number} id - The ID of the wearable. * @param {string} type - The type of the wearable. * @returns {boolean} The new pinned state. */ togglePin(id, type) { const pinned = this.state.pinnedWearables; if (pinned.has(id)) { // Unpin pinned.delete(id); return false; } else { // Pin pinned.add(id); this.addWearableButton(id, type); return true; } }, /** * Toggles the draggable property on all wearable buttons based on the user setting. * @param {boolean} isEnabled - Whether dragging should be enabled. * @returns {void} */ toggleDraggable(isEnabled) { const LocalC = this.constants; const allButtons = document.querySelectorAll(`.${LocalC.DOM.WEARABLE_BUTTON_CLASS}`); allButtons.forEach(btn => { btn.draggable = isEnabled; }); }, // --- EVENT HANDLERS (DRAG & DROP) --- /** * Handles the dragover event for reordering pinned items. * @param {DragEvent} e - The DOM drag event. * @returns {void} */ handleDragOver(e) { e.preventDefault(); const grid = e.currentTarget; const currentlyDragged = this.state.draggedItem; if (!currentlyDragged || currentlyDragged.parentElement != grid) return; // Determine where the item SHOULD be placed. const afterElement = this._getDragAfterElement(grid, e.clientX, e.clientY); // Optimization: Prevent DOM updates if position hasn't changed to avoid jitter. if (currentlyDragged.nextSibling === afterElement) return; grid.insertBefore(currentlyDragged, afterElement); }, // --- HELPER FUNCTIONS --- /** * Finds the sibling element that should come after the dragged item. * @private * @param {HTMLElement} container - The grid container element. * @param {number} x - The cursor's horizontal position. * @param {number} y - The cursor's vertical position. * @returns {HTMLElement|null} The sibling element to insert before. */ _getDragAfterElement(container, x, y) { const LocalC = this.constants; const selector = `.${LocalC.DOM.WEARABLE_BUTTON_CLASS}:not(.${LocalC.DOM.WEARABLE_DRAGGING_CLASS})`; const draggableSiblings = [...container.querySelectorAll(selector)]; for (const sibling of draggableSiblings) { const box = sibling.getBoundingClientRect(); const isVerticallyBefore = y < box.top + box.height / 2; const isInRow = y >= box.top && y <= box.bottom; const isHorizontallyBefore = x < box.left + box.width / 2; if (isVerticallyBefore || (isInRow && isHorizontallyBefore)) { return sibling; } } return null; // If after all other elements }, }; /** * @module TypingIndicatorMiniMod * @description Shows a "..." typing indicator in chat while the user is typing. */ const TypingIndicatorMiniMod = { // --- MINI-MOD PROPERTIES --- /** @property {object|null} core - A reference to the core module. */ core: null, /** @property {string} name - The display name of the minimod. */ name: "Typing Indicator", /** @property {object} config - Holds user-configurable settings. */ config: { /** @property {boolean} enabled - Master switch for this minimod. */ enabled: true, /** @property {number} INDICATOR_INTERVAL - The time in milliseconds between each animation frame. */ INDICATOR_INTERVAL: 1000, /** @property {number} RATE_LIMIT_MS - The cooldown period between sending chat messages. */ RATE_LIMIT_MS: 550, /** @property {number} START_DELAY - A safe buffer before showing the indicator. */ START_DELAY: 1000, /** @property {string[]} ANIMATION_FRAMES - The sequence of strings used for the typing animation. */ ANIMATION_FRAMES: ['.', '..', '...'], /** @property {number} QUEUE_PROCESSOR_INTERVAL - How often to check the message queue for pending messages to send. */ QUEUE_PROCESSOR_INTERVAL: 100, }, /** @property {object} state - Dynamic state for this minimod. */ state: { /** @property {HTMLElement|null} chatBoxElement - A reference to the game's chat input element. */ chatBoxElement: null, /** @property {number|null} indicatorIntervalId - The ID of the interval used for the typing animation. */ indicatorIntervalId: null, /** @property {number|null} startIndicatorTimeoutId - The ID of the timeout used to delay the start of the indicator. */ startIndicatorTimeoutId: null, /** @property {number|null} queueProcessorIntervalId - The ID of the interval that processes the message queue. */ queueProcessorIntervalId: null, /** @property {number} animationFrameIndex - The current index in the `ANIMATION_FRAMES` array. */ animationFrameIndex: 0, /** @property {number} lastMessageSentTime - The timestamp of the last message sent to the server. */ lastMessageSentTime: 0, /** @property {Array<{type: 'user'|'system', content: string}>} messageQueue - The queue of messages waiting to be sent. */ messageQueue: [], /** @property {object} boundHandlers - Stores bound event handler functions for easy addition and removal of listeners. */ boundHandlers: {}, }, /** * Defines the settings for this minimod. * @returns {Array<object>} An array of setting definition objects. */ getSettings() { return [ { id: 'typing_indicator_enabled', configKey: 'enabled', label: 'Enable Typing Indicator', desc: 'Shows "..." in chat while you are typing.', type: 'checkbox' } ] }, // --- MINI-MOD LIFECYCLE & HOOKS --- /** * Adds all necessary event listeners for this minimod. * @returns {void} */ addEventListeners() { if (!this.config.enabled) return; this.state.chatBoxElement = document.getElementById(this.core.data.constants.DOM.CHAT_BOX); if (!this.state.chatBoxElement) return Logger.error("Could not find chatBox element. Mod will not function."); this.state.boundHandlers.handleFocus = this.handleFocus.bind(this); this.state.boundHandlers.handleBlur = this.handleBlur.bind(this); this.state.boundHandlers.handleKeyDown = this.handleKeyDown.bind(this); this.state.chatBoxElement.addEventListener('focus', this.state.boundHandlers.handleFocus); this.state.chatBoxElement.addEventListener('blur', this.state.boundHandlers.handleBlur); this.state.chatBoxElement.addEventListener('keydown', this.state.boundHandlers.handleKeyDown); // Start the queue processor, which will run continuously to send queued messages. this.startQueueProcessor(); Logger.log("Typing indicator event listeners attached and queue processor started."); }, /** * Cleans up all timers and event listeners. * @returns {void} */ cleanup() { clearInterval(this.state.indicatorIntervalId); clearInterval(this.state.queueProcessorIntervalId); clearTimeout(this.state.startIndicatorTimeoutId); if (this.state.chatBoxElement) { this.state.chatBoxElement.removeEventListener('focus', this.state.boundHandlers.handleFocus); this.state.chatBoxElement.removeEventListener('blur', this.state.boundHandlers.handleBlur); this.state.chatBoxElement.removeEventListener('keydown', this.state.boundHandlers.handleKeyDown); } }, // --- EVENT HANDLERS --- /** * Handles the `focus` event on the chat box. * @returns {void} */ handleFocus() { if (!this.config.enabled) return; // Instead of starting immediately, set a timeout to begin the animation. // This prevents the indicator from flashing for accidental clicks or very fast messages. if (this.state.startIndicatorTimeoutId) clearTimeout(this.state.startIndicatorTimeoutId); this.state.startIndicatorTimeoutId = setTimeout(() => { this.startTypingIndicator(); }, this.config.START_DELAY); }, /** * Handles the `blur` event on the chat box. * @returns {void} */ handleBlur() { if (!this.config.enabled) return; clearTimeout(this.state.startIndicatorTimeoutId); this.stopTypingIndicator(); }, /** * Intercepts the 'Enter' key press to queue the message. * @param {KeyboardEvent} event - The DOM keyboard event. * @returns {void} */ handleKeyDown(event) { if (this.config.enabled && event.key === 'Enter') { // Prevent the game from sending the message. We will handle it. event.preventDefault(); clearTimeout(this.state.startIndicatorTimeoutId); const message = this.state.chatBoxElement.value.trim(); if (message) { this.queueUserMessage(message); } // Clear the chat box and stop the indicator, as the user is done typing. this.state.chatBoxElement.value = ''; this.stopTypingIndicator(); } }, // --- CORE LOGIC --- /** * Starts the animation loop. * @returns {void} */ startTypingIndicator() { if (!this.config.enabled || this.state.indicatorIntervalId) return; // Already running Logger.log("Starting typing indicator."); this.state.animationFrameIndex = 0; // Run once immediately, then start the interval this.animateIndicator(); this.state.indicatorIntervalId = setInterval(this.animateIndicator.bind(this), this.config.INDICATOR_INTERVAL); }, /** * Stops the animation loop and cleans up. * @returns {void} */ stopTypingIndicator() { if (!this.state.indicatorIntervalId) return; // Already stopped Logger.log("Stopping typing indicator and cleaning up queue."); clearInterval(this.state.indicatorIntervalId); this.state.indicatorIntervalId = null; // Remove any pending system messages this.state.messageQueue = this.state.messageQueue.filter(msg => msg.type !== 'system'); // Queue one final, empty message to clear the indicator that might be visible in chat. this.queueSystemMessage(''); }, /** * Queues the next frame of the animation to be sent. * @returns {void} */ animateIndicator() { const frame = this.config.ANIMATION_FRAMES[this.state.animationFrameIndex]; this.queueSystemMessage(frame); // Cycle to the next frame this.state.animationFrameIndex = (this.state.animationFrameIndex + 1) % this.config.ANIMATION_FRAMES.length; }, // --- RATE LIMIT & QUEUE MANAGEMENT --- /** * Starts the interval that processes the message queue. * @returns {void} */ startQueueProcessor() { if (this.state.queueProcessorIntervalId) return; this.state.queueProcessorIntervalId = setInterval(this.processMessageQueue.bind(this), this.config.QUEUE_PROCESSOR_INTERVAL); }, /** * Adds a user-typed message to the front of the queue. * @param {string} message - The user's chat message. * @returns {void} */ queueUserMessage(message) { Logger.log(`Queueing user message: "${message}"`); this.state.messageQueue.unshift({ type: 'user', content: message }); }, /** * Adds a system message (like the indicator) to the back of the queue. * @param {string} message - The system message content. * @returns {void} */ queueSystemMessage(message) { // Optimization: Don't queue up a ton of indicator dots. // If the last message in the queue is also an indicator, replace it. const lastInQueue = this.state.messageQueue[this.state.messageQueue.length - 1]; if (lastInQueue && lastInQueue.type === 'system') { this.state.messageQueue[this.state.messageQueue.length - 1].content = message; } else { this.state.messageQueue.push({ type: 'system', content: message }); } }, /** * Checks the queue and sends the next message if the rate limit has passed. * @returns {void} */ processMessageQueue() { const CoreC = this.core.data.constants; const canSendMessage = (Date.now() - this.state.lastMessageSentTime) > this.config.RATE_LIMIT_MS; if (canSendMessage && this.state.messageQueue.length > 0) { const messageToSend = this.state.messageQueue.shift(); // Get the next message this.core.sendGamePacket(CoreC.PACKET_TYPES.CHAT, [messageToSend.content]); this.state.lastMessageSentTime = Date.now(); if (messageToSend.type === 'user') { Logger.log(`Sent queued user message: "${messageToSend.content}"`); } } } }; /** * @module ProximityChatMiniMod * @description Displays nearby player chats in a Minecraft/Roblox-style chatbox, * showing player names, leaderboard ranks, and timestamps. */ const ProximityChatMiniMod = { // --- MINI-MOD PROPERTIES --- /** @property {object|null} core - A reference to the core module. */ core: null, /** @property {string} name - The display name of the minimod. */ name: "Proximity Chat", /** @property {object} config - Holds user-configurable settings. */ config: { /** @property {boolean} enabled - Master switch for this minimod. */ enabled: true, /** @property {string} TOGGLE_KEY - The hotkey to show or hide the toolbar. */ TOGGLE_KEY: 'T', /** @property {number} maxMessages - The maximum number of messages to keep in the chatbox. */ maxMessages: 12 }, /** @property {object} constants - Constants specific to this minimod. */ constants: { DOM: { CHATBOX_CONTAINER_ID: 'proximityChatboxContainer', CHATBOX_MESSAGES_ID: 'proximityChatboxMessages', CHAT_MESSAGE_CLASS: 'proximityChatMessage' } }, /** @property {object} state - Dynamic state for this minimod. */ state: { /** @property {boolean} isVisible - Whether the toolbar UI is currently shown. */ isVisible: true, /** @property {Map<number, object>} players - Maps player SID to their data {id, name}. */ players: new Map(), /** @property {Map<number, number>} leaderboard - Maps player SID to their rank. */ leaderboard: new Map(), /** @property {HTMLElement|null} chatboxContainer - Reference to the main chatbox UI element. */ chatboxContainer: null, /** @property {HTMLElement|null} messagesContainer - Reference to the inner element that holds messages. */ messagesContainer: null, /** @property {object} boundHandlers - Stores bound event handler functions for easy addition and removal of listeners. */ boundHandlers: {} }, /** * Defines the settings for this minimod. * @returns {Array<object>} An array of setting definition objects. */ getSettings() { return [ { id: 'proximity_chat_enabled', configKey: 'enabled', label: 'Enable Proximity Chat', desc: 'Shows nearby chats in a custom chatbox.', type: 'checkbox', onChange: (value) => this.toggleFeature(value) }, { id: 'proximity_chat_toggle_key', configKey: 'TOGGLE_KEY', label: 'Toggle Chatbox Key', desc: 'Press to show or hide the chatbox.', type: 'keybind' }, { id: 'proximity_chat_max_messages', configKey: 'maxMessages', label: 'Max Chat Messages', desc: 'The number of messages to show before old ones disappear.', type: 'number', min: 10, max: 500 } ]; }, // --- MINI-MOD LIFECYCLE & HOOKS --- /** * Initializes the minimod by creating the chatbox UI. * @returns {void} */ init() { if (!this.config.enabled) return; const CoreC = this.core.data.constants; // Wait for the main game UI to be ready before injecting our chatbox this.core.waitForElementsToLoad(CoreC.DOM.GAME_UI).then(gameUI => { const chatboxContainer = document.createElement('div'); chatboxContainer.id = this.constants.DOM.CHATBOX_CONTAINER_ID; this.core.registerFocusableElement(chatboxContainer.id); const messagesContainer = document.createElement('div'); messagesContainer.id = this.constants.DOM.CHATBOX_MESSAGES_ID; chatboxContainer.appendChild(messagesContainer); gameUI.appendChild(chatboxContainer); this.state.chatboxContainer = chatboxContainer; this.state.messagesContainer = messagesContainer; this.state.boundHandlers.handleKeyDown = (e) => { if (!this.config.enabled || this.core.isInputFocused()) return; if (e.key.toUpperCase() === this.config.TOGGLE_KEY) { this.state.isVisible = !this.state.isVisible; this.state.chatboxContainer.style.display = this.state.isVisible ? CoreC.CSS.DISPLAY_FLEX : CoreC.CSS.DISPLAY_NONE; } }; document.addEventListener('keydown', this.state.boundHandlers.handleKeyDown); }); }, /** * Returns the CSS rules required for styling the chatbox. * @returns {string} The complete CSS string. */ applyCSS() { const LocalC = this.constants; return ` #${LocalC.DOM.CHATBOX_CONTAINER_ID} { position: absolute; bottom: 215px; /* Positioned above the action bar */ left: 20px; width: 400px; max-width: 50%; height: 250px; opacity: 0.75; background-color: rgba(0, 0, 0, 0.33333); border-radius: 4px; color: white; font-family: 'Hammersmith One', sans-serif; font-size: 16px; display: flex; flex-direction: column-reverse; /* New messages appear at the bottom */ pointer-events: all; z-index: 10; /* Ensure it's above most game elements but below menus */ /* --- KEY CHANGE: Scrolling is now handled by the main container --- */ overflow-y: auto; scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.3) rgba(0, 0, 0, 0.2); transition: opacity 1s; &:hover { opacity: 1; } &:not(:has(.${LocalC.DOM.CHAT_MESSAGE_CLASS})):is(&, &:hover) { opacity: 0; } } #${LocalC.DOM.CHATBOX_CONTAINER_ID}::-webkit-scrollbar { width: 6px; } #${LocalC.DOM.CHATBOX_CONTAINER_ID}::-webkit-scrollbar-track { background: rgba(0, 0, 0, 0.2); } #${LocalC.DOM.CHATBOX_CONTAINER_ID}::-webkit-scrollbar-thumb { background-color: rgba(255, 255, 255, 0.3); border-radius: 3px; } #${LocalC.DOM.CHATBOX_MESSAGES_ID} { /* --- KEY CHANGE: Removed flex properties and overflow from the inner container --- */ padding: 8px; word-wrap: break-word; } .${LocalC.DOM.CHAT_MESSAGE_CLASS} { margin-top: 4px; text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7); animation: fadeIn 0.3s ease-in-out; transition: 1s; } @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } .${LocalC.DOM.CHAT_MESSAGE_CLASS} .timestamp { color: #AAAAAA; } .${LocalC.DOM.CHAT_MESSAGE_CLASS} .player-name { font-weight: bold; color: #FFFFFF; } .${LocalC.DOM.CHAT_MESSAGE_CLASS} .message-content { color: #FFFFFF; } `; }, /** * Cleans up all UI created by this minimod. * @returns {void} */ cleanup() { this.state.chatboxContainer?.remove(); this.state.chatboxContainer = null; this.state.messagesContainer = null; this.state.players.clear(); this.state.leaderboard.clear(); document.removeEventListener('keydown', this.state.boundHandlers.handleKeyDown); }, /** * Toggles the feature's visibility. * @param {boolean} isEnabled - The new enabled state. */ toggleFeature(isEnabled) { if (this.state.chatboxContainer) { this.state.chatboxContainer.style.display = isEnabled ? 'flex' : 'none'; } }, /** * Handles incoming game packets to update the minimod's state. * @param {string} packetName - The human-readable name of the packet. * @param {object} packetData - The parsed data object from the packet. */ onPacket(packetName, packetData) { if (!this.config.enabled) return; switch (packetName) { case 'Add Player': if (packetData.sid) { this.state.players.set(packetData.sid, { id: packetData.id, name: packetData.name }); } break; case 'Remove Player': { const idToRemove = packetData.id; for (const [sid, playerData] of this.state.players.entries()) { if (playerData.id === idToRemove) { this.state.players.delete(sid); break; } } break; } case 'Leaderboard Update': this.state.leaderboard.clear(); packetData.leaderboard.forEach((player, index) => { this.state.leaderboard.set(player.sid, index + 1); }); break; case 'Receive Chat': if (!(/^\.+$/.test(packetData.message))) this.addChatMessage(packetData.sid, packetData.message); break; case 'Client Player Death': case 'Setup Game': // Clear on respawn or new game this.state.players.clear(); this.state.leaderboard.clear(); if (this.state.messagesContainer) { this.state.messagesContainer.innerHTML = ''; } break; } }, /** * Creates and adds a new chat message to the UI. * @param {number} sid - The sender's session ID. * @param {string} message - The chat message content. */ async addChatMessage(sid, message) { if (!this.config.enabled || !this.state.messagesContainer) return; const playerInfo = this.state.players.get(sid); const rank = this.state.leaderboard.get(sid); let playerName = playerInfo ? playerInfo.name : `Player ${sid}`; if (rank) { playerName = `[${rank}] ${playerName}`; } const timestamp = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }); // Create elements safely to prevent HTML injection const msgElement = document.createElement('div'); msgElement.className = this.constants.DOM.CHAT_MESSAGE_CLASS; const timeSpan = document.createElement('span'); timeSpan.className = 'timestamp'; timeSpan.textContent = `[${timestamp}] `; const nameSpan = document.createElement('span'); nameSpan.className = 'player-name'; nameSpan.textContent = `${playerName}`; // Style ranked players differently for emphasis if (rank) { nameSpan.style.color = '#FFD700'; // Gold color } const contentSpan = document.createElement('span'); contentSpan.className = 'message-content'; contentSpan.textContent = `: ${message}`; msgElement.append(timeSpan, nameSpan, contentSpan); // Add to UI and manage message limit this.state.messagesContainer.appendChild(msgElement); if (this.state.messagesContainer.children.length > this.config.maxMessages) { const lastMessage = this.state.messagesContainer.firstChild; lastMessage.style.opacity = '0'; lastMessage.style.translateY = '-10px'; await this.core.wait(1000); this.state.messagesContainer.removeChild(lastMessage); } }, }; // --- REGISTER MINI-MODS & INITIALIZE --- MooMooUtilityMod.registerMod(SettingsManagerMiniMod); MooMooUtilityMod.registerMod(ScrollInventoryMiniMod); MooMooUtilityMod.registerMod(WearablesToolbarMiniMod); MooMooUtilityMod.registerMod(TypingIndicatorMiniMod); MooMooUtilityMod.registerMod(ProximityChatMiniMod); MooMooUtilityMod.init(); })();