Stats y Tiempo Jugado

20/27

Lectura

En este apartado vamos a terminar el componente de MainBlock. Vamos a crear, primero, la estructura de carpetas.

Dentro de /MainBlock creamos una nueva carpeta /PlayerStats. Dentro vamos a crear Index.vue, SingleStat.vue, TimePlayed.vue y TimePlayedHero.vue:

📂 /MainBlock
└──📂 /PlayerStats
   ├── Index.vue
   ├── SingleStat.vue
   ├── TimePlayed.vue
   └── TimePlayedHero.vue

Ahora, en el componente MainBlock, en el bloque de la derecha, quitamos el texto que teníamos puesto y ponemos el componente que acabamos de crear. El HTML de /MainBlock/Index.vue se vería así:

<template>
  <div class="grid-container">
    <div class="grid-item item-left">

      <TopHeroes v-if="hasHeroes" :heroes="topHeroes"/>

      <HeroesList v-if="hasHeroesList" :heroes="heroesList"/>

      <ProgressList :acts="profileData.progression"/>

    </div>

    <!-- Right Bar-->
    <div class="grid-item item-right">

      <PlayerStats :stats="statsData"/>

    </div>

  </div>
</template>

La prop que le estamos pasando a PlayerStats con el nombre de stats, es una computed property. statsData tiene el siguiente contenido:

statsData () {
  const { paragonLevel, kills, timePlayed } = this.profileData
  return { paragonLevel, kills, timePlayed }
}

Lo que devuelve statsData son los datos que necesita nuestro componente de stats:

  • Paragon level: nivel de leyenda.
  • Kills: monstruos y élites.
  • Time played: Tiempo jugado por héroe en porcentaje.
    • Esto quiere decir que si has jugado 10 minutos con el monje y 5 minutos con el cruzado vas a tener el monje al 100% y el cruzado al 50%. No está basado en horas o minutos.

El código completo del componente MainBlock (sin contar el CSS, que no ha cambiado) es este:

<template>
  <div class="grid-container">
    <div class="grid-item item-left">

      <TopHeroes v-if="hasHeroes" :heroes="topHeroes"/>

      <HeroesList v-if="hasHeroesList" :heroes="heroesList"/>

      <ProgressList :acts="profileData.progression"/>

    </div>

    <!-- Right Bar-->
    <div class="grid-item item-right">

      <PlayerStats :stats="statsData"/>

    </div>

  </div>
</template>

<script>

import TopHeroes from './TopHeroes/Index'
import HeroesList from './HeroesList/Index'
import ProgressList from './ProgressList/Index'
import PlayerStats from './PlayerStats/Index'

export default {
  name: 'MainBlock',
  components: { PlayerStats, ProgressList, HeroesList, TopHeroes },
  props: {
    profileData: {
      type: Object,
      required: true
    }
  },
  computed: {
    hasHeroes () {
      return this.profileData.heroes.length > 0
    },
    hasHeroesList () {
      return this.profileData.heroes.length > 3
    },
    topHeroes () {
      return this.profileData.heroes.slice(0, 3)
    },
    heroesList () {
      return this.profileData.heroes.slice(3, this.profileData.heroes.length)
    },
    statsData () {
      const { paragonLevel, kills, timePlayed } = this.profileData
      return { paragonLevel, kills, timePlayed }
    }
  }
}
</script>

El HTML del componente PlayerStats es el siguiente:

<template>
  <div class="multi-stats pl-lg-4">
    <hr class="bg-white mt-5 d-lg-none">
    <h2 class="font-diablo my-4">Stats</h2>
    <div class="stats d-flex flex-column bg-dark p-3">
      <SingleStat
        class="mb-3"
        ico-name="skull"
        ico-color="#9E9E9E"
        :info="{value: stats.kills.monsters, text:'Lifetime Kills'}" />

      <SingleStat
        class="mb-3"
        ico-name="crown"
        ico-color="#ffc107"
        :info="{value: stats.kills.elites, text:'Elite Kills'}"/>

      <SingleStat
        ico-name="dungeon"
        ico-color="#795548"
        :info="{value: stats.paragonLevel, text:'Paragon Level'}"/>
    </div>

    <TimePlayed :timePlayed="timePlayed"/>

  </div>
