Videojuegos

A Contrarreloj: Supera el Desafío del Tiempo en 3D, Plataformas, Precisión y Presión.

“A Contrarreloj” es un videojuego de plataformas 3D, desarrollado en Unity como proyecto final del curso de Renderización en 3D y Videojuegos. Este título propone una experiencia dinámica y desafiante, donde el jugador debe superar una serie de tres niveles diseñados con cuidado para ofrecer un equilibrio entre accesibilidad y dificultad progresiva. La esencia del juego radica en la combinación de la acción contra el tiempo y la exploración de escenarios flotantes, con una estética futurista que refuerza la atmósfera de urgencia. Los jugadores deberán recolectar objetos específicos en cada nivel antes de que el temporizador se agote, promoviendo la toma rápida de decisiones y la precisión en el control del personaje. En caso de no cumplir con el objetivo a tiempo, el juego presenta una pantalla de Game Over, invitando al jugador a intentar nuevamente y perfeccionar su desempeño.

Los protagonistas de “A Contrarreloj” son dos personajes seleccionables, representando a los autores del proyecto. Esta característica de selección de personajes añade un nivel extra de personalización y conexión con el juego, permitiendo a los usuarios elegir entre dos avatares únicos, cada uno con un diseño visual que refleja la identidad de los creadores. La mecánica permite que el jugador se sumerja en la experiencia desde el menú inicial, donde la elección se guarda mediante un script especializado para asegurar que el personaje seleccionado se mantenga activo a lo largo de la partida. La diversidad de personajes no solo aporta variedad visual, sino que también contribuye a la narrativa del juego, haciendo que la experiencia sea más atractiva y personalizada.

Lógica y mecánica de juego

La jugabilidad de “A Contrarreloj” está basada en una lógica sencilla pero efectiva, enfocada en la interacción rápida y la gestión del tiempo. Desde el menú inicial, el jugador tiene la posibilidad de seleccionar entre dos personajes diferentes, implementando un script que guarda esta elección para que se mantenga consistente a lo largo de toda la partida, evitando así la necesidad de seleccionar nuevamente en cada nivel. Esta selección inicial añade un toque de personalización que mejora la experiencia de usuario.

El objetivo principal en cada nivel es claro y directo: recolectar los objetos, que pueden ser monedas o hongos, antes de que el temporizador llegue a cero. Esta mecánica genera una sensación constante de urgencia y presión, obligando al jugador a explorar el entorno de manera eficiente mientras evita perder tiempo en movimientos innecesarios. Si el jugador logra recolectar el objeto dentro del tiempo establecido, automáticamente avanza al siguiente nivel, asegurando un flujo de juego continuo. En caso contrario, el juego carga una pantalla de Game Over, desde donde el usuario puede decidir reiniciar o regresar al menú principal para intentarlo nuevamente.

La progresión del juego está estructurada en tres niveles, cada uno aumentando ligeramente la dificultad y el desafío temporal. Al completar exitosamente los tres niveles dentro del tiempo límite, se despliega una pantalla de victoria que reconoce el logro del jugador, ofreciendo una sensación de culminación y satisfacción.

Implementación técnica

1. Menú principal

El desarrollo del menú principal se realiza en una escena independiente, dedicada exclusivamente a la navegación inicial y selección de opciones. Este menú contiene elementos clave como el título del juego, botones con imágenes icónicas y claras, importadas desde icon-icons.com, que permiten a los jugadores iniciar la partida, seleccionar su personaje preferido y acceder a una sección donde se muestran los créditos del proyecto. Cada botón está cuidadosamente diseñado para ofrecer una experiencia intuitiva y visualmente atractiva, facilitando la interacción y mejorando la usabilidad.

Además, se incorpora un script de navegación entre escenas que controla la transición fluida desde el menú principal hacia el juego o hacia la pantalla de créditos. Este sistema garantiza que las acciones del usuario se traduzcan en cambios inmediatos dentro del flujo del juego, contribuyendo a una experiencia de usuario coherente y sin interrupciones.

