¿Cómo funciona realmente el patrón StartCoroutine / return return en Unity?


134

Entiendo el principio de las corutinas. Sé cómo hacer que el estándar StartCoroutine/ yield returnpatrón funcione en C # en Unity, por ejemplo, invocar un método que regresa a IEnumeratortravés de StartCoroutiney en ese método hacer algo, yield return new WaitForSeconds(1);esperar un segundo y luego hacer otra cosa.

Mi pregunta es: ¿qué está pasando realmente detrás de escena? ¿Qué hace StartCoroutinerealmente? ¿Qué IEnumeratorestá WaitForSecondsvolviendo? ¿Cómo StartCoroutinedevuelve el control a la parte "otra cosa" del método llamado? ¿Cómo interactúa todo esto con el modelo de concurrencia de Unity (donde suceden muchas cosas al mismo tiempo sin el uso de corutinas)?


3
El compilador de C # transforma métodos que devuelven IEnumerator/ IEnumerable(o los equivalentes genéricos) y que contienen la yieldpalabra clave. Busque iteradores.
Damien_The_Unbeliever

44
Un iterador es una abstracción muy conveniente para una "máquina de estados". Comprende eso primero y obtendrás también corutinas de Unity. en.wikipedia.org/wiki/State_machine
Hans Passant

2
La etiqueta de la unidad está reservada por Microsoft Unity. Por favor no lo use mal.
Lex Li

11
Este artículo me pareció bastante esclarecedor: las corutinas de Unity3D en detalle
Kay

55
@Kay - Ojalá pudiera comprarte una cerveza. Ese artículo es exactamente lo que necesitaba. Estaba empezando a cuestionar mi cordura ya que parecía que mi pregunta ni siquiera tenía sentido, pero el artículo responde directamente a mi pregunta mejor de lo que podría haber imaginado. ¿Quizás pueda agregar una respuesta con este enlace que pueda aceptar, en beneficio de los futuros usuarios de SO?
Ghopper21

Respuestas:


109

El enlace a menudo detallado de Cority de Unity3D en detalle está muerto. Como se menciona en los comentarios y las respuestas, voy a publicar el contenido del artículo aquí. Este contenido proviene de este espejo .


Unity3D coroutines en detalle

Muchos procesos en los juegos tienen lugar en el transcurso de múltiples cuadros. Tienes procesos 'densos', como la búsqueda de rutas, que trabajan duro cada fotograma pero se dividen en varios fotogramas para no afectar demasiado la velocidad de fotogramas. Tienes procesos 'dispersos', como los desencadenantes del juego, que no hacen nada en la mayoría de los cuadros, pero ocasionalmente se les pide que hagan un trabajo crítico. Y tienes una variedad de procesos entre los dos.

Siempre que esté creando un proceso que tendrá lugar en varios cuadros, sin múltiples subprocesos, debe encontrar alguna forma de dividir el trabajo en fragmentos que se puedan ejecutar uno por cuadro. Para cualquier algoritmo con un bucle central, es bastante obvio: un pathfinder A *, por ejemplo, puede estructurarse de modo que mantenga sus listas de nodos de forma semipermanente, procesando solo un puñado de nodos de la lista abierta de cada cuadro, en lugar de intentar hacer todo el trabajo de una vez. Hay que hacer un balance para administrar la latencia: después de todo, si está bloqueando su velocidad de cuadros a 60 o 30 cuadros por segundo, entonces su proceso solo tomará 60 o 30 pasos por segundo, y eso podría hacer que el proceso solo tome demasiado largo en general Un diseño ordenado podría ofrecer la unidad de trabajo más pequeña posible en un nivel, p. Ej. procese un solo nodo A *, y coloque encima una forma de agrupar el trabajo en trozos más grandes, por ejemplo, siga procesando nodos A * durante X milisegundos. (Algunas personas llaman a esto "división de tiempo", aunque yo no).

Aún así, permitir que el trabajo se rompa de esta manera significa que tiene que transferir el estado de un cuadro a otro. Si está rompiendo un algoritmo iterativo, entonces debe preservar todo el estado compartido entre las iteraciones, así como un medio para rastrear qué iteración se realizará a continuación. Eso no suele ser tan malo: el diseño de una 'clase de pathfinder A *' es bastante obvio, pero también hay otros casos que son menos agradables. A veces se enfrentará a largos cálculos que realizan diferentes tipos de trabajo de cuadro a cuadro; el objeto que captura su estado puede terminar con un gran desorden de 'locales' semi-útiles, guardados para pasar datos de un cuadro a otro. Y si se trata de un proceso escaso, a menudo terminas teniendo que implementar una pequeña máquina de estado solo para rastrear cuándo se debe hacer el trabajo.

