Skip to content

Mesa de juego

Pablo Martínez López edited this page Nov 10, 2026 · 61 revisions

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).

Qué es (y qué no)

  • 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_accion también recibe como: IdJugador | null (actuar como otro jugador de una sesión local); la pantalla manda como: 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 Pos del motor (0–10.000 por eje); disposicion.ts solo las pasa a píxeles. Qué es una carta, si se ve, a dónde va tras una acción: todo sale de Rust.

Cómo fluye una acción

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)
Loading
  • hacer(accion) (acciones.ts) es el único que manda. Devuelve true/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.ts reú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 abre mesa_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), Cerrada y, en red, Rechazo { motivo } (el anfitrión no hizo una intención de esta pantalla; llega después, no como respuesta de mesa_accion).
  • Sesion::accion manda una Vista entera cuando el evento es un Cambio::Restaurar (deshacer y reiniciar sustituyen la partida entera) y un Parche en los demás casos.
  • mesa-store.ts::conParche solo acepta un parche cuya version sea la actual más uno; si no, devuelve null y conexion.ts pide la vista entera (mesa_vista). Copia solo lo que cambia: una carta que no se tocó sigue siendo el mismo objeto y su CartaMesa memorizada no se repinta.
  • Un rechazo (mesa::Rechazo) se convierte en AppError Rejected con params.motivo = el nombre de la variante (impl From<Rechazo> for AppError). src/lib/errores.ts tiene un texto por cada motivo conocido (NoExiste, SinPermiso, ZonaVacia, FueraDeTurno, Limite, NoSePuede, y los propios de la sesión como espectador, no_anfitrion o repeticion) y el texto general de Rejected para uno desconocido, así un motivo nuevo del motor nunca sale como código en crudo.

Empezar una partida

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 de useBiblioteca.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, limited y, si no, constructed; «Libre» se elige a mano) y las opciones vuelven a opcionesDeFabrica(formato, 1), una copia del Opciones::de_fabrica de 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 de COLORES_JUGADOR. Las variantes van apagadas. «Sparring (rival fantasma)» elige la intensidad con la que empieza la partida (se recuerda como mesa.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 (formatoActual en entrada.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.

Listas para jugar

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 avisos AvisoLista; 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, tabla deck_listas, migración 0005): aparte del deck, como las etiquetas, así el autoguardado del deck (que lo reescribe) nunca las pisa y no cambian su updated_at; se borran con el deck por ON DELETE CASCADE. El nombre se limpia de espacios y se corta en 60 caracteres; uno vacío es InvalidInput/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 y sin_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 entonces InvalidInput con motivo: falta_lista; una lista que no existe o con un aviso que impide es motivo: 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_retomar vuelve 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) en ConfigMesa.
  • 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 rejilla auto-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). impide repite AvisoLista::impide. Las reglas las pone Rust.
  • Los motivos falta_lista, lista y lista_sin_nombre tienen sus textos en src/lib/errores.ts (MOTIVOS.InvalidInput).

Preparar el deck y sus imágenes