Figura 1. Menú principal

Figura 2. Créditos del videojuego

Muestra una interfaz centrada en el jugador tres botones interactivos y diseño limpio. Cada botón tiene una imagen/icono con funcionalidad ligada a scripts de navegación (SceneManager.LoadScene()).

2. Importación y selección de personajes

En esta etapa del desarrollo, se realiza la importación de los dos personajes principales, que representan a los autores del proyecto, directamente al entorno de Unity. Cada modelo 3D fue cuidadosamente preparado para asegurar compatibilidad con las mecánicas del juego y optimización en cuanto a rendimiento y calidad visual. Estos personajes sirven como avatares seleccionables para el jugador, brindando una conexión más personal y única con la experiencia de juego.

Para permitir la elección entre ambos personajes, se implementa un script de selección que se ejecuta en el menú principal. Este script no solo permite seleccionar visualmente al avatar deseado, sino que también guarda esta elección mediante un sistema de almacenamiento temporal o persistente, de manera que al iniciar la partida se instancie automáticamente el personaje previamente seleccionado en la plataforma de juego. Esta funcionalidad garantiza que la elección del jugador se mantenga consistente durante toda la sesión, mejorando la experiencia y evitando que se tenga que elegir nuevamente en cada nivel. Además, el script está diseñado para ser fácilmente extensible en caso de que se deseen agregar más personajes en futuras actualizaciones.

Figura 3. Importación personaje 1

Figura 4. Importación personaje 2

Para la implementación de los personajes validar el siguiente articulo : Principios y Aplicaciones de la Animación 3D en Blender :Ambiente, Renderizado e Iluminación.

Para la realización de las animaciónes 3D con Blender y Mixamo revisar el siguiente articulo.

3. Cámara y movimiento

Para lograr una experiencia de juego fluida y dinámica, se implementa una Cinemachine FreeLook Camera que sigue constantemente al personaje seleccionado por el jugador. Esta cámara avanzada permite una visión en tercera persona con rotación libre, lo que ofrece un control intuitivo y natural sobre el entorno del juego, ayudando al jugador a orientarse fácilmente en los escenarios flotantes y complejos.

El control del personaje se realiza a través del teclado, usando las teclas WASD para el movimiento en las direcciones básicas: adelante, atrás, izquierda y derecha. Mientras tanto, la cámara responde a la rotación del mouse, permitiendo al jugador ajustar el ángulo de visión libremente alrededor del personaje, lo que facilita la exploración y la precisión al momento de recolectar objetos o evitar obstáculos.

Para gestionar estos controles, se utiliza el componente ThirdPersonController, parte del paquete Starter Assets de Unity, que proporciona una base robusta y personalizable para el movimiento y la interacción del personaje con el entorno. Este controlador calcula el movimiento relativo a la dirección actual de la cámara, permitiendo que el personaje rote suavemente hacia la dirección en la que se desplaza, creando una sensación de control realista y una experiencia de juego más inmersiva.

Figura 5. Ajuste de cámara

La cámara está vinculada al objeto PlayerCameraRoot. El movimiento se calcula relativo a la dirección de la cámara (cameraForward, cameraRight). El personaje rota hacia la dirección de movimiento.

4. Objetos recolectables

En el juego se emplean dos tipos principales de objetos recolectables que el jugador debe encontrar para avanzar en cada nivel: el hongo y la moneda. El modelo del hongo fue importado directamente como un asset local, diseñado por los autores. Por otro lado, la moneda fue descargada desde la plataforma Sketchfab, una fuente popular de modelos 3D gratuitos y de alta calidad, lo que permitió incorporar un objeto visualmente atractivo y familiar para los jugadores.

Para garantizar que la interacción con estos objetos sea fluida y precisa, cada uno cuenta con componentes de colisión llamados colliders configurados como “isTrigger”. Esto significa que, en lugar de bloquear físicamente al personaje, detectan cuando el jugador los atraviesa, lo que activa una serie de eventos dentro del juego.

