Klanowicze online

Dodatek do gry Margonem

// ==UserScript==
// @name         Klanowicze online
// @author       Reskiezis
// @description  Dodatek do gry Margonem
// @version      2.0.3
// @match        *://*.margonem.pl/
// @match        *://*.margonem.com/
// @run-at       document-idle
// @grant        none
// @namespace    https://greasyfork.org/users/233329
// ==/UserScript==
 
/*
  - - -
  KLANOWICZE ONLINE
  AUTORSTWA RESKIEZISA aka PERSKIEGO KOTA
  WERSJA DLA NOWEGO I STAREGO INTERFEJSU
  - - -
 
  - - - - - - -
  GARMORY ZNOWU POPSULO DODATEK?
  POPROS SWOJEGO DODATKOPISARZA O NAPRAWE!
 
  Garmory czesto cos zmienia, ale dzieki temu mozna przewidziec co sie zepsulo.
  1. Najczestszy problem - zmiana struktury listy krotek (zlaczonych tablic zawierajacych id gracza, imie itd...)
     PATRZ linia 105
  2. Nowa automatycznie wykonywana funkcja po wywolaniu _g('clan&a=members') lub zmiana w nazewnictwie funkcji/elementow UI, ktore sa wykorzystywane do ominiecia automatycznego wywolania tej funkcji
     PATRZ metody ApplicationSI.prototype.fetchMembers lub ApplicationNI.prototype.fetchMembers
  3. Zmiana nazwy wlasciwosci w obiekcie zwracanym przez _g('clan&a=members').
     (kiedys wlasciwosc members nazywala sie members2)
 
  Otworzenie konsoli w Chrome - CTRL+SHIFT+J
*/
 
