« Cómo se demuestra que… « || Inicio || » Elm: Creación rápida … »

Elm: Proyecto Juego

Última modificación: 25 de Noviembre de 2016, y ha tenido 219 vistas

Etiquetas utilizadas: || || ||

En esta entrada vamos a analizar un proyecto completo que hace uso de las estructuras que hemos visto en las entradas anteriores: elementos visuales y señales

El proyecto que se ha seleccionado es el juego Snake, lanzado a mediados de los 70 y que muestra muchas características que facilitan su adecuación a un proyecto completo pero introductorio: muestra una interacción limitada con el usuario, y una representación visual sencilla.

Nos basaremos en la implementación realizada por Yan Cui, aunque introduciremos algunos ligeros cambios para adaptarlo a la versión 0.16 de Elm y comentaremos detalladamente el código para que sirva de explicación completa. Puedes jugar al resultado aquí o haciendo cllic en la imagen de la izquierda.

El proyecto hará uso de muchas de las señales que vimos en la entrada anterior: Teclado, Ventanas, Tiempo, y por supuesto pondremos en práctica de qué forma manipularlas por medio de Signal.map, Signal.merge, y Signal.foldp.

Las librerías que necesitamos son:

import Graphics.Element exposing (..)
import Graphics.Collage exposing (..)
import Color exposing (black, yellow, white)
import Keyboard
import List
import Random
import Signal
import Text
import Time
import Window

 Todas ellas las conocemos y las hemos usado en ejemplos anteriores, así que estamos en disposición de pasar a entender la estructura general del proyecto. 

El proyecto (como otros muchos que ya hemos visto, pero a menor escala), sigue la arquitectura Modelo-Vista-Controlador. Esta arquitectura es un patrón muy simple de componentes que se comunican, separando de facto las distintas funcionalidades, lo que facilita una gran modularidad, reusar el código de forma muy eficiente, y hacer tests de verificación, permitiendo escalar el procedimiento hasta crear aplicaciones web complejas sin perder estas buenas características.

Esencialmente, el modelo–vista–controlador es un patrón de arquitectura de software que separa los datos y la lógica de una aplicación de la interfaz de usuario y del módulo encargado de gestionar los eventos y las comunicaciones. Para ello MVC propone la construcción de tres componentes distintos que son el modelo, la vista y el controlador, es decir, por un lado define componentes para la representación de la información, y por otro lado para la interacción del usuario. Cada una de estas partes se encargará de lo siguiente:

  1. Modelo: define las estructuras de datos necesarias para mantener la información de la aplicación que estamos desarrollando.
  2. Vista: define los procedimientos necesarios para poder crear la interfaz visual y mostrar los datos adecuadamente formateados al usuario.
  3. Controlador: es el módulo encargado de toda la lógica del programa, recibe la interacción del usuario, actualiza el modelo para adaptarla a estos cambios, y se la transmite a la vista para que la represente.

Debemos recordar que en un lenguaje funcional como Elm no disponemos de variables mutables para poder almacenar los datos del programa en ejecución, sino que todo se produce a partir de llamadas de funciones en las que la información se modifica por medio de los argumentos en las sucesivas llamadas.

Analizaremos cada una de estas partes en el problema que estamos resolviendo:

El Modelo

En el modelo comenzamos definiendo algunas constantes que usaremos a lo largo del programa: el tamaño del segmento de la serpiente (que está formada por cuadrados), el radio de la cereza (que es un círculo) y la semilla inicial con la que generaremos los números pseudoaleatorios que necesita el juego.

-- Constantes
segmentDim   = 15.0 -- Tamaño del cuadro
cherryRadius = 7.5 -- Radio de la cereza
initSeed     = Random.initialSeed 42 -- Semilla inicial

A continuación necesitamos definir los tipos necesarios para poder manejar la interacción con el usuario, por medio del teclado. Para ello, hemos de mezclar las señales de las teclas admitidas (flechas y barra espaciadora).

