Sesion Laboratorio 11 Practica 4 2 - jesusgpa/2023-2024-CSAAI GitHub Wiki

Sesión Laboratorio 11: Práctica 4-2

  • Tiempo: 2h
  • Fecha: Martes, 18 de Abril de 2024
  • Objetivos de la sesión:
    • Implementar la lógica de juego

Contenido

The memory, el juego

Ahora que ya hemos calentado y hemos puesto las bases para nuestro juego, vamos a empezar a desarrollar la lógica.

Elementos y estados

Como siempre empezaremos por encontrar los elementos dentro del documento, y definir cómo vamos a gestionar el estado en el que se encuentra el juego.

const selectors = {
    gridContainer: document.querySelector('.grid-container'),
    tablero: document.querySelector('.tablero'),
    movimientos: document.querySelector('.movimientos'),
    timer: document.querySelector('.timer'),
    comenzar: document.querySelector('button'),
    win: document.querySelector('.win')
}

const state = {
    gameStarted: false,
    flippedCards: 0,
    totalFlips: 0,
    totalTime: 0,
    loop: null
}

Esto nos permitirá reutilizar los selectores tantas veces como necesitemos.

Y en el caso del estado, necesitamos gestionar 5 valores diferentes.

  • gameStarted. Un booleano para controlar si hemos pulsado el botón de empezar o no, o también si hemos hecho pulsado para girar alguna carta.
  • flippedCards. Lo utilizaremos para acumular el número de cartas que giramos. Solo podemos girar dos cartas en cada intento. Si hemos acertado, se quedan giradas, pero si hemos fallado se vuelven a ocultar.
  • totalFlips y totalTime. Nos servirá para contar el número de giros total, y el tiempo que ha pasado desde que iniciamos el juego respectivamente.
  • loop. Lo utilizaremos para llevar la cuenta del tiempo.

El tablero de juego

const generateGame = () => {
    const dimensions = selectors.tablero.getAttribute('grid-dimension')

    //-- Nos aseguramos de que el número de dimensiones es par
    // y si es impar lanzamos un error
    if (dimensions % 2 !== 0) {
        throw new Error("Las dimensiones del tablero deben ser un número par.")
    }

    //-- Creamos un array con los emojis que vamos a utilizar en nuestro juego
    const emojis = ['🥔', '🍒', '🥑', '🌽', '🥕', '🍇', '🍉', '🍌', '🥭', '🍍']
    
    //-- Elegimos un subconjunto de emojis al azar, así cada vez que comienza el juego
    // es diferente.
    // Es decir, si tenemos un array con 10 emojis, vamos a elegir el cuadrado de las
    // dimensiones entre dos, para asegurarnos de que cubrimos todas las cartas
    const picks = pickRandom(emojis, (dimensions * dimensions) / 2) 

    //-- Después descolocamos las posiciones para asegurarnos de que las parejas de cartas
    // están desordenadas.
    const items = shuffle([...picks, ...picks])
    
    //-- Vamos a utilizar una función de mapeo para generar 
    //  todas las cartas en función de las dimensiones
    const cards = `
        <div class="tablero" style="grid-template-columns: repeat(${dimensions}, auto)">
            ${items.map(item => `
                <div class="card">
                    <div class="card-front"></div>
                    <div class="card-back">${item}</div>
                </div>
            `).join('')}
       </div>
    `
    
    //-- Vamos a utilizar un parser para transformar la cadena que hemos generado
    // en código html.
    const parser = new DOMParser().parseFromString(cards, 'text/html')

    //-- Por último, vamos a inyectar el código html que hemos generado dentro de el contenedor
    // para el tablero de juego.
    selectors.tablero.replaceWith(parser.querySelector('.tablero'))
}

Vamos a ver paso a paso qué hace generateGame().

  • Obtenemos las dimensiones del atributo que hemos definido en el div tablero.
  • Lo primero es asegurarnos de que las dimensiones es un número par.
  • Después creamos el array de emojis, para el ejemplo estamos utilizando frutas, pero puedes utilizar los emojis que quieras mientras sean diferentes.
  • Procura el número de emojis sea suficiente para poder elegir un subconjunto diferente cada vez que se inicia el juego.

A continuación elegimos un conjunto de emojis al azar para formar las parejas con pickRandom().

Que es la función que puedes ver a continuación:

