Soluciones arquitectónicas para un juego móvil. Parte 3: Ver en el empuje del jet



En artículos anteriores, describimos cómo un modelo debe organizarse de manera conveniente y con amplias capacidades, qué tipo de sistema de comando sería adecuado para él, actuando como controlador, es hora de hablar sobre la tercera letra de nuestra abreviatura MVC alternativa.

En realidad, Assetstore tiene una biblioteca UniRX muy sofisticada que implementa reactividad y controla la inversión para la unidad. Pero hablaremos de eso al final del artículo, porque esta herramienta poderosa, enorme y compatible con RX para nuestro caso es bastante redundante. Hacer todo lo que necesitamos es perfectamente posible sin abrir el RX, y si lo posee, puede hacer lo mismo con él fácilmente.

Soluciones arquitectónicas para un juego móvil. Parte 1: modelo
Soluciones arquitectónicas para un juego móvil. Parte 2: Comando y sus colas

Cuando una persona está comenzando a escribir el primer juego, parece lógico que exista una función que dibuje toda la forma, o parte de ella, y la tire cada vez que algo importante cambie. A medida que pasa el tiempo, la interfaz crece en tamaño, la forma y las partes de los moldes se vuelven cien, luego doscientos, y cuando la billetera cambia su estado, una cuarta parte de ellos tiene que ser rediseñada. Y luego viene el administrador y dice que "como en ese juego", debes hacer un pequeño punto rojo en el botón si hay una sección dentro del botón en la que hay una subsección en la que está el botón, y ahora tienes suficientes recursos para hacer algo haciendo clic en él. Eso es importante. Y eso es todo, navegó ...

La desviación del concepto de dibujo tiene lugar en varias etapas. Primero, se resuelve el problema de los campos individuales. Tiene, por ejemplo, un campo en el modelo y un campo de texto en el que se deben mostrar todos sus contenidos. Ok, comenzamos un objeto que se suscribe a las actualizaciones de este campo, y con cada actualización agrega los resultados a un campo de texto. En el código, algo como esto:

var observable = new ChildControl(FCPlayerModel.ASSIGNED, Player); observable.onChange(i => Assigned.text = i.ToString()) 

Ahora no necesitamos seguir el rediseño, solo cree este diseño, y luego todo lo que ocurra en el modelo caerá en la interfaz. Bueno, pero engorroso, contiene muchos gestos obviamente innecesarios que un programador tendrá que escribir con sus manos 100.500 veces y, a veces, cometer errores. Vamos a envolver estos anuncios en una función de extensión que ocultará las letras adicionales debajo del capó.

 Player.Get(c, FCPlayerModel.ASSIGNED).Action(c, i => Assigned.text = i.ToString()); 

Mucho mejor, pero eso no es todo. Cambiar el campo del modelo al campo de texto es una operación tan frecuente y típica que crearemos una función de contenedor separada para él. Ahora resulta bastante breve y bien, como me parece.

 Player.Get(c, FCPlayerModel.ASSIGNED).SetText(c, Assigned); 

Aquí mostré la idea principal, que me guiará al crear interfaces para el resto de mi vida: "Si un programador tuviera que hacer algo al menos dos veces, envuélvelo en una función especial, conveniente y corta".

Recolección de basura


Un efecto secundario de la ingeniería de interfaz reactiva es la creación de un grupo de objetos que están suscritos a algo y, por lo tanto, no dejarán memoria sin una patada especial. Para mí, en la antigüedad, se me ocurrió una forma que no es tan hermosa, sino simple y asequible. Al crear cualquier formulario, se crea una lista de todos los controladores que se crean en relación con este formulario, por brevedad, simplemente se llama "c". Todas las funciones especiales de contenedor aceptan esta lista como el primer parámetro requerido y cuando DisconnectModel el formulario, pasa la lista de todos los controles y lo desalmada sin piedad con el código en el ancestro común. Sin belleza y gracia, pero barato, confiable y relativamente práctico. Puede tener un poco más de seguridad si, en lugar de la hoja de control, necesita que IView ingrese y le dé esto a todos estos lugares. Esencialmente lo mismo, olvidarse de completar lo mismo no funcionará, pero es más difícil de hackear. Tengo miedo de olvidar, pero no tengo mucho miedo de que alguien rompa deliberadamente el sistema, porque a esas personas inteligentes se les debe luchar con un cinturón y otros métodos que no son de software, así que me limito a c.

Se puede extraer un enfoque alternativo de UniRX. Cada contenedor crea un nuevo objeto que tiene un enlace al anterior que escucha. Y al final, se llama al método AddTo (componente), que atribuye toda la cadena de controles a algún objeto destructible. En nuestro ejemplo, dicho código se vería así:

 Player.Get(FCPlayerModel.ASSIGNED).SetText(Assigned).AddTo(this); 