¿No sería genial si, en lugar de tener que rastrear explícitamente todo este estado a través de múltiples cuadros, y en lugar de tener que realizar múltiples subprocesos y administrar la sincronización y el bloqueo, etc., podría escribir su función como un solo fragmento de código, y ¿Marcar lugares particulares donde la función debería 'pausar' y continuar más adelante?

Unity, junto con otros entornos e idiomas, proporciona esto en forma de Coroutines.

¿Como se ven? En "Unityscript" (Javascript):

function LongComputation()
{
    while(someCondition)
    {
        /* Do a chunk of work */

        // Pause here and carry on next frame
        yield;
    }
}

C ª#:

IEnumerator LongComputation()
{
    while(someCondition)
    {
        /* Do a chunk of work */

        // Pause here and carry on next frame
        yield return null;
    }
}

¿Cómo trabajan? Permítanme decir, rápidamente, que no trabajo para Unity Technologies. No he visto el código fuente de Unity. Nunca he visto las agallas del motor de rutina de Unity. Sin embargo, si lo han implementado de una manera radicalmente diferente de lo que estoy a punto de describir, entonces me sorprenderé bastante. Si alguien de UT quiere intervenir y hablar sobre cómo funciona realmente, entonces sería genial.

Las grandes pistas están en la versión C #. En primer lugar, tenga en cuenta que el tipo de retorno para la función es IEnumerator. Y en segundo lugar, tenga en cuenta que una de las declaraciones es el rendimiento del rendimiento. Esto significa que el rendimiento debe ser una palabra clave, y como el soporte C # de Unity es vanilla C # 3.5, debe ser una palabra clave vanilla C # 3.5. De hecho, aquí está en MSDN , hablando de algo llamado 'bloques iteradores'. Entonces, ¿qué está pasando?

En primer lugar, está este tipo de IEnumerator. El tipo IEnumerator actúa como un cursor sobre una secuencia, proporcionando dos miembros significativos: Current, que es una propiedad que le proporciona el elemento sobre el que se encuentra actualmente el cursor, y MoveNext (), una función que se mueve al siguiente elemento de la secuencia. Como IEnumerator es una interfaz, no especifica exactamente cómo se implementan estos miembros; MoveNext () podría simplemente agregar uno a Current, o podría cargar el nuevo valor de un archivo, o podría descargar una imagen de Internet y hacer un hash y almacenar el nuevo hash en Current ... o incluso podría hacer una cosa por primera vez elemento en la secuencia, y algo completamente diferente para el segundo. Incluso podría usarlo para generar una secuencia infinita si así lo desea. MoveNext () calcula el siguiente valor en la secuencia (devuelve falso si no hay más valores),

Normalmente, si quisiera implementar una interfaz, tendría que escribir una clase, implementar los miembros, etc. Los bloques de iterador son una forma conveniente de implementar IEnumerator sin toda esa molestia: solo tiene que seguir algunas reglas y el compilador genera automáticamente la implementación de IEnumerator.

Un bloque iterador es una función regular que (a) devuelve IEnumerator y (b) usa la palabra clave de rendimiento. Entonces, ¿qué hace realmente la palabra clave de rendimiento? Declara cuál es el siguiente valor en la secuencia, o que no hay más valores. El punto en el que el código encuentra un retorno de rendimiento X o un salto de rendimiento es el punto en el que IEnumerator.MoveNext () debe detenerse; un retorno de rendimiento X hace que MoveNext () devuelva verdadero y se asigne el valor X a Corriente, mientras que un límite de rendimiento hace que MoveNext () devuelva falso.

Ahora, aquí está el truco. No tiene que importar cuáles son los valores reales devueltos por la secuencia. Puede llamar a MoveNext () repetidamente e ignorar Current; los cálculos aún se realizarán. Cada vez que se llama a MoveNext (), su bloque iterador se ejecuta a la siguiente instrucción 'rendimiento', independientemente de la expresión que realmente produzca. Entonces puedes escribir algo como:

