Daños y resistencias

En Sticks & Stones hemos optado por un sistema de daños y resistencias, lo que significa que un arma que puede ser poderosa contra un enemigo pero débil contra otro. Lo mismo ocurre con los enemigos, que pueden ser vulnerables o resistentes a los diferentes tipos de daño.

Tipos de daño

Los objetos que causan daño, como las armas, definen uno o varios tipos de daño.

  • Perforante
  • Aplastante
  • Cortante
  • Hendidor
  • Fuego
  • Disparo
  • Virtual

En nuestro código, utilizamos un enum para catalogarlos.

public enum DamageType
{
    Piercing,
    Crushing,
    Slashing,
    Cleaving,
    Fire,
    Shoot,
    Virtual
}

Un arma podría combinar varios tipos de daño, por ejemplo un garrote con pinchos combinaría daño aplastante y perforante. Además para cada tipo de daño se define la cantidad de daño, pero eso lo veremos más adelante.

Armas

Las armas tienen varias propiedades en común. En nuestro código las agrupamos con 3 interfaces: IInventariable, IEquippable y IDamageDealer.

public abstract class Weapon : Wearable,
    IInventariable,
    IEquippable,
    IDamageDealer
{
    ...
}

Inventariable: indica que las armas pueden colocarse en un inventario, como por ejemplo la mochila.

public interface IInventariable
{
    InventariableType InventariableType { get; }
 
    float Weight { get; }
 
    bool Stackable { get; }
 
    short InventoryValue { get; }
}

Equipable: indica que las armas pueden equiparse en el cuerpo de un personaje.

public interface IEquippable
{
    List<BodyPart> AllowedEquippableAreas { get; }
 
    bool TwoHandsUse { get; }
}

Dañino: cualquier objeto que puede causar daños.

/// <summary>
/// Cualquier cosa susceptible de causar daño.
/// </summary>
public interface IDamageDealer
{
    /// <summary>
    /// Objeto con la configuración e información sobre el daño.
    /// </summary>
    DamageSource DamageSource { get; }
 
    /// <summary>
    /// Indica si puede ser lanzado o arrojado.
    /// </summary>
    bool CanBeHurled { get; }
}

La parte más interesante es IDamageDealer que es la interface que indica que un objeto puede causar daño mediante la propiedad DamageSource, que habla de la cantidad y tipos de daño.

DamageSource básicamente contiene un diccionario donde la clave es el tipo de daño y el valor es un entero que indica la cantidad de daño.

public class DamageSource
{
    public readonly Dictionary<DamageTypeint> DamageByType;
 
    #region Constructor
    public DamageSource(Dictionary<DamageTypeint> types)
    {
        DamageByType = types;
    }
    #endregion
}

Inicialmente DamageSource tenía propiedades adicionales relacionadas con efectos negativos (debuff) que se aplicaban al atacar. Pero es una mecánica que por ahora vamos a omitir del videojuego.

Estos son ejemplos de objetos dañinos (armas principalmente) que tenemos configurados en Sticks & Stones.

stone-axe-icon  Hacha de piedra
Hendidor = 13
wooden-bow-icon  Arco de madera
Disparo = 5
wooden-club-icon  Mazo de madera
Aplastante = 25
bone-knife  Cuchillo de hueso
Perforante = 4
Cortante = 2
*Daño potencial = 6
wooden-arrows  Flecha de madera
Perforante = 8

En el caso de las armas de proyectil, cómo los arcos, el daño que acaban causando depende también del proyectil que lanzan. Simplificando, se puede decir que un arma de proyectil causa un daño equivalente al suyo más el del proyectil que lanza. Según los ejemplos anteriores, un arco de madera que lanza flechas de madera, tendría un daño potencial de 13 (5 de disparo y 8 de perforante).

Resistencia y vulnerabilidad al daño

La resistencia o vulnerabilidad al daño es una propiedad que se configura en los personajes, pero también afecta al entorno, como por ejemplo un árbol.

Similar a cómo ocurre en las armas, en el código utilizamos una interface que indica si un objeto puede recibir daño.

Esta interface se llama IVulnerable.

public interface IVulnerable
    {
        uint BaseHitPoints { get; }
 
        int CurrentHitPoints { getset; }
 
        Dictionary<DamageTypeint> ResistencesByDamageType { get; }
 
        DamageInfo GettingDamage(
            CharacterBase owner,
            IDamageDealer damageDealer,
            ProjectileWeapon projectileWeapon,
            float damageMultiplier,
            AttackProperties attackProperties);
    }

La propiedad interesante es ResistencesByDamageType, que consiste en un diccionario donde la clave es el tipo de daño y el valor es el porcentaje de resistencia o vulnerabilidad a ese tipo de daño. Un valor negativo indica que es vulnerable y un valor positivo que es resistente. La omisión de un tipo de daño indica que no le afectará ni positivamente ni negativamente, es decir, un objeto que no tiene configuradas resistencias o vulnerabilidades recibirá el daño potencial del arma (13 siguiendo el ejemplo del arco y flecha u 6 del cuchillo de hueso).

Como dije, no solo los personajes son vulnerables al daño, hay objetos del entorno que también lo son, como por ejemplo los árboles.

forest-tree
Hendidor = -50%
Aplastante = 99%
Perforante = 80%
Disparo = 90%
Cortante = 80%
Fuego = -80%

Se puede observar que los árboles son especialmente vulnerables al daño hendidor (el que producen las hachas) y al fuego, y muy resistentes al resto de daños.

Los principales enemigos de Sticks & Stones, la míticas Holo-Balls, configuran sus propias resistencias y vulnerabilidades. La finalidad es crear estrategias y elecciones de armas específicas para cada enfrentamiento. No queremos que exista una sola arma poderosa, queremos que cualquier arma pueda ser útil.