-- Tipo de datos para la interacción del usuario
--   * Teclas de dirección
--   * Barra espaciadora (inicio del juego)
type UserInput   = Arrow {x:Int, y:Int} | Space
-- Pulación por defecto: no pulsación
defaultUserInput = Arrow {x=0, y=0}
    
-- Transforma la señal del teclado (flechas) en el subtipo Arrow
arrows : Signal UserInput
arrows = Signal.map Arrow Keyboard.arrows
    
-- Transforma la señal del teclado (espacio) en el subtipo Space
spaces : Signal UserInput
spaces = Signal.map (\p -> if p then Space else defaultUserInput) Keyboard.space
    
-- Mezcla las dos señales anteriores
userInput : Signal UserInput
userInput = Signal.merge arrows spaces
    
-- Posibles entradas al programa:
--   * Cambios en las dimensiones de la ventana
--   * Teclas pulsadas por el usuario
type alias Input =
    { windowDim : (Int, Int)
    , userInput : UserInput
    }

Los elementos del juego: serpiente y cerezas, se definen con sus propios tipos. La serpiente viene definida por la lista de segmentos que la componen y la dirección de movimiento que lleva su cabeza, mientras que la cereza solo precisa de la posición que ocupa. Observa que la cereza se define como Maybe debido a que puede no estar disponible en algunos momentos de la partida.

-- Tipo para reconocer las direcciones de movimiento
type Direction = Up | Down | Left | Right
    
-- Tipo que almacena la serpiente:
--   * Lista de posiciones del cuerpo
--   * Dirección de movimiento
type alias Snake = {segments:List(Float, Float), direction:Direction}
-- Valor por defecto de la serpiente
defaultSnake = 
    { segments  = List.reverse <| List.map (\n -> (n*segmentDim, 0)) [0.0..8.0]
    , direction = Right
    }
    
-- Tipo para las cerezas:
--    Es un Maybe porque puede ocurrir que no esté presente
type alias Cherry = Maybe(Float, Float)

Por último, definimos el estado del juego, que contiene la información necesaria para definir una configuración instantánea del mismo: la serpiente, la cereza, y la semilla para producir nuevos números pseudoaleatorios.

-- Tipo para el Estado del Juego:
--   * No comenzado
--   * Comenzado y almacena: la serpiente, la cereza, y la semilla de aleatoriedad
type GameState = NotStarted | Started Snake Cherry Random.Seed
    
-- Estado por defecto del juego
defaultGame : GameState
defaultGame = NotStarted

 Vista

El módulo de Vista contiene, en este caso, una única función de representación, display, que recibe como datos de entrada las dimensiones del mundo, y el estado del juego. La representación se hace por medio de un fondo negro, sobre el que se representa la serpiente como una sucesión de cuadrados amarillos (uno por cada segmento) y las cerezas como círculos blancos (en caso de que estén presentes):

-- Función de representación del mundo. Recibe:
--   * Dimensiones del mundo
--   * Estado del Juego
display : (Int,Int) -> GameState -> Element
display (w,h) gameState = 
    let 
      -- El fondo es un rectángulo negro
      bg = rect (toFloat w) (toFloat h) |> filled black
      -- El resto del contenido...
      content =
        -- En función del Estado de Juego
        case gameState of
          -- Si no ha comenzado, se muestra el mensaje
          NotStarted -> [txt "press SPACE to start"]
          -- Si ha comenzado, necesitamos la serpiente y la cereza para mostrarlos
          Started snake cherry _ ->
            -- segments son las representaciones de los segmentos de la serpiente
            let segments =
              List.map (\pos -> rect segmentDim segmentDim |> filled yellow |> move pos)
                       snake.segments
            in
              -- Representamos la cereza			
              case cherry of
                -- Si no hay, repetimos los segmentos
                Nothing -> segments
                -- Si hay, adjuntamos un círculo blanco al contenido
                Just pos -> (circle cherryRadius
                            |> filled white
                            |> move pos) :: segments
    -- Montamos el collage con el fondo y el contenido
    in collage w h (bg::content)

Controlador

