Repository navigation
Mesa de juego
English · Español
La pestaña «Jugar» es una mesa de cocina virtual para jugar un deck de la biblioteca: en solitario, con hasta diez jugadores en un mismo equipo o en red. La pantalla solo pinta y manda acciones: la partida vive en Rust, en una sesión que envuelve el motor puro del crate mesa (Motor de la mesa), y la pantalla recibe la vista y sus parches por un canal ordenado. Esta página cubre el lado de la app: preparar las imágenes, la sesión, guardar y seguir, el tapete, la geometría del tablero, el arrastre, los menús, los atajos, los diálogos, los ajustes y el rendimiento.
Código: src/features/mesa/ (pantalla, con listas/ para las listas para jugar), src/stores/mesa-store.ts (la vista), src-tauri/src/mesa/ (sesion.rs, preparar.rs, imagenes.rs, guardada.rs, historial.rs, repeticion.rs, encuentro.rs, tapete.rs, dados.rs, originales.rs, ejemplo.rs) y src-tauri/src/commands/mesa.rs (comandos).
- Sin motor de reglas. Como en el motor, nada comprueba maná, fases ni legalidad. Quien juega mueve cartas, gira, cuenta vidas y contadores, tira dados; la mesa lo apunta y deja deshacer. Las ayudas (robar y enderezar al empezar el turno, impuesto del comandante, contadores al entrar, avisos de vida) se pueden apagar.
-
Solitario, local o en red. Las partidas en red (host, invitados, espectadores, la sala de espera) están en Juego en línea; la pantalla es la misma y, con varios jugadores, los jugadores de la esquina cambian el tablero que se ve (
mesa-store.mirando,tableroDe; la mano de un rival sale como reversos).mesa_acciontambién recibecomo: IdJugador | null(actuar como otro jugador de una sesión local); la pantalla mandacomo: null, porque en una partida local quien se mira es quien juega (ver «Varios jugadores» más abajo). -
La pantalla no decide nada. Las posiciones del campo son la
Posdel motor (0–10.000 por eje);disposicion.tssolo las pasa a píxeles. Qué es una carta, si se ve, a dónde va tras una acción: todo sale de Rust.
sequenceDiagram
participant UI as Tablero / menús / atajos
participant OP as operaciones.ts + acciones.ts
participant CMD as commands/mesa.rs
participant S as Sesion (mesa/sesion.rs)
participant M as crate mesa
participant ST as mesa-store
UI->>OP: p. ej. op.girar(id)
OP->>CMD: call("mesa_accion", { accion, como: null })
CMD->>S: con_sesion(|s| s.accion(accion, como))
S->>M: Registro::procesar(Intencion, &mut Azar, ahora)
M-->>S: Hecho { evento, tocado } o Rechazo
S->>M: parche(&Partida, &tocado, Mirador)
S-->>ST: Channel MensajeMesa::Parche { parche, evento }
ST->>ST: conParche (¿versión + 1?) si no, mesa_vista
CMD->>CMD: guardar_si_toca(state, false)
CMD-->>OP: Ok(()) o AppError Rejected { motivo }
OP-->>UI: aviso con el motivo (mensajeDeError)
-
hacer(accion)(acciones.ts) es el único que manda. Devuelvetrue/false; un rechazo sale como aviso. El resultado de la acción no vuelve en lo que devuelve el comando: llega por el canal. Así hay un solo camino para todo cambio (las acciones propias, deshacer, reiniciar y, más adelante, las de los demás jugadores). -
operaciones.tsreúne lo que se hace con las cartas (girar, boca abajo, transformar, contadores, mover a una zona, adjuntar, flechas, montones, lo de la biblioteca, deshacer/rehacer). Los menús, los atajos y el arrastre lo llaman, así cada comportamiento se escribe una vez. Cada operación actúa sobre la selección si la carta está en ella (objetivo(id)); si no, solo sobre esa carta. - El canal es un
tauri::ipc::Channel<MensajeMesa>que abremesa_suscribir(conexion.ts::suscribir). Entrega en orden, que es lo que necesita una ristra de parches. Mensajes:Vista { vista, registro }(la vista entera: al suscribirse, al deshacer y al reiniciar, con el registro que sigue contando),Parche { parche, evento }(lo que cambió y el evento, para el registro),Cerraday, en red,Rechazo { motivo }(el anfitrión no hizo una intención de esta pantalla; llega después, no como respuesta demesa_accion). -
Sesion::accionmanda unaVistaentera cuando el evento es unCambio::Restaurar(deshacer y reiniciar sustituyen la partida entera) y unParcheen los demás casos. -
mesa-store.ts::conParchesolo acepta un parche cuyaversionsea la actual más uno; si no, devuelvenullyconexion.tspide la vista entera (mesa_vista). Copia solo lo que cambia: una carta que no se tocó sigue siendo el mismo objeto y suCartaMesamemorizada no se repinta. - Un rechazo (
mesa::Rechazo) se convierte enAppErrorRejectedconparams.motivo= el nombre de la variante (impl From<Rechazo> for AppError).src/lib/errores.tstiene un texto por cada motivo conocido (NoExiste,SinPermiso,ZonaVacia,FueraDeTurno,Limite,NoSePuede, y los propios de la sesión comoespectador,no_anfitrionorepeticion) y el texto general deRejectedpara uno desconocido, así un motivo nuevo del motor nunca sale como código en crudo.
EntradaMesa (entrada.tsx) es la pestaña cuando no hay partida abierta:
- El selector de decks es el de la Biblioteca: la misma barra de herramientas (búsqueda, filtros, orden, agrupación, cuadrícula/lista y tamaño), barra lateral de etiquetas y vistas, con sus propias preferencias y filtros recordados (
ORGANIZADOR_JUGAR; véase Biblioteca de mazos). Los decks salen deuseBiblioteca.cargar(). Pulsar elige un deck (sin casillas ni selección múltiple); viene elegido el último deck abierto, y uno que desaparece de la biblioteca se sustituye igual. Doble clic en el deck elegido empieza en solitario. El menú del deck ofrece Abrir (en la pestaña Deck), Propiedades, favorito, etiquetas e idioma, pero no renombrar, duplicar, exportar ni eliminar. Ctrl+F enfoca la búsqueda. El botón «Historial» va en la barra de herramientas. - El formato se deduce de
deck_details(...).kind(commander,limitedy, si no,constructed; «Libre» se elige a mano) y las opciones vuelven aopcionesDeFabrica(formato, 1), una copia delOpciones::de_fabricade Rust para un jugador (hay que mantener las dos iguales). Se pueden cambiar las vidas, el modo de mulligan (londres,londresPrimeroGratis,amistoso,libre), robar y enderezar al empezar el turno, «El primero no roba», el impuesto del comandante y los contadores al entrar. - El nombre del jugador se recuerda en
localStorage(proxybuilder.mesa.nombre); el color es el primero deCOLORES_JUGADOR. Las variantes van apagadas. «Sparring (rival fantasma)» elige la intensidad con la que empieza la partida (se recuerda comomesa.sparring, también en Ajustes). - «Lista para jugar» elige qué cartas del deck van a la partida (véase Listas para jugar más abajo).
- «Tapete de este deck…» abre el diálogo del tapete del deck elegido.
- Con una partida guardada, una tarjeta «Partida a medias» enseña sus decks y el turno, con «Seguir la partida» y «Descartar». Empezar otra pregunta antes (
¿Empezar otra partida?), porque la nueva sobrescribe el fichero guardado; también el «Empezar» de «Partida local…» (partida-local.tsx, «La partida a medias se pierde.»). «Descartar» también pregunta (¿Descartar la partida?). - Cambiar a otro deck del mismo formato conserva las opciones ya tocadas (vidas, mulligan…); solo un cambio de formato las vuelve a las de fábrica (
formatoActualenentrada.tsx). - La columna de opciones tiene su propio desplazamiento (
ScrollArea) y los botones de empezar se quedan fijos debajo. Un «Empezar»/«Solitario» desactivado dice por qué en su tooltip («Elige un deck.», «Este deck tiene más cartas de las que se juegan: elige una lista.»), igual que el diálogo de la partida local («Hacen falta al menos 2 jugadores.», «A algún jugador le falta el deck o el nombre.», «A algún jugador le falta elegir la lista.», «Revisa los equipos.»). - «Con amigos…» abre «Amigos» (Amigos y mensajes), donde los amigos se escriben y un anfitrión puede invitarles a su partida. Mientras esta app está en una partida en red, «En red…» pasa a «Volver a la partida en red».
mesa_empezar(config: ConfigMesa) es un trabajo largo (state.jobs.alta(), el guardián AppState::trabajo()) que devuelve en el acto el id del trabajo y emite mesa://progress (MesaProgressEvent { jobId, mazo, mazos, hechas, total }) y mesa://done (MesaDoneEvent { jobId, error }). En la pantalla, conexion.ts::conProgreso usa lanzarTrabajo con el tipo "mesa" del trabajo-store, junta varios decks en una sola escala de 0 a 1000 y se suscribe al acabar. Dentro, abrir llama a preparar_mazo por cada jugador, que abre el deck (store::decks::open), comprueba su lista (lista_para_jugar, más abajo), lo prepara (deck_de_mesa con esa lista) y le pone su tapete; después abrir crea Sesion::nueva, la guarda una vez y la deja en AppState::mesa.
Un deck puede tener más cartas de las que se juegan: variantes de diseño de una misma carta, alternativas para probar otras sinergias. Una lista para jugar elige qué copias van a la partida; en Commander, el comandante (una copia concreta, así la variante de diseño que se quiera) y 99 más. El deck no cambia nunca.
-
El dato (
mesa::lista, puro, véase Motor de la mesa):ListaDeJuego { id, nombre, comandantes, cartas }guarda ids de copias del deck (DeckCard.id).comprobar(cartas, lista, formato)devuelve avisosAvisoLista; solo el tamaño, la falta de comandante, más de dos comandantes y las copias de más impiden empezar (AvisoLista::impide). La identidad de color, una carta que no puede ser comandante, una pareja sin compañero ni trasfondo y las copias que ya no están en el deck solo avisan: los datos pueden faltar (una carta propia sin vincular) y en casa se juega como se quiera. -
Dónde se guarda (
store/listas.rs, tabladeck_listas, migración 0005): aparte del deck, como las etiquetas, así el autoguardado del deck (que lo reescribe) nunca las pisa y no cambian suupdated_at; se borran con el deck porON DELETE CASCADE. El nombre se limpia de espacios y se corta en 60 caracteres; uno vacío esInvalidInput/lista_sin_nombre. Viajan en el.pbdeck(Formato del paquete de mazo). -
Los datos de las cartas (
mesa/preparar.rs::cartas_de_lista): las copias elegibles (candidatas_de_lista: todas menos los huecos en blanco, las fichas, los planos y los planes, marcadas o no) con su nombre, tipo, identidad de color, si pueden ser comandante o ir en pareja (comandante_por_texto), si se pueden llevar las que se quiera (tierras básicas ysin_limite_por_texto), el blob del frente para la miniatura y la marca. De Scryfall (la caché o la red, como para la partida) o, si no, del diseño de la carta. -
Al empezar (
commands/mesa.rs::lista_para_jugar): sin lista se juega el deck entero, salvo que cuenten más cartas de las que deja el formato (Construccion::pide_lista; solo Commander tiene máximo), y entoncesInvalidInputconmotivo: falta_lista; una lista que no existe o con un aviso que impide esmotivo: lista. Con lista,secciones_de_mesa(deck, Some(lista))coge solo las copias elegidas, más las fichas, los planos, los planes y las copias marcadas como banquillo.mesa_retomarvuelve a leer la lista solo para saber qué imágenes preparar (si se borró, se ignora). -
Comandos:
mesa_listas(deck)→ListasDeDeck { listas, cuentan }(cuentan: las cartas que cuentan sin lista),mesa_lista_cartas,mesa_lista_comprobar,mesa_lista_guardar,mesa_lista_borrar;JugadorMesa.lista(id opcional) enConfigMesa. -
La entrada (
entrada.tsx): «Lista para jugar» ofrece «Todo el deck (n)» (no, si el deck necesita lista) y las listas guardadas; viene elegida la primera guardada. «Nueva lista…» y «Editar…» abren el editor. En Commander con más de 100 cartas que cuentan y sin lista, un aviso lo explica y «Solitario» queda desactivado. -
El editor (
listas/lista-dialog.tsx): los candidatos a comandante a la izquierda, las copias del deck a la derecha (filtro por nombre o tipo, «Solo las elegidas»). Cada copia es una celda del mismo ancho (una rejillaauto-fill, la carta con su nombre debajo), así variantes y duplicados quedan alineados en filas y no en grupos desiguales; las básicas (sin límite) son una sola celda con su contador n/total, y un contador n/100 en Commander. Rust comprueba la lista 250 ms después de cada cambio y los avisos salen abajo en palabras; se puede guardar a medias, pero los avisos que impiden no dejan empezar la partida. Una lista nueva empieza con las copias sin marca (eleccionDeFabrica). En un móvil las cartas son lo primero: «Comandante» y «Cartas» son pestañas a toda la altura, con miniaturas más grandes; el nombre, la búsqueda, «Solo las elegidas» y un «⋯» con «Las del deck» y «Ninguna» se quedan en filas compactas (de lado, en una columna a la izquierda), los avisos van a una hoja y el pie es una sola fila. -
Elegir sin reglas (
listas/eleccion.ts, con test): en singleton, elegir una variante de diseño quita las otras copias de la misma carta; elegir un comandante lo saca (con sus variantes) de las demás cartas y, con dos ya elegidos, el nuevo sustituye al segundo; las básicas se eligen por número (conCopias).impiderepiteAvisoLista::impide. Las reglas las pone Rust. - Los motivos
falta_lista,listaylista_sin_nombretienen sus textos ensrc/lib/errores.ts(MOTIVOS.InvalidInput).
preparar.rs::deck_de_mesa convierte un Deck de la biblioteca en el DeckDeMesa del motor:
-
mesa::preparar::secciones_de_mesa(deck, lista)decide dónde empieza cada carta (mazo, comandante, banquillo, fichas…; con lista, solo lo elegido; véase Motor de la mesa). -
datos_dereúne las cartas de Scryfall del deck desde la caché; los ids que no están, o que están sinkeywords, se piden por/cards/collectionen lotes de 75. Un fallo de red se apunta en el registro y se juega con lo que haya. - Cada cara recibe una
RefImagen, probando en este orden:- una carta diseñada: la pinta el motor del diseñador (
diseno::pintar) aANCHO_MESAconSalida::Impresion, con la claved-<huella del diseño>; - una impresión de Scryfall sin retocar (
CardSource::Scryfall, sin diseño, retoques neutros, origenScryfallPng/ScryfallLarge, una URL dehttps://cards.scryfall.io/):RefImagen::Scryfall { id, cara, url }y una copia local; - lo demás:
render_rgbadel blob con los ajustes de impresión del deck (oscurecido del borde, tamaño de carta, esquinas) y sin sangrado, con la claver-<RenderSpec::key()>. Los retoques valen solo para el frente, como en la hoja. El dorso es la cara de atrás de una DFC, un reverso propio (blob) o el del deck (default_back; si no tiene, el reverso genérico de la app deBlobs::generic_back, el mismo que sale en las hojas, preparado como cualquier otra imagen).RefImagen::Generica, un reverso que dibuja la pantalla (DorsoGenerico), solo queda por si esa imagen no se puede leer.
- una carta diseñada: la pinta el motor del diseñador (
-
info_derellena elInfoCartadel motor con la carta de Scryfall: tipo, cmc en décimas, coste, colores, tierra/criatura/legendaria (por la cara de delante de la línea de tipo), fuerza, resistencia, lealtad, palabras clave, las funciones deproxy_core::stats::funcionesybono_fijo. Sin datos de Scryfall, una carta diseñada usa su propioCardData(info_de_diseno, tipo y reglas en inglés); si no, solo la línea de tipo deCardMeta. - Las cartas se preparan en paralelo (
rayon) y se llama aprogreso(hechas, total)por cada una.
bono_fijo es una expresión regular para «equipped/enchanted creature gets +N/+N» que descarta la coincidencia si la misma frase dice «for each». Es solo una oferta (véase «Adjuntar» más abajo): muchas auras condicionan su bono y quien juega decide.
Por qué la imagen final: la mesa enseña justo lo que se imprimiría (recorte, bordes, el marco diseñado) y la sesión nunca toca el almacén de blobs. Por qué las impresiones de Scryfall van aparte: en las partidas en línea los demás pueden bajarlas de cards.scryfall.io en vez de recibir los bytes; una imagen Propia se nombra por el SHA-256 de los bytes de su JPEG por lo mismo.
| Qué | Ruta (dentro de app_cache_dir) |
|---|---|
Cara preparada (Propia { sha256 }), JPEG 85 a 745 px |
mesa/<2 hex>/<sha256>.jpg |
Impresión de Scryfall (Scryfall { id, cara }) |
mesa/s/<id>-<cara>.jpg |
Índice clave → RefImagen
|
mesa/i/<clave>.json |
| Copias reducidas que se sirven a la pantalla | renders/m/<sha256 o s-id-cara>-<ancho>.jpg |
El índice es lo que hace que la segunda partida con el mismo deck no pinte nada: una cara cuya clave ya está en el índice y cuyo JPEG existe se reutiliza (de_indice). PINTADAS cuenta las caras pintadas de verdad para el test tres_cartas_tres_imagenes_y_la_segunda_vez_nada. Todo es caché: si se borra, el siguiente arranque (o seguir la partida) lo vuelve a preparar.
La pantalla las pide por pbimg:// (src/lib/image-url.ts::urlMesa): /m/<sha256>/<ancho> y /m/s/<id de Scryfall>/<cara>/<ancho>, con los anchos de protocol.rs::ANCHOS_MESA (146, 244, 488, 672, 745, 1000, 1920). Con 745 o más se sirve el fichero guardado; los anchos menores se reducen una vez y se guardan en renders/m/. protocol.rs::leer_ruta solo acepta un SHA-256 de 64 hexadecimales, un id de Scryfall de 36 caracteres hexadecimales y guiones y la cara 0 o 1 (las_rutas_de_la_mesa), así una ruta no puede salirse de las carpetas. RefImagen::Generica no tiene URL: imagen-carta.tsx pinta DorsoGenerico.
Anchos que se usan: las cartas del campo a la «calidad» de los ajustes (146/244/488), subida a 244 por encima de 120 px y a 488 por encima de 200 px en pantalla; mano y zonas, 244; la vista grande, 488; el tapete, 1920. Los anchos del tablero 146, 244 y 488 (imagenes::ANCHOS_DE_ANTEMANO) se hacen al preparar el deck (reducir_de_antemano, de cada cara y del reverso; un fallo no para nada, la pantalla la pide luego), así al abrir la mesa nunca se ven las cartas aparecer una a una mientras se calculan las reducidas. Una carta del campo y su copia arrastrada usan el mismo ancho (carta-mesa.tsx::anchoDeCarta): otro ancho es otra URL, otra carga y un parpadeo al soltar.
Sesion guarda la ConfigMesa, los decks preparados (mazos, para reiniciar con la misma lista), el Registro del motor (con ModoDeshacer::SinLimite; UltimaPropia en red), su propio Azar, el canal, yo (quién mira en esta pantalla: IdJugador(0) al empezar, lo cambia cambiar_yo en una partida local y en red es el asiento del invitado), sin_guardar, guardada_ms, red (el papel en red, None en solitario o en local), encuentro (el marcador del «al mejor de») y repeticion (mientras se ve una repetición). Vive en AppState::mesa: Mutex<Option<Sesion>>.
- Las semillas salen de la entropía del sistema (
getrandom; el reloj si falla): una para repartir la partida y otra para elAzarde la sesión. -
Accion::Reiniciar(«Otra partida») lo hace la sesión: unaPartidanueva con la misma configuración y los mismos decks y una semilla nueva, y despuésRegistro::reemplazar. -
AppState::ocupado()es verdadero mientras hay una partida abierta (hay_partida()), aunque se esté en otra pestaña. Entonces la limpieza de blobs yupdate_installdevuelvenBusy: una actualización reinicia la app en mitad de la partida (testcon_partida_la_app_esta_ocupada).update_installmira primero la partida y añademotivo: partida, que sale como «Hay una partida abierta: termínala o sal de la mesa para actualizar.» (errores.ts::MOTIVOS.Busy). Rust es la red de seguridad: con la mesa abierta la pantalla ni lo pide, deja la instalación en cola (useActualizacion.alSalirDeLaMesa) y la hace al salir de la mesa (Versiones y actualizaciones automáticas). -
mesa_info_carta(carta)(«Inspeccionar») comprueba primero conmesa::visibilidad::veque quien mira puede ver la carta (si no,Rejected/SinPermiso) y devuelveReglasCartade la caché de Scryfall (unaCaraReglaspor cara) o, sin ella, delInfoCartade la carta (sin texto de reglas). - Tras cada evento del jugador, el sparring (véase «El sparring en pantalla» más abajo).
- En red, actuar a nombre de otro asiento se rechaza (
motivo: como), y elmesa_accionde un espectador se rechaza conmotivo: espectador. -
mesa_origen_scryfall(scryfallId)monta unOrigenCartapara una ficha o una carta de verdad que no está en el deck: la carta de la caché o de la red, sus imágenes bajadas amesa/s/yinfo_de.
La partida en curso es un fichero: app_data_dir/partidas/actual.json.gz, un gzip (flate2 con zlib-rs) de PartidaGuardada { versionCatalogo, config, mazos, inicial, eventos, guardadaMs, encuentro }, escrito de forma atómica (store::blobs::escribir_atomico). Es el registro del motor, no una foto: al seguir se reproduce con Registro::reconstruir, así se conserva el historial de deshacer y la huella es la misma (guardar_y_seguir_deja_la_misma_huella).
Cuándo se escribe:
- al empezar cada partida;
- tras una acción, si hay 50 eventos sin guardar (
CADA) o si hay algo sin guardar y pasaron 20 s desde la última vez (CADA_MS). Lo de los 20 s se mira al llegar una acción (no hay temporizador), así el tiempo parado no añade nada que perder; - a la fuerza (
mesa_guardar) cuando se desmonta la pestaña, antes de «Editar este deck» y al cerrar la ventana (confirmar-cierre.tsxllama aconexion.ts::guardartanto al comprobar como al cerrar); - a la fuerza al abrir la siguiente partida de un encuentro (
mesa_siguiente_partida); -
mesa_salir(true)(«Dejarla para luego»).
Solo se escribe una sesión que tiene la partida entera (Sesion::guardable): la partida de un invitado la guarda el anfitrión, y con una repetición abierta no se escribe nada.
Un fallo al guardar se apunta en el registro y nunca para el juego. mesa_salir(false) («Terminarla», «Descartar», «Salir» tras el final) cierra la sesión y borra el fichero, salvo al salir de una repetición abierta desde el historial, que nunca toca la partida guardada. En red, salir de la mesa es también salir de la partida y de su sala.
mesa_estado devuelve EstadoMesa { abierta, deckIds, guardada } (abierta también vale mientras se mira como espectador); guardada (ResumenGuardada: nombres de los decks, sus ids, turno, ronda y hora de guardado) solo si no hay partida abierta. Un fichero que no se entiende se apunta y se ignora, así nunca impide empezar otra. Un fichero de un catálogo más nuevo (versionCatalogo > VERSION_CATALOGO) se rechaza con Unsupported (motivo: version).
mesa_retomar también es un trabajo: si falta en la caché alguna imagen de los decks guardados (faltan_imagenes), vuelve a preparar esos decks (mismas claves, mismas imágenes; los decks borrados desde entonces se saltan) y después reconstruye la sesión.
La vida de una partida en la pantalla:
- Cambiar de pestaña deja la sesión abierta en Rust.
MesaScreenllama amesa_estadoal montarse y, si hay partida abierta y la pantalla no está suscrita, se vuelve a suscribir; el canal nuevo empieza con la vista entera. - «Salir de la mesa» pregunta: «Terminarla» o «Dejarla para luego».
Sesion::cerrarmandaCerraday el store se vacía. - Al volver a abrir la app no hay partida abierta: la guardada se ofrece en la entrada.
- Cerrar la ventana con una partida abierta pregunta (motivo «Hay una partida en la mesa: se guarda y podrás seguirla al volver.»,
Pendientes.partidaensrc/lib/cierre.ts) y la guarda en cualquier caso.
Una imagen de fondo por deck, o una general para los decks sin tapete propio. Orígenes (OrigenTapete): un fichero (se importa al almacén de blobs), el art_crop de una carta de Scryfall (se baja al almacén como ImageOrigin::Upload) o un blob que ya está guardado. Tapete { blob, velo (0–90 %), desenfoque (0–20 px) } se guarda en JSON en app_settings (mesa.tapete.<id del deck> o mesa.tapete) y su blob se protege de la limpieza en blob_refs con owner_kind = 'mesa' y owner_id = el id del deck o general. Por qué no owner_kind = 'deck': store/decks.rs reescribe las referencias deck de un deck cada vez que se guarda y la borraría. Borrar un deck borra las referencias y el ajuste de su tapete (store::decks::delete).
Al empezar una partida, tapete::de_deck elige el tapete del deck o el general y para_mesa lo reduce a 1920 px de ancho como imagen preparada (Propia, clave t-<blob> en el índice), que viaja en DeckDeMesa.tapete y llega a la pantalla como VistaJugador.tapete. El diálogo enseña el velo y el desenfoque en la muestra. En el tablero, tablero.tsx::Tapete (recibe el id del deck) lee él mismo el tapete guardado con mesa_tapete_get (el del deck o, si no, el general), pinta su blob con urlArte(blob, 2000) y le aplica su velo (la opacidad de la capa negra) y su desenfoque (filter: blur). Lo vuelve a leer cada vez que sube usePantalla.tapeteCambiado: tapete-dialog.tsx llama a avisarTapete() tras poner, quitar o ajustar un tapete, así un cambio a media partida se ve en el acto. Guarda el último tapete leído de cada deck (leidos, copiado en TAPETES_LEIDOS, del módulo, para que dure aunque el tablero se desmonte y se vuelva a montar durante la sesión de la app), así al volver a un tablero se ve el suyo en el acto en vez del del tablero anterior hasta que termina la lectura; un deck que aún no se leyó no enseña imagen (no la equivocada). Un tapete nuevo entra con un fundido sobre el anterior cuando ha cargado; uno ya pintado en esta sesión (TAPETES_PINTADOS) sale sin fundido. Solo sin tapete guardado usa la imagen de la vista (VistaJugador.tapete) bajo un velo del 35 %. Por qué las dos: el tapete de la vista es el que quedó fijado al empezar la partida, el que verían los demás jugadores; el guardado es la elección actual de quien juega, con sus ajustes de pantalla. De qué deck lee el tapete un tablero lo dice privacidad.ts::deckDelTablero: el de su asiento si se sabe. En red solo se sabe el propio (Rust deja vacíos los deckIds de los demás), para el anfitrión y para cada invitado; antes solo lo leía el tablero propio del anfitrión. El diálogo del tapete de una partida (mesa-screen.tsx) cambia el deck del asiento propio.
flowchart TB
subgraph Board["Tablero (un jugador)"]
F["Campo: posiciones libres 0–10.000"]
subgraph Strip["Franja de abajo: 22 % del alto, 90–220 px"]
H["Mano en abanico"]
Z["Mando · (Banquillo) · Biblioteca · Cementerio · Exilio"]
end
end
T["Tapete + velo"] -.-> Board
B["Barra (arriba en el centro)"] -.-> Board
L["Vida, ayudas del turno (abajo a la derecha)"] -.-> Board
P["Pila (izquierda), fantasma (arriba a la derecha)"] -.-> Board
-
disponer(w, h, tamano, conBanquillo)reparte el tablero: la franja de abajo es el 22 % del alto, entre 90 y 220 px; las cartas pequeñas (mano, zonas) miden de alto la franja menos 26 px; las zonas van a la derecha y la mano ocupa el resto; el hueco del banquillo solo sale si tiene cartas. Una carta del campo midemin(24 % del alto del campo, 240 px)× el tamaño de los ajustes (25–200 %). -
posAPx/pxAPospasan del 0–10.000 del motor a píxeles del campo y al revés, recortando. Como las posiciones son relativas, una partida se ve igual tras cambiar el tamaño de la ventana. -
abanicocoloca la mano en abanico: solapadas si no caben y con un giro de hasta 8°. - La pila es una columna a la izquierda (la de arriba, la primera); las flechas (
flechas.tsx) son una capa SVG del color del jugador (continua para atacar, discontinua para objetivo). Cada extremo lo colocaFlechas::punto: una carta del tablero grande, en la carta; yo (Mesa.yo; en una repetición con «Todo a la vista» no hayyo, así que es el dueño del tablero que se mira,flechas.tsx::duenoDeControles), al borde de arriba de mi marcador de vida (medido en el DOM por su atributodata-vida-de, porque crece con los contadores y el botón de pasar turno) cuando está en pantalla; el dueño del tablero grande cuando es de otro, al borde de arriba de su marcador de vida (Controlesenseña el suyo y no el mío en su tablero) o, si no está, arriba en el centro; yo mientras miro el tablero de otro, por el borde que da a mi miniatura; lo que está en otro tablero (una carta que controla otro u otro jugador), en el borde del tablero grande que da a la miniatura de ese jugador (cuentas-flechas.ts::entradaDesde, con los centros de las miniaturas que ponemesas.tsxen el contextoMesasAlrededor; con un solo tablero, arriba en el centro). Una flecha sin ningún extremo en el tablero grande, o de largo casi nulo, no se pinta ahí (se cuenta en las miniaturas); antes, una flecha de una carta del rival hacia mí tenía los dos extremos arriba en el centro y se veía como una punta suelta, con la punta del mismo color (un<marker>por color,idDePunta:context-strokeno lo entiende todo WebKit y la punta salía negra) y, con varios jugadores, una etiqueta junto a la punta con a quién va, pintada del color de ese jugador (cuentas-flechas.ts::duenoDeExtremo: el jugador al que se apunta o quien controla la carta objetivo); la miniatura de un jugador enseña cuántos ataques le apuntan (atacantesDe); las «bolitas» de colores se pintan en el campo y se quitan con doble clic. - El fantasma (
general.fantasma,vida-fantasma.tsx::VidaFantasma, arriba a la derecha) es la vida del rival imaginado del solitario, tratado como un jugador más: − y + o la rueda (los clics en menos de 700 ms se juntan en un evento, como en los jugadores) y un menú contextual con daño, ganar vidas, veneno ± (VenenoFantasma) y daño de comandante por cada comandante de la mesa. Con una flecha en marcha, pulsarlo lo hace el objetivo (objetivo.ts::elegirFantasma, una flecha aExtremo::Fantasma; las flechas que le llegan acaban en su marcador, encontrado porATRIBUTO_FANTASMA). Se pone con un aro rojo cuando estaría fuera (vida 0, el límite de veneno o el de daño de un comandante); quién gana lo sigue decidiendo la persona. Antes del catálogo v4 la pantalla no tenía cómo señalarlo y una flecha al fantasma se convertía en una flecha al jugador 0, es decir, a uno mismo. -
Más fantasmas (
general.fantasmasExtra) simulan una mesa de varios en solitario: «Rivales fantasma» en la entrada (se recuerda enui-store.mesa.rivalesFantasma) empieza con hasta 9, y el menú del marcador tiene «Añadir otro fantasma» y, en todos menos el primero, «Quitar este fantasma». Cada uno tiene su propia vida, veneno y daño de comandante y puede ser objetivo de flechas (data-vida-fantasmalleva su sitio yflechas.tsxmide un punto por marcador). Solo son marcadores: el sparring no los lee, así que no hay más reacciones que con uno. Los marcadores se apilan arriba a la derecha y siguen en otra columna a la izquierda cuando llegarían a tu vida. En un móvil con varios, cada marcador es una ficha pequeña (número y vida): los completos taparían el campo; tocarla abre la pantalla de vida y mantenerla, su menú.
Cambios del motor hechos para esta pantalla (aplicar.rs): una carta que entra al campo sin posición ocupa el primer hueco libre de su fila (11 huecos separados 900 desde x = 600, así una carta de un 8 % de ancho en una pantalla ancha nunca se corta en el borde), y las cartas robadas van al final de la mano.
CartaMesa va memorizada y solo lee su propia carta del store. Se coloca por su centro con un transform (girada 90° si está girada, más el ángulo del abanico en la mano). Insignias: etiquetas, contadores, contadores de palabra clave, fuerza/resistencia (en ámbar si cambió), tamaño del montón (×N), y el texto de una habilidad en la pila. Arriba a la izquierda, apiladas, van una insignia rosa «Robada»/«Prestada» si quien la controla no es su dueño y una violeta «Copia» (con el icono de copiar) en una ficha que copia otra carta (esFicha con copiaDe, de «Create token copy»). No van abajo porque en una carta pequeña taparían la fuerza y resistencia y el texto de reglas. En una carta pequeña (96 px de ancho o menos, medidos con una consulta de contenedor en una capa del tamaño de la carta, así que vale en cualquier tablero y con cualquier zoom) estas insignias, y la del control prestado de arriba a la derecha con el nombre del jugador, se quedan solo con el símbolo; el nombre sigue en aria-label. Las dos esquinas viven en esa capa y se reparten su ancho (cqw, 50 % a la izquierda y 45 % a la derecha, con los nombres largos recortados), así que los dos lados nunca se tocan, y robada y copia se apilan sin pisarse. En una carta girada las insignias se pintan en una caja girada −90°, así quedan derechas y se leen. Una carta boca abajo que quien mira conoce lleva el icono de un ojo; las que están fuera de fase salen desvaídas; arriba a la derecha, bajo la insignia del control prestado, unas marcas redondas enseñan lo que dura sobre la carta: «No se endereza» (candado), «Fuera de fase» (portal) y sus notas atadas (una insignia ámbar con el número cuando hay más de una; su tooltip lista cada nota con un punto del color de su autor), cada una con su tooltip; las resaltadas brillan en uno de 16 colores (colores.ts).
pt.ts::ptDe calcula la fuerza/resistencia que se enseña: la impresa (lo que no es número, como *, cuenta 0) más los contadores de fuerza/resistencia (bonoDeContadores: cada tipo +X/+Y o -X/-Y, como +1/+1, +2/+2 o -0/-1, por su cantidad; los demás tipos no suman) más las filas «otros» y «hasta el final del turno»; un valor fijo lo tapa todo.
El diálogo «Añadir contador» (ContadorDialog en dialogos/pequenos.tsx) ofrece un catálogo en cuatro secciones, y su campo de texto es a la vez el tipo del contador y el filtro del catálogo (cualquier otro texto vale como tipo propio; el motor guarda el tipo como texto libre): «Habituales» (CONTADORES_CARTA, los diez más usados), «Maná» (CONTADORES_MANA: uno por color y el incoloro, para llevar la cuenta del maná que ha producido una criatura), fuerza y resistencia (CONTADORES_PT, los +X/+Y y -X/-Y que existen en cartas reales) y otros contadores (CONTADORES_OTROS, unos 200 tipos sacados del texto de reglas de cartas reales en Scryfall; se dejan fuera los de jugador, como veneno, energía, experiencia o rad). El tipo es lo que guarda la partida (el nombre de las reglas en inglés, así dos jugadores con idiomas distintos hablan del mismo contador) y el nombre que se ve se traduce: colores.ts lleva el nombre oficial en español como cadena fuente y cada idioma lo traduce con el que va impreso en sus cartas (nombreContador en contadores.ts). Esos nombres se sacaron del texto impreso de cartas reales en los datos masivos de Scryfall, en los nueve idiomas con cartas impresas; donde un tipo no se imprimió nunca en un idioma sale el nombre inglés, y lo mismo en ro, pl, nl y uk, que no tienen cartas. Un interruptor «Temporal» convierte lo que se añade en un contador temporal (el tipo lleva el prefijo ~, contadores.ts: conTemporal, esTemporal); el motor los borra al pasar el turno (ver «Motor de la mesa»). También vale para los contadores de palabra clave (PALABRAS_CLAVE, un submenú «Hasta el final del turno» junto a los permanentes en el menú de la carta: «gana vínculo vital este turno»). Los contadores de fuerza y resistencia cuentan aunque sean temporales (bonoDeContadores). En la insignia de la carta un contador temporal lleva un reloj y un aro ámbar, los de maná son una bolita de color con su cantidad, los de fuerza y resistencia salen enteros y los demás, abreviados a cinco letras.
«Editar contadores…» en el menú de la carta abre EditarContadoresDialog: los contadores y los de palabra clave de la carta en dos grupos (permanentes y temporales), cada uno con botones − / +, un campo numérico (fijarContador), un botón de reloj para pasarlo a temporal o a permanente (se pone el nuevo antes de quitar el viejo, así no se pierde nada si uno falla) y uno de papelera. «Añadir contador temporal…» abre el diálogo de añadir con el interruptor encendido.
VistaGrande (vista-grande.tsx, oculta mientras se arrastra) enseña en grande, a la derecha, la carta bajo el ratón (a la izquierda si el puntero estaba en la mitad derecha de la pantalla al empezar a señalar esa carta, para no taparla; el lado se decide una vez por carta con la última posición del puntero; ancho de los ajustes, las dos caras si es de dos caras) con un panel de estado: texto de la habilidad, contadores, fuerza/resistencia y de dónde sale cada bono, palabras clave, etiquetas y «La controla X; es de Y».
La mesa usa sus propios eventos de puntero en vez de una librería de arrastrar y soltar: el campo es posición libre con animación entre zonas, no listas ordenadas.
- Un arrastre empieza a partir de 5 px (
UMBRAL_ARRASTRE). Mientras se arrastra,usePantalla.arrastremueveArrastrado, una copia que sigue al puntero sin esperar a Rust; las originales se desvanecen. - Lo que está adjunto a una carta que se arrastra (equipos, auras, cartas amontonadas:
adjuntaAyagrupadaBajo) viaja con ella en tiempo real. La geometría del campo es una sola función,puestos-campo.ts::puestosDelCampo, que usanCampoyArrastrado; al empezar el arrastreacompanantesdice qué va con ella y se guarda enArrastre.con.Arrastradopinta esas cartas detrás, con sus propios desplazamientos más lo que se mueve el puntero; las reales se ocultan (y aterrizan sin deslizarse, comoaterriza) hasta que llega la carta principal. Lo que ven los demás del arrastre (vivo.arrastre) sigue llevando solo las arrastradas. - Al soltar,
destinoDeSoltarelige el destino: la biblioteca arriba (abajo solo con Mayús al soltar: soltar una carta robada por error en la mitad de abajo la escondía al fondo), cementerio/exilio/mando/banquillo arriba, la mano al final, el campo en esa posición. Las cartas arrastradas por el campo mantienen sus distancias:tablero.tsx::soltarcalcula el sitio nuevo de cada una (el de antes más lo que se movió el puntero) y manda unMoversi es una sola o unColocar(acciones.ts::colocar) si son varias, así llegan juntas y un solo «deshacer» las devuelve todas. Mientras se arrastra una carta no se pinta su sitio de antes (ya no queda la copia atenuada que dejaba, ahoraopacity-0), y tras soltarla la carta se queda donde se soltó: el movimiento va a Rust y vuelve, así que durante ese momento el arrastre se sigue pintando en el punto donde se soltó (Arrastre.soltado,Arrastrado) mientras la carta real, oculta en su sitio de antes (useVivo.soltar, 5 s como mucho,MS_SOLTADA: en red, con un anfitrión lento, el evento puede tardar, y con menos la carta volvía un momento a su sitio de antes y luego saltaba; un rechazo lo borra en el acto conolvidarSoltadas, desdeacciones.tsy desde unRechazoenconexion.ts), toma el relevo en el mismo fotograma en que llega a su sitio nuevo. Una carta recién aterrizada no tiene transición CSS en su posición (CartaMesa,aterriza) ni se anima con FLIP (flip.ts::marcarArrastradas), así que nunca se desliza desde su sitio de antes. Girar es un elemento aparte dentro de ella con su propia transición derotate, así que siempre se anima, incluso nada más soltarla. El arrastre también se manda en vivo a los demás en una partida en red, grupo incluido (véase Juego en línea). Soltar en la zona de la que salieron no hace nada (salvo en la biblioteca y el campo). -
flip.tsanima una carta desde su último sitio en pantalla hasta el nuevo (FLIP), también con lo que mueve el motor (robar, moler, organizar); un sitio de hace más de 5 s no cuenta. - Clic elige; Ctrl/Cmd/Mayús+clic suma o quita; arrastrar en el campo vacío dibuja un recuadro de selección (las cartas cuyo centro queda dentro; con modificador, suma); Ctrl+A elige el campo, Esc suelta.
- «Adjuntar a…» y «Atacar / objetivo» entran en un modo de elegir objetivo (
usePantalla.modo): el siguiente clic en una carta (o en el fantasma, para las flechas) lo completa; Esc o un clic en el campo vacío lo cancela. En una partida local la otra mesa se pinta solo para mirar (LecturaGrande) o en miniatura, y mientras se elige una flecha pulsarla no cambia de mesa: una carta de esa mesa es el objetivo (objetivo.ts::elegirCarta, compartido con el tablero principal) y cualquier otro punto señala al jugador (elegirJugador, una flecha aExtremo::Jugador). Sin miniaturas, ese papel lo hace la lista de jugadores:SelectorTablero(red/estado-red.tsx) en la disposición «Un solo tablero» y la barra de asientos en el móvil (ver «Barra de asientos»). Pulsar a un jugador lo marca como objetivo y el ojo de al lado (Ver el tablero de {nombre}) enseña su tablero sin terminar el modo, para que el objetivo sea una de sus cartas. Cada jugador enseña además cuántos ataques le apuntan. Adjuntar solo admite cartas. Una flecha hacia la mesa de otro se dibuja al centro de arriba de la mesa que miras. Un tercer modo,{ tipo: "nota", nota }, ata una nota: el siguiente clic en una carta del campo la ata (atarNota); una carta de otro sitio da el aviso «Las notas solo se atan a cartas del campo.». - Doble clic: en el campo gira, en la mano juega la carta. Un clic en la biblioteca roba; con el ratón encima, las teclas 1–9 roban esas cartas.
- Con «Mantener pulsado abre el menú de la carta», mantener el botón izquierdo 500 ms lanza un evento
contextmenuy se traga elpointerupy elclicksiguientes, así soltar no elige la opción que quede debajo.
La misma pantalla y el mismo motor juegan de 1 a 10 jugadores. En red son otros equipos (Juego en línea); en un mismo equipo es una partida local. Lo que la pantalla añade para varios tableros es geometría, miniaturas y unos pocos diálogos; quién es oponente de quién, la vida del equipo, el orden de los turnos y quién está fuera lo decide el motor (Motor de la mesa).
«Partida local…» en la entrada abre PartidaLocalDialog: de 2 a 10 asientos y, para cada uno, un nombre, un color y un deck de la biblioteca (con su lista para jugar si la necesita); el formato y las opciones son los de una partida normal. config-local.ts solo arma el ConfigMesa (construirConfig) con lo que se rellena en el diálogo: asientos, formato, cómo se juega por equipos (ModoEquiposLocal: ninguno, equipos, dosCabezas, archienemigo), orden de asientos (primero) y «al mejor de». problemaDeEquipos dice por qué un reparto de equipos no puede empezar (con equipos, ninguno tiene dos jugadores, sinCompaneros; Gigante de dos cabezas pide un número par de cuatro asientos o más, dosCabezasPares); lo demás es de Rust (preparar::iniciar valida que cada equipo exista).
En una partida local quien se mira es quien juega: Sesion::cambiar_yo(jugador) (comando mesa_mirar_como) cambia yo y manda la vista entera otra vez, así el jugador que se ve tiene su mano a la vista y actúa con sus propios permisos, sin tocar como. En una sesión en red se rechaza con Rejected { motivo: "red" } y Sesion::accion rechaza allí un como distinto de yo (motivo: "como"). Los jugadores de la esquina (SelectorTablero) llaman a mirarJugador (mirar.ts), que elige entre mesa_mirar_como (partida local de varios: esLocalDeVarios()) y el mirar de siempre (en red: solo qué tablero se ve, la mano de un rival sigue como reversos). El ajuste «En partida local, pasar a mirar al jugador del turno» (seguirTurno, apagado) hace que useSeguirTurno llame a mesa_mirar_como(activo) cada vez que cambia el turno. Un espectador con la disposición «foco» tiene siempre en grande el tablero del jugador activo.
repartir(n, rect, config, grande, equipos) devuelve un rectángulo por tablero dentro de rect, sin solapes (el de quien mira, primero); disponer sigue repartiendo las zonas dentro de un tablero. Modos (ModoDisposicion, los que ofrece modosPara(n, conEquipos)):
| Modo | Qué hace |
|---|---|
unico |
Un tablero, el de siempre; los demás se alcanzan con los jugadores de la esquina. |
dividido |
Solo con 2: uno al lado del otro o uno encima del otro, con un separador que se arrastra (proporcion 0,2–0,8; 0,7 de fábrica). |
foco |
Un tablero grande y los demás en miniatura en una tira (tira: arriba, derecha o abajo); pulsar una miniatura la hace grande. |
rejilla |
Todos iguales; filas y columnas según el aspecto y n. |
equipos |
Solo con tres jugadores o más y equipos: rejilla con los compañeros juntos. |


ConfigDisposicion se recuerda por número de jugadores (ui-store.mesa.disposicion[n]; la de un espectador en -n, claveDisposicion, para no pisar la de quien juega); con un jugador no hay selector. Sin una guardada, disposicionPorDefecto da dividido con dos jugadores, foco con más y rejilla a un espectador. Mesas (mesas.tsx) pinta el tablero de quien mira como el interactivo y los demás como TableroMini; ordenTableros pone primero al que mira en red, y en una partida local deja el orden de los asientos, para que los tableros no salten al cambiar de jugador. Los tests (disposicion.test.ts) comprueban, para n de 1 a 10 y en cada modo, que ningún rectángulo se sale del contenedor ni se solapa con otro, que en «foco» el de quien mira es el mayor y que todos miden al menos 40 × 28 px.
Una miniatura enseña el color del jugador y el aro de su equipo, el nombre, la vida, sus marcas (MarcasJugador en pequeño, véase abajo), el número de cartas de la mano y de la biblioteca y el campo con las cartas a 146 px (AnchoMiniatura), con las mismas animaciones que el tablero grande: la carta gira con transición y se desliza a su sitio nuevo (FLIP a la escala de la miniatura, también en la escala de lectura LecturaGrande), y en una repetición el deslizamiento dura lo que el paso, véase «Repetición». useMesaLenta mira la mesa como mucho cada 100 ms (MS_MINIATURA) y solo repinta si lo elegido cambió de verdad (igual), así nueve tableros pequeños no frenan el grande. Por debajo de ALTO_CON_CAMPO (64 px) la miniatura solo pinta los números, y las cuentas de la mano y la biblioteca solo salen desde 120 px de ancho. Pasar el ratón por una carta enseña la vista grande. Un clic pasa a ese jugador (mirarJugador; en red, Alt + clic le pone en cambio un aro de aviso), y su menú contextual tiene daño, ganar vida y veneno para ese jugador (OpcionesDeVida; no para un espectador ni en una repetición) y «Cartas originales en mi idioma». Los punteros de los demás sobre ese tablero se pintan en ella como en el grande, un punto con su nombre (FantasmasMini); antes era un punto de 8 px sin nombre que no se veía.

La mano siempre se ve en una miniatura, igual que en el tablero grande cuando miras a otro jugador: una franja en el borde de abajo (ManoMini, hasta MAX_MANO_MINI = 20) en el mismo abanico sutil que la mano grande (abanico, con el arco y el giro reducidos al tamaño de la carta), dibujada con el reverso del deck de ese jugador (el elegido o el genérico) incluso en las cartas de doble cara, para que nadie sepa qué lleva. Solo las cartas que el motor dice que este mirador puede ver (la mano de un compañero, véase «Equipos») salen boca arriba (DatosMini.manoVista). Las capas propias de la miniatura (isolate) quedan por debajo de los diálogos, los paneles laterales y la pantalla de la mano inicial.
Las demás zonas también se ven siempre, como en tu tablero (ZonasMini): el mando (si tiene cartas), la biblioteca con el reverso de ese deck, y el cementerio y el exilio con la carta de arriba (la última de la lista) boca arriba si quien mira puede verla, cada una con cuántas cartas tiene. En una miniatura de al menos FILA_ZONAS = 400 px de ancho van en fila abajo a la derecha, con su nombre; en una más estrecha, en columna a la derecha. Pasar el ratón por la de arriba enseña la vista grande.
Los compañeros se ven la mano sin pedirla (visibilidad::ve_la_mano_de_un_companero): la opción «Los compañeros ven su mano» de la partida local (AjustesLocal.manoDelCompanero, encendida de fábrica) escribe Variantes.manoPrivadaEnEquipo (al revés: encendido = las manos siguen privadas). Vale para equipos, Two-Headed Giant y los rivales del archienemigo. Los rivales y los espectadores solo ven reversos.
Los equipos son del motor: ModoEquipos::Equipos { fuego_amigo } y DosCabezas (30 vidas compartidas por pareja). Con el fuego amigo apagado, Drenar, Extorsion y VidaATodos pasan por Partida::oponentes, que deja fuera a los compañeros; con vida compartida (Equipo.vida_compartida) el daño a uno baja la vida del equipo y los dos jugadores leen el mismo número. La pantalla solo avisa cuando una flecha o un objetivo apunta a un compañero (ataqueAUnCompanero), pinta el color del equipo alrededor del jugador y junta a los compañeros en la disposición «equipos». Un equipo gana junto (Resultado.ganadores). Archenemy usa la misma maquinaria: el archienemigo (asientoArchienemigo, 40 vidas) contra todos los demás como un equipo sin fuego amigo.
-
Daño de comandante (
dialogos/dano-comandante.tsx,dano-comandante.ts): una tabla con una fila por jugador y una columna por comandante de la mesa y, bajo cada comandante, sus lanzamientos y su impuesto (impuestoDe). Es solo apuntar (las celdas de un comandante con su propio dueño están apagadas: no puede darle daño de comandante; solo se puede quitar lo que hubiera):Accion::DanoComandantebaja la vida punto a punto yLanzamientoscuenta los lanzamientos. Quitar más de lo apuntado deja el contador en 0 y devuelve solo la vida que de verdad se había quitado (el motor mueve la vida lo que el contador cambió de verdad, tanto en el jugador como en el fantasma del sparring);deltaSeguroevita igualmente que la pantalla lo pida. El aviso al llegar a 21 es la ayudaavisarVidaCero. -
Planechase (
plano-activo.tsx,planar.ts): el plano activo en el centro, «Planeswalk» y el dado planar con lo que cuesta la siguiente tirada (tiradas_planar: una más cada vez y a cero con el turno). El mazo de planos es común (Comun { Planos }). -
Archenemy (
planes.tsx): el mazo de planes es del archienemigo; «Poner en marcha» y «Abandonar» son solo suyos y, al empezar su turno, un aviso se lo recuerda.
Los turnos siguen el orden de los asientos (Partida.jugadores). En el diálogo local los asientos se arrastran por su asa (ordenTrasMover) o se barajan (ordenAlAzar); la fila «Empieza» elige quién empieza (ConfigMesa.primero; sin él, el motor lo sortea con la semilla de la partida). Regla del motor (aplicar.rs): se salta a los eliminados y la ronda sube al pasar por el asiento del primer jugador (esté vivo o no), no por el asiento 0; si solo queda uno, cada turno es una ronda (tests/turnos.rs).
Quién empieza lo decide siempre el motor (turno.primero, sorteado en mesa::preparar::iniciar). La ruleta solo enseña ese resultado, como los dados: nada de la pantalla puede cambiarlo, y en una partida en red todas las pantallas caen en el mismo jugador porque el valor viaja con la partida.
-
Mientras se prepara la mesa.
conexion.ts::conRuletapone la ruleta a girar en cuanto se lanzamesa_empezarored_empezar, con los asientos deConfigMesay debajo el progreso del trabajo, para que la espera tenga algo que mirar. Si algo falla, se quita. -
La parada. La detiene la primera vista.
alMensajeretiene esa vista hasta que acaban la precarga de imágenes y la ruleta (Promise.all), así que la mesa se abre justo después del resultado. El frenado arranca a la velocidad que traía la rueda (ruleta/cuentas.ts::frenado), dura de 1,8 a 3,6 s y siempre da una o dos vueltas más.azarsolo mueve la aguja dentro del sector del ganador. -
Los invitados no preparan nada al empezar, así que su ruleta sale con la vista: el aviso
Empezadallama aesperarSorteo()para distinguir esa primera vista de la de retomar una partida guardada. Con un relé lento, el invitado sigue en la sala hasta que le llega la partida. -
Otra partida. «Otra partida» y la siguiente de un encuentro no cargan nada, así que la ruleta sale encima de la mesa. Llegan como
MensajeMesa::Vista { nueva: true }, que poneSesion::publicarante unCambio::Restaurarsin nada deshecho. No se puede deducir del registro:Registro::vigentesquita el evento de partida nueva, y deshacer hasta el principio deja el mismo registro vacío. Al espectador no le llega este dato, así que no ve ruleta al reiniciar. -
Cuándo no hay ruleta. Con «Ruleta al sortear quién empieza» apagado en Ajustes (
ruletaEmpieza, encendido de fábrica; solo afecta a esta pantalla, el sorteo es el mismo); en solitario, porque los fantasmas no son jugadores y hay un solo asiento; al retomar o volver a una partida ya en marcha (partidaSinEmpezar); y siempre que no se haya sorteado nada, es decir, si quien empieza se eligió a mano o perdió la partida anterior (VistaEncuentro.empieza). Para que el invitado lo sepa también en una partida suelta, el anfitrión mandaDelHost::Encuentrosiempre que se haya elegido quién empieza, no solo con un encuentro. -
La capa.
ruleta/ruleta.tsxva por portal abody, porque las partidas se empiezan desde diálogos y la mesa es un contexto de apilado propio. No recibe el puntero, porque un clic fuera cerraría el diálogo de debajo. Los temporizadores viven enruleta/estado.tsy no en el componente, de modo que la mesa se abre igual aunque la pestaña Jugar no esté a la vista. Con «reducir movimiento» la rueda no gira y solo enseña el resultado.
Después, en la mesa (turno.tsx, todo a partir de la función pura quienJuega):
-
ChipTurno, en la barra: «Turno de {quien}» con el color del jugador, y «Empieza {quien}» durante el mulligan. «Tu turno» y «Empiezas tú» existen solo en red: en una partida local el «yo» es quien se mira, y diría siempre «Tu turno». En el móvil va en dos líneas con el número de turno, para que la barra no crezca. -
AvisoTurno: la misma frase en grande durante 1,8 s cuando el turno avanza, no al abrir la mesa ni al deshacer. No recibe el puntero. -
AroTurno: un aro del color de la app en el tablero del jugador al que le toca, sin difuminado, porque está sobre cartas que se mueven y un difuminado grande se repintaría cada vez. Las miniaturas enseñan lo mismo con la cabecera más intensa y un icono de «play». -
marcas-jugador.tsxdeja una bandera en el primer jugador durante toda la partida, porque las rondas se cuentan desde ese asiento.
Un encuentro vive en la sesión, no en la Partida (así Accion y Cambio no cambian). mesa::encuentro::Encuentro { al_mejor_de, cerradas } es puro: se ofrecen 1, 3 o 5 (DURACIONES), el marcador se calcula de los resultados cerrados más el de la partida en curso si ya terminó (así deshacer «Terminar partida» nunca deja un punto de más), un equipo gana junto y empieza la siguiente quien perdió la anterior (primero_siguiente). cambiar(mazo, banquillo, sale, entra) valida antes de tocar nada que salgan tantas cartas como entran, que las que salen estén en el mazo y las que entran en el banquillo, y que ninguna se repita. La sesión (mesa/encuentro.rs) lo une a los mazos y abre la siguiente partida con el mismo reemplazo que «Otra partida», pero con los mazos nuevos y el marcador al día. Comandos: mesa_encuentro (VistaEncuentro: marcador, quién empieza y por qué), mesa_banquillo(jugador), mesa_banquillo_cambiar y mesa_siguiente_partida. BanquilloDialog agrupa las copias por nombre (encuentro.ts::agrupar), deja elegir cuántas cambiar a cada jugador y no deja mandarlo si no salen tantas como entran. En una partida local el diálogo cambia el de todos. En red cada jugador cambia solo su propio mazo (Sesion::es_el_mio, si no ajeno) y la siguiente partida la abre el anfitrión. El anfitrión tiene los mazos de todos, así que el invitado se lo pide: AlHost::PideBanquillo y AlHost::CambiaBanquillo { sale, entra }, que contesta con DelHost::Banquillo (el mazo y el banquillo como quedan) o BanquilloNo { motivo }. Por eso mesa_banquillo y mesa_banquillo_cambiar son async y esperan hasta 10 s la respuesta (Invitado.banquillo, un oneshot). Los invitados también llevan el marcador: el anfitrión manda DelHost::Encuentro { encuentro, primero } con la bienvenida y antes de cada partida nueva del encuentro, y cada invitado calcula el marcador con eso y el resultado de su partida. Así FinDePartida enseña al invitado «Banquillo» (no «Siguiente partida») y «El anfitrión empieza la siguiente partida.»; el diálogo tiene «Guardar» en vez de empezar la partida. Tras un traspaso del anfitrión, el nuevo no tiene los mazos de los demás, así que no puede abrir la siguiente partida del encuentro (sin_mazos).
-
Quién está fuera.
mesa::fuera_de_juego(&Partida, j) -> Option<Causa>(muerte.rs, puro) dice si alguien está a 0 vidas o menos (la vida compartida del equipo, si la hay), en el límite de veneno (10) o a 21 de un mismo comandante (Causa::{Vida, Veneno, Comandante { de, carta }}; manda la vida, luego el veneno y luego el comandante) y aún no está eliminado. Viaja enVistaJugador.fuera_de_juego. La mesa nunca elimina a nadie sola:Muertesle pregunta a ese jugador «¿Confirmas que estás fuera?» (a los demás, en su pantalla, «¿Confirmas que {quien} está fuera?») con el motivo y quién lo causó, y mandaEliminar { jugador, por }; para todos los demás es un aviso que no bloquea y, pasados 15 s (ESPERA_APUNTAR_MS), cualquiera puede apuntar la muerte. «Sigo en juego» cierra la pregunta para esa causa (claveCausa); solo vuelve a preguntar si la causa cambia o el jugador se recupera y vuelve a caer.
-
De quién fue el daño.
DanoDeQuien: cuando alguien pierde 5 o más vidas (DANO_MINIMO) en el turno de otro jugador, al jugador activo le sale una pregunta de un clic, «¿De quién fue?» (12 s,DURA_PREGUNTA_MS); la respuesta se recuerda solo en esa pantalla (useDanoDe.ultimo) y es lo que rellenaporcuando ese jugador muere. Se olvida al cerrar la mesa y cada vez que una partida empieza por el mulligan (reiniciar, o la siguiente partida de un encuentro en la misma mesa), así la segunda partida nunca propone al que hizo el daño en la primera. Sin respuesta se apunta como antes.debePreguntarDanoyeliminadoPorDefecto(cuentas-muerte.ts) son las cuentas, con tests. -
Ausentes. El anfitrión cuenta a todos quién ha perdido la conexión (
AvisoRed::Ausentes; ver Juego en línea).Ausentespinta un aviso por cada ausente que no esté eliminado; si el jugador activo lleva 30 s fuera (ESPERA_SALTAR_MS), el siguiente jugador que siga en la partida y conectado (siguienteVivosobre los asientos que no están eliminados ni ausentes: si el siguiente también se fue, nadie podría saltarlo) ve «Saltar el turno de {quien}» (Accion::SaltarTurno) y los demás ven quién puede hacerlo. Su mano se queda en la mesa. En el mulligan, pasado un minuto, el anfitrión se queda con la mano del ausente (mesa::ausencia::quedarse_por_defecto). -
Expulsar. El anfitrión de una partida en red tiene «Expulsar de la partida» en el menú de la ficha de un jugador (
estado-red.tsx, con la confirmación «¿Expulsar a {nombre}?»; comandored_expulsar): el expulsado queda eliminado y no puede volver a entrar en esa partida.
-
Los menús (
menus/menu-carta.tsx,menus/menu-zonas.tsx, el de la vida envida-jugador.tsx) usanContenidoContextual(véase Vista previa en vivo). El de la carta cambia según la zona (campo, mano, pila, mando, cementerio, exilio) y enseña la tecla de cada opción. Menú de la biblioteca: robar, robar varias, moler, exiliar de arriba (boca arriba, boca abajo u oculta), mirar las de arriba o abajo, escrutar, vigilar, buscar una carta o una tierra, revelar y lanzar, manifestar, revelar la de arriba, jugar con la de arriba revelada, rueda y barajar. En la biblioteca de otro (en red, desde su tablero) todo se hace a su nombre (hacerPor,PorJugador) salvo «Revelar y lanzar…» y «Cascada…», que son de lo que lanza uno y por eso ahí no salen. Al cerrar una búsqueda con «Barajar y cerrar» se baraja la biblioteca en la que se buscó (la del otro, a su nombre) y lo que se puso arriba vuelve arriba de ella; antes se barajaba la propia y la suya se quedaba a la vista. Menú de zona: ver; en el cementerio y el exilio, además, una al azar a la mano, al campo o al exilio («Una al azar…»), barajar todas en la biblioteca, todas al fondo en orden al azar y, en el cementerio, exiliarlas todas. Un espectador no tiene menú de zona. Campo vacío: enderezar todo, organizar, crear ficha, poner una bolita aquí, elegir todo y quitar mis flechas. -
Atajos (
atajos.ts): una sola tablaATAJOScon tres contextos,carta(el ratón sobre una carta, o la selección),biblioteca(el ratón sobre la biblioteca) ymesa. La carta gana a la biblioteca y la biblioteca a la mesa. Las teclas no cuentan dentro de un campo de texto ni con un diálogo o un menú abiertos. Ejemplos: T girar, R transformar, F boca abajo, C contador, +/= y − para +1/+1, X fuerza y resistencia, G cementerio, H mano, E exilio (Mayús+E boca abajo), S barajar dentro, B al fondo, I inspeccionar, P resaltar, A adjuntar, D atacar/objetivo, O sacar una del montón; sobre la biblioteca D robar, S escrutar, U vigilar, M moler, E exiliar la de arriba (Mayús+E boca abajo), O mirar la de arriba, V ver, T buscar, L buscar una tierra, H barajar (y del 1 al 9 robar esas); actúan sobre la biblioteca que está bajo el ratón, que es la del tablero grande (tableroDe): en red, mirando el tablero de otro, la suya, como en su menú, que enseña las mismas teclas junto a «Biblioteca de {quien}»; en la mesa U enderezar todo, Ctrl+Z/Ctrl+Y deshacer/rehacer, Mayús+R responder / volver al turno (partida local), Ctrl+Enter pasar el turno, ? la lista. Las teclas de un espectador no hacen nada. Un test comprueba que ninguna tecla se repite en un contexto. Durante una repetición su barra tiene sus propias teclas: Espacio reproducir/pausar, ←/→ un evento, Inicio/Fin al principio o al final. -
Diálogos (
dialogos/, uno a la vez porusePantalla.dialogo): pedir un número o un texto; contador; filas de fuerza y resistencia; etiqueta (16 colores,COLORES_ETIQUETA; los 7 primeros no cambian de sitio porque las partidas guardadas nombran el color por su número, y Rust acepta0..16,aplicar::COLORES_ETIQUETA);mirar.tsx(mirar, escrutar, vigilar: repartir las cartas entre arriba en orden, abajo, cementerio y exilio y confirmar conOrdenar; cerrar sin confirmar las deja donde estaban);buscar.tsx(buscar en la biblioteca con filtros de nombre, tipo y color, ofreciendo barajar al cerrar (barajar no deshace lo que se puso arriba durante la búsqueda: baraja primero y luego vuelve a poner esas cartas arriba, en el orden en que se pusieron, como en una partida de verdad;arribaTrasSacar); ver el cementerio, el exilio, el mando o el banquillo con el separador «Antes»);revelar.tsx(revelar y lanzar: descubrir, cascada, revelar hasta, revelar N, Jodah, Sunbird's y una excavación a medida; primero todos ven lo revelado y después se decide qué hace cada carta y a dónde va el resto);fichas.tsx(las fichas del deck, las rápidas, las de ayuda como las mazmorras, The Monarch o The Ring, cualquier búsqueda en Scryfall o una carta de verdad para copiarla pormesa_origen_scryfall, una libre; cantidad y «Como un montón»);azar.tsx(monedas con «Lanzar la moneda», hasta perder, Pulgares de Krark, tiradas rápidas, N dados de X, el dado planar; el resultado lo decide Rust);terminar.tsx(quién queda fuera, quién gana y cómo; definitivo); la lista de atajos;InspeccionDialog(mesa_info_carta). -
Sin conexión una ficha rápida se crea igual, genérica y con su nombre (
crearFichaconOrigenFichalibre). -
Adjuntar (
operaciones.ts::adjuntar): tras adjuntar, si «Ofrecer el bono…» está encendido, el aura o el equipo tienebonoFijoy el objetivo es una criatura, un aviso ofrece «Sumar», que lo añade a la fila «otros» de la criatura (MasXMasY). El motor nunca lo suma solo. La oferta es una tarjeta llamativa (aviso-bono.tsx,avisarBono: untoast.customcon el bono en grande, el equipo y la criatura, «Sumar» y «Ahora no», 20 s a la vista), no un aviso corriente. -
Campo protegido (
Opciones.campoAjenoProtegido, en red):privacidad.ts::campoAjenodice si una carta del campo la controla otro; esa carta no se arrastra ni se gira con doble clic (tablero.tsx) y su menú solo ofrece «Inspeccionar» y «Tomar el control». Un rechazoSinPermiso(un atajo sobre una carta ajena) no avisa, ni desdeacciones.ts::hacerni delrechazodel anfitrión (conexion.ts): tocar lo que no es tuyo no hace nada. -
Control (
menus/menu-carta.tsx::Control): en el menú del campo, en una carta que controlas «Dar el control a…» (un submenú con los demás jugadores que siguen en la partida) y, en una que controla otro, «Tomar el control» y «Tomar el control hasta el final del turno»; actúan sobre la selección (operaciones.ts::darControl,tomarControl). Una temporal enseña un reloj con el nombre de a quién vuelve (insignias.tsx, y en la vista grande). Lo que cuelga de una carta desde otro campo (mi equipo sobre una criatura que ahora controla otro, un aura de otro jugador sobre la mía) se pinta junto a su carta, que es donde está, y viaja con ella al arrastrarla (puestos-campo.ts::conLasAjenas); antes no se pintaba en ningún sitio. -
El tablero de otro jugador (
tablero.tsx,menus/menu-zonas.tsx): todos los menús de zona actúan sobre el tablero que se mira, por su dueño si no es el tuyo (operaciones.ts::hacerPor→porJugador): el de la biblioteca (robar, moler, exiliar, barajar, mirar o buscar en su biblioteca), los de cementerio y exilio, «Enderezar todo» y «Crear ficha…» (el diálogo de fichas tiene un selector «Para»: Beast Within, Forbidden Orchard). Su mano tiene un menú propio (MenuManoAjena: mirarla, que la enseñe, que descarte al azar o toda, que robe); una vez mirada, las cartas que conoces se ven boca arriba en su sitio del abanico y se pueden mover. Pulsar la biblioteca de otro no roba (va por el menú). -
La búsqueda en Scryfall del diálogo de fichas (
dialogos/fichas.tsx) convierte lo escrito en una consulta (consulta-ficha.ts::consultaDeFicha): el nombre de una ficha no lleva su color ni su fuerza, así que «green beast» buscaBeast c:gy «3/3 beast» buscabeast pow=3 tou=3; lo que ya parece sintaxis de Scryfall (t:goblin, comillas, operadores) se manda tal cual. Con «Solo fichas» se le añade(t:token OR t:emblem OR t:dungeon), y cuando no hay nada el diálogo lo dice. -
Ver una zona entera (
dialogos/buscar.tsx::VerZonaDialog): un modal con todas las cartas de un cementerio o un exilio, sin sacarlas una a una. Tiene una pestaña para cada uno (con su cuenta), un selector con todos los jugadores (y cuántas cartas lleva cada uno ahí, si hay varios) y la misma rejilla que la búsqueda en la biblioteca; desde cada carta se puede ir a la mano, el campo (siempre el de quien mueve, así que sacar del cementerio de otro la pone en el tuyo), el cementerio, el exilio o el fondo de la biblioteca. Se abre con el botón de la calavera de la barra (del tablero que se mira), desde el menú de una zona («Ver») y con doble clic en ella. También está en los menús contextuales: en cualquier carta de un cementerio, exilio o banquillo («Ver todas las cartas (…)», enmenu-carta.tsx::DeOtraZona, porque el clic derecho en la carta de arriba de una zona abre el menú de la carta, no el de la zona), en el menú propio de la zona («Ver») y en el del campo vacío («Cementerios y exilios»). -
Revelar la de arriba:
RevelarArribala deja vista por todos (vista_por = Todos,VistaCarta.revelada) yOcultarArriba { n }lo deshace (solo lo que estaba visto por todos; lo que alguien mira por su cuenta no cambia; está en la lista de lo que se puede hacer por otro jugador, catálogo v2). El tablero pinta la de arriba boca arriba si se juega con la de arriba revelada o si se acaba de revelar (arriba-biblioteca.ts::cartaDeArribaRevelada, también en las miniaturas), y al pasar el ratón sale la vista grande. Una carta revelada lleva un botoncito de «ojo tachado» sobre la biblioteca y el menú de la biblioteca tiene «Dejar de revelar la de arriba»; sigue revelada hasta que se mueve o se baraja la biblioteca. -
Cascada varias veces (
dialogos/revelar.tsx::RevelarDialog, menú de la biblioteca «Cascada, varias veces…» y «Revelar y lanzar… (cascada, descubrir)»): los presets Descubrir, Cascada y Jodah tienen un campo «Veces» (1–20) además del coste N. Cada vuelta esexcavar+ elegir +resolverExcavacion(la que coincide va a la pila de fábrica, el resto abajo al azar), y al resolverla empieza sola la siguiente con la misma N, revelando de lo que queda de la biblioteca (lo de antes ya está abajo o en la pila); el diálogo dice «Revelación 2 de 4» y el botón «Siguiente revelación». Sin cambios en el motor. Si una revelación falla (biblioteca vacía) se para. -
Mostrar una carta (
menus/menu-carta.tsx::Mostrar,operaciones.ts::mostrar): en el menú de una carta de tu mano, y de una carta tuya boca abajo en el campo, «Mostrar…» ofrece «A todos» (MostrarATodos: la ve todo el mundo, también quien solo mira, y sale boca arriba en tu mano en sus pantallas), una entrada por jugador (RevelarA) y «Dejar de mostrar» (DejarDeMostrar, catálogo v2). La teclaMlo alterna sobre la carta (ATAJOS). Una carta mostrada a todos lleva un ojito ámbar en tu mano; al moverla vuelve a taparse. -
Listas para jugar en la partida local (
partida-local.tsx): cada asiento elige su deck y su lista para jugar (ListaDeAsiento: un selector con las listas del deck, o «Todo el deck» si no pasa del tamaño del formato, y un lápiz o un más para editarla o crear una conListaDialog). Un deck de Commander con más de 100 cartas empieza con su primera lista, pero un asiento sin lista no puede empezar (un aviso ámbar nombra a los jugadores). El solitario, «Con amigos…» y «En red…» usan el deck y la lista que se eligen en el panel de la derecha de la entrada. -
Los trabajos que preparan la mesa (
mesa_empezar,mesa_retomar,red_deck,red_unirse,red_empezar) se lanzan concommands/mesa.rs::lanzar_trabajo: una segunda tarea espera a la primera y, si acabó mal (un pánico), emitemesa://donecon un error. Sin eso la pantalla se quedaba en «Preparando la mesa…» para siempre, porque el evento final es lo único que la quita. -
Partida local (varios jugadores en una pantalla): la mano de quien no tiene el turno no se enseña, salen sus reversos (
privacidad.ts::manoOcultaPorTurno; no durante el mulligan ni en partidas en red). Responder fuera de turno (un instantáneo, un contrahechizo): «Responder como …» en la barra (un menú si hay más de dos jugadores),Mayús+Ro pulsar la miniatura del otro jugador pasan a mirar como ese jugador (mirar.ts::responderComo, que solo cambia quién mira; el motor ya deja actuar con lo propio) y su mano queda tras una cortina («Mano de …: pulsa para verla»,usePantalla.manoMostrada, el argumentomostradademanoOcultaPorTurno) hasta que esa persona la destape; al cambiar de jugador se vuelve a tapar (mirarJugadorla limpia). Mientras respondes, la barra ofrece «Volver a …» (volverAlTurno) o, si puede responder más de uno, un menú «Respondiendo como …» para volver al turno o saltar a otro;Mayús+Rrecorre a quienes pueden responder en orden y, tras el último, vuelve al jugador del turno (privacidad.ts::siguienteAlResponder). Con algo en la pila, las miniaturas de los demás parpadean con «Hay algo en la pila: ¿responder?».quienRespondeda quién puede responder (no el del turno ni los eliminados, en el orden de la mesa desde el que juega). Cada tablero usa el tapete de su jugador:deckDelTableroda el deck del asiento en una partida local (en red, solo el del anfitrión al mirar su tablero); sin deck vale el tapete que la partida preparó para ese jugador, nunca mis ajustes. Las miniaturas también pintan ese tapete. -
La otra mesa a escala completa: cuando un tablero tiene un hueco grande (420×340 px o más, como en «dos tableros»),
tablero-mini.tsx::LecturaGrandelo pinta con la misma geometría que el propio (disponer,puestosDelCampo): las cartas del campo del mismo tamaño, sus contadores y su fuerza y resistencia (Insignias), la mano en abanico (reversos, o boca arriba lo que este mirador puede ver) y las zonas con su carta de arriba, solo para mirar; pulsarlo sigue pasando a ese jugador. Los huecos más pequeños (tres jugadores o más) conservan la miniatura. -
mulligan.tsxtapa el tablero hasta quedarse la mano: la mano en grande, la regla del modo elegido y cuántas van al fondo (alFondo, la misma cuenta que elQuedarseCondel motor). Con varios jugadores dice quién empieza, sorteado con la semilla de la partida. -
ayudas-turno.tsx: al empezar el turno del jugador, si las ayudas de robar o enderezar están apagadas, sus botones salen durante 10 s (con un recordatorio de moler por los contadores de rad); el monarca tiene siempre a mano su botón de robar; conavisarVidaCero, avisos al quedarse sin vidas, al llegar al límite de veneno y al límite de daño de comandante. -
vida-jugador.tsx: − y + o la rueda; los clics que llegan en menos de 700 ms se juntan en un solo eventoViday se enseñan como «40 + 2 = 42». Su menú: daño, ganar vida, drenar, vida a todos, veneno, daño de comandante, contadores de jugador, monarca, iniciativa, la bendición de la ciudad, día/noche y revelar la mano. «Pasar turno» va al lado. El tablero grande enseña solo la vida del jugador de ese tablero (mesas.tsx::Controles): en red, mirar el tablero de otro no cambia quién eres (no cambiayo, solomirando), así que ahí el contador es el suyo, con su nombre delante (ajena,conNombre) y sin lo que solo hace uno mismo (pasar el turno, drenar, vida a todos, revelar la mano, terminar la partida). Antes era siempre el tuyo, y «hacerle daño» al rival desde su tablero te quitaba la vida a ti. El menú contextual de una miniatura tiene el mismo daño, ganar vida y veneno (OpcionesDeVida), así el daño va siempre a quien se señala. «Terminar la partida…» solo se ofrece a quien lleva la partida (en local o el anfitrión), como en la barra. Encima del contador,marcas-jugador.tsx::MarcasJugadorenseña lo que lleva el jugador: veneno, el daño de cada comandante (por nombre), monarca, iniciativa y la bendición de la ciudad, cada uno con un icono de juego (game-icons:*) y su tooltip; en rojo lo que ya llega a su límite (limiteVeneno,limiteComandante); el motor solo avisa, quién queda fuera lo confirma la persona. -
responder.tsx: el botón de la barra para responder fuera de turno (un botón o un menú) y «Volver a …». -
barra-mesa.tsx: «Mirando» cuando el tablero que se ve no es el tuyo, un sol o una luna cuando la partida tiene día/noche (designaciones.diaNoche, tooltip «Es de día» / «Es de noche»), turno y ronda con su reloj (turno.desdeMs, parado durante la pausa), pausa, deshacer, rehacer, dados, daño de comandante (en Commander), fichas, quién mira (en red), registro, chat (en red o con varios jugadores), tapete, atajos, pantalla completa (alternarPantallaCompleta()delib/ventana.ts, lo mismo que F11, véase Ajustes y shell), el selector de disposición, el botón del sparring (no en red), reiniciar, terminar, «Editar este deck» (guarda, abre el deck en la pestaña Deck y la partida queda guardada) y salir. -
Al terminar (
general.fin)fin-partida.tsxenseña el resumen de la partida (véase «El final de la partida, el historial y la repetición» más abajo), con «Ver la mesa», «Ver repetición», «Salir» y «Otra partida» (no para un invitado en red: otra partida la empieza el anfitrión, ver Juego en línea).
Los eventos llegan a la pantalla como datos y textoDeEvento escribe cada uno con t() en el idioma de la interfaz; Rust nunca escribe prosa. Lo oculto se escribe según quien mira («roba 2 cartas», nunca cuáles). El store guarda los últimos 500 eventos (MAX_REGISTRO). Los parches añaden su evento; una Vista entera por el canal trae registro, los últimos 500 (REGISTRO_EN_VISTA) de Registro::vigentes() (los eventos que siguen contando: sin lo deshecho, sin lo de antes de reiniciar y sin los propios Restaurar), y recibirVista(v, registro) sustituye la lista por él. Así, al deshacer desaparecen las líneas deshechas, tras «Otra partida» se va el registro de la anterior y al seguir una partida guardada se ve su registro. mesa_vista (que se pide tras saltarse un parche) no trae registro y se queda el que había.
El registro vive en el panel lateral (registro-panel.tsx, PanelLateral): un panel a la derecha de la mesa con tres pestañas, «Registro», «Notas» y «Chat», que se abre desde la barra y se cierra con su propio botón (la vista grande de las cartas se aparta de él, ANCHO_PANEL en pantalla.ts; los diálogos no quedan tapados). No es una ventana aparte.
- Cada línea lleva el icono y el color de su categoría (
categorias-eventos.ts:CATEGORIAS_DE_ACCIONes unRecord<Accion["que"], …>, así que una acción nueva sin categoría no compila), su hora y la frase. Los nombres de carta van marcados en el texto con delimitadores privados (\u0001id\u0002nombre\u0003) quepiezasDeTextovuelve a separar: pasar el ratón por un nombre de una carta que sigue en la mesa la enseña en grande (setSobre). - Soltar varias cartas del campo a la vez es una sola línea, «{quien} recoloca {cartas}» (
Colocar, categoríamover). - Las líneas de «Pasar turno» dicen cuánto duró el turno («pasa el turno tras 2:13»,
duracionDeTurnoencuentas-registro.ts: desde el pase anterior o desde que empezó la partida; nada si el principio ya no está en la lista). - Las líneas de las flechas nombran los dos extremos: «{quien} ataca a {a} con {de}» / «{quien} señala a {a} con {de}» para un jugador, el fantasma («{quien} ataca al fantasma con {de}») o una carta (
textos-eventos.ts, casoflecha), en vez de un «pone una flecha» genérico. - En las últimas 8 líneas (
ULTIMAS_PARA_APILAR), una carta puesta directamente en el campo y que sigue ahí tiene «A la pila» (cartaParaApilar, mandaapilarDespues): para cuando debió pasar por la pila. En solitario cada línea tiene «Deshacer hasta aquí» (Deshacer { hasta: seq }). - Durante una repetición el panel enseña el registro del punto que se mira y no aparece ninguno de los dos botones.
Cada jugador puede apuntarse cosas para acordarse, sobre todo el maná que sale de sacrificar un tesoro o algo parecido, en una pestaña del panel lateral («Notas», con un botón en la barra que lleva el número de notas). Son del motor (versión 3 del catálogo): Accion::PonerNota { texto, temporal, privada }, EditarNota { id, texto, temporal, privada } y QuitarNota { id }, guardadas en Partida.notas (Nota { id, autor, texto, temporal, privada }; skip_serializing_if vacío, así que las partidas y los registros sin notas conservan su huella). Reglas:
-
De quién: cada nota es del jugador que la escribió y solo él la edita o la borra (
resolver::permisos; «todos tocan todo» lo abre). Hasta 30 por jugador y 200 caracteres cada una (MAX_NOTAS,MAX_NOTA; sin espacios de los lados y no vacías). -
Privada:
VistaGeneral.notasse construye para cada mirador (Nota::la_ve), así que una privada nunca llega a la pantalla de otro jugador, ni en los parches ni en la repetición con «todo a la vista». Como las manos, es una regla de la vista, no un cifrado: en una partida en red cada copia de la partida guarda el estado entero (véase Juego en línea). El registro oculta las líneas de las notas privadas y las de borrar una nota (cuentas-registro.ts::visibleEnRegistro); a un espectador nunca le llegan los eventos de las notas (anfitrion.rs::evento_visible), solo el parche con las públicas. -
Temporal: se borra al pasar el turno (
aplicar::pasar_turno), como los contadores~. Una nota se cambia entre temporal/fija y privada/pública con un clic, se edita en su sitio (Intro guarda, Escape cancela) o se borra en cualquier momento. -
No son parte del juego: no se deshacen ni cuentan como «la última acción» (
Accion::es_notaenregistro.rs), y deshacer conserva las notas que haya en ese momento. En una repetición no se aceptan. -
Atadas a una carta (catálogo v4,
notas-mesa.tsx): una nota puede colgar de una carta del campo (Nota.carta). El menú contextual de la carta tiene una sección de notas (NotasEnMenuCarta: «Añadir nota…» atada a ella, editar las tuyas que lleva y «Soltar mis notas»), el menú contextual del campo vacío tiene «Añadir nota…» (una nota suelta) y los dos abrenNotaDialog(texto, temporal, privada). Las notas sueltas se pintan como pósits del color de su autor arriba a la izquierda del campo (NotasSueltas): pulsa una tuya y luego una carta para atarla (modo de objetivonota, Esc cancela), o usa su menú (editar, atar, temporal/fija, privada/pública, borrar). Una nota atada se ve como una insignia en la carta (véase «Cartas» arriba) y se suelta sola cuando la carta sale del campo, así nunca se pierde. Solo cartas del campo: atarla a una carta de una zona oculta revelaría qué carta es. El registro oculta las líneas deatarNotay las denotaEnCartaprivadas, como las demás líneas de notas privadas.
La comunicación es parte del registro del motor, así que viaja bien en red, sale en el registro y en la repetición y se repite al retomar: Accion::Chat { texto } (como mucho MAX_CHAT = 500 caracteres, sin espacios de más y no vacío), Accion::Aviso { tipo } (espera, respondo, ok, paso, tuTurno, pienso y, desde el catálogo v5, 14 frases amigables: holaATodos, buenaPartida, buenaJugada, jugadon, quePasada, gracias, suerte, animo, buenMazo, malaSuerte, meHasPillado, perdon, vuelvoEnseguida, otraMas) y Accion::Senal { senal } (sinRespuesta, espera, pensando, ausente, o null para quitarla). Con la partida terminada el motor solo acepta Chat y Aviso (es_comunicacion). Los espectadores no tienen asiento, así que no pueden escribir: el panel lo dice.
- La pestaña «Chat» lista mensajes y avisos (
mensajesDeChat), tiene un botón por aviso, un botón «Frases» que abre un popover con las frases amigables (AVISOS_AMIGABLES, sin atajo de teclado) y un campo de texto (Enter envía, Esc sale del campo). Las frases son avisos del motor, no texto libre, a propósito: solo viaja el tipo y cada pantalla lo escribe cont()en su idioma, así que lo que manda un jugador en español lo lee uno japonés en japonés. Frase nueva = variante nueva deAvisoRapidoal final,VERSION_CATALOGOyPROTOCOLOarriba ycatalogo/v<N>.json; su texto va enAVISOS_AMIGABLESy en los 15 diccionarios. Los mensajes sin leer ponen un contador en la pestaña. Silenciar a alguien (stores/chat-store.ts,silenciados) lo oculta solo en esta pantalla: el evento sigue viajando y sigue en el registro. -
Botón de emojis (
BotonEmoji,src/components/emoji/), entre el campo de texto y «Enviar». Usa EmojiMart (emoji-mart5.6.0 y@emoji-mart/data1.2.1), con estas decisiones:-
Dónde se abre. En escritorio y tableta, un
Popoverencima del botón que se queda abierto para poner varios emojis seguidos (se ignoraonFocusOutsideporque el chat devuelve el foco al campo tras cada emoji; un clic fuera lo cierra) y que no roba el foco (onOpenAutoFocus/onCloseAutoFocusanulados, así el cursor sigue donde estaba). En el móvil, unaHojaMenucon el selector a todo el ancho (un popover de 352 px no cabe de lado con el teclado, y los menús del móvil suben como hoja); se cierra al elegir y el foco no vuelve al campo, porque abriría el teclado encima. -
Dónde va el emoji.
insertarEnCursor(insertar.ts, pura y con tests) lo pone en el cursor o sustituye lo seleccionado, y dice dónde queda el cursor; si el resultado pasara deMAX_CHAT(contado en unidades UTF-16, comomaxLength) no inserta nada, porque un emoji cortado por la mitad sería basura. Sin selección conocida (el campo nunca tuvo el foco) va al final. -
Solo se carga si se usa.
selector-emoji.tsxes un trozo aparte (lazy, se baja la primera vez que se pulsa el botón), y los datos de los emojis (@emoji-mart/data/sets/15/native.json) son otro, que se carga una vez y se comparte. -
Sin internet. De fábrica, emoji-mart pide sus datos, traducciones e imágenes a jsdelivr, y la vista web nunca debe salir a la red (toda la red la hace Rust, y la CSP lo bloquea). Por eso los datos y la traducción se pasan siempre desde el paquete (también en inglés), y se usa el juego
native: los emojis los pinta la letra del sistema, la misma que pinta los mensajes del chat, así que en el selector se ve exactamente lo que saldrá en el mensaje (los otros juegos bajan imágenes). -
Idioma (
idioma.ts): los 13 idiomas que trae emoji-mart; el rumano no está y sale en inglés, yzhs/zhtusan los doszh(simplificado). Cada traducción es su propio trozo. -
Colores. El selector vive en su propio shadow DOM, así que el CSS de la app no le llega:
coloresDelTemalee los tokens de la app (--popover,--popover-foreground,--primary,--muted, más--font-sansy--radius), los pasa ar, g, bcon un lienzo de 1×1 (los tokens pueden seroklch) y pone las variables de emoji-mart; claro u oscuro se decide por el brillo del fondo, no por el modo, porque mandan los temas. - No se usa
@emoji-mart/react: pide React 18 o anterior y solo envuelve lo mismo, así que se monta directamente el elementoPickerdeemoji-mart.
-
Dónde se abre. En escritorio y tableta, un
- Cada mensaje o aviso nuevo de otro jugador sale además como un bocadillo durante 6 s (
BOCADILLO_MS) junto a su miniatura (tablero-mini.tsx) o, para el jugador cuyo tablero es el grande y para todos en la disposición de un solo tablero, en una pila arriba a la izquierda (BocadillosFlotantes). Una señal es una insignia (InsigniaSenal) junto al nombre o la vida del jugador. - Atajos (
ATAJOS, contextomesa): Enter abre el chat y pone el cursor en el campo; Alt+1…6 mandan un aviso; Ctrl+Mayús+1…4 ponen una señal (elegir la señal que ya tienes la quita,senalTrasElegir). Van por el código físico de la tecla (teclaDeComunicacion) porque Mayús o Alt cambian el símbolo de un dígito, y el tablero ignora las teclas con Alt para que no choquen con las de las cartas. -
reaccion-eventos.ts::reaccionarAEventoes el único sitio donde un evento nuevo del canal provoca algo en pantalla además de actualizar el store: el bocadillo, el contador de sin leer y los sonidos.

mesa::resumir(&Registro) -> ResumenPartida { puestos, rondas, turnos, duracionMs, empezadaMs, terminadaMs } cuenta la partida a partir del registro: en qué puesto acabó cada jugador (Puesto { jugador, puesto, ronda, por }; 1 = ganó, compartido por un empate o un equipo; los demás por el orden en que cayeron), la ronda en que cayó y quién lo eliminó cuando se sabe, las rondas, los turnos y el tiempo del primer al último evento que sigue contando. Es puro y no usa reloj. El diálogo «Terminar», la pantalla del final y el historial leen el mismo resumen (mesa_resumen), y la pantalla nunca recuenta nada (cuentas-fin.ts solo da formato).

La pantalla del final (fin-partida.tsx) enseña quién ganó y cómo, el puesto de cada jugador, la ronda y quién lo eliminó, las rondas y la duración y, en un encuentro (al mejor de 3 o 5), cómo va el encuentro con «Siguiente partida». Sus botones: «Ver la mesa», «Ver repetición», «Salir» y «Otra partida». «Ver la mesa» (con el foco de entrada) y Escape la apartan para mirar la mesa, y un botón «Ver el resultado» abajo la vuelve a abrir, así «Otra partida» nunca queda fuera de alcance.
Cuando llega Terminar, la sesión arma una NuevaPartida (historial::de_sesion) y se la da a un gancho (historial::instalar, un OnceLock que se pone una vez en lib.rs para que la sesión no conozca la base de datos; sin él, como en los tests del motor, no pasa nada). store::partidas::guardar la escribe en las tablas partidas (una fila por partida: formato, modo, jugadores como JSON, ganadores, cómo acabó, rondas, duración, la versión del catálogo y el registro) y partida_decks (qué deck se sentó en cada asiento, para pedirlas por deck), creadas por la migración 0006. No cuelgan de los decks: borrar un deck deja sus partidas con el nombre que tenía, y jugar nunca cambia el updated_at del deck.
-
Qué se guarda. Quien tiene la partida entera (solitario, local o el anfitrión de una partida en red) guarda su
PartidaGuardadacomprimida enregistro(el mismo formato que la partida guardada, así se puede repetir). Un invitado en red también guarda la repetición entera cuando su copia empieza antes del turno 1 (recibió la partida en el mulligan,inicial.turno.numero == 0, así que tiene todos los eventos:historial::de_sesion); un invitado cuya copia empieza a media partida (volvió tras un corte) guarda solo el resumen, conregistrovacío. Los espectadores no guardan nada. Un invitado no tiene los mazos, así que la app recuerda el deck y la lista que eligió (red::recordar_mi_deckdesdered_unirseyred_deck;red::mi_deck): su asiento lleva esedeck_id, la lista y el nombre del deck, y la partida sale bajo ese deck en «Partidas». -
Comandos.
partidas_de_deck(deck)devuelveHistorialDeck(partidas, victorias y cada filaFilaPartida: puesto, si fue victoria, rivales, rondas, duración, fecha y si tiene repetición);partida_borrar(id)quita la partida y su repetición, y las copias de sus imágenes que no usa ninguna otra partida (más abajo). En el diálogo de propiedades del deck, la pestaña «Partidas» las lista; los rivales son los otros asientos de esa fila, así que una partida local con el mismo deck en dos asientos no sale como «en solitario». -
Historial general (
historial-general.tsx, botón «Historial» de la pantalla Jugar).partidas_todas(limite, desde)devuelveHistorialGeneral { total, victorias, filas }: todas las partidas terminadas de todos los decks, la más reciente primero, por páginas (la pantalla pide 100; Rust limita una página aMAX_PAGINA= 500). El puesto de una fila es el mejor de los asientos con un deck de la biblioteca (0 si fue un invitado en red, que no cuenta como victoria ni como derrota). Cada asiento enseña quién lo jugó, con qué deck y, si no llevó el deck entero, con qué lista para jugar y cuántas cartas tenía (JugadorHistorial.listaId,lista,cartasLista). El nombre y el tamaño se apuntan al guardar la partida (completar_listas, desdedeck_listas), así que se quedan aunque luego se renombre o se borre la lista (una lista que ya no estaba al guardar la partida sale como «Lista borrada»); las filas viejas sin esos campos se siguen leyendo (#[serde(default)], sin migración). El diálogo filtra lo cargado por texto (deck, lista, jugador, nota; sin tildes ni mayúsculas, deben aparecer todas las palabras), resultado (ganadas/perdidas) y formato (cuentas-historial.ts), y tiene «Ver repetición» y borrar en cada fila. Una fila enseña hasta cuatro asientos (MAX_ASIENTOS_FILA, después «y {n} más») y «Cargar más» pide la página siguiente. -
Las imágenes de las repeticiones, fuera de la caché (migración
0008_partida_imagenes.sql). Una repetición pinta las imágenes propias de todos los jugadores (cartas subidas o diseñadas, fichas, reversos, tapetes), y las de los demás no se pueden volver a preparar cuando la partida ya acabó; la caché de la mesa (app_cache_dir/mesa/) se puede borrar en cualquier momento. Por esoNuevaPartida.imageneslleva las huellas que necesita la repetición (solo si hay registro:historial::de_sesiontomared::imagenes::propias_dede la partida inicial y de la final, sin repetir; las fichas que vinieron y se fueron no se pintan con imagen propia),store::partidas::guardarlas apunta enpartida_imagenes(partida_id, sha)y después el gancho del historial enlib.rscopia cada fichero de la caché aapp_data_dir/partidas/imagenes/(mesa::imagenes::guardar_para_historial, en su propio hilo para no hacer esperar a la mesa; lo que no esté en la caché se salta).mesa::imagenes::servirrepone desde esa copia un fichero que falte en la caché antes de servirlo.store::partidas::borrardevuelve las huellas que ya no usa ninguna otra partida ypartida_borrarborra sus copias (olvidar_de_historial). -
En pantalla, las dos listas (
historial-general.tsx,partidas-deck.tsx) enseñan un giro mientras carga la primera página y, si falla, el error con «Reintentar» en su sitio (src/components/estado-carga.tsx,EstadoCarga, que guarda el alto para que el diálogo no salte); una página posterior que falla es solo un aviso. En el historial general, con un filtro y páginas aún sin cargar, la cuenta dice «hay más sin cargar» y un resultado vacío dice «Ninguna de las partidas cargadas cumple el filtro.»; un resultado filtrado vacío tiene «Quitar los filtros». - No impide que una partida acabe: si falla el guardado, se anota en el registro de la app y la partida sigue.


Una repetición es la misma mesa en modo «solo mirar»: la misma sesión, canal e imágenes con otra partida delante, la que calcula Registro::en para el punto que se mira, vista como un jugador o como todos (como, porque la partida ya acabó). Se entra de dos maneras: desde la pantalla del final (entrar_repeticion, en el sitio) y desde el historial (repeticion_abrir(id) → Sesion::desde_guardada, una sesión que solo existe para verla y se cierra al salir).
- Comandos:
repeticion_abrir,repeticion_actual,repeticion_ir { pos, como }yrepeticion_salir(vuelve a la partida terminada o, si se abrió desde el historial, cierra la mesa).VistaRepeticion { total, pos, como, hitos, tMs, desdeHistorial }lleva lo que necesita la barra: loshitos(Hito { pos, turno, ronda, activo, tMs }) marcan dónde empieza cada turno. - La barra de abajo: deslizador de la línea de tiempo, al principio, turno anterior, reproducir/pausar, turno siguiente, al final (
hitoAnterior/hitoSiguiente), velocidad de 0,5× a 8× (VELOCIDADES), «Ver como» (un jugador o «Todo a la vista») y «Salir de la repetición». Al reproducir se espera el hueco real entre dos eventos, acotado entre 400 ms y 1,5 s y dividido por la velocidad (pausaEntre). Teclas: Espacio reproduce o pausa, Izquierda/Derecha avanzan o retroceden un evento, Inicio/Fin van a los extremos; no cuentan en los campos de texto ni cuando un botón o el deslizador tiene el foco (esos ya responden al Espacio y a las flechas, y el paso se daría dos veces). - Mientras se repite, la mesa no acepta ninguna acción de esta pantalla (
Rejected,motivo: repeticion; la sesión deja pasar el chat y los avisos rápidos,es_comunicacion, pero el cuadro del chat no escribe durante una repetición) y ninguna línea ofrece «A la pila». Cada salto llega como unaVistaentera, nunca como parche, así que una repetición no recibe ningún parche al que reaccionar; la pantalla pone tanto los efectos como el movimiento. Efectos:conexion.tscompara el registro de la vista nueva con el de la anterior (cuentas-repeticion.ts::eventosNuevos) y, si es un paso corto hacia delante (hasta 3 eventos, con duración de movimiento: no un salto, ni hacia atrás, ni un cambio de «Ver como»),reaccion-eventos.ts::reaccionarEnRepeticionhace sonar cada evento nuevo (sonidoDeEvento, que sigue pasando por los interruptores de cada grupo) y encola su tirada de dados o moneda; los bocadillos de chat y la cuenta de sin leer no salen (los mensajes están en el registro). El interruptor «Sonidos y dados en las repeticiones» (efectosRepeticion, encendido de fábrica) lo apaga. Movimiento: un paso de hastaSALTO_ANIMABLE(3) puntos desliza las cartas a su sitio nuevo duranteduracionMovimiento(el 90 % de la espera mínima a la velocidad de ahora, para que acabe antes del siguiente paso) poruseRepeticion.animarMs, que leenCartaMesa(transición CSS en el campo, FLIP entre zonas,ease-in-out) y todas las miniaturas (tablero-mini.tsx::Deslizante, FLIP a la escala de la miniatura, tanto enAnchoMiniaturacomo en la escala de lecturaLecturaGrande; durante una repetición muestrean la mesa cada 30 ms en vez de 100, los reversos de la mano se recolocan con una transición CSS y los marcos de las zonas dejan de recortar mientras una carta se desliza) para que la mesa de cada jugador se mueva igual que la propia. Los sitios se apuntan por escala (opciónambitodeuseFlip) y sin caducidad en una repetición, yolvidarSitios()los olvida en un salto o al cambiar de tablero; un salto mayor de la línea de tiempo, o cambiar «Ver como», se ve de golpe. No salen las ayudas del turno y el marcador de vida sigue al tablero que se mira. -
La partida de verdad y el momento que se repite son dos cosas (
Sesion::partida()ySesion::en_pantalla()).partida()es siempre la partida de verdad, la que va a la red, se guarda y decide;en_pantalla()es el punto que se repite y solo alimenta la vista (vista,repeticion_salir). Antes un único acceso daba el punto repetido, así que un invitado que volvía mientras el anfitrión miraba la repetición en su sitio recibía el pasado. El anfitrión que mira una repetición en su sitio sigue aplicando lo que piden los demás (hacerya no rechaza las intenciones de fuera: un invitado puede deshacer unTerminarpor error, o escribir en el chat); solo su propia pantalla se queda en el pasado. Lo que llega mientras tanto no mueve la vista, pero la línea de tiempo crece con ello (repeticion_al_diarehaceseqsehitossin cambiar el punto que se mira). Un invitado que recibe la partida entera otra vez (recibir_partida) rehace una repetición abierta sobre el registro nuevo, en su último punto. - Una línea de chat o un aviso que se conservó tras un deshacer (véase Motor de la mesa) pertenece a un momento que ya no existe: la repetición lo enseña con la mesa del último evento anterior que sigue en pie (
Registro::en_lo_deshecho). - Las imágenes de Scryfall que necesita una repetición y que ya no están en la caché (una repetición del historial, una caché borrada, una ficha que ya no está en la mesa) se bajan directamente de su URL aunque no haya sala en red (
red/imagenes.rs::asegurar), y una imagen se busca tanto en la partida de verdad como en el punto que se repite. - Motivos de rechazo nuevos:
partida_abierta(no se abre una repetición del historial con una partida abierta),sin_registro(una partida guardada sin registro),sin_terminar(repetir una partida que no ha acabado),repeticion,no_es_repeticion(moverse por una repetición que no está abierta) yno_existe(ver como un jugador que no está en la partida). Un registro de un catálogo más nuevo se rechaza conUnsupportedyversion.

En solitario, el rival fantasma de Motor de la mesa (su sección «Sparring» tiene el modelo). La sesión le pregunta tras cada evento del jugador (Sesion::hacer, detrás de Sesion::accion: guarda una copia de la partida de antes de la acción, solo si la intensidad no es apagado, llama a mesa::sparring::reaccionar y, si hay respuesta, la mete justo detrás como evento de la mesa con Registro::de_la_mesa), así la pantalla recibe dos parches seguidos.
-
boton.tsx(BotonSparring, en la barra, solo con un asiento): «Sparring» y la intensidad; abre un panel con la intensidad («Desactivado», «Ligero», «Realista», «Despiadado», que se manda comoAccion::IntensidadSparringy vale desde la siguiente ocasión), «Reacciones de esta partida» (carta,R<ronda>, qué hizo y qué se decidió) y «Cómo funciona». La primera vez que se elige una intensidad que no es la apagada, se abrecomo-funciona.tsxcon «Empezar el sparring» (mesa.sparringVisto). -
reaccion.tsx(ReaccionPendiente): mientras una reacción no tiene decisión, una tarjeta en el centro de la mesa (role="dialog", así los atajos de la mesa no van) con lo que hace («Exilia tu Metallic Mimic»), la carta del fantasma y su coste, «¿Por qué ahora?», «Ver el porqué» (las líneas debase) y «Resolverla» (Intro), «Tengo respuesta» (A) y «No aplica» (Esc), recogidas en la fase de captura. En un sacrificio, «Resolverla» enseña antes las criaturas del jugador para elegir la que se sacrifica. -
carta-fantasma.tsx: las cartas del fantasma van por nombre;mesa_sparring_carta(nombre)(Rust) comprueba que el nombre esté en la lista, lo busca una vez en Scryfall con/cards/collectionpor nombre, recuerda su id enapp_settings(mesa.sparring.<nombre>) y devuelve unOrigenCartacon la imagen preparada como cualquier carta de Scryfall de la mesa (origen_de_scryfall, compartido conmesa_origen_scryfall). Sin red, el nombre sobre el reverso genérico. -
vista-fantasma.tsx(VistaFantasma,useSenalarFantasma): las cartas del fantasma no están en la mesa (no tienenIdCarta), así que no pueden pasar porVistaGrande; se señalan por su nombre (usePantalla.fantasma). Pasar el ratón por la carta del aviso de la reacción, por su nombre en el registro (textos-eventos.tslo envuelve en su propio par de marcas ypiezasDeTextodevuelve{ fantasma }) o por el nombre de la carta en la lista del botón de Sparring la enseña en grande, al ancho de la vista grande de Ajustes, del mismo lado queVistaGrande(apartándose del panel lateral) y por encima del aviso. Las dos vistas grandes se pintan concapa-alta.tsx(CapaAlta): la mesa es un contexto de apilado propio (isolate), así que nada de lo que lleva dentro puede subir por encima de un modal (un diálogo de Radix va por portal abodyconz-50);CapaAltalas lleva por portal abodyen una capa fija que no recibe el puntero (z-60), medida para cubrir justo la caja de la mesa, de modo que sus hijos siguen colocándose conabsolutey una carta se puede leer con un modal abierto. Si lo señalado desaparece con el puntero encima (la reacción se resuelve), la vista se quita sola. -
textos.tsescribe cont()las claves deExplicaciony las líneas del registro («El fantasma contrarresta Rhystic Study con Counterspell»; la decisión, como «… resuelve la reacción del fantasma» y parecidas). Los nombres de las intensidades evitan «Apagado» y «Suave», que ya existen en el área de ajustes con otro sentido.
Rust decide cada resultado (Cambio::Dados, Monedas, DadoPlanar, cada uno con una semilla_animacion sacada de la semilla del evento); la pantalla solo lo enseña. conexion.ts pasa cada evento de los parches a useDados.encolar (cola.ts) y tiradaDe convierte el cambio en piezas: un dado por dado tirado (un d100 son dos d10, decenas y unidades, dadosDeTirada), la última tanda de monedas (con «hasta perder», la que perdió) o el dado planar; se pintan como mucho MAX_PIEZAS = 20 y el resultado sale entero igualmente. La cola deja la tirada que se ve y como mucho dos en espera: si se tira muy deprisa, las de en medio sobran. Cada tirada solo deja paso a la siguiente cuando su animación avisa de que acabó, así que una que no acabase dejaría la mesa sin dados el resto de la partida: pasados TOPE_TIRADA_MS = 8 s (la física dura como mucho 4 s, más la carga de three y Rapier la primera vez) CapaDados enseña el resultado igual y, si era el 3D, las siguientes van en CSS 3D ligero. Los golpes que suena el bucle del 3D van dentro de un try (escena3d.ts) y audio.ts crea el AudioContext también dentro de uno (sin salida de audio el constructor puede fallar): un sonido que falla nunca para una tirada. azar.tsx se cierra al tirar, para que la tirada se vea sobre la mesa.
capa.tsx (CapaDados, en mesa-screen.tsx) va entre la barra de arriba y la mano (top-[11%] bottom-[26%]), no capta el ratón y tras cada tirada enseña ResultadoAzar (el mismo bloque que el diálogo) durante RESULTADO_MS = 2,2 s; un clic lo quita. Tres niveles, elegidos en Ajustes (mesa.dados):
| Nivel | Ficheros | Cómo |
|---|---|---|
3D con física (3d) |
escena3d.ts, fisica.ts
|
three.js a pelo (sin envoltorio de React) con cámara ortográfica inclinada, luz ambiente y direccional con sombras sobre un suelo ShadowMaterial. fisica.ts::simular hace la tirada entera con Rapier (@dimforge/rapier3d-deterministic-compat) antes de enseñarla, graba cada cuadro y la escena reproduce la grabación. |
3D ligero (css) |
css3d.tsx |
Low poly y color plano, sin WebGL ni física: cada cara es un elemento recortado con clip-path y colocado con matrix3d; la tirada es una animación de la Web Animations API (cae, bota una vez y se queda: tres giros al azar y la pose final, con una sombra difuminada). Luz plana: cada cara se oscurece según su normal en la pose final. La caja de cada cara es la de su polígono, no un cuadrado mayor: las cajas grandes (las cometas del d10) se cruzan con los planos de otras caras y Chromium, al partirlas para ordenarlas, perdía trozos. Cada pieza lleva su propia perspectiva centrada en ella (perspective-origin: 0 0), así se ve igual caiga donde caiga; fuera se anima la posición en 2D y dentro el giro. |
2D (2d) |
plano.tsx |
Siluetas SVG; los números cambian cada 70 ms y se quedan quietos a los 900 ms. |
auto (de fábrica) usa el 3D si hay WebGL2 y si no el CSS 3D; si la escena 3D falla (sin WebGL o sin WebAssembly), pasa al CSS 3D el resto de la sesión. Con las animaciones reducidas (el ajuste o prefers-reduced-motion) sale solo el resultado.
Cómo la cara que se ve coincide con la del motor:
-
geometria.tsconstruye los cinco sólidos: d4, d6 y d8 por sus vértices; d12 y d20 como duales (dual()prueba las seis permutaciones de ejes para que las caras miren hacia fuera); el d10 como trapezoedro pentagonal. Las caras opuestas suman n + 1 (en el d10, del 0 al 9, suman 9).planoCarada a cada cara su centro, su normal y sus ejes en el plano conw = cruz(u, n), así ninguna sale en espejo (un test mira el área con signo). - La física solo dice qué cara quedó arriba (
caraLeida, la de normal más cercana a la vertical; el d4 se lee por la cara de abajo y sus números van en las esquinas,esquinasD4; la escena 3D pone cada número en el ángulo de su vértice real,angulosEsquinas, porque las esquinas de una cara van en sentido antihorario: con el giro contrario, dos números de cada cara salían intercambiados y la punta de arriba no enseñaba el mismo número en sus tres caras). DespuésconResultadointercambia etiquetas para que esa cara lleve el valor del motor. El nivel CSS hace lo contrario: calcula la pose final que deja la cara del resultado hacia quien mira (giroFinal, que usa la función purageometria.giroDeCara: la cara mira a quien mira con su «arriba» arriba y sin espejo, y el d4 se vuelve del revés para que se vea la punta contraria a la cara del resultado, con el resultado repetido en las tres caras que la rodean; los tests lo comprueban en todos los dados). El 6 y el 9 llevan punto desde el d8, y también el 60 y el 90 del d10 de decenas. - La mesa de la física es fija (
ANCHO14 ×FONDO9, gravedad −40,HZ60, como mucho 4 s) y no la del tablero, así la misma semilla da la misma animación en todos los equipos; la escena la escala al tablero. Una pieza se da por parada tras 12 cuadros quietos; si queda de canto (plana< 0,94) recibe un empujoncito, hasta tres veces. 300 tiradas al azar se paran en 1,7 s de media (2,2 s como mucho; 20 dados, 3,4 s). -
azar.ts:generador(mulberry32) ygiroAlAzar(un giro al azar uniforme sin trigonometría), para que el CSS y el 2D también sean deterministas. - Monedas: un cilindro (3D) o dos discos con
rotateX(CSS); la cara es una corona y la cruz una cruz (simbolos.ts, trazados SVG en una caja de 100 × 100, que sirven en el lienzo conPath2Dy en<svg>). El dado planar es un d6 con el planeswalk en el 1, el caos en el 6 y caras en blanco (piezas.ts::VALOR_PLANAR).
Carga: three (unos 526 KB) y Rapier (unos 4,3 MB, WebAssembly) se cargan con import() la primera vez que alguien tira en 3D, en trozos aparte. Rapier necesita 'wasm-unsafe-eval' en el script-src de la CSP (compila WebAssembly; no permite eval de JavaScript). En desarrollo vite.config.ts los pone en optimizeDeps.include; si no, Vite los descubre en la primera tirada, los optimiza y recarga la página. optimizeDeps.entries es index.html para que el escaneo de dependencias no lea otros .html sueltos.
Cada jugador elige cómo son sus dados y su moneda (Ajustes > «Mesa de juego» > «Dados y monedas»): un color propio o el del jugador, el color de los números, un acabado (Acabado: liso, nacarado, mármol, purpurina, translúcido), una imagen propia y el metal de la moneda (Metal: oro, plata, cobre). Es solo pintura; el resultado lo sigue decidiendo el motor.
- Lo guarda Rust (
src-tauri/src/mesa/dados.rs):AspectoDadosen JSON enapp_settings(mesa.dados). Los colores se comprueban (#rrggbb; si no,InvalidInputconmotivo: color). La imagen se prepara al importarla (importar_imagen: el cuadrado del centro aLADO_IMAGEN= 256 px, en JPEG) y se guarda en el almacén de blobs, protegida enblob_refsconowner_kind = 'mesa'yowner_id = 'dados', como el tapete. Comandos:mesa_dados_get,mesa_dados_set { aspecto }ymesa_dados_imagen { ruta }(nullla quita); los tres devuelvenVistaAspectoDados { aspecto, imagen }, con la imagen comodata:URL. Por quédata:y nopbimg://: una textura de WebGL de otro origen «mancha» el lienzo y no se puede subir;pbimg:no manda cabeceras CORS. - La pantalla lo lee una vez (
useAspectoDadosenaspecto.ts) y lo convierte en unEstilopor tirada (estiloDe): tu aspecto en tus tiradas y el de fábrica con el color de quien tira en las de los demás (en línea, cada jugador mandará el suyo).contorno(el trazo alrededor de los números) es oscuro o claro según el color de los números. -
texturas.ts::lienzoFondopinta una vez por estilo (y lo guarda) el fondo de las caras: el color con su acabado, dibujado con una semilla fija para que el mismo color dé siempre el mismo dado (las vetas del mármol en tres pasadas, de ancha y tenue a fina y marcada, porque WebKitGTK no tienefilteren el lienzo), o la imagen. El 3D pone un trozo distinto en cada cara (trozoDeCara) en su atlas; el CSS 3D y el 2D la usan comodata:URL (urlFondo). Los materiales de cada acabado, enescena3d.ts::material(translúcido = 78 % de opacidad en 3D y algo de transparencia en CSS y 2D). - El sonido (
sonidos.ts): el golpe de un dado (golpeDado) y el agitarlos en la mano (agitarDados) son muestras reales del pack «Casino Audio» de Kenney (CC0;muestras.ts, ver «Sonidos de la mesa»), con una variante al azar, un tono algo distinto y un volumen que sigue la fuerza del golpe; mientras una muestra no está cargada (y si falla) suena la sintetizada con la Web Audio API: ruido filtrado con un «toc» corto del cuerpo y un golpe grave de la mesa. La moneda siempre es sintetizada: cuatro parciales metálicos que se apagan.fisica.tsgraba cada choque durante la simulación (Grabacion.golpes: cuadro, pieza y fuerza, por el cambio de velocidad sin la gravedad; como mucho uno por pieza cada 4 cuadros) y el 3D hace sonar cada uno al pintar su cuadro, así los dados suenan cuando chocan y con la fuerza con que chocan. El CSS 3D suena en los dos botes de su animación (tiempoDeinvierte la curva de aceleración para saber cuándo), y el 2D agita los dados y luego cae cada uno. Como mucho 14 golpes cada 100 ms, nada con la ventana oculta, y el contexto de audio se reanuda con el primer clic o tecla (el WebView no suena antes de un gesto). El azar del sonido esMath.random: no decide nada. Los sonidos de las cartas y de la mesa están en «Sonidos de la mesa» más abajo.
Los sonidos de las cartas (barajar, robar, girar una carta, ponerla en juego, moverla) son muestras del pack «Casino Audio» de Kenney (CC0; 23 de sus 50 ficheros, convertidos a WAV estéreo de 16 bits con los silencios recortados y los niveles igualados; src/features/mesa/muestras/, con la licencia y cómo se convirtieron). muestras.ts dice qué ficheros son de cada sonido (MUESTRAS; un test comprueba que existen todos), los carga cuando hacen falta y los decodifica con decodeAudioData: cada uno es un módulo aparte importado con ?inline (un data: URL), porque la CSP no tiene connect-src 'self' y fetch no puede leer los ficheros de la propia app. precargarMuestras (al abrir la pestaña Jugar) carga las de los interruptores encendidos; tocar suena una de las variantes ya cargadas al azar, con un pequeño cambio de tono, y devuelve false si ninguna está lista, y entonces suena el sonido sintetizado de antes. Los demás se sintetizan con Web Audio (sin ficheros): pasar el turno, «es tu turno», un mensaje o aviso del chat y la reacción del fantasma del sparring. audio.ts tiene el contexto compartido (se reanuda con el primer clic o tecla, no suena nada con la ventana oculta, como mucho 14 sonidos cada 100 ms, compartidos con los dados) y las ayudas (envolvente, azar). sonidoDeEvento(ev, yo, activo) es puro y tiene test: dice qué suena con un evento nuevo, y reaccionarAEvento lo reproduce, así el mismo evento suena igual en red. Cada sonido pertenece a un grupo (GRUPO_DE_SONIDO: cartas, turno, chat) con su propio interruptor en Ajustes. Un jugador silenciado tampoco suena. En una partida solo suenan los eventos que llegan como parche; una repetición, que avanza con vistas enteras, hace sonar ella misma los eventos nuevos (véase «Repetición»). El azar del sonido es Math.random: no decide nada.
La misma pantalla y el mismo motor; solo cambia cómo se maneja, y nada en Rust ni en el protocolo (Accion, Cambio y PROTOCOLO no se tocan). Qué es un «móvil» lo decide useFormato() (Móvil): menos de 640 px de ancho o, con el dedo, un lado corto de menos de 600 dp, así que un teléfono apaisado (914 × 411) es un móvil. La mesa lo pregunta con useMesaMovil() (formato-mesa.ts).
Un gesto por elemento, porque «mantener para arrastrar» y «mantener para el menú» no caben en la misma carta:
| Gesto | Efecto |
|---|---|
Moverse más de 8 px (UMBRAL_ARRASTRE_DEDO; 5 px con ratón o lápiz) |
Arrastra la carta (los mismos eventos de puntero que el ratón). |
| Mantenerla quieta 500 ms | Abre el menú de la carta (lo mismo que el clic derecho). |
| Toque | Lee la carta en grande. |
Doble toque (300 ms, 30 px: crearDobleToque) |
Lo que hace el doble clic: endereza en el campo, juega desde la mano. |
| Un dedo en un hueco vacío del campo | Dibuja el recuadro de selección. |
| Dos dedos | Mueven y acercan el campo (abajo). |
«Dedo» aquí es solo pointerType === "touch". El lápiz va como el ratón (hayEncima en leer-en-grande.ts trata "mouse" y "pen" como que tienen «encima», y umbralDe da los 8 px solo a "touch"), porque un lápiz tiene «encima» y doble clic nativo (carta-mesa.tsx). El escritorio con ratón está como antes: lo añadido para el dedo solo vale con lo táctil. Los rótulos de zona («Cementerio», «Exilio»…) pasan a ser botones que abren la zona solo con tactil (el dedo no tiene doble clic; con el ratón, como siempre, el doble clic en la zona la abre); la vista grande sigue siendo entrar y salir con el ratón; y el umbral de arrastre, los menús y los clics del ratón no cambiaron.
Mantener pulsado algo que también hace algo al tocarlo (la biblioteca roba, una miniatura cambia de tablero, un − resta) abre su menú y, al soltar, llegaría un clic que nadie quiere: crearFiltroClicTrasMenu descarta el primer clic que llega tras abrir un menú sin otro toque de por medio.
Con el ratón, leer es entrar y salir de la carta; el dedo no tiene «encima», así que es un toque: la carta tocada se queda en grande hasta que se toca otra, se toca fuera o se pulsa su botón de cerrar. Los 11 sitios de la mesa que enseñan la vista grande pasan por useLeerEnGrande / manejadoresLeer (nunca un onPointerEnter suelto), así que señalar y tocar se comportan igual en todos. sobreTras es la regla pura (con tests). La carta de arriba de la biblioteca tiene un botón de lupa para lo mismo. En un móvil la carta en grande es LecturaMovil (lectura-movil.tsx, que usan vista-grande.tsx y vista-fantasma.tsx del fantasma): la carta entera dentro de la pantalla, a su proporción de 63 × 88 y sin recortar (object-contain; CartaFantasma admite contener), lo más grande que quepa, con el botón de cerrar fuera de la carta para que nunca tape el título. El tamaño es la función pura lecturaMovil(ancho, alto, horizontal, caras, conPanel) de leer-en-grande.ts (margen de 8 px, 48 px reservados para la X, un panel de estado de 176 px al lado de la carta si el móvil está de lado o de 120 px debajo si está de pie; una carta de dos caras enseña las dos una junto a otra). De lado, la carta va de arriba abajo en el lado contrario a donde se tocó (aLaIzquierda) y la X y el panel de estado (habilidades, contadores, fuerza/resistencia, palabras clave) van junto a ella hacia el centro; de pie, la carta ocupa el ancho y la X y el panel van debajo. El hueco se mide con un ResizeObserver, así que sigue a un giro. Por qué un componente aparte: la vista de escritorio dimensiona la carta por el ajuste de ancho y la recorta en un marco, lo que en un móvil cortaba el título y ponía la X encima. Todavía no: leer en grande dentro de los diálogos de revelar, fichas, daño de comandante y final, ni desde las listas para jugar (sus cartas no están en el store).
Dos dedos mueven y acercan el campo entre 1× y 3× (ZOOM_MIN, ZOOM_MAX; el campo nunca deja un hueco en el borde). Mientras los dedos se mueven solo cambia un transform de CSS de la capa de las cartas (transformEntre), y al soltar se recoloca todo con el zoom nuevo, así no se repintan cien cartas por fotograma. conVista(disposicion, vista) devuelve la geometría ya ampliada (campo pasa a ser el campo ampliado, visible la parte que se ve de verdad), de modo que todo lo que convierte posiciones del motor a píxeles y al revés sigue valiendo sin saber del zoom: nunca convertir con disp.campo a mano. touch-action: none solo va en el tablero. Con zoom (o mientras se mueven los dedos), la capa de las cartas y lo que se pinta sobre el campo (bolitas, flechas, los fantasmas y punteros de los demás) se recortan al campo visible (Recortado en tablero.tsx, un clip-path de poligonoDe(visible) en una capa que no recoge toques), así que nada ampliado tapa las zonas ni la mano. Este zoom existe solo en táctil: en un ordenador no hay zoom con la rueda, el botón central ni la barra espaciadora.
-
De quién es el turno, en la barra.
ChipTurnova en dos líneas con el número de turno y, de pie,BotonResponder compactoenseña solo su icono (el nombre, al mantenerlo): la frase entera no cabía y tapaba el turno. -
Un tablero a la vez.
ModoDisposiciontiene un sexto modo,movil(repartirMovil): el asiento que se mira se lleva todo el rectángulo y los demás uno vacío (no se pintan miniaturas). No se elige ni se recuerda por número de jugadores; un móvil lo fuerza. -
Barra de asientos (
movil/barra-asientos.tsx): el color y la vida de cada jugador (y de los fantasmas en solitario, solo con su número si hay varios: «Fantasma 2» no cabe en esa barra); tocar uno enseña su tablero. Mientras se elige una flecha, es donde se elige al jugador objetivo, porque el móvil no tiene miniaturas: tocar una fila apunta a ese jugador (elegirJugador, oelegirFantasmaen la fila del fantasma) y el ojo de al lado (Ver el tablero de {nombre}) enseña su tablero sin terminar el modo, para que el objetivo sea una de sus cartas. Durante ese modo la barra se abre sola aunque esté plegada, y luego vuelve a plegarse. Adjuntar y atar una nota solo admiten cartas, así que tocar sigue cambiando de tablero. Cada fila enseña cuántos ataques apuntan a ese jugador o a sus cartas (atacantesDe). Se pliega a un solo botón, que se recuerda mientras viva la app, y tiene «Enfocar al jugador activo», que lleva la vista a quien tiene el turno en cada turno. Es solo de pantalla: qué tablero se mira no cambia la partida. Abierta, tapa unos 120 px del campo. -
«Mano» (
movil/mano-completa.tsx): la mano entera a pantalla completa, agrupada por tipo o por coste (cuentas-mano.ts, 7 o más en un solo grupo); tocar una carta la elige y la lee en grande, mantenerla (o su «⋯») abre su menú, y abajo están las acciones más comunes (jugarla, descartarla). Es otra forma de ver la mano: lo que se hace con ella va a Rust como siempre. -
Pantalla de vida (
movil/pantalla-vida.tsx): vida, veneno y daño de comandante a pantalla completa; − y + suman de uno en uno con un toque y de diez en diez manteniendo (PASO_TOQUE,PASO_LARGO); se pueden elegir varios jugadores (y el fantasma del solitario) y el cambio va a todos. Los toques se juntan en un evento por racha como haceVidaJugador, y cada cambio es la mismaAccionque los contadores del tablero (cuentas-vida.ts). Con vida compartida (Two-Headed Giant,vidaCompartidadel equipo) el equipo es un solo objetivo de vida:objetivoQueCuentalleva a cualquiera de los dos compañeros al de asiento más bajo yobjetivosQueCuentanquita los repetidos, así que elegir a los dos no cambia la vida dos veces, y los dos enseñan la vida del equipo (vidaDe). El veneno y el daño de comandante siguen siendo de cada jugador. Quién queda fuera lo sigue decidiendo el motor y lo confirma la persona. - «Pasar turno» es un botón redondo arriba a la derecha (de 44 px, la altura de la barra que tiene al lado: más grande se comía el campo), solo en mi turno (no durante el mulligan, ni mirando, ni en una repetición).
-
La barra es compacta a propósito (
BarraMovilenbarra-mesa.tsx). Un móvil de lado mide solo unos 390 dp de alto y lo que importa es el tablero, así que los botones de la barra sonBOTON_MOVIL(size-9 !min-h-9 !min-w-9, unos 36 px) en vez del mínimo táctil de 2,5 rem deindex.css(el!hace falta porque esa regla pesa más que una clase suelta), con iconos de 18 px. De lado va centrada y puede partirse en líneas, con mano, vida, registro y chat como botones. De pie (unos 390 dp de ancho) es una sola fila pegada a la izquierda, hasta «Pasar turno» (max-w-[calc(100%-3.5rem)], sin saltos), y la mano, la vida, el registro y el chat pasan a su menú «⋯». El botón de sparring es compacto (BotonSparring compacto: solo el fantasma, de color si está encendido, con unaria-labelque lleva la intensidad). El texto del turno se recorta antes de empujar nada fuera. -
Los marcadores de vida son más pequeños en un móvil: los − y + son
MAS_MENOS_MOVIL(36 px) en vez de 44 px (MAS_MENOS_TACTIL), el número estext-xly la píldora no lleva relleno vertical (vida-jugador.tsx,vida-fantasma.tsx). Dos marcadores de 44 px se comían el campo de un teléfono de lado. La vida del fantasma queda a 52 px del borde de arriba, bajo la barra. -
La franja de abajo del tablero se adapta (
disponer(w, h, tamano, conBanquillo, movil)): en un móvil de pie las zonas a la altura de la franja serían más anchas que la pantalla y dejarían la mano sin sitio, así que las zonas (y sus cartas pequeñas) se encogen hasta dejar a la mano la mitad del ancho y la franja baja con ellas (nunca de menos de 90 px); lo que sobra es campo. El nombre de una zona que no cabe baja a 9 px y se recorta (Zonasentablero.tsx): vale siempre que la zona mida menos de 64 px, lo que también pasa en un tablero pequeño de una rejilla de 4, para que los rótulos vecinos no se pisen. -
La barra de asientos (
barra-asientos.tsx) empieza a 3 rem y acaba sobre la franja de la mano (usa el mismomin(220px, max(90px, 22%))quedisponer), así que no tapa la mano; plegada es un botón de 40 px. - Apaisado. La mesa se juega de lado: en vertical sale encima un aviso «Gira el teléfono» con «Seguir en vertical» (no bloquea la orientación; el resto de la app es vertical). No sale durante el mulligan: la mano inicial se ve bien de pie.
-
El mulligan de lado es una fila (
mulligan.tsx,cartaEnFilademovil/cuentas-mano.ts). La mano se mide en el hueco que dejan el título y los botones (unResizeObserver) ycartaEnFila(ancho, alto, n)da la carta más grande, a 63 × 88, con la que caben lasnuna junto a otra, sin pasar de los 172 px del escritorio. El título y la regla ocupan menos, los botones se ven siempre y la carta elegida baja 8 px (contado en el alto). De pie conserva las cartas de tamaño fijo que se parten en líneas. -
«¿Quién ganó?» (
dialogos/terminar.tsx) en un móvil de lado pone en una fila el selector de «cómo» y la nota, y los botones del pie en otra (!flex-row, porqueDialogoAdaptableapila el pie con una regla que pesa más que una clase suelta), de modo que «Terminar» se ve sin desplazar. -
La reacción del fantasma es compacta (
sparring/reaccion.tsx,ReaccionPendienteconuseMesaMovil()): la carta pequeña (w-14, imagen de 244 px; al tocarla saleLecturaMovilen grande), el título y el nombre de la carta, el razonamiento «¿Por qué ahora?» plegado tras un interruptor (Ocultar el porqué/¿Por qué ahora?) y las tres respuestas («Resolverla», «Tengo respuesta», «No aplica») en una fila. De lado va arriba, entre la barra de asientos (7,5 rem) y la vida del fantasma (13,5 rem); de pie, a lo ancho bajo esa vida. Elegir qué criatura se sacrifica es la misma pieza en las dos disposiciones (eleccion), con una lista más corta en el móvil. La del escritorio no cambia. Sigue siendo unrole="dialog"propio (no una capa de Radix) para que «atrás» nunca decida por la persona. -
Diálogos y paneles a pantalla completa (
DialogoAdaptable), el registro, el chat y las notas incluidos. -
«Jugar» (la entrada) en un móvil es otra disposición de las mismas piezas (
entrada-movil.tsx,PanelEmpezarMovil): los decks se quedan con la pantalla (la barra de la biblioteca en una fila,enUnaFila, con «Historial» en su «⋯»; la misma cuadrícula y la hoja de etiquetas) y un panel de empezar va fijo abajo (de pie) o a la derecha, de 20 rem y con su propio scroll (de lado). El panel deja a la vista solo lo necesario para empezar: la partida guardada para seguirla, el formato y la lista para jugar (un desplegable; crear y editar listas son sus últimas entradas; de lado la lista tiene su propia fila), el botón de opciones y los cuatro botones de empezar en una fila, con el icono sobre el texto («Solitario», «Con amigos…», «Partida local…», «En red…»; el motivo de que «Solitario» esté desactivado se escribe como texto porque el dedo no tiene tooltip). Todo lo demás (el nombre, la vida, el mulligan, las ayudas de inicio de turno, el impuesto del comandante, el sparring, el tapete del deck) va a la hoja «Opciones de la partida» (HojaMenu): los mismos campos que en el escritorio, definidos una vez (opcionesExtra) y dibujados en cualquiera de los dos sitios. Las elecciones siguen viviendo enEntrada, así que nada se duplica. - El orden de asientos en la partida local y en la sala de espera tiene botones de subir y bajar para quien no arrastra (ver Juego en línea).
Con una partida abierta en un móvil, el shell esconde la barra de arriba y la de pestañas (shell/mesa-completa.ts::useMesaAPantallaCompleta) y la mesa enseña su propio botón de salir; salirDeLaMesa() vuelve a la pantalla anterior y la partida sigue abierta y guardada, como al cambiar de pestaña (lo que la cierra es el botón de salir de la barra). En Android no hay botón de pantalla completa: toda la app va siempre a pantalla completa (la actividad esconde las barras del sistema, véase Móvil), así que el botón de la barra y su entrada de menú existen solo fuera de Android (useEsMovil()); el interruptor de antes, que llamaba a app_pantalla_completa desde la barra, ya no está (el comando sigue registrado). La pantalla se mantiene encendida con una mesa abierta (mesa_suscribir la enciende, mesa_salir la apaga; un fallo solo se apunta; sin comprobar: que se restaure si la sesión se cierra sin mesa_salir).
«Atrás» (lib/atras.ts, lo registra mesa-screen.tsx): primero las capas de Radix abiertas (diálogos, menús, la mano, la pantalla de vida, el registro; solo las que llevan data-state="open", así que la reacción del sparring, donde Escape decide, nunca la contesta «atrás»; el Escape va al elemento con el foco para que los atajos de la mesa no suelten además la selección); luego lo que se está leyendo, un fantasma que se elige o un modo de objetivo; y por fin la pregunta «¿Salir de la partida?» (la partida se guarda), como el botón de la barra, o salir de la repetición.
mesaDeFabricaPara(tactil) arranca un equipo táctil con las sombras apagadas y los dados en 3D ligero de CSS (dados: "css"), que aguantan mejor en equipos modestos que el 3D con física, y la miniatura de la vista previa en 244 (calidadVistaDeFabrica); lo que la persona haya guardado manda. El CSS global también sube a 2,5 rem los campos, selectores y filas de menú en táctil (la barra de la mesa y los marcadores de vida quedan por debajo a propósito, arriba).
Hay: todo lo que hace la mesa, con los gestos de arriba. No hay o no está medido: el zoom con la rueda, el botón central o el espacio en un ordenador (no se ha implementado), un diálogo propio de ajustes de la partida a pantalla completa, una medida de rendimiento en un teléfono real, y los gestos táctiles se han probado en un navegador sin ventana con emulación táctil (914 × 411) y un Tauri simulado, no en Android ni en WebKitGTK, ni con imágenes reales. Deducido: la carga de animación de diez tableros no es un problema porque solo se pinta uno.
src/features/settings/mesa.tsx edita useUi().mesa (PreferenciasMesa), que se guarda en localStorage con las demás preferencias (proxybuilder.preferencias, clave mesa) y al leerse se funde sobre MESA_DE_FABRICA, así un ajuste nuevo sale con su valor de fábrica.
| Ajuste | Campo | De fábrica |
|---|---|---|
| Ofrecer el bono de auras y equipos al adjuntarlos | ofrecerBono |
encendido |
| Sparring al empezar en solitario | sparring |
apagado |
| Ya se enseñó la explicación del sparring | sparringVisto |
apagado |
| Ver la vida del fantasma en solitario | fantasma |
encendido |
| Mantener pulsado abre el menú de la carta | clicSostenido |
apagado |
| Tamaño de las cartas del campo | tamanoCartas |
100 % (25–200) |
| Tamaño de la vista grande | vistaGrande |
320 px (200–500) |
| Panel de estado junto a la vista grande | panelEstado |
encendido |
| Calidad de las imágenes del campo | calidad |
244 (146 baja, 488 alta) |
| Dados y monedas (Automático, 3D con física, 3D ligero, 2D) | dados |
auto (3D ligero, css, en un equipo táctil) |
Aspecto de los dados y la moneda (aspecto-dados.tsx, con una muestra en CSS 3D que se tira con «Probar») |
mesa.dados en Rust (app_settings) |
color del jugador, números blancos, liso, oro |
| Sonido de los dados y las monedas | sonidoDados |
encendido |
| Sonido de las cartas (barajar, robar, girar, jugar) | sonidoCartas |
encendido |
| Sonido del turno (pasar, tu turno) | sonidoTurno |
encendido |
| Sonido del chat (mensajes, avisos, fantasma) | sonidoChat |
encendido |
| Volumen | volumen |
60 % |
| Reducir animaciones | reducirAnimaciones |
apagado |
| Sombras | sombras |
encendido (apagado en un equipo táctil) |
| Contador de fotogramas | fps |
apagado |
| Contadores de jugador junto a la vida | trackers |
ninguno (se eligen en el menú de la vida) |
| En red, ver cómo mueven los demás y mostrarles mi cursor | movimientoEnVivo |
encendido |
| En partida local, pasar a mirar al jugador del turno |
seguirTurnoLocal (antes seguirTurno, renombrado para que todos reciban el valor nuevo) |
encendido |
| Ruleta al sortear quién empieza (solo con varios jugadores) | ruletaEmpieza |
encendida |
Cómo se reparten los tableros (por número de jugadores: unico, dividido, foco, rejilla, equipos) |
disposicion |
ninguna guardada: dividido con 2, foco con más |
«Modo rendimiento (todo al mínimo)» pone MODO_RENDIMIENTO: calidad 146, sin sombras, animaciones reducidas y sin panel de estado.
- Parches, no vistas: Rust manda solo las cartas y zonas tocadas y el store conserva la identidad de lo demás;
CartaMesava conmemoy se suscribe a su propio id. - Los selectores nunca devuelven un array vacío nuevo (
NINGUNA) ni una lista nueva sinuseShallow, porque zustand vería un cambio en cada lectura y repintaría (o entraría en bucle). Lo mismo vale para las listas hechas conObject.entries(...)o.map(...):useShallowcompara los elementos por identidad, así que una lista de tuplas[clave, valor]nuevas nunca es igual y React se para con «Maximum update depth exceeded» (#185). Se elige el registro del store (misma referencia mientras no cambia) y la lista se deriva conuseMemo, como haceen-vivo/fantasmas.tsxcon los cursores y las cartas arrastradas de los demás. - Las imágenes se preparan una vez a su medida final (y los anchos del tablero se reducen de antemano, véase «Dónde viven las imágenes») y se piden al ancho al que se pintan, con
decoding="async"y sinloading="lazy": en WebKitGTK la carga diferida retrasa las imágenes de cajas que se mueven, y las cartas salían tarde.imagen-carta.tsxle da al<img>un fondo oscuro (bg-slate-900) que guarda la forma de la carta mientras carga; si una URL falla pinta en su lugar el reverso genérico con el nombre de la carta (DorsoGenerico, nunca un hueco) y lo vuelve a intentar a los 4 s (REINTENTO_MS; en red puede que el anfitrión aún no tuviera la imagen). -
Abrir la mesa sin que las cartas aparezcan de golpe. La primera vista entera (con la mesa aún sin activar) espera a que sus imágenes estén decodificadas (
precarga.ts::precargar, 1,5 s como mucho,ESPERA_PRECARGA_MS):urlsAPrecargarlista las caras a la vista (campo, mano, lo de arriba de cada zona), los reversos y los tapetes a los anchos que pide el tablero; lo que llegue tarde sigue cargando y entra después. Mientras tantoconexion.tspone en cola todo mensaje que llegue (enEspera) y lo aplica en orden cuando se enseña la vista, así un parche nunca se adelanta a su vista. -
Sin deslizarse desde la esquina.
tablero.tsxmide el tablero en unuseLayoutEffect, antes del primer pintado, y no pinta nada mientras mide 0 × 0; antes, el primer fotograma repartía las cartas en un tablero vacío y todas entraban deslizándose desde la esquina de arriba a la izquierda. -
Sin deslizarse al cambiar de tamaño. Mientras el tablero cambia de tamaño (la ventana, el separador entre mesas, un panel lateral)
usePantalla.redimensionandoestá encendido (hasta 200 ms después del último cambio, desde elResizeObserverdel tablero) yCartaMesaquita la transición de su posición y el FLIP, así las cartas van directas a su sitio nuevo en vez de quedarse atrás y parecer que tiemblan. - Con los dados en 3D, three.js y Rapier se importan en un rato libre al abrir la mesa (
dados/capa.tsx,requestIdleCallbacko 1,5 s), así la primera tirada no los espera. -
fps.tsx:ContadorFps(ajuste) enseña los fotogramas por segundo.VigiaFpsmide cada segundo y, tras 120 segundos seguidos por debajo de 30 fps (una ventana oculta no cuenta), ofrece una vez «Bajar la calidad», salvo que todo esté ya al mínimo. Las animaciones se saltan cuando lo pide el ajuste o el sistema (prefers-reduced-motion) (useSinAnimaciones).
- Las cartas que quien mira conoce viajan con su imagen; las ocultas, solo con su dorso (
caraVisiblecae endorso). Mirar la de arriba de la biblioteca (escrutar) no la pone boca arriba en la mesa; solo «Jugar con la de arriba revelada». - Una carta elegida o bajo el ratón que desaparece en un parche (o que falta en una vista entera nueva) se quita del estado de pantalla, así un atajo posterior nunca actúa sobre una carta que ya no está. Una vista entera que trae registro (deshacer, reiniciar) siempre quita la carta bajo el ratón, porque ahí los ids se reutilizan.
- Soltar cartas en la zona de la que salieron no hace nada (salvo en la biblioteca y el campo), así la mano no se reordena arrastrando.
-
src-tauri/src/mesa/ejemplo.rses un test ignorado que crea un deck de ejemplo con cartas diseñadas, sin red, para probar la mesa y sacar capturas en una máquina sin Scryfall (PB_DATOS=… PB_CACHE=… cargo test --lib deck_de_ejemplo -- --ignored).
- Rust:
mesa::sesion::tests(50 acciones al azar dan la misma vista por parches que entera, un rechazo llega con su motivo, reiniciar reparte otra vez con la misma lista, una partida abierta deja la app ocupada),mesa::guardada::tests,mesa::preparar::tests(tres cartas dan tres imágenes y la segunda vez ninguna),protocol::tests::las_rutas_de_la_mesa. - Datos del motor:
src-tauri/crates/mesa/tests/fixtures.rsescribesrc/stores/__tests__/fixtures/mesa-parches.json(una primera vista, 60 parches y la vista final; se rehace conPB_FIXTURES=1 cargo test -p mesa --test fixtures).src/stores/__tests__/mesa-store.test.tslos aplica y tiene que llegar a la vista final; también comprueba los saltos de versión, la identidad de los objetos, la limpieza de la selección y que una vista con registro lo sustituye y una sin él lo conserva.tests/registro.rsdel motor compruebavigentes(). La carpeta de datos queda fuera de Biome. - Listas: los tests de
mesa::lista,tests/preparar.rs::con_una_lista_solo_va_lo_elegido,store/listas.rs(guardar, leer, sustituir, borrar, se van con el deck),mesa/preparar.rs::una_disenada_sabe_si_puede_ser_comandante,package.rs::las_listas_para_jugar_viajan_con_los_ids_nuevos,src/features/mesa/listas/__tests__/eleccion.test.ts. - Dados:
src/features/mesa/dados/__tests__/geometria.test.ts(caras, opuestas que suman, lectura, cambio de etiquetas, las esquinas del d4, el d100, ninguna cara en espejo) yfisica.test.ts(la misma semilla da la misma grabación y los mismos golpes; los golpes van en orden y con fuerza de 0 a 1; 300 tiradas se paran a tiempo, no salen de la mesa y la cara leída es la del motor); en Rust,mesa::dados::tests(el aspecto de fábrica, los colores comprobados, la imagen preparada cuadrada y protegida, una imagen que no está se rechaza) ymesa::tapete::tests. - Pantalla:
src/features/mesa/__tests__/(arrastre.test.tsgeometría y destinos al soltar,atajos.test.ts,buscar.test.ts,pt.test.ts),src/lib/__tests__/errores.test.ts(motivos de rechazo),src/lib/__tests__/cierre.test.ts,src/stores/__tests__/ui-store.test.ts(ajustes de la mesa fundidos con los de fábrica). - Varios jugadores:
src/features/mesa/__tests__/(disposicion.test.tspara cada modo ynde 1 a 10,tablero-mini.test.ts,config-local.test.ts,equipos.test.ts,dano-comandante.test.ts,planar.test.ts,encuentro.test.ts,cuentas-muerte.test.ts,limitador.test.ts); en el motortests/{equipos,formatos,turnos,encuentro,muerte}.rs;mesa::sesion::tests(cambiar quién se mira en una partida local, rechazado en red). - Final, historial y repetición:
crates/mesa/tests/resumen.rs(puestos, empates, equipos, conceder, una partida sin acabar);store::partidas::tests(guardar y leer las partidas de un deck, un deck en dos asientos cuenta una vez, volver a terminar reemplaza, borrar, jugar no cambia la fecha del deck, una base vieja pasa de la 5 a la 6);mesa::historial::tests(una partida sin terminar no se guarda, una terminada lleva su registro, y un invitado que siguió la partida desde el mulligan guarda la repetición entera con la huella del anfitrión);mesa::repeticion::tests(cada punto tiene la huella que tenía al jugarse, un hito por turno, no se juega en repetición, ver como un jugador o como todos); en la pantalla, ensrc/features/mesa/__tests__/,cuentas-fin.test.ts,registro.test.ts(cuentas-registro),cuentas-repeticion.test.ts,chat.test.ts(cuentas-chat),cuentas-historial.test.tsysonidos.test.ts.
- Una acción nueva en la pantalla: primero en el motor (Motor de la mesa),
pnpm bindings, su operación enoperaciones.ts, su opción de menú y, si la tiene, su tecla enATAJOS, y su frase entextos-eventos.ts::FRASES(el tipoRecord<Accion["que"], string>hace que falte una y no compile). - La categoría de una acción nueva en el registro:
categorias-eventos.ts::CATEGORIAS_DE_ACCION(si falta, no compila) y, si debe sonar,sonidoDeEvento. - Un motivo de rechazo nuevo: su texto en
errores.ts::MOTIVOS.Rejected. - Un dado o pieza nuevos: su sólido en
geometria.ts(con los tests de caras y orientación), su pieza encola.ts/piezas.tsy su silueta enplano.tsx::SILUETA. - Un ajuste nuevo:
PreferenciasMesayMESA_DE_FABRICAenui-store.ts, y despuésfeatures/settings/mesa.tsx. - Una disposición nueva: un
ModoDisposicionendisposicion.tscon su caso enrepartiry su test de que no se solapa parande 1 a 10; las miniaturas sontablero-mini.tsxy nunca pasan a ser interactivas. - Una regla de formato nueva (quién es oponente, vida del equipo, orden de turnos, quién está fuera): en el motor, con su test en
tests/; la pantalla solo pinta lo que diceVistaMesa. - Texto nuevo: por
t()con la fuente en español en el áreamesade los 15 diccionarios (src/i18n/<código>/mesa.json), con los términos de juego de la tabla «Términos de la mesa de juego» desrc/i18n/GLOSARIO.md(Internacionalización).
Use and build · Uso y desarrollo
- User-Guide · Guía de usuario
- Development-Setup · Preparación del entorno de desarrollo
- Testing-and-CI · Tests y CI
- Releases-and-Auto-Updates · Versiones y actualizaciones automáticas
- Conventions · Convenciones
- Rename-to-Tapete · Cambio de nombre a Tapete
- License · Licencia
Architecture · Arquitectura
- Architecture · Arquitectura
- IPC-and-Error-Handling · IPC y manejo de errores
- Rust-App-Layer · Capa de aplicación Rust
Data · Datos
- Deck-Model · Modelo del mazo
- Storage-and-Blobs · Almacenamiento y blobs
- Deck-Package-Format · Formato del paquete de mazo
- Deck-Library · Biblioteca de mazos
Getting cards · Conseguir cartas
- Decklist-Parsing · Parseo de la lista del mazo
- Scryfall-Integration · Integración con Scryfall
- External-Sources · Fuentes externas
- Card-Identification-and-OCR · Identificación de cartas y OCR
Layout, images, PDF · Maquetación, imagen y PDF
- Layout-Engine · Motor de maquetación
- Imaging-and-PDF · Imagen y PDF
- Export · Exportación
- The-Card-Stamp · El sello de las cartas
- Live-Preview · Vista previa en vivo
- Statistics · Estadísticas
Card designer · Diseñador de cartas
- Designer-Overview · Visión general del diseñador
- Designer-Engine · Motor del diseñador
- Designer-Composition · Composición del diseñador
- Frame-Packs · Packs de marcos
- Designer-App-Layer · Capa de aplicación del diseñador
- Designer-UI · Interfaz del diseñador
- Card-Editor-Dialog · Diálogo Editar carta
Game table · Mesa de juego
- Game-Engine · Motor de la mesa
- Game-Table · Mesa de juego
- Online-Play · Juego en línea
- Friends-and-Messages · Amigos y mensajes
Mobile · Móvil
Frontend · Frontend
- Frontend-Architecture · Arquitectura del frontend
- State-Stores · Stores de estado
- Internationalization · Internacionalización
- Themes-and-Skins · Temas y skins
- Icons · Iconos
- Settings-and-Shell · Ajustes y shell
- Tutorials · Tutoriales
- Decklist-Panel · Panel de la lista del mazo
- Add-Card-and-Variants · Añadir carta y variantes
- Card-Search · Búsqueda de cartas
- Set-Browser · Explorador de sets
Reference · Referencia