1. Code
  2. Game Development

Programar un generador de secuencia en un Paisaje Estelar

En mi artículo anterior, expliqué la diferencia entre un generador de números pseudoaleatorios y un generador de secuencias, y examiné las ventajas que tiene un generador de secuencias sobre un PRNG. En este tutorial codificaremos un generador de secuencias bastante simple. Genera una serie de números, manipula e interpreta esta secuencia, y luego la usa para dibujar un paisaje estelar muy simple.
Scroll to top

Spanish (Español) translation by Elías Nicolás (you can also view the original English article)

En mi artículo anterior, expliqué la diferencia entre un generador de números pseudoaleatorios y un generador de secuencias, y examiné las ventajas que tiene un generador de secuencias sobre un PRNG. En este tutorial codificaremos un generador de secuencias bastante simple. Genera una serie de números, manipula e interpreta esta secuencia, y luego la usa para dibujar un paisaje estelar muy simple.

Nota: Aunque este tutorial está escrito en Java, debería poder usar las mismas técnicas y conceptos en casi cualquier entorno de desarrollo de juegos.


Creando e inicializando la imagen

Lo primero que tenemos que hacer es crear la imagen. Para este generador de secuencias, vamos a crear una imagen de 1000 × 1000px para que la generación de números sea lo más simple posible. Diferentes idiomas hacen esto de manera diferente, así que use el código necesario para su plataforma de desarrollo.