Estos eventos son gestionados por dos scripts clave: el script ObjectCollect que se encarga de detectar la colisión entre el jugador y el objeto recolectable, permitiendo así que el ítem sea recogido y desaparezca de la escena; y el script Collection, que mantiene la cuenta de todos los objetos recolectados durante el nivel y actualiza la interfaz de usuario (UI) para reflejar el progreso del jugador en tiempo real. Este sistema asegura una experiencia interactiva y motivadora, alentando al jugador a buscar y recolectar todos los objetos antes de que se acabe el tiempo.

Figura 6. Importación objeto 1 recolectable

Figura 7. Importación objeto 2 recolectable

Modelos 3D estáticos con componentes MeshRenderer, Collider(isTrigger) y scripts que detectan colisión con Player mediante OnTriggerEnter.

Para aprender a modelar estas figuras en Blender 3D visitar Modelado y texturizado en Blender escena en 3D.

5. Temporizador y avance de nivel

Cada nivel dentro de “A Contrarreloj” comienza con un cronómetro que marca el tiempo límite que el jugador tiene para completar el objetivo: recolectar el objeto designado, ya sea una moneda o un hongo. Este temporizador no solo añade una sensación constante de urgencia y presión, sino que también es un elemento fundamental para la mecánica central del juego, reforzando el desafío y la necesidad de rapidez y precisión en la ejecución de las acciones.

La lógica de avance de nivel está programada de manera sencilla pero efectiva. Si el jugador logra recoger el objeto antes de que el tiempo se agote, el juego automáticamente carga la siguiente escena usando la función SceneManager.LoadScene(nivel + 1), permitiendo así la progresión directa al siguiente nivel. Esta transición es inmediata y mantiene el ritmo dinámico del juego, motivando al jugador a continuar y superar el desafío.

En contraste, si el jugador no consigue recolectar el objeto dentro del límite de tiempo establecido, el juego termina la partida actual y carga la escena de Game Over. Esta escena especial informa al jugador que ha perdido y generalmente ofrece opciones para reiniciar el nivel o regresar al menú principal, incentivando así la repetición y el perfeccionamiento de las habilidades para superar los niveles en futuras partidas.

6. Pantalla Game Over y final

La pantalla de Game Over se presenta en una escena independiente que se activa automáticamente cuando el jugador no logra completar el objetivo dentro del tiempo establecido. Esta escena contiene un mensaje claro y visible que informa al jugador que ha perdido la partida, generando una pausa necesaria para que el usuario asimile el resultado. Además, incluye un sistema de reinicio automático que, después de unos segundos, redirige al jugador de vuelta al menú principal, facilitando que pueda intentar nuevamente sin interrupciones o complicaciones.

Por otro lado, el juego también contempla un final positivo para los jugadores que logren superar todos los retos. Al completar exitosamente los tres niveles dentro del tiempo límite, se muestra un mensaje especial de victoria. Esta pantalla de victoria ofrece una sensación de logro y recompensa, motivando al jugador y cerrando la experiencia de juego de manera satisfactoria. La transición a esta escena también es automática y está diseñada para destacar el éxito obtenido, reforzando la narrativa del juego y el sentido de progreso.

Figura 8. Pantalla de Game Over

Figura 9. Pantalla de victoria por nivel

Figura 10. Pantalla de victoria total

7. Scripts integrados:

  • ObjectCollect.cs Detecta colisión y recolecta objeto
using UnityEngine;

public class ObjectCollect : MonoBehaviour
{
    private Collection playerCollection;

    void Start()
    {
        GameObject player = GameObject.FindGameObjectWithTag("Player");

        if (player != null)
        {
            playerCollection = player.GetComponent<Collection>();
        }
    }