En definitiva, en Sticks & Stones hay que experimentar para conocer y aprender sobre los enemigos y las armas.

¿Crees que este nivel de profundidad en el sistema de daños le sienta bien al videojuego?

Generación Procedural o cómo generar escenarios sexys

Querido lector. Si tienes curiosidad de ver cómo las matemáticas pueden ayudar a generar mapas aleatorios tan hermosos como el siguiente, ¡este es tu articulo! Agárrate que vienen curvas.

central-zone-2

En cada partida de Sticks & Stones, los mapas son diferentes, para que el jugador no pueda memorizarlos y haga uso de la habilidad y su experiencia para superarlos. Además, es un juego difícil, donde se repite una y otra vez lo mismo. Por lo tanto, la variedad es una característica esencial en la generación de mapas.

Crear pantallas lo más mutables, bonitas y orgánicas posibles, es una muy buena manera de conseguir esta variedad ¿Pero cómo se puede lograr esto? Con algo llamado generación procedural:

En computación generación procedural es el método de creación de datos con algoritmos en lugar de forma manual

Es decir, que para generar las pantallas es necesario recurrir a las matemáticas y a sus fórmulas. ¡Ah! Las matemáticas… yo siempre me preguntaba de pequeño que para qué diablos sirven las matemáticas en la vida… 😛

Volviendo al tema, para nuestra generación de mapas, hemos usado un algoritmo llamado Perlin Noise. Para entender su funcionamiento, vamos a imaginarnos una onda de radio. Y que mediante 3 parámetros (frecuencia, amplitud y octavas), podemos llegar a cambiar su apariencia. En el siguiente gráfico hay algunos ejemplos visuales:

perlinnoise

  • La frecuencia determina el número de ondas
  • La amplitud determina la altura de las ondas
  • Las octavas determina la suavidad de las ondas

Una vez explicado esto, vayamos a cómo implementar esta onda a nuestra generación de mapas. Para ello, primero vamos a crear celdas con texturas totalmente planas. En nuestro ejemplo vamos a usar un mapa de 3×3.

1

A continuación se genera un Perlin Noise de 3 dimensiones XYZ para cada una de las celdas. Donde la XY son las coordenadas del píxel, y la Z es la ALTURA.

En la siguiente animación tenéis 3 muestras de 3 generaciones distintas de Perlin Noise. Fijaos en lo diferentes que pueden llegar a ser unos mapas de otros. Para facilitar su visualización, se ha establecido una franja de color distinto a cada una de las ALTURAs.

5-7-6

Ahora solo falta establecer una relación entre ALTURATEXTURA. En este caso usamos la misma que utilizan los mapas topográficos,

world_map

Para nuestro ejemplo vamos a usar la siguiente relación:

[∞ .. 300] = Hierba
[0 .. -200] = Tierra
[-500 .. -700] = Tierra con piedras
[-900 .. ∞] = Agua (transparencia del 10%) 

Y estas son las texturas que vamos a usar para la generación:

texturas

Si metemos en una coctelera el Perlin Noise, las franjas de terreno y las texturas, obtenemos este resultado:

5-7-6

Si este proceso lo repetimos, variando únicamente uno de los valores base como puede ser la frecuencia, la amplitud o las octavas, el resultado puede llegar a cambiar bastante.

Amplitud = 1 (menos diferencia entre altura mínima y máxima)

5-1-6

5-1-6

Frecuencia = 1 (espacio entre cambios de altura más espaciados)

1-7-6

1-7-6

Octavas = 1 (transición entre alturas mucho más suavizada)

5-7-1

5-7-1

Mediante este sencillo proceso de configurar un Perlin Noise, estableciendo las franjas de terreno y texturas,  podemos obtener resultados como estos:

magma

meadowsnowedswampdesertwater

Llegados a este punto, lo único que falta por hacer es colocar objetos en el terreno, y para ello volveremos a usar la ALTURA y vincular en este caso OBJETOS (usando un % de aparición).

Veamos un ejemplo:

[∞ .. 500] = Árboles
[500..0] = Plantas, flores
[0 .. -200] = Rocas
[-500 .. -700] = Piedras, árboles secos
[-900 .. ∞] = Juncos

¡¡Y aquí tenéis el resultado!! forest

¿No es fascinante lo natural que parecen los objetos una vez colocados?

La ventaja de configurar todo el escenario en base al concepto de la ALTURA, es que si se desplaza la base de esta ALTURA, el escenario cambia:

forest

Si ahora le aplicamos un efecto de desenfoque en la parte superior e inferior, además de unos bonitos rayos de luz, obtenemos el aspecto que tiene en la actualidad el juego:

central-zone-2

Si te ha gustado el artículo: comenta y comparte.

¡Gracias!

Diseño y programación – Supervivencia y consumibles

En este post contaré cómo hemos diseñado y programado el sistema de supervivencia y consumibles, que afectan directamente a la mecánica de supervivencia.

El post se divide en 3 apartados:

  1. Supervivencia: la mecánica relacionada con los consumibles.
  2. Consumibles: los objetos que representan el concepto de consumibles.
  3. Colores: los colores que identifican los diferentes indicadores de supervivencia.

 

Supervivencia

Es una de las mecánicas del videojuego que sirve para hacer más difícil el reto al jugador. Durante la partida, sufrirá de hambre, deshidratación y sueño. Para lidiar con ello, tendrá que buscarse la vida, sobre todo utilizando el entorno natural en el que se encuentra. Podrá cazar animales, beber agua, dormir, recoger frutos silvestres, fruta, setas y otras tantas cosas.

Por tanto, la supervivencia mide tres factores:

  • Hambre hunger-icon
  • Deshidratación dehydration-icon
  • Sueño sleep-icon

