Keybindings

Adds keybindings to Melvor Idle. Visit the Settings menu (X) to view all keybinds.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name        Keybindings
// @description Adds keybindings to Melvor Idle. Visit the Settings menu (X) to view all keybinds.
// @version     1.1.1
// @license     MIT
// @match       https://*.melvoridle.com/*
// @exclude     https://wiki.melvoridle.com*
// @grant       none
// @namespace   https://github.com/ChaseStrackbein/melvor-keybindings
// ==/UserScript==

window.kb = (() => {
  const invalidKeys = ['SHIFT', 'CONTROL', 'ALT', 'META'];
  let cachePage = window.currentPage;
  let previousPage = -1;
  let settingsGrid = null;
  let bindingBeingRemapped = null;

  let keymap = {};

  const keybindings = [
    {
      name: 'Menu',
      category: 'General',
      defaultKeys: { key: 'M' },
      callback: () => document.getElementById('page-header-user-dropdown').click()
    },
    {
      name: 'Save',
      category: 'General',
      defaultKeys: { key: 'S', ctrlKey: true },
      callback: () => forceSync(false, false)
    },
    {
      name: 'Reload / Character Select',
      category: 'General',
      defaultKeys: { key: 'F5' },
      callback: () => window.location.reload()
    },
    {
      name: 'Open Wiki',
      category: 'General',
      defaultKeys: { key: 'F1' },
      callback: () => window.open('https://wiki.melvoridle.com/', '_blank')
    },
    {
      name: 'Settings',
      category: 'General',
      defaultKeys: { key: 'X' },
      callback: () => changePage(CONSTANTS.page.Settings, false, false)
    },
    {
      name: 'Shop',
      category: 'General',
      defaultKeys: { key: 'V' },
      callback: () => changePage(CONSTANTS.page.Shop, false, false)
    },
    {
      name: 'Bank',
      category: 'General',
      defaultKeys: { key: 'B' },
      callback: () => changePage(CONSTANTS.page.Bank, false, false)
    },
    {
      name: 'Combat',
      category: 'General',
      defaultKeys: { key: 'C' },
      callback: () => changePage(CONSTANTS.page.Combat, false, false)
    },
    {
      name: 'Eat Equipped Food',
      category: 'General',
      defaultKeys: { key: 'H' },
      callback: () => player.eatFood()
    },
    {
      name: 'Loot All (Combat)',
      category: 'General',
      defaultKeys: { key: 'SPACE' },
      callback: () => combatManager.loot.lootAll()
    },
    {
      name: 'Run (Combat)',
      category: 'General',
      defaultKeys: { key: 'SPACE', ctrlKey: true },
      callback: () => combatManager.stopCombat()
    },
    {
      name: 'Equipment Set 1',
      category: 'General',
      defaultKeys: { key: '1', ctrlKey: true },
      callback: () => player.changeEquipmentSet(0)
    },
    {
      name: 'Equipment Set 2',
      category: 'General',
      defaultKeys: { key: '2', ctrlKey: true },
      callback: () => player.changeEquipmentSet(1)
    },
    {
      name: 'Equipment Set 3',
      category: 'General',
      defaultKeys: { key: '3', ctrlKey: true },
      callback: () => player.changeEquipmentSet(2)
    },
    {
      name: 'Equipment Set 4',
      category: 'General',
      defaultKeys: { key: '4', ctrlKey: true },
      callback: () => player.changeEquipmentSet(3)
    },
    {
      name: 'Search Bank',
      category: 'General',
      defaultKeys: { key: 'F', ctrlKey: true },
      callback: () => {
        changePage(CONSTANTS.page.Bank, false, false);
        updateBankSearchArray();
        document.getElementById('searchTextbox').focus();
      }
    },
    {
      name: 'Summoning Synergies Menu',
      category: 'General',
      defaultKeys: { key: 'S' },
      callback: () => {
        const modal = $('#modal-summoning-synergy').data('bs.modal');
        if (!modal || !modal._isShown) openSynergiesBreakdown();
        else modal.hide();
      }
    },
    {
      name: 'Search Summoning Synergies',
      category: 'General',
      defaultKeys: { key: 'F', ctrlKey: true, altKey: true },
      callback: () => {
        openSynergiesBreakdown();
        document.getElementById('summoning-synergy-search').focus();
      }
    },
    {
      name: 'Woodcutting',
      category: 'General',
      defaultKeys: { key: '1' },
      callback: () => changePage(CONSTANTS.page.Woodcutting, false, false)
    },
    {
      name: 'Fishing',
      category: 'General',
      defaultKeys: { key: '2' },
      callback: () => changePage(CONSTANTS.page.Fishing, false, false)
    },
    {
      name: 'Firemaking',
      category: 'General',
      defaultKeys: { key: '3' },
      callback: () => changePage(CONSTANTS.page.Firemaking, false, false)
    },
    {
      name: 'Cooking',
      category: 'General',
      defaultKeys: { key: '4' },
      callback: () => changePage(CONSTANTS.page.Cooking, false, false)
    },
    {
      name: 'Mining',
      category: 'General',
      defaultKeys: { key: '5' },
      callback: () => changePage(CONSTANTS.page.Mining, false, false)
    },
    {
      name: 'Smithing',
      category: 'General',
      defaultKeys: { key: '6' },
      callback: () => changePage(CONSTANTS.page.Smithing, false, false)
    },
    {
      name: 'Thieving',
      category: 'General',
      defaultKeys: { key: '7' },
      callback: () => changePage(CONSTANTS.page.Thieving, false, false)
    },
    {
      name: 'Farming',
      category: 'General',
      defaultKeys: { key: '8' },
      callback: () => changePage(CONSTANTS.page.Farming, false, false)
    },
    {
      name: 'Fletching',
      category: 'General',
      defaultKeys: { key: '9' },
      callback: () => changePage(CONSTANTS.page.Fletching, false, false)
    },
    {
      name: 'Crafting',
      category: 'General',
      defaultKeys: { key: '0' },
      callback: () => changePage(CONSTANTS.page.Crafting, false, false)
    },
    {
      name: 'Runecrafting',
      category: 'General',
      defaultKeys: { key: '!' },
      callback: () => changePage(CONSTANTS.page.Runecrafting, false, false)
    },
    {
      name: 'Herblore',
      category: 'General',
      defaultKeys: { key: '@' },
      callback: () => changePage(CONSTANTS.page.Herblore, false, false)
    },
    {
      name: 'Agility',
      category: 'General',
      defaultKeys: { key: '#' },
      callback: () => changePage(CONSTANTS.page.Agility, false, false)
    },
    {
      name: 'Summoning',
      category: 'General',
      defaultKeys: { key: '$' },
      callback: () => changePage(CONSTANTS.page.Summoning, false, false)
    },
    {
      name: 'Astrology',
      category: 'General',
      defaultKeys: { key: '%' },
      callback: () => changePage(CONSTANTS.page.Astrology, false, false)
    },
    {
      name: 'Alt. Magic',
      category: 'General',
      defaultKeys: { key: 'M', altKey: true },
      callback: () => changePage(CONSTANTS.page.AltMagic, false, false)
    },
    {
      name: 'Completion Log',
      category: 'General',
      defaultKeys: { key: 'Y' },
      callback: () => changePage(30, false, false)
    },
    {
      name: 'Statistics',
      category: 'General',
      defaultKeys: { key: 'F2' },
      callback: () => changePage(CONSTANTS.page.Statistics, false, false)
    },
    {
      name: 'Golbin Raid',
      category: 'General',
      defaultKeys: { key: 'G' },
      callback: () => changePage(CONSTANTS.page.GolbinRaid, false, false)
    },
    {
      name: 'Previous Page',
      category: 'General',
      defaultKeys: { key: 'BACKSPACE' },
      callback: () => changePage(previousPage, false, false)
    }
  ];

  const createHeader = () => {
    const header = document.createElement('h2');
    header.classList.add('content-heading', 'border-bottom', 'mb-4', 'pb-2');
    header.innerHTML = 'Keybindings';
    return header;
  };

  const createHelpText = () => {
    const helpText = document.createElement('div');
    helpText.classList.add('font-size-sm', 'text-muted', 'ml-2', 'mb-2');
    helpText.innerHTML = 'Click a keybinding to remap to new keys.<br />ESC or click again to cancel remapping.<br />CTRL + ALT + SPACE to clear the mapping.';
    return helpText;
  };
  
  const createWrapper = (grid) => {
    const row = document.createElement('div');
    row.classList.add('row');
    const column = document.createElement('div');
    column.classList.add('col-md-6', 'offset-md-3');
    const wrapper = document.createElement('div');
    wrapper.classList.add('mb-4');
    
    wrapper.appendChild(createHelpText());
    wrapper.appendChild(grid);
    wrapper.appendChild(createResetButton());
    column.appendChild(wrapper);
    row.appendChild(column);
    
    return row;
  };
  
  const createGrid = () => {
    const grid = document.createElement('div');
    grid.classList.add('mkb-grid');
    return grid;
  };
  
  const createRow = (keybinding) => {
    const row = document.createElement('div');
    row.classList.add('mkb-row');

    row.addEventListener('click', () => beginListeningForRemap(keybinding));
    const nameCell = createCell(keybinding.name);
    const keyCell = createCell(keybinding.keys);
    row.appendChild(nameCell);
    row.appendChild(keyCell);

    keybinding.keyCell = keyCell;

    return row;
  };
  
  const createCell = (keysOrText) => {
    const cell = document.createElement('div');
    cell.classList.add('mkb-cell');
    if (typeof keysOrText === 'string') cell.innerHTML = keysOrText;
    else {
      if (keysOrText.ctrlKey) {
        cell.appendChild(createKbd('CTRL'));
        cell.appendChild(createPlus());
      }
      if (keysOrText.altKey) {
        cell.appendChild(createKbd('ALT'));
        cell.appendChild(createPlus());
      }
      if (keysOrText.key) cell.appendChild(createKbd(keysOrText.key));
    }
    return cell;
  };
  
  const createKbd = (text) => {
    const kbd = document.createElement('kbd');
    kbd.innerHTML = text;
    return kbd;
  };
  
  const createPlus = () => {
    const plus = document.createTextNode('+');
    return plus;
  };

  const createResetButton = () => {
    const resetButton = document.createElement('button');
    resetButton.type = 'button';
    resetButton.classList.add('btn', 'btn-sm', 'btn-danger', 'm-1');
    resetButton.innerHTML = 'Reset Default Keybindings';
    resetButton.addEventListener('click', resetDefaults);
    return resetButton;
  };

  const createStylesheet = () => {
    const stylesheet = document.createElement('style');
    stylesheet.innerHTML = 
    `.mkb-grid {
      background-color: #161a22;
      height: 300px;
      overflow-y: auto;
      width: 100%;
    }
    
    .mkb-row {
      cursor: pointer;
      display: flex;
    }
    
    .mkb-row:nth-of-type(even) {
      background-color: rgba(255, 255, 255, 0.03);
    }
    
    .mkb-row:hover {
      background-color: rgba(255, 255, 255, 0.1);
    }

    .mkb-row.mkb-listening {
      background-color: #577baa !important;
    }
    
    .mkb-cell {
      align-items: center;
      display: flex;
      flex: 1 1 auto;
      padding: 5px;
      width: 100%;
    }
    
    .mkb-grid kbd {
      background-color: hsl(210, 8%, 90%);
      border: 1px solid hsl(210, 8%, 65%);
      border-radius: 3px;
      box-shadow: 0 1px 1px hsla(210, 8%, 5%, 0.15),
        inset 1px 1px 0 #ffffff;
      color: hsl(210, 8%, 15%);
      font-size: 66%;
      margin: 0 5px;
      min-width: 26px;
      padding: 3px;
      text-align: center;
      text-shadow: #ffffff;
    }
    
    .mkb-grid kbd:first-of-type {
      margin-left: 0px;
    }
    
    .mkb-grid kbd:last-of-type {
      margin-right: 0px;
    }`;
    return stylesheet;
  };
  
  const inject = () => {
    const isGameLoaded = window.isLoaded && !window.currentlyCatchingUp;
      
    if (!isGameLoaded) {
      setTimeout(inject, 50);
      return;
    }

    const grid = createGrid();
    keybindings.forEach(k => {
      k.row = createRow(k);
      grid.appendChild(k.row);
    });

    settingsGrid = grid;

    const notifications = Array.from(document.querySelectorAll('#settings-container h2')).find(e => e.textContent === 'Notification Settings');
    notifications.parentNode.insertBefore(createHeader(), notifications);
    notifications.parentNode.insertBefore(createWrapper(grid), notifications);
    document.head.appendChild(createStylesheet());
  };

  const beginListeningForRemap = (keybinding) => {
    if (keybinding === bindingBeingRemapped) {
      endListeningForRemap();
      return;
    }
    endListeningForRemap();
    keybinding.row.classList.add('mkb-listening');
    bindingBeingRemapped = keybinding;
  };

  const endListeningForRemap = () => {
    if (!bindingBeingRemapped) return;
    bindingBeingRemapped.row.classList.remove('mkb-listening');
    bindingBeingRemapped = null;
  };

  const resetDefaults = () => {
    const reset = [];
    keybindings.forEach(k => {
      const conflict = keybindings.some(kb => reset.includes(kb.name) && parseKeypress(kb.keys) === parseKeypress(k.defaultKeys));
      reset.push(k.name);
      remap(conflict ? {} : k.defaultKeys, k);
    });
  };

  const remap = (keys, keybinding) => {
    if (keys.key) {
      const conflict = keybindings.find(k => k.name !== keybinding.name && parseKeypress(k.keys) === parseKeypress(keys));
      if (conflict) remap({}, conflict);
    }
    keybinding.keys = keys;
    if (settingsGrid !== null) {
      const keyCell = createCell(keys);
      keybinding.row.replaceChild(keyCell, keybinding.keyCell);
      keybinding.keyCell = keyCell;
    }

    saveData();
    updateKeymap();
  };

  const updateKeymap = () => {
    keymap = {};
    keybindings.forEach(k => {
      const keypress = parseKeypress(k.keys);
      if (keypress) keymap[keypress] = k.callback
    });
  };

  const toKeys = (e) => {
    if (!e.key) return {};
    let key = e.key.toUpperCase();
    if (key === 'ESCAPE') key = 'ESC';
    else if (key === ' ') key = 'SPACE';
    else if (key === '\n') key = 'ENTER';
    return { key, ctrlKey: e.ctrlKey, altKey: e.altKey, metaKey: e.metaKey };
  };

  const parseKeypress = (e) => {
    if (!e.key) return '';
    if (invalidKeys.includes(e.key)) return '';

    let keys = [];
    if (e.ctrlKey) keys.push('CTRL');
    if (e.altKey) keys.push('ALT');
    keys.push(e.key.toUpperCase());

    return keys.join('+');
  };

  const doNotTriggerKeybind = (e) => {
    return e.target.tagName == 'INPUT'
      || e.target.tagName == 'SELECT'
      || e.target.tagName == 'TEXTAREA'
      || e.target.isContentEditable; 
  };

  const saveData = () => {
    const data = keybindings.map(k => ({ bindTo: k.name, keys: k.keys }));
    const existingData = getSavedData();
    existingData.forEach(d => {
      if (!data.some(dt => dt.bindTo === d.bindTo))
        data.push(d);
    });
  localStorage.setItem('MKB-data', JSON.stringify(data));
  };

  const loadData = () => {
    const data = getSavedData();
    data.forEach(k => {
      const match = keybindings.find(kb => kb.name === k.bindTo);
      if (match) match.keys = k.keys;
    });
    keybindings.filter(k => !k.keys).forEach(k => k.keys = k.defaultKeys);
  };

  const getSavedData = () => {
    const dataJson = localStorage.getItem('MKB-data');
    if (!dataJson) return [];
    return JSON.parse(dataJson);
  };

  const onKeyPress = (e) => {
    if (doNotTriggerKeybind(e)) return true;
    if (e.repeat) return true;
    const keysPressed = parseKeypress(toKeys(e));
    if (!keysPressed) return true;

    if (bindingBeingRemapped) {
      if (e.key !== 'Escape') {
        if (e.ctrlKey && e.altKey && e.key === ' ') remap({}, bindingBeingRemapped);
        else remap(toKeys(e), bindingBeingRemapped);
      }
      endListeningForRemap();
      e.preventDefault();
      return false;
    }

    if (!keymap[keysPressed]) return true;

    e.preventDefault();
    keymap[keysPressed]();
    return false;
  };

  const trackCurrentPage = () => {
    if (window.currentPage === undefined) return;
    if (currentPage === cachePage) return;
      
    endListeningForRemap();
    previousPage = cachePage;
    cachePage = currentPage;
  };

  const initialize = () => {
    if (window.kb) return;

    console.log('Initializing Keybindings...');
    loadData();
    updateKeymap();
    document.addEventListener('keydown', onKeyPress);
    inject();
    setInterval(trackCurrentPage, 10);
    console.log('Keybindings initialized.');
  };

  const register = (name, category, defaultKeys, callback) => {
    if (typeof callback !== 'function') throw `Expected type of callback is function, instead found ${typeof callback}.`;

    const conflictingName = keybindings.find(k => k.name === name);
    if (conflictingName) throw `A keybinding with the name "${name}" already exists. Please select another name and try again.`;

    let keys = defaultKeys;
    if (defaultKeys && defaultKeys.key) {
      const conflictingKeys = keybindings.find(k => parseKeypress(k.keys) === parseKeypress(defaultKeys));
      if (conflictingKeys) {
        keys = {};
        console.warn(`A keybinding matching ${parseKeypress(defaultKeys)} already exists. "${name}" will be unbound by default.`);
      }
    }

    const keybinding = { name, category, defaultKeys, keys, callback };
    keybindings.push(keybinding);
    if (settingsGrid !== null) {
      keybinding.row = createRow(keybinding);
      settingsGrid.appendChild(keybinding.row);
    }
    
    const savedData = getSavedData().find(d => d.bindTo === name);
    if (savedData) remap(savedData.keys, keybinding);
    updateKeymap();
  };

  initialize();

  return {
    register,
  };
})();