From 434afefeaa6af9c00a8cef02c38ee8015fa51dc7 Mon Sep 17 00:00:00 2001 From: Guangcong Luo Date: Mon, 18 Feb 2019 17:25:53 -0600 Subject: [PATCH] Massively improve tooltips - Tooltips work in replays now - Calculation is better (correctly handles more corner cases) - Explanations are better (better messages for Magic Room etc) - Tooltips for sidebar pokemon - Support "locking" tooltips with long-click / long-tap - Can copy/paste from locked tooltips - Increased font size --- .eslintignore | 1 + .eslintrc.js | 1 + .gitignore | 1 + data/text.js | 7 + index.template.html | 2 +- js/client-battle.js | 50 +- src/battle-animations.ts | 41 +- src/battle-dex.ts | 145 ++-- src/battle-tooltips.ts | 1513 ++++++++++++++++++++++++++++++++++++++ src/battle.ts | 171 +++-- style/battle.css | 56 +- style/client.css | 48 +- testclient.html | 2 +- tsconfig.json | 2 +- tslint.json | 3 +- 15 files changed, 1841 insertions(+), 202 deletions(-) create mode 100644 src/battle-tooltips.ts diff --git a/.eslintignore b/.eslintignore index 520c87cc5..a7b160847 100644 --- a/.eslintignore +++ b/.eslintignore @@ -12,3 +12,4 @@ node_modules/ /js/battle-animations-moves.js /js/battle-scene-stub.js /js/battle-animations.js +/js/battle-tooltips.js diff --git a/.eslintrc.js b/.eslintrc.js index b6095be66..40f5ad8c8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -23,6 +23,7 @@ module.exports = { "BattleFormats": false, "BattleFormatsData": false, "BattleLearnsets": false, "BattleItems": false, "BattleMoveAnims": false, "BattleMovedex": false, "BattleNatures": false, "BattleOtherAnims": false, "BattlePokedex": false,"BattlePokemonSprites": false, "BattlePokemonSpritesBW": false, "BattleSearchCountIndex": false, "BattleSearchIndex": false, "BattleArticleTitles": false, "BattleSearchIndexOffset": false, "BattleSearchIndexType": false, "BattleStatIDs": false, "BattleStatNames": false, "BattleStats": false, "BattleStatusAnims": false, "BattleStatuses": false, "BattleTeambuilderTable": false, + "ModifiableValue": false, // Generic global variables "Config": false, "BattleSearch": false, "soundManager": false, "Storage": false, "Dex": false, diff --git a/.gitignore b/.gitignore index c2086a339..f36d7417e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ package-lock.json /js/battle-dex-data.js /js/battle-animations-moves.js /js/battle-animations.js +/js/battle-tooltips.js /js/battle-scene-stub.js .vscode diff --git a/data/text.js b/data/text.js index 6baab40d8..73cd5e221 100644 --- a/data/text.js +++ b/data/text.js @@ -124,21 +124,27 @@ exports.BattleText = { // stats hp: { statName: "HP", + statShortName: "HP", }, atk: { statName: "Attack", + statShortName: "Atk", }, def: { statName: "Defense", + statShortName: "Def", }, spa: { statName: "Special Attack", + statShortName: "SpA", }, spd: { statName: "Special Defense", + statShortName: "SpD", }, spe: { statName: "Speed", + statShortName: "Spe", }, accuracy: { statName: "accuracy", @@ -148,6 +154,7 @@ exports.BattleText = { }, spc: { statName: "Special", + statShortName: "Spc", }, stats: { statName: "stats", diff --git a/index.template.html b/index.template.html index f2128c4b3..d6b210c99 100644 --- a/index.template.html +++ b/index.template.html @@ -134,7 +134,7 @@ ga('send', 'pageview'); - + diff --git a/js/client-battle.js b/js/client-battle.js index 8acab9474..8616ee05b 100644 --- a/js/client-battle.js +++ b/js/client-battle.js @@ -23,7 +23,8 @@ BattleSound.setMute(Dex.prefs('mute')); this.battle = new Battle(this.$battle, this.$chatFrame, this.id); - this.tooltips = new BattleTooltips(this.battle, this); + this.tooltips = this.battle.scene.tooltips; + this.tooltips.listen(this.$controls); this.battle.roomid = this.id; this.battle.joinButtons = true; @@ -333,7 +334,7 @@ act = this.request.requestType; if (this.request.side) { - switchables = this.myPokemon; + switchables = this.battle.myPokemon; } if (!this.finalDecision) this.finalDecision = !!this.request.noCancel; } @@ -407,7 +408,7 @@ // Request full team order if one of our Pokémon has Illusion for (var i = 0; i < switchables.length && i < 6; i++) { if (toId(switchables[i].baseAbility) === 'illusion') { - this.choice.count = this.myPokemon.length; + this.choice.count = this.battle.myPokemon.length; } } if (this.battle.teamPreviewCount) { @@ -491,7 +492,7 @@ app.addPopup(TimerPopup, {room: this}); }, updateMoveControls: function (type) { - var switchables = this.request && this.request.side ? this.myPokemon : []; + var switchables = this.request && this.request.side ? this.battle.myPokemon : []; if (type !== 'movetarget') { while (switchables[this.choice.choices.length] && switchables[this.choice.choices.length].fainted && this.choice.choices.length + 1 < this.battle.mySide.active.length) { @@ -555,7 +556,7 @@ } else if (!pokemon || pokemon.fainted) { targetMenus[0] += ' '; } else { - targetMenus[0] += ' '; + targetMenus[0] += ' '; } } for (var i = 0; i < myActive.length; i++) { @@ -574,7 +575,7 @@ } else if (!pokemon || pokemon.fainted) { targetMenus[1] += ' '; } else { - targetMenus[1] += ' '; + targetMenus[1] += ' '; } } @@ -593,6 +594,7 @@ var hasMoves = false; var moveMenu = ''; var movebuttons = ''; + var typeValueTracker = new ModifiableValue(this.battle, this.battle.mySide.active[pos], this.battle.myPokemon[pos]); for (var i = 0; i < curActive.moves.length; i++) { var moveData = curActive.moves[i]; var move = Dex.getMove(moveData.move); @@ -602,11 +604,11 @@ if (move.id === 'Struggle' || move.id === 'Recharge') pp = '–'; if (move.id === 'Recharge') move.type = '–'; if (name.substr(0, 12) === 'Hidden Power') name = 'Hidden Power'; - var moveType = this.tooltips.getMoveType(move, this.battle.mySide.active[pos] || this.myPokemon[pos]); + var moveType = this.tooltips.getMoveType(move, typeValueTracker)[0]; if (moveData.disabled) { - movebuttons += ' '; @@ -619,9 +621,9 @@ for (var i = 0; i < curActive.moves.length; i++) { var moveData = curActive.moves[i]; var move = Dex.getMove(moveData.move); - var moveType = this.tooltips.getMoveType(move, this.battle.mySide.active[pos] || this.myPokemon[pos]); + var moveType = this.tooltips.getMoveType(move, typeValueTracker)[0]; if (canZMove[i]) { - movebuttons += ' '; } else { movebuttons += ''; @@ -663,9 +665,9 @@ var pokemon = switchables[i]; pokemon.name = pokemon.ident.substr(4); if (pokemon.fainted || i < this.battle.mySide.active.length || this.choice.switchFlags[i]) { - switchMenu += ' '; + switchMenu += ' '; } else { - switchMenu += ' '; + switchMenu += ' '; } } if (this.finalDecisionSwitch && this.battle.gen > 2) { @@ -696,7 +698,7 @@ } } - var switchables = this.request && this.request.side ? this.myPokemon : []; + var switchables = this.request && this.request.side ? this.battle.myPokemon : []; var myActive = this.battle.mySide.active; var requestTitle = ''; @@ -710,13 +712,13 @@ requestTitle += "Which Pokémon will it switch in for?"; var controls = '
'; for (var i = 0; i < myActive.length; i++) { - var pokemon = this.myPokemon[i]; + var pokemon = this.battle.myPokemon[i]; if (pokemon && !pokemon.fainted || this.choice.switchOutFlags[i]) { - controls += ' '; + controls += ' '; } else if (!pokemon) { controls += ' '; } else { - controls += ' '; + controls += ' '; } } controls += '
'; @@ -737,9 +739,9 @@ for (var i = 0; i < switchables.length; i++) { var pokemon = switchables[i]; if (pokemon.fainted || i < this.battle.mySide.active.length || this.choice.switchFlags[i]) { - switchMenu += ' '; } @@ -760,7 +762,7 @@ } }, updateTeamControls: function (type) { - var switchables = this.request && this.request.side ? this.myPokemon : []; + var switchables = this.request && this.request.side ? this.battle.myPokemon : []; var maxIndex = Math.min(switchables.length, 24); var requestTitle = ""; @@ -775,9 +777,9 @@ var oIndex = this.choice.teamPreview[i] - 1; var pokemon = switchables[oIndex]; if (i < this.choice.done) { - switchMenu += ' '; + switchMenu += ' '; } else { - switchMenu += ' '; + switchMenu += ' '; } } @@ -859,7 +861,7 @@ buf += 'use ' + Dex.getMove(move).name + (target ? ' against ' + target : '') + '.
'; break; case 'switch': - buf += '' + this.myPokemon[parts[1] - 1].species + ' will switch in'; + buf += '' + this.battle.myPokemon[parts[1] - 1].species + ' will switch in'; if (myActive[i]) { buf += ', replacing ' + myActive[i].species; } @@ -946,7 +948,7 @@ } }, updateSide: function (sideData) { - this.myPokemon = sideData.pokemon; + this.battle.myPokemon = sideData.pokemon; for (var i = 0; i < sideData.pokemon.length; i++) { var pokemonData = sideData.pokemon[i]; this.battle.parseDetails(pokemonData.ident.substr(4), pokemonData.ident, pokemonData.details, pokemonData); diff --git a/src/battle-animations.ts b/src/battle-animations.ts index 15e4d2d38..1c3dee0fb 100644 --- a/src/battle-animations.ts +++ b/src/battle-animations.ts @@ -60,6 +60,8 @@ class BattleScene { $messagebar: JQuery = null!; $delay: JQuery = null!; $hiddenMessage: JQuery = null!; + $tooltips: JQuery = null!; + tooltips: BattleTooltips; sideConditions: [{[id: string]: Sprite[]}, {[id: string]: Sprite[]}] = [{}, {}]; @@ -109,6 +111,8 @@ class BattleScene { numericId = Math.floor(Math.random() * 1000000); } this.numericId = numericId; + this.tooltips = new BattleTooltips(battle); + this.tooltips.listen($frame[0]); this.preloadEffects(); // reset() is called during battle initialization, so it doesn't need to be called here @@ -156,6 +160,24 @@ class BattleScene { this.$messagebar = $('
'); this.$delay = $('
'); this.$hiddenMessage = $(''); + this.$tooltips = $('
'); + + let tooltipBuf = ''; + const tooltips = { + p2c: {top: 70, left: 250, width: 80, height: 100, tooltip: 'activepokemon|1|2'}, + p2b: {top: 85, left: 320, width: 90, height: 100, tooltip: 'activepokemon|1|1'}, + p2a: {top: 90, left: 390, width: 100, height: 100, tooltip: 'activepokemon|1|0'}, + p1a: {top: 200, left: 130, width: 120, height: 160, tooltip: 'activepokemon|0|0'}, + p1b: {top: 200, left: 250, width: 150, height: 160, tooltip: 'activepokemon|0|1'}, + p1c: {top: 200, left: 350, width: 150, height: 160, tooltip: 'activepokemon|0|2'}, + }; + for (const id in tooltips) { + let layout = tooltips[id as 'p1a']; + tooltipBuf += `
`; + } + this.$tooltips.html(tooltipBuf); this.$battle.append(this.$bg); this.$battle.append(this.$terrain); @@ -170,6 +192,7 @@ class BattleScene { this.$battle.append(this.$messagebar); this.$battle.append(this.$delay); this.$battle.append(this.$hiddenMessage); + this.$battle.append(this.$tooltips); if (!this.animating) { this.$battle.append('
seeking...
'); @@ -557,7 +580,7 @@ class BattleScene { } else { let statustext = ''; if (pokemon.hp !== pokemon.maxhp) { - statustext += pokemon.hpDisplay(); + statustext += Pokemon.getHPText(pokemon); } if (pokemon.status) { if (statustext) statustext += '|'; @@ -578,23 +601,23 @@ class BattleScene { for (let i = 0; i < pokemonCount; i++) { let poke = side.pokemon[i]; if (i >= side.totalPokemon && i >= side.pokemon.length) { - pokemonhtml += ''; + pokemonhtml += ``; } else if (noShow && poke && poke.fainted) { - pokemonhtml += ''; + pokemonhtml += ``; } else if (noShow && poke && poke.status) { - pokemonhtml += ''; + pokemonhtml += ``; } else if (noShow) { - pokemonhtml += ''; + pokemonhtml += ``; } else if (!poke) { - pokemonhtml += ''; + pokemonhtml += ``; } else if (!poke.ident && this.battle.teamPreviewCount && this.battle.teamPreviewCount < side.pokemon.length) { const details = this.getDetailsText(poke); - pokemonhtml += ''; + pokemonhtml += ``; } else { const details = this.getDetailsText(poke); - pokemonhtml += ''; + pokemonhtml += ``; } - if (i % 3 === 2) pokemonhtml += '
'; + if (i % 3 === 2) pokemonhtml += `
`; } pokemonhtml = '
' + pokemonhtml + '
'; const $sidebar = (side.n ? this.$rightbar : this.$leftbar); diff --git a/src/battle-dex.ts b/src/battle-dex.ts index 54c7b932d..03f968f79 100644 --- a/src/battle-dex.ts +++ b/src/battle-dex.ts @@ -68,6 +68,15 @@ if (!Object.assign) { return thing; }; } +if (!Object.values) { + Object.values = function values(thing: any) { + let out: any[] = []; + for (let k in thing) { + out.push(thing[k]); + } + return out; + }; +} // if (!Object.create) { // Object.create = function (proto) { // function F() {} @@ -149,31 +158,39 @@ interface SpriteData { shiny?: boolean; } -const Dex = { - gen: 7, +const Dex = new class implements ModdedDex { + readonly gen = 7; + readonly modid = 'gen7' as ID; + readonly cache = null!; - resourcePrefix: (() => { + readonly statNames: ReadonlyArray = ['hp', 'atk', 'def', 'spa', 'spd', 'spe']; + readonly statNamesExceptHP: ReadonlyArray = ['atk', 'def', 'spa', 'spd', 'spe']; + + resourcePrefix = (() => { let prefix = ''; if (!window.document || !document.location || document.location.protocol !== 'http:') prefix = 'https:'; return prefix + '//play.pokemonshowdown.com/'; - })(), + })(); - fxPrefix: (() => { + fxPrefix = (() => { if (window.document && document.location && document.location.protocol === 'file:') { if (window.Replays) return 'https://play.pokemonshowdown.com/fx/'; return 'fx/'; } return '//play.pokemonshowdown.com/fx/'; - })(), + })(); - moddedDexes: {} as any as {[mod: string]: ModdedDex}, - mod(modid: ID) { + loadedSpriteData = {xy: 1, bw: 0}; + moddedDexes: {[mod: string]: ModdedDex} = {}; + + mod(modid: ID): ModdedDex { + if (modid === 'gen7') return this; if (modid in this.moddedDexes) { return this.moddedDexes[modid]; } this.moddedDexes[modid] = new ModdedDex(modid); return this.moddedDexes[modid]; - }, + } resolveAvatar(avatar: string): string { if (avatar in BattleAvatarNumbers) { @@ -189,7 +206,7 @@ const Dex = { '/avatars/' + encodeURIComponent(avatar).replace(/\%3F/g, '?'); } return Dex.resourcePrefix + 'sprites/trainers/' + Dex.sanitizeName(avatar || 'unknown') + '.png'; - }, + } /** * This is used to sanitize strings from data files like `moves.js` and @@ -205,13 +222,13 @@ const Dex = { sanitizeName(name: any) { if (!name) return ''; return ('' + name).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').slice(0, 50); - }, + } prefs(prop: string, value?: any, save?: boolean) { // @ts-ignore if (window.Storage && Storage.prefs) return Storage.prefs(prop, value, save); return undefined; - }, + } getShortName(name: string) { let shortName = name.replace(/[^A-Za-z0-9]+$/, ''); @@ -219,7 +236,7 @@ const Dex = { shortName += name.slice(shortName.length).replace(/[^\(\)]+/g, '').replace(/\(\)/g, ''); } return shortName; - }, + } getEffect(name: string | null | undefined): PureEffect | Item | Ability | Move { name = (name || '').trim(); @@ -232,7 +249,7 @@ const Dex = { } let id = toId(name); return new PureEffect(id, name); - }, + } getMove(nameOrMove: string | Move | null | undefined): Move { if (nameOrMove && typeof nameOrMove !== 'string') { @@ -273,16 +290,13 @@ const Dex = { let move = new Move(id, name, data); window.BattleMovedex[id] = move; return move; - }, + } - getCategory(move: Move, gen: number, type?: string) { - if (gen <= 3 && move.category !== 'Status') { - return [ - 'Fire', 'Water', 'Grass', 'Electric', 'Ice', 'Psychic', 'Dark', 'Dragon', - ].includes(type || move.type) ? 'Special' : 'Physical'; - } - return move.category; - }, + getGen3Category(type: string) { + return [ + 'Fire', 'Water', 'Grass', 'Electric', 'Ice', 'Psychic', 'Dark', 'Dragon', + ].includes(type) ? 'Special' : 'Physical'; + } getItem(nameOrItem: string | Item | null | undefined): Item { if (nameOrItem && typeof nameOrItem !== 'string') { @@ -302,7 +316,7 @@ const Dex = { let item = new Item(id, name, data); window.BattleItems[id] = item; return item; - }, + } getAbility(nameOrAbility: string | Ability | null | undefined): Ability { if (nameOrAbility && typeof nameOrAbility !== 'string') { @@ -322,7 +336,7 @@ const Dex = { let ability = new Ability(id, name, data); window.BattleAbilities[id] = ability; return ability; - }, + } getTemplate(nameOrTemplate: string | Template | null | undefined): Template { if (nameOrTemplate && typeof nameOrTemplate !== 'string') { @@ -363,7 +377,7 @@ const Dex = { forme, }); return template; - }, + } getTier(pokemon: Pokemon, gen = 7, isDoubles = false): string { let table = window.BattleTeambuilderTable; @@ -374,7 +388,7 @@ const Dex = { // Prevents Pokemon from having their tier displayed as 'undefined' when they're in a previous generation teambuilder if (this.getTemplate(pokemon.species).gen > gen) return 'Illegal'; return table.overrideTier[toId(pokemon.species)]; - }, + } getType(type: any): Effect { if (!type || typeof type === 'string') { @@ -389,41 +403,16 @@ const Dex = { } } return type; - }, + } - getAbilitiesFor(template: any, gen = 7): {[id: string]: string} { - template = this.getTemplate(template); - if (gen < 3 || !template.abilities) return {}; - const id = template.id; - const templAbilities = template.abilities; - const table = (gen >= 7 ? null : window.BattleTeambuilderTable['gen' + gen]); - if (!table) return {...templAbilities}; - const abilities: {[id: string]: string} = {}; - - if (table.overrideAbility && id in table.overrideAbility) { - abilities['0'] = table.overrideAbility[id]; - } else { - abilities['0'] = templAbilities['0']; - } - const removeSecondAbility = table.removeSecondAbility && id in table.removeSecondAbility; - if (!removeSecondAbility && templAbilities['1']) { - abilities['1'] = templAbilities['1']; - } - if (gen >= 5 && templAbilities['H']) abilities['H'] = templAbilities['H']; - if (gen >= 7 && templAbilities['S']) abilities['S'] = templAbilities['S']; - - return abilities; - }, - - hasAbility(template: any, ability: string, gen = 7) { - const abilities = this.getAbilitiesFor(template, gen); - for (const i in abilities) { - if (ability === abilities[i]) return true; + hasAbility(template: Template, ability: string) { + for (const i in template.abilities) { + // @ts-ignore + if (ability === template.abilities[i]) return true; } return false; - }, + } - loadedSpriteData: {xy: 1, bw: 0}, loadSpriteData(gen: 'xy' | 'bw') { if (this.loadedSpriteData[gen]) return; this.loadedSpriteData[gen] = 1; @@ -435,7 +424,7 @@ const Dex = { let el = document.createElement('script'); el.src = path + 'data/pokedex-mini-bw.js' + qs; document.getElementsByTagName('body')[0].appendChild(el); - }, + } getSpriteData(pokemon: Pokemon | Template | string, siden: number, options: { gen?: number, shiny?: boolean, gender?: GenderName, afd?: boolean, noScale?: boolean, } = {gen: 6}) { @@ -576,7 +565,7 @@ const Dex = { } return spriteData; - }, + } getPokemonIcon(pokemon: any, facingLeft?: boolean) { let num = 0; @@ -620,7 +609,7 @@ const Dex = { let left = (num % 12) * 40; let fainted = (pokemon && pokemon.fainted ? ';opacity:.3;filter:grayscale(100%) brightness(.5)' : ''); return 'background:transparent url(' + Dex.resourcePrefix + 'sprites/smicons-sheet.png?a5) no-repeat scroll -' + left + 'px -' + top + 'px' + fainted; - }, + } getTeambuilderSprite(pokemon: any, gen: number = 0) { if (!pokemon) return ''; @@ -661,7 +650,7 @@ const Dex = { else if (gen <= 3 && template.gen <= 3) spriteDir = Dex.resourcePrefix + 'sprites/rse'; else if (gen <= 4 && template.gen <= 4) spriteDir = Dex.resourcePrefix + 'sprites/dpp'; return 'background-image:url(' + spriteDir + shiny + '/' + spriteid + '.png);background-position:10px 5px;background-repeat:no-repeat'; - }, + } getItemIcon(item: any) { let num = 0; @@ -671,13 +660,13 @@ const Dex = { let top = Math.floor(num / 16) * 24; let left = (num % 16) * 24; return 'background:transparent url(' + Dex.resourcePrefix + 'sprites/itemicons-sheet.png) no-repeat scroll -' + left + 'px -' + top + 'px'; - }, + } getTypeIcon(type: string, b?: boolean) { // b is just for utilichart.js if (!type) return ''; let sanitizedType = type.replace(/\?/g, '%3f'); return '' + type + ''; - }, + } }; class ModdedDex { @@ -688,10 +677,10 @@ class ModdedDex { Items: {} as any as {[k: string]: Item}, Templates: {} as any as {[k: string]: Template}, }; - getAbility = Dex.getAbility; + getAbility: (nameOrAbility: string | Ability | null | undefined) => Ability = Dex.getAbility; constructor(modid: ID) { this.modid = modid; - let gen = parseInt(modid.slice(3)); + let gen = parseInt(modid.slice(3), 10); if (!modid.startsWith('gen') || !gen) throw new Error("Unsupported modid"); this.gen = gen; } @@ -716,6 +705,9 @@ class ModdedDex { break; } } + if (this.gen <= 3 && data.category !== 'Status') { + data.category = Dex.getGen3Category(data.type); + } const move = new Move(id, name, data); this.cache.Moves[id] = move; @@ -753,18 +745,25 @@ class ModdedDex { let data = {...Dex.getTemplate(name)}; const table = window.BattleTeambuilderTable[this.modid]; - if (id in table.overrideAbility) { - data.abilities = {...data.abilities, 0: table.overrideAbility[id]}; + if (this.gen < 3) { + data.abilities = {0: "None"}; + } else { + let abilities = {...data.abilities}; + if (id in table.overrideAbility) { + abilities['0'] = table.overrideAbility[id]; + } + if (id in table.removeSecondAbility) { + delete abilities['1']; + } + if (this.gen < 5) delete abilities['H']; + if (this.gen < 7) delete abilities['S']; } if (id in table.overrideStats) { data.baseStats = {...data.baseStats, ...table.overrideStats[id]}; } if (id in table.overrideType) data.types = table.overrideType[id].split('/'); - if (id in table.removeSecondAbility) { - data.abilities = {...data.abilities}; - // @ts-ignore - delete data.abilities['1']; - } + + if (id in table.overrideTier) data.tier = table.overrideTier[id]; const template = new Template(id, name, data); this.cache.Templates[id] = template; diff --git a/src/battle-tooltips.ts b/src/battle-tooltips.ts new file mode 100644 index 000000000..055bdf5e0 --- /dev/null +++ b/src/battle-tooltips.ts @@ -0,0 +1,1513 @@ +/** + * Pokemon Showdown Tooltips + * + * A file for generating tooltips for battles. This should be IE7+ and + * use the DOM directly. + * + * @author Guangcong Luo + * @license MIT + */ + +class ModifiableValue { + value = 0; + maxValue = 0; + comment: string[]; + battle: Battle; + pokemon: Pokemon | null; + serverPokemon: ServerPokemon; + itemName: string; + abilityName: string; + weatherName: string; + isAccuracy = false; + constructor(battle: Battle, pokemon: Pokemon | null, serverPokemon: ServerPokemon) { + this.comment = []; + this.battle = battle; + this.pokemon = pokemon; + this.serverPokemon = serverPokemon; + + this.itemName = Dex.getItem(serverPokemon.item).name; + this.abilityName = Dex.getAbility(serverPokemon.ability || (pokemon && pokemon.ability) || serverPokemon.baseAbility).name; + this.weatherName = Dex.getMove(battle.weather).name; + } + reset(value = 0, isAccuracy?: boolean) { + this.value = value; + this.maxValue = 0; + this.isAccuracy = !!isAccuracy; + this.comment = []; + } + tryItem(itemName: string) { + if (itemName !== this.itemName) return false; + if (this.battle.hasPseudoWeather('Magic Room')) { + this.comment.push(` (${itemName} suppressed by Magic Room)`); + return false; + } + if (this.pokemon && this.pokemon.volatiles['embargo']) { + this.comment.push(` (${itemName} suppressed by Embargo)`); + return false; + } + const ignoreKlutz = ["Macho Brace", "Power Anklet", "Power Band", "Power Belt", "Power Bracer", "Power Lens", "Power Weight"]; + if (this.tryAbility('Klutz') && !ignoreKlutz.includes(itemName)) { + this.comment.push(` (${itemName} suppressed by Klutz)`); + return false; + } + return true; + } + tryAbility(abilityName: string) { + if (abilityName !== this.abilityName) return false; + if (this.pokemon && this.pokemon.volatiles['gastroacid']) { + this.comment.push(` (${abilityName} suppressed by Gastro Acid)`); + return false; + } + return true; + } + tryWeather(weatherName?: string) { + if (!this.weatherName) return false; + if (!weatherName) weatherName = this.weatherName; + else if (weatherName !== this.weatherName) return false; + for (const side of this.battle.sides) { + for (const active of side.active) { + if (active && ['Air Lock', 'Cloud Nine'].includes(active.ability)) { + this.comment.push(` (${weatherName} suppressed by ${active.ability})`); + return false; + } + } + } + return true; + } + itemModify(factor: number, itemName?: string) { + if (!itemName) itemName = this.itemName; + if (!itemName) return false; + if (!this.tryItem(itemName)) return false; + return this.modify(factor, itemName); + } + abilityModify(factor: number, abilityName: string) { + if (!this.tryAbility(abilityName)) return false; + return this.modify(factor, abilityName); + } + weatherModify(factor: number, weatherName?: string, name?: string) { + if (!weatherName) weatherName = this.weatherName; + if (!weatherName) return false; + if (!this.tryWeather(weatherName)) return false; + return this.modify(factor, name || weatherName); + } + modify(factor: number, name?: string) { + if (factor === 0) { + if (name) this.comment.push(` (${name})`); + this.value = 0; + this.maxValue = 0; + return true; + } + if (name) this.comment.push(` (${factor}× from ${name})`); + this.value *= factor; + if (!(name === 'Technician' && this.maxValue > 60)) this.maxValue *= factor; + return true; + } + set(value: number, reason?: string) { + if (reason) this.comment.push(` (${reason})`); + this.value = value; + this.maxValue = 0; + return true; + } + setRange(value: number, maxValue: number, reason?: string) { + if (reason) this.comment.push(` (${reason})`); + this.value = value; + this.maxValue = maxValue; + return true; + } + toString() { + let valueString; + if (this.isAccuracy) { + valueString = this.value ? `${this.value}%` : `can't miss`; + } else { + valueString = this.value ? `${this.value}` : ``; + } + if (this.maxValue) { + valueString += ` to ${this.maxValue}` + (this.isAccuracy ? '%' : ''); + } + return valueString + this.comment.join(''); + } +} + +class BattleTooltips { + battle: Battle; + + constructor(battle: Battle) { + this.battle = battle; + } + + // tooltips + // Touch delay, pressing finger more than that time will cause the tooltip to open. + // Shorter time will cause the button to click + static LONG_TAP_DELAY = 350; // ms + static longTapTimeout = 0; + static elem: HTMLDivElement | null = null; + static parentElem: HTMLElement | null = null; + static isLocked = false; + + static hideTooltip() { + if (!BattleTooltips.elem) return; + BattleTooltips.cancelLongTap(); + BattleTooltips.elem.parentNode!.removeChild(BattleTooltips.elem); + BattleTooltips.elem = null; + BattleTooltips.parentElem = null; + BattleTooltips.isLocked = false; + $('#tooltipwrapper').removeClass('tooltip-locked'); + } + + static cancelLongTap() { + if (BattleTooltips.longTapTimeout) { + clearTimeout(BattleTooltips.longTapTimeout); + BattleTooltips.longTapTimeout = 0; + } + } + + lockTooltip() { + if (BattleTooltips.elem && !BattleTooltips.isLocked) { + BattleTooltips.isLocked = true; + $('#tooltipwrapper').addClass('tooltip-locked'); + } + } + + handleTouchEnd(e: TouchEvent) { + BattleTooltips.cancelLongTap(); + + if (!BattleTooltips.isLocked) BattleTooltips.hideTooltip(); + } + + listen(elem: HTMLElement) { + const $elem = $(elem); + $elem.on('mouseover', '.has-tooltip', this.showTooltipEvent); + $elem.on('click', '.has-tooltip', this.clickTooltipEvent); + $elem.on('touchstart', '.has-tooltip', this.holdLockTooltipEvent); + $elem.on('touchend', '.has-tooltip', BattleTooltips.unshowTooltip); + $elem.on('touchleave', '.has-tooltip', BattleTooltips.unshowTooltip); + $elem.on('touchcancel', '.has-tooltip', BattleTooltips.unshowTooltip); + $elem.on('focus', '.has-tooltip', this.showTooltipEvent); + $elem.on('mouseout', '.has-tooltip', BattleTooltips.unshowTooltip); + $elem.on('mousedown', '.has-tooltip', this.holdLockTooltipEvent); + $elem.on('blur', '.has-tooltip', BattleTooltips.unshowTooltip); + $elem.on('mouseup', '.has-tooltip', BattleTooltips.unshowTooltip); + } + + clickTooltipEvent = (e: Event) => { + if (BattleTooltips.isLocked) { + e.preventDefault(); + e.stopImmediatePropagation(); + } + }; + /** + * An event that will lock a tooltip if held down + * + * (Namely, a long-tap or long-click) + */ + holdLockTooltipEvent = (e: Event) => { + if (BattleTooltips.isLocked) BattleTooltips.hideTooltip(); + const target = e.currentTarget as HTMLElement; + this.showTooltip(target); + let factor = (e.type === 'mousedown' && target.tagName === 'BUTTON' ? 2 : 1); + + BattleTooltips.longTapTimeout = setTimeout(() => { + BattleTooltips.longTapTimeout = 0; + this.lockTooltip(); + }, BattleTooltips.LONG_TAP_DELAY * factor); + }; + + showTooltipEvent = (e: Event) => { + if (BattleTooltips.isLocked) return; + this.showTooltip(e.currentTarget as HTMLElement); + }; + + /** + * Only hides tooltips if they're not locked + */ + static unshowTooltip() { + if (BattleTooltips.isLocked) return; + BattleTooltips.hideTooltip(); + } + + showTooltip(elem: HTMLElement) { + const args = (elem.dataset.tooltip || '').split('|'); + const [type] = args; + /** + * If false, we instead attach the tooltip above the parent element. + * This is important for the move/switch menus so the tooltip doesn't + * cover up buttons above the hovered button. + */ + const ownHeight = !!elem.dataset.ownheight; + + let buf: string; + switch (type) { + case 'move': + case 'zmove': { // move|MOVE|ACTIVEPOKEMON + let move = this.battle.dex.getMove(args[1]); + let index = parseInt(args[2], 10); + let pokemon = this.battle.mySide.active[index]!; + let serverPokemon = this.battle.myPokemon![index]; + buf = this.showMoveTooltip(move, type === 'zmove', pokemon, serverPokemon); + break; + } + + case 'pokemon': { // pokemon|SIDE|POKEMON + // mouse over sidebar pokemon + // pokemon definitely exists, serverPokemon always ignored + let sideIndex = parseInt(args[1], 10); + let side = this.battle.sides[sideIndex]; + let pokemon = side.pokemon[parseInt(args[2], 10)]; + buf = this.showPokemonTooltip(pokemon); + break; + } + case 'activepokemon': { // activepokemon|SIDE|ACTIVE + // mouse over active pokemon + // pokemon definitely exists, serverPokemon maybe + let sideIndex = parseInt(args[1], 10); + let side = this.battle.sides[sideIndex]; + let activeIndex = parseInt(args[2], 10); + let pokemon = side.active[activeIndex]; + let serverPokemon = null; + if (sideIndex === 0 && this.battle.myPokemon) { + serverPokemon = this.battle.myPokemon[activeIndex]; + } + if (!pokemon) return false; + buf = this.showPokemonTooltip(pokemon, serverPokemon, true); + break; + } + case 'switchpokemon': { // switchpokemon|POKEMON + // mouse over switchable pokemon + // serverPokemon definitely exists, sidePokemon maybe + let side = this.battle.sides[0]; + let activeIndex = parseInt(args[1], 10); + let pokemon = null; + if (activeIndex < side.active.length) { + pokemon = side.active[activeIndex]; + } + let serverPokemon = this.battle.myPokemon![activeIndex]; + buf = this.showPokemonTooltip(pokemon, serverPokemon); + break; + } + default: + throw new Error(`unrecognized type`); + } + + let offset = { + left: 150, + top: 500, + }; + if (elem) offset = $(elem).offset()!; + let x = offset.left - 2; + if (elem) { + offset = (ownHeight ? $(elem) : $(elem).parent()).offset()!; + } + let y = offset.top - 5; + + if (y < 140) y = 140; + // if (x > room.leftWidth + 335) x = room.leftWidth + 335; + if (x > $(window).width()! - 305) x = Math.max($(window).width()! - 305, 0); + if (x < 0) x = 0; + + let $wrapper = $('#tooltipwrapper'); + if (!$wrapper.length) { + $wrapper = $(``); + $(document.body).append($wrapper); + } else { + $wrapper.removeClass('tooltip-locked'); + } + $wrapper.css({ + left: x, + top: y, + }); + buf = `
${buf}
`; + $wrapper.html(buf).appendTo(document.body); + BattleTooltips.elem = $wrapper.find('.tooltip')[0] as HTMLDivElement; + BattleTooltips.isLocked = false; + if (elem) { + let height = $(BattleTooltips.elem).height()!; + if (height > y) { + y += height + 10; + if (ownHeight) y += $(elem).height()!; + else y += $(elem).parent().height()!; + $wrapper.css('top', y); + } + } + BattleTooltips.parentElem = elem; + return true; + } + + hideTooltip() { + BattleTooltips.hideTooltip(); + } + + static zMoveEffects: {[zEffect: string]: string} = { + 'clearnegativeboost': "Restores negative stat stages to 0", + 'crit2': "Crit ratio +2", + 'heal': "Restores HP 100%", + 'curse': "Restores HP 100% if user is Ghost type, otherwise Attack +1", + 'redirect': "Redirects opposing attacks to user", + 'healreplacement': "Restores replacement's HP 100%", + }; + + getStatusZMoveEffect(move: Move) { + if (move.zMoveEffect in BattleTooltips.zMoveEffects) { + return BattleTooltips.zMoveEffects[move.zMoveEffect]; + } + let boostText = ''; + if (move.zMoveBoost) { + let boosts = Object.keys(move.zMoveBoost) as StatName[]; + boostText = boosts.map(stat => + BattleStats[stat] + ' +' + move.zMoveBoost![stat] + ).join(', '); + } + return boostText; + } + + static zMoveTable: {[type in TypeName]: string} = { + Poison: "Acid Downpour", + Fighting: "All-Out Pummeling", + Dark: "Black Hole Eclipse", + Grass: "Bloom Doom", + Normal: "Breakneck Blitz", + Rock: "Continental Crush", + Steel: "Corkscrew Crash", + Dragon: "Devastating Drake", + Electric: "Gigavolt Havoc", + Water: "Hydro Vortex", + Fire: "Inferno Overdrive", + Ghost: "Never-Ending Nightmare", + Bug: "Savage Spin-Out", + Psychic: "Shattered Psyche", + Ice: "Subzero Slammer", + Flying: "Supersonic Skystrike", + Ground: "Tectonic Rage", + Fairy: "Twinkle Tackle", + "???": "", + }; + + showMoveTooltip(move: Move, isZ: boolean, pokemon: Pokemon, serverPokemon: ServerPokemon) { + let text = ''; + + let zEffect = ''; + let foeActive = pokemon.side.foe.active; + // TODO: move this somewhere it makes more sense + if (pokemon.ability === '(suppressed)') serverPokemon.ability = '(suppressed)'; + let ability = toId(serverPokemon.ability || pokemon.ability || serverPokemon.baseAbility); + + let value = new ModifiableValue(this.battle, pokemon, serverPokemon); + + if (isZ) { + let item = this.battle.dex.getItem(serverPokemon.item); + if (item.zMoveFrom === move.name) { + move = this.battle.dex.getMove(item.zMove as string); + } else if (move.category === 'Status') { + move = new Move(move.id, "", { + ...move, + name: 'Z-' + move.name, + }); + zEffect = this.getStatusZMoveEffect(move); + } else { + const zMove = this.battle.dex.getMove(BattleTooltips.zMoveTable[item.zMoveType as TypeName]); + move = new Move(zMove.id, zMove.name, { + ...zMove, + category: move.category, + basePower: move.zMovePower, + }); + // TODO: Weather Ball type-changing shenanigans + } + } + + text += '

' + move.name + '
'; + + // Handle move type for moves that vary their type. + let [moveType, category] = this.getMoveType(move, value); + + text += Dex.getTypeIcon(moveType); + text += ` ${category}

`; + + // Check if there are more than one active Pokémon to check for multiple possible BPs. + let showingMultipleBasePowers = false; + if (category !== 'Status' && foeActive.length > 1) { + // We check if there is a difference in base powers to note it. + // Otherwise, it is just shown as in singles. + // The trick is that we need to calculate it first for each Pokémon to see if it changes. + let prevBasePower: string | null = null; + let basePower: string = ''; + let difference = false; + let basePowers = []; + for (const active of foeActive) { + if (!active) continue; + value = this.getMoveBasePower(move, moveType, value, active); + basePower = '' + value; + if (prevBasePower === null) prevBasePower = basePower; + if (prevBasePower !== basePower) difference = true; + basePowers.push('Base power vs ' + active.name + ': ' + basePower); + } + if (difference) { + text += '

' + basePowers.join('
') + '

'; + showingMultipleBasePowers = true; + } + // Falls through to not to repeat code on showing the base power. + } + if (!showingMultipleBasePowers && category !== 'Status') { + let activeTarget = foeActive[0] || foeActive[1] || foeActive[2]; + value = this.getMoveBasePower(move, moveType, value, activeTarget); + text += '

Base power: ' + value + '

'; + } + + let accuracy = this.getMoveAccuracy(move, value); + + // Deal with Nature Power special case, indicating which move it calls. + if (move.id === 'naturepower') { + let calls; + if (this.battle.gen > 5) { + if (this.battle.hasPseudoWeather('Electric Terrain')) { + calls = 'Thunderbolt'; + } else if (this.battle.hasPseudoWeather('Grassy Terrain')) { + calls = 'Energy Ball'; + } else if (this.battle.hasPseudoWeather('Misty Terrain')) { + calls = 'Moonblast'; + } else if (this.battle.hasPseudoWeather('Psychic Terrain')) { + calls = 'Psychic'; + } else { + calls = 'Tri Attack'; + } + } else if (this.battle.gen > 3) { + // In gens 4 and 5 it calls Earthquake. + calls = 'Earthquake'; + } else { + // In gen 3 it calls Swift, so it retains its normal typing. + calls = 'Swift'; + } + let calledMove = this.battle.dex.getMove(calls); + text += 'Calls ' + Dex.getTypeIcon(this.getMoveType(calledMove, value)[0]) + ' ' + calledMove.name; + } + + text += '

Accuracy: ' + accuracy + '

'; + if (zEffect) text += '

Z-Effect: ' + zEffect + '

'; + + if (this.battle.gen < 7 || this.battle.hardcoreMode) { + text += '

' + move.shortDesc + '

'; + } else { + text += '

'; + if (move.priority > 1) { + text += 'Nearly always moves first (priority +' + move.priority + ').

'; + } else if (move.priority <= -1) { + text += 'Nearly always moves last (priority −' + (-move.priority) + ').

'; + } else if (move.priority === 1) { + text += 'Usually moves first (priority +' + move.priority + ').

'; + } + + text += '' + (move.desc || move.shortDesc) + '

'; + + if (this.battle.gameType === 'doubles') { + if (move.target === 'allAdjacent') { + text += '

◎ Hits both foes and ally.

'; + } else if (move.target === 'allAdjacentFoes') { + text += '

◎ Hits both foes.

'; + } + } else if (this.battle.gameType === 'triples') { + if (move.target === 'allAdjacent') { + text += '

◎ Hits adjacent foes and allies.

'; + } else if (move.target === 'allAdjacentFoes') { + text += '

◎ Hits adjacent foes.

'; + } else if (move.target === 'any') { + text += '

◎ Can target distant Pokémon in Triples.

'; + } + } + + if ('defrost' in move.flags) { + text += '

The user thaws out if it is frozen.

'; + } + if (!('protect' in move.flags) && move.target !== 'self' && move.target !== 'allySide') { + text += '

Not blocked by Protect (and Detect, King\'s Shield, Spiky Shield)

'; + } + if ('authentic' in move.flags) { + text += '

Bypasses Substitute (but does not break it)

'; + } + if (!('reflectable' in move.flags) && move.target !== 'self' && move.target !== 'allySide' && move.category === 'Status') { + text += '

✓ Not bounceable (can\'t be bounced by Magic Coat/Bounce)

'; + } + + if ('contact' in move.flags) { + text += '

✓ Contact (triggers Iron Barbs, Spiky Shield, etc)

'; + } + if ('sound' in move.flags) { + text += '

✓ Sound (doesn\'t affect Soundproof pokemon)

'; + } + if ('powder' in move.flags) { + text += '

✓ Powder (doesn\'t affect Grass, Overcoat, Safety Goggles)

'; + } + if ('punch' in move.flags && ability === 'ironfist') { + text += '

✓ Fist (boosted by Iron Fist)

'; + } + if ('pulse' in move.flags && ability === 'megalauncher') { + text += '

✓ Pulse (boosted by Mega Launcher)

'; + } + if ('bite' in move.flags && ability === 'strongjaw') { + text += '

✓ Bite (boosted by Strong Jaw)

'; + } + if ((move.recoil || move.hasCustomRecoil) && ability === 'reckless') { + text += '

✓ Recoil (boosted by Reckless)

'; + } + if ('bullet' in move.flags) { + text += '

✓ Bullet-like (doesn\'t affect Bulletproof pokemon)

'; + } + } + return text; + } + + /** + * Needs either a Pokemon or a ServerPokemon, but note that neither + * are guaranteed: If you hover over a possible switch-in that's + * never been switched in before, you'll only have a ServerPokemon, + * and if you hover over an opponent's pokemon, you'll only have a + * Pokemon. + * + * isActive is true if hovering over a pokemon in the battlefield, + * and false if hovering over a pokemon in the Switch menu. + * + * @param clientPokemon + * @param serverPokemon + * @param isActive + */ + showPokemonTooltip(clientPokemon: Pokemon | null, serverPokemon?: ServerPokemon | null, isActive?: boolean) { + const pokemon = clientPokemon || serverPokemon!; + let text = ''; + let genderBuf = ''; + if (pokemon.gender) { + genderBuf = ' ' + pokemon.gender + ''; + } + + let name = BattleLog.escapeHTML(pokemon.name); + if (pokemon.species !== pokemon.name) { + name += ' (' + BattleLog.escapeHTML(pokemon.species) + ')'; + } + + text += '

' + name + genderBuf + (pokemon.level !== 100 ? ' L' + pokemon.level + '' : '') + '
'; + + let template = this.battle.dex.getTemplate(clientPokemon ? clientPokemon.getSpecies() : pokemon.species); + if (clientPokemon && clientPokemon.volatiles.formechange) { + if (clientPokemon.volatiles.transform) { + text += '(Transformed into ' + clientPokemon.volatiles.formechange[1] + ')
'; + } else { + text += '(Changed forme: ' + clientPokemon.volatiles.formechange[1] + ')
'; + } + } + + let types = this.getPokemonTypes(pokemon); + + if (clientPokemon && (clientPokemon.volatiles.typechange || clientPokemon.volatiles.typeadd)) { + text += '(Type changed)
'; + } + text += types.map(type => Dex.getTypeIcon(type)).join(' '); + text += '

'; + + if (pokemon.fainted) { + text += '

HP: (fainted)

'; + } else if (this.battle.hardcoreMode) { + if (serverPokemon) { + text += '

HP: ' + serverPokemon.hp + '/' + serverPokemon.maxhp + (pokemon.status ? ' ' + pokemon.status.toUpperCase() + '' : '') + '

'; + } + } else { + let exacthp = ''; + if (serverPokemon) { + exacthp = ' (' + serverPokemon.hp + '/' + serverPokemon.maxhp + ')'; + } else if (pokemon.maxhp === 48) { + exacthp = ' (' + pokemon.hp + '/' + pokemon.maxhp + ' pixels)'; + } + text += '

HP: ' + Pokemon.getHPText(pokemon) + exacthp + (pokemon.status ? ' ' + pokemon.status.toUpperCase() + '' : ''); + if (clientPokemon) { + if (pokemon.status === 'tox') { + if (pokemon.ability === 'Poison Heal' || pokemon.ability === 'Magic Guard') { + text += ' Would take if ability removed: ' + Math.floor(100 / 16) * Math.min(clientPokemon.statusData.toxicTurns + 1, 15) + '%'; + } else { + text += ' Next damage: ' + Math.floor(100 / 16) * Math.min(clientPokemon.statusData.toxicTurns + 1, 15) + '%'; + } + } else if (pokemon.status === 'slp') { + text += ' Turns asleep: ' + clientPokemon.statusData.sleepTurns; + } + } + text += '

'; + } + + const supportsAbilities = this.battle.gen > 2 && !this.battle.tier.includes("Let's Go"); + if (serverPokemon) { + if (supportsAbilities) { + let abilityText = Dex.getAbility(serverPokemon.baseAbility).name; + let ability = Dex.getAbility(serverPokemon.ability || pokemon.ability).name; + if (ability && (ability !== abilityText)) { + abilityText = ability + ' (base: ' + abilityText + ')'; + } + text += '

Ability: ' + abilityText; + if (serverPokemon.item) { + text += ' / Item: ' + Dex.getItem(serverPokemon.item).name; + } + text += '

'; + } else if (serverPokemon.item) { + let itemName = Dex.getItem(serverPokemon.item).name; + text += '

Item: ' + itemName + '

'; + } + } else if (clientPokemon) { + if (supportsAbilities) { + if (!pokemon.baseAbility && !pokemon.ability) { + let abilities = template.abilities; + text += '

Possible abilities: ' + abilities['0']; + if (abilities['1']) text += ', ' + abilities['1']; + if (abilities['H']) text += ', ' + abilities['H']; + if (abilities['S']) text += ', ' + abilities['S']; + text += '

'; + } else if (pokemon.ability) { + if (pokemon.ability === pokemon.baseAbility) { + text += '

Ability: ' + Dex.getAbility(pokemon.ability).name + '

'; + } else { + text += '

Ability: ' + Dex.getAbility(pokemon.ability).name + ' (base: ' + Dex.getAbility(pokemon.baseAbility).name + ')' + '

'; + } + } else if (pokemon.baseAbility) { + text += '

Ability: ' + Dex.getAbility(pokemon.baseAbility).name + '

'; + } + } + let item = ''; + let itemEffect = clientPokemon.itemEffect || ''; + if (clientPokemon.prevItem) { + item = 'None'; + if (itemEffect) itemEffect += '; '; + let prevItem = Dex.getItem(clientPokemon.prevItem).name; + itemEffect += clientPokemon.prevItemEffect ? prevItem + ' was ' + clientPokemon.prevItemEffect : 'was ' + prevItem; + } + if (pokemon.item) item = Dex.getItem(pokemon.item).name; + if (itemEffect) itemEffect = ' (' + itemEffect + ')'; + if (item) text += '

Item: ' + item + itemEffect + '

'; + } + + text += this.renderStats(clientPokemon, serverPokemon, !isActive); + + if (serverPokemon && !isActive) { + // move list + text += '

'; + let battlePokemon = this.battle.getPokemon(pokemon.ident, pokemon.details); + for (const moveid of serverPokemon.moves) { + let move = Dex.getMove(moveid); + let moveName = move.name; + if (battlePokemon && battlePokemon.moveTrack) { + for (const row of battlePokemon.moveTrack) { + if (moveName === row[0]) { + moveName = this.getPPUseText(row, true); + break; + } + } + } + text += '• ' + moveName + '
'; + } + text += '

'; + } else if (!this.battle.hardcoreMode && clientPokemon && clientPokemon.moveTrack.length) { + // move list (guessed) + text += '

'; + for (const row of clientPokemon.moveTrack) { + text += '• ' + this.getPPUseText(row) + '
'; + } + if (clientPokemon.moveTrack.length > 4) { + text += '(More than 4 moves is usually a sign of Illusion Zoroark/Zorua.)'; + } + if (this.battle.gen === 3) { + text += '(Pressure is not visible in Gen 3, so in certain situations, more PP may have been lost than shown here.)'; + } + text += '

'; + } + return text; + } + + calculateModifiedStats(clientPokemon: Pokemon | null, serverPokemon: ServerPokemon) { + let stats = {...serverPokemon.stats}; + let pokemon = clientPokemon || serverPokemon; + for (const statName of Dex.statNamesExceptHP) { + stats[statName] = serverPokemon.stats[statName]; + + if (clientPokemon && clientPokemon.boosts[statName]) { + let boostTable = [1, 1.5, 2, 2.5, 3, 3.5, 4]; + if (clientPokemon.boosts[statName] > 0) { + stats[statName] *= boostTable[clientPokemon.boosts[statName]]; + } else { + if (this.battle.gen <= 2) boostTable = [1, 100 / 66, 2, 2.5, 100 / 33, 100 / 28, 4]; + stats[statName] /= boostTable[-clientPokemon.boosts[statName]]; + } + stats[statName] = Math.floor(stats[statName]); + } + } + + let ability = toId(serverPokemon.ability || pokemon.ability || serverPokemon.baseAbility); + if (clientPokemon && 'gastroacid' in clientPokemon.volatiles) ability = '' as ID; + + // check for burn, paralysis, guts, quick feet + if (pokemon.status) { + if (this.battle.gen > 2 && ability === 'guts') { + stats.atk = Math.floor(stats.atk * 1.5); + } else if (pokemon.status === 'brn') { + stats.atk = Math.floor(stats.atk * 0.5); + } + + if (this.battle.gen > 2 && ability === 'quickfeet') { + stats.spe = Math.floor(stats.spe * 1.5); + } else if (pokemon.status === 'par') { + if (this.battle.gen > 6) { + stats.spe = Math.floor(stats.spe * 0.5); + } else { + stats.spe = Math.floor(stats.spe * 0.25); + } + } + } + + // gen 1 doesn't support items + if (this.battle.gen <= 1) { + for (const statName of Dex.statNamesExceptHP) { + if (stats[statName] > 999) stats[statName] = 999; + } + return stats; + } + + let item = toId(serverPokemon.item); + if (ability === 'klutz' && item !== 'machobrace') item = '' as ID; + let species = Dex.getTemplate(clientPokemon ? clientPokemon.getSpecies() : serverPokemon.species).baseSpecies; + + // check for light ball, thick club, metal/quick powder + // the only stat modifying items in gen 2 were light ball, thick club, metal powder + if (item === 'lightball' && species === 'Pikachu') { + if (this.battle.gen >= 4) stats.atk *= 2; + stats.spa *= 2; + } + + if (item === 'thickclub') { + if (species === 'Marowak' || species === 'Cubone') { + stats.atk *= 2; + } + } + + if (species === 'Ditto' && !(clientPokemon && 'transform' in clientPokemon.volatiles)) { + if (item === 'quickpowder') { + stats.spe *= 2; + } + if (item === 'metalpowder') { + if (this.battle.gen === 2) { + stats.def = Math.floor(stats.def * 1.5); + stats.spd = Math.floor(stats.spd * 1.5); + } else { + stats.def *= 2; + } + } + } + + // check abilities other than Guts and Quick Feet + // check items other than light ball, thick club, metal/quick powder + if (this.battle.gen <= 2) { + return stats; + } + + let weather = this.battle.weather; + if (weather) { + // Check if anyone has an anti-weather ability + outer: for (const side of this.battle.sides) { + for (const active of side.active) { + if (active && ['Air Lock', 'Cloud Nine'].includes(active.ability)) { + weather = '' as ID; + break outer; + } + } + } + } + + if (item === 'choiceband') { + stats.atk = Math.floor(stats.atk * 1.5); + } + if (ability === 'purepower' || ability === 'hugepower') { + stats.atk *= 2; + } + if (ability === 'hustle') { + stats.atk = Math.floor(stats.atk * 1.5); + } + if (weather) { + if (weather === 'sunnyday' || weather === 'desolateland') { + if (ability === 'solarpower') { + stats.spa = Math.floor(stats.spa * 1.5); + } + let allyActive = clientPokemon && clientPokemon.side.active; + if (allyActive && allyActive.length > 1) { + for (const ally of allyActive) { + if (!ally || ally.fainted) continue; + if (ally.ability === 'flowergift' && (ally.getTemplate().baseSpecies === 'Cherrim' || this.battle.gen <= 4)) { + stats.atk = Math.floor(stats.atk * 1.5); + stats.spd = Math.floor(stats.spd * 1.5); + } + } + } + } + if (this.battle.gen >= 4 && this.pokemonHasType(serverPokemon, 'Rock') && weather === 'sandstorm') { + stats.spd = Math.floor(stats.spd * 1.5); + } + if (ability === 'chlorophyll' && (weather === 'sunnyday' || weather === 'desolateland')) { + stats.spe *= 2; + } + if (ability === 'swiftswim' && (weather === 'raindance' || weather === 'primordialsea')) { + stats.spe *= 2; + } + if (ability === 'sandrush' && weather === 'sandstorm') { + stats.spe *= 2; + } + if (ability === 'slushrush' && weather === 'hail') { + stats.spe *= 2; + } + } + if (ability === 'defeatist' && serverPokemon.hp <= serverPokemon.maxhp / 2) { + stats.atk = Math.floor(stats.atk * 0.5); + stats.spa = Math.floor(stats.spa * 0.5); + } + if (clientPokemon) { + if ('slowstart' in clientPokemon.volatiles) { + stats.atk = Math.floor(stats.atk * 0.5); + stats.spe = Math.floor(stats.spe * 0.5); + } + if (ability === 'unburden' && 'itemremoved' in clientPokemon.volatiles && !item) { + stats.spe *= 2; + } + } + if (ability === 'marvelscale' && pokemon.status) { + stats.def = Math.floor(stats.def * 1.5); + } + if (item === 'eviolite' && Dex.getTemplate(pokemon.species).evos) { + stats.def = Math.floor(stats.def * 1.5); + stats.spd = Math.floor(stats.spd * 1.5); + } + if (ability === 'grasspelt' && this.battle.hasPseudoWeather('Grassy Terrain')) { + stats.def = Math.floor(stats.def * 1.5); + } + if (ability === 'surgesurfer' && this.battle.hasPseudoWeather('Electric Terrain')) { + stats.spe *= 2; + } + if (item === 'choicespecs') { + stats.spa = Math.floor(stats.spa * 1.5); + } + if (item === 'deepseatooth' && species === 'Clamperl') { + stats.spa *= 2; + } + if (item === 'souldew' && this.battle.gen <= 6 && (species === 'Latios' || species === 'Latias')) { + stats.spa = Math.floor(stats.spa * 1.5); + stats.spd = Math.floor(stats.spd * 1.5); + } + if (clientPokemon && (ability === 'plus' || ability === 'minus')) { + let allyActive = clientPokemon.side.active; + if (allyActive.length > 1) { + let abilityName = (ability === 'plus' ? 'Plus' : 'Minus'); + for (const ally of allyActive) { + if (!(ally && ally !== clientPokemon && !ally.fainted)) continue; + if (!(ally.ability === 'Plus' || ally.ability === 'Minus')) continue; + if (this.battle.gen <= 4 && ally.ability === abilityName) continue; + stats.spa = Math.floor(stats.spa * 1.5); + break; + } + } + } + if (item === 'assaultvest') { + stats.spd = Math.floor(stats.spd * 1.5); + } + if (item === 'deepseascale' && species === 'Clamperl') { + stats.spd *= 2; + } + if (item === 'choicescarf') { + stats.spe = Math.floor(stats.spe * 1.5); + } + if (item === 'ironball' || item === 'machobrace' || /power(?!herb)/.test(item)) { + stats.spe = Math.floor(stats.spe * 0.5); + } + if (ability === 'furcoat') { + stats.def *= 2; + } + + return stats; + } + + renderStats(clientPokemon: Pokemon | null, serverPokemon?: ServerPokemon | null, short?: boolean) { + if (!serverPokemon) { + if (!clientPokemon) throw new Error('Must pass either clientPokemon or serverPokemon'); + let [min, max] = this.getSpeedRange(clientPokemon); + return '

Spe ' + min + ' to ' + max + ' (before items/abilities/modifiers)

'; + } + const stats = serverPokemon.stats; + const modifiedStats = this.calculateModifiedStats(clientPokemon, serverPokemon); + + let buf = '

'; + + if (!short) { + let hasModifiedStat = false; + for (const statName of Dex.statNamesExceptHP) { + if (this.battle.gen === 1 && statName === 'spd') continue; + let statLabel = this.battle.gen === 1 && statName === 'spa' ? 'spc' : statName; + buf += statName === 'atk' ? '' : ' / '; + buf += '' + BattleText[statLabel].statShortName + ' '; + buf += '' + stats[statName]; + if (modifiedStats[statName] !== stats[statName]) hasModifiedStat = true; + } + buf += '

'; + + if (!hasModifiedStat) return buf; + + buf += '

(After stat modifiers:)

'; + buf += '

'; + } + + for (const statName of Dex.statNamesExceptHP) { + if (this.battle.gen === 1 && statName === 'spd') continue; + let statLabel = this.battle.gen === 1 && statName === 'spa' ? 'spc' : statName; + buf += statName === 'atk' ? '' : ' / '; + buf += '' + BattleText[statLabel].statShortName + ' '; + if (modifiedStats[statName] === stats[statName]) { + buf += '' + modifiedStats[statName]; + } else if (modifiedStats[statName] < stats[statName]) { + buf += '' + modifiedStats[statName] + ''; + } else { + buf += '' + modifiedStats[statName] + ''; + } + } + buf += '

'; + return buf; + } + + getPPUseText(moveTrackRow: [string, number], showKnown?: boolean) { + let [moveName, ppUsed] = moveTrackRow; + let move; + let maxpp; + if (moveName.charAt(0) === '*') { + // Transformed move + move = this.battle.dex.getMove(moveName.substr(1)); + maxpp = 5; + } else { + move = this.battle.dex.getMove(moveName); + maxpp = move.noPPBoosts ? move.pp : Math.floor(move.pp * 8 / 5); + } + if (ppUsed === Infinity) { + return move.name + ' (0/' + maxpp + ')'; + } + if (ppUsed || moveName.charAt(0) === '*') { + return move.name + ' (' + (maxpp - ppUsed) + '/' + maxpp + ')'; + } + return move.name + (showKnown ? ' (revealed)' : ''); + } + + ppUsed(move: Move, pokemon: Pokemon) { + for (let [moveName, ppUsed] of pokemon.moveTrack) { + if (moveName.charAt(0) === '*') moveName = moveName.substr(1); + if (move.name === moveName) return ppUsed; + } + return 0; + } + + /** + * Calculates possible Speed stat range of an opponent + */ + getSpeedRange(pokemon: Pokemon): [number, number] { + let level = pokemon.level; + let baseSpe = pokemon.getTemplate().baseStats['spe']; + let tier = this.battle.tier; + let gen = this.battle.gen; + let isRandomBattle = tier.includes('Random Battle') || (tier.includes('Random') && tier.includes('Battle') && gen >= 6); + + let minNature = (isRandomBattle || gen < 3) ? 1 : 0.9; + let maxNature = (isRandomBattle || gen < 3) ? 1 : 1.1; + let maxIv = (gen < 3) ? 30 : 31; + + let min; + let max; + const tr = Math.trunc || Math.floor; + if (tier.includes("Let's Go")) { + min = tr(tr(tr(2 * baseSpe * level / 100 + 5) * minNature) * tr((70 / 255 / 10 + 1) * 100) / 100); + max = tr(tr(tr((2 * baseSpe + maxIv) * level / 100 + 5) * maxNature) * tr((70 / 255 / 10 + 1) * 100) / 100); + if (tier.includes('No Restrictions')) max += 200; + else if (tier.includes('Random')) max += 20; + } else { + let maxIvEvOffset = maxIv + ((isRandomBattle && gen >= 3) ? 21 : 63); + min = tr(tr(2 * baseSpe * level / 100 + 5) * minNature); + max = tr(tr((2 * baseSpe + maxIvEvOffset) * level / 100 + 5) * maxNature); + } + return [min, max]; + } + + /** + * Gets the proper current type for moves with a variable type. + */ + getMoveType(move: Move, value: ModifiableValue): [TypeName, 'Physical' | 'Special' | 'Status'] { + let moveType = move.type; + let category = move.category; + let pokemonTypes = value.pokemon!.getTypeList(value.serverPokemon); + value.reset(); + if (move.id === 'revelationdance') { + moveType = pokemonTypes[0]; + } + // Moves that require an item to change their type. + let item = Dex.getItem(value.itemName); + if (move.id === 'multiattack' && item.onMemory) { + if (value.itemModify(0)) moveType = item.onMemory; + } + if (move.id === 'judgment' && item.onPlate) { + if (value.itemModify(0)) moveType = item.onPlate; + } + if (move.id === 'technoblast' && item.onDrive) { + if (value.itemModify(0)) moveType = item.onDrive; + } + if (move.id === 'naturalgift' && item.naturalGift) { + if (value.itemModify(0)) moveType = item.naturalGift.type; + } + // Weather and pseudo-weather type changes. + if (move.id === 'weatherball' && value.weatherModify(0)) { + switch (this.battle.weather) { + case 'sunnyday': case 'desolateland': moveType = 'Fire'; break; + case 'raindance': case 'primordialsea': moveType = 'Water'; break; + case 'sandstorm': moveType = 'Rock'; break; + case 'hail': moveType = 'Ice'; break; + } + } + // Other abilities that change the move type. + const noTypeOverride = ['judgment', 'multiattack', 'naturalgift', 'revelationdance', 'struggle', 'technoblast', 'weatherball']; + const allowTypeOverride = !noTypeOverride.includes(move.id); + + if (allowTypeOverride && move.flags['sound'] && value.abilityModify(0, 'Liquid Voice')) { + moveType = 'Water'; + } + if (allowTypeOverride && moveType === 'Normal' && category !== 'Status') { + if (value.abilityModify(0, 'Aerilate')) moveType = 'Flying'; + if (value.abilityModify(0, 'Galvanize')) moveType = 'Electric'; + if (value.abilityModify(0, 'Pixilate')) moveType = 'Fairy'; + if (value.abilityModify(0, 'Refrigerate')) moveType = 'Ice'; + if (value.abilityModify(0, 'Normalize')) moveType = 'Normal'; + } + if (this.battle.gen <= 3 && category !== 'Status') { + category = Dex.getGen3Category(moveType); + } + return [moveType, category]; + } + + // Gets the current accuracy for a move. + getMoveAccuracy(move: Move, value: ModifiableValue, target?: Pokemon) { + value.reset(move.accuracy === true ? 0 : move.accuracy, true); + + let pokemon = value.pokemon!; + if (move.id === 'toxic' && this.battle.gen >= 6 && this.pokemonHasType(pokemon, 'Poison')) { + value.set(0, "Poison type"); + return value; + } + if (move.id === 'blizzard') { + value.weatherModify(0, 'Hail'); + } + if (move.id === 'hurricane' || move.id === 'thunder') { + value.weatherModify(0, 'Rain Dance'); + value.weatherModify(0, 'Primordial Sea'); + if (value.tryWeather('Sunny Day')) value.set(50, 'Sunny Day'); + if (value.tryWeather('Desolate Land')) value.set(50, 'Desolate Land'); + } + value.abilityModify(0, 'No Guard'); + if (!value.value) return value; + if (move.ohko) { + if (this.battle.gen === 1) { + value.set(value.value, `fails if target's Speed is higher`); + return value; + } + if (move.id === 'sheercold' && this.battle.gen >= 7) { + if (!this.pokemonHasType(pokemon, 'Ice')) value.set(20, 'not Ice-type'); + } + if (target) { + if (pokemon.level < target.level) { + value.reset(0); + value.set(0, "FAILS: target's level is higher"); + } else if (pokemon.level > target.level) { + value.set(value.value + pokemon.level - target.level, "+1% per level above target"); + } + } else { + if (pokemon.level < 100) value.set(value.value, "fails if target's level is higher"); + if (pokemon.level > 1) value.set(value.value, "+1% per level above target"); + } + return value; + } + if (pokemon && pokemon.boosts.accuracy) { + if (pokemon.boosts.accuracy > 0) { + value.modify((pokemon.boosts.accuracy + 3) / 3); + } else { + value.modify(3 / (3 - pokemon.boosts.accuracy)); + } + } + if (move.category === 'Physical') { + value.abilityModify(0.8, "Hustle"); + } + value.abilityModify(1.3, "Compound Eyes"); + for (const active of pokemon.side.active) { + if (!active || active.fainted) continue; + let ability = Dex.getAbility(active.ability).name; + if (ability === 'Victory Star') { + value.modify(1.1, "Victory Star"); + } + } + value.itemModify(1.1, "Wide Lens"); + if (this.battle.hasPseudoWeather('Gravity')) { + value.modify(5 / 3, "Gravity"); + } + return value; + } + + // Gets the proper current base power for moves which have a variable base power. + // Takes into account the target for some moves. + // If it is unsure of the actual base power, it gives an estimate. + getMoveBasePower(move: Move, moveType: TypeName, value: ModifiableValue, target: Pokemon | null = null) { + const pokemon = value.pokemon!; + const serverPokemon = value.serverPokemon; + + value.reset(move.basePower); + + if (move.id === 'acrobatics') { + if (!serverPokemon.item) { + value.modify(2, "Acrobatics + no item"); + } + } + if (['crushgrip', 'wringout'].includes(move.id) && target) { + value.set( + Math.floor(Math.floor((120 * (100 * Math.floor(target.hp * 4096 / target.maxhp)) + 2048 - 1) / 4096) / 100) || 1, + 'approximate' + ); + } + if (move.id === 'brine' && target && target.hp * 2 <= target.maxhp) { + value.modify(2, 'Brine + target below half HP'); + } + if (move.id === 'eruption' || move.id === 'waterspout') { + value.set(Math.floor(150 * pokemon.hp / pokemon.maxhp) || 1); + } + if (move.id === 'facade' && !['', 'slp', 'frz'].includes(pokemon.status)) { + value.modify(2, 'Facade + status'); + } + if (move.id === 'flail' || move.id === 'reversal') { + let multiplier; + let ratios; + if (this.battle.gen > 4) { + multiplier = 48; + ratios = [2, 5, 10, 17, 33]; + } else { + multiplier = 64; + ratios = [2, 6, 13, 22, 43]; + } + let ratio = pokemon.hp * multiplier / pokemon.maxhp; + let basePower; + if (ratio < ratios[0]) basePower = 200; + else if (ratio < ratios[1]) basePower = 150; + else if (ratio < ratios[2]) basePower = 100; + else if (ratio < ratios[3]) basePower = 80; + else if (ratio < ratios[4]) basePower = 40; + else basePower = 20; + value.set(basePower); + } + if (move.id === 'hex' && target && target.status) { + value.modify(2, 'Hex + status'); + } + if (move.id === 'punishment' && target) { + let boostCount = 0; + for (const boost of Object.values(target.boosts)) { + if (boost > 0) boostCount += boost; + } + value.set(Math.min(60 + 20 * boostCount, 200)); + } + if (move.id === 'smellingsalts' && target) { + if (target.status === 'par') { + value.modify(2, 'Smelling Salts + Paralysis'); + } + } + if (['storedpower', 'powertrip'].includes(move.id) && target) { + let boostCount = 0; + for (const boost of Object.values(target.boosts)) { + if (boost > 0) boostCount += boost; + } + value.set(20 + 20 * boostCount); + } + if (move.id === 'trumpcard') { + const ppLeft = 5 - this.ppUsed(move, pokemon); + let basePower = 40; + if (ppLeft === 1) basePower = 200; + else if (ppLeft === 2) basePower = 80; + else if (ppLeft === 3) basePower = 60; + else if (ppLeft === 4) basePower = 50; + value.set(basePower); + } + if (move.id === 'venoshock' && target) { + if (['psn', 'tox'].includes(target.status)) { + value.modify(2, 'Venoshock + Poison'); + } + } + if (move.id === 'wakeupslap' && target) { + if (target.status === 'slp') { + value.modify(2, 'Wake-Up Slap + Sleep'); + } + } + if (move.id === 'weatherball') { + value.weatherModify(2); + } + if (move.id === 'watershuriken' && pokemon.getSpecies() === 'Greninja-Ash' && pokemon.ability === 'Battle Bond') { + value.set(20, 'Battle Bond'); + } + // Moves that check opponent speed + if (move.id === 'electroball' && target) { + let [minSpe, maxSpe] = this.getSpeedRange(target); + let minRatio = (serverPokemon.stats['spe'] / maxSpe); + let maxRatio = (serverPokemon.stats['spe'] / minSpe); + let min; + let max; + + if (minRatio >= 4) min = 150; + else if (minRatio >= 3) min = 120; + else if (minRatio >= 2) min = 80; + else if (minRatio >= 1) min = 60; + else min = 40; + + if (maxRatio >= 4) max = 150; + else if (maxRatio >= 3) max = 120; + else if (maxRatio >= 2) max = 80; + else if (maxRatio >= 1) max = 60; + else max = 40; + + value.setRange(min, max); + } + if (move.id === 'gyroball' && target) { + let [minSpe, maxSpe] = this.getSpeedRange(target); + let min = (Math.floor(25 * minSpe / serverPokemon.stats['spe']) || 1); + if (min > 150) min = 150; + let max = (Math.floor(25 * maxSpe / serverPokemon.stats['spe']) || 1); + if (max > 150) max = 150; + value.setRange(min, max); + } + // Moves which have base power changed due to items + if (serverPokemon.item) { + let item = Dex.getItem(serverPokemon.item); + if (move.id === 'fling' && item.fling) { + value.itemModify(item.fling.basePower); + } + if (move.id === 'naturalgift') { + value.itemModify(item.naturalGift.basePower); + } + } + // Moves which have base power changed according to weight + if (['lowkick', 'grassknot', 'heavyslam', 'heatcrash'].includes(move.id)) { + let isGKLK = ['lowkick', 'grassknot'].includes(move.id); + if (target) { + let targetWeight = target.getWeightKg(); + let pokemonWeight = pokemon.getWeightKg(serverPokemon); + let basePower; + if (isGKLK) { + basePower = 20; + if (targetWeight >= 200) basePower = 120; + else if (targetWeight >= 100) basePower = 100; + else if (targetWeight >= 50) basePower = 80; + else if (targetWeight >= 25) basePower = 60; + else if (targetWeight >= 10) basePower = 40; + } else { + basePower = 40; + if (pokemonWeight > targetWeight * 5) basePower = 120; + else if (pokemonWeight > targetWeight * 4) basePower = 100; + else if (pokemonWeight > targetWeight * 3) basePower = 80; + else if (pokemonWeight > targetWeight * 2) basePower = 60; + } + value.set(basePower); + } else { + value.setRange(isGKLK ? 20 : 40, 120); + } + } + if (!value.value) return value; + + // Other ability boosts + if (pokemon.status === 'brn' && move.category === 'Special') { + value.abilityModify(1.5, "Flare Boost"); + } + if (move.flags['pulse']) { + value.abilityModify(1.5, "Mega Launcher"); + } + if (move.flags['bite']) { + value.abilityModify(1.5, "Strong Jaw"); + } + if (value.value <= 60) { + value.abilityModify(1.5, "Technician"); + } + if (['psn', 'tox'].includes(pokemon.status) && move.category === 'Physical') { + value.abilityModify(1.5, "Toxic Boost"); + } + if (['Rock', 'Ground', 'Steel'].includes(moveType) && this.battle.weather === 'sandstorm') { + if (value.tryAbility("Sand Force")) value.weatherModify(1.3, "Sandstorm", "Sand Force"); + } + if (move.secondaries) { + value.abilityModify(1.3, "Sheer Force"); + } + if (move.flags['contact']) { + value.abilityModify(1.3, "Tough Claws"); + } + if (target) { + if (["MF", "FM"].includes(pokemon.gender + target.gender)) { + value.abilityModify(1.25, "Rivalry"); + } else if (["MM", "FF"].includes(pokemon.gender + target.gender)) { + value.abilityModify(0.75, "Rivalry"); + } + } + const noTypeOverride = ['judgment', 'multiattack', 'naturalgift', 'revelationdance', 'struggle', 'technoblast', 'weatherball']; + if (move.type === 'Normal' && move.category !== 'Status' && !noTypeOverride.includes(move.id)) { + value.abilityModify(this.battle.gen > 6 ? 1.2 : 1.3, "Aerilate"); + value.abilityModify(this.battle.gen > 6 ? 1.2 : 1.3, "Galvanize"); + value.abilityModify(this.battle.gen > 6 ? 1.2 : 1.3, "Pixilate"); + value.abilityModify(this.battle.gen > 6 ? 1.2 : 1.3, "Refrigerate"); + if (this.battle.gen > 6) { + value.abilityModify(1.2, "Normalize"); + } + } + if (move.flags['punch']) { + value.abilityModify(1.2, 'Iron Fist'); + } + if (move.recoil || move.hasCustomRecoil) { + value.abilityModify(1.2, 'Reckless'); + } + + if (move.category !== 'Status') { + let auraBoosted = ''; + let auraBroken = false; + for (const ally of pokemon.side.active) { + if (!ally || ally.fainted) continue; + if (moveType === 'Fairy' && ally.ability === 'Fairy Aura') { + auraBoosted = 'Fairy Aura'; + } else if (moveType === 'Dark' && ally.ability === 'Dark Aura') { + auraBoosted = 'Dark Aura'; + } else if (ally.ability === 'Aura Break') { + auraBroken = true; + } else if (ally.ability === 'Battery') { + if (ally !== pokemon && move.category === 'Special') { + value.modify(1.3, 'Battery'); + } + } + } + for (const foe of pokemon.side.foe.active) { + if (!foe || foe.fainted) continue; + if (foe.ability === 'Fairy Aura') { + if (moveType === 'Fairy') auraBoosted = 'Fairy Aura'; + } else if (foe.ability === 'Dark Aura') { + if (moveType === 'Dark') auraBoosted = 'Dark Aura'; + } else if (foe.ability === 'Aura Break') { + auraBroken = true; + } + } + if (auraBoosted) { + if (auraBroken) { + value.modify(0.75, auraBoosted + ' + Aura Break'); + } else { + value.modify(1.33, auraBoosted); + } + } + } + + // Terrain + if ((this.battle.hasPseudoWeather('Electric Terrain') && moveType === 'Electric') || + (this.battle.hasPseudoWeather('Grassy Terrain') && moveType === 'Grass') || + (this.battle.hasPseudoWeather('Psychic Terrain') && moveType === 'Psychic')) { + if (pokemon.isGrounded(serverPokemon)) { + value.modify(1.5, 'Terrain boost'); + } + } else if (this.battle.hasPseudoWeather('Misty Terrain') && moveType === 'Dragon') { + if (target ? target.isGrounded() : true) { + value.modify(0.5, 'Misty Terrain + grounded target'); + } + } + + return value; + } + + static incenseTypes: {[itemName: string]: TypeName} = { + 'Odd Incense': 'Psychic', + 'Rock Incense': 'Rock', + 'Rose Incense': 'Grass', + 'Sea Incense': 'Water', + 'Wave Incense': 'Water', + }; + static itemTypes: {[itemName: string]: TypeName} = { + 'Black Belt': 'Fighting', + 'Black Glasses': 'Dark', + 'Charcoal': 'Fire', + 'Dragon Fang': 'Dragon', + 'Hard Stone': 'Rock', + 'Magnet': 'Electric', + 'Metal Coat': 'Steel', + 'Miracle Seed': 'Grass', + 'Mystic Water': 'Water', + 'Never-Melt Ice': 'Ice', + 'Poison Barb': 'Poison', + 'Sharp Beak': 'Flying', + 'Silk Scarf': 'Normal', + 'SilverPowder': 'Bug', + 'Soft Sand': 'Ground', + 'Spell Tag': 'Ghost', + 'Twisted Spoon': 'Psychic', + }; + static orbUsers: {[speciesName: string]: string} = { + 'Latias': 'Soul Dew', + 'Latios': 'Soul Dew', + 'Dialga': 'Adamant Orb', + 'Palkia': 'Lustrous Orb', + 'Giratina': 'Griseous Orb', + }; + static orbTypes: {[itemName: string]: TypeName} = { + 'Soul Dew': 'Psychic', + 'Adamant Orb': 'Steel', + 'Lustrous Orb': 'Water', + 'Griseous Orb': 'Ghost', + }; + static noGemMoves = [ + 'Fire Pledge', + 'Fling', + 'Grass Pledge', + 'Struggle', + 'Water Pledge', + ]; + getItemBoost(move: Move, value: ModifiableValue, moveType: TypeName) { + let item = this.battle.dex.getItem(value.serverPokemon.item); + let itemName = item.name; + let moveName = move.name; + + // Plates + if (item.onPlate === moveType && !item.zMove) { + value.itemModify(1.2); + return value; + } + + // Incenses + if (BattleTooltips.incenseTypes[item.name] === moveType) { + value.itemModify(1.2); + return value; + } + + // Type-enhancing items + if (BattleTooltips.itemTypes[item.name] === moveType) { + value.itemModify(this.battle.gen < 4 ? 1.1 : 1.2); + return value; + } + + // Pokemon-specific items + if (item.name === 'Soul Dew' && this.battle.gen < 7) return value; + if (BattleTooltips.orbUsers[Dex.getTemplate(value.serverPokemon.species).baseSpecies] === item.name && + [BattleTooltips.orbTypes[item.name], 'Dragon'].includes(moveType)) { + value.itemModify(1.2); + return value; + } + + // Gems + if (BattleTooltips.noGemMoves.includes(moveName)) return value; + if (itemName === moveType + ' Gem') { + value.itemModify(this.battle.gen < 6 ? 1.5 : 1.3); + return value; + } + + return value; + } + getPokemonTypes(pokemon: Pokemon | ServerPokemon): ReadonlyArray { + if (!(pokemon as Pokemon).getTypes) { + return this.battle.dex.getTemplate(pokemon.species).types; + } + + return (pokemon as Pokemon).getTypeList(); + } + pokemonHasType(pokemon: Pokemon | ServerPokemon, type: TypeName, types?: ReadonlyArray) { + if (!types) types = this.getPokemonTypes(pokemon); + for (const curType of types) { + if (curType === type) return true; + } + return false; + } +} diff --git a/src/battle.ts b/src/battle.ts index 200e74f58..63f0362b1 100644 --- a/src/battle.ts +++ b/src/battle.ts @@ -34,7 +34,7 @@ type WeatherState = [string, number, number]; type EffectTable = {[effectid: string]: EffectState}; type HPColor = 'r' | 'y' | 'g'; -class Pokemon { +class Pokemon implements PokemonDetails, PokemonHealth { name = ''; species = ''; @@ -94,7 +94,6 @@ class Pokemon { volatiles: EffectTable = {}; turnstatuses: EffectTable = {}; movestatuses: EffectTable = {}; - weightkg = 0; lastMove = ''; /** [[moveName, ppUsed]] */ @@ -132,7 +131,7 @@ class Pokemon { } return ''; } - getPixelRange(pixels: number, color: HPColor): [number, number] { + static getPixelRange(pixels: number, color: HPColor | ''): [number, number] { let epsilon = 0.5 / 714; if (pixels === 0) return [0, 0]; @@ -155,7 +154,7 @@ class Pokemon { return [pixels / 48, (pixels + 1) / 48 - epsilon]; } - getFormattedRange(range: [number, number], precision: number, separator: string) { + static getFormattedRange(range: [number, number], precision: number, separator: string) { if (range[0] === range[1]) { let percentage = Math.abs(range[0] * 100); if (Math.floor(percentage) === percentage) { @@ -185,8 +184,8 @@ class Pokemon { return [damage[2] / 100, damage[2] / 100]; } // pixel damage - let oldrange = this.getPixelRange(damage[3], damage[4]); - let newrange = this.getPixelRange(damage[3] + damage[0], this.hpcolor); + let oldrange = Pokemon.getPixelRange(damage[3], damage[4]); + let newrange = Pokemon.getPixelRange(damage[3] + damage[0], this.hpcolor); if (damage[0] === 0) { // no change in displayed pixel width return [0, newrange[1] - newrange[0]]; @@ -384,6 +383,10 @@ class Pokemon { // let badBoostTable = ['Normal', '−1', '−2', '−3', '−4', '−5', '−6']; return '' + badBoostTable[-this.boosts[boostStat]] + ' ' + boostStatTable[boostStat]; } + getWeightKg(serverPokemon?: ServerPokemon) { + let autotomizeFactor = this.volatiles.autotomize ? this.volatiles.autotomize[1] * 100 : 0; + return Math.max(this.getTemplate(serverPokemon).weightkg - autotomizeFactor, 0.1); + } getBoostType(boostStat: BoostStatName) { if (!this.boosts[boostStat]) return 'neutral'; if (this.boosts[boostStat] > 0) return 'good'; @@ -391,9 +394,6 @@ class Pokemon { } clearVolatile() { this.ability = this.baseAbility; - if (window.BattlePokedex && BattlePokedex[this.species] && BattlePokedex[this.species].weightkg) { - this.weightkg = BattlePokedex[this.species].weightkg; - } this.boosts = {}; this.clearVolatiles(); for (let i = 0; i < this.moveTrack.length; i++) { @@ -452,29 +452,59 @@ class Pokemon { this.removeVolatile('typeadd' as ID); } } - getTypes(): [string[], string] { - let types; + getTypes(serverPokemon?: ServerPokemon): [ReadonlyArray, TypeName | ''] { + let types: ReadonlyArray; if (this.volatiles.typechange) { types = this.volatiles.typechange[1].split('/'); } else { - const species = this.getSpecies(); - types = ( - window.BattleTeambuilderTable && - window.BattleTeambuilderTable['gen' + this.side.battle.gen] && - window.BattleTeambuilderTable['gen' + this.side.battle.gen].overrideType[toId(species)] - ); - if (types) types = types.split('/'); - if (!types) types = Dex.getTemplate(species).types || []; + types = this.getTemplate(serverPokemon).types; + } + if (this.volatiles.roost && types.includes('Flying')) { + types = types.filter(typeName => typeName !== 'Flying'); + if (!types.length) types = ['Normal']; } const addedType = (this.volatiles.typeadd ? this.volatiles.typeadd[1] : ''); return [types, addedType]; } - getTypeList() { - const [types, addedType] = this.getTypes(); - return types.concat(addedType); + isGrounded(serverPokemon?: ServerPokemon) { + const battle = this.side.battle; + if (battle.hasPseudoWeather('Gravity')) { + return true; + } else if (this.volatiles['ingrain'] && battle.gen >= 4) { + return true; + } else if (this.volatiles['smackdown']) { + return true; + } + + let item = toId(serverPokemon ? serverPokemon.item : this.item); + let ability = toId(this.ability || (serverPokemon && serverPokemon.ability)); + if (battle.hasPseudoWeather('Magic Room') || this.volatiles['embargo'] || ability === 'klutz') { + item = '' as ID; + } + + if (item === 'ironball') { + return true; + } + if (ability === 'levitate') { + return false; + } + if (this.volatiles['magnetrise'] || this.volatiles['telekinesis']) { + return false; + } else if (item !== 'airballoon') { + return false; + } + return !this.getTypeList(serverPokemon).includes('Flying'); } - getSpecies(): string { - return this.volatiles.formechange ? this.volatiles.formechange[1] : this.species; + getTypeList(serverPokemon?: ServerPokemon) { + const [types, addedType] = this.getTypes(serverPokemon); + return addedType ? types.concat(addedType) : types; + } + getSpecies(serverPokemon?: ServerPokemon): string { + return this.volatiles.formechange ? this.volatiles.formechange[1] : + (serverPokemon ? serverPokemon.species : this.species); + } + getTemplate(serverPokemon?: ServerPokemon) { + return this.side.battle.dex.getTemplate(this.getSpecies(serverPokemon)); } reset() { this.clearVolatile(); @@ -500,7 +530,7 @@ class Pokemon { // Draw the health bar to the middle of the range. // This affects the width of the visual health bar *only*; it // does not affect the ranges displayed in any way. - let range = this.getPixelRange(this.hp, this.hpcolor); + let range = Pokemon.getPixelRange(this.hp, this.hpcolor); let ratio = (range[0] + range[1]) / 2; return Math.round(maxWidth * ratio) || 1; } @@ -510,11 +540,11 @@ class Pokemon { } return percentage * maxWidth / 100; } - hpDisplay(precision = 1) { - if (this.maxhp === 100) return this.hp + '%'; - if (this.maxhp !== 48) return (100 * this.hp / this.maxhp).toFixed(precision) + '%'; - let range = this.getPixelRange(this.hp, this.hpcolor); - return this.getFormattedRange(range, precision, '–'); + static getHPText(pokemon: PokemonHealth, precision = 1) { + if (pokemon.maxhp === 100) return pokemon.hp + '%'; + if (pokemon.maxhp !== 48) return (100 * pokemon.hp / pokemon.maxhp).toFixed(precision) + '%'; + let range = Pokemon.getPixelRange(pokemon.hp, pokemon.hpcolor); + return Pokemon.getFormattedRange(range, precision, '–'); } destroy() { if (this.sprite) this.sprite.destroy(); @@ -875,6 +905,48 @@ enum Playback { Seeking = 5, } +interface PokemonDetails { + details: string; + name: string; + species: string; + level: number; + shiny: boolean; + gender: GenderName | ''; + ident: string; + searchid: string; +} +interface PokemonHealth { + hp: number; + maxhp: number; + hpcolor: HPColor | ''; + status: StatusName | 'tox' | '' | '???'; + fainted?: boolean; +} +interface ServerPokemon extends PokemonDetails, PokemonHealth { + ident: string; + details: string; + condition: string; + active: boolean; + /** unboosted stats */ + stats: { + atk: number, + def: number, + spa: number, + spd: number, + spe: number, + }; + /** currently an ID, will revise to name */ + moves: string[]; + /** currently an ID, will revise to name */ + baseAbility: string; + /** currently an ID, will revise to name */ + ability?: string; + /** currently an ID, will revise to name */ + item: string; + /** currently an ID, will revise to name */ + pokeball: string; +} + class Battle { scene: BattleScene | BattleSceneStub; @@ -923,9 +995,12 @@ class Battle { yourSide: Side = null!; p1: Side = null!; p2: Side = null!; + myPokemon: ServerPokemon[] | null = null; sides: [Side, Side] = [null!, null!]; lastMove = ''; + gen = 7; + dex: ModdedDex = Dex; teamPreviewCount = 0; speciesClause = false; tier = ''; @@ -934,6 +1009,11 @@ class Battle { endLastTurnPending = false; totalTimeLeft = 0; graceTimeLeft = 0; + /** + * true: timer on, state unknown + * false: timer off + * number: seconds left this turn + */ kickingInactive: number | boolean = false; // options @@ -1394,7 +1474,7 @@ class Battle { break; } } else { - let damageinfo = '' + poke.getFormattedRange(range, damage[1] === 100 ? 0 : 1, '\u2013'); + let damageinfo = '' + Pokemon.getFormattedRange(range, damage[1] === 100 ? 0 : 1, '\u2013'); if (damage[1] !== 100) { let hover = '' + ((damage[0] < 0) ? '\u2212' : '') + Math.abs(damage[0]) + '/' + damage[1]; @@ -1406,7 +1486,7 @@ class Battle { } args[3] = damageinfo; } - this.scene.damageAnim(poke, poke.getFormattedRange(range, 0, ' to ')); + this.scene.damageAnim(poke, Pokemon.getFormattedRange(range, 0, ' to ')); this.log(args, kwArgs); break; } @@ -1439,7 +1519,7 @@ class Battle { } } this.scene.runOtherAnim('heal' as ID, [poke]); - this.scene.healAnim(poke, poke.getFormattedRange(range, 0, ' to ')); + this.scene.healAnim(poke, Pokemon.getFormattedRange(range, 0, ' to ')); this.log(args, kwArgs); break; } @@ -1449,7 +1529,7 @@ class Battle { if (cpoke) { let damage = cpoke.healthParse(args[2 + 2 * k])!; let range = cpoke.getDamageRange(damage); - let formattedRange = cpoke.getFormattedRange(range, 0, ' to '); + let formattedRange = Pokemon.getFormattedRange(range, 0, ' to '); let diff = damage[0]; if (diff > 0) { this.scene.healAnim(cpoke, formattedRange); @@ -2059,11 +2139,10 @@ class Battle { } newSpecies = args[2].substr(0, commaIndex); } - let template = Dex.getTemplate(newSpecies); + let template = this.dex.getTemplate(newSpecies); poke.species = newSpecies; poke.ability = poke.baseAbility = (template.abilities ? template.abilities['0'] : ''); - poke.weightkg = template.weightkg; poke.details = args[2]; poke.searchid = args[1].substr(0, 2) + args[1].substr(3) + '|' + args[2]; @@ -2084,7 +2163,6 @@ class Battle { poke.boosts = {...tpoke.boosts}; poke.copyTypesFrom(tpoke); - poke.weightkg = tpoke.weightkg; poke.ability = tpoke.ability; const species = (tpoke.volatiles.formechange ? tpoke.volatiles.formechange[1] : tpoke.species); const pokemon = tpoke; @@ -2188,6 +2266,7 @@ class Battle { break; case 'imprison': this.scene.resultAnim(poke, 'Imprisoning', 'good'); + break; case 'disable': this.scene.resultAnim(poke, 'Disabled', 'bad'); break; @@ -2239,6 +2318,11 @@ class Battle { break; case 'autotomize': this.scene.resultAnim(poke, 'Lightened', 'good'); + if (poke.volatiles.autotomize) { + poke.volatiles.autotomize[1]++; + } else { + poke.addVolatile('autotomize' as ID, 1); + } break; case 'focusenergy': this.scene.resultAnim(poke, '+Crit rate', 'good'); @@ -2685,7 +2769,7 @@ class Battle { return data.spriteData[siden]; } */ - parseDetails(name: string, pokemonid: string, details = "", output: any = {}) { + parseDetails(name: string, pokemonid: string, details = "", output: PokemonDetails = {} as any) { output.details = details; output.name = name; output.species = name; @@ -2700,7 +2784,7 @@ class Battle { splitDetails.pop(); } if (splitDetails[splitDetails.length - 1] === 'M' || splitDetails[splitDetails.length - 1] === 'F') { - output.gender = splitDetails[splitDetails.length - 1]; + output.gender = splitDetails[splitDetails.length - 1] as GenderName; splitDetails.pop(); } if (splitDetails[1]) { @@ -2711,13 +2795,7 @@ class Battle { } return output; } - parseHealth(hpstring: string, output: any = {}): { - hp: number, - maxhp: number, - hpcolor: HPColor | '', - status: StatusName | '', - fainted?: boolean, - } | null { + parseHealth(hpstring: string, output: PokemonHealth = {} as any) { let [hp, status] = hpstring.split(' '); // hp parse @@ -3177,6 +3255,7 @@ class Battle { } case 'gen': { this.gen = parseInt(args[1], 10); + this.dex = Dex.mod(`gen${this.gen}` as ID); this.scene.updateGen(); this.log(args); break; diff --git a/style/battle.css b/style/battle.css index 04a437790..95fa45d5f 100644 --- a/style/battle.css +++ b/style/battle.css @@ -711,6 +711,60 @@ License: GPLv2 -ms-interpolation-mode: nearest-neighbor; } +/********************************************************* + * Tooltips + *********************************************************/ + +#tooltipwrapper { + position: absolute; + top: 400px; + left: 100px; + text-align: left; + color: black; + pointer-events: none; +} +#tooltipwrapper .tooltipinner { + position: relative; +} +#tooltipwrapper .tooltip { + position: absolute; + bottom: 0; + left: 0; + width: 300px; + border: 1px solid #888888; + background: #EEEEEE; + background: rgba(240,240,240,.9); + border-radius: 5px; + z-index: 50; +} +#tooltipwrapper .tooltip h2 { + padding: 2px 4px; + margin: 0; + border-bottom: 1px solid #888888; + font-size: 10pt; +} +#tooltipwrapper .tooltip h2 small { + font-weight: normal; +} +#tooltipwrapper .tooltip p { + padding: 2px 4px; + margin: 0; + font-size: 9pt; +} +#tooltipwrapper .tooltip p small { + font-size: 8pt; +} +#tooltipwrapper .tooltip p.section { + border-top: 1px solid #888888; +} +#tooltipwrapper.tooltip-locked { + pointer-events: auto; +} +#tooltipwrapper.tooltip-locked .tooltip { + border: 2px solid #888888; + background: #DEDEDE; +} + /********************************************************* * Message log styling *********************************************************/ @@ -829,7 +883,7 @@ License: GPLv2 color: #DDD; } .stat-boosted { - color: #119911; + color: #117911; } .stat-lowered { color: #991111; diff --git a/style/client.css b/style/client.css index f26a0d0af..c83bf5ff5 100644 --- a/style/client.css +++ b/style/client.css @@ -1970,12 +1970,12 @@ a.ilink.yours { .movemenu button small.type { padding-top: 3px; float: left; - font-size: 7pt; + font-size: 8pt; } .movemenu button small.pp { - padding-top: 3px; + padding-top: 2px; float: right; - font-size: 7pt; + font-size: 8pt; } .megaevo { clear: both; @@ -2135,48 +2135,6 @@ a.ilink.yours { } } -/****************/ - -#tooltipwrapper { - position: absolute; - top: 400px; - left: 100px; - text-align: left; - color: black; - pointer-events: none; -} -#tooltipwrapper .tooltipinner { - position: relative; -} -#tooltipwrapper .tooltip { - position: absolute; - bottom: 0; - left: 0; - width: 300px; - border: 1px solid #888888; - background: #EEEEEE; - background: rgba(240,240,240,.9); - border-radius: 5px; - z-index: 50; -} -#tooltipwrapper .tooltip h2 { - padding: 2px 4px; - margin: 0; - border-bottom: 1px solid #888888; - font-size: 10pt; -} -#tooltipwrapper .tooltip h2 small { - font-weight: normal; -} -#tooltipwrapper .tooltip p { - padding: 2px 4px; - margin: 0; - font-size: 8pt; -} -#tooltipwrapper .tooltip p.section { - border-top: 1px solid #888888; -} - /********************************************************* * Teambuilder *********************************************************/ diff --git a/testclient.html b/testclient.html index 91ca8881b..62b580f62 100644 --- a/testclient.html +++ b/testclient.html @@ -104,7 +104,7 @@ - + diff --git a/tsconfig.json b/tsconfig.json index 728957bb3..8b9edd422 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "jsx": "preserve", "strict": true }, - "types": ["node"], + "types": [], "include": [ "./js/lib/preact.d.ts", "./src/*" diff --git a/tslint.json b/tslint.json index 9935a1522..34464fc9b 100644 --- a/tslint.json +++ b/tslint.json @@ -32,8 +32,9 @@ "no-bitwise": false, "prefer-conditional-expression": false, "no-shadowed-variable": [true, {"temporalDeadZone": false}], + "no-switch-case-fall-through": true, "object-literal-sort-keys": false, - "object-literal-key-quotes": [true, "as-needed"], + "object-literal-key-quotes": false, "trailing-comma": [ true, {