Cada uno de estos factores tiene su indicador y se dividen en varias propiedades, que permite tener el control del estado de supervivencia de los personajes.

survival-status

Propiedades de supervivencia

La supervivencia está vinculada a los personajes orgánicos, como los concursantes, de manera que cada personaje define dos métricas importantes para cada uno de los factores de supervivencia (hambre, deshidratación y sueño):

  • El tiempo que tarda en desmayarse
  • La cantidad de puntos que necesita para quedar saciado

El código que verás a continuación, es parte del script de un personaje orgánico, donde definimos las propiedades de supervivencia.

  • Tiempo desmayo por hambre: indica el tiempo para desmayarse por hambre o desnutrición, expresado en segundos.
  • Tiempo desmayo por deshidratación: indica el tiempo para desmayarse por deshidratación, expresado en segundos.
  • Tiempo desmayo por sueño: indica el tiempo para desmayarse por sueño, expresado en segundos.
  • Puntos para saciar el hambre: puntos necesarios para saciar completamente el hambre.
  • Puntos para hidratarse: puntos necesarios para hidratarse completamente.
  • Puntos para aliviar sueño: puntos necesarios para aliviar el sueño completamente.
  • Tiempo durmiendo para recuperar sueño: tiempo necesario que se necesita dormir para recuperar completamente el sueño, expresado en segundos.
/// <summary>
/// Cualquier personaje del juego que sea orgánico, por ejemplo un concursante o un animal de la fauna.
/// </summary>
public abstract class OrganicCharacter : CharacterBase
{

...

  /// <summary>
  /// Tiempo para desmayarse por hambre o desnutrición (en segundos).
  /// </summary>
  public abstract float TimeToFaintFromHunger { get; protected set; }

  /// <summary>
  /// Tiempo para desmayarse por deshidratación (en segundos).
  /// </summary>
  public abstract float TimeToFaintFromDehydration { get; protected set; }

  /// <summary>
  /// Tiempo para desmayarse por sueño (en segundos).
  /// </summary>
  public abstract float TimeToFaintFromSleep { get; protected set; }

  /// <summary>
  /// Puntos necesarios para nutrirse al completo y saciar el hambre.
  /// </summary>
  public abstract ushort PointsToSatiateHunger { get; protected set; }

  /// <summary>
  /// Puntos necesarios para hidratarse al completo y saciar la sed.
  /// </summary>
  public abstract ushort PointsToHydrate { get; protected set; }

  /// <summary>
  /// Puntos necesarios para aliviar el sueño.
  /// </summary>
  public abstract ushort PointsToAlleviateSleep { get; protected set; }

  /// <summary>
  /// Tiempo necesario para recuperarse por completo del sueño,
  /// desde un estado completamente agotado o cansado por sueño (en segundos).
  /// </summary>
  public abstract float SleepTimeForFullRecovery { get; protected set; }

...

}

Métodos de consumo de los personajes

El código que añado a continuación, muestra algunos de los métodos de los personajes orgánicos, que permiten interactuar con los consumibles o consultar el estado de supervivencia.

  • Comprobar desmayo: indica si el personaje se ha desmayado por alguna de las propiedades de supervivencia.
  • Consumir: actualiza los indicadores de supervivencia del personaje para un consumible.
  • Beber una bebida: actualiza los indicadores de supervivencia de un personaje para una bebida.
  • Beber de un contenedor: da un sorbo a la bebida contenida en el contenedor de bebidas.
  • Dormir: actualiza el indicador de supervivencia que corresponde con el sueño aumentando los puntos correspondientes al tiempo dormido.
/// <summary>
/// Cualquier personaje del juego que sea orgánico, por ejemplo un concursante o un animal de la fauna.
/// </summary>
public abstract class OrganicCharacter : CharacterBase
{
 
...

 /// <summary>
 /// Indica si el personaje se ha desmayado por falta de comida, hidratación o cansancio.
 /// </summary>
 /// <returns>'Verdadero' si se ha desmayado, 'Falso' en caso contrario.</returns>
 public bool IsFaint()
 {
   return (CurrentFeedingPoints <= 0 || CurrentHydrationPoints <= 0 || CurrentStimulationPoints <= 0);
 }

 /// <summary>
 /// Consumir algún alimento, bebida o estimulante.
 /// Modificará el estado de supervivencia del individuo.
 /// </summary>
 /// <param name="consumable">Objeto a consumir.</param>
 public void Consume(Consumable consumable)
 {
   if (consumable != null)
   {
     // Alimentación
     float estimatedFeedingPoints = (consumable.FeedingPoints + CurrentFeedingPoints);

     if (estimatedFeedingPoints > PointsToSatiateHunger)
     {
       estimatedFeedingPoints = PointsToSatiateHunger;
     }

     CurrentFeedingPoints = estimatedFeedingPoints;

    // Hidratación
    float estimatedHydrationPoints = (consumable.HydrationPoints + CurrentHydrationPoints);

    if (estimatedHydrationPoints > PointsToHydrate)
    {
      estimatedHydrationPoints = PointsToHydrate;
    }

    CurrentHydrationPoints = estimatedHydrationPoints;

    // Estimulante
    float estimatedStimulationPoints = (consumable.StimulationPoints + CurrentStimulationPoints);

    if (estimatedStimulationPoints > PointsToAlleviateSleep)
    {
      estimatedStimulationPoints = PointsToAlleviateSleep;
    }

    CurrentStimulationPoints = estimatedStimulationPoints;
  }
 }

 /// <summary>
 /// Consumir una bebida.
 /// Modificará el estado de supervivencia del individuo, normalmente afectando a su hidratación.
 /// </summary>
 /// <param name="drinkSource">Fuente o surtidor de bebida.</param>
 public void Drink(IDrinkSource drinkSource)
 {
   if (drinkSource != null)
   {
     GameObject drinkCandidate = GameObjectFactory.GetSample(drinkSource.DrinkType);

     if (drinkCandidate is Drink)
     {
       Drink drink = (Drink)drinkCandidate;
       Consume(drink);
     }
   }
 }