Por último, el módulo de control dispone de una función principal, stepGame, que calcula el cambio que se produce en el estado en un paso del juego. La explicación de cómo funciona la puedes encontrar en los comentarios que hay insertados. Ten en cuenta únicamente que por medio de una estructura let se van definiendo las distintas partes que necesitamos para que la nueva construcción se haga de forma más sencilla:

-- Función que calcula un paso de juego.
--   Recibe la entrada y el Estado actual del Juego, 
--   Devuelve el nuevo Estado del Juego
stepGame : Input -> GameState -> GameState
stepGame {windowDim, userInput} gameState =
  case gameState of
    -- Si el juego no ha empezado
    NotStarted ->
    -- Y se ha pulsado espacio, el juego comienza con los valores por defecto
    if userInput == Space then Started defaultSnake Nothing initSeed
    -- Si no, sigue como estaba
    else gameState
    -- Si el juego está empezado
    Started {segments, direction} cherry seed ->  
    let 
      -- Calcula la nueva dirección a partir de la dirección actual y la pulsación
      newDir = getNewDirection userInput direction
      (head,tail) = ((Maybe.withDeafault (0,0) (List.head segments)),
(Maybe.withDefault [] (List.tail segments))
) -- Calcula la posición de la nueva cabeza newHead = getNewSegment head newDir --- Calcula 3 nuevos valores aleatorios y una nueva semilla (randList, newSeed) = genRandoms 3 seed
(spawn, randX, randY) =
case randList of
a :: b :: c :: [] -> (a,b,c)
_ -> (0,0,0) -- Decide si la cabeza de la serpiente está sobre la cereza ateCherry = case cherry of Nothing -> False Just pos -> isOverlap newHead pos -- En caso de que no haya cereza y el aleatorio spawn sea pequeño, se crea una -- cereza en la posición aleatoria (randX, randY). Si la cereza existía y no -- se ha comido, se devuelve la misma newCherry = if ateCherry then Nothing else if cherry == Nothing && spawn <= 0.1 then spawnCherry windowDim randX randY else cherry -- Se calcula la nueva cola de la serpiente newTail = if ateCherry then segments else List.take (List.length segments-1) segments -- La nueva serpiente se construye a partir lo anterior, y la nueva dirección newSnake = {segments=newHead::newTail, direction=newDir} -- Se decide si el juego debe terminar gameOver = isGameOver windowDim newHead newTail in -- Si el juego ha terminado, se devuelve el estado de No empezado if gameOver then NotStarted -- En caso contrario, se devuelve el nuevo estado else Started newSnake newCherry newSeed

Además, en este módulo también podemos encontrar las funciones para controlar la manipulación de señales que hacen uso del proceso de actualización anterior y provocan la representación de los estados del juego. Usamos Signal.foldp para calcular el estado de juego instantáneo, como un plegado de estados que comienzan por el estado inicial y que usan stepGame para aumularse, y Signal.map para proyectar sobre display las dimensiones de la ventana y el estado del juego actualizado: 

-- Señal de incremento de tiempo para la actualización del sistema
--  manteniendo 10 actualizaciones por segundo
tick : Signal Float
tick = Time.fps 10
    
-- Señal de entrada de datos con una actualización de 10 fps
input : Signal Input
input = Signal.sampleOn tick (Signal.map2 Input Window.dimensions userInput)
    
-- Señal de Estado del juego. Se calcula como un plegado de estados
-- comenzando con el estado por defecto, y usando la actualización
-- de un paso y las entradas del usuario
gameState : Signal GameState
gameState = Signal.foldp stepGame defaultGame input
    
-- Función principal que proyecta la señal de estado del juego usando
-- la representación.
main : Signal Element
main = Signal.map2 display Window.dimensions gameState

 Auxiliares

Además, el programa precisa de algunas funciones auxiliares que facilitan su construcción y que se detallan a continuación:

-- Muestra un texto en medio de la pantalla
txt msg = 
    msg 
    |> Text.fromString 
    |> Text.color white 
    |> Text.monospace 
    |> leftAligned 
    |> toForm