</template>

PlayerStats hace uso de dos componentes distintos.

Por un lado tenemos el componente SingleStat.vue. Lo único distinto aquí es la propiedad ico-name, que se refiere al nombre del ícono de FontAwesome.

📗 Ver documentación de Vue FontAwesome: https://github.com/FortAwesome/vue-fontawesome#usage

Por otro lado, el componente TimePlayed.vue itera sobre el objeto que contiene los tiempos por cada personaje, y, en su interior, hace uso del componente de barra de progreso de bootstrap-vue (https://bootstrap-vue.js.org/docs/components/progress).

El bloque <script></script> del componente PlayerStats/Index.vue es el siguiente:

import heroName from '@/mixins/heroName'
import SingleStat from './SingleStat'
import TimePlayed from './TimePlayed'

export default {
  name: 'PlayerStats',
  mixins: [heroName],
  components: {
    TimePlayed,
    SingleStat
  },
  props: {
    stats: {
      required: true,
      type: Object
    }
  }
}

De momento, no hay nada nuevo que explicar. Tenemos un mixin y dos componentes.
Nos vamos a centrar en el segundo componente, TimePlayed.vue.

Antes de revisar el componente TimePlayed, vamos a copiar el contenido del componente SingleStat.vue para pegarlo en nuestro fichero:

<!-- SingleStat.vue -->
<template>
  <div class="single-stat w-100">
    <b-card class="text-body">
      <div class="d-flex">
        <div class="d-block ico-cont">
          <div class="text-center">
            <!-- Ícono & Color-->
            <font-awesome-icon :icon="icoName" class="fa-3x" :style="{color: icoColor}"/>
          </div>
        </div>
        <div class="flex-grow-1">
          <!-- Valor & Filtro -->
          <h4 class="font-weight-bold mb-0">{{ info.value | formatNumber }}</h4>
          <!-- Texto -->
          <span class="text-muted font-weight-light mb-0">{{ info.text }}</span>
        </div>
      </div>
    </b-card>
  </div>
</template>

<script>
import { formatNumber } from '@/filters/numeral'

export default {
  name: 'SingleStat',
  filters: {
    formatNumber
  },
  props: {
    icoName: {
      required: true,
      type: String
    },
    icoColor: {
      required: true,
      type: String
    },
    info: {
      required: true,
      type: Object
    }
  }
}
</script>

<style lang="stylus" scoped>
  .single-stat
    .ico-cont
      width 80px
</style>

Como has podido ver, este componente no tiene ninguna complicación. Simplemente pinta los datos que le pasamos: el ícono, color del ícono y valor numérico (formateado con el filtro de numeral). Deberías ver algo así:

preview-1.png

Acuérdate, a los íconos los estamos pintando gracias a FontAwesome.

TimePlayed.vue tiene una configuración especial que vamos a explicar ahora. Cuando estamos definiendo las props de un componente, podemos validar el valor que recibe la propiedad a través de una función y definir el tipo de variable que espera dicha propiedad. Lo hemos hecho en algún caso. Ejemplo:

props: {
  type: {
    required: false,
    type: String,
    default: 'border',
    validator: (value) => {
      return ['border', 'grow'].indexOf(value) !== -1
    }
  }
}

¿Qué pasa si yo quiero validar un tipo (type) de objeto específico?
Algo que no sea, por ejemplo, un String o un Object. Vue nos permite crear nuestros propios tipos de dato y poder usarlos como type.

Custom Types

Con un ejemplo lo verás más claro. Imagina que queremos crear un tipo que sea Persona. Una Persona tiene nombre y el apellido. Podríamos crear algo como esto:

function Persona (nombre, apellido) {
  this.nombre = nombre
  this.apellido = apellido
}

Y luego, dentro de nuestro componente lo podríamos usar así:

props: {
  author: {
    type: Persona, // Tipo que acabamos de crear
    required: true
  }
}

📗 Aquí explican, un poco más, como funciona esto de los custom types: https://es.vuejs.org/v2/guide/components-props.html#Tipos-de-la-validacion

Esto es lo que vamos a hacer en nuestro componente TimePlayed: vamos a crearnos un tipo de dato personalizado para poder usarlo en la definición de las props.

Lo primero que vamos a hacer es crear la función tipo. Vamos a crearla en la carpeta /utils. Al fichero, le vamos a dar el nombre de typeValidation.js. El contenido es el siguiente:

/**
 * Used for custom validations
 * @param hero {String}
 * @param time {String}
 * @param classSlug {String}
 * @constructor
 */
function HeroData (hero, time, classSlug) {
  this.hero = hero
  this.time = time
  this.classSlug = classSlug
}

export {
  HeroData
}

Con esto hemos creado una función que usaremos como un nuevo tipo de dato. Podemos usar esta función y definir nuestra propiedad del componente como HeroData 💃.

A continuación, si revisas cómo está usándose el componente TimePlayed en PlayerStats/Index.vue, puedes ver que tiene este HTML:

<TimePlayed :timePlayed="timePlayed"/>

Sin embargo, no tenemos nada definido como timePlayed dentro de nuestro componente PlayerStats.
Para crear este dato, en el componente PlayerStats/Index.vue creamos una computed property con este nombre, de esta forma:

computed: {
  timePlayed () {
    return Object.keys(this.stats.timePlayed)
      .sort()
      .map(hero => {
        return new HeroData(
          this.classToName(hero), 
          this.stats.timePlayed[hero], 
          hero
        )
      })
  }
}

Si inspeccionamos el navegador y analizamos el objeto de stats.timePlayed, vemos que tiene este contenido:

{
  "demon-hunter":0.424,
  "barbarian":0.055,
  "witch-doctor":0.319,
  "necromancer":0.226,
  "wizard":0.103,
  "monk":1,
  "crusader":0.399
}

Lo que estamos haciendo en esta computed property es obtener todas las claves (keys) de este objeto, ordenarlas con sort, y, sobre este array de items ordenados, usar la función map. Con esto estaríamos generando un array de objetos con estos datos:

[
  {
    hero: 'Barbarian',
    time: 0.055,
    classSlug: 'barbarian'
  }, 
  {
    hero: 'Crusader',
    time: 0.399,
    classSlug: 'crusader'
  }, 
  {
    hero: 'Demon Hunter',
    time: 0.424,
    classSlug: 'demon-hunter'
  }
  // ...
]

Con esto ya tenemos el nombre del héroe normalizado y el valor del tiempo. Además, estos datos los hemos creado con nuestra función HeroData, que utilizaremos más adelante.

Vayamos entonces al componente PlayerStats/TimePlayed.vue. Este componente también es muy sencillo y no requiere una explicación extensa:

<template>
  <div class="time-played mt-3">
    <hr class="bg-white mt-5">
    <h2 class="font-diablo my-4">Time Played</h2>
    <div class="bg-dark p-3">
      <div v-for="hero in timePlayed" :key="hero.classSlug">
        <TimePlayedHero :hero-time="hero"/>
      </div>
    </div>
  </div>
</template>

<script>
import TimePlayedHero from './TimePlayedHero'

export default {
  name: 'TimePlayed',
  components: { TimePlayedHero },
  props: {
    timePlayed: {
      required: true,
      type: Array
    }
  }
}
</script>

Un v-for para recorrer el array de elementos de tipo HeroData que le hemos pasado como property y para cada elemento renderizamos el componente TimePlayedHero. Es aquí donde vamos a usar nuestra custom validation.


Vamos a explicar el componente TimePlayedHero.vue:

  • El bloque de JavaScript tiene lo siguiente:
<script>
// Traemos nuestro tipo personalizado
import { HeroData } from '@/utils/typeValidation'

export default {
  name: 'TimePlayedHero',
  props: {
    // Definimos la propiedad con el tipo personalizado 'HeroData'
    heroTime: {
      type: HeroData,
      required: true
    }
  },
  computed: {
    // Cada héroe tiene un color
    // Creamos una clase por cada héroe
    classHeroBg () {
      return `hero-bg-color-${this.heroTime.classSlug}`
    }
  }
}
</script>

La propiedad heroTime que recibe el componente , y que es de tipo HeroData, tiene este formato:

{
  "hero": "Barbarian",      // String
  "time": 0.055,            // Number
  "classSlug": "barbarian"  // String
}

Lo bueno de haber usado el custom type es que si en vez de pasarle un dato de tipo HeroData le hubiéramos pasado un dato con el mismo formato que HeroData (es decir, un objeto con las 3 claves: hero, time y classSlug) pero que no ha sido creado con el constructor de HeroData, Vue nos indicará que ese tipo no es el esperado.

En este caso, para forzar el error, he usado un objeto en vez del tipo HeroData. Si lo cambias, deberías ver un error como este:
custom-type-err.pngEs aquí dónde vemos la verdadera utilidad de los custom types.

Recuerda que vamos a hacer uso del componente progress de bootstrap-vue, que se utiliza bajo el nombre de <b-progress>.
Más info: https://bootstrap-vue.js.org/docs/components/progress

  • El HTML (de TimePlayedHero) es el siguiente:
<template>
  <div class="progress-time-played">
    <div class="d-flex justify-content-between">
      <h5 class="mb-0 font-weight-lighter"> {{heroTime.hero}} </h5>
      <span>
        <b-badge class="w-50p">{{ (heroTime.time * 100).toFixed(2) }}</b-badge>
      </span>
    </div>
    <b-progress :max="1" height="14px" class="mb-3 rounded-0">
      <b-progress-bar :value="heroTime.time" :class="classHeroBg">
        {{ heroTime.hero }}
      </b-progress-bar>
    </b-progress>
  </div>
</template>

Como hemos establecido el valor máximo (:max) de la barra de progreso en 1, no hace falta que transformemos el valor (heroTime.time).
Con la clase CSS resultante de ejecutar classHeroBg, estamos estableciendo un color para cada personaje.

preview-2.pngAhora solo falta el bloque de CSS. En esta ocasión, vamos a ver cómo generar bucles y variables de CSS con Stylus.

// Definimos una variable, parecida a un objeto javascript
// clave:valor
$heroesBg = {
  barbarian: #4e1c16,
  crusader: #795548,
  demon-hunter: #F44336,
  monk: #ff9800,
  necromancer: #00bcd4,
  witch-doctor: #8bc34a,
  wizard: #3f51b5
}
// Creamos las clases CSS necesarias
.progress-time-played

  h5.title
    color #000

  .w-50p
    position relative
    width 50px
    bottom -2px
    border-bottom-left-radius 0
    border-bottom-right-radius 0


  // Bucle "for"
  // Itera sobre la variable $heroesBg
  // "hero" es la clave
  // "bgColor" es el valor
  for hero, bgColor in $heroesBg
    .hero-bg-color-{hero}
      background-color bgColor
  • {hero} se va a sustituir por la clave en $heroesBg, que correspondería a: ‘barbarian’, ‘crusader’, ‘monk’, ‘necromancer’, etc.
  • bgColor es el valor, es decir, los colores que hemos definido: ‘#4e1c16’, ‘#795548’, etc.

Con este bucle (de tres líneas) estamos creando todas la clases CSS necesarias para cada tipo de personaje.

📗 Documentación de bucles con Stylus: https://stylus-lang.com/docs/iteration.html

Es decir, estamos generando este código (repetitivo) a través de una de las funcionalidades que nos proporciona Stylus:

.progress-time-played .hero-bg-color-barbarian {
  background-color: #4e1c16;
}
.progress-time-played .hero-bg-color-crusader {
  background-color: #795548;
}
.progress-time-played .hero-bg-color-demon-hunter {
  background-color: #f44336;
}
.progress-time-played .hero-bg-color-monk {
  background-color: #ff9800;
}
.progress-time-played .hero-bg-color-necromancer {
  background-color: #00bcd4;
}
.progress-time-played .hero-bg-color-witch-doctor {
  background-color: #8bc34a;
}
.progress-time-played .hero-bg-color-wizard {
  background-color: #3f51b5;
}

Este es el componente de TimePlayedHero.vue completo:

<template>
  <div class="progress-time-played">
    <div class="d-flex justify-content-between">
      <h5 class="mb-0 font-weight-lighter"> {{heroTime.hero}} </h5>
      <span>
        <b-badge class="w-50p">{{ (heroTime.time * 100).toFixed(2) }}</b-badge>
      </span>
    </div>
    <b-progress :max="1" height="14px" class="mb-3 rounded-0">
      <b-progress-bar :value="heroTime.time" :class="classHeroBg">
        {{ heroTime.hero }}
      </b-progress-bar>
    </b-progress>
  </div>
</template>

<script>
// Custom validator
import { HeroData } from '@/utils/typeValidation'

export default {
  name: 'TimePlayedHero',
  props: {
    heroTime: {
      type: HeroData,
      required: true
    }
  },
  computed: {
    classHeroBg () {
      return `hero-bg-color-${this.heroTime.classSlug}`
    }
  }
}
</script>

<style lang="stylus" scoped>
  $heroesBg = {
    barbarian: #4e1c16,
    crusader: #795548,
    demon-hunter: #F44336,
    monk: #ff9800,
    necromancer: #00bcd4,
    witch-doctor: #8bc34a,
    wizard: #3f51b5
  }

  .progress-time-played

    h5.title
      color #000

    .w-50p
      position relative
      width 50px
      bottom -2px
      border-bottom-left-radius 0
      border-bottom-right-radius 0

    for hero, bgColor in $heroesBg
      .hero-bg-color-{hero}
        background-color bgColor

</style>

Si todo va bien, se debería ver así:

preview-3.png

¡Llegaste al final… De este capítulo!

No te preocupes, todavía no hemos terminado. Te espero en la siguiente lectura.

Aportes 6

Preguntas 0

Ordenar por:

¿Quieres ver más aportes, preguntas y respuestas de la comunidad? Crea una cuenta o inicia sesión.

Genial, como siempre stylus dandome problemas con la identación al copiar y pegar lo estilos… cosa que no pasaría si usaramos llaves xD

Si seguimos el mismo patrón de carpetas del curso, creo que lo más recomendable hubiese sido crear una carpetas dentro de PlayerStats llamada “TimePlayed” y ahí meter el Index.vue (TimePlayed.vue) y el TimePlayedHero.vue:D

Por último, faltó indicar que en el Index.vue de PlayerStats se tenía que importar el HeroData para poder usarlo:

import { HeroData } from '@/utils/typeValidation'

Adicionalmente, a mi VSCode me marcaba una opción de que la forma de usar HeroData como clase estaba deprecada para ES2015, y me dio está alternativa que se me mucho más elegante usando ya las clases de ES2015:

class HeroData {
  constructor (hero, time, classSlug) {
    this.hero = hero
    this.time = time
    this.classSlug = classSlug
  }
}

Que felicidad como casi 20 minutos buscando un error y era que había escrito TImePlayedHero en un import y debería haber sido TimePlayedHero c:

Los Custom Types vienen a ser como Interfaces!! Cool!!

De momento todo va de maravilla 😃

Me ha gustado la forma de leer, puedo regresar muy rapido por si algo no se me quedo. Excelente curso!

Genial, cuanto se aprende leyendo