const pickRandom = (array, items) => {
    // La sintaxis de tres puntos nos sirve para hacer una copia del array
    const clonedArray = [...array]
    // Random picks va almacenar la selección al azar de emojis
    const randomPicks = [] 

    for (let index = 0; index < items; index++) {
        const randomIndex = Math.floor(Math.random() * clonedArray.length)
        // Utilizamos el índice generado al azar entre los elementos del array clonado
        // para seleccionar un emoji y añadirlo al array de randompicks.
        randomPicks.push(clonedArray[randomIndex])
        // Eliminamos el emoji seleccionado del array clonado para evitar que 
        // vuelva a salir elegido con splice.
        // 0 - Inserta en la posición que le indicamos.
        // 1 - Remplaza el elemento, y como no le damos un nuevo elemento se queda vacío.
        clonedArray.splice(randomIndex, 1)
    }

    return randomPicks
}

Después nos aseguramos de que las parejas están descolocadas utilizando la función shuffle().

Que está basado en un algoritmo clásico de intercambio de posiciones en un array.

const shuffle = array => {
    const clonedArray = [...array]

    // Intercambiamos las posiciones de los emojis al azar para desorganizar el array
    // así nos aseguramos de que las parejas de emojis no están consecutivas.
    // Para conseguirlo utilizamos un algoritmo clásico de intercambio y nos apoyamos
    // en una variable auxiliar.
    for (let index = clonedArray.length - 1; index > 0; index--) {
        const randomIndex = Math.floor(Math.random() * (index + 1))
        const original = clonedArray[index]

        clonedArray[index] = clonedArray[randomIndex]
        clonedArray[randomIndex] = original
    }

    return clonedArray
}

Por último, ahora que hemos elegido el subconjunto de emojis que queremos utilizar, y hemos desordenado las parejas.

Vamos a generar el código para cada carta y a inyectarlo dentro del tablero.

Para eso utilizamos una función de mapeo que nos producirá una cadena con el código para el tablero.

Antes de inyectar el código lo tenemos que traducir a código html, así nos aseguramos de añadir nuevo código al documento sin problemas, para eso utilizaremos DomParser.

Una vez listo, añadimos las cartas al documento.

Lo que veremos serán todas las cartas una a continuación de otra y volteadas.

Pero si miramos el código, veremos que son cartas diferentes y desorganizadas.

Ya tenemos listo el tablero de juego ¿No?

Extra CSS

En la sesión anterior ya vimos lo básico para crear el efecto de girar las cartas.

Así que vamos a aplicar el mismo concepto al tablero, y de paso organizar un poco las cartas para trabajar mejor.

Algunas notas importantes antes de que pegues los estilos en tu fichero css.

  • He comentado los gradientes del fondo y el tablero para que no distraigan, pero los he dejado por si los quieres utilizar.
  • He asignado el mismo tipo de fuente a todo el documento, yo he elegido Montserrat, pero tú puedes elegir cualquier otra.
  • Asegurate de que tus clases coinciden en tus documentos html, css y javascript.
@font-face {
    font-family: Montserrat;
    src: url(./Montserrat-Regular.ttf);
}

html {
    width: 100%;
    height: 100%;
    /* background: linear-gradient(325deg,  #6f00fc 0%,#fc7900 50%,#fcc700 100%); */
    font-family: Montserrat;
}

.game {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
}

.controls {
    display: flex;
    gap: 20px;
    margin-bottom: 20px;
}

button {
    background: #282A3A;
    color: #FFF;
    border-radius: 5px;
    padding: 10px 20px;
    border: 0;
    cursor: pointer;
    font-family: Montserrat;
    font-size: 18pt;
}

.disabled {
    color: #757575;
}

.display {
    color: #282A3A;
    font-size: 14pt;
}

.grid-container {
    position: relative;
}

.tablero,
.win {
    border-radius: 5px;
    box-shadow: 0 25px 50px rgb(33 33 33 / 25%);
    /* background: linear-gradient(135deg,  #6f00fc 0%,#fc7900 50%,#fcc700 100%); */
    transition: transform .6s cubic-bezier(0.4, 0.0, 0.2, 1);
    backface-visibility: hidden;
}

.tablero {
    padding: 20px;
    display: grid;
    grid-template-columns: repeat(4, auto);
    grid-gap: 20px;
}

.grid-container.flipped .tablero {
    transform: rotateY(180deg) rotateZ(50deg);
}