-- A partir de la tecla del usuario y la dirección actual
-- devuelve una nueva dirección
getNewDirection : UserInput -> Direction -> Direction
getNewDirection userInput currentDir =
  let (x, y) = 
    case userInput of
        Arrow {x, y} -> (x, y)
        _            -> (0, 0)
  in 
     if x == 0  && y == 0 then currentDir
     else if x == -1 && currentDir == Right then currentDir
     else if x == 1  && currentDir == Left  then currentDir
     else if y == -1 && currentDir == Up    then currentDir
     else if y == 1  && currentDir == Down  then currentDir
     else if x == -1 then Left
     else if x == 1  then Right
     else if y == -1 then Down
     else Up
     
-- Calcula el nuevo segmento en función de la dirección actual
getNewSegment (x, y) direction =
  case direction of
    Up    -> (x, y+segmentDim)
    Down  -> (x, y-segmentDim)
    Left  -> (x-segmentDim, y)
    Right -> (x+segmentDim, y)

-- Decide si la cabeza pisa la cereza
isOverlap (snakeX, snakeY) (cherryX, cherryY) =
  let (xd, yd) = (cherryX - snakeX, cherryY - snakeY)
      distance = sqrt(xd * xd + yd * yd)
  in distance <= (cherryRadius * 2)

-- Establece las condiciones para que el juego se acabe:
--   * La cabeza pisa un segmento de la propia serpiente
--   * Se sale del mundo
isGameOver (w,h) newHead newTail =
    List.any (\t -> t == newHead) newTail
    || fst newHead > (toFloat w / 2)
    || snd newHead > (toFloat h / 2)
    || fst newHead < (toFloat -w / 2)
    || snd newHead < (toFloat -h / 2)

-- Devuelve n valores aleatorios a partir de una semilla
genRandoms n seed = Random.generate (Random.list n (Random.float 0 1)) seed

-- Convierte las coordenadas aleatorias (randW, randH) de (0,1)x(0,1) en 
-- coordenadas del mundo
spawnCherry (w, h) randW randH =
    let x = randW * toFloat w - toFloat w/2
        y = randH * toFloat h - toFloat h/2
    in Just (x, y)

Puedes encontrar el código completo del programa aquí (ya actualizado a la versión 0.6). Observa que todos los componentes usados son los ya conocidos y cómo la estructura MVC es especialmente adecuada para Elm.

Cosas que puedes intentar

  1. Cambia el aspecto visual del juego. Observa cómo para ello solo necesitas tener acceso al módulo Vista, e incluso es sencillo mantener diversas versones del juego en función del aspecto visual que quieras mostrar. Posibles cambios: hacer que el cuerpo de la serpiente se componga de círculos (destacando la cabeza) y con otros colores, hacer que la cereza y fondo de juego sean distintos, etc...
  2. ¿Cómo podrías hacer que la serpiente dejara un rastro (que se perdiera con el tiempo) por donde fuera pasando?
  3. Añade la posibilidad de pausar el juego. ¿Qué modulos crees a priori que debes tocar?
  4. ¿Qué necesitarías para construir un juego similar para 2 jugadores y que usase dos serpientes (una controlada por cada jugador)?

Estudios de casos

  • En este código puedes encontrar comentado un ejemplo completo de uso de señales para un pequeño efecto visual: se representa de color azul el último rastro del ratón a medida que se mueve por la pantalla. El código está completamente comentado para poder seguir cómo se ha hecho.
  • En este otro código podéis encontrar un pequeño programa de dibujo que por medio de las teclas (flechas o wasd) maneja una cabeza que va dejando rastro en un papel. Al igual que en el caso anterior, el código está completamente comentado. (Este ejemplo es una ligera modificación de este otro).
  • Otro ejemplo interesante en el que se pueden poner en práctica los conocimientos adquiridos hasta ahora lo muestra el código de Mario que ofrecen como ejemplo en el sitio de Elm.

« Cómo se demuestra que… « || Inicio || » Elm: Creación rápida … »