Project name

Project description

Inclina la cabeza para esquivar las bolas de bolos que se acercan, ¡tu objetivo es sobrevivir todo el tiempo que puedas!

thelastpin

Hazlo tuyo con el proyecto de ejemplo

globalscorecontroller

Estudio: GLOBAL SCORE CONTROLLER checkmark bullet

Un proyecto de ejemplo diseñado para ayudarle a comprender los eventos globales y, en concreto, cómo pueden utilizarse para actualizar una puntuación.

Ver ejemplo de proyecto

Behind the Build: The Last Pin

Written by Sam Guilmard

May 5, 2025


Introduction

Introduction

En este ejemplo, la rotación de la cabeza del usuario se utiliza para controlar el reproductor principal. También utiliza el componente de animación incorporado para controlar muchos elementos del juego, como el movimiento de los enemigos. 


El juego se llama "EL ÚLTIMO PIN", y en él encarnas a un boliche que intenta esquivar las bolas que se acercan. Cuanto más tiempo sobrevivas, más puntos obtendrás. Una vez que el jugador es golpeado por una bola de bolos, el juego termina. A lo largo del juego, la velocidad a la que aparecen las bolas de bolos aumenta, al igual que la velocidad de las bolas.

Project Structure

If intro text, paste here

También utiliza elementos de interfaz de usuario en 3D para que su posición pueda manipularse en el espacio tridimensional.
Activos
  • Hay 2 activos 3D en este proyecto. El canalón se sitúa en la escena a ambos lados de la pista de bolos y el boliche es lo que el usuario controla con el giro de su cabeza. Puedes pensar en el pin y en el jugador principal. 
  • Hay un archivo de audio que se reproduce cuando la bola golpea el pin.
  •  
  • En la carpeta de texturas hay una combinación de texturas de modelos y elementos de interfaz de usuario.
  • También hay un total de 10 texturas de bola diferentes. Estos se aplican aleatoriamente a cada bola cuando aparece.
Scripts
  • Sólo hay 1 script en este proyecto "GameController" que maneja toda la lógica necesaria para el juego. Como se trata de un juego bastante simple, sólo se necesita 1 script y debería hacer más fácil seguir cómo todas las funciones, componentes y valores funcionan e interactúan entre sí. 

Implementation

Esta sección de la documentación repasará algunas de las mecánicas básicas del juego.

- Movimiento del jugador 
En el esquema se hace referencia al objeto "Pin", que permite modificar componentes del objeto como la posición, rotación, escala, material, etc. 

         
schema: { 
// Add data that can be configured on the component. 
pin: ecs.eid, 
}, 

      

Ahora dentro de la jerarquía de la escena, seleccionamos el componente Game Controller (donde el script "GameController" está adjunto) y navegamos hacia el script en el panel del inspector. Hay una opción para Pin así que asegúrate de seleccionar el objeto correcto usando el panel desplegable.

Ahora en la función add del script "GameController" podemos crear una referencia a este objeto para que podamos referirnos a él fácilmente en otras funciones. 

         
const { 
pin, 
} = component.s

      

También en la función add creamos 2 escuchadores de eventos. Uno escucha cuando la cara se muestra "facecontroller.faceupdated" y el otro escucha cuando la cara se oculta "facecontroller.facelost". Al final del escuchador de eventos definimos el nombre de la función que queremos que sea llamada cuando este evento ocurra - cuando la cara se actualice, llama a la función "show" y cuando la cara se pierda, llama a la función "lost". 

         
// listener for head rotation and pin movement 
world.events.addListener(world.events.globalId, 'facecontroller.faceupdated', show) 
// listener for losing head rotation 
world.events.addListener(world.events.globalId, 'facecontroller.facelost', lost) 

      