IEnumerator TellMeASecret()
{
  PlayAnimation("LeanInConspiratorially");
  while(playingAnimation)
    yield return null;

  Say("I stole the cookie from the cookie jar!");
  while(speaking)
    yield return null;

  PlayAnimation("LeanOutRelieved");
  while(playingAnimation)
    yield return null;
}

y lo que realmente ha escrito es un bloque iterador que genera una larga secuencia de valores nulos, pero lo importante son los efectos secundarios del trabajo que realiza para calcularlos. Puede ejecutar esta rutina usando un bucle simple como este:

IEnumerator e = TellMeASecret();
while(e.MoveNext()) { }

O, más útilmente, podría mezclarlo con otro trabajo:

IEnumerator e = TellMeASecret();
while(e.MoveNext()) 
{ 
  // If they press 'Escape', skip the cutscene
  if(Input.GetKeyDown(KeyCode.Escape)) { break; }
}

Todo está en el tiempo Como has visto, cada declaración de rendimiento debe proporcionar una expresión (como nulo) para que el bloque iterador tenga algo que asignar realmente a IEnumerator.Current. Una larga secuencia de nulos no es exactamente útil, pero estamos más interesados ​​en los efectos secundarios. ¿No estamos?

Hay algo útil que podemos hacer con esa expresión, en realidad. ¿Qué pasa si, en lugar de ceder nulo e ignorarlo, arrojamos algo que indica cuándo esperamos tener que hacer más trabajo? A menudo tendremos que continuar directamente en el siguiente fotograma, claro, pero no siempre: habrá muchas ocasiones en las que queremos continuar después de que una animación o sonido haya terminado de reproducirse, o después de que haya pasado una cantidad de tiempo particular. Aquellos mientras (playingAnimation) producen retorno nulo; las construcciones son un poco tediosas, ¿no te parece?

Unity declara el tipo base YieldInstruction y proporciona algunos tipos derivados concretos que indican tipos particulares de espera. Tienes WaitForSeconds, que reanuda la rutina después de que haya transcurrido el tiempo designado. Tienes WaitForEndOfFrame, que reanuda la rutina en un punto particular más adelante en el mismo marco. Tienes el tipo de Corutina en sí mismo, que, cuando la corutina A produce la corutina B, pausa la corutina A hasta que la corutina B haya terminado.

¿Cómo se ve esto desde el punto de vista del tiempo de ejecución? Como dije, no trabajo para Unity, así que nunca he visto su código; pero me imagino que podría verse un poco así:

List<IEnumerator> unblockedCoroutines;
List<IEnumerator> shouldRunNextFrame;
List<IEnumerator> shouldRunAtEndOfFrame;
SortedList<float, IEnumerator> shouldRunAfterTimes;

foreach(IEnumerator coroutine in unblockedCoroutines)
{
    if(!coroutine.MoveNext())
        // This coroutine has finished
        continue;

    if(!coroutine.Current is YieldInstruction)
    {
        // This coroutine yielded null, or some other value we don't understand; run it next frame.
        shouldRunNextFrame.Add(coroutine);
        continue;
    }

    if(coroutine.Current is WaitForSeconds)
    {
        WaitForSeconds wait = (WaitForSeconds)coroutine.Current;
        shouldRunAfterTimes.Add(Time.time + wait.duration, coroutine);
    }
    else if(coroutine.Current is WaitForEndOfFrame)
    {
        shouldRunAtEndOfFrame.Add(coroutine);
    }
    else /* similar stuff for other YieldInstruction subtypes */
}

unblockedCoroutines = shouldRunNextFrame;

No es difícil imaginar cómo se podrían agregar más subtipos de YieldInstruction para manejar otros casos: se podría agregar soporte a nivel de motor para señales, por ejemplo, con un WaitForSignal ("SignalName") YieldInstruction que lo soporte. Al agregar más YieldInstructions, las corutinas mismas pueden volverse más expresivas: el retorno de rendimiento nuevo WaitForSignal ("GameOver") es más agradable de leer que mientras (! Signals.HasFired ("GameOver")) devuelve nulo, si me lo preguntas, aparte de el hecho de que hacerlo en el motor podría ser más rápido que hacerlo en script.