preparar.rs::deck_de_mesa convierte un Deck de la biblioteca en el DeckDeMesa del motor:

  1. 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).
  2. datos_de reúne las cartas de Scryfall del deck desde la caché; los ids que no están, o que están sin keywords, se piden por /cards/collection en lotes de 75. Un fallo de red se apunta en el registro y se juega con lo que haya.
  3. Cada cara recibe una RefImagen, probando en este orden:
    • una carta diseñada: la pinta el motor del diseñador (diseno::pintar) a ANCHO_MESA con Salida::Impresion, con la clave d-<huella del diseño>;
    • una impresión de Scryfall sin retocar (CardSource::Scryfall, sin diseño, retoques neutros, origen ScryfallPng/ScryfallLarge, una URL de https://cards.scryfall.io/): RefImagen::Scryfall { id, cara, url } y una copia local;
    • lo demás: render_rgba del blob con los ajustes de impresión del deck (oscurecido del borde, tamaño de carta, esquinas) y sin sangrado, con la clave r-<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 de Blobs::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.
  4. info_de rellena el InfoCarta del 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 de proxy_core::stats::funciones y bono_fijo. Sin datos de Scryfall, una carta diseñada usa su propio CardData (info_de_diseno, tipo y reglas en inglés); si no, solo la línea de tipo de CardMeta.
  5. Las cartas se preparan en paralelo (rayon) y se llama a progreso(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.

Dónde viven las imágenes (imagenes.rs)

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.

La sesión (mesa/sesion.rs)

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 el Azar de la sesión.
  • Accion::Reiniciar («Otra partida») lo hace la sesión: una Partida nueva con la misma configuración y los mismos decks y una semilla nueva, y después Registro::reemplazar.
  • AppState::ocupado() es verdadero mientras hay una partida abierta (hay_partida()), aunque se esté en otra pestaña. Entonces la limpieza de blobs y update_install devuelven Busy: una actualización reinicia la app en mitad de la partida (test con_partida_la_app_esta_ocupada). update_install mira primero la partida y añade motivo: 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 con mesa::visibilidad::ve que quien mira puede ver la carta (si no, Rejected/SinPermiso) y devuelve ReglasCarta de la caché de Scryfall (una CaraReglas por cara) o, sin ella, del InfoCarta de 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 el mesa_accion de un espectador se rechaza con motivo: espectador.
  • mesa_origen_scryfall(scryfallId) monta un OrigenCarta para 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 a mesa/s/ y info_de.

Guardar y seguir (mesa/guardada.rs)

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.tsx llama a conexion.ts::guardar tanto 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. MesaScreen llama a mesa_estado al 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::cerrar manda Cerrada y 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.partida en src/lib/cierre.ts) y la guarda en cualquier caso.

El tapete (mesa/tapete.rs, tapete-dialog.tsx)

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.

El tablero (tablero.tsx, disposicion.ts)

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
Loading
  • 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 mide min(24 % del alto del campo, 240 px) × el tamaño de los ajustes (25–200 %).
  • posAPx / pxAPos pasan 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.
  • abanico coloca 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 coloca Flechas::punto: una carta del tablero grande, en la carta; yo (Mesa.yo; en una repetición con «Todo a la vista» no hay yo, 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 atributo data-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 (Controles enseñ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 pone mesas.tsx en el contexto MesasAlrededor; 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-stroke no 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 a Extremo::Fantasma; las flechas que le llegan acaban en su marcador, encontrado por ATRIBUTO_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 en ui-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-fantasma lleva su sitio y flechas.tsx mide 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.

Las cartas (carta-mesa.tsx, insignias.tsx, pt.ts, imagen-carta.tsx)

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».

Arrastrar y elegir (arrastre.ts)

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.arrastre mueve Arrastrado, 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: adjuntaA y agrupadaBajo) viaja con ella en tiempo real. La geometría del campo es una sola función, puestos-campo.ts::puestosDelCampo, que usan Campo y Arrastrado; al empezar el arrastre acompanantes dice qué va con ella y se guarda en Arrastre.con. Arrastrado pinta esas cartas detrás, con sus propios desplazamientos más lo que se mueve el puntero; las reales se ocultan (y aterrizan sin deslizarse, como aterriza) hasta que llega la carta principal. Lo que ven los demás del arrastre (vivo.arrastre) sigue llevando solo las arrastradas.
  • Al soltar, destinoDeSoltar elige 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::soltar calcula el sitio nuevo de cada una (el de antes más lo que se movió el puntero) y manda un Mover si es una sola o un Colocar (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, ahora opacity-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 con olvidarSoltadas, desde acciones.ts y desde un Rechazo en conexion.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 de rotate, 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.ts anima 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 a Extremo::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 contextmenu y se traga el pointerup y el click siguientes, así soltar no elige la opción que quede debajo.

Varios jugadores (fase G)

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 (partida-local.tsx, config-local.ts, mirar.ts)

«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.

Disposiciones (disposicion.ts::repartir, selector-disposicion.tsx, mesas.tsx)

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.

Cuatro tableros en rejilla, cada uno con el color de su jugador

El selector de disposición en la barra: un tablero, uno grande y los demás en miniatura, o todos iguales

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.

Miniaturas (tablero-mini.tsx, lento.ts)

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.

Miniaturas con la mano de cada jugador: la del compañero boca arriba, la de los rivales con el reverso

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.

Equipos (equipos.ts)

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.

Formatos: daño de comandante y planos

  • 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::DanoComandante baja la vida punto a punto y Lanzamientos cuenta 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); deltaSeguro evita igualmente que la pantalla lo pida. El aviso al llegar a 21 es la ayuda avisarVidaCero.
  • 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.

Orden de asientos y primer jugador

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 y de quién es el turno (ruleta/, turno.tsx)

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::conRuleta pone la ruleta a girar en cuanto se lanza mesa_empezar o red_empezar, con los asientos de ConfigMesa y 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. alMensaje retiene 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. azar solo 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 Empezada llama a esperarSorteo() 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 pone Sesion::publicar ante un Cambio::Restaurar sin nada deshecho. No se puede deducir del registro: Registro::vigentes quita 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 manda DelHost::Encuentro siempre que se haya elegido quién empieza, no solo con un encuentro.
  • La capa. ruleta/ruleta.tsx va por portal a body, 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 en ruleta/estado.ts y 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.tsx deja una bandera en el primer jugador durante toda la partida, porque las rondas se cuentan desde ese asiento.

Al mejor de 1, 3 o 5 con banquillo (mesa/encuentro.rs, banquillo-dialog.tsx)

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).

Muertes, quién hizo el daño y ausentes (muerte.tsx, dano-de-quien.tsx, red/ausentes.tsx)

  • 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 en VistaJugador.fuera_de_juego. La mesa nunca elimina a nadie sola: Muertes le 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 manda Eliminar { 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. Una muerte pendiente de confirmar, con la pregunta de quién hizo el daño

  • 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 rellena por cuando 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. debePreguntarDano y eliminadoPorDefecto (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). Ausentes pinta 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 (siguienteVivo sobre 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}?»; comando red_expulsar): el expulsado queda eliminado y no puede volver a entrar en esa partida.

Menús, atajos y diálogos

  • Los menús (menus/menu-carta.tsx, menus/menu-zonas.tsx, el de la vida en vida-jugador.tsx) usan ContenidoContextual (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 tabla ATAJOS con tres contextos, carta (el ratón sobre una carta, o la selección), biblioteca (el ratón sobre la biblioteca) y mesa. 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 por usePantalla.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 acepta 0..16, aplicar::COLORES_ETIQUETA); mirar.tsx (mirar, escrutar, vigilar: repartir las cartas entre arriba en orden, abajo, cementerio y exilio y confirmar con Ordenar; 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 por mesa_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 (crearFicha con OrigenFicha libre).

  • Adjuntar (operaciones.ts::adjuntar): tras adjuntar, si «Ofrecer el bono…» está encendido, el aura o el equipo tiene bonoFijo y 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: un toast.custom con 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::campoAjeno dice 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 rechazo SinPermiso (un atajo sobre una carta ajena) no avisa, ni desde acciones.ts::hacer ni del rechazo del 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» busca Beast c:g y «3/3 beast» busca beast 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 (…)», en menu-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: RevelarArriba la deja vista por todos (vista_por = Todos, VistaCarta.revelada) y OcultarArriba { 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 es excavar + 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 tecla M lo 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 con ListaDialog). 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 con commands/mesa.rs::lanzar_trabajo: una segunda tarea espera a la primera y, si acabó mal (un pánico), emite mesa://done con 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+R o 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 argumento mostrada de manoOcultaPorTurno) hasta que esa persona la destape; al cambiar de jugador se vuelve a tapar (mirarJugador la 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+R recorre 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?». quienResponde da 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: deckDelTablero da 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::LecturaGrande lo 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.tsx tapa 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 el QuedarseCon del 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; con avisarVidaCero, 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 evento Vida y 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 cambia yo, solo mirando), 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::MarcasJugador enseñ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() de lib/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.tsx enseñ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).

El registro (textos-eventos.ts)

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_ACCION es un Record<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) que piezasDeTexto vuelve 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ía mover).
  • Las líneas de «Pasar turno» dicen cuánto duró el turno («pasa el turno tras 2:13», duracionDeTurno en cuentas-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, caso flecha), 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, manda apilarDespues): 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.