La función "mostrar" introduce los valores de la posición y la rotación de la cabeza del usuario. Dentro de esta función se crea una variable "xRot" a la que se asigna el valor de la rotación x de la cabeza del usuario. Dentro de una sentencia IF, que está comprobando si el juego se está jugando actualmente, el valor xRot se aplica constantemente a la posición X de los pins dentro de la escena. Este valor es multiplicado por otra variable "pinSpeed" que puede ser fácilmente editada en el objeto de escena GameController ya que esta es otra variable de esquema. 

         
// controls the movement of the pin 
const show = ({data}) => { 
const xRot = data.transform.rotation.x 
// console.log(xRot) 
if (playing == true) { 
ecs.Position.set(world, pin, {x: Math.min(Math.max(xRot * -pinSpeed, -1.5), 1.5), y: 0, z: 8}) 
} 
} 

      

Velocidad del pasador

Cuanto mayor sea este número, más "rápido" se moverá el pasador. La razón por la que se niega este valor es para que se mueva en la dirección correcta con respecto a la escena y a la posición del usuario. También puede ver que cuando este valor se aplica al objeto pin, también está anidado dentro de una función Math.min y Math.max. Esto es para limitar la posición de los pins en la pantalla para que tengan que permanecer dentro de los límites del juego y no puedan hacer trampas. 
En la función "perdido", la posición del alfiler se restablece a la posición inicial original, esto es para que el jugador no pueda hacer trampa ocultando su cara.


- Ball Spawning 
Cuando el juego comienza (el usuario toca la pantalla) las bolas comienzan a aparecer - cada bola tiene una posición inicial X aleatoria y una posición final X aleatoria, esto es para hacer que las bolas se muevan en diagonal haciendo así el juego más difícil. A cada bola se le aplica una velocidad creciente pero aleatoria, cierta rotación dependiendo de la distancia entre la posición X inicial y final y también una textura aleatoria (entre 10 opciones) aplicada a cada bola. La mayoría de estas funciones se manejan implementando el controlador de animación integrado en Niantic Studio. 
De forma similar a como hicimos una referencia de objeto al Pin, tenemos que hacer 10 referencias de objeto diferentes dentro del esquema para las 10 bolas de bolos. 

         
schema: { 
// Add data that can be configured on the component. 
ball1: ecs.eid, 
ball2: ecs.eid, 
ball3: ecs.eid, 
ball4: ecs.eid, 
ball5: ecs.eid, 
ball6: ecs.eid, 
ball7: ecs.eid, 
ball8: ecs.eid, 
ball9: ecs.eid, 
ball10: ecs.eid, 
pin: ecs.eid, 
}, 

      

De nuevo ahora podemos usar la barra de selección desplegable en el script GameController adjunto al objeto GameController en la escena para aplicar cada bola.