 /// <summary>
 /// Beber o dar un sorbo de un contenedor de bedida, por ejemplo, una cantimplora.
 /// </summary>
 /// <param name="drinkContainer">Contenedor de bedida del que se va a dar un sorbo.</param>
 public void Drink(DrinkContainer drinkContainer)
 {
   if (drinkContainer != null)
   {
     if (!drinkContainer.IsEmpty())
     {
       drinkContainer.Use();
       Drink(drinkContainer as IDrinkSource);
     }
   }
 }

 /// <summary>
 /// Dormir un tiempo limitado.
 /// Modificará el indicador de sueño del individuo.
 /// </summary>
 /// <param name="sleepTime">Tiempo de descanso o sueño (en segundos).</param>
 public void Sleep(float sleepTime)
 {
   float calculatedPointsBySecond = sleepTime * SurvivalStatusEngine.GetRecoveryPointsWhenSleep(this);
   float estimatedStimulationPoints = (CurrentStimulationPoints + calculatedPointsBySecond);

   if (estimatedStimulationPoints > PointsToAlleviateSleep)
   {
     estimatedStimulationPoints = PointsToAlleviateSleep;
   }

   CurrentStimulationPoints = estimatedStimulationPoints;
 }

...

}

Los tres indicadores de supervivencia (hambre, deshidratación y sueño) tienen un valor que va disminuyendo con el tiempo, cuando cualquiera de estos llega a 0, el concursante se desmaya y pierde la partida. Para impedir que eso ocurra, el concursante debe consumir ciertas cosas para aumentar dicho valor. Estas cosas son las que antes he llamado consumibles.

 

Consumibles

En el videojuego, un elemento consumible es cualquier cosa que se puede consumir, como por ejemplo carne, agua, setas, fruta… Todos los consumibles tienen lo que llamamos puntos de supervivencia.

Puntos de supervivencia

Los consumibles otorgan puntos de supervivencia al consumirlos, aumentando el tiempo que el personaje puede aguantar sin desmayarse. Cuando hablamos de puntos usamos la siguiente nomenclatura:

  • Para saciar el hambre: puntos de alimento
  • Para hidratarse: puntos de hidratación
  • Para aliviar el sueño: puntos de estimulación

Por ejemplo, consumir moras añade 7 puntos de alimento a los indicadores de supervivencia del personaje. Esto aumentará el tiempo que tiene el concursante antes de desmayarse por hambre.

survival-stats-example

Es posible que un consumible pueda dar puntos a más de un indicador de supervivencia. Por ejemplo una naranja, otorga puntos de alimentación e hidratación.

Caducidad

Los consumibles pueden caducar, lo que significa que transcurrido su tiempo de caducidad se echan a perder y no se pueden consumir.

Actualmente tenemos deshabilitada esta opción por temas de jugabilidad. Creemos que es una mecánica más pesada que divertida y por el momento no la utilizamos.

Esto abre la puerta a otras mecánicas interesantes, como por ejemplo: si un consumible caducado se encuentra en un contenedor con otros consumibles, podría contagiar al resto y acelerar el tiempo que tardan en echarse a perder… Pero como he dicho, es una mecánica que de momento no vamos a utilizar 😛

El código de los consumibles

En nuestro código disponemos de una clase que representa este concepto de objeto consumible, a la que llamamos Consumable.

Las propiedades más relevantes son:

  • Puntos de alimentación: cantidad de puntos de alimentación que se obtiene al consumirlo.
  • Puntos de hidratación: cantidad de puntos de hidratación que se obtienen al consumirlo.
  • Puntos de estimulante: cantidad de puntos de estimulante que se obtienen al consumirlo.
  • Tiempo de caducidad: tiempo total que tarda en caducar.

Los métodos más relevantes son:

  • Comprobar estado de caducidad: indica si el consumible ha caducado.
  • Comprobar si caduca: indica si el consumible caduca. Hay consumibles a los que no aplica la caducidad en el videojuego, como por ejemplo el agua.
/// <summary>
/// Cualquier elemento que se puede consumir, como por ejemplo bebida, comida, estimulantes, etc.
/// </summary>
public abstract class Consumable : GameObject
{
 #region Constructor
 protected Consumable() : base()
 {
   if (BaseExpirationTime > 0)
   {
     float minExpirationTime = BaseExpirationTime * ((100f - ExpirationTimeMargin) / 100f);
     float maxExpirationTime = BaseExpirationTime * ((100f + ExpirationTimeMargin) / 100f);
     float expirationTime = CustomConstants.Random.Next((int)minExpirationTime, (int)maxExpirationTime);
     ExpirationTime = expirationTime;
     RemainingExpirationTime = expirationTime;
   }
 }
 #endregion

 #region Properties
 /// <summary>
 /// Margen en el que el tiempo de caducidad definido puede variar (en porcentaje).
 /// </summary>
 protected const float ExpirationTimeMargin = 10;

 /// <summary>
 /// Puntos de alimento que se obtienen al consumir esto.
 /// Sirven para saciar el hambre y mitigar la desnutrición.
 /// </summary>
 public abstract ushort FeedingPoints { get; }

 /// <summary>
 /// Puntos de hidratación que se obtienen al consumir esto.
 /// Sirven para saciar la sed y mitigar la deshidratación.
 /// </summary>
 public abstract ushort HydrationPoints { get; }

 /// <summary>
 /// Puntos de estimulación contra el sueño que se obtienen al consumir esto.
 /// Sirven para combatir el sueño, como la cafeína.
 /// </summary>
 public abstract ushort StimulationPoints { get; }

