Haciendo una app educativa para niñes con Go + Ebiten

Era una semana de hackaton interna y yo no tenia muy claro qué quería hacer. Mi único objetivo era aprender Golang. En eso veo que un compañero de trabajo tenia ganas de armar una aplicación mobile que replicara la actividad Letras de Lija de Montessori. Me pareció una idea genial – ¿Qué mejor forma de aprender un lenguaje que haciendo un jueguito? –, así que me puse a investigar qué herramientas hay en Go para hacer algo así.

Introduciendo a Ebiten

ebiten.org

Existen varios frameworks para hacer videojuegos en Golang. Pero el que más me llamo la atención fue Ebiten (camarón en japonés). Esta bien documentado, tiene una API sencilla, exporta a WebAssembly, Linux, Windows, macOS, Android e iOS y cuenta con varios ejemplos de mecánicas comunes en juegos.

Dicho esto, Ebiten trae muy pocas funcionalidades en si mismo:

  • Manipulación de Imágenes
  • Input (teclado, mouse, gamepad, touchscreen)
  • Sonido
  • Tipografía
  • Vectores (experimental)

Cosas como manejo de animaciones, tilesets, escenas o física quedan a libre implementación de cada desarrollador/a. Por lo cual es muy entretenido para aquellas/os que les gusta hacer juegos desde cero.

Todo es una Imagen

Ni nodos, ni RigidBody ni Sprite. Los únicos elementos gráficos que maneja Ebiten son Imágenes. Hasta la pantalla es una imagen más.

El Game Loop

Para usar Ebiten tenemos que definir una estructura (struct) para nuestro juego – que puede tener cualquier nombre pero por convención se la llama Game – y cuatro métodos asociados a instancias de Game:

  • Update – ejecuta en cada tick la lógica del juego
  • Draw – define en cada frame qué se dibuja en pantalla
  • Layout – Permite detectar cambios en el tamaño de la ventana
  • init – sirve para cargar recursos y configuraciones
package main

import (
	"log"

	"github.com/hajimehoshi/ebiten"
	"github.com/hajimehoshi/ebiten/ebitenutil"
)

type Game struct{}

func (g *Game) Update(screen *ebiten.Image) error {
	return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
	ebitenutil.DebugPrint(screen, "Hello, World!")
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
	return 320, 240
}

func init() {
        // load assets
}

func main() {
	ebiten.SetWindowSize(640, 480)
	ebiten.SetWindowTitle("Hello, World!")
	if err := ebiten.RunGame(&Game{}); err != nil {
		log.Fatal(err)
	}
}

El Juego

La idea de las Letras de Lija es que les niñes aprendan el sonido que hacen las letras (no como se llaman) al mismo tiempo que aprenden a trazarlas. Para esto se usan unas letras hechas con papel de lija (de ahí el nombre) y mientras le niñe recorre el contorno de la letra va haciendo el sonido («rrr» en caso de la R) en voz alta.

¿Como pasamos esta interacción a una app? Lo ideal para reemplazar la textura de la lija seria hacer que el dispositivo vibre pero como para la hackaton íbamos a juntar varias apps en un sitio web, decidí hacer que suene un sonido rasposo mientras la persona esta trazando la letra.

El desafío

¿Como hacemos para saber si le niñe trazó la letra correctamente? Recuerden que ebiten no tiene motor de física por lo cual detectar colisiones no es algo que podamos hacer trivialmente.

Bueno, como todo es una imagen, lo que podemos hacer es obtener la posición de todos los pixeles de la letra y elegir 100 al azar que usaremos para saber si la letra ya fue trazada. Luego mientras vamos dibujando calculamos el área del cursor y chequeamos si alguno de esos 100 puntos esta debajo y lo eliminamos de nuestra lista.

Este es el código:

func getPixelsInLetter(letter *ebiten.Image) []*PixelPos {
	// At(Bounds().Min.X, Bounds().Min.Y) returns the upper-left pixel of the grid.
	// At(Bounds().Max.X-1, Bounds().Max.Y-1) returns the lower-right one.
	pixels := make([]*PixelPos, 0)
	b := letter.Bounds()
	for y := b.Min.Y; y < b.Max.Y; y++ {
		for x := b.Min.X; x < b.Max.X; x++ {
			if letter.At(x, y).(color.RGBA).A <= 0 {
				pixels = append(pixels, &PixelPos{x: x, y: y})
			}
		}
	}
	return getRandomPixelsInLetter(pixels)
}