Si este último propietario de la cadena decide ser destruido, enviará a todos los controles que le hayan sido asignados el comando "suicídate si no te escucha nadie más que yo". Y toda la cadena se limpia obedientemente. Por supuesto, es mucho más conciso, pero desde mi punto de vista hay una falla importante. AddTo puede olvidarse accidentalmente y nadie lo sabrá hasta que sea demasiado tarde.

De hecho, puede usar el sucio truco de Unity y prescindir de cualquier código adicional en Ver:

 public static T AddTo<T>(this T disposable, Component component) where T : IDisposable { var composite = new CompositeDisposable(disposable); Observable .EveryUpdate() .Where(_ => component == null) .Subscribe(_ => composite.Dispose()) .AddTo(composite); return disposable; } 

Como sabe, un enlace a un Componente Unicomponente o GameObject en Unity es nulo. Pero debe comprender que este hakokostyl crea un detector de actualizaciones para cada cadena de controles que se destruye, y esto ya es un poco cortés.

Modelo de interfaz independiente


Nuestro ideal, que, sin embargo, podemos lograr fácilmente, es la situación en la que podemos cargar el GameState completo en cualquier momento, tanto el modelo verificado por el servidor como el modelo de datos para la interfaz de usuario, y la aplicación estará exactamente en el mismo estado, hasta el estado de todos los botones. Hay dos razones para esto. La primera es que a algunos programadores les gusta almacenar dentro del controlador de formulario, o incluso en la vista misma, citando el hecho de que su ciclo de vida es exactamente el mismo que el del formulario en sí. La segunda es que, incluso si todos los datos para el formulario están en su modelo, el comando para crear y completar el formulario en sí toma la forma de una llamada de función explícita, con algunos parámetros adicionales, por ejemplo, en qué campo de la lista debería enfocarse.

No tiene que lidiar con esto si realmente no desea la comodidad de la depuración. Pero no somos así, queremos depurar la interfaz tan convenientemente como las operaciones básicas con el modelo. Para hacer esto, el siguiente enfoque. En la parte de la interfaz de usuario del modelo, se configura una variable, por ejemplo .main, y en ella, como parte del comando, coloca el modelo de la forma que desea ver. Un controlador especial monitorea el estado de esta variable; si aparece un modelo en esta variable, según su tipo, crea una instancia de la forma deseada, la coloca donde sea necesario y le envía una llamada a ConnectModel (modelo). Si la variable se libera del modelo, el controlador eliminará el formulario del lienzo y lo usará. Por lo tanto, no se producen acciones para omitir el modelo, y todo lo que hizo con la interfaz es claramente visible en el modelo ExportChanges. Y luego nos guiamos por el principio de "todo lo que se ha hecho dos veces" y usamos exactamente el mismo controlador en todos los niveles de la interfaz. Si el molde tiene un lugar para otro molde, se crea un modelo de interfaz de usuario para él y se crea una variable en el modelo del molde principal. Exactamente lo mismo con las listas.

Un efecto secundario de este enfoque es que se agregan dos archivos a cualquier formulario, uno con un modelo de datos para este formulario y el otro, generalmente una monobah que contiene enlaces a elementos de la interfaz de usuario, que, después de recibir el modelo en su función ConnectModel, creará todos los controladores reactivos para todos campos de modelo y todos los elementos de la interfaz de usuario. Bueno, es aún más compacto, por lo que también es conveniente trabajar con él, probablemente sea imposible. Si es posible, escriba en los comentarios.

Lista de controles


Una situación típica es cuando el modelo tiene una lista de algunos elementos. Como quiero que todo se haga de manera muy conveniente, y preferiblemente en una sola línea, también quería hacer algo para las listas que sería conveniente manejar. Una línea es posible, pero resulta ser incómodamente larga. Empíricamente, resultó que casi toda la diversidad de casos está cubierta por solo dos tipos de controles. El primero monitorea el estado de una colección y llama a tres funciones lambda, el primero se llama cuando se agrega algún elemento a la colección, el segundo cuando el elemento abandona la colección y finalmente el tercero cuando los elementos de la colección cambian el orden. El segundo tipo de control más común monitorea la lista y es la fuente de una suscripción de la misma: páginas con un número específico. Es decir, por ejemplo, sigue una Lista con una longitud de 102 elementos, y en sí misma devuelve una Lista de 10 elementos, del 20 al 29. Y los eventos generados son exactamente los mismos que si se tratara de una lista en sí.