 /// <summary>
 /// <para>Tiempo base que tarda esto en echarse a perder, con el tiempo
 /// expirado no se puede consumir (en segundos).
 /// </para>
 /// <para>El tiempo real de caducidad queda definido por un margen pequeño
 /// que puede acortar o alargar la duración total.</para>
 /// <para>El valor 0 indica que no caduca.</para>
 /// </summary>
 public abstract float BaseExpirationTime { get; }

 /// <summary>
 /// Tiempo restante, que le queda al consumible para caducar o echarse a perder (en segundos).
 /// </summary>
 public float RemainingExpirationTime { get; set; } = 0;

 /// <summary>
 /// <para>Tiempo real que tarda esto en echarse a perder.</para>
 /// <para>Este tiempo no es constante, depende de un pequeño margen definido
 /// internamente que puede acortar o alargar la caducidad.</para>
 /// <para>Ver 'ExpirationTimeMargin'</para>
 /// </summary>
 public float ExpirationTime { get; protected set; } = 0;
 #endregion

 /// <summary>
 /// Obtiene el porcentaje actual de caducidad del consumible.
 /// Representa porcentualmente cuanto tiempo le queda para caducarse.
 /// </summary>
 /// <returns>Porcentaje restante antes de caducar.</returns>
 public float GeCurrentExpirationPercent()
 {
   return (RemainingExpirationTime / ExpirationTime) * 100f;
 }

 /// <summary>
 /// Indica si el consumible ha caducado.
 /// </summary>
 /// <returns>'Verdadero' si ha caducado, 'Falso' en caso contrario.</returns>
 public bool IsExpired()
 {
   return (IsExpirable() && RemainingExpirationTime <= 0);
 }

 /// <summary>
 /// <para>Indica si el consumible tiene tiempo de caducidad.</para>
 /// <para>Nota: Un consumible con una tiempo base de caducidad de 0 se considera no-caduco.</para>
 /// </summary>
 /// <returns>'Verdadero' si puede caducar, 'Falso' en caso contrario.</returns>
 public bool IsExpirable()
 {
   return (BaseExpirationTime > 0);
 }
}

La naranja sería un ejemplo de implementación de un consumible:

/// <summary>
/// <para>Naranja natural. Se obtiene del naranjo.</para>
/// <para>Datos orientativos del fruto: Calorías: 47; Agua: 87%</para>
/// </summary>
public class Orange : Consumable, IInventariable, IDamageDealer
{

...

 #region Inventariable
 public InventariableType InventariableType { get; } = InventariableType.Orange;

 public bool Stackable { get; } = true;

 public float Weight { get; } = 0.2f;
 #endregion

 #region Consumable
 public override float BaseExpirationTime { get; } = 0f;

 public override ushort FeedingPoints { get; } = 28;

 public override ushort HydrationPoints { get; } = 10;

 public override ushort StimulationPoints { get; } = 0;
 #endregion

 #region Damage Dealer
 public DamageSource DamageSource { get; } = new DamageSource(
 new Dictionary<DamageType, int>()
   {
     { DamageType.Crushing, 1 }
   },
   null);

 public bool CanBeHurled { get; } = true;
 #endregion
}

 

Colores

A cada factor de supervivencia le hemos asociado un color que permita identificarlo.

hunger-icon                        dehydration-icon                     sleep-icon
Hambre        
Deshidratación        Sueño

Utilizamos una clase que centraliza estos valores en forma de constantes, de manera que podamos cambiar estos valores de forma fácil si lo necesitamos.

Para elegir los colores hemos utilizado Adobe Kuler, una herramienta muy útil e interesante para buscar sintonía entre colores.

/// <summary>
/// Color base relacionado con el hambre.
/// </summary>
public static Color HungerColor new Color(139, 127, 89);
 
/// <summary>
/// Color base relacionado con la deshidratación.
/// </summary>
public static Color HydrationColor new Color(140, 190, 178);
 
/// <summary>
/// Color base relacionado con el sueño.
/// </summary>
public static Color SleepColor new Color(198, 168, 201);

Eso es todo (a alto nivel) en cuanto a diseño y programación sobre el sistema de supervivencia y los consumibles.

Si tienes dudas o curiosidad por saber el detalle de alguna cosa ¡espero tus comentarios!

 

 

El Motor del Videojuego – III

Contexto

Si acabas de aterrizar en el Blog, te recomiendo que antes de continuar que te dejes caer por aquí:

Así mismo recomiendo leer también las 2 entradas que hablan sobre YokaiEngine – el motor que estamos creando para desarrollar Sticks & Stones:

Auto-cast de objetos

El artículo de hoy se va a centrar en lo que llamo auto-cast de objetos de Flash a C#.

Para que me sea más sencillo de explicar en qué consiste el auto-cast, voy a recurrir de nuevo a nuestro personaje de ejemplo Sigfrid – El Paladín.

Imaginemos que hemos creado una animación en Flash y la llamamos paladin:

paladin_instance

Tal y como hemos hablado en anteriores artículos, el motor es capaz de replicar todos los objetos Flash y sus animaciones de forma recursiva, y reconstruirlos tal cual en C#.

Sólo basta con definir en la Screen de juego una variable del tipo CustomSprite (propia del motor) con el mismo nombre que hemos usado en el Flash:

public class Screen PlayableScreen
{
      public CustomSprite paladin; 
}

Y obtenemos lo siguiente:

paladin

Imaginemos por un momento que queremos acceder a cualquier parte de la animación, por ejemplo a la espada. Para ello deberemos realizar los siguientes pasos:

  1. Dar un nombre identificativo al objeto Flash
  2. Crear jerarquía de clases C# para definir padres-hijos