Un par de ramificaciones no obvias Hay un par de cosas útiles sobre todo esto que la gente a veces extraña y que pensé que debería señalar.

En primer lugar, el rendimiento de rendimiento solo produce una expresión, cualquier expresión, y YieldInstruction es un tipo regular. Esto significa que puede hacer cosas como:

YieldInstruction y;

if(something)
 y = null;
else if(somethingElse)
 y = new WaitForEndOfFrame();
else
 y = new WaitForSeconds(1.0f);

yield return y;

Las líneas específicas devuelven nuevo WaitForSeconds (), devuelven nuevo WaitForEndOfFrame (), etc., son comunes, pero en realidad no son formas especiales por derecho propio.

En segundo lugar, debido a que estas corutinas son solo bloques iteradores, puede iterar sobre ellas usted mismo si lo desea; no es necesario que el motor lo haga por usted. He usado esto para agregar condiciones de interrupción a una rutina antes:

IEnumerator DoSomething()
{
  /* ... */
}

IEnumerator DoSomethingUnlessInterrupted()
{
  IEnumerator e = DoSomething();
  bool interrupted = false;
  while(!interrupted)
  {
    e.MoveNext();
    yield return e.Current;
    interrupted = HasBeenInterrupted();
  }
}

En tercer lugar, el hecho de que pueda ceder en otras corutinas puede permitirle implementar sus propias instrucciones de rendimiento, aunque no de manera tan eficaz como si fueran implementadas por el motor. Por ejemplo:

IEnumerator UntilTrueCoroutine(Func fn)
{
   while(!fn()) yield return null;
}

Coroutine UntilTrue(Func fn)
{
  return StartCoroutine(UntilTrueCoroutine(fn));
}

IEnumerator SomeTask()
{
  /* ... */
  yield return UntilTrue(() => _lives < 3);
  /* ... */
}

sin embargo, realmente no recomendaría esto: el costo de comenzar una Coroutine es un poco pesado para mi gusto.

Conclusión Espero que esto aclare un poco algo de lo que realmente está sucediendo cuando usas una Coroutine in Unity. Los bloques iteradores de C # son una pequeña construcción maravillosa, e incluso si no está utilizando Unity, tal vez le resulte útil aprovecharlos de la misma manera.


2
Gracias por reproducir eso aquí. Es excelente y me ayudó significativamente.
Naikrovek

96

El primer encabezado a continuación es una respuesta directa a la pregunta. Los dos títulos siguientes son más útiles para el programador cotidiano.

Detalles de implementación posiblemente aburridos de las rutinas

Las corutinas se explican en Wikipedia y en otros lugares. Aquí solo proporcionaré algunos detalles desde un punto de vista práctico. IEnumerator, yield, Etc., son características del lenguaje C # que se utilizan para algo así como un propósito diferente en la Unidad.

En pocas palabras, un IEnumeratorreclamo de tener una colección de valores que puede solicitar uno por uno, algo así como un List. En C #, una función con una firma para devolver un IEnumeratorno tiene que crear y devolver uno, pero puede permitir que C # proporcione un implícito IEnumerator. La función puede proporcionar el contenido devuelto IEnumeratoren el futuro de manera perezosa, a través de yield returndeclaraciones. Cada vez que la persona que llama solicita otro valor de ese implícito IEnumerator, la función se ejecuta hasta la siguiente yield returninstrucción, que proporciona el siguiente valor. Como subproducto de esto, la función se detiene hasta que se solicita el siguiente valor.

En Unity, no los usamos para proporcionar valores futuros, explotamos el hecho de que la función se detiene. Debido a esta explotación, muchas cosas sobre las corutinas en Unity no tienen sentido (¿Qué IEnumeratortiene que ver con algo? ¿Qué es yield? ¿Por qué new WaitForSeconds(3)? Etc.). Lo que sucede "bajo el capó" es que los valores que proporciona a través del IEnumerator se utilizan StartCoroutine()para decidir cuándo solicitar el siguiente valor, lo que determina cuándo su rutina se reanudará nuevamente.

Your Unity Game es Single Threaded (*)

Las corutinas no son hilos. Hay un bucle principal de Unity y todas esas funciones que escribes están siendo llamadas por el mismo hilo principal en orden. Puede verificar esto colocando un while(true);en cualquiera de sus funciones o rutinas. Congelará todo, incluso el editor de Unity. Esto es evidencia de que todo se ejecuta en un hilo principal. Este enlace que Kay mencionó en su comentario anterior también es un gran recurso.