En la función Add podemos crear una referencia a cada bola para que sea fácilmente accesible en todo el script. 

         
add: (world, component) => { 
// Runs when the component is added to the world. 
const { 
ball1, 
ball2, 
ball3, 
ball4, 
ball5, 
ball6, 
ball7, 
ball8, 
ball9, 
ball10, 
pin, 
} = component.schema 

      

Cuando se inicia el juego, ya sea al principio de la experiencia o cuando se reinicia, se llama a la función "spawnBall". 

 

         
function spawnBall() { 
// checks if the game is currently playing 
if (playing == true) { 
// decrease the spawn time every time a ball is spawned 
if (spawnTime > spawnFrequencyMinimum) { 
spawnTime -= spawnFrequencyMultiplier 
} 
const randomxStart = getRandomStartX() 
const randomxEnd = getRandomEndX() 
// this calculates the difference between the x start position and x end position - the higher the difference the more rotation that is added const differenceInDistance = Math.abs(randomxStart - randomxEnd) 
// if the x start position is bigger than the end then the ball is moving left 
if (randomxStart >= randomxEnd) { 
curveLeft = true 
} else { 
curveLeft = false 
}
updateMaterial() 
applyScaleAnimation() 
world.setPosition(balls[currentBall], randomxStart, 0.375, -0.5) applyPositionAnimation(randomxStart, randomxEnd) 
applyRotationAnimation(differenceInDistance) 
// Increase current ball or reset 
if (currentBall == 9) { 
currentBall = 0 
} else { 
currentBall++ 
} 
// time out function to delay the next spawning of a ball 
world.time.setTimeout(() => { 
if (playing == true) { 
spawnBall() 
} 
}, spawnTime) 
} 
} 

      

Todo lo que hay dentro de la función "spawnBall" está anidado dentro de una sentencia if que comprueba si se está jugando actualmente. Esto es para que si el juego termina no se generen más bolas. 
Al final de la función puedes ver que se utiliza una función "setTimeout" para volver a llamar a la función "spawnBall". El valor "spawnTime" es el tiempo que debe pasar antes de que se llame a la función. Este valor se modifica al inicio de la función cada vez que se llama. 

         
if (spawnTime > spawnFrequencyMinimum) { 
spawnTime -= spawnFrequencyMultiplier 
} 

      

Funciona comprobando primero si el spawnTime actual es mayor que el spawnFrequencyMinimum. Si lo es entonces resta el spawnFrequencyMultiplier del spawnTime, si no lo es entonces no hagas nada. Limitamos este valor para que no haya demasiadas bolas en la pantalla a la vez, lo que haría imposible el juego y podría afectar drásticamente a la tasa de fotogramas.

A continuación, asignamos la posición inicial X y la posición final X llamando a estas 2 funciones. 

         
const randomxStart = getRandomStartX() 
const randomxEnd = getRandomEndX() 

      

Ambas funciones funcionan de la misma manera y comienza definiendo el valor "negativeStart" con un valor aleatorio de 0 o 1. Se utiliza para determinar si la posición X es negativa o positiva. A continuación, se asigna a "xValue" un valor aleatorio entre 0 y 2. Este valor se devuelve, pero si "negativeStart" es igual a 0, el xValue se niega. 

         
// Get a random x value for ball start position 
function getRandomStartX() { 
const negativeStart = Math.floor(Math.random() * (2)) 
const xValue = Math.random() * (2) 
if (negativeStart == 0) { 
return xValue * -1 
} else { 
return xValue 
} 
} 

      

A continuación, se asigna un valor a "differenceInDistance" utilizando las 2 nuevas posiciones x para calcular a qué distancia se encuentran en el eje X. Esto se utiliza cuando se aplica la rotación de giro más tarde, cuanto más lejos la distancia más rápido la bola debe girar. 

         
const differenceInDistance = Math.abs(randomxStart - randomxEnd) 

      

A continuación, calculamos en qué dirección se mueve la pelota: ¿se mueve en diagonal hacia la izquierda o hacia la derecha? Usamos esto de nuevo para aplicar la animación de rotación, si la bola se mueve a la izquierda la bola también debería rotar a la izquierda. El valor "differenceInDistance" y el valor "curveLeft" se utilizan posteriormente al llamar a la función "applyRotationAnimation". 

         
// if the x start position is bigger than the end then the ball is moving left 
if (randomxStart >= randomxEnd) { 
curveLeft = true 
} else { 
curveLeft = false 
}

      

La siguiente función llamada es la función "applyMaterial". Al inicio de la función add se ha creado un array para los nombres de las texturas. Se define otro array que contiene todas las bolas, esto es para que podamos hacer un seguimiento de qué bola se está generando y aplicar cosas como la animación de posición y el nuevo material a la bola correcta. 

         
const textures = ['ballTexture1', 'ballTexture2', 'ballTexture3', 'ballTexture4', 'ballTexture5', 'ballTexture6', 'ballTexture7', 'ballTexture8', 'ballTexture9', 'ballTexture10'] 
const balls = [ball1, ball2, ball3, ball4, ball5, ball6, ball7, ball8, ball9, ball10] 

      

Es importante que estos nombres coincidan con las texturas dentro de su carpeta de activos, pero no incluyen la extensión, por ejemplo, png. 
Al inicio de la función "applyMaterial" se genera un número aleatorio entre 0 y 9. En función de ese número, se aplica a la bola una textura específica. La función ecs.Material.set se aplica a la bola actual del array y establece una nueva textura de la lista de nombres del array de texturas, dependiendo de la opción generada aleatoriamente. 

         
currentTexture = Math.floor(Math.random() * 10) 
// Apply the texture to the object 
ecs.Material.set(world, balls[currentBall], { 
textureSrc: `${require(`./assets/${textures[currentTexture]}.png`)}`, roughness: 0.5, 
metalness: 0.5, 
}) 

      

Es importante que estos nombres coincidan con las texturas dentro de su carpeta de activos, pero no incluyen la extensión, por ejemplo, png. 
Al inicio de la función "applyMaterial" se genera un número aleatorio entre 0 y 9. En función de ese número, se aplica a la bola una textura específica. La función ecs.Material.set se aplica a la bola actual del array y establece una nueva textura de la lista de nombres del array de texturas, dependiendo de la opción generada aleatoriamente. 

         
currentTexture = Math.floor(Math.random() * 10) 
// Apply the texture to the object 
ecs.Material.set(world, balls[currentBall], { 
textureSrc: `${require(`./assets/${textures[currentTexture]}.png`)}`, roughness: 0.5, 
metalness: 0.5, 
}) 

      

A continuación, se llama a "applyScaleAnimation", una función sencilla que se utiliza para escalar las bolas a medida que aparecen en la pantalla, de modo que no aparezcan de repente. 

         
function applyScaleAnimation() { 
ecs.ScaleAnimation.set(world, balls[currentBall], { 
autoFrom: false, 
fromX: 0, 
fromY: 0, 
fromZ: 0, 
toX: 1, 
toY: 1, 
toZ: 1, 
duration: 500, 
loop: false, 
easeIn: false, 
}) 
}

      

A continuación, se llama a "applyScaleAnimation", una función sencilla que se utiliza para escalar las bolas a medida que aparecen en la pantalla, de modo que no aparezcan de repente. 

         
world.setPosition(balls[currentBall], randomxStart, 0.375, -0.5) 

      

A continuación, se llama a la función "applyPositionAnimation" para añadir el movimiento de la bola de bolos. Los valores "randomxStart" y "randomxEnd" se pasan a esta función. Este es un buen ejemplo de cómo funciona el sistema de animación incorporado en el estudio Niantic. La ventaja de usar el sistema de animación y no un sistema de física que aplica fuerza es que puedes tener más control sobre la velocidad y no diferirá dependiendo de la calidad del dispositivo por ejemplo. 

         
applyPositionAnimation(randomxStart, randomxEnd) 
// Apply the position animation to the new ball 
function applyPositionAnimation(xStart, xEnd) { 
ecs.PositionAnimation.set(world, balls[currentBall], { 
autoFrom: false, 
fromX: xStart, 
fromY: 0.375, 
fromZ: -0.5, 
toX: xEnd, 
toY: 0.375, 
toZ: 10, 
duration: getRandomSpeed(), 
loop: false, 
easeIn: true, 
}) 
} 

      

Como puedes ver la "duración" de esta animación se define llamando a la función "getRandomSpeed". La duración está en milisegundos y cuanto más bajo sea este valor, más rápida será la animación. 


La función "getRandomSpeed" funciona generando un número aleatorio entre 0 y la "ballSpeedVariation" que está establecida por defecto en 200. Este valor se añade a la variable "initialSpeed", que se fija en 3000. Esto significa que la duración de la animación puede oscilar entre 3000 y 3200 milisegundos (añadiendo alguna pequeña variación). Cada vez que se llama a esta función, se comprueba si la "initialSpeed" es mayor que la "maximumBallSpeed" si lo es entonces se resta el valor de "ballSpeedIncreaseValue" lo que hace que la animación de posición sea más rápida con el tiempo (haciendo el juego más difícil cuanto más dure). Si el valor no es mayor entonces no se altera, esto es para limitar la velocidad de las bolas porque si la velocidad de la animación se reduce constantemente entonces eventualmente podrías obtener animaciones que son tan rápidas que son imposibles de esquivar.

         
function getRandomSpeed() { 
newSpeed = (Math.random() * ballSpeedVariation) + initialSpeed 
if (initialSpeed > maximumBallSpeed) { 
initialSpeed -= ballSpeedIncreaseValue 
} 
return newSpeed 
} 

      

A continuación, se llama a la función "applyRotationAnimation" y el valor "differenceInDistance" se pasa a esta función. Aquí también se utiliza la variable "curveLeft". La animación rotacional que se aplica a la bola es sólo en el eje Z, esto es para imitar la forma en que las bolas de bolos giran en la vida real. Al inicio de la función comprueba si "curveLeft" es verdadero - si lo es entonces fija el valor de "endRotation" a 720 (curva a la izquierda) y si no entonces lo fija a -720 (curva a la derecha). 

         
applyRotationAnimation(differenceInDistance) 
function applyRotationAnimation(differenceInDistanceValue) { 
const rotationValue = 4000 - (Math.floor(differenceInDistanceValue * 500)) 
// this changes the end rotation based on if the ball is moving left or right let endRotation 
if (curveLeft == true) { 
endRotation = 720 
} else { 
endRotation = -720 
} 
ecs.RotateAnimation.set(world, balls[currentBall], { 
autoFrom: false, 
fromX: 0, 
fromY: 0, 
fromZ: 0, 
toX: 0, 
toY: 0, 
toZ: endRotation, 
shortestPath: false, 
duration: rotationValue, 
loop: true, 
}) 
} 

      

La velocidad de la rotación se modifica afectando al valor "duración" de la animación. Al principio de la función se define una variable "rotationValue" tomando 4000 y restando el valor "differenceInDistance" multiplicado por 50. Esto significa que cuanto mayor sea la diferencia en la posición x inicial y x final, menor será el valor de rotación que se aplica a la duración de la animación (completa la animación en un tiempo más corto). Esta animación es en bucle para que las bolas giren constantemente y también es importante que establezcas "shortestPath" a false, de lo contrario parecerá que no pasa nada. 


Por último, al final de la función "spawnBall", se modifica el valor de "currentBall". Si la bola actual es 9 entonces pon la bola actual a 0, si no entonces incrementa el valor de "currentBall" en 1. 

         
// Increase current ball or reset 
if (currentBall == 9) { 
currentBall = 0 
} else { 
currentBall++ 
} 

      

 

Detección de colisiones 

A lo largo del juego hay 2 instancias de cuando se detecta una colisión. Una cuando una bola choca con la "CollisionBox" que se encuentra detrás del pin. Cuando se detecta esta colisión, significa que el jugador ha esquivado con éxito una bola de bolos y debe anotarse un punto. La otra instancia es cuando una bola golpea el pin, esto causa que el juego termine. 
Como la mayoría de las cosas en este proyecto, se inicia mediante la creación de una referencia al objeto en el esquema. El nombre del objeto que queremos comprobar que ha colisionado con algo es "colisión". A continuación, aplicamos este objeto en la escena y creamos una referencia al inicio de la función add. 

         
schema: { 
collision: ecs.eid, 
}, 
add: (world, component) => { 
// Runs when the component is added to the world. 
const { 
collision, 
} = component.s

      

Todas las bolas también tienen un Colisionador de Física unido a ellos, pero el "Rigidbody" aquí se establece en "Dinámico" como las bolas se están moviendo. La casilla "Sólo Evento" también está marcada aquí, esto es porque queremos que se detecte una colisión pero no queremos que las bolas interactúen realmente con la caja y reboten.

         
world.events.addListener(collision, ecs.physics.COLLISION_START_EVENT, handleCollision) 

      

Dentro de la función "add" se crea un escuchador de eventos - "ecs.physics.COLLISION_START_EVENT". Este componente se añade al objeto "collision" y cuando se detecta una colisión llama a la función "handleCollision". No necesitamos hacer un seguimiento de qué objeto está colisionando porque las bolas son lo único que puede colisionar en este proyecto. 


La colisión entre la bola y el alfiler se maneja exactamente de la misma manera, pero en lugar de crear un oyente de eventos para el objeto "colisión", comprobamos el objeto "alfiler" del que ya tenemos una referencia, ya que estamos controlando su posición con la rotación de la cabeza del usuario. Llama a la función "hitPin" que es donde se contiene toda la lógica para el escenario final del juego. 

         
world.events.addListener(pin, ecs.physics.COLLISION_START_EVENT, hitPin) 

      

 

Personalice  

Todos estos valores pueden ser alterados seleccionando el "GameController" en la jerarquía de la escena y luego navegando hacia el panel de inspección y bajo el script "GameController" los encontrarás. 
Velocidad del Pin - Esta es la velocidad a la que se mueve el pin (el jugador principal). Cuanto mayor sea este valor, más rápido se moverá. No te preocupes si esto hace que el pin salga de la pantalla ya que estamos fijando su posición. 
Initial Spawn Frequency - Esta es la cantidad de tiempo (en milisegundos) que tarda en aparecer una nueva bola después de la anterior. Recuerde que este valor se reduce cada vez que se genera una bola, por lo que es una buena idea mantenerlo bastante alto al principio del juego para que no sea demasiado difícil demasiado pronto.

Multiplicador de Frecuencia de Generación - Este es el valor que se resta de la Frecuencia de Generación Inicial. Puedes pensar en este valor como la velocidad a la que el juego se vuelve más difícil. Con su valor por defecto actualmente en 5, significa que cada vez que se genera una bola, el tiempo en el que se genera la siguiente se reduce en 5 milisegundos. Cuanto mayor sea este número, más rápido se reducirá el tiempo de aparición y se alcanzará la máxima frecuencia de aparición de bolas. De nuevo, es una buena idea mantener este número bastante bajo, no quieres que el juego se vuelva demasiado difícil demasiado rápido. 


Frecuencia mínima de aparición - Este es el valor mínimo que deseas que tenga la frecuencia de aparición. Por defecto está ajustado a 500 milisegundos, lo que significa que el retardo de aparición entre cada bola nunca será inferior a medio segundo. Puedes pensar en este valor como la dificultad máxima, si quieres realmente poner a prueba al jugador podrías hacer este valor más bajo para que las bolas aparezcan a un ritmo más rápido. 


Velocidad inicial de la bola - Este es el tiempo inicial que tarda la bola en viajar hacia el jugador. Recuerda que este valor se reduce a lo largo del juego para que la velocidad del balón aumente. El valor por defecto significa que al comienzo de la experiencia la pelota tardará aproximadamente 3 segundos en llegar al jugador (dependiendo de la variación que se añada). 


Variación de la velocidad de la bola - Este es el valor aleatorio que se aplica a la velocidad de la bola para que todas las bolas de la pantalla no se muevan a la misma velocidad. Con el valor por defecto en 200, esto significa que la velocidad de la bola puede ser entre 0 y 200 milisegundos más rápida. Cuanto mayor sea este valor, mayor será la variación en la velocidad de la bola, cuanto menor sea el número, más consistente será la velocidad de la bola. 


Valor de aumento de la velocidad de la bola - Este número se resta de la velocidad de la bola cada vez que se genera una bola. Esto significa que cada vez que se genera una bola, la velocidad aumenta en 10 milisegundos. Cuanto mayor sea este número, más rápido alcanzarán las bolas la velocidad máxima. 


Velocidad máxima de las bolas: es la velocidad máxima a la que pueden moverse las bolas. En este momento está fijado en 2000 milisegundos, lo que significa que, añadiendo una posible variación de 200 milisegundos, el tiempo más corto que puede tardar una pelota en llegar al jugador es de 1800 milisegundos. Si reduces este número significa que la velocidad máxima será mayor. 


Moving Forward 
La idea de esta plantilla era proporcionar la mecánica básica de un juego. Lo mejor de hacer juegos es que puedes contar la historia que quieras. En lugar de limitarse a ajustar los valores de este juego, intente cambiar la historia intercambiando los activos. En este momento eres un boliche que intenta esquivar bolas de bolos en una pista de bolos, pero podrías ser un esquiador que esquiva enormes bolas de nieve mientras desciende por una montaña, o una nave espacial que tiene que evitar asteroides. Tal vez si cambias a bolas de nieve podrías alterar la rotación para que giren hacia delante y no hacia los lados, o si te gusta la idea de las naves espaciales y los asteroides, podrías aleatorizar el tamaño de los asteroides cuando se generan para que no sean todos del mismo tamaño. Hay muchas formas de modificar este juego para adaptarlo a tu narrativa, y no sólo cambiando el aspecto general. Si lo piensas bien, las posibilidades son infinitas…

 

Your cool escaped html goes here.