func getRandomPixelsInLetter(pixels []*PixelPos) []*PixelPos {
	randomPixels := make([]*PixelPos, 0)
	for i := 0; i < 100; i++ {
		randomPixels = append(randomPixels, pixels[rand.Intn(len(pixels))])
	}
	return randomPixels
}

Para simplificar la selección de pixeles y el renderizado de nuestra app, usamos varias imágenes superpuestas en este orden:

  1. Fondo de papel de lija blanco
  2. Imagen de la letra calada con fondo de color
  3. figura del trazo del cursor (100% solida)
  4. Imagen que representa la posición actual del cursor (transparente al 50%)

Y entre ellas varias imágenes transparentes donde dibujaremos los trazos.

Para verificar que todo funciona correctamente, agregamos una pantalla de debug que muestra los pixeles característicos en rojo, y los pinta de azul cuando les pasamos por encima. Ademas vemos un cuadro de texto con la posición del puntero y cuantos pixels nos falta pintar.

El ultimo obstáculo que hubo que superar es la transición de una letra a la otra. Ebiten no tiene motor de animaciones, pero si nos dice cuanto tiempo pasó desde el ultimo llamado a la función Update y Draw. Así que usando eso y un par de contadores podemos calcular la animación cuadro por cuadro.

Para hacerlo lo mas sencillo posible, la animación reproduce durante 2 segundos el sonido de la letra dibujada y luego hace una transición en la que la pantalla gradualmente se llena del color de fondo de la letra actual. Finalmente se carga una letra aleatoriamente y se reproduce su sonido.

Este es el código

func (g *Game) transitionLetter() {
	logErrorAndExit(animationOverlay.Dispose())
	var err error
	animationOverlay, err = ebiten.NewImage(screenWidth, screenHeight, ebiten.FilterDefault)
	logErrorAndExit(err)
	// animationOverlay.Clear()
	if !transitioning {
		transitioning = true
		animationTimer = animationDuration
		letterPlayer.Rewind()
		// play letter pronunciation
		letterPlayer.Play()
	} else if animationTimer > animationDuration/2 {
		animationTimer -= 60
	} else if animationTimer > 0 {
		fadeOut()
		animationTimer -= 60
	} else {
		transitioning = false
		g.loadRandomLetter()
	}
}

func (g *Game) loadRandomLetter() {
	g.currentLetter = rand.Intn(len(assets.Letters))
	letter := assets.Letters[g.currentLetter]
	logErrorAndExit(letterOverlay.Dispose())
	var err error
	letterOverlay, _, err = ebitenutil.NewImageFromFile(letter, ebiten.FilterDefault)
	logErrorAndExit(err)
	// This avoids a memory leak on desktop caused by unused images being kept in memory
	logErrorAndExit(canvasImage.Dispose())
	canvasImage, _, err = ebitenutil.NewImageFromFile("./assets/img/sandpaper.jpg", ebiten.FilterDefault)
	logErrorAndExit(err)
	debugImage.Clear()
	if letterPlayer.IsPlaying() {
		letterPlayer.Rewind()
		letterPlayer.Pause()
	}
	letterPlayer = letterSounds[g.currentLetter]
	// play letter pronunciation on load
	letterPlayer.Play()
	letterJustLoaded = true
}

// fadeOut draws an overlay with the color of the letter background
// and opacity increasing from 0 to 1
func fadeOut() {
	logErrorAndExit(sweep.Dispose())
	var err error
	sweep, err = ebiten.NewImage(2*screenWidth, 2*screenHeight, ebiten.FilterDefault)
	logErrorAndExit(err)
	// sweep.Clear()
	ccolor := letterOverlay.At(0, 0).(color.RGBA)
	sweep.Fill(ccolor)
	op := &ebiten.DrawImageOptions{}
	offset := float64(animationDuration/2-animationTimer) / float64(animationDuration/2)
	op.ColorM.Scale(1, 1, 1, offset)
	animationOverlay.DrawImage(sweep, op)
}

Y bueno, podria escribir 10 parrafos mas con detalles tecnicos, pero es mas sencillo si se adentran en el codigo fuente.

La app la pueden ver en https://lettertracer.netlify.app/ (tarda un par de segundos en cargar el codigo compilado en WebAssembly)

Nota: Ebiten tiene problemas de performance de audio en ciertos browsers asi que puede sonar rara la demo. Estos problemas no existen si se compila a versiones desktop o mobile nativo 😀

Deja una respuesta