(*) Unity llama a sus funciones desde un hilo. Entonces, a menos que usted mismo cree un hilo, el código que escribió es de un solo hilo. Por supuesto, Unity emplea otros hilos y puede crearlos usted mismo si lo desea.

Una descripción práctica de las rutinas para programadores de juegos

Básicamente, cuando se llama StartCoroutine(MyCoroutine()), es exactamente igual que una llamada a la función regular para MyCoroutine(), hasta que la primera yield return X, donde Xes algo así como null, new WaitForSeconds(3), StartCoroutine(AnotherCoroutine()), break, etc Esto es cuando comienza a ser diferente de una función. Unity "pausa" esa función justo en esa yield return Xlínea, continúa con otros asuntos y pasan algunos marcos, y cuando llega el momento, Unity reanuda esa función justo después de esa línea. Recuerda los valores para todas las variables locales en la función. De esta manera, puede tener un forbucle que se repita cada dos segundos, por ejemplo.

Cuándo Unity reanudará tu rutina depende de lo que Xhaya en tu yield return X. Por ejemplo, si lo usó yield return new WaitForSeconds(3);, se reanuda después de 3 segundos. Si lo usó yield return StartCoroutine(AnotherCoroutine()), se reanudará una vez que AnotherCoroutine()haya terminado por completo, lo que le permite anidar comportamientos a tiempo. Si acaba de utilizar un yield return null;, se reanuda en el siguiente cuadro.


2
Eso es una pena, UnityGems parece estar inactivo por un tiempo por ahora. Algunas personas en Reddit lograron obtener la última versión del archivo: web.archive.org/web/20140702051454/http://unitygems.com/…
ForceMagic

3
Esto es muy vago y corre el riesgo de ser incorrecto. Así es como se compila el código y por qué funciona. Además, esto tampoco responde la pregunta. stackoverflow.com/questions/3438670/…
Louis Hong

Sí, supongo que expliqué "cómo funcionan las corutinas en Unity" desde la perspectiva de un programador de juegos. La pregunta real era preguntar qué está pasando bajo el capó. Si puede señalar partes incorrectas de mi respuesta, me complacería solucionarlo.
Gazihan Alankus

44
Estoy de acuerdo con el retorno de rendimiento falso, lo agregué porque alguien criticó mi respuesta por no tenerlo y tenía prisa por revisar si era útil, y simplemente agregué el enlace. Lo quité ahora. Sin embargo, creo que Unity es de un solo subproceso y cómo las corutinas encajan con eso no es obvio para todos. Muchos de los programadores principiantes de Unity con los que hablé tienen una comprensión muy vaga de todo y se benefician de tal explicación. Edité mi respuesta para proporcionar una respuesta anual a la pregunta. Sugerencias de bienvenida.
Gazihan Alankus

2
La unidad no es un solo hilo. Tiene un hilo principal en el que se ejecutan los métodos de ciclo de vida MonoBehaviour, pero también tiene otros hilos. Incluso eres libre de crear tus propios hilos.
benthehutt

10

No podría ser más simple:

La unidad (y todos los motores de juego) están basados ​​en marcos .

Todo el punto, toda la razón de ser de la Unidad, es que está basado en marcos. El motor hace las cosas "cada cuadro" por usted. (Anima, renderiza objetos, hace física, etc.)

Usted podría preguntar ... "Oh, eso es genial. ¿Qué pasa si quiero que el motor haga algo por mí en cada cuadro? ¿Cómo le digo al motor que haga tal o cual cosa en un cuadro?"

La respuesta es ...

Para eso es exactamente una "corutina".

Es así de simple.

Y considera esto ...

Conoces la función "Actualizar". En pocas palabras, todo lo que pones allí se hace en cada cuadro . Literalmente es exactamente lo mismo, sin ninguna diferencia, de la sintaxis de rendimiento de rutina.

void Update()
 {
 this happens every frame,
 you want Unity to do something of "yours" in each of the frame,
 put it in here
 }

...in a coroutine...
 while(true)
 {
 this happens every frame.
 you want Unity to do something of "yours" in each of the frame,
 put it in here
 yield return null;
 }

No hay absolutamente ninguna diferencia.