Por supuesto, siguiendo el principio de "crear una envoltura para todo lo que se hizo dos veces", apareció una gran cantidad de envolturas convenientes, por ejemplo, una que acepta solo la entrada de Fábrica, creando una correspondencia entre los tipos de modelos y sus Vistas, y un enlace a Canvas en el que Necesitas agregar elementos. Y muchos otros similares, solo alrededor de una docena de envoltorios para casos típicos.

Controles más complejos


A veces surgen situaciones que son redundantes para expresar a través del modelo, tanto como sean obvias. Aquí, los controles que realizan algún tipo de operación en un valor pueden venir al rescate, así como los controles que monitorean otros controles. Por ejemplo, una situación típica: una acción tiene un precio, y el botón está activo solo si hay más dinero en la cuenta que su precio.

 item.Get(c, FCUnitItem.COST).Join(c, Player.Get(c, MONEY)).Func(c, (cost, money) => cost <= money).SetActive(c, BuyButton); 

De hecho, la situación es tan típica que, de acuerdo con mi principio, hay un envoltorio listo para usar, pero luego mostré su contenido.

Tomamos el artículo para comprarlo, creamos un objeto que está suscrito a uno de sus campos y tiene un valor de tipo largo. Agregaron un control más, que también es de tipo largo, el método devolvió un control que tiene un par de valores, y el evento Modificado se activa cuando alguno de ellos cambia, luego Func crea un objeto para cualquier cambio en la entrada que calcula la función, y el evento Modificado si se calcula el valor final La función ha cambiado.

El compilador construirá con éxito el tipo de control necesario sobre la base de los tipos de datos de entrada y el tipo de la expresión resultante. En casos raros cuando el tipo devuelto por la función lambda no es obvio, el compilador le pedirá que lo aclare explícitamente. Finalmente, la última llamada escucha el control booleano, según el cual activa o desactiva el botón.

De hecho, el contenedor real en el proyecto acepta dos botones como entrada, uno para el caso cuando hay dinero y el otro cuando no hay suficiente dinero, y el comando para abrir la ventana modal "Comprar monedas" también se cuelga en el segundo botón. Y todo esto en una línea simple.

Es fácil ver que usando Join y Func puedes construir estructuras complejas arbitrariamente. En mi código, había una función que generaba controles complejos, que calculaba cuánto podía comprar un jugador teniendo en cuenta la cantidad de jugadores de su lado, y la regla de que todos podían superar el presupuesto en un 10% si todos juntos no superaban el presupuesto total. Y este es un ejemplo de cómo no es necesario hacerlo, porque lo simple y fácil de depurar lo que sucede en los modelos es igual de difícil detectar un error en los controles reactivos. Incluso captará la ejecución y pasará mucho tiempo para comprender lo que la condujo.

Por lo tanto, el principio general de usar controles complejos es el siguiente: al crear prototipos de un formulario, puede usar estructuras en controles reactivos, especialmente si no está seguro de que se volverán más complicados en el futuro, pero tan pronto como sospeche que si se rompe, no entenderá lo que sucedió, debe transferir inmediatamente estas manipulaciones al modelo y colocar los cálculos que se realizaron previamente en los controles en métodos de extensión en clases de reglas estáticas.

Esto es significativamente diferente del principio de "Hazlo bien de inmediato", tan querido entre los perfeccionistas, porque vivimos en un mundo de desarrollo de juegos, y cuando comienzas a saltarte un formulario, no puedes estar seguro de lo que hará en tres días. Como dijo uno de mis colegas: "Si obtuviera cinco centavos cada vez que los diseñadores de juegos cambien de opinión, ya sería una persona muy rica". De hecho, esto no es malo, pero incluso viceversa es bueno. El juego debe desarrollarse por prueba y error, porque si no estás haciendo un clon estúpido, entonces no puedes imaginar lo que los jugadores realmente necesitan.

Una fuente de datos para múltiples vistas


Para tantos casos arquetípicos que necesita hablar sobre ellos por separado. Sucede que el mismo modelo de un elemento como parte de un modelo de interfaz se representa en una Vista diferente dependiendo de dónde y en qué contexto esto suceda. Y usamos el principio: "un tipo, una vista". Por ejemplo, tiene una tarjeta de compra de armas que contiene la misma información sin complicaciones, pero en diferentes modos de tienda debe estar representada por diferentes prefabricados. La solución consta de dos partes para dos situaciones diferentes.