Vamos a asignar un nombre al objeto de la espada, en este caso le llamaremos weapon.

weapon_instance

Clases tipadas

Sigfrid, nuestro paladin, va a dejar de ser una clase del tipo CustomSprite (que es genérica) para pasar a tener su propia clase – PaladinCharacter. Lo que nos va a permitir poder definir dentro contenido propio:

public class Screen PlayableScreen
{
      public PaladinCharacter paladin; 
}
public class PaladinCharacter CustomSprite
{
      public CustomSprite weapon;
}

De esta manera ya podríamos tener acceso al arma a través del objeto paladin, y por ejemplo hacerla desaparecer:

public class Screen PlayableScreen
{
    public void RemoveWeapon()
    {
        paladin.weapon.Visible = false;
    }

no_weapon

Cuando el motor recrea los objetos del Flash, lo que hace es crear instancias automáticamente al tipo indicado en la definición de la variable. En nuestro caso:

  • paladin lo castea al tipo PaladinCharacter
  • sword lo castea al tipo CustomSprite

Recursividad

Esta técnica de auto-cast es recursiva y puede comprender todos los niveles que se necesiten. Profundizando más en el mismo ejemplo, podríamos tener la siguiente estructura:

public class PaladinCharacter CustomSprite
{
      public Weapon weapon;
      public Helmet helmet;
      public Shield shield;
}
public class Weapon CustomSprite
{
      public Blade blade;
      public Grid grip;    
      public CrossGuard crossGuard;
}
public class GripCustomSprite
{
      public Handle handle;
      public Jewel jewel;    
}

Listas

El auto-cast también funciona con listas. En el caso que tengamos más de un objeto del mismo tipo, por ejemplo los brazos (en este caso 2), en el Flash los identificamos con el mismo nombre acabado en número  (arm0 y arm1):

instance_arm

Ahora solo basta con tiparlo como una lista y ya:

public class PaladinCharacter CustomSprite
{
      public Weapon weapon;
      public Helmet helmet;
      public Shield shield;
      public List<Arm> arm;
}

Generar cualquier estructura jerarquizada de objetos es muy útil para poder realizar animaciones complejas y aún así tener el control total de acceso a cualquier objeto (para poder realizar la transformación).

 

¡Si te ha gustado compártelo! :]

Diseño y programación – Mochila del concursante

Introducción

En este post contaré cómo hemos diseñado y programado la mochila del concursante a alto nivel, desde un punto de vista conceptual, hasta un nivel más técnico viendo algo del código.

El juego cuenta con múltiples objetos, pero para crear un ecosistema que funcione hay que etiquetarlos y diseñarlos pensando en conceptos reales.

backpack-schema

 

Objetos inventariables (artículos)

La mochila solo permite almacenar objetos inventariables.

Este tipo de objetos tienen un peso y una propiedad que indica si pueden apilarse. Por ejemplo, las flechas pueden apilarse en el inventario, pero los arcos no.

Las propiedades que se configuran para un objeto inventariable son:

  • Tipo: tipo de objeto inventariable, que lo identifica de forma única en el ámbito de los inventariables.
  • Peso: el peso que tiene el objeto en el juego expresado en gramos.
  • Apilable: indica si este objeto puede apilarse en las ranuras de inventario.
/// <summary>
/// <para>Cualquier objeto que puede formar parte de un inventario o que puede inventariarse.</para>
/// <para>En la mayoría de casos, serán cosas que podrán llevarse en el cuerpo o en algo 
/// que permita almacenar, como una mochila o saco.</para>
/// </summary>
public interface IInventariable
{
    /// <summary>
    /// Tipo de objeto invnetariable. Actua como identificador único.
    /// </summary>
    InventariableType InventariableType { get; }
 
    /// <summary>
    /// Peso de este objeto (en gramos).
    /// </summary>
    float Weight { get; }
 
    /// <summary>
    /// <para>Indica si el objeto se puede apilar en las ranuras de un contenedor de inventario.</para>
    /// <para>Por ejemplo, las armas por norma general no se pueden apilar en una misma ranura.</para>
    /// </summary>
    bool Stackable { get; }
}

Contenedor de inventario

El contenedor de inventario es el recipiente donde se pueden guardar los objetos inventariables.

La característica más particular de estos contenedores es que están formados por ranuras de inventario. En estas ranuras es donde se almacenan los artículos u objetos inventariables. Pueden contener un número ilimitado de artículos o un tope a partir del cual no deja apilar más objetos.

backpack-slot-schema

Las propiedades que se pueden configurar para un contenedor son:

  • Capacidad: cantidad de ranuras de las que dispone.
  • Límite de ranura: indica la cantidad máxima de artículos por ranura.
  • Peso: peso máximo que puede soportar.

Por ejemplo, un contenedor que tiene 10 ranuras y un límite de 5 artículos por ranura tiene una capacidad máxima de 10×5, es decir, como máximo podrá contener 50 artículos.

Además dispone de varias propiedades y métodos de utilidad, que permiten consultar el estado del contenedor y gestionarlo. Estos son los más relevantes:

Métodos de extracción

  • Coger X: permite coger una cantidad específica de un tipo de artículo. Se da prioridad a las ranuras con menos artículos, con el fin de intentar liberarlas.
  • Coger X no rotos: hay artículos que se rompen con el uso, este método solo coge artículos que aún no están rotos.
  • Coger todos: saca todos los artículos de la mochila. Queda vacía.

Métodos de inserción

  • Poner pila de artículos en ranura con espacio: añade al contenedor una pila de artículos del mismo tipo. Busca una ranura con espacio suficiente, dando prioridad a ranuras que ya contengan artículos del mismo tipo. Solo realiza la inserción si puede colocarlos todos en una sola ranura. Tiene en cuenta el peso, impidiendo la inserción si se sobrepasa.
  • Poner lista de artículos: añade al contenedor una lista de artículos que pueden ser de tipos diferentes. Se añadirán al contenedor tantos como quepan en el orden en que vienen en la lista.
  • Poner un artículo: añade el artículo al contenedor (si cabe). Tiene en cuenta el peso, impidiendo la inserción si se sobrepasa.

