Skip to content

Mesa de juego

Pablo Martínez López edited this page Oct 27, 2026 · 20 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, salas) 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; «Descartar» también pregunta (¿Descartar la partida?).

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 agrupadas por nombre (filtro por nombre o tipo, «Solo las elegidas»), 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).
  • 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.

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

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 son una capa SVG del color del jugador (continua para atacar, discontinua para objetivo; un jugador objetivo apunta arriba en el centro); las «bolitas» de colores se pintan en el campo y se quitan con doble clic.
  • El fantasma (general.fantasma) es la vida del rival imaginado del solitario: botones −/+, daño de comandante y objetivo de las flechas.

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), «Robada»/«Prestada» si quien la controla no es su dueño y el texto de una habilidad en la pila. 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 (Eye); las que están fuera de fase salen desvaídas; 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 tres 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, traducidos), fuerza y resistencia (CONTADORES_PT, los +X/+Y y -X/-Y que existen en cartas reales) y otros contadores (CONTADORES_OTROS, unos 200 tipos por su nombre en las reglas en inglés, sacados del texto de reglas de cartas reales en Scryfall; se dejan fuera los de jugador, como veneno, energía, experiencia o rad, y los de habilidad, que son los contadores de palabra clave del menú de la carta, PALABRAS_CLAVE). Los de fuerza y resistencia salen enteros en la insignia de la carta; los demás, abreviados a cinco letras.

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.
  • 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 (un Mover por carta). 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, 0,8 s como mucho), toma el relevo en el mismo fotograma en que llega a su sitio nuevo. Una carta recién aterrizada no tiene la transición CSS del transform (CartaMesa, aterriza) ni se anima con FLIP (flip.ts::marcarArrastradas), así que nunca se desliza desde su sitio de antes. 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.
  • 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, el número de cartas de la mano y de la biblioteca y el campo con las cartas a 146 px (AnchoMiniatura), sin animaciones. 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 «Cartas originales en mi idioma».

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

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. 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 (siguienteVivo) 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. Quien creó la sala (o el anfitrión de una partida por código) tiene «Expulsar de la partida» en el menú del jugador y «Expulsar de la sala» en la sala de espera: 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. 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; en la mesa U enderezar todo, Ctrl+Z/Ctrl+Y deshacer/rehacer, 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; 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.
  • 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.
  • barra-mesa.tsx: «Mirando» cuando el tablero que se ve no es el tuyo, 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 (setFullscreen, que necesita core:window:allow-set-fullscreen en capabilities/default.json), 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».

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 dos pestañas, «Registro» 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).
  • 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).
  • 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.

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 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 y un campo de texto (Enter envía, Esc sale del campo). 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.
  • 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».

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 guarda solo el resumen, con registro vacío, porque nunca tuvo el registro entero; los espectadores no guardan nada.
  • 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. 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.
  • 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 150 ms y 1,5 s y dividido por la velocidad (pausaEntre).
  • Mientras se repite, la mesa no acepta ninguna acción (Rejected, motivo: repeticion), el chat no escribe y ninguna línea ofrece «A la pila». Cada salto llega como una Vista entera, nunca como parche, así que una repetición no hace sonar la mesa ni saca bocadillos de chat.
  • 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.
  • 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. 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). 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).
  • 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. Solo suenan los eventos que llegan como parche, así que una repetición (que avanza con vistas enteras) no suena. El azar del sonido es Math.random: no decide nada.

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
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
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 seguirTurno apagado
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).
  • Las imágenes se preparan una vez a su medida final y se piden al ancho al que se pintan, con loading="lazy" y decoding="async".
  • 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 y una terminada lleva su registro); 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

Frontend · Frontend

Reference · Referencia

Clone this wiki locally