Ya iba siendo hora de volver con nuestra saga de artículos de como crear un videojuego, ya hemos visto en una primera parte como montar nuestro fondo espacial, y en una segunda parte como crear nuestra nave espacial y como moverla por el vasto espacio, pero ¿qué pasaría si vinieran enemigos? no estamos preparados, debemos equipar nuestra nave con un cañón láser a toda costa, ¿cómo os preguntaréis? pues seguid leyendo.
Crear nuestro láser
Sabemos por las películas del espacio, como Star Wars, que o tenemos armas con láseres o estamos vendidos, por ello lo primero que deberíamos crear es la configuración del mismo, que será el que usemos para disparar una y otra vez.
Para ello lo primero que haremos es crear un nuevo fichero, internal/laser.go
. Si nos paramos un poco a pensar con los conocimientos que ya tenemos de los artículos anteriores llegaremos a la conclusión de que un láser podría ser algo así,
type Laser struct {
pic pixel.Picture
pos *pixel.Vec
vel float64
isVisible bool
world *World
}
Como podemos observar tendremos un pixel.Picture
el cual representará la imagen de nuestro láser, una posición que será un pixel.Vec
que nos informará de donde se encuentra nuestro láser en cada momento, además una velocidad, sino no se movería, una propiedad que nos indicará si es visible o no nuestro láser es decir si ha salido del mundo o si ha dado a algún enemigo y por lo tanto no se tiene que pintar más, y por último igual que vimos en el Player
, un World
para poder acceder a las propiedades del mismo.
Vamos a inicializarlo, para ello crearemos nuestro constructor como estamos acostumbrados, pero atención que tiene un pequeño truco.
func NewBaseLaser(path, vel float64, world *World) (*Laser, error) {
pic, err := loadPicture(path)
if err != nil {
return nil, err
}
return &Laser{
pic: pic,
vel: vel,
world: world,
}, nil
}
A simple vista parece un constructor normal, primero inicializamos la carga de la imagen del láser, pic, err := loadPicture(path)
y controlamos su error, y a continuación inicializamos el Laser
, pero si os fijáis el nombre del constructor es, NewBaseLaser
y no NewLaser
.
Hemos decidido hacerlo de esta forma, aunque bien puede haber otras formas completamente válidas, para poder crear un láser base, valga la redundancia, es decir un láser que tendrá almacenado unos parámetros de origen, pero que no será realmente con el láser que trataremos a la hora de disparar.
Vamos a modificar ahora nuestra struct
de Laser
para que sirva también a la hora de disparar, ya veréis que la modificación es mínima.
type Laser struct {
pic pixel.Picture
pos *pixel.Vec
vel float64
isVisible bool
world *World
sprite *pixel.Sprite
}
Hemos añadido, sprite *pixel.Sprite
esto lo haremos porque necesitamos ir pintando en cada momento nuestra bala, pero además necesitaremos de las demás propiedades.
Lo que haremos ahora será crear un nuevo constructor, esta vez para instanciar un nuevo laser que copiara los datos del anterior y además preparara el sprite
.
func (l *Laser) NewLaser(pos pixel.Vec) *Laser {
spr := pixel.NewSprite(l.pic, l.pic.Bounds())
return &Laser{
pos: &pos,
vel: l.vel,
sprite: spr,
isVisible: true,
world: l.world,
}
}
Nuestro nuevo constructor, vemos que es un método de nuestro Laser struct
, como vemos aqui lo que hacemos es crear un nuevo láser con las propiedades del objeto base, añadiendo además, la posición que será la posición de origen desde donde se dispara, veremos como calcularla luego en nuestro Player
, inicializamos el sprite
y además declaramos que será visible.
¿Qué toca ahora? Pues, si habéis respondido Draw
, estáis en lo correcto, tenemos que crear el método que nos permitirá pintar nuestro láser en la pantalla veamos como es.
func (l Laser) Draw(t pixel.Target) {
if l.isVisible == true {
l.sprite.Draw(t, pixel.IM.Moved(*l.pos))
}
}
Sencillito no, a estas alturas esto ya no supone ningún problema para nosotros, tenemos nuestro típico método Draw
el cual espera un pixel.Target
y que mientras la propiedad isVisible
sea true
, irá pintando el láser en la posición que se encuentre, pero… ¿en qué posición se encuentra nuestro láser?, exacto, necesitaremos del método Update
.
func (l *Laser) Update() {
l.pos.Y += l.vel
if l.pos.Y > l.world.height {
l.isVisible = false
}
}
Como sabemos que el láser sólo se mueve hacia arriba, sólo tendremos que aumentar la posición en el eje Y
, y esto lo haremos sumando su velocidad, a posteriori lo que haremos es comprobar si la bala ha salido de la pantalla para dejar de pintarla marcándola como no visible.
Ya tenemos todo lo necesario para empezar a disparar, pero claro los disparos tienen que ser producido desde una nave, así que vayamos a nuestro player.go
y comencemos a ver que modificaciones necesitamos realizar.
Empezando a disparar
Primero que nada vamos a ver como modificamos nuestro Player struct
.
type Player struct {
direction Direction
world *World
sprite *pixel.Sprite
life int
pos *pixel.Vec
vel float64
laser *Laser
lasers map[string]*Laser
}
Las modificaciones que vemos aquí es primeramente, laser *Laser
, el cual será el láser base del que hablamos y a continuación tenemos, lasers map[string]*Laser
, que es una representación de todos los láseres que hemos disparado.
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())
// Initialize the laser for the player
l, err := NewBaseLaser("resources/laser.png", 270.0, world)
if err != nil {
return nil, err
}
return &Player{
life: life,
sprite: spr,
world: world,
pos: &initialPos,
vel: playerVel,
laser: l,
lasers: make(map[string]*Laser),
}, nil
}
Como podéis ver, hemos añadido la configuración de nuestro láser base, l, err := NewBaseLaser("resources/laser.png", 270.0, world)
y hemos inicializado nuestro mapa de láseres, cambios muy simples a la hora de crear nuestro Player
.
Por otro lado vamos a añadir una variable, para gestionar el tiempo de recarga de nuestro láser
var laserDelay = 35
Gracias a ello, nuestros láseres no serán armas de destrucción masiva, sino que serán algo más realistas y tendrán un tiempo de espera entre disparo y disparo.
Como supongo que habréis imaginado, además de adaptar nuestro Player struct
deberemos modificar ligeramente los métodos, Draw
y Update
, para poder incluir a nuestra nave los poderosos láseres.
Empezamos por el Draw
.
func (p Player) Draw(t pixel.Target) {
p.sprite.Draw(t, pixel.IM.Moved(*p.pos))
for _, l := range p.lasers {
l.Draw(t)
}
}
Lo que hemos hecho es, además de obviamente seguir pintando nuestra nave, hacer un bucle entre todos los láseres, recordad que habíamos creado un map
para almacenarlos, y entonces llamar por cada uno a su método Draw
.
func (p *Player) Update(direction Direction, action Action, dt float64) {
p.direction = direction
p.move(direction, dt)
p.shoot(action, dt)
for k, l := range p.lasers {
l.Update()
// remove unused lasers
if !l.isVisible {
delete(p.lasers, k)
}
}
}
func (p *Player) shoot(action Action, dt float64) {
if laserDelay >= 0 {
laserDelay--
}
if action == ShootAction && laserDelay <= 0 {
l := p.laser.NewLaser(*p.pos)
l.vel *= dt
p.lasers[NewULID()] = l
laserDelay = rechargeTime
}
}
Por otro lado lo que hemos hecho en el Update
es añadir una nueva acción (método), shoot
, el cual espera un Action
y un delta time
, el Action
no es más que otro tipo custom que hemos creado como el Direction
para saber cuando estamos disparando.
type Action int
const (
NoneAction Action = iota
ShootAction
)
Vamos a ver que hace nuestro método shoot
paso a paso. Primeramente vemos un if
que comprueba que si el tiempo de retardo del láser es mayor que 0
lo vaya restando.
if laserDelay >= 0 {
laserDelay--
}
Así pues a continuación podemos observar, que si estamos ejecutando la acción de disparar y además nuestro tiempo de retardo se ha terminado podemos ejecutar un disparo, if action == ShootAction && laserDelay <= 0
.
Para disparar lo que haremos es, crear un nuevo láser, añadirle el delta time
a su velocidad, añadir ese láser a nuestro map
para que sea pintado y resetear el tiempo de retardo.
Por otro lado si volvemos a nuestro método Update
, vemos que para que los láseres ejecuten su método Update
, debemos recorrernos el map
y llamar a dicho método, además tras cada Update
comprobaremos si el láser ha salido o no de la pantalla, es decir, si isVisible
es false
, si se cumple dicha condición lo eliminaremos de nuestro map
para evitar iteraciones innecesarias.
for k, l := range p.lasers {
l.Update()
// remove unused lasers
if !l.isVisible {
delete(p.lasers, k)
}
}
Ahora nuestra nave ya es capaz de disparar, pero aún nos queda modificar un poco nuestro punto de entrada (main.go
) para indicar la acción de disparar.
Quiero disparar, y quiero disparar ¡ya!
Vale vale, no te me desesperes vamos a ello, realmente lo que tendremos que modificar en nuestro main.go
es relativamente sencillo el cambio que tendremos que hacer, si recordamos nuestra función Update
, ahora además de un Direction
, tiene un Action
.
...
action := spacegame.NoneAction
for !win.Closed() {
...
if win.Pressed(pixelgl.KeySpace) {
action = spacegame.ShootAction
}
player.Update(direction, action, dt)
player.Draw(win)
direction = spacegame.Idle
action = spacegame.NoneAction
fps := 1 / dt
fmt.Println("FPS: ", int(fps))
win.Update()
}
Como podéis ver dentro del bucle que hace funcionar nuestro juego, lo que hemos añadido es que cuando presionemos Space
en nuestro teclado, indicaremos que estamos disparando, le pasaremos dicha acción a nuestro player
y luego la devolveremos a NoneAction
.
Si ejecutamos nuestro juego podremos ver algo tal que así.
Pero aunque sabemos que en el espacio no hay sonido eso no es a lo que estamos acostumbrado en las películas o en los videojuegos, entonces, ¿podemos hacer que nuestros disparos realicen el mítico pew pew?
No hay sonido en el espacio, cambiemos eso
Hasta ahora todo lo que hemos trabajado es en dotar de gráficos a nuestro programa, incluyendo imágenes diversas y jugando con su posición, pero con una librería llamada pixel
no vamos a esperar el que se pueda incluir sonido ¿no?
Pero los creadores de dicha librería han pensado en todo y por ello nos encontramos con un nuevo paquete, github.com/faiface/beep, que podremos instalar igual que cualquier otro paquete.
Para el sonido nosotros hemos buscado algún recurso libre, y podréis encontrarlo en nuestro propio repositorio. Una vez tengáis el sonido que queráis que haga nuestra nave al disparar pasaremos al código, para ello igual que hicimos con el Picture
nos creamos un nuevo fichero que contendrá nuestro helper
, internal/sound.go
.
package spacegame
import (
"log"
"os"
"github.com/faiface/beep"
"github.com/faiface/beep/wav"
)
type SFX struct {
streamer beep.StreamSeekCloser
format beep.Format
}
func loadSound(path string) (*SFX, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
streamer, format, err := wav.Decode(f)
if err != nil {
log.Fatal(err)
}
return &SFX{
streamer: streamer,
format: format,
}, nil
}
Primero que nada, tened en cuenta que a la hora de tratar con ficheros de audio, pueden tener distintos formatos como las imágenes, si por ejemplo trabajáis con mp3
en vez de wav
, tendréis que cargar el paquete respectivo, nosotros trabajaremos con wav
y por eso utilizamos, github.com/faiface/beep/wav
.
En nuestra solución hemos creado un SFX struct
, ya que tendremos que trabajar posteriormente con los datos del audio que nos devuelven los métodos respectivos. Por un lado tenemos el streamer
, que es un beep.StreamSeekCloser
, con el que interactuaremos para reproducir nuestro audio, y además tendremos un, beep.Format
, el cual como veremos luego nos permitirá indicar el muestreo por segundo, aunque no entiendo mucho de audio, mejor consultar en su documentación.
Vayamos a por nuestro, helper
, loadSound(path string) (*SFX, error)
, dado un path
, devolveremos una referencia de SFX
o bien un error.
Si vamos por parte vemos que no tiene mucho misterio el método, primeramente abriremos dicho fichero, una vez lo tenemos cargado en memoria haremos uso del método Decode
, del paquete wav
, recordar que si es mp3
tenéis que usar el paquete mp3
, y simplemente inicializaremos nuestro SFX
con el resultado de dicho Decode
siempre y cuando no devuelva error.
Ahora que ya somos capaces de cargar sonidos en nuestro juego vamos a utilizarlos, vamos a dotar a nuestro láser de su característico sonido.
Dando sonido a nuestro láser
Para poder dotar de sonido a nuestro láser lo que haremos es volver a nuestro fichero, internal/laser.go
y como no podía ser de otro modo modificaremos primeramente nuestro struct
.
type Laser struct {
pic pixel.Picture
pos *pixel.Vec
vel float64
sprite *pixel.Sprite
isVisible bool
world *World
sfxPath string
}
Exacto, simplemente vamos a almacenar el path de donde se encuentra nuestro sonido y se lo indicaremos en el constructor.
func NewBaseLaser(path, sfxPath string, vel float64, world *World) (*Laser, error) {
pic, err := loadPicture(path)
if err != nil {
return nil, err
}
return &Laser{
pic: pic,
vel: vel,
world: world,
sfxPath: sfxPath,
}, nil
}
No lo ponemos fijo en dicho constructor sino que lo pasamos por parámetro por si queremos por ejemplo que los enemigos disparen con otro sonido, como sabéis los enemigos siempre pierden, pero tienen armas más poderosas.
Una vez hecho esto necesitamos que cada vez que se realice un disparo suene el sonido, nosotros hemos decidido publicar un método para ello en láser.
func (l Laser) Shoot() {
sfx, err := loadSound(l.sfxPath)
if err != nil {
log.Fatal(err)
}
speaker.Init(sfx.format.SampleRate, sfx.format.SampleRate.N(time.Second/10))
defer sfx.streamer.Close()
done := make(chan bool)
speaker.Play(beep.Seq(sfx.streamer, beep.Callback(func() {
done <- true
})))
<-done
}
Bien bien, parece algo más complejo, vayamos por parte, primero cargamos nuestro sonido, hasta aquí no hay mucho problema, sfx, err := loadSound(l.sfxPath)
. A continuación lo que haremos es llamar al paquete, speaker
que es el que se encargará de preparar todo lo necesario para reproducir nuestro sonido. Aquí no me voy a meter ya que como digo mis conocimientos de música son nulos, y simplemente he ido siguiendo las pautas que comentan en la documentación de la librería.
A continuación lo que haremos ya sí es ejecutar el sonido, con el método speaker.Play
, el cual pasando un streamer
y un callback
, el cual nos informará de cuando acabe el audio, reproducirá nuestro sonido.
Por último bloqueamos la función con nuestro channel
.
Ahora solo tendremos que modificar nuestro, internal/player.go
para que sea capaz de reproducir el sonido.
Primero en el constructor pasaremos el path
del audio.
func NewPlayer(path string, life int, world *World) (*Player, error) {
...
// Initialize the laser for the player
l, err := NewBaseLaser("resources/laser.png", "resources/sfx/pew.wav", 270.0, world)
if err != nil {
return nil, err
}
...
}
Y a continuación en nuestro método Update
añadiremos la llamada a esta nueva acción Shoot
del láser.
func (p *Player) shoot(action Action, dt float64) {
if laserDelay >= 0 {
laserDelay--
}
if action == ShootAction && laserDelay <= 0 {
l := p.laser.NewLaser(*p.pos)
go l.Shoot()
l.vel *= dt
p.lasers[NewULID()] = l
laserDelay = rechargeTime
}
}
Así que en nuestro propio método, shoot
, lo que haremos es llamar mediante una gorrutina para que todo el proceso del sonido no interrumpa la ejecución de cada disparo.
Si ejecutamos ahora nuestro juego, magia, pew pew
.
Conclusión
Pues en este extenso artículo habéis aprendido como disparar con vuestra nave, para que ningún extraterrestre con malas intenciones venga a destruir la Tierra, y además hemos visto que podemos luchar contras las leyes y dotar de sonido a nuestros láseres incluso en el espacio.
En siguientes artículos veremos como incluir nuestros enemigos, y como dotarles de algo de IA básica para que nos intenten destruir, así como dotar de un poco de UI
al juego con un contador de vidas y puntos, y algún jefe final, así que como véis todavía tenemos para rato con esta saga de artículos, seguid atentos a nuestro blog, y a nuestras redes sociales.
Y no os olvidéis cualquier duda o sugerencia, podéis dejarla en los comentarios o en nuestro twitter oficial, @FriendsOfGoTech.
¡Pew, pew!