La primera es cuando esta Vista se coloca dentro de dos Vistas diferentes, por ejemplo, una tienda en forma de una lista corta y una tienda con imágenes grandes. En este caso, se configuran dos fábricas separadas para ayudar, creando una coincidencia de tipo prefabricado. En el método ConnectModel de una Vista, usará una y la otra en la otra. Es un caso completamente diferente si necesita mostrar tarjetas con información absolutamente idéntica en un lugar de manera un poco diferente. A veces, en este caso, el modelo del elemento tiene un campo adicional que indica el fondo festivo de un elemento en particular, y a veces es solo que el modelo del elemento tiene un heredero que no tiene ningún campo y solo es necesario dibujarlo con otro prefabricado. En principio, nada contradice.

Parecería una solución obvia, pero vi suficiente en un código extraño sobre bailes extraños con una pandereta en torno a esta situación, y consideré necesario escribir sobre ello.

Caso especial: controles con muchas dependencias


Hay un caso muy especial del que quiero hablar por separado. Estos son controles que monitorean una gran cantidad de elementos. Por ejemplo, un control que monitorea una lista de modelos y resume el contenido de un campo dentro de cada uno de los elementos. Con un gran sobrebubo en la lista, por ejemplo, llenándolo con datos, tal control corre el riesgo de atrapar tantos eventos sobre el cambio como más uno en la lista de elementos. Recalcular la función agregada tantas veces es, por supuesto, una mala idea. Especialmente para tales casos, hacemos un control que se suscribe al evento onTransactionFinished, que sobresale de GameState, y un enlace a GameState, como recordamos, está disponible en cualquier modelo. Y con cualquier cambio en la entrada, este control simplemente pondrá una marca en sí mismo de que los datos originales han cambiado, y solo se volverá a contar cuando reciba un mensaje sobre el final de la transacción, o cuando descubra que la transacción ya se completó en el momento en que recibió un mensaje del flujo de eventos de entrada . Está claro que dicho control puede no estar protegido de mensajes innecesarios si hay dos de esos controles en la cadena de procesamiento de flujo. El primero acumulará una nube de cambios, esperará el final de la transacción, comenzará la secuencia de cambios aún más, y hay otro que ya ha captado un montón de cambios, recibió el evento sobre el final de la transacción (tuvo la mala suerte de estar en la lista de funciones suscritas anteriormente), contó todo y luego bam y otro evento de cambio, y vuelva a contar todo por segunda vez. Puede ser, pero rara vez, y más importante, si sus controles hacen cálculos tan monstruosos más de una vez en un flujo de cálculos, entonces está haciendo algo mal y necesita transferir todas estas manipulaciones infernales al modelo y las reglas, donde , de hecho, el lugar.

Biblioteca preparada para UniRX


Y sería posible limitarnos a todo lo anterior, y comenzar a escribir con calma su obra maestra, especialmente porque en comparación con el modelo y los equipos de control, es muy simple y están escritos en menos de una semana, si la idea de que estaba inventando una bicicleta no se oscureció, y todo ya está pensado y escrito antes de mí se distribuye de forma gratuita a todos.

Al descubrir UniRX, encontramos un diseño hermoso y compatible con los estándares que puede crear hilos de todo en general, fusionarlos inteligentemente, filtrarlos del hilo principal al hilo no principal, o devolver el control al hilo principal, que tiene un montón de herramientas listas para enviar a diferentes lugares, y así sucesivamente. más lejos No tenemos exactamente dos cosas allí: simplicidad y conveniencia de depuración. ¿Alguna vez ha intentado depurar algún edificio de varios pisos en Linq en pasos del depurador? Así que aquí todavía es mucho peor. Al mismo tiempo, nos falta por completo para qué se creó toda esta maquinaria sofisticada. En aras de la simplicidad de los estados de depuración y reproducción, carecemos por completo de una variedad de fuentes de señal, todo sucede en la transmisión principal, porque jugar con subprocesos múltiples en el metajuego es completamente redundante, toda la asincronía del procesamiento de comandos está oculta dentro del motor de envío de comandos, y la asincronía misma ocupa mucho en ella No hay mucho espacio, se presta mucha más atención a todo tipo de comprobaciones, autocomprobaciones y las posibilidades de registro y reproducción.

En general, si ya sabe cómo usar UniRX, lo haré especialmente para usted para los modelos IObservable, y puede usar las funciones de triunfo de su biblioteca favorita donde lo necesite, pero por lo demás le sugiero que no intente construir tanques de automóviles de alta velocidad y autos desde tanques solo en el suelo Que ambos tienen ruedas.

Al final del artículo, tengo para ustedes, queridos lectores, preguntas tradicionales que son muy importantes para mí, mis ideas sobre lo bello y las perspectivas para el desarrollo de mi trabajo científico y técnico.

Source: https://habr.com/ru/post/es436060/


All Articles