    private void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Player") && playerCollection != null)
        {
            playerCollection.Quantity += 1;
            playerCollection.UpdateCounter();
            Debug.Log("Moneda recolectada por el jugador.");
            Destroy(gameObject);
        }
    }
}
  • Collection.cs Muestra contador y valida si se recolectaron todos los objetos
using UnityEngine;
using TMPro;
using UnityEngine.SceneManagement;

public class Collection : MonoBehaviour
{
    public int Quantity = 0;
    public int totalObjects = 3;
    public TextMeshProUGUI textCount;

    public int nivelActual = 1;

    void Start()
    {
        Quantity = 0;
        UpdateCounter();
    }

    public void UpdateCounter()
    {
        if (textCount != null)
        {
            int remaining = totalObjects - Quantity;
            textCount.text = "Objetos restantes: " + remaining;

            if (remaining <= 0)
            {
                textCount.text = "﹗odos recolectados!";

                string personaje = PlayerPrefs.GetString("SkinSeleccionada", "Daniel");
                string mensaje = "";
                if (nivelActual+1 >= 3)
                {
                    mensaje = $";elicidades {personaje}, haz completado el juego!";
                }
                else
                {
                    mensaje = $";elicidades {personaje}, nivel completado!";
                }
                string siguienteEscena = "GameL" + (nivelActual + 1);

                PlayerPrefs.SetString("MensajeNivel", mensaje);
                PlayerPrefs.SetString("JugadorActivo", personaje);
                PlayerPrefs.SetInt("NivelActual", nivelActual);
                PlayerPrefs.SetString("SiguienteEscena", siguienteEscena);

                SceneManager.LoadScene("VictoryScreen");
            }
        }
    }
}
  • GameOverManager.cs Muestra la pantalla de derrota
using UnityEngine;
using UnityEngine.SceneManagement;

public class GameOverManager : MonoBehaviour
{
    public float delay = 5f;
    public string sceneToLoad = "MenuPrincipal";

    void Start()
    {
        Invoke("LoadNextScene", delay);
    }

    void LoadNextScene()
    {
        SceneManager.LoadScene(sceneToLoad);
    }
}
  • VictoryScreenmanager.cs Cambia de escena según eventos del jugador
using UnityEngine;
using TMPro;
using UnityEngine.SceneManagement;
using static Cinemachine.DocumentationSortingAttribute;

public class VictoryScreenManager : MonoBehaviour
{
    [Header("Referencias UI")]
    public TextMeshProUGUI mensajeTMP;

    [Header("Configuración de avance automático")]
    public float delay = 5f;

    private int nivelActual;

    void Start()
    {
        string mensaje = PlayerPrefs.GetString("MensajeNivel", "¡Felicidades, nivel completado!");
        string jugador = PlayerPrefs.GetString("JugadorActivo", "Jugador");
        nivelActual = PlayerPrefs.GetInt("NivelActual", 1);

        if (mensajeTMP != null)
            mensajeTMP.text = mensaje;

        Invoke(nameof(InvocarIrASiguienteNivel), delay);
    }

    private void InvocarIrASiguienteNivel()
    {
        IrASiguienteNivel(nivelActual);
    }

    public void IrASiguienteNivel(int level)
    {
        string nameLevel = "";
        if (level >= 3) {
            nameLevel = $"MenuPrincipal";
        }
        else
        {
            nameLevel = $"GameL{level}";
        }
        Debug.Log(nameLevel);
        string siguienteEscena = PlayerPrefs.GetString("SiguienteEscena", nameLevel);
        SceneManager.LoadScene(siguienteEscena);
    }
}
  • MainMenu.cs Selecciona el personaje inicial y lleva a la escena de créditos
using UnityEngine;
using UnityEngine.SceneManagement;

public class MainMenu : MonoBehaviour
{
    public void JugarDaniel()
    {
        PlayerPrefs.SetString("SkinSeleccionada", "Daniel");
        SceneManager.LoadScene("GameL1");
    }

    public void JugarSebastian()
    {
        PlayerPrefs.SetString("SkinSeleccionada", "Sebastian");
        SceneManager.LoadScene("GameL1");
    }