Nota al pie: como todos han señalado, Unity simplemente no tiene hilos . Los "marcos" en Unity o en cualquier motor de juego no tienen ninguna conexión con hilos de ninguna manera.

Las rutinas / rendimiento son simplemente cómo accede a los marcos en Unity. Eso es. (Y, de hecho, es absolutamente lo mismo que la función Update () proporcionada por Unity). Eso es todo, es así de simple.


¡Gracias! Pero su respuesta explica cómo usar las corutinas, no cómo funcionan detrás de escena.
Ghopper21

1
Es un placer, gracias. Entiendo lo que quieres decir: esta puede ser una buena respuesta para los principiantes que siempre se preguntan qué diablos son las rutinas. ¡Salud!
Fattie

1
En realidad, ninguna de las respuestas, aunque sea un poco, explica lo que está sucediendo "detrás de escena". (Que es que es un IEnumerator que se apila en un programador).
Fattie

Dijiste "No hay absolutamente ninguna diferencia". Entonces, ¿por qué Unity creó Coroutines cuando ya tienen una implementación de trabajo exacta Update()? Quiero decir que debería haber al menos una ligera diferencia entre estas dos implementaciones y sus casos de uso, lo cual es bastante obvio.
Leandro Gecozo

hola @LeandroGecozo - Yo diría más, que "Actualizar" es solo una especie de simplificación ("tonta") que agregaron. (Mucha gente nunca lo usa, ¡solo usa corutinas!) No creo que haya una buena respuesta a tu pregunta, es solo cómo es la Unidad.
Fattie

5

Excavó en esto últimamente, escribió una publicación aquí - http://eppz.eu/blog/understanding-ienumerator-in-unity-3d/ - que arroja una luz sobre las partes internas (con ejemplos de códigos densos), la IEnumeratorinterfaz subyacente , y cómo se usa para las corutinas.

Usar enumeradores de colecciones para este propósito todavía me parece un poco extraño. Es lo contrario a lo que los enumeradores se sienten diseñados. El punto de los enumeradores es el valor devuelto en cada acceso, pero el punto de Coroutines es el código entre los retornos de valor. El valor devuelto real no tiene sentido en este contexto.


0

Las funciones básicas en Unity que obtienes automáticamente son la función Start () y la función Update (), por lo que Coroutine son esencialmente funciones como las funciones Start () y Update (). Cualquier función antigua func () se puede llamar de la misma manera que se puede llamar a Coroutine. Obviamente, la unidad ha establecido ciertos límites para las Coroutinas que las hacen diferentes de las funciones normales. Una diferencia es en lugar de

  void func()

Usted escribe

  IEnumerator func()

para corutinas. Y de la misma manera puede controlar el tiempo en funciones normales con líneas de código como

  Time.deltaTime

Una corutina tiene un control específico sobre la forma en que se puede controlar el tiempo.

  yield return new WaitForSeconds();

Aunque esto no es lo único que se puede hacer dentro de un IEnumerator / Coroutine, es una de las cosas útiles para las que se utilizan Coroutines. Tendría que investigar la API de secuencias de comandos de Unity para conocer otros usos específicos de Coroutines.


0

StartCoroutine es un método para llamar a una función IEnumerator. Es similar a simplemente llamar a una función vacía simple, la diferencia es que la usa en las funciones de IEnumerator. Este tipo de función es única, ya que puede permitirle usar una función de rendimiento especial , tenga en cuenta que debe devolver algo. Eso es lo que yo sé. Aquí escribí un simple juego de parpadeo sobre el método de texto en la unidad

    public IEnumerator GameOver()
{
    while (true)
    {
        _gameOver.text = "GAME OVER";
        yield return new WaitForSeconds(Random.Range(1.0f, 3.5f));
        _gameOver.text = "";
        yield return new WaitForSeconds(Random.Range(0.1f, 0.8f));
    }
}

Luego lo llamé desde el IEnumerator

    public void UpdateLives(int currentlives)
{
    if (currentlives < 1)
    {
        _gameOver.gameObject.SetActive(true);
        StartCoroutine(GameOver());
    }
}

Como puede ver, utilicé el método StartCoroutine (). Espero haber ayudado de alguna manera. Yo también soy un ganador, por lo que si me corrige o me aprecia, cualquier tipo de comentario sería excelente.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.