Notas (notas.tsx, cuentas-notas.ts)

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.notas se 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_nota en registro.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 abren NotaDialog (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 objetivo nota, 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 de atarNota y las de notaEnCarta privadas, como las demás líneas de notas privadas.

Chat, avisos rápidos y señales (chat.tsx, cuentas-chat.ts, reaccion-eventos.ts)

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 con t() 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 de AvisoRapido al final, VERSION_CATALOGO y PROTOCOLO arriba y catalogo/v<N>.json; su texto va en AVISOS_AMIGABLES y 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-mart 5.6.0 y @emoji-mart/data 1.2.1), con estas decisiones:
    • Dónde se abre. En escritorio y tableta, un Popover encima del botón que se queda abierto para poner varios emojis seguidos (se ignora onFocusOutside porque el chat devuelve el foco al campo tras cada emoji; un clic fuera lo cierra) y que no roba el foco (onOpenAutoFocus/onCloseAutoFocus anulados, así el cursor sigue donde estaba). En el móvil, una HojaMenu con 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 de MAX_CHAT (contado en unidades UTF-16, como maxLength) 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.tsx es 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, y zhs/zht usan los dos zh (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: coloresDelTema lee los tokens de la app (--popover, --popover-foreground, --primary, --muted, más --font-sans y --radius), los pasa a r, g, b con un lienzo de 1×1 (los tokens pueden ser oklch) 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 elemento Picker de emoji-mart.
  • 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, contexto mesa): 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::reaccionarAEvento es 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.