Cuando haya creado la imagen con éxito, es hora de darle un color de fondo. Ya que estamos hablando de un cielo estrellado, sería más sensato comenzar con un fondo negro (#000000) y luego agregar las estrellas blancas, en lugar de al revés.


Haciendo un perfil de estrella y un campo de estrellas

Antes de comenzar a trabajar en el generador de secuencias, debes averiguar hacia dónde quieres dirigirte. Esto significa saber lo que quieres crear y cómo las diferentes semillas y números varían lo que quieres crear, en este caso las estrellas.

Para hacer esto, necesitamos crear un perfil de estrella de muestra que contendrá variables de clase que indiquen algunas de las propiedades de las estrellas. Para mantener las cosas simples, vamos a comenzar con solo tres atributos:

  • coordenada x
  • coordenada y
  • tamaño

Cada uno de los tres atributos tendrá valores que van de 0 a 999, lo que significa que cada atributo tendrá tres dígitos asignados. Todo esto será almacenado en una clase Star.

Dos métodos importantes en la clase Star son getSize() y getRadiusPx(). El método getSize() devuelve el tamaño de la estrella, reducido a un número decimal entre cero y uno, y el método getRadiusPx() devuelve qué tan grande debe ser el radio de la estrella en la imagen final.

Descubrí que 4 píxeles constituyen un buen radio máximo en mi demostración, por lo que getRadiusPx() simplemente devolverá el valor getSize() multiplicado por cuatro. Por ejemplo, si el método getSize() devuelve un radio de 0.4, el método getRadiusPx() dará un radio de 1.6px.

1
2
//Star class

3
4
private int s_x, s_y, s_size;
5
6
public Star(int x, int y, int size){
7
//Constructor which sets initial attributes

8
        s_x = x;
9
        s_y = y;
10
        s_size = size;
11
}
12
13
public int getX(){
14
//Returns the x-co-ordinate of the star

15
        return s_x;
16
}
17
18
public int getY(){
19
//Returns the y-co-ordinate of the star

20
        return s_y;
21
}
22
23
public double getSize(){
24
//Returns the star's radius as a decimal number between 0 and 1

25
        return (double) (s_size/1000);
26
}
27
28
public double getRadiusPx(){
29
//Returns the star's radius in pixels

30
        return (double) 4*getSize(); //4px is the biggest radius a star can have

31
}

También deberíamos hacer una clase muy simple cuyo trabajo es hacer un seguimiento de todas las estrellas en cada secuencia de estrellas. La clase Starfield solo consiste en métodos que agregan, eliminan o recuperan estrellas de un ArrayList. También debería poder devolver el ArrayList.

1
2
//Starfield class

3
4
private ArrayList s_stars = new ArrayList();
5
6
public void addStar(Star s){
7
//A method which adds a star to an ArrayList

8
        s_stars.add(s);
9
}
10
11
public void removeStar(Star s){
12
//A method which removes a star from an ArrayList

13
        s_stars.remove(s);
14
}
15
16
public Star getStar(int i){
17
//A method which retrieves a star with index i from an ArrayList

18
        return (Star) getStarfield().get(i);
19
}
20
21
public ArrayList getStarfield(){
22
//A method which returns the ArrayList storing all the stars

23
        return s_stars;
24
}

Planificación del generador de secuencias

Ahora que hemos terminado el perfil de estrella e inicializado la imagen, sabemos algunos puntos importantes sobre el generador de secuencias que queremos crear.

En primer lugar, sabemos que el ancho y el alto de la imagen es 1000px. Esto significa que, para explotar los recursos disponibles, el rango de coordenadas x e y debe caer en el rango 0-999. Dado que dos de los números requeridos se encuentran en el mismo rango, podemos aplicar el mismo rango al tamaño de la estrella para mantener la uniformidad. El tamaño se reducirá más adelante cuando interpretemos la serie de números.

Vamos a utilizar una serie de variables de clase. Estos incluyen: s_seed, un entero entero que define la secuencia completa; s_start y s_end, dos enteros que se generan al dividir la semilla en dos; y s_current, un entero que contiene el número generado más recientemente en la secuencia.

Creating a sequenceCreating a sequenceCreating a sequence
Ver esta imagen de mi artículo anterior. 1234 es la semilla, y 12 y 34 son los valores iniciales de s_start y s_end.
Consejo: Tenga en cuenta que cada número generado se origina a partir de la semilla; no hay llamada random(). Esto significa que la misma semilla siempre generará el mismo paisaje de estrellas.

También utilizaremos s_sequence, un String que contendrá la secuencia general. Las dos últimas variables de clase son s_image (de tipo Image - una clase que crearemos más adelante) y s_starfield (de tipo Starfield, la clase que acabamos de crear). El primero almacena la imagen, mientras que el segundo contiene el campo de estrellas.

La ruta que vamos a tomar para crear este generador es bastante simple. Primero, necesitamos hacer un constructor que acepte una semilla. Cuando se hace esto, necesitamos crear un método que acepte un número entero que represente la cantidad de estrellas que debe crear. Este método debería entonces llamar al generador real para obtener los números. Y ahora comienza el trabajo real... creando el generador de secuencias.


Codificando el generador de secuencias.

Lo primero que debe hacer un generador de secuencias es aceptar una semilla. Como se mencionó, dividiremos la semilla en dos: los dos primeros dígitos y los dos últimos dígitos. Por esta razón, debemos verificar si la semilla tiene cuatro dígitos, y rellenarla con ceros si no la tiene. Cuando se hace esto, podemos dividir la cadena semilla en dos variables: s_start y s_end.

1
2
//StarfieldSequence class

3
4
public StarfieldSequence (int seed){
5
//A constructor which accepts a seed, and splits it into two

6
        String s_seedTemp;
7
        s_starfield = new Starfield(); //Initialize the Starfield

8
        s_seed = seed; //Store the seed in a string so that we can easily split it

9
10
        //Add zeros to the string if the seed doesn't have four digits

11
        if (seed < 10){
12
            s_seedTemp = "000";
13
            s_seedTemp = s_seedTemp.concat(Integer.toString(seed));
14
        } else if (seed < 100){
15
            s_seedTemp = "00";
16
            s_seedTemp = s_seedTemp.concat(Integer.toString(seed));
17
        } else if (seed < 1000){
18
            s_seedTemp = "0";
19
            s_seedTemp =  s_seedTemp.concat(Integer.toString(seed));
20
        } else {
21
            s_seedTemp =  Integer.toString(seed);
22
        }
23
24
        //Split the seed into two - the first two digits are stored in s_start, while the last two are stored in s_end

25
        s_start = Integer.parseInt(s_seedTemp.substring(0, 2));
26
        s_end = Integer.parseInt(s_seedTemp.substring(2, 4));
27
}

Asi que:

  • seed = 1234 significa s_start = 12 y s_end = 34
  • seed = 7 significa s_start = 00 y s_end = 07
  • seed = 303 significa s_start = 03 y s_end = 03

Siguiente en línea: cree otro método que, dados los dos números, genere el siguiente número en la secuencia.

Encontrar la fórmula correcta es un proceso cansado. Por lo general, significa horas de trabajo de prueba y error que intentan encontrar una secuencia que no implique demasiados patrones en la imagen resultante. Por lo tanto, sería más sabio encontrar la mejor fórmula una vez que podamos ver la imagen, en lugar de ahora. Lo que nos interesa en este momento es encontrar una fórmula que genere una secuencia que sea más o menos aleatoria. Por esta razón, usaremos la misma fórmula utilizada en la secuencia de Fibonacci: suma de los dos números.

1
2
//StarfieldSequence class

3
4
private int getNext(){
5
    //A method that returns the next number in the sequence

6
    return (s_start + s_end);
7
}

Cuando se haga esto, ahora podemos continuar y comenzar a crear la secuencia. En otro método, manipularemos la semilla inicial para generar un flujo completo de números, que luego se pueden interpretar como atributos del perfil de estrella.

Sabemos que para una estrella dada necesitamos nueve dígitos: los tres primeros definen la coordenada x, los tres medios definen la coordenada y, y los últimos tres definen el tamaño. Por lo tanto, como fue el caso al alimentar la semilla, para mantener la uniformidad en todo es importante asegurarse de que cada número generado tenga tres dígitos. En este caso, también tenemos que truncar el número si es mayor que 999.

Esto es bastante similar a lo que hicimos antes. Solo necesitamos almacenar el número en una cadena temporal, temp, y luego eliminar el primer dígito. Si el número no tiene tres dígitos, debemos rellenarlo con ceros como hicimos anteriormente.

1
2
//StarfieldSequence class

3
4
private void fixDigits(){
5
        String temp = "";
6
        //If the newly-generated number has more than three digits, only take the last three

7
        if (s_current > 999){
8
            temp = Integer.toString(s_current);
9
            s_current = Integer.parseInt(temp.substring(1, 4));
10
        }
11
        //If the newly-generated number has less than three digits, add zeros to the beginning

12
        if (s_current < 10){
13
            s_sequence += "00";
14
        } else if (s_current < 100){
15
             s_sequence += "0";
16
        }
17
}

Con eso envuelto, ahora deberíamos hacer otro método que cree y devuelva un perfil de estrella cada vez que generemos tres números. Usando este método, podemos agregar la estrella a la ArrayList de estrellas.

1
2
//StarfieldSequence class

3
4
private Star getStar(int i){
5
        //A method which accepts an integer (the size of the sequence) and returns the Star

6
        
7
        //Split the last nine digits in the sequence into three (the three attributes of the star)

8
        Star star = new Star(Integer.parseInt(s_sequence.substring(i-9, i-6)),
9
                Integer.parseInt(s_sequence.substring(i-6, i-3)),
10
                Integer.parseInt(s_sequence.substring(i-3, i)));
11
        return star;
12
}

Poniendolo todo junto

Una vez terminado esto, podemos montar el generador. Debe aceptar la cantidad de estrellas que tiene que generar.

Ya sabemos que para una estrella, necesitamos nueve dígitos, por lo que este generador necesita contar el número de caracteres en las cadenas. El contador, s_counter, almacenará la longitud máxima de la secuencia. Por lo tanto, multiplicamos el número de estrellas por nueve y eliminamos una, ya que un String comienza desde el índice cero.

También debemos contar el número de caracteres que hemos creado desde la última vez que generamos una estrella. Para esta tarea, vamos a utilizar s_starcounter. En un bucle for, que se repetirá hasta que la longitud de la serie sea igual a s_counter, ahora podemos llamar a los métodos que hemos creado hasta ahora.

Consejo: No debemos olvidar reemplazar s_start y s_end, ¡o seguiremos generando el mismo número una y otra vez!
1
2
//StarfieldSequence class

3
4
public void generate(int starnumber){
5
    //Generates a number of stars as indicated by the integer starnumber

6
        int s_counter = 9 * starnumber; //s_counter keeps track of the number of characters the String must have to generate the designated number of stars

7
        s_counter -= 1; //Remove one since a String starts from index 0

8
        int s_starcounter = 0; //s_starcounter keeps track of the number of numbers generated

9
        
10
        for (int i = 1; s_sequence.length() <= s_counter; i++){
11
            s_current = getNext(); //Generate the next number in the sequence

12
            fixDigits(); //Make sure the number has three digits

13
            s_sequence += s_current; //Add the new number to the sequence

14
            s_starcounter++;
15
            
16
            if (s_starcounter >= 3 && s_starcounter % 3 == 0){
17
                //If three numbers have been generated since the last star was created, create another one

18
                s_starfield.addStar(getStar(s_sequence.length())); 
19
            }
20
21
            //Replace s_start and s_end, or else you will keep on generating the same number over and over again!

22
            s_start = s_end;
23
            s_end = s_current;
24
        }
25
}

Dibujando estrellas

Ahora que la parte difícil ha terminado, finalmente es hora de pasar a la clase Image y comenzar a dibujar estrellas.

En un método que acepta un Starfield, primero creamos una instancia de un Color, y luego recuperamos el número de estrellas que debemos dibujar. En un bucle for, vamos a dibujar todas las estrellas. Después de hacer una copia de la estrella actual, es importante que recuperemos el radio de la estrella. Dado que el número de píxeles es un entero, debemos agregarlo al radio para convertirlo en un entero.

Para dibujar la estrella, utilizaremos un degradado radial.

An example of a radial gradientAn example of a radial gradientAn example of a radial gradient
Un ejemplo de gradiente radial.

La opacidad de un degradado radial depende de la distancia de un píxel desde el centro. El centro del círculo tendrá coordenadas (0,0). Usando la convención más común, cualquier píxel a la izquierda del centro tiene una coordenada x negativa, y cualquier píxel debajo tiene una coordenada y negativa.

Debido a esto, los bucles for anidados comienzan con un número negativo. Usando el teorema de Pitágoras, calculamos la distancia desde el centro del círculo y la usamos para recuperar la opacidad. Para las estrellas que tienen el radio más pequeño posible (1px), su opacidad depende únicamente de su tamaño.

1
2
//Image class

3
4
public void draw(Starfield starfield){
5
        Color color;
6
        for (int i = 0; i < starfield.getStarfield().size(); i++){ //Repeat for every star

7
            Star s = starfield.getStar(i);
8
            int f = (int) Math.ceil(s.getRadiusPx()); //We need an integer, so we ceil the star's radius

9
            for (int x = -1*f; x <= f; x++){
10
                for (int y = -1*f; y <= f; y++){
11
//Calculate the distance of the current pixel from the star's center

12
                    double d = Math.abs(Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)));
13
                    if (d < s.getRadiusPx()){ //Only draw pixel if it falls within radius

14
                        if (f == 1){
15
//If the star's radius is just one, the opacity depends on the star's size

16
                            color = new Color(0.85f, 0.95f, 1, (float) s.getSize());
17
                        } else {
18
//The opacity here depends on the distance of the pixel from the center

19
                            color = new Color(0.85f, 0.95f, 1, (float) ((s.getRadiusPx() - d)/s.getRadiusPx()));
20
                        }
21
                        graphics.setColor(color); //Assign a color for the next pixel

22
                        graphics.fillRect(s.getX()+x, s.getY()+y, 1, 1); //Fill the pixel

23
                    }
24
                }
25
            }
26
        }