Métodos de utilidad

  • Peso actual: suma del peso de todos los artículos que contiene.
  • Reforzar: aumenta el peso máximo que puede soportar el contenedor.
  • Comprobar artículo: indica si un artículo cabe en el contenedor.
  • Comprobar artículos: indica si una lista de artículos cabe completa en el contenedor.
  • Contar artículos: cuenta los artículos de un tipo concreto que hay en el contenedor.
  • Contar artículos no rotos: cuenta los artículos no rotos de un tipo concreto que hay en el contenedor.
    /// <summary>
    /// <para>Contenedor de inventario que permite transportar o guardar cosas.</para>
    /// <para>Tiene una capacidad y peso máximos que puede soportar.</para>
    /// <para>Permite ser reforzado para aumentar el peso máximo que soporta.</para>
    /// </summary>
    public abstract class InventoryContainer GameObject
    {
        #region Constructor
        protected InventoryContainer() : base()
        {
            InitSlots();
            MaxWeightSupported BaseWeightSupported;
        }
        #endregion
 
        #region Properties
        /// <summary>
        /// Indica si el contenedor es un clon, es decir, se ha creado
        /// a partir del método "Clone()'.
        /// </summary>
        private bool IsClone { get; set; } = false;
 
        /// <summary>
        /// <para>Número máximo o límite de artículos que caben en las ranuras del contenedor.</para>
        /// <para>El valor '0' significa ilimitado.</para>
        /// </summary>
        public abstract uint SlotLimit { get; }
 
        /// <summary>
        /// <para>Capacidad base del container, equivale al número de ranuras de las que dispone inicialmente.</para>
        /// </summary>
        public abstract uint Capacity { get; }
 
        /// <summary>
        /// <para>Peso base máximo que puede cargar (en gramos).</para>
        /// </summary>
        public abstract float BaseWeightSupported { get; }
 
        /// <summary>
        /// Acción que se lanza cuando el contenedor de inventario sufre alguna modificación
        /// que cambie su contenido, normalmente cuando se añaden o quitan artículos.
        /// </summary>
        public ActionEvent OnInventoryModified { get; set; }
 
        /// <summary>
        /// <para>Peso máximo que puede cargar.</para>
        /// <para>Se puede reforzar aumentando su limite de peso.</para>
        /// <para>Si no ha sido reforzado coincidirá con el peso base máximo soportado.</para>
        /// </summary>
        public float MaxWeightSupported { get; protected set; }
 
        /// <summary>
        /// Ranuras de inventario del contenedor (las ranuras pueden apilar objetos u artículos del mismo tipo).
        /// </summary>
        public List<InventorySlot> Slots { get; protected set; }
 
        /// <summary>
        /// Peso total del contenedor en base a la suma de pesos de todos los artículos que contiene.
        /// </summary>
        public float CurrentWeight
        {
            get
            {
                float totalWeight = 0f;
 
                if (Slots != null)
                {
                    totalWeight = Slots.Sum(slot => slot.Weight);
                }
 
                return totalWeight;
            }
        }
        #endregion
 
        #region Extraction
        public Stack<IInventariable> Take(InventariableType type, uint amount)
            ...
 
        public IInventariable TakeUnbroken(InventariableType type)
            ...
 
        public Stack<IInventariable> TakeUnbroken(InventariableType type, uint amount)
            ...
 
        public Stack<IInventariable> Take(int slotIndex, uint quantity)
            ...
 
        public Stack<IInventariable> TakeAll(int slotIndex)
            ...
        #endregion
 
        #region Insertion
        public bool Put(Stack<IInventariable> articles)
            ...
 
        public List<IInventariable> Put(List<IInventariable> articles)
            ...
 
        public bool Put(IInventariable article)
            ...
 
        public Stack<IInventariable> Put(Stack<IInventariable> newArticles, int slotIndex)
            ...
 
        public Stack<IInventariable> Put(IInventariable article, int slotIndex)
            ...
        #endregion
 
        #region Information & Utils
        public void Reinforce(float weightIncrement)
            ...
 
        public bool CheckSpace(IInventariable article)
            ...
 
        public bool CheckSpace(InventariableType articleType)
            ...
 
        public bool ContainsAllUnbrokenIngredientsAndTools(CraftingRecipe recipe)
            ...
 
        public bool CanFit(List<IInventariable> articles)
            ...
 
        public List<ICraftingTool> GetAllUnbrokenTools()
            ...
 
        public HashSet<ICraftingTool> GetUnbrokenTools(HashSet<CraftingToolType> toolTypes)
            ...
 
        public bool IsFull()
            ...
 
        public InventoryContainer Clone()
            ...
 
        public uint CountProjectiles(Type projectileType)
            ...
 
        public uint CountArticles(InventariableType type)
            ...
 
        public uint CountUnbrokenArticles(InventariableType type)
            ...
 
        public List<DrinkContainer> GetAllDrinkCotnainers()
            ...
 
        public List<DrinkContainer> GetDrinkCotnainers(Type drinkType)
            ...
        #endregion
 
        ...
    }

Mochila del concursante

En definitiva la mochila del concursante es un contenedor de inventario, que tiene una capacidad de 16 ranuras y un límite de 5 artículos por ranura. Puede soportar un peso máximo de 8 kilos.

public class BasicBackpack Backpack
{
    #region Constructor
    protected BasicBackpack() : base() { }
    #endregion
 
    #region Game Object
    public override string Name { get; } = "Basic backpack";
 