El panel de chat con mensajes y avisos rápidos

El final de la partida, el historial y la repetición

El resumen (crates/mesa/src/resumen.rs)

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).

El resumen del final de la partida con los puestos

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.

Historial (mesa/historial.rs, store/partidas.rs, library/partidas-deck.tsx)

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 PartidaGuardada comprimida en registro (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, con registro vací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_deck desde red_unirse y red_deck; red::mi_deck): su asiento lleva ese deck_id, la lista y el nombre del deck, y la partida sale bajo ese deck en «Partidas».
  • Comandos. partidas_de_deck(deck) devuelve HistorialDeck (partidas, victorias y cada fila FilaPartida: 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) devuelve HistorialGeneral { 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 a MAX_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, desde deck_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 eso NuevaPartida.imagenes lleva las huellas que necesita la repetición (solo si hay registro: historial::de_sesion toma red::imagenes::propias_de de la partida inicial y de la final, sin repetir; las fichas que vinieron y se fueron no se pintan con imagen propia), store::partidas::guardar las apunta en partida_imagenes(partida_id, sha) y después el gancho del historial en lib.rs copia cada fichero de la caché a app_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::servir repone desde esa copia un fichero que falte en la caché antes de servirlo. store::partidas::borrar devuelve las huellas que ya no usa ninguna otra partida y partida_borrar borra 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.

El historial general, con el deck y la lista para jugar de cada asiento

La pestaña Partidas de un deck, con el resultado de cada partida

Repetición (mesa/repeticion.rs, repeticion.tsx, cuentas-repeticion.ts)

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 } y repeticion_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: los hitos (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 una Vista entera, 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.ts compara 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::reaccionarEnRepeticion hace 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 hasta SALTO_ANIMABLE (3) puntos desliza las cartas a su sitio nuevo durante duracionMovimiento (el 90 % de la espera mínima a la velocidad de ahora, para que acabe antes del siguiente paso) por useRepeticion.animarMs, que leen CartaMesa (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 en AnchoMiniatura como en la escala de lectura LecturaGrande; 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ón ambito de useFlip) y sin caducidad en una repetición, y olvidarSitios() 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() y Sesion::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 (hacer ya no rechaza las intenciones de fuera: un invitado puede deshacer un Terminar por 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_dia rehace seqs e hitos sin 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) y no_existe (ver como un jugador que no está en la partida). Un registro de un catálogo más nuevo se rechaza con Unsupported y version.

La barra de repetición sobre la mesa

El sparring en pantalla (sparring/)

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 como Accion::IntensidadSparring y 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 abre como-funciona.tsx con «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 de base) 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/collection por nombre, recuerda su id en app_settings (mesa.sparring.<nombre>) y devuelve un OrigenCarta con la imagen preparada como cualquier carta de Scryfall de la mesa (origen_de_scryfall, compartido con mesa_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 tienen IdCarta), así que no pueden pasar por VistaGrande; 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.ts lo envuelve en su propio par de marcas y piezasDeTexto devuelve { 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 que VistaGrande (apartándose del panel lateral) y por encima del aviso. Las dos vistas grandes se pintan con capa-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 a body con z-50); CapaAlta las lleva por portal a body en 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 con absolute y 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.ts escribe con t() las claves de Explicacion y 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.