27
}

Para terminar, necesitamos crear un método que acepte un String y la use para guardar la imagen con ese nombre de archivo. En el generador, debemos crear primero la imagen. Entonces, deberíamos llamar a estos dos últimos métodos desde el generador de secuencias.

1
2
//StarfieldSequence class

3
4
public void generate(int starnumber){
5
        s_image.createImage(); //Create the image

6
        int s_counter = 9*starnumber;
7
        s_counter -= 1;
8
        int s_starcounter = 0;
9
        for (int i = 1; s_sequence.length() <= s_counter; i++){
10
             s_current = getNext();
11
             fixDigits();
12
             s_sequence += s_current;
13
             s_starcounter++;
14
             if (s_starcounter >= 3 && s_starcounter % 3 == 0){
15
                s_starfield.addStar(getStar(s_sequence.length()));
16
             }
17
             s_start = s_end;
18
             s_end = s_current;
19
        }
20
        s_image.draw(s_starfield); //Draw the starfield

21
        s_image.save("starfield"); //Save the image with the name 'starfield'

22
}

En la clase Main, deberíamos crear una instancia del generador de secuencias, asignarle una semilla y obtener un buen número de estrellas (400 deberían ser suficientes). Intente ejecutar el programa, corrija los errores y verifique la ruta de destino para ver qué imagen se creó.

