Tras la buena aceptación recibida por la parte 1 de ¿cómo crear un videojuego en Go?, hemos decidido no dejaros con la miel en los labios y saltar a la chicha, vamos a ponerle cara al juego.
Así que no me enrollo más y pasamos a darle caña.
Instanciar nuestra nave
Vale que el fondo que hemos puesto es realmente chulo, pero claro no podemos crear un juego de naves sin una nave ¿no? Pues vamos a ello.
Para ello crearemos un nuevo fichero, internal/player.go
, y crearemos su struct
respectivo.
type Player struct {
direction Direction
world *World
sprite *pixel.Sprite
life int
pos *pixel.Vec
vel float64
}
Vamos a descomponer nuestro Player
para ir entendiendo porque necesita de todos estos parámetros, primero que nada nos encontramos con un parámetro direction
, creo que el nombre es bastante descriptivo, básicamente este parámetro nos indicará la dirección a la que se esta moviendo nuestra nave en cada momento; como podéis ver no es un tipo básico sino que es un tipo creado por nosotros.
Como he dicho para conocer la dirección en la que se mueve nuestra nave en cada momento hemos hecho uso de un tipo custom, llamado Direction
, dicho tipo lo colocaríamos al mismo nivel que nuestro Player
es decir, internal/direction.go
, y ¿por qué no dentro del propio fichero de player.go
? os preguntaréis, simple, a no ser que simplemente queramos explorar el espacio y aún así tendremos enemigos interestelares, que necesitan también moverse y de los cuales necesitaremos conocer su dirección.
type Direction int
const (
Idle Direction = iota
LeftDirection
RightDirection
)
Realmente no tiene mucha ciencia dicho tipo, no es más que un enum
para que nos sea más sencillo asignar y entender en que dirección se mueve nuestra nave o nuestros enemigos en el futuro, para ello tendremos tres estados, Idle
que será el estado en reposo, Left
y Right
, izquierda y derecha respectivamente.
Una vez entendido que es nuestro tipo Direction
volvemos a Player
, y vemos que segundo parámetro es world
el cual simplemente es una instancia al world
que creamos en el artículo anterior, obviamente nuestra nave debe estar en un mundo, continuando con los parámetros nos encontramos sprite
, esta vez no guardaremos una representación del pixel.Picture
sino el sprite
en sí ya que sólo tenemos que cargarlo cuando inicializamos nuestro Player
. Los siguientes parámetros son muy auto explicativos, life
para la vida, aunque de momento no la utilizaremos, pos
nos indicara en que posición se encuentra la nave en cada momento y vel
que será a la velocidad a la que se mueve.
Teniendo clara la estructura de nuestra nave, pasemos a inicializarla.
func NewPlayer(path string, life int, world *World) (*Player, error) {
// Initialize sprite to use with the player
pic, err := loadPicture(path)
if err != nil {
return nil, err
}
spr := pixel.NewSprite(pic, pic.Bounds())
initialPos := pixel.V(world.Bounds().W()/2, spr.Frame().H())
return &Player{
life: life,
sprite: spr,
world: world,
pos: &initialPos,
vel: 250.0,
}, nil
}
Las primeras líneas nos son conocidas, ya que lo único que hacen es crear el sprite
a partir de la ruta que le hayamos pasado por parámetro.
Lo siguiente que veremos es, initialPos := pixel.V(world.Bounds().W()/2, spr.Frame().H())
, cuando creamos la nave debemos indicarle una posición inicial, para ello la colocaremos en el centro del mundo por debajo, para calcular la x
entonces sólo tendremos que dividir width
del mundo entre dos, y para la y
diremos que empiece en su propia altura.
Ahora igual que hicimos con el World
en el artículo anterior, crearemos el método Draw
.
func (p Player) Draw(t pixel.Target) {
p.sprite.Draw(t, pixel.IM.Moved(*p.pos))
}
Aquí básicamente pintaremos nuestro sprite
en el pixel.Target
indicado, dada la posición en la que se encuentre la nave en ese momento.
Ahora vamos a ver si todo ha funcionado como debe, para ello vamos a hacer las modificaciones pertinentes en el main.go
en nuestro método run
.
Si recordamos, tras acabar la primera parte, teníamos un método run
tal que así
func run() {
cfg := pixelgl.WindowConfig{
Title: "Friends of Go: Space Game",
Bounds: pixel.R(0, 0, windowWidth, windowHeight),
VSync: true,
}
win, err := pixelgl.NewWindow(cfg)
if err != nil {
log.Fatal(err)
}
world := spacegame.NewWorld(windowWidth, windowHeight)
if err := world.AddBackground("resources/background.png"); err != nil {
log.Fatal(err)
}
world.Draw(win)
for !win.Closed() {
win.Update()
}
}
Así que partiendo de dicho run
haremos las modificaciones pertinentes, veamos como queda
func run() {
...
player, err := spacegame.NewPlayer("resources/player.png", 5, world)
if err != nil {
log.Fatal(err)
}
world.Draw(win)
for !win.Closed() {
player.Draw(win)
win.Update()
}
}
Si ejecutamos ahora nuestro juego tendremos algo como esto
Maravilloso, y ahora estaréis pensando, vale mola, pero no nos dejes igual que el artículo pasado yo quiero hacer cosas con la nave, no se que se mueva el menos, tranquilos, vamos a ello.
Moviendo mi nave por el espacio
Obviamente, poner la nave en medio del mundo pues ha sido divertido, pero a parte de un simple wallpaper, no tenemos mucho más, así que vamos a moverla, para ello partiremos de la premisa de que sólo se podrá mover entre izquierda y derecha, tal y como vimos en el tipo Direction
, a lo Space Invaders.
Así que volvamos a nuestro Player
, recordemos que el Player
tiene una propiedad pos
, direction
y vel
, las cuales nos indican que nos vendrán muy bien para movernos por la pantalla, pero ¿cómo lo hago?
Para ello igual que hicimos con Draw
existe un consenso dentro del mundo de los videojuegos que es el método Update
el cual se ejecuta en cada frame, gracias a ello podremos decir que hace la nave en cada frame.
func (p *Player) Update(direction Direction, dt float64) {
// code here
}
En nuestro caso nuestro método Update
tendrá una direction
el cual nos queda claro y además una propiedad llamada dt
(delta time).
¿Qué es el delta time?
Hago un breve inciso para explicar que es el delta time
ya que nos acompañará a lo largo de todos los desarrollos de nuestros juegos (excepto en algunos motores como Unity o Unreal Engine, donde ya lo calculan por nosotros y nos lo dan en variable) y es algo que debemos conocer. delta time
es la diferencia de tiempo entre cuando se dibujo el frame anterior y el actual. Dicho esto necesitaremos este tiempo para poder calcular los desplazamientos, rotaciones, etc. de nuestros objetos, ya que debemos recordar que nuestros juegos funcionan mediante frames, si conocemos el tiempo que hay entre un frame y otro podremos calcular a que velocidad se mueven nuestros objetos sin tener que rompernos la cabeza de a cuantos frames por segundos corre nuestra aplicación o si nos baja la tasa de frame.
Si queréis profundizar más sobre el tema, os dejamos este interesante artículo en inglés donde explican mucho más en detalle qué es y cómo funciona el delta time
.
Una vez entendido que es el delta time
podemos volver a nuestro método Update
, aquí lo que queremos hacer es actualizar la posición de la nave en cada frame, para ello nos valdremos de la direction
para saber si nos movemos hacia el eje negativo o positivo de x
, es decir, izquierda y derecha.
En código sería algo similar a esto
func (p *Player) Update(direction Direction, action Action, dt float64) {
p.direction = direction
switch direction {
case LeftDirection:
newX := p.pos.X - (p.vel * dt)
if newX > 0 {
p.pos.X = newX
}
case RightDirection:
newX := p.pos.X + (p.vel * dt)
if newX < p.world.Bounds().W() {
p.pos.X = newX
}
}
}
Primero que nada seteamos la direction a nuestro Player
actualmente no la necesitamos para nada pero puede que más adelante necesitemos consultar en que dirección está nuestra nave en dicho frame, por ejemplo esto sería útil si estuvieramos utilizando un spritesheet y queremos cambiar la imagen del personaje en cada dirección.
A continuación montaremos un simple switch con los casos que tenemos, como vemos en cada uno primero calculamos el nuevo punto de x
y luego se lo asignamos a nuestra posición. Para calcularlo restaremos o sumaremos dependiendo a donde nos queremos mover, la velocidad por el delta time
, tal y como explicamos anteriormente.
Además ya que no queremos que la nave se salga de los límites de la pantalla, pondremos una condición en cada caso de que no sea capaz de atravesar la pantalla ni por un lado ni por otra sino que simplemente se quede en la misma posición en la que está. Para ello nos basaremos en que sabemos que el principio de la pantalla se encuentra en el eje x:0
y el final de la misma en el eje x
donde acaba el mundo, es decir su width
(p.world.Bounds().W()
).
Como habréis averiguado ahora tendremos que cambiar nuestro main.go
para añadir nuestro Update
y además tendremos que ver cómo calcular el delta time
.
Si aplicamos los cambios oportunos a nuestro run
anterior, tendremos algo como esto.
func run() {
...
player, err := spacegame.NewPlayer("resources/player.png", 5, world)
if err != nil {
log.Fatal(err)
}
direction := spacegame.Idle
world.Draw(win)
last := time.Now()
for !win.Closed() {
dt := time.Since(last).Seconds()
last = time.Now()
if win.Pressed(pixelgl.KeyLeft) {
direction = spacegame.LeftDirection
}
if win.Pressed(pixelgl.KeyRight) {
direction = spacegame.RightDirection
}
player.Update(direction, dt)
player.Draw(win)
direction = spacegame.Idle
win.Update()
}
}
¡Madre mía, cuánto código de repente! No nos alarmemos que es la mar de fácil de seguir. Lo primero que hemos hecho es inicializar nuestra direction
en reposo, no queremos que la nave este vagando sola por el espacio.
A continuación, last := time.Now()
obtendrá el tiempo de ahora, que una vez entre en el bucle lo utilizará para calcular el delta time
inicial, dt := time.Since(last).Seconds()
y así durante cada frame, ¿fácil no?
A continuación nuestra ventana, nos ofrece también métodos para saber que teclas están presionadas, para esto sería mejor realizar un buen mapeo, ya que si nos conectan un mando o similar no nos funcionaría, pero para nuestro ejemplo nos centraremos exclusivamente en el teclado, para ello gracias al método Pressed
y las constantes pertinentes podemos saber que tecla estamos pulsando, por ello podemos indicar si la dirección es izquierda o derecha.
A continuación simplemente llamaremos al método Update
y luego al Draw
, por último volveremos a dejar la dirección en reposo, ya que sino se estará moviendo sin parar hacia el lado elegido.
Si ejecutamos nuestra aplicación y empezamos a mover la nave veremos algo como esto.
No mola, ¿no? se va quedando la estela de nuestra nave en pantalla, pero esto no es lo que buscábamos, esto sucede porque no estamos limpiando la ventana cada vez para ello simplemente tendremos que mover el Draw
del mundo dentro del bucle y previamente hacer un Clear
de la ventana, es decir.
for !win.Closed() {
dt := time.Since(last).Seconds()
last = time.Now()
win.Clear(colornames.Black)
world.Draw(win)
...
}
Listo, si volvemos a ejecutar:
Conclusión
Hoy ya hemos dado un poco más de cara al juego, ahora tenemos una nave y podemos moverla por la pantalla, parece poca cosa, pero poco a poco vamos teniendo un mini juego bastante resultón y lo mejor de todo hecho plenamente en Go. En la próxima entrega de esta saga, de ¿Cómo crear un videojuego en Go? veremos como disparar y añadirle sonido a nuestra aplicación, que sería un juego de naves sin su mítico pew pew.
Recordar que todo el proyecto lo podéis encontrar en nuestro repositorio.
No olvidéis que cualquier duda o sugerencia, podéis dejarla en los comentarios o en nuestro twitter @FriendsofGoTech