Dados y monedas (dados/)

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.ts construye 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). planoCara da a cada cara su centro, su normal y sus ejes en el plano con w = 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és conResultado intercambia 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 pura geometria.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 (ANCHO 14 × FONDO 9, gravedad −40, HZ 60, 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) y giroAlAzar (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 con Path2D y 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.

Aspecto y sonido de los dados (aspecto.ts, texturas.ts, sonidos.ts, mesa/dados.rs)

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): AspectoDados en JSON en app_settings (mesa.dados). Los colores se comprueban (#rrggbb; si no, InvalidInput con motivo: color). La imagen se prepara al importarla (importar_imagen: el cuadrado del centro a LADO_IMAGEN = 256 px, en JPEG) y se guarda en el almacén de blobs, protegida en blob_refs con owner_kind = 'mesa' y owner_id = 'dados', como el tapete. Comandos: mesa_dados_get, mesa_dados_set { aspecto } y mesa_dados_imagen { ruta } (null la quita); los tres devuelven VistaAspectoDados { aspecto, imagen }, con la imagen como data: URL. Por qué data: y no pbimg://: 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 (useAspectoDados en aspecto.ts) y lo convierte en un Estilo por 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::lienzoFondo pinta 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 tiene filter en 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 como data: URL (urlFondo). Los materiales de cada acabado, en escena3d.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.ts graba 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 (tiempoDe invierte 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 es Math.random: no decide nada. Los sonidos de las cartas y de la mesa están en «Sonidos de la mesa» más abajo.

Sonidos de la mesa (sonidos.ts, audio.ts)

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.

En un móvil o una tableta (táctil)

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).

Gestos sobre una carta (gestos.ts, tablero.tsx)

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.

Leer en grande (leer-en-grande.ts)

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).

Zoom y desplazamiento del campo (gestos.ts::conVista)

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.

Modo móvil (useMesaMovil())

  • De quién es el turno, en la barra. ChipTurno va en dos líneas con el número de turno y, de pie, BotonResponder compacto enseña solo su icono (el nombre, al mantenerlo): la frase entera no cabía y tapaba el turno.
  • Un tablero a la vez. ModoDisposicion tiene 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, o elegirFantasma en 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 hace VidaJugador, y cada cambio es la misma Accion que los contadores del tablero (cuentas-vida.ts). Con vida compartida (Two-Headed Giant, vidaCompartida del equipo) el equipo es un solo objetivo de vida: objetivoQueCuenta lleva a cualquiera de los dos compañeros al de asiento más bajo y objetivosQueCuentan quita 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 (BarraMovil en barra-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 son BOTON_MOVIL (size-9 !min-h-9 !min-w-9, unos 36 px) en vez del mínimo táctil de 2,5 rem de index.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 un aria-label que 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 es text-xl y 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 (Zonas en tablero.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 mismo min(220px, max(90px, 22%)) que disponer), 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, cartaEnFila de movil/cuentas-mano.ts). La mano se mide en el hueco que dejan el título y los botones (un ResizeObserver) y cartaEnFila(ancho, alto, n) da la carta más grande, a 63 × 88, con la que caben las n una 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, porque DialogoAdaptable apila 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, ReaccionPendiente con useMesaMovil()): la carta pequeña (w-14, imagen de 244 px; al tocarla sale LecturaMovil en 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 un role="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 en Entrada, 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).

Pantalla completa, «atrás» y pantalla encendida

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.

Valores de fábrica al tocar

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).