;(function(){
    'use strict';
 
    // czy gracz gra na Nowym Interfejsie?
    var isNewInterface = typeof window.Engine !== 'undefined' && typeof window.Engine.hero !== 'undefined'
 
    /*
    \/ \/ \/
    SEKCJA UI START
    Wyjatek: metody renderMembers i setBattleInfo sa wykorzystywane z poziomu klasy Application
  */
    var STORAGE_KEY = 'klanowicze_online'
 
    // Enum - przyjmuje jedna z dwoch wartosci
    // SizeEnum.NORMAL albo SizeEnum.COMPRESSED
    var SizeEnum = {
        NORMAL: 0,
        COMPRESSED: 1
    }
 
    function Popup(events){
        /*
      Metody z klasy Application obslugujace zdarzenia.
      events: {
        startFetchingInIntervals(),
        stopFetchingInIntervals(),
        addToGroup(),
        sendMessageTo()
      }
    */
        this.events = events
 
        // stan UI komponentu
        this.state = {
            hidden: false,
            top: 10,
            left: 10,
            size: SizeEnum.NORMAL
        }
 
        // zaladuj poprzedni stan UI komponentu z dysku, o ile istnieje
        this.loadStateFromDisk()
 
        // elementy HTML
        this.kobox = null
        this.title = null
        this.expandButton = null
        this.membersTable = null
        this.hideButton = null
 
        // stworz strukture, przypisz elementy html do obiektu i nasluchuj zdarzenia
        this.build()
 
        // upewnij sie, ze okienko jest widoczne w przegladarce
        this.noOverflow()
 
        // dopasuj wyglad w zaleznosci od this.state.size
        this.implementStateSize()
    }
 
    Popup.prototype.renderMembers = function(members){
        this.title.removeAttribute('data-battleinfo')
 
        var tbody = document.createElement('tbody')
 
        var includesHero = false
        var count = 0
        var MEMBERS_TUPLE_LENGTH = 10
 
        /*
      tablica members to ciag zlaczonych tablic (krotek) typu:
      [ id, nick, lvl, prof, map, x, y, ?, loggedTimeAgo, icon ]
      rozmar jednej takiej tablicy przechowywany jest w stalej MEMBERS_TUPLE_LENGTH
 
      > > > UWAGA! < < <
      PRAWDOPODOBNIE COS SIE KIEDYS ZMIENI W STRUKTURZE TEJ TABLICY
      PRZY TESTOWANIU WARTO JA WYPISAC Z console.log(members)
 
      Zmiany w przeszlosci:
      - dodano 10 element, czyli sciezke do wygladu postaci (icon)
      - loggedTimeAgo (9 element) przechowywal wartosc 'online' gdy gracz byl zalogowany
    */
 
        for(var j = 0; j <= members.length; j += MEMBERS_TUPLE_LENGTH){
            // jezeli dany gracz jest zalogowany to loggedTimeAgo (dziewiaty element krotki) jest rowny zero
            if(members[j+8] !== 0)
                continue
 
            count++
 
            // nie pokazuj wlasnej postaci na liscie zalogowanych klanowiczow
            var nick = members[j+1]
            if(isNewInterface ? nick === window.Engine.hero.d.nick : nick === hero.nick){
                includesHero = true
                continue
            }
 
            var id = members[j]
            var lvl = members[j+2]
            var prof = members[j+3]
            var map = members[j+4]
            var x = members[j+5]
            var y = members[j+6]
 
            var row = tbody.insertRow()
            row.classList.add('ko-row')
 
            var addToGroupCell = row.insertCell()
            addToGroupCell.textContent = '+'
            if(isNewInterface) addToGroupCell.dataset.tip = 'Dodaj do grupy'
            else addToGroupCell.setAttribute('tip', 'Dodaj do grupy')
            addToGroupCell.classList.add('ko-add-to-group-cell')
            addToGroupCell.addEventListener('click', this.events.addToGroup.bind(this, id))
 
            var nickCell = row.insertCell()
            nickCell.textContent = `${nick} (${lvl}${prof})`
      nickCell.classList.add('ko-nick-cell')
        nickCell.addEventListener('click', this.events.sendMessageTo.bind(this, nick))
 
        var locationTip = `${map} (${x},${y})`
      if(this.state.size == SizeEnum.COMPRESSED){
          if(isNewInterface) nickCell.dataset.tip = locationTip
          else  nickCell.setAttribute('tip', locationTip)
      } else {
          var mapCell = row.insertCell()
          mapCell.textContent = map
          mapCell.classList.add('ko-map-cell')
          if(isNewInterface) mapCell.dataset.tip = locationTip
          else mapCell.setAttribute('tip', locationTip)
      }
    }
 
      if(!includesHero)
          count++
 
      if(this.state.size == SizeEnum.COMPRESSED)
          this.title.textContent = `Online: ${count}`
    else
        this.title.textContent = `Klanowicze online: ${count}`
 
    var titleTipText = count === 1
    ? 'Jesteś tylko ty'
    : `${count} klanowiczów (łącznie z tobą)`
 
    if(isNewInterface) this.title.dataset.tip = titleTipText
      else this.title.setAttribute('tip', titleTipText)
 
      if(this.membersTable.tBodies.length === 0){
          this.membersTable.appendChild(tbody)
          return
      }
 
      this.membersTable.replaceChild(tbody, this.membersTable.tBodies[0])
  }
 
    Popup.prototype.setBattleInfo = function(){
        this.title.textContent = this.state.size === SizeEnum.COMPRESSED
            ? 'Walka'
        : 'Gracz uczestniczy w walce'
 
        if(isNewInterface) this.title.dataset.tip = 'Dodatek aktywuje się po zakończeniu walki'
        else this.title.setAttribute('tip', 'Dodatek aktywuje się po zakończeniu walki')
 
        this.title.setAttribute('data-battleinfo', '1')
    }
 
    Popup.prototype.handleHideButtonClick = function(){
        var newHidden = !this.state.hidden
        this.state.hidden = newHidden
        this.membersTable.hidden = this.state.hidden
        this.saveStateToDisk()
        if(this.state.hidden){
            this.hideButton.textContent = 'Rozwiń'
            this.events.stopFetchingInIntervals()
        } else {
            this.hideButton.textContent = 'Zwiń'
            this.events.startFetchingInIntervals()
        }
    }
 
    Popup.prototype.implementStateSize = function(){
        // aktualizacja klasy
        if(this.state.size === SizeEnum.COMPRESSED){
            this.kobox.classList.add('compressed')
        } else {
            this.kobox.classList.remove('compressed')
        }
 
        // aktualizacja tekstu
        if(this.title.getAttribute('data-battleinfo')){
            // wyswietlono wczesniej informacje o walce, nie zmieniaj
            this.setBattleInfo()
        } else {
            var lastOnline = this.title.textContent.split(': ')[1]
            if(lastOnline === undefined)
                lastOnline = '-'
 
            if(this.state.size === SizeEnum.COMPRESSED){
                this.title.textContent = `Online: ${lastOnline}`
      } else {
          this.title.textContent = `Klanowicze online: ${lastOnline}`
      }
    }
  }
 
    Popup.prototype.handleExpandButtonClick = function(){
        var nextSize = (this.state.size + 1) % 2
        this.state.size = nextSize
        this.saveStateToDisk()
        this.implementStateSize()
    }
 
    Popup.prototype.loadStateFromDisk = function(){
        try {
            var state = JSON.parse(
                localStorage.getItem(STORAGE_KEY)
            )
 
            if(state.areMembersHidden !== undefined || state.wasMembersHidden !== undefined)
                throw 'Stary sposób zapisu'
 
            if(state.hidden !== undefined && state.top !== undefined && state.left !== undefined && state.size !== undefined)
                this.state = state
        } catch(error) {
            console.log('Klanowicze online: błędna konfiguracja, reset. Powód:', error)
            localStorage.removeItem(STORAGE_KEY)
        }
    }
 
    Popup.prototype.saveStateToDisk = function(){
        // funkcja w setTimeout tworzy nowy this
        var self = this
 
        // nie zatrzymuj petli zdarzen
        setTimeout(function(){
            localStorage.setItem(STORAGE_KEY, JSON.stringify(self.state))
        }, 0)
    }
 
    // ogranicz pozycje okna do widzialnej czesci ekranu
    Popup.prototype.noOverflow = function(){
        var { top, left, width } = this.kobox.getBoundingClientRect()
 
        var oneThird = Math.ceil(1/3*width)
 
        if(top < 0)
            this.kobox.style.top = `0px`
    else if(top > window.innerHeight - 18)
        this.kobox.style.top = `${window.innerHeight - 18}px`
 
    if(left < 0 - oneThird*2)
        this.kobox.style.left = `${0 - oneThird*2}px`
    else if(left > window.innerWidth - oneThird)
        this.kobox.style.left = `${window.innerWidth - oneThird}px`
 
    // zapisz zmiany
    if(this.state.top !== top || this.state.left !== left){
        this.state.top = top
        this.state.left = left
        this.saveStateToDisk()
    }
  }
 
    Popup.prototype.build = function(){
        // struktura HTML
        $(document.body).append(`
      <div id="kobox">
        <div class="header">
          <span ctip="t_npc"></span>
          <img class="expand" tip="Zmień wielkość" ctip="t_npc" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE5IDEyaC0ydjNoLTN2Mmg1di01ek03IDloM1Y3SDV2NWgyVjl6bTE0LTZIM2MtMS4xIDAtMiAuOS0yIDJ2MTRjMCAxLjEuOSAyIDIgMmgxOGMxLjEgMCAyLS45IDItMlY1YzAtMS4xLS45LTItMi0yem0wIDE2SDNWNWgxOFYxOXoiLz48L3N2Zz4=">
        </div>
        <table></table>
        <div class="hide">Zwiń</div>
        <div class="corner1"></div>
        <div class="corner2"></div>
      </div>
    `);
 
      // przypisz elementy do obiektu
      this.kobox = document.querySelector('#kobox')
      this.title = this.kobox.querySelector('.header span')
      this.expandButton = this.kobox.querySelector('.header img')
      this.membersTable = this.kobox.querySelector('table')
      this.hideButton = this.kobox.querySelector('.hide')
 
      // zaktualizuj wyglad
      this.kobox.style.left = `${this.state.left}px`
    this.kobox.style.top = `${this.state.top}px`
    this.hideButton.textContent = this.state.hidden
        ? 'Rozwiń'
    : 'Zwiń'
      this.membersTable.hidden = this.state.hidden
 
      if(isNewInterface) this.expandButton.dataset.tip = "Zmień wielkość"
      else this.expandButton.setAttribute('tip', 'Zmień wielkość')
 
      // obsluz zdarzenia
      this.hideButton.addEventListener('click', this.handleHideButtonClick.bind(this))
      this.expandButton.addEventListener('click', this.handleExpandButtonClick.bind(this))
 
      var self = this
      // przeciaganie okienka
      $(this.kobox).draggable({
          cancel: 'table, .hide, .expand',
          start: function(){
              if(!isNewInterface) g.lock.add('ko')
          },
          stop: function(){
              if(!isNewInterface) g.lock.remove('ko')
              self.noOverflow()
          }
      })
 
      // style
      var stylesheet = document.createElement('style')
      stylesheet.appendChild(document.createTextNode(`
      #kobox {
        font-family: Helvetica;
        box-sizing: border-box;
        position: absolute !important;
        border: 3px gold double;
        color: #eeeeee;
        background: black;
        z-index: 500;
        font-size: 14px;
        width: 12em;
      }
 
      #kobox.compressed {
        width: 8em;
      }
 
      @media (min-width: 1500px) {
        #kobox {
          font-size: 16px;
        }
      }
 
      @media (min-width: 1700px) {
        #kobox {
          font-size: 17px;
          width: 16em;
        }
        #kobox.compressed {
          width: 10em;
        }
      }
 
      #kobox > .header {
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: 3px;
        font-size: 1em;
        text-align: center;
        font-weight: bold;
        border-bottom: 1px solid gold;
        z-index: 1;
      }
 
      #kobox > .header {
        cursor: move;
        cursor: -webkit-grab;
        cursor:    -moz-grab;
        cursor:         grab;
      }
      #kobox > .header:active {
        cursor: -webkit-grabbing;
        cursor:    -moz-grabbing;
        cursor:         grabbing;
      }
 
      #kobox > .header > span {
        pointer-events: none;
      }
 
      #kobox > .header > .expand {
        height: 1em;
        cursor: pointer;
        opacity: 0.7;
      }
      #kobox > .header > .expand:hover {
        opacity: 0.9;
      }
 
      #kobox > table {
        font-size: 0.7em;
        width: 100%;
        border-collapse: collapse;
        table-layout: fixed;
      }
 
      #kobox > .hide {
        font-size: 0.8em;
        margin: 1px;
        text-align: center;
        cursor: pointer;
        border-top: 1px solid gold;
        z-index: 1;
        user-select: none;
      }
 
      #kobox > .corner1, .corner2 {
        position: absolute;
        width: 35px;
        height: 23px;
        z-index: -1;
      }
      #kobox > .corner1 {
        background: url(img/tip-cor.png) no-repeat 0px 0px;
        top: -6px;
        left: -6px;
      }
      #kobox > .corner2 {
        background: url(img/tip-cor.png) no-repeat -35px 0px;
        bottom: -6px;
        right: -6px;
      }
 
      #kobox > table > tbody > .ko-row {
        border: solid;
        border-width: 1px 0;
        border-color: #5d5006;
        height: 1.6em;
      }
      #kobox > table > tbody > .ko-row:hover {
        background: #3c3c16;
      }
      #kobox > table > tbody > .ko-row:first-child {
        border-top: none;
      }
      #kobox > table > tbody > .ko-row:last-child {
        border-bottom: none;
      }
 
      #kobox > table > tbody > .ko-row > .ko-add-to-group-cell, .ko-nick-cell {
        cursor: pointer;
        user-select: none;
      }
      #kobox > table > tbody > .ko-row > .ko-add-to-group-cell:hover, .ko-nick-cell:hover {
        color: #eaeb74;
      }
 
      #kobox > table > tbody > .ko-row > .ko-add-to-group-cell {
        text-align: center;
        width: 12px;
      }
 
      #kobox > table > tbody > .ko-row > .ko-map-cell {
        text-align: right;
        white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
      }
    `))
      document.body.appendChild(stylesheet)
  }
    /*
    SEKCJA UI KONIEC
    /\ /\ /\
  */
 
    /*
    \/ \/ \/
    KLASA APPLICATION
  */
    // pomocnicza funkcja do deklaracji metod abstrakcyjnych (ktore musza zostac nadpisane przez dzieci)
    var abstractMethod = function(){
        throw new Error('Klanowicze online: wywolanie metody abstrakcyjnej')
    }
 
    function Application(){
        // konstruktor
 
        this.interval = null
 
        this.popup = new Popup({
            startFetchingInIntervals: this.startFetchingInIntervals.bind(this),
            stopFetchingInIntervals: this.stopFetchingInIntervals.bind(this),
            addToGroup: this.addToGroup.bind(this),
            sendMessageTo: this.sendMessageTo.bind(this)
        })
 
        if(!this.popup.hidden)
            this.startFetchingInIntervals()
    }
    // metody:
    Application.prototype.startFetchingInIntervals = function(){
        this.fetchMembers()
        this.interval = setInterval(this.fetchMembers.bind(this), 10000)
    }
    Application.prototype.stopFetchingInIntervals = function(){
        if(this.interval !== null){
            clearInterval(this.interval)
            this.interval = null
        }
    }
    // metody abstrakcyjne (musza byc nadpisane przez dzieci):
    Application.prototype.fetchMembers = abstractMethod
    Application.prototype.addToGroup = abstractMethod
    Application.prototype.sendMessageTo = abstractMethod
    Application.prototype.checkIfIsInBattle = abstractMethod
 
    /*
    \/ \/ \/
    DZIECI KLASY APPLICATION (z nadpisanymi metodami pod Nowy Interfejs i Stary Interfejs)
  */
 
    // Stary Interfejs
    function ApplicationSI(){
        var self = this
 
        // ostatnia pobrana lista klanowiczow
        var lastFetchedMembers = null
 
        // gracz otworzyl okno z klanowiczami
        var isOpenedMembersWindow = false
        document.querySelector('#clanmenu span[name="Klanowicze"]').parentElement.addEventListener('click', () => {
            isOpenedMembersWindow = true
        })
 
        var parseInput = window.parseInput
        window.parseInput = function(d, callback, xhr){
            if(d.w && (d.w.toString().startsWith('Zapytanie odrzucone') || d.w.toString().startsWith('Odrzucono stare zapytanie')))
                delete d.w
 
            if(!d.members2 && !d.members)
                return parseInput(d, callback, xhr)
 
            if(isOpenedMembersWindow){
                // gracz otworzyl okno z klanowiczami
                isOpenedMembersWindow = false
            } else {
                // lista klanowiczow przechwycona przez dodatek
                if(d.members) lastFetchedMembers = d.members.slice()
                delete d.members2
                delete d.members
            }
 
            return parseInput(d, callback, xhr)
        }
 
        this.fetchMembers = function(){
            // pierwsze zaladowanie strony - wyswietl info o walce
            if(self.checkIfIsInBattle() && lastFetchedMembers === null){
                self.popup.setBattleInfo()
            }
 
            _g('clan&a=members', function(){
                if(lastFetchedMembers)
                    self.popup.renderMembers(lastFetchedMembers)
            })
        }
 
        this.checkIfIsInBattle = function(){
            return Boolean(g.battle)
        }
 
        this.addToGroup = function(id){
            window._g(`party&a=inv&id=${id}`)
        }
 
        this.sendMessageTo = function(nick){
            getEngine().chatController.getChatInputWrapper().setPrivateMessageProcedure(nick)
        }
 
        Application.call(this)
    }
 
    // Nowy Interfejs
    function ApplicationNI(){
        var self = this
 
        // jesli gracz nie ma klanu to wyjdz
        if(!window.Engine.hero.d.clan)
            return
 
        const NO_CHAT_INPUT_WARN = 'Klanowicze online: chatInputElement ma wartosc null - potrzebny jest nowy selektor okienka tekstowego chatu.\nSkontaktuj sie z dodatkopisarzem.'
 
        var chatInputElement = document.querySelector('.chat-tpl input')
        if(chatInputElement === null){
            console.warn(NO_CHAT_INPUT_WARN);
        }
 
        var fetchedMembersBefore = false
 
        this.fetchMembers = function(){
            if(self.checkIfIsInBattle() && !fetchedMembersBefore){
                self.popup.setBattleInfo()
            }
 
            // nie przeszkadzaj gdy gracz zmienia postac lub pisze wiadomosc
            if(Engine.logOff || document.activeElement === chatInputElement)
                return
 
            var clan = Engine.clan ? { ...Engine.clan } : Engine.clan
            if(!clan)
                Engine.clan = {
                    updateMembers(){}
                }
 
            _g(`clan&a=members`, function({ members }){
                Engine.clan = clan
 
                if(members){
                    self.popup.renderMembers(members)
 
                    if(!fetchedMembersBefore)
                        fetchedMembersBefore = true
                }
            })
        }
 
        this.checkIfIsInBattle = function(){
            return window.Engine.battle && window.Engine.battle.show
        }
 
        this.addToGroup = function(id){
            window._g(`party&a=inv&id=${id}`)
        }
 
        this.sendMessageTo = function(nick){
            getEngine().chatController.getChatInputWrapper().setPrivateMessageProcedure(nick)
        }
 
        Application.call(this)
    }
 
    // dziedziczenie (NIE RUSZAJ TEGO)
    ApplicationSI.prototype = Object.create(Application.prototype);
    ApplicationSI.prototype.constructor = ApplicationSI
    ApplicationNI.prototype = Object.create(Application.prototype);
    ApplicationNI.prototype.constructor = ApplicationNI
 
    // funkcja pomocnicza, ktora czeka az funkcja "check" zwroci prawde i wtedy wywola funkcje "then"
    var waitFor = function(check, then){
        if(!check())
            setTimeout(waitFor, 1000, check, then)
        else
            then()
    }
 
    if(isNewInterface){
        waitFor(function(){
            // czekaj na pelne zaladowanie gry
            return window.Engine && window.Engine.allInit
        }, function(){
            new ApplicationNI()
        })
    } else {
        waitFor(function(){
            // czasem zdarzy sie, ze TamperMonkey wykona sie przed skryptem Margonem i zmienna g jest niezainicjowana - czekaj na zaladowanie
            return window.g !== undefined
        }, function(){
            window.g.loadQueue.push({ fun: function(){
                new ApplicationSI()
            } })
        })
    }
})();