.grid-container.flipped .win {
    transform: rotateY(0) rotateZ(0);
}

.card {
    position: relative;
    width: 100px;
    height: 100px;
    cursor: pointer;
}

.card-front,
.card-back {
    position: absolute;
    border-radius: 5px;
    width: 100%;
    height: 100%;
    background: #282A3A;
    transition: transform .6s cubic-bezier(0.4, 0.0, 0.2, 1);
    backface-visibility: hidden;
}

.card-back {
    font-size: 28pt;
    text-align: center;
    line-height: 100px;
    background: #FDF8E6;
    transform: rotateY(180deg) rotateZ(50deg);
    user-select: none;
}

.card.flipped .card-front {
    transform: rotateY(180deg) rotateZ(50deg);
}

.card.flipped .card-back {
    transform: rotateY(0) rotateZ(0);
}

.win {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    text-align: center;
    background: #FDF8E6;
    transform: rotateY(180deg) rotateZ(50deg);
}

.win-text {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    font-size: 21pt;
    color: #282A3A;
}

.highlight {
    color: #6f00fc;
}

Ahora tu tablero de juego se debería parecer más a esto:

Aunque cuando hacemos clic en las cartas no pasa nada, eso es porque nos queda cosas por hacer, como añadir eventos.

Eventos

Ha llegado el momento de añadir la funcionalidad para empezar a jugar.

Y para eso vamos a necesitar enlazar algunas funciones a determinados eventos.

Como por ejemplo, hacer clic en un carta.

const attachEventListeners = () => {
    document.addEventListener('click', event => {
        // Del evento disparado vamos a obtener alguna información útil
        // Como el elemento que ha disparado el evento y el contenedor que lo contiene
        const eventTarget = event.target
        const eventParent = eventTarget.parentElement

        // Cuando se trata de una carta que no está girada, le damos la vuelta para mostrarla
        if (eventTarget.className.includes('card') && !eventParent.className.includes('flipped')) {
            flipCard(eventParent)
        // Pero si lo que ha pasado es un clic en el botón de comenzar lo que hacemos es
        // empezar el juego
        } else if (eventTarget.nodeName === 'BUTTON' && !eventTarget.className.includes('disabled')) {
            startGame()
        }
    })
}

// Generamos el juego
generateGame()

// Asignamos las funciones de callback para determinados eventos
attachEventListeners()

Cuando generamos código de manera dinámica para inyectarlo en el documento, nos tenemos que asegurar de que enlazamos la funcionalidad a los eventos una vez hemos añadido los elementos y no antes.

Pero una vez que el juego está generado con generateGame().

Es el momento de enlazar las funciones a los eventos, y para eso utilizamos una única función attachEventListeners().

Dentro de attachEventListeners() llamaremos a diferentes funciones en función del origen del evento.

  • flipCard. Para voltear la carta si está oculta.
  • startGame. Para comenzar con el juego si el botón no está deshabilitado.

Comenzamos el juego con startGame()

Necesitamos esta función para poner el juego en el estado de inicio y arrancar el cronómetro.

Una vez iniciado el bucle de juego con setInterval(), esto nos permite actualizar el display dentro del bucle a cada segundo.

const startGame = () => {
    // Iniciamos el estado de juego
    state.gameStarted = true
    // Desactivamos el botón de comenzar
    selectors.comenzar.classList.add('disabled')

    // Comenzamos el bucle de juego
    // Cada segundo vamos actualizando el display de tiempo transcurrido
    // y movimientos
    state.loop = setInterval(() => {
        state.totalTime++

        selectors.movimientos.innerText = `${state.totalFlips} movimientos`
        selectors.timer.innerText = `tiempo: ${state.totalTime} sec`
    }, 1000)
}

Girando cartas con flipCard()

Cuando giramos una carta tenemos que hacer unas cuantas comprobaciones para conseguir la lógica del juego.

  • Llevar un contador de pareja de cartas giradas.
  • Llevar el contador total de giros o movimientos
  • Si el juego no está iniciado, iniciarlo, esto pondrá en marcha el contador
  • Mientras que no tengamos una pareja de cartas girada podemos seguir girando cartas
  • Cuando tenemos una pareja de cartas girada, comprobamos si es un match
  • Si es un match, las dejaremos giradas
  • Pero si no hemos acertado, esperamos un segundo y volvemos a ocultar las cartas con flipBackCards()