Qué hay y qué no hay en un móvil

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.

Ajustes (Ajustes > «Mesa de juego»)

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.

Rendimiento

  • Parches, no vistas: Rust manda solo las cartas y zonas tocadas y el store conserva la identidad de lo demás; CartaMesa va con memo y se suscribe a su propio id.
  • Los selectores nunca devuelven un array vacío nuevo (NINGUNA) ni una lista nueva sin useShallow, porque zustand vería un cambio en cada lectura y repintaría (o entraría en bucle). Lo mismo vale para las listas hechas con Object.entries(...) o .map(...): useShallow compara 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 con useMemo, como hace en-vivo/fantasmas.tsx con 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 sin loading="lazy": en WebKitGTK la carga diferida retrasa las imágenes de cajas que se mueven, y las cartas salían tarde. imagen-carta.tsx le 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): urlsAPrecargar lista 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 tanto conexion.ts pone 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.tsx mide el tablero en un useLayoutEffect, 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.redimensionando está encendido (hasta 200 ms después del último cambio, desde el ResizeObserver del tablero) y CartaMesa quita 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, requestIdleCallback o 1,5 s), así la primera tirada no los espera.
  • fps.tsx: ContadorFps (ajuste) enseña los fotogramas por segundo. VigiaFps mide 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).

Casos límite y decisiones

  • Las cartas que quien mira conoce viajan con su imagen; las ocultas, solo con su dorso (caraVisible cae en dorso). 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.rs es 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).

Tests

  • 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.rs escribe src/stores/__tests__/fixtures/mesa-parches.json (una primera vista, 60 parches y la vista final; se rehace con PB_FIXTURES=1 cargo test -p mesa --test fixtures). src/stores/__tests__/mesa-store.test.ts los 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.rs del motor comprueba vigentes(). 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) y fisica.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) y mesa::tapete::tests.
  • Pantalla: src/features/mesa/__tests__/ (arrastre.test.ts geometrí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.ts para cada modo y n de 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 motor tests/{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, en src/features/mesa/__tests__/, cuentas-fin.test.ts, registro.test.ts (cuentas-registro), cuentas-repeticion.test.ts, chat.test.ts (cuentas-chat), cuentas-historial.test.ts y sonidos.test.ts.

Por dónde empezar al ampliar

  • Una acción nueva en la pantalla: primero en el motor (Motor de la mesa), pnpm bindings, su operación en operaciones.ts, su opción de menú y, si la tiene, su tecla en ATAJOS, y su frase en textos-eventos.ts::FRASES (el tipo Record<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 en cola.ts/piezas.ts y su silueta en plano.tsx::SILUETA.
  • Un ajuste nuevo: PreferenciasMesa y MESA_DE_FABRICA en ui-store.ts, y después features/settings/mesa.tsx.
  • Una disposición nueva: un ModoDisposicion en disposicion.ts con su caso en repartir y su test de que no se solapa para n de 1 a 10; las miniaturas son tablero-mini.tsx y 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 dice VistaMesa.
  • Texto nuevo: por t() con la fuente en español en el área mesa de 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» de src/i18n/GLOSARIO.md (Internacionalización).

Véase también

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Mobile · Móvil

Frontend · Frontend

Reference · Referencia

Clone this wiki locally