The resulting image with a seed 1234The resulting image with a seed 1234The resulting image with a seed 1234
La imagen resultante con una semilla de 1234.

Mejoras

Todavía hay algunos cambios que podemos hacer. Por ejemplo, lo primero que habrías notado es que las estrellas están agrupadas en el centro. Para arreglar eso, tendrías que encontrar una buena fórmula que elimine cualquier patrón. Alternativamente, puede crear varias fórmulas y alternar entre ellas usando un contador. Las fórmulas que utilizamos fueron estas:

1
2
//StarfieldSequence class

3
4
private int getNext(){
5
        if (count == 0){
6
            if (s_start > 0 && s_end > 0){
7
                count++;
8
                return (int) (Math.pow(s_start*s_end, 2) / (Math.pow(s_start, 1)+s_end)
9
                         + Math.round(Math.abs(Math.cos(0.0175f * s_end))));
10
            } else {
11
                count++;
12
                return (int) (Math.pow((s_end + s_start), 4) /
13
                        Math.pow((s_end + s_start), 2) +
14
                        Math.round(Math.abs(Math.cos(0.0175f * s_end))) +
15
                        Math.cos(s_end) + Math.cos(s_start));
16
            }
17
        } else {
18
            if (s_start > 0 && s_end > 0){
19
                count--;
20
                return (int) (Math.pow((s_end + s_start), 2) +
21
                        Math.round(Math.abs(Math.cos(0.0175f * s_end))));
22
            } else {
23
                count--;
24
                return (int) (Math.pow((s_end + s_start), 2) +
25
                        Math.round(Math.abs(Math.cos(0.0175f * s_end))) +
26
                        Math.cos(s_end) + Math.cos(s_start));
27
            }
28
        }
29
}

