Buenas a todos y gracias por pasaros por aquí.
Prologo
spoilerEl sentido de este hilo es ver el proceso por el que voy pasando a la hora de crear un videojuego o crear las necesidades técnicas cuando me llegan con ideas para uno y hay que crear el GDD, para posteriormente poder trabajar con él.
La idea actual es crear un juego basado en los "Theme..." enfocado en la gestión y administración de una compañía de Alquiler de Coches. Para tomar la idea del juego, me han ayudado varias amigas que trabajan en una y me han sabido responder las dudas expuestas.
Juego Rent A Car
Preguntas y respuestas
spoilerLo primero que voy a hacer es desglosar todas las ideas sacadas a través de la entrevista; ahorraré de poner las preguntas y las respuestas porque serían innecesarias para el caso.
Al ser un rent a car, lo principal que vamos a tratar son los coches. Los coches pueden estar alquilados, esperando en un escampado llamado "campa" donde esperan para moverlos al parking, ser reparados, matriculados, estar limpiándose o en trayecto de un lado a otro que suelen ser tiempos muy cortos. Los vehículos se engloban en unas categorías que marcan su precio de alquiler.
Los coches pueden sufrir varios percances durante el periodo de alquiler, entre los que pueden ser un pinchazo, accidente, rotura de luna, batería o cualquier otro infortunio. En todos ellos, se cambia por otro coche de misma categoría o superior si no hay disponibilidad.
Empleados: La empresa tiene en plantilla varios trabajadores con perfiles diferentes, entre los oficinistas que realizan los contratos, los mecánicos, los que se encargan de limpiar los coches y los conductores de los vehículos que llevan a los clientes del aeropuerto al parking o a la inversa. Cada uno de ellos cuenta con su sueldo.
Sobre el parking, nada especial. Tiene un número limitado de plazas en el que llegan los coches devueltos del alquiler y los que se llevarán los clientes.
Los contratos van únicamente dos, franquicia o todo cubierto. Si eligen franquicia y no dan un golpe les sale barato. Si golpean, pues les cuesta la reparación. De donde se gana el dinero es de aquellos coches que piden el Top Cover, pero no dan ningún golpe o que este cueste menos de lo cobrado.
En el apartado de los clientes, tendrán un gusto por una categoría en especial que se adapte a ellos y que estará categorizado por el precio que están dispuestos a pagar.
Modelo de Datos
spoilerCon esto podemos empezar a crear lo que será el esqueleto de clases.
Tenemos para los trabajadores unas características y vamos a añadirlas:
- ID
- Name
- CategoryWorker
- Salary
- Happiness
- Fatigue
He añadido fatiga para marcar el tiempo que puede estar trabajando el empleado hasta que necesite irse a descansar. Lo normal en un rent a car es que cada uno tenga su día de descanso pero vamos a hacer un 24/7 y aquí los de UGT y CCOO no nos van a venir a quejarse porque saquemos el latigo
Para clientes:
- IDClient
- Name
- Happiness
- Cash
- CategoryCar
Para coches:
- IDCar
- Model
- Plate
- CategoryCar
- PositionCar
- StatusCar
- StructurePoints
- RentCost
- Value
- ValueMax
Incluimos modelo de coche, matricula, valor actual del coche que va degradándose con el uso, valor de compra y el valor del alquiler por día.
Para los casos de cliente y trabajador, veo que ambos tienen atributos en común así que voy a crear una clase padre que contenga las comunes y añadiré aquella herencia para los elementos en los que sean dispares.
public class Person {
public int IDPerson;
public string name;
}
No he incluido Happiness como común en persona porque voy a hacer que compartan misma simbología con StructurePoints de los coches, que marcará su estado de daño. Para ello, voy a crear una interfaz que implemente este estado sin tener que depender de la herencia. En este caso lo llamaré IHappiness. Un coche puede estar happy? Bueno, en One Piece estaba Merry con su espíritu así que voy a hacer que siga este sistema, más bajo peor animo.
public interface IHappiness {
int happy { get; set; }
}
Para las categorías de los empleados, posiciones de los coches, categoría de coche y estado del coche, voy a realizar varios enums que determinaran su estado o tipo. Además voy a incluir ya el enum para tipos de contrato y estado de los trabajadores si se encuentran descansando o empleados.
public enum CategoryWorker {
Driver,
Office,
Mechanic,
Cleaner
}
public enum Contract {
TopCover,
Excess
}
public enum PositionCar {
Parking,
Hired,
Storage,
Repair,
Cleaning,
Moving
}
public enum PositionWorker {
Working,
Resting,
Moving
}
public enum CategoryCar {
A,
B,
C,
D,
E
}
public enum StatusCar {
Retired,
Damaged,
Usable,
Fine,
New
}
PositionCar nos permitirá conocer en donde se encuentra el coche en ese momento, "moving" para indicarnos que está en carretera dirigiéndose a algún destino, por ahora voy a dejarlo así y decidiré si quitarlo para colocar un Moving To "PositionCar".
PositionWorker nos marca el estado en el que está el trabajador, cuando su fatiga llegue a un % marcado por el jugador, irá a descansar. Mismo hecho con Moving de PositionCar, por ahora lo dejaré estar.
CategoryCar marca el modelo del coche y su coste de alquiler
StatusCar determina el estado del coche, este estado nos indicará el coste del coche si queremos venderlo y la felicidad de nuestro cliente a la hora de recibirlo. A mayor uso, más decaerá la felicidad de nuestro cliente para cuando lo reciba. A nadie le gusta un coche que tenga arañazos, bollos, hendiduras y destrozos por todos lados.
Con esto ya podemos crear las clases faltantes.
public class Worker : Person, IHappiness
{
public float salary;
public int fatigue;
//Enums
public CategoryWorker job;
public PositionWorker positionWorker;
//interface IHappiness
public int happy { get; set; }
}
public class Client : Person, IHappiness {
public float cash;
//Enums
public CategoryCar carChosen;
//interface IHappiness
public int happy { get; set; }
}
public class Car : IHappiness {
public int IDCar;
public string modelCar;
public string plateCar;
public float RentCost;
public float ValueCar;
public float ValueMax;
//Enums
public CategoryCar categoryCar;
public PositionCar positionCar;
public StatusCar statusCar;
//interface IHappiness
public int happy { get; set; }
}
Y por último, la clase Parking
public class Parking {
public int IDParking;
public int slotsCars;
public float RentValue;
}
Salvado del juego | 06 - 09 - 2018
Antes de ponernos a preparar cualquier cosa, siempre debemos trabajar en el sistema de salvado y carga del juego. Pues es la parte fundamental de este para que permitamos al jugador recuperar una partida jugada o permitirle varias instancias del juego.
He estado pensando en 2 formas de guardar el juego. La primera que se me vino a la mente era crear un sistema de tablas y campos usando SQlite, pero la acabé desechando porque iba a crear clientes y empleados aleatorios que una vez finalizados sus contratos o despedidos, iban a ser eliminados ya que no sería necesario guardar su información.
Es por ello que me decanté por Serializar/Deserializar sólo aquella info que me sería necesaria en ese momento. Como número de clientes activos, coches, la posición de estos, su estado. El resto de información que no pertenezca al juego sería obviada.
Para ello, he creado una escena llamada "Test" donde iré realizando pruebas para la funcionalidad de los elementos. En este caso, he incluido un Canvas con 2 botones, Salvar y Cargar junto a un DataManager para controlar todo el aspecto de gestión de los datos.
Creación de la tabla Player. Contendrá la información del jugador en ese momento.
[Serializable]
public class PlayerData {
public string name;
}
Por ahora sólo contendrá el nombre del jugador e iremos añadiendo más datos según se vayan generando
Creación del DataManager que contendrá las funciones para la gestión del cargado y el salvado de la información
public class DataManager : MonoBehaviour {
public static PlayerData playerData;
string NAMEFILE = "/Savedata";
string EXTENSION = ".sav";
public string _playername;
private void Awake()
{
playerData = new PlayerData();
}
public void SaveInfo()
{
Stream stream = File.Open(Application.dataPath + NAMEFILE + EXTENSION, FileMode.OpenOrCreate);
BinaryFormatter bf = new BinaryFormatter();
playerData.name = _playername;
bf.Serialize(stream, playerData);
stream.Close();
}
public void LoadInfo()
{
Stream stream = File.Open(Application.dataPath + NAMEFILE + EXTENSION, FileMode.Open);
BinaryFormatter bf = new BinaryFormatter();
playerData = (PlayerData)bf.Deserialize(stream);
stream.Close();
_playername = playerData.name;
}
}
No es difícil explicar esto y en muchos tutoriales de salvado y cargado ya se utiliza así que no me voy a explayar. Se crean unos archivos de salvado con una extensión a nuestro antojo, se crea un stream de datos para el almacenamiento de estos en el fichero o su extracción. La ventaja que se tiene con ello es que si alguien modifica el archivo desde fuera del juego, posiblemente le salten errores y no sea capaz de cambiar el juego y acabe con un salvado inservible.
Una de las funciones abre el archivo para salvar y la otra función lo abre para cargar esa información.
10 - Sept - 2018
Cargado de Datos desde un CSV
spoilerEn nuestro juego, vamos a cargar bastantes datos que serán necesarios para el funcionamiento del juego, en especial la creación de clientes y trabajadores aleatorios para que tengan un nombre variado. Es por ello que hoy trabajaremos con una herramienta que permite realizar cambios en nuestros datos de una manera automática si cambiamos el CSV sin tener que cargar los datos de nuevo.
Manos a la obra. Vamos a tener 3 csv que contendrán diferentes nombres y apellidos. Tendremos Nombre y Apellido. Nombres los tendremos separados en malenames.csv y femalenames.csv para que haya una diferenciación de nombres cuando vayamos a crear al personaje. lastnames.csv para apellidos.
Males
JAMES
JOHN
ROBERT
MICHAEL
WILLIAM
DAVID
RICHARD
CHARLES
JOSEPH
THOMAS
Females
MARY
PATRICIA
LINDA
BARBARA
ELIZABETH
JENNIFER
MARIA
SUSAN
MARGARET
DOROTHY
SAMANTHA
LastNames
ADAN
CORCUERA
GRISSON
MCGRILL
NOGALES
SCHMALE
SHARP
TAAL
WALKER
ZUBER
Por qué hacer esto si podemos copiar y pegar? Pues porque así aprendemos cosas nuevas y es una forma curiosa de que si tenemos un archivo que está siendo editado o siendo traducido, se vaya actualizando según se va guardando los datos sin tener que estar constantemente interviniendo.
Creamos el ScriptableObject que contendrá el listado de nombres obtenidos por los CSV
public class NameList : ScriptableObject {
public List<string> names;
public void Awake()
{
names = new List<string>();
}
public void Load(string line)
{
string[] elements = line.Split(',');
names.Add(elements[0]);
}
}
El método Load permite añadir nombres a la lista desde nuestro gestor de nombres.
public class SettingsAutoConverter : AssetPostprocessor
{
static Dictionary<string, Action> parsers;
static SettingsAutoConverter()
{
parsers = new Dictionary<string, Action>();
parsers.Add("malenames.csv", ParseMaleNames);
parsers.Add("femalenames.csv", ParseFemaleNames);
parsers.Add("lastnames.csv", ParseLastNames);
}
static void OnPostprocessAllAssets(string[] importedAssets, string[] deletedAssets, string[] movedAssets, string[] movedFromAssetPaths)
{
for (int i = 0; i < importedAssets.Length; i++)
{
string fileName = Path.GetFileName(importedAssets[i]);
if (parsers.ContainsKey(fileName))
parsers[fileName]();
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
}
static void ParseMaleNames()
{
string filePath = Application.dataPath + "/Settings/malenames.csv";
if (!File.Exists(filePath))
{
Debug.LogError("Missing Data: " + filePath);
return;
}
string[] readText = File.ReadAllLines("Assets/Settings/malenames.csv");
filePath = "Assets/Resources/";
NameList nameData = ScriptableObject.CreateInstance<NameList>();
string fileName = string.Format("{0}Malenames.asset", filePath, nameData.names);
for (int i = 1; i < readText.Length; ++i)
{
nameData.Load(readText[i]);
}
AssetDatabase.CreateAsset(nameData, fileName);
}
static void ParseFemaleNames()
{
string filePath = Application.dataPath + "/Settings/femalenames.csv";
if (!File.Exists(filePath))
{
Debug.LogError("Missing Data: " + filePath);
return;
}
string[] readText = File.ReadAllLines("Assets/Settings/femalenames.csv");
filePath = "Assets/Resources/";
NameList nameData = ScriptableObject.CreateInstance<NameList>();
string fileName = string.Format("{0}Femalenames.asset", filePath, nameData.names);
for (int i = 1; i < readText.Length; ++i)
{
nameData.Load(readText[i]);
}
AssetDatabase.CreateAsset(nameData, fileName);
}
static void ParseLastNames()
{
string filePath = Application.dataPath + "/Settings/lastnames.csv";
if (!File.Exists(filePath))
{
Debug.LogError("Missing Data: " + filePath);
return;
}
string[] readText = File.ReadAllLines("Assets/Settings/lastnames.csv");
filePath = "Assets/Resources/";
NameList nameData = ScriptableObject.CreateInstance<NameList>();
string fileName = string.Format("{0}Lastnames.asset", filePath, nameData.names);
for (int i = 1; i < readText.Length; ++i)
{
nameData.Load(readText[i]);
}
AssetDatabase.CreateAsset(nameData, fileName);
}
}
Vamos por partes. Creamos un diccionario que contendrá el nombre del csv que vamos a parsear y la función que ejecutaremos cuando se detecte una modificación en el archivo.
El método OnPostProccessAllAssets es el encargado de que los csv almacenados se guarden en nuestro proyecto y se actualicen.
Las funciones ParseMaleNames, ParseFemaleNames y ParseLastNames realizan la misma función. La de crear un .asset con el nombre determinado y almacenar la información contenida en los CSV para que podamos trabajar con ella durante la ejecución del programa.
Este script posee unos requisitos especiales, debe estar contenido en una carpeta llamada "Editor" dentro de Assets para su correcto funcionamiento.
Los demás scripts podemos guardarlos donde queramos teniendo constancia de donde se encuentran y modificar las rutas de acceso a ellos.
Los .assets deben ser almacenados en la carpeta "Resources" para que puedan ser cargados con el Comando Resources.Load<Script> y poder asignar su información a scripts.
Si lo hemos implementado correctamente, podemos eliminar nombres o añadir adicionales para luego verlos aparecer o modificarse los scriptableobjects dentro de nuestro proyecto.
Para la siguiente acción, nos iremos a la creación de clientes y trabajadores aleatorios para salvaguardarlos en nuestro archivo de salvado y cargarlos.
9 - OCT - 2018
Finite State Machine
Hoy vamos a trabajar en algo más interesante, vamos a crear el FSM para las shuttles o vehículos de transporte de clientes.
Lo importante en este punto es determinar cuáles van a ser los pasos que necesitará la shuttle a lo largo de su recorrido. Por ahora analizaremos los más básicos e iremos añadiendo nuevos que permitan mejorar y profundizar en este apartado (cuando no tenga empleado para conducirse, bloquear al empleado para no ser despedido/ir a descansar durante un trayecto o tarea, etc).
- La shuttle tendrá un periodo de reposo a la espera de llegada de clientes
- Conduciendo desde un punto a otro
- Esperando en parada de clientes recogiendo a estos o dejándolos en la oficina.
Creamos el controlador de las Shuttle que permitirá administrar todos los datos de estas y poder ser usados en nuestros estados.
public class ShuttleController : StateMachine {
public Transform officeStop;
public Transform clientsStop;
public float timeReset = 5f;
public NavMeshAgent agent;
public PositionCar positionCar;
[HideInInspector] public int slotSeats = 8;
private void Start()
{
ChangeState<InitShuttleState>();
}
}
Por ahora tendremos controladas las 2 paradas, tiempo de espera que usaremos como referencia, NavMeshAgent para poder mover el vehículo y posición del coche hasta ese momento (lo usaremos para que una vez habilitada la función de seleccionar el vehículo, podamos ver en que estado se encuentra).
Añadimos número de asientos.
Para crear el FSM, necesitamos la estructura que hará que nuestro StateMachine funcione. Será necesario crear nuestros Estado Base del cual heredarán el resto de los Estados de nuestro vehículo.
public class ShuttleState : State {
protected ShuttleController owner;
public Transform officeStop { get { return owner.officeStop; } }
public Transform clientsStop { get { return owner.clientsStop; } }
public float timeReset { get { return owner.timeReset; } }
public PositionCar positionCar { get { return owner.positionCar; } }
public int slotSeats { get { return owner.slotSteats; } }
public NavMeshAgent agent { get { return owner.agent; } }
protected virtual void Awake()
{
owner = GetComponent<ShuttleController>();
}
}
Creamos una referencia a la controladora de la Shuttle de manera Protected, así podremos acceder a los datos de la controladora desde States que hereden de ShuttleState. Mismo proceso para los métodos.
Ahora procedemos a añadir los estados por los que pasará la shuttle durante su trabajo
public class InitShuttleState : ShuttleState {
public override void Enter()
{
base.Enter();
StartCoroutine(Init());
}
IEnumerator Init()
{
yield return null;
owner.positionCar = PositionCar.Parking;
owner.ChangeState<WaitShuttleState>();
}
}
Como podemos apreciar, realizamos un override en la función Enter() para poder incluir la coroutine que dará paso al siguiente estado. He incluido este estado vacío por así decirlo ya que será el que nos permita crear las shuttle desde cero una vez las compremos y tengamos que instanciarlas, además de incluir y hacer referencia a datos necesarios para el futuro.
public class WaitShuttleState : ShuttleState {
protected override void AddListeners()
{
base.AddListeners();
this.AddObserver(OnClientWaitingStop, ClientsActions.ClientsWaitStop);
}
protected override void RemoveListeners()
{
base.RemoveListeners();
this.RemoveObserver(OnClientWaitingStop, ClientsActions.ClientsWaitStop);
}
private void OnClientWaitingStop(object sender, object args)
{
owner.positionCar = PositionCar.Moving;
owner.ChangeState<MovingToClientStopState>();
}
}
Para este estado, añadimos 2 listeners u observadores. Lo que estarán observando será que un cliente o varios aparezcan en la parada y la shuttle vaya a recogerlos. Es el único estado que tendrá estos Observadores. El motivo es que el único momento en el que nos interesa ver que hay clientes, es cuando el vehículo se encuentra estacionado en nuestras oficinas. No queremos que el vehículo salga de recoger clientes y se vuelva a la parada justo en mitad del camino.
public class ClientsActions : MonoBehaviour {
public const string ClientsWaitStop = "ClientsActions.ClientsWaitStop";
public void ClientAreWaiting()
{
this.PostNotification(ClientsWaitStop);
}
}
ClientsActions contendrá las acciones que realizarán los clientes una vez se creen en la parada, lleguen a nuestra oficina y/o acaben su relación comercial con nosotros.
public class MovingToClientStopState : ShuttleState
{
bool hasStopped = false;
public override void Enter()
{
base.Enter();
MovingTo();
}
public override void Exit()
{
base.Exit();
hasStopped = false;
}
void MovingTo()
{
agent.SetDestination(clientsStop.position);
StartCoroutine(StartMoving());
}
IEnumerator StartMoving()
{
while (!CheckPosition())
{
yield return new WaitForSeconds(1f);
}
owner.positionCar = PositionCar.Parking;
owner.ChangeState<ReceiveClientsState>();
}
bool CheckPosition()
{
if(!agent.pathPending)
{
if (agent.remainingDistance <= agent.stoppingDistance)
{
if (!agent.hasPath || agent.velocity.sqrMagnitude == 0f)
{
hasStopped = !hasStopped;
}
}
}
return hasStopped;
}
}
Tenemos la variable hasStopped para comprobar si el vehículo se ha detenido en la parada o si aun sigue en marcha hacia su destino. Lo conseguimos comprobando si no está pendiente de otra ruta, si la distancia entre el vehículo y la parada es menor, si no tiene una ruta establecida o si su velocidad es 0. Si todo esto pasa el visto bueno, se cambia su valor a true y se relanza la coroutine y el siguiente estado.
public class ReceiveClientsState : ShuttleState {
float timeWait = 0;
public override void Enter()
{
base.Enter();
StartCoroutine(MovingToOffce());
}
public override void Exit()
{
base.Exit();
timeWait = 0;
}
IEnumerator MovingToOffce()
{
while (timeWait <= owner.timeReset)
{
yield return new WaitForSeconds(1f);
timeWait++;
}
owner.positionCar = PositionCar.Moving;
yield return new WaitForSeconds(1f);
owner.ChangeState<MovingToOfficeStopState>();
}
}
Por ahora marcamos un tiempo fijo, en su debido momento, se creará aquí un delegate que será el de recogida de clientes, estos activarán los observadores y se añadirán a la cola de clientes. En este momento, se añadirán a un array de clientes y se reducirá su número del slots de cada vehículo. Si pasado el tiempo Reset, nadie se ha subido, la shuttle se marchará, que es lo que realiza en este momento.
MovingToOfficeStopState es igual que MovingToClientStopState salvo que se dirige a una posición u a otra dependiendo de donde se encuentre. Será modificado en el siguiente avance.
Con esto, tenemos un vehículo que espera en la parada de la oficina, que recibe la llamada de un cliente o de varios, se dirige al punto de encuentro y vuelve.
Versión 0.4 - Creación del FSM para las shuttle.
Versión 0.3 - Introducimos la herramienta para cargar datos desde archivos CSV y su actualización automática.
Versión 0.2 - Preparamos lo que es el sistema de carga y salvado del juego.
Versión 0.1 - Analizamos los requisitos necesarios para hacer el proyecto y creamos aquellas clases que serán las principales.