    public void Creditos()
    {
        SceneManager.LoadScene("Creditos");
    }
}
  • Timelimitreturn.cs Al terminar el tiempo, lleva a la escena de game over
using UnityEngine;
using TMPro;
using UnityEngine.SceneManagement;

public class TimeLimitReturn : MonoBehaviour
{
    [SerializeField] private float timeLimit = 30f;
    private float currentTime;

    [SerializeField] private TextMeshProUGUI timerText;

    private void Start()
    {
        currentTime = timeLimit;
        UpdateTimeDisplay();
    }

    private void Update()
    {
        currentTime -= Time.deltaTime;
        //Debug.Log(gameObject.name + " - Update: Tiempo restante: " + currentTime);
        UpdateTimeDisplay();

        if (currentTime <= 0f)
        {
            Debug.Log(gameObject.name + " - Update: ¡Tiempo agotado! Fin del juego.");
            GamerOver();
        }
    }

    public void ResetTimer()
    {
        currentTime = timeLimit;
        UpdateTimeDisplay();
    }

    public void SetTimeLimit(float newTimeLimit)
    {
        timeLimit = newTimeLimit;
        currentTime = Mathf.Min(currentTime, timeLimit);
        UpdateTimeDisplay();
    }

    private void UpdateTimeDisplay()
    {
        if (timerText != null)
        {
            timerText.text = "Tiempo: " + Mathf.CeilToInt(currentTime).ToString();
        }
        else
        {
            Debug.LogWarning("No se ha asignado el componente TextMeshProUGUI al script TimeLimitReturn en " + gameObject.name);
        }
    }

    private void GamerOver()
    {
        SceneManager.LoadScene("GamerOver");
    }
}
  • Playerselector.cs Verifica que jugador seleccionado
using UnityEngine;

public class PlayerSelector : MonoBehaviour
{
    public GameObject playerDaniel;
    public GameObject playerSebastian;

    public GameObject camDaniel;
    public GameObject camSebastian;

    void Start()
    {
        string skin = PlayerPrefs.GetString("SkinSeleccionada");

        playerDaniel.SetActive(false);
        playerSebastian.SetActive(false);
        camDaniel.SetActive(false);
        camSebastian.SetActive(false);

        if (skin == "Sebastian")
        {
            playerSebastian.SetActive(true);
            camSebastian.SetActive(true);
        }
        else
        {
            playerDaniel.SetActive(true);
            camDaniel.SetActive(true);
        }
    }
}
  • FallDameg.cs Calcula daño de caida
using UnityEngine;
using UnityEngine.SceneManagement;

public class FallDamageAndReset : MonoBehaviour
{
    [Header("Configuración de Caída")]
    public float fallThreshold = 5f;  // Altura mínima para considerar una caída peligrosa
    public float damageMultiplier = 10f;  // Multiplicador de daño basado en la altura

    private Vector3 initialPosition;  // Posición inicial para reiniciar
    private float startFallY;  // Y de inicio cuando el personaje empieza a caer
    private bool isFalling = false;  // Si el personaje está cayendo

    private CharacterController characterController;
    private Rigidbody rigidBody;

    [Header("Sistema de Salud")]
    public float maxHealth = 100f;  // Salud máxima
    private float currentHealth;  // Salud actual

    private void Awake()
    {
        // Inicializa los componentes
        characterController = GetComponent<CharacterController>();
        rigidBody = GetComponent<Rigidbody>();

        // Guarda la posición inicial del jugador
        initialPosition = transform.position;

        // Inicializa la salud actual al valor máximo
        currentHealth = maxHealth;
    }

    private void Update()
    {
        // Lógica de caída y reinicio de posición
        HandleFallDamage();
    }