Así es como quedaría nuestra función flipCard:

const flipCard = card => {
    // Sumamos uno al contador de cartas giradas
    state.flippedCards++
    // Sumamos uno al contador general de movimientos
    state.totalFlips++

    // Si el juego no estaba iniciado, lo iniciamos
    if (!state.gameStarted) {
        startGame()
    }

    // Si no tenemos la pareja de cartas girada
    // Giramos la carta añadiendo la clase correspondiente
    if (state.flippedCards <= 2) {
        card.classList.add('flipped')
    }

    // Si ya tenemos una pareja de cartas girada tenemos que comprobar
    if (state.flippedCards === 2) {
        // Seleccionamos las cartas que están giradas
        // y descartamos las que están emparejadas
        const flippedCards = document.querySelectorAll('.flipped:not(.matched)')

        // Si las cartas coinciden las marcamos como pareja 
        // añadiendo la clase correspondiente
        if (flippedCards[0].innerText === flippedCards[1].innerText) {
            flippedCards[0].classList.add('matched')
            flippedCards[1].classList.add('matched')
        }

        // Arrancamos un temporizador que comprobará si tiene
        // que volver a girar las cartas porque no hemos acertado
        // o las deja giradas porque ha sido un match
        // y para eso llamamos a la función flipBackCards()
        setTimeout(() => {
            flipBackCards()
        }, 1000)
    }
}

Si no hemos acertado, giramos otra vez las cartas para que se queden ocultas.

Y ponemos el contador de pareja a cero.

const flipBackCards = () => {
    // Seleccionamos las cartas que no han sido emparejadas
    // y quitamos la clase de giro
    document.querySelectorAll('.card:not(.matched)').forEach(card => {
        card.classList.remove('flipped')
    })
    // Ponemos el contado de parejas de cartas a cero
    state.flippedCards = 0
}

Te habrás dado cuenta ya de que cuando giramos todas las parejas no pasa nada, eso es porque no estamos detectando el estado final en el juego.

Vamos a ver como solucionarlo.

¡He vuelto a ganar!

A todos nos gusta ganar, y cuando pasa queremos saberlo.

Por eso necesitamos añadir una condición extra para detectar el estado de final de juego y mostrar el mensaje correspondiente al jugador.

    // Antes de terminar, comprobamos si quedan cartas por girar
    // porque cuando no quedan cartas por girar hemos ganado
    // y se lo tenemos que mostrar al jugador
    if (!document.querySelectorAll('.card:not(.flipped)').length) {
        setTimeout(() => {
            // Le damos la vuelta al tablero
            selectors.gridContainer.classList.add('flipped')
            // Le mostramos las estadísticas del juego
            selectors.win.innerHTML = `
                <span class="win-text">
                    ¡Has ganado!<br />
                    con <span class="highlight">${state.totalFlips}</span> movimientos<br />
                    en un tiempo de <span class="highlight">${state.totalTime}</span> segundos
                </span>
            `
            // Paramos el loop porque el juego ha terminado
            clearInterval(state.loop)
        }, 1000)
    }

Para eso debemos añadir la condición anterior a flipCard() que quedará así.

const flipCard = card => {
    // Sumamos uno al contador de cartas giradas
    state.flippedCards++
    // Sumamos uno al contador general de movimientos
    state.totalFlips++

    // Si el juego no estaba iniciado, lo iniciamos
    if (!state.gameStarted) {
        startGame()
    }

    // Si no tenemos la pareja de cartas girada
    // Giramos la carta añadiendo la clase correspondiente
    if (state.flippedCards <= 2) {
        card.classList.add('flipped')
    }

    // Si ya tenemos una pareja de cartas girada tenemos que comprobar
    if (state.flippedCards === 2) {
        // Seleccionamos las cartas que están giradas
        // y descartamos las que están emparejadas
        const flippedCards = document.querySelectorAll('.flipped:not(.matched)')

        // Si las cartas coinciden las marcamos como pareja 
        // añadiendo la clase correspondiente
        if (flippedCards[0].innerText === flippedCards[1].innerText) {
            flippedCards[0].classList.add('matched')
            flippedCards[1].classList.add('matched')
        }

        // Arrancamos un temporizador que comprobará si tiene
        // que volver a girar las cartas porque no hemos acertado
        // o las deja giradas porque ha sido un match
        // y para eso llamamos a la función flipBackCards()
        setTimeout(() => {
            flipBackCards()
        }, 1000)
    }

    // Antes de terminar, comprobamos si quedan cartas por girar
    // porque cuando no quedan cartas por girar hemos ganado
    // y se lo tenemos que mostrar al jugador
    if (!document.querySelectorAll('.card:not(.flipped)').length) {
        setTimeout(() => {
            // Le damos la vuelta al tablero
            selectors.gridContainer.classList.add('flipped')
            // Le mostramos las estadísticas del juego
            selectors.win.innerHTML = `
                <span class="win-text">
                    ¡Has ganado!<br />
                    con <span class="highlight">${state.totalFlips}</span> movimientos<br />
                    en un tiempo de <span class="highlight">${state.totalTime}</span> segundos
                </span>
            `
            // Paramos el loop porque el juego ha terminado
            clearInterval(state.loop)
        }, 1000)
    }
}