Hay una mejora más simple que podemos implementar. Si miras al cielo, verás algunas estrellas grandes y muchas más pequeñas. Sin embargo, en nuestro caso, el número de estrellas pequeñas es aproximadamente el mismo que el número de estrellas grandes. Para solucionar esto, solo tenemos que volver al método getSize() en la clase Star. Después de hacer que el tamaño sea una fracción de uno, tenemos que aumentar este número a la potencia de un entero, por ejemplo, cuatro o cinco.

1
2
//Star class

3
4
public double getSize(){
5
        return (double) (Math.pow((double)s_size/1000, 4));
6
}

Ejecutar el programa una última vez debería darle un resultado satisfactorio.

The final result – a whole starscape procedurally generated by our Sequence Generator!The final result – a whole starscape procedurally generated by our Sequence Generator!The final result – a whole starscape procedurally generated by our Sequence Generator!
El resultado final: ¡todo un paisaje estelar generado de manera procesal por nuestro generador de secuencias!

Conclusión

En este caso, utilizamos un generador de secuencia para generar un fondo de manera procesal. Un generador de secuencias como este podría tener muchos más usos; por ejemplo, una coordenada z podría agregarse a la estrella, de modo que en lugar de dibujar una imagen, podría generar estrellas como objetos en un entorno 3D.