    private void HandleFallDamage()
    {
        // Si el personaje está cayendo y no está en el suelo
        if (!characterController.isGrounded && !isFalling)
        {
            isFalling = true;
            startFallY = transform.position.y;  // Guardar la altura inicial
            Debug.Log("[Caída] Inicia caída desde altura: " + startFallY);
        }

        // Si el personaje toca el suelo y estaba cayendo
        if (characterController.isGrounded && isFalling)
        {
            isFalling = false;
            float fallDistance = startFallY - transform.position.y;  // Calcula la distancia de caída

            // Si la distancia de la caída supera el umbral, se reinicia la posición
            if (fallDistance > fallThreshold)
            {
                // Aplica daño basado en la distancia de la caída
                float damage = fallDistance * damageMultiplier;
                TakeDamage(damage);

                // Imprime el log de vida
                Debug.Log("[Impacto] Caída desde " + fallDistance.ToString("F2") + " metros. Daño recibido: " + damage + ". Vida actual: " + currentHealth);

                // Reinicia la posición
                //Debug.Log("[Impacto] Caída peligrosa, reiniciando la posición...");
                //ResetPosition();  // Llama al método que reinicia la posición

                GamerOver();
            }
            else
            {
                Debug.Log("[Impacto] Caída de " + fallDistance.ToString("F2") + " metros. Sin reinicio.");
            }
        }
    }

    // Método para aplicar daño al jugador
    private void TakeDamage(float damage)
    {
        currentHealth -= damage;
        currentHealth = Mathf.Max(currentHealth, 0);  // Asegura que la salud no sea negativa
    }

    // Método para reiniciar la posición y rotación del jugador
    private void ResetPosition()
    {
        // Asegúrate de desactivar el CharacterController para evitar problemas de colisión durante el reinicio
        if (characterController != null)
        {
            characterController.enabled = false;
        }

        // Reinicia la posición y la rotación
        transform.position = initialPosition;
        transform.rotation = Quaternion.identity;  // Puedes establecer la rotación deseada

        // Reactiva el CharacterController después del reinicio
        if (characterController != null)
        {
            characterController.enabled = true;
        }

        Debug.Log("[Reinicio] Posición reiniciada a: " + initialPosition);
    }

    private void GamerOver()
    {
        SceneManager.LoadScene("GamerOver");
    }
}

8. Estética y narrativa:

El estilo visual es estándar de unity. El juego transcurre en plataformas suspendidas en el vacío, con una atmósfera de urgencia constante impuesta por el tiempo.

Narrativa:

“En un universo fracturado por el tiempo, dos viajeros estelares deben recolectar objetos valiosos antes de que todo colapse.”

9. Video:

10. Conclusión:

A Contrarreloj demuestra cómo un concepto simple, recolectar un objeto a contrarreloj, puede dar lugar a un juego completo con selección de personajes, progresión, diseño visual coherente, e integración técnica sólida. Es un proyecto ideal para aprender diseño de niveles, programación orientada a eventos y manejo de escenas en Unity.

11. Créditos:

Autores: Sebastián Alberto Garcia Galeano y Daniel Hernández Cardenas

Editor: Master- Ingeniero Carlos Iván Pinzón

Código : UCMV-9

Universidad : Universidad Central

12. Referencias

Unity Technologies. (2024). Unity Manual: Unity 2022.3 LTS Documentation. https://docs.unity3d.com/2022.3/Documentation/Manual/index.html

Unity Technologies. (2024). Starter Assets – Third Person Character Controller [Paquete de Unity]. Unity Asset Store. https://assetstore.unity.com/packages/essentials/starter-assets-third-person-character-controller-196526

Unity Technologies. (2024). Cinemachine Documentation. https://docs.unity3d.com/Packages/com.unity.cinemachine@2.8/manual/index.html

Unity Technologies. (2024). TextMeshPro Documentation. https://docs.unity3d.com/Packages/com.unity.textmeshpro@3.0/manual/index.html

Sketchfab. (2024). Free 3D Models. https://sketchfab.com

Ready Player Me. (2024). Crea tu avatar 3D personalizado. https://readyplayer.me/es