Ahora cuando terminamos el tablero se gira y nos muestra el resultado.

Así es como se verá.

Y con esto hemos llegado casi al final.

Ya tienes todo lo necesario para montar tu Memory Game y personalizarlo.

Recuerda que para la práctica es necesario incluir algún detalle más al funcionamiento.

Pero estoy seguro de que no te llevará mucho tiempo completarlo, la clave puede estar en el trabajo que has hecho en prácticas anteriores y los apuntes.

Extra: ¿Se puede utilizar imágenes en lugar de emojis?

Sí, pero hay que tocar un poco el javascript.

En primer lugar utilizaremos un array de imágenes en lugar de el de emojis.

const img = ['logo-urjc.png', 'logo-urjc.png', 'logo-urjc.png', 'logo-urjc.png', 'logo-urjc.png', 'logo-urjc.png']

Elegimos un subconjunto del array de imágenes pasando a pickRandom el array de imágenes.

const picks = pickRandom(img, (dimensions * dimensions) / 2) 

Por último, a la hora de generar las cartas, tenemos que asegurarnos de utilizar la etiqueta html de imagen.

<div class="card-back"><img src="${item}" alt="Logo URJC"></div>

Y el resto debería funcionar igual.

Este es código completo.

    //-- Creamos un array con los emojis que vamos a utilizar en nuestro juego
    const emojis = ['🥔', '🍒', '🥑', '🌽', '🥕', '🍇', '🍉', '🍌', '🥭', '🍍']
    const img = ['logo-urjc.png', 'logo-urjc.png', 'logo-urjc.png', 'logo-urjc.png', 'logo-urjc.png', 'logo-urjc.png']

    //-- Elegimos un subconjunto de emojis al azar, así cada vez que comienza el juego
    // es diferente.
    // Es decir, si tenemos un array con 10 emojis, vamos a elegir el cuadrado de las
    // dimensiones entre dos, para asegurarnos de que cubrimos todas las cartas
    const picks = pickRandom(img, (dimensions * dimensions) / 2) 

    //-- Después descolocamos las posiciones para asegurarnos de que las parejas de cartas
    // están desordenadas.
    const items = shuffle([...picks, ...picks])
    
    //-- Vamos a utilizar una función de mapeo para generar 
    //  todas las cartas en función de las dimensiones
    const cards = `
        <div class="tablero" style="grid-template-columns: repeat(${dimensions}, auto)" grid-dimension="4">
            ${items.map(item => `
                <div class="card" item-back="${item}">
                    <div class="card-front"></div>
                    <div class="card-back"><img src="${item}" alt="Logo URJC"></div>
                </div>
            `).join('')}
       </div>
    `

¡A practicar!

  • Implementa paso a paso la lógica del juego.
  • Piensa en como añadir la funcionalidad que falta para completar la práctica.

Resumen de tareas recomendadas

  • Haz los ejercicios propuestos en esta sesión.

No olvides subir todas las pruebas que hagas al repo, en la carpeta P4.

Aunque sean pruebas temporales que luego no formen parte del juego, súbelas.

Son una prueba objetiva de tu trabajo.

Conclusiones

Con lo que se ha indicado en esta sesión deberías ser capaz de implementar la lógica de tu juego.

Autor

Jesús Parrado Alameda (jesusgpa)

Creditos

Licencia

Enlaces

⚠️ **GitHub.com Fallback** ⚠️