    public override string FlavorText { get; } = "The simplest and free backpack provided by the show.";
    #endregion
 
    #region Backpack
    public override uint SlotLimit { get; } = 5;
 
    public override uint Capacity { get; } = 16;
 
    public override float BaseWeightSupported { get; } = 8000;
    #endregion
}

Eso es todo (a alto nivel) en cuanto a diseño y programación de la mochila del concursante.

Si tienes dudas o curiosidad por saber el detalle de alguna cosa ¡espero tus comentarios!

 

 

El Motor del Videojuego – I

Siempre que nos preguntan -¿Y qué motor estáis usando para desarrollar Sticks & Stones? muchas veces antes de que podamos contestar, añaden una segunda pregunta Unity supongo… ¿no?

Pues no.

Sé que en el mercado actual del desarrollo indie, se está usando como primera opción el de-sobras-conocido motor Unity, ya sea por su facilidad de uso, su documentación, su store o por su comunidad…

Pero en nuestro caso hay varios factores, que os iremos explicando más adelante, por los que nos decantamos a usar un motor propio – llamado YokaiEngine – basado en el Framework de Microsoft – XNA.

Sinergia Adobe Flash y YokaiEngine

El leitmotiv de YokaiEngine, es poder usar objetos Adobe Flash como si fueran nativos en C#.

Todo su potencial reside precisamente en este factor, que aunque parezca algo trivial, veremos más adelante que no lo es en absoluto.

En nuestro caso, con Sticks & Stones, usamos Adobe flash en una gran parte del arte, sobre todo para elaborar el diseño de personajes y sus animaciones.

Para entender la mecánica más básica del motor y de cómo incorporar dibujos y animaciones a nuestro proyecto, vamos a verlo mediante un ejemplo visual y sin entrar en la parte técnica (aún).

Lo primero que haremos será crear una animación en Adobe Flash:

En este caso, he recuperado una vieja animación de Sigfrid, un Paladín que hice hace algún tiempo, que me va a servir para explicar todo el proceso.

Para poder incorporar la animación de Sigfrid a de nuesto juego, bastará con:

  1. Añadir el fichero compilado que genera Adobe Flash (.swf) a nuestro proyecto
  2. Crear una clase C# indicándole la ruta de este fichero
  3. Ejecutar el juego

YokaiEngine detectará este fichero Adobe Flash (.swf) como nuevo y realizará de forma automática los siguientes pasos:

  1. Leer todos los objetos incluidos en el .swf
  2. Generar las texturas de todos los objetos
  3. Crear un fichero XML con las instancias, animaciones y metadatos necesarios

export

El resultado una vez ejecutado el juego es el siguiente:

Como podéis ver, incorporar animaciones Flash al juego es realmente sencillo, pero tampoco nada super-awesome. Vamos a indagar más en qué es lo realmente potente de dicho motor…

Las claves del YokaiEngine

A continuación nombraré algunos de los que podríamos considerar puntos fuertes de YokaiEngine:

  • Facilidad para ajustar el arte en cualquier etapa de desarrollo
  • Transformación de objetos en tiempo real
  • Auto-cast de objetos
  • Agrupación de todos los assets y texturas en un único fichero (.swf)

Hoy vamos a desarrollar el primero de ellos.

Facilidad para ajustar el arte en cualquier etapa del desarrollo

Por mucha experiencia que tengamos desarrollando videojuegos, los cambios a nivel de arte siempre van a estar presentes a lo largo de todo el desarrollo. Ya sea que el tamaño de un personaje es desproporcionado una vez colocado en un escenario, la paleta de colores va evolucionando o simplemente hay que realizar ajustes estéticos.

Supongamos que ya tenemos incorporadas en nuestro juego algunas animaciones de Sigfrid: como la de correr, saltar y atacar. Y por alguna razón, tenemos que modificar el aspecto actual del casco. ¿Os imagináis modificar todas y cada una de las animaciones y fotogramas para realizar dicho ajuste? Dependiendo de cómo las hayamos realizado (¿pixel art?), puede ser una tarea titánica, ¿verdad?

Por suerte, en nuestro caso no nos llevará más de unos pocos segundos, y esto es gracias a cómo gestiona los objetos y símbolos Adobe Flash.

ajustesarte_0

Bastará con acceder al elemento casco, realizar cualquier modificación (color, forma, tamaño, …) y guardar los cambios (generando así un nuevo .swf). En nuestro caso hemos ampliado ligeramente el tamaño del casco (aumentando la sensación de cartoon) además de añadir una estrella decorativa. El resultado es el siguiente:

ajustesarte_2

Al ejecutar de nuevo el juego, YokaiEngine detectará estos cambios y volverá a generar de nuevo texturas y el XML con la información (vemos que en realidad sólo ha generado la nueva textura del casco).

exportb

Ya tenemos todas las animaciones de Sigfrid de nuevo incorporadas al juego sin tocar una sóla línea de código:

Poder realizar cualquier ajuste a nivel de arte en cualquier etapa del desarrollo del juego, y además de una forma tan inmediata, es uno de los factores clave del motor.

Además, hemos de tener en cuenta que esta necesidad de ir ajustando el arte, se acentúa más aún si somos noveles en esto de desarrollar videojuegos, ya que muchas veces al inicio de éste, no sabemos cómo será el resultado final. Poder disponer de una herramienta que nos ayude a ajustar esto de forma casi transparente es un win-win.

Otro factor a tener en cuenta en esto, es que al tratarse de dibujo vectorial, se puede escalar, rotar y alterar cualquier textura sin perder nada de calidad (doble win-win).

 

Espero que os haya gustado el artículo. Me gustaría saber vuestra opinión sobre esta elección de usar un motor propio. ¿Creéis que es acertada?