From bc9e0ba112f2e0f3bc8f6d0073035eaa5c060d29 Mon Sep 17 00:00:00 2001 From: jmoredev Date: Tue, 13 Oct 2026 18:17:28 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20arrastre=20t=C3=A1ctil,=20resumen?= =?UTF-8?q?=20del=20examen,=20aviso=20de=20carga=20y=20arreglos=20de=20la?= =?UTF-8?q?=20fase=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un solo commit porque los cambios están entrelazados en los mismos ficheros (hoja de estilos, App, store, arnés y documentación). - Arrastre: `pointercancel` en las dos superficies desde las que se empieza a arrastrar y `touch-action` efectivo en el lienzo (el inline que escribe OrbitControls al desmontarse ganaba a la clase, así que la regla era inerte). - Examen: resumen final con la pieza, el hueco y qué iba ahí, más lo que quedó sin colocar con su fase; y la barra de estado deja de dar el nombre de la pieza y del hueco compatible. - Aviso de carga con porcentaje, no bloqueante, mientras llega un modelo pesado. - Fase 1: mesa de 6 columnas x 3 filas (filas a 2,1 u) para que dos piezas de la misma columna visual no se dibujen una detrás de otra, y ventanita con la pieza seleccionada ampliada (PNG nitido del conector o vista 3D de cerca, sin el nombre en examen). - Practica: cartel al empezar cada fase con lo que hay que hacer en ella. - Arnes: secciones 13-17, check de "misma columna visual" en la 11, preflight de servidor y de instancia del store, y ONLY= para acotar pasadas. 210 comprobaciones en verde y falsacion de cada regla nueva. --- AGENTS.md | 44 +- README.md | 28 +- src/App.tsx | 11 + src/data/components.ts | 27 +- src/data/stages.ts | 11 +- src/index.css | 143 +++++ src/store/useGameStore.ts | 56 +- src/three/Scene.tsx | 7 + src/ui/ComponentZoom.tsx | 49 ++ src/ui/ConnectorBar.tsx | 6 + src/ui/FinishModal.tsx | 60 +- src/ui/LoadingBar.tsx | 34 ++ src/ui/StageBanner.tsx | 32 +- src/ui/StatusBar.tsx | 15 +- tests/run.mjs | 1087 ++++++++++++++++++++++++++++++++++++- 15 files changed, 1559 insertions(+), 51 deletions(-) create mode 100644 src/ui/ComponentZoom.tsx create mode 100644 src/ui/LoadingBar.tsx diff --git a/AGENTS.md b/AGENTS.md index af8f3c4..cedbbd6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,7 +89,7 @@ PC-Simulator/ │ │ ├── CalibrationPicker.tsx # captura de puntos en modo calibración │ │ ├── DebugGrid.tsx # rejilla de coordenadas (?debug=1) │ │ └── primitives.tsx # helpers Box / Cyl / Ball -│ ├── ui/ # menú, lista por fases, ficha, progreso, resultados, calibración y leyenda +│ ├── ui/ # menú, lista por fases, ficha, progreso, resultados, calibración, aviso de carga y leyenda │ ├── types.ts # tipos compartidos (Stage, MountPoint, ComponentDef…) │ ├── App.tsx # lienzo 3D + capas de interfaz │ ├── main.tsx # punto de entrada @@ -186,6 +186,13 @@ Reglas de layout: | 49 | **Publicado en GitHub Pages como sitio de proyecto** (`deploy-pages.yml`) | La web pública quedará en `https://jmoredev.github.io/PC-Simulator/` (subruta de sitio de proyecto) una vez activado Pages en el repositorio: el `base` de Vite sale de `BASE_PATH`, que el workflow exporta como `/PC-Simulator/` (sin esa variable todo queda en `/` para desarrollo y preview). Los pull requests hacia `main` solo compilan; publicar lo hace únicamente un push a `main` (el job de despliegue exige además `github.ref == refs/heads/main`, así que una ejecución manual sobre otra rama tampoco publica). Los modelos de placa se publican **tal cual** precisamente para no mover la calibración (`mounts`, `rear`, `ports` se midieron sobre esos `.glb` exactos). | | 50 | **Los paneles laterales se pliegan** y la partida arranca con la lista recogida y la ficha abierta | La lista de fases ocupaba 300 px de lienzo para una información que solo hace falta al elegir pieza, y la ficha se consulta a cada rato: de ahí el reparto por defecto. El estado **no se recuerda** entre partidas, así que cada partida arranca en el mismo punto (y el arnés puede afirmarlo). Plegado, el panel queda como una franja fina con su botón (`◂`/`▸`) y su contenido no se monta siquiera: nada invisible que atrape el foco. No cambia el tamaño del lienzo, porque los paneles son capas flotantes sobre el canvas. | | 51 | **El examen no da ninguna ayuda** | En examen no se montan los paneles laterales: la lista de fases es el nombre de cada pieza y la ficha es la explicación, así que los dos son la respuesta (decisión 14). Y el imán del arrastre se limita a práctica: de todos los huecos, aquel al que se pega la pieza al acercarla es el correcto, así que el fantasma sigue al puntero (`ghostSnaps` en `src/three/dragGhost.ts`) y solo se pone verde cuando el imán está activo. El hueco candidato se calcula igual, así que soltar coloca y falla exactamente como antes. La leyenda de controles y el aviso de la barra de estado se quedan: no son la respuesta. Consecuencia asumida: en examen se juega arrastrando las piezas de la mesa, porque el «clic y clic» necesita la lista. | +| 52 | **El arrastre también termina cuando el navegador cancela el gesto** | Las dos superficies desde las que se empieza a arrastrar (el lienzo 3D en `Scene.tsx` y la barra de cables en `ConnectorBar.tsx`) escuchaban `pointermove`/`pointerup` en `window` pero no `pointercancel`, así que un gesto que el navegador se queda —desplazamiento, zoom o gesto del sistema en una pantalla táctil— dejaba la pieza colgada del cursor y solo se salía con `Esc`. Además, el lienzo y la barra llevan `touch-action: none`, que es lo que impide que el navegador se quede el gesto de entrada. La cancelación **cancela**, no suelta: no coloca la pieza y no cuenta intento ni fallo, igual que `Esc`. Consecuencia: en Chromebooks táctiles (o convertibles) el arrastre es el mismo gesto que con ratón. | +| 53 | **El resumen del examen dice qué se falló y qué quedó sin colocar** | Una nota sobre 100 no enseña nada: `FinishModal` solo mostraba contadores. Ahora el store anota cada fallo con la pieza arrastrada y el hueco señalado (`misses`) y las piezas que quedaron sin poner al rendirse (`unplaced`). El recuento y la lista de lo que falta salen de la **misma** función (`stageComponents()` en `src/data/components.ts`, de la que también se deriva `STAGE_STEPS`), así que no pueden discrepar, y el fallo se anota bajo la **misma** condición que lo cuenta, así que `errors` es siempre el número de filas. Cada fila dice la pieza, el hueco y cuál iba ahí, que es el momento didáctico; las piezas sin colocar llevan además **la fase en la que faltaban**, porque la misma pieza puede faltar dos veces en una partida (identificarla y montarla): sin eso la lista salía repetida y sin explicación, y con la fase la clave de cada fila es única. El resumen es solo del examen (en práctica no hay fallos que anotar) y no cambia la puntuación. | +| 54 | **Los modelos que tardan se avisan con una barra, sin bloquear** | El `.glb` de la placa pesa hasta 18,8 MB (medido: `motherboard-01` son 652.238 vértices y sus texturas, 0,08 MB, así que el peso es geometría ya cuantizada, no texturas) y con placa aleatoria por carga una de cada tres partidas lo descarga al empezar la fase 2. Antes lo único que se veía era el placeholder procedural, que aparecía y luego se sustituía sin explicar nada. La barra usa `useProgress` de drei sobre el gestor de carga de three, va arriba bajo la barra superior y con `pointer-events: none`, así que no bloquea el juego: se puede seguir mirando la escena. Desaparece al terminar porque `FileLoader` llama a `itemEnd()` en su `.finally()`, también cuando el modelo falla. | +| 55 | **La mesa de la identificación se reparte en 6 columnas × 3 filas** (filas a 2,1 u) | En la fase 1 están las 18 piezas a la vez y la cámara mira en oblicuo, así que dos piezas con la misma X se dibujan **una detrás de otra**: con la rejilla anterior (4 filas a 1,25) la separación en profundidad dentro de una «misma columna visual» era 0,9 u (~71 px) y en 200 barajados había **1.628 pares** por debajo del suelo. Con 3 filas a 2,1 u son 1,42 u (~110 px) y 0 pares. El ancho se queda en ±4 y la fila de delante en 4,4 **a propósito, medido con la proyección de la cámara**: 9 columnas hasta ±5,2 sacaban piezas de la mesa visible (x≈118 px) y las metían detrás de la ficha (x≈1190-1282), y una fila en 5,8 las cortaba en el borde inferior. El arnés lo fija con el check de «misma columna visual» (§11), que hoy falla si se vuelve a las 4 filas. | +| 56 | **En examen la barra de estado no da el nombre de la pieza ni el del hueco** | El examen consiste en reconocer las piezas: «CPU seleccionada» o «Suelta aquí: Zócalo de la CPU» era la respuesta (decisión 51). En práctica se sigue diciendo todo; en examen quedan solo las instrucciones («Pieza seleccionada. Arrástrala hasta su sitio.» y «Lleva la pieza hasta el hueco correcto.»). Cubierto en §12. | +| 57 | **En práctica, cada fase empieza con su cartel** | Antes el cartel salía solo al completar una fase, así que quien arrancaba la partida o saltaba de fase no veía qué había que hacer. Ahora sale al **empezar** cada fase en práctica (también la primera, y también al reiniciar), se quita solo a los 3,6 s, y el estado guarda `lastAdvance` para poder decir «Fase completada» solo cuando se llegó completando y no cuando se saltó. En examen no se monta: el examen no da explicaciones. Cubierto en §16. | +| 58 | **La pieza seleccionada se ve ampliada en una ventanita** (fase 1) | En la fase 1 están todas las piezas sobre la mesa y los conectores se ven muy pequeños. Al seleccionar una, una ventanita la enseña entera: el **PNG del conector** cuando existe (más nítido que el dibujo 3D: `ps2`, `usb`, `lan`, `hdmi`, `displayport`, `dvi`, `vga`) y, si no, una **vista 3D de cerca** con la misma `ComponentVisual` (drei no trae `View` en esta versión, así que es un mini lienzo). Va al centro-abajo —la captura enseñó que a la izquierda tapaba la lista de fases—, con `pointer-events: none` para no estorbar el arrastre, solo en la fase 1 y **sin el nombre en examen** (misma regla que la 56). Cubierto en §17. | ## 7. Pipeline de assets 3D @@ -314,7 +321,8 @@ npm run models:optimize -- --force # reoptimiza todo ## 8. Interacción (resumen técnico) - `TrayItem.onPointerDown` (o el asa de la lista de piezas en la fase 2) inicia - el arrastre; `DragController` escucha `pointermove`/`pointerup` en `window` y + el arrastre; `DragController` escucha `pointermove` y `pointerup` en `window`, + y también `pointercancel` (si el navegador se queda el gesto, cancela) y proyecta el rayo sobre el plano de la fase (`horizontal` o `vertical`). - `updateDrag` calcula el hueco candidato más cercano dentro de `snapRadius`, comparando las dos coordenadas del plano de la fase, y resalta el fantasma con @@ -346,7 +354,37 @@ No hay suite de tests automáticos. Antes de dar por bueno un cambio: modo calibración y una partida completa de las 3 fases). El arnés de `tests/` usa Chromium headless vía `puppeteer-core` y es la forma rápida de detectar regresiones en la colocación de piezas, el avance de fase y -el conteo de fallos. +el conteo de fallos. Busca el navegador en las cachés de puppeteer y de +Playwright y en las instalaciones habituales de Chrome (`CHROME=` lo fija). + +**Reinicia el servidor de desarrollo antes de correr el arnés si has tocado +`src/`.** Con HMR, el import dinámico de `/src/store/useGameStore.ts` que usan +algunas secciones (§7, §12 y §13) puede devolver una copia **distinta** de la que +usa la app: Vite añade `?t=…` al módulo invalidado, así que las acciones del +store dejan de llegar a la partida. El síntoma es desconcertante —la fase no +avanza, la partida no reacciona— y parece un fallo del código cuando es de la +prueba. §14 y §15 van por la interfaz con `clickButton` precisamente para no +depender de qué instancia del módulo tenga la app. + +**Y levántalo con el puerto fijado:** `npm run dev -- --port 5199 --strictPort`. +Sin `--strictPort`, si 5199 sigue ocupado por un servidor anterior —pararlo desde +una herramienta del agente no siempre mata el proceso de Vite— el servidor se pasa +al 5200 **en silencio** y el arnés, que habla con 5199, acaba hablando con un +proceso moribundo: la app carga, las primeras comprobaciones pasan y el resto +falla con errores de red que no tienen nada que ver con el código. Comprueba la +línea «Local:» del log. El arnés avisa de esto con un preflight +(`requireServer()`): si no hay simulador vivo en `BASE_URL`, sale con un mensaje +claro en vez de con veinte fallos. + +**Y que el import del store sea el de la app**: el arnés lleva otro preflight +(`requireSameStoreInstance()`) que mueve el store desde el import dinámico y +comprueba que la app lo refleja en el DOM. Sin él, la trampa de HMR de arriba se +disfrazaba de fallo de producto: siete comprobaciones «se quedó en identificación» +(job-23) sin decir en ninguna por qué. + +**Para comprobar una sola cosa** (por ejemplo falsar un cambio sin pagar la +pasada entera, que dura unos 11 minutos): `ONLY=touchDrag,loadingBar node +tests/run.mjs`, con los nombres de `SECTIONS` separados por comas. ## 11. Despliegue (GitHub Pages) diff --git a/README.md b/README.md index 9f9da50..4e1d4ec 100644 --- a/README.md +++ b/README.md @@ -33,13 +33,23 @@ La versión online para el aula se publica desde la rama `main` en 4. **Periféricos**: monitor, teclado, ratón y altavoces a **escala realista** junto al PC ya montado. - **Modo práctica**: al coger una pieza se ilumina su hueco en verde con el - nombre del sitio. + nombre del sitio. Al empezar cada fase aparece un **cartel** con lo que hay que + hacer en ella. +- **Piezas ampliadas**: en la fase 1, la pieza que seleccionas se ve ampliada en + una ventanita (los conectores, con su imagen nítida), en práctica y en examen. - **Modo examen**: sin pistas, cuenta fallos y da una nota sobre 100, con tiempo - y precisión. + y precisión. Al terminar, un **resumen** dice qué se falló —con la pieza que + pusiste, el hueco y cuál iba ahí— y qué quedó sin colocar, indicando en qué + fase faltaba cada pieza (la misma puede faltar en dos: identificarla y + montarla). Sin pistas de ningún tipo: la barra de estado no dice el nombre de + la pieza ni el del hueco. - **Ficha didáctica** de cada componente (qué es, para qué sirve y un dato curioso), en lenguaje divulgativo. - **Funciona sin assets**: si no hay modelos `.glb`, se usan formas 3D procedurales. Solo hay que soltar los modelos y activarlos. +- **Aviso de carga**: mientras llega un modelo (la placa pesa hasta 18,8 MB) se + ve una barra con el porcentaje y qué se está cargando. No bloquea: la escena y + la interfaz siguen respondiendo. - Al completar una fase, la cámara se desplaza sola a la siguiente y aparece un aviso con lo que toca hacer. @@ -69,6 +79,9 @@ npm run models:optimize # comprime los .glb de public/assets/models en la zona iluminada (solo en modo práctica). - **Ratón**: botón izquierdo para girar la cámara, rueda para acercar/alejar. - **Esc**: cancela el arrastre en curso. +- **Pantalla táctil** (Chromebooks y portátiles convertibles): el mismo gesto + sirve con el dedo. Si el sistema se queda con el gesto (desplazamiento o + zoom), el arrastre se cancela y la pieza vuelve a su sitio: no cuenta fallo. - **Ayuda**: la tarjeta de controles se abre y se cierra con el botón `?`. ## Desarrollo y ramas @@ -96,7 +109,9 @@ node tests/run.mjs # calibración + partida completa de las 3 fases ``` Usa Chromium headless (`puppeteer-core`) instalado aparte en `tests/`, así que -no añade dependencias a la app. +no añade dependencias a la app. Busca el navegador en las cachés de puppeteer y +de Playwright y en las instalaciones habituales de Chrome; si no lo encuentra, +indica la ruta con `CHROME=`. ## Modelos 3D (opcional) @@ -219,7 +234,7 @@ src/ │ ├── Placeholder.tsx # geometría procedural de cada componente │ ├── MountZone.tsx # zonas resaltadas de los huecos │ └── primitives.tsx # cajas, cilindros y esferas reutilizables -├── ui/ # menú, lista por fases, ficha, progreso, resultados y leyenda +├── ui/ # menú, lista por fases, ficha, progreso, resultados, aviso de carga y leyenda └── App.tsx # composición del lienzo y la interfaz ``` @@ -242,5 +257,10 @@ así que se citan como adaptados. - Pensado para funcionar en portátiles y Chromebooks modestos: si va lento, baja el `dpr` en `src/App.tsx` o desactiva las sombras (`shadows`). +- **Peso de los modelos**: el `.glb` más pesado es la placa + (`motherboard-01.glb`, 18,8 MB y 652.238 vértices; los otros dos, 9,3 MB y + 4,9 MB). Como la placa es aleatoria en cada carga (salvo `?board=`), una de + cada tres partidas descarga la grande al empezar la fase 2: en ese momento + aparece la barra de progreso. - El modo examen es una buena evaluación inicial; el modo práctica, para introducir el vocabulario técnico. diff --git a/src/App.tsx b/src/App.tsx index a060e60..c22f420 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,10 +6,12 @@ import type { GameMode } from './types' import { Scene } from './three/Scene' import { CalibrationPanel } from './ui/CalibrationPanel' import { ComponentList } from './ui/ComponentList' +import { ComponentZoom } from './ui/ComponentZoom' import { ConnectorBar } from './ui/ConnectorBar' import { ControlsHelp } from './ui/ControlsHelp' import { FinishModal } from './ui/FinishModal' import { InfoPanel } from './ui/InfoPanel' +import { LoadingBar } from './ui/LoadingBar' import { ModeMenu } from './ui/ModeMenu' import { StageBanner } from './ui/StageBanner' import { StatusBar } from './ui/StatusBar' @@ -85,6 +87,10 @@ export default function App() { + {/* El aviso de carga va también en calibración: la placa es el modelo más + pesado y allí se carga igual. */} + + {CALIBRATE ? ( ) : ( @@ -110,6 +116,11 @@ export default function App() { {cableBar && } + {/* En la fase 1 las piezas se ven pequeñas: la seleccionada aparece + ampliada (con su nombre solo en práctica). */} + {stage.id === 'identify' && selectedId && ( + + )} {phase === 'building' && } )} diff --git a/src/data/components.ts b/src/data/components.ts index 696e8b4..ca4f685 100644 --- a/src/data/components.ts +++ b/src/data/components.ts @@ -753,12 +753,31 @@ export const MOUNTS_BY_STAGE: Record = { peripherals: MOUNTS.filter((m) => m.stage === 'peripherals'), } +/** + * Piezas que cuentan para terminar una fase: en la identificación, todas + * (incluidos los cables que sobran); en el resto, las que tienen sitio. + * `STAGE_STEPS` y el resumen final salen de aquí para que el número y la lista + * no puedan discrepar. + */ +export function stageComponents(stage: StageId): ComponentDef[] { + const inStage = COMPONENTS_BY_STAGE[stage] + return stage === 'identify' ? inStage : inStage.filter((c) => !c.decoy) +} + /** Piezas que hay que colocar en cada fase (en la identificación, todas). */ export const STAGE_STEPS: Record = { - identify: COMPONENTS_BY_STAGE.identify.length, - board: COMPONENTS_BY_STAGE.board.filter((c) => !c.decoy).length, - ports: COMPONENTS_BY_STAGE.ports.filter((c) => !c.decoy).length, - peripherals: COMPONENTS_BY_STAGE.peripherals.filter((c) => !c.decoy).length, + identify: stageComponents('identify').length, + board: stageComponents('board').length, + ports: stageComponents('ports').length, + peripherals: stageComponents('peripherals').length, +} + +/** + * Pieza del catálogo que va en un hueco (la primera que encaja). La usa el + * resumen del examen para poder decir qué iba en el sitio del fallo. + */ +export function componentForMount(mount: MountPoint): ComponentDef | undefined { + return stageComponents(mount.stage).find((c) => mount.accepts.includes(c.kind)) } /** Colocaciones de toda la partida. */ diff --git a/src/data/stages.ts b/src/data/stages.ts index 21eeb50..d6edf29 100644 --- a/src/data/stages.ts +++ b/src/data/stages.ts @@ -38,9 +38,18 @@ export const STAGES: Stage[] = [ // Rejilla de la mesa: dos piezas contiguas nunca pueden quedar más juntas de // lo que ocupan (0,75). El desplazamiento aleatorio se deriva de estas // separaciones (`trayJitter`), así que ensanchar la rejilla las separa. + // + // 6 columnas × 3 filas = 18 celdas para 18 piezas, con las filas a 2,1 u: dos + // piezas de la misma columna —que la cámara oblicua dibuja una detrás de + // otra— quedan a 1,4 u en profundidad (~110 px en pantalla) en vez de a 0,9. + // Eran justo los conectores pequeños con latiguillo (los tres jacks y el + // USB-C) los que parecían estar en el mismo sitio. Las columnas se quedan en + // ±4 y la fila de delante en 4,4 a propósito: más ancho o más profundo saca + // las piezas de la mesa visible, las mete debajo de los paneles laterales o + // las corta en el borde inferior (medido con la proyección de la cámara). tray: { cols: [-4, -2.4, -0.8, 0.8, 2.4, 4], - rows: [0.6, 1.85, 3.1, 4.35], + rows: [0.2, 2.3, 4.4], surfaceY: 0.11, }, camera: { position: [0, 8.6, 9.2], target: [0, 0, 0.7] }, diff --git a/src/index.css b/src/index.css index 537758a..8426358 100644 --- a/src/index.css +++ b/src/index.css @@ -50,6 +50,26 @@ button { z-index: 0; } +/* Arrastrar una pieza es un gesto del juego, no del navegador: las dos + superficies desde las que se empieza a arrastrar (el lienzo 3D y la barra de + cables) se quedan el gesto, para que el desplazamiento o el zoom de la página + no lo cancelen a mitad. + + En el lienzo hace falta `!important`: `OrbitControls` (three-stdlib) escribe + `touch-action: auto` EN LÍNEA sobre el canvas al desmontarse + (`controls/OrbitControls.cjs`), y el estilo en línea gana a una clase, así que + la regla quedaba inerte y el navegador se quedaba el gesto. Se pone además en + el contenedor, porque el navegador decide el gesto con la intersección de la + propiedad en el elemento y en sus antepasados. */ +.canvas-host, +.connbar__item { + touch-action: none; +} + +.canvas-host canvas { + touch-action: none !important; +} + /* ---------- barra superior ---------- */ .topbar { position: absolute; @@ -685,6 +705,92 @@ button { bottom: 96px; } +/* ---------- aviso de carga de los modelos ---------- */ +.loadingbar { + position: absolute; + top: 74px; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px; + border-radius: 12px; + border: 1px solid var(--border); + background: var(--panel); + backdrop-filter: blur(12px); + font-size: 13px; + color: var(--muted); + /* Avisa sin estorbar: no se queda con los clics del juego. */ + pointer-events: none; + z-index: 10; +} + +.loadingbar__label { + color: var(--text); +} + +.loadingbar__pct { + min-width: 38px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.loadingbar__track { + width: 120px; + height: 6px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.22); + overflow: hidden; +} + +.loadingbar__fill { + height: 100%; + background: var(--accent); + transition: width 0.2s linear; +} + +/* ---------- ventanita con la pieza seleccionada ampliada ---------- */ +.zoom { + position: absolute; + /* Centrado y por encima de la barra de estado: así no tapa la lista de fases + (izquierda) ni la ficha (derecha), que en práctica ocupan los lados. */ + left: 50%; + transform: translateX(-50%); + bottom: 84px; + width: 176px; + border-radius: 14px; + border: 1px solid var(--border); + background: var(--panel); + backdrop-filter: blur(12px); + overflow: hidden; + /* No estorba: no se queda con los clics del arrastre. */ + pointer-events: none; + z-index: 11; +} + +.zoom__view { + height: 132px; + display: flex; + align-items: center; + justify-content: center; +} + +.zoom__img { + max-width: 92%; + max-height: 92%; + object-fit: contain; +} + +.zoom__name { + display: block; + padding: 8px 10px; + border-top: 1px solid var(--border); + font-size: 12.5px; + color: var(--text); + text-align: center; +} + /* ---------- barra de cables (fase de conectores) ---------- */ .connbar { position: absolute; @@ -930,6 +1036,43 @@ button { margin-top: 4px; } +/* ---------- resumen del examen ---------- */ +.finish-review { + margin: 18px 0 0; + padding: 14px 16px; + border-radius: 14px; + border: 1px solid var(--border); + background: rgba(30, 41, 59, 0.35); +} + +.finish-review h2 { + margin: 0 0 8px; + font-size: 13px; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--muted); +} + +.finish-review__list { + margin: 0; + padding-left: 18px; + font-size: 14px; + line-height: 1.7; +} + +.finish-review__list b { + color: var(--accent); +} + +.finish-review__where { + color: var(--muted); + font-size: 13px; +} + +.finish-review--unplaced .finish-review__list b { + color: var(--warning); +} + .finish-score { display: flex; flex-direction: column; diff --git a/src/store/useGameStore.ts b/src/store/useGameStore.ts index 91e819e..d069175 100644 --- a/src/store/useGameStore.ts +++ b/src/store/useGameStore.ts @@ -5,10 +5,27 @@ import { STAGE_STEPS, TOTAL_STEPS, shuffleLayout, + stageComponents, } from '../data/components' import { STAGES } from '../data/stages' import type { GameMode, MountPoint, Phase, Stage, StageId, Vec3 } from '../types' +/** Un fallo del examen: la pieza que se arrastró y el hueco donde se soltó. */ +export interface ExamMiss { + componentId: string + mountId: string +} + +/** + * Pieza que quedó sin colocar al rendirse, con la fase en la que faltaba: la + * misma pieza puede faltar en dos fases (identificarla y montarla), y el resumen + * tiene que poder decirlo sin repetirse sin sentido. + */ +export interface UnplacedPiece { + stage: StageId + componentId: string +} + interface State { mode: GameMode phase: Phase @@ -23,9 +40,15 @@ interface State { /** Piezas que se dejaron sin colocar al rendirse (solo examen). */ forfeited: number wrongFlash: string | null + /** Fallos del examen, en orden, para el resumen final. */ + misses: ExamMiss[] + /** Piezas que quedaron sin colocar al rendirse, para el resumen final. */ + unplaced: UnplacedPiece[] /** Marca de tiempo del último cambio de fase (para el aviso). */ stageChangedAt: number lastCompletedStage: StageId | null + /** Cómo se llegó a la fase actual: completando la anterior, saltándola o empezando. */ + lastAdvance: 'completed' | 'skipped' | null startedAt: number finishedAt: number | null @@ -104,6 +127,7 @@ function advanceAfterPlace(placed: Record, stageIndex: number) { hoverMountId: null, stageChangedAt: Date.now(), lastCompletedStage: STAGES[stageIndex].id, + lastAdvance: 'completed' as const, } } @@ -120,8 +144,11 @@ export const useGameStore = create((set, get) => ({ attempts: 0, forfeited: 0, wrongFlash: null, + misses: [], + unplaced: [], stageChangedAt: 0, lastCompletedStage: null, + lastAdvance: null, startedAt: 0, finishedAt: null, @@ -139,8 +166,11 @@ export const useGameStore = create((set, get) => ({ attempts: 0, forfeited: 0, wrongFlash: null, + misses: [], + unplaced: [], stageChangedAt: 0, lastCompletedStage: null, + lastAdvance: null, startedAt: Date.now(), finishedAt: null, }) @@ -159,8 +189,11 @@ export const useGameStore = create((set, get) => ({ attempts: 0, forfeited: 0, wrongFlash: null, + misses: [], + unplaced: [], stageChangedAt: 0, lastCompletedStage: null, + lastAdvance: null, startedAt: Date.now(), finishedAt: null, mode: s.mode, @@ -185,6 +218,7 @@ export const useGameStore = create((set, get) => ({ wrongFlash: null, stageChangedAt: Date.now(), lastCompletedStage: STAGES[stageIndex].id, + lastAdvance: 'skipped' as const, }) }, @@ -196,13 +230,19 @@ export const useGameStore = create((set, get) => ({ // lo mismo). Guardia silenciosa: sin confirmación visible. if (mode !== 'exam' || phase !== 'building' || finishedAt !== null) return if (Date.now() - stageChangedAt < 500) return - const { placed, stageIndex, forfeited } = get() + const { placed, stageIndex, forfeited, unplaced } = get() const stage = STAGES[stageIndex] - const filled = placedInStage(placed, stageIndex).size - const remaining = Math.max(0, STAGE_STEPS[stage.id] - filled) + const done = placedInStage(placed, stageIndex) + // La lista de lo que falta y su recuento salen de la misma fuente, así que no + // pueden discrepar. + const remaining = stageComponents(stage.id).filter((c) => !done.has(c.id)) const isLast = stageIndex >= STAGES.length - 1 set({ - forfeited: forfeited + remaining, + forfeited: forfeited + remaining.length, + unplaced: [ + ...unplaced, + ...remaining.map((c) => ({ stage: stage.id, componentId: c.id })), + ], selectedId: null, dragging: false, hoverMountId: null, @@ -213,6 +253,7 @@ export const useGameStore = create((set, get) => ({ stageIndex: stageIndex + 1, stageChangedAt: Date.now(), lastCompletedStage: stage.id, + lastAdvance: 'skipped' as const, }), }) }, @@ -278,11 +319,16 @@ export const useGameStore = create((set, get) => ({ .filter((c) => c.d <= stage.drop.wrongRadius) .sort((a, b) => a.d - b.d) + const failed = mode === 'exam' && wrong.length > 0 set({ dragging: false, hoverMountId: null, attempts: attempts + 1, - errors: mode === 'exam' && wrong.length > 0 ? get().errors + 1 : get().errors, + errors: failed ? get().errors + 1 : get().errors, + // El resumen final necesita saber qué se falló y dónde, no solo cuántos. + misses: failed + ? [...get().misses, { componentId: selectedId, mountId: wrong[0].mount.id }] + : get().misses, wrongFlash: wrong.length > 0 ? wrong[0].mount.id : null, }) }, diff --git a/src/three/Scene.tsx b/src/three/Scene.tsx index 8e4b172..af839e2 100644 --- a/src/three/Scene.tsx +++ b/src/three/Scene.tsx @@ -358,11 +358,18 @@ function DragController({ stage }: { stage: Stage }) { else useGameStore.getState().updateDrag(hit.x, hit.z) } const onUp = () => useGameStore.getState().endDrag() + // El navegador puede quedarse con el gesto (desplazamiento, zoom, gesto del + // sistema) y cancelar el puntero a mitad: sin atender esa cancelación la + // pieza se queda colgada del cursor y el arrastre nunca termina. Cancelar + // suelta la pieza sin colocarla y sin contar fallo, como el Esc. + const onCancel = () => useGameStore.getState().cancelDrag() window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onCancel) return () => { window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onCancel) } }, [dragging, camera, gl, raycaster, stage]) diff --git a/src/ui/ComponentZoom.tsx b/src/ui/ComponentZoom.tsx new file mode 100644 index 0000000..37fb59b --- /dev/null +++ b/src/ui/ComponentZoom.tsx @@ -0,0 +1,49 @@ +import { Suspense } from 'react' +import { Canvas } from '@react-three/fiber' +import { connectorImageUrl } from '../data/assets' +import { COMPONENT_BY_ID } from '../data/components' +import { ComponentVisual } from '../three/ComponentModel' +import type { GameMode } from '../types' + +/** + * Ventanita con la pieza seleccionada ampliada. En la fase 1 están todas sobre la + * mesa y algunas —los conectores— se ven muy pequeñas: aquí se ven enteras. + * + * Para los conectores con imagen propia se usa el PNG (más nítido que el dibujo + * 3D); para el resto, una vista 3D de cerca con la misma pieza que se arrastra. + * + * En examen no se escribe el nombre: decir qué pieza es también es una pista + * (misma regla que en la barra de estado). + */ +export function ComponentZoom({ id, mode }: { id: string; mode: GameMode }) { + const def = COMPONENT_BY_ID[id] + if (!def) return null + + const image = def.category === 'conector' ? connectorImageUrl(def.kind) : undefined + // La pieza se dibuja a un tamaño parecido en la ventanita sea cual sea su + // `size` (los modelos se autoajustan a `def.size`; los conectores procedurales + // son más pequeños y se agrandan con este factor). + const scale = 1.15 / Math.max(0.12, def.size) + + return ( +
+
+ {image ? ( + + ) : ( + + + + + + + + + + + )} +
+ {mode === 'practice' && {def.name}} +
+ ) +} diff --git a/src/ui/ConnectorBar.tsx b/src/ui/ConnectorBar.tsx index 98064e5..d22ddc8 100644 --- a/src/ui/ConnectorBar.tsx +++ b/src/ui/ConnectorBar.tsx @@ -40,12 +40,18 @@ export function ConnectorBar() { beginDrag(def.id, 100, 100) } const onUp = () => stop() + // Si el navegador cancela el gesto (desplazamiento, zoom, gesto del + // sistema) no llega ningún `pointerup`: sin esto los escuchas se + // quedarían puestos hasta el siguiente gesto. + const onCancel = () => stop() const stop = () => { window.removeEventListener('pointermove', onMove) window.removeEventListener('pointerup', onUp) + window.removeEventListener('pointercancel', onCancel) } window.addEventListener('pointermove', onMove) window.addEventListener('pointerup', onUp) + window.addEventListener('pointercancel', onCancel) }} onClick={() => select(active ? null : def.id)} > diff --git a/src/ui/FinishModal.tsx b/src/ui/FinishModal.tsx index 08cd173..ce14cae 100644 --- a/src/ui/FinishModal.tsx +++ b/src/ui/FinishModal.tsx @@ -1,6 +1,23 @@ -import { COMPONENTS, MOUNTS, TOTAL_STEPS } from '../data/components' +import { + COMPONENT_BY_ID, + COMPONENTS, + IDENTIFY_LABEL, + MOUNTS, + MOUNT_BY_ID, + TOTAL_STEPS, + componentForMount, +} from '../data/components' import { STAGES } from '../data/stages' import { examScore, scoreColor, useGameStore } from '../store/useGameStore' +import type { StageId } from '../types' + +/** Cómo se llama cada fase en el resumen, en palabras y no en identificadores. */ +const STAGE_WORD: Record = { + identify: 'identificación', + board: 'sobre la placa', + ports: 'conectores', + peripherals: 'periféricos', +} function formatTime(ms: number): string { const total = Math.max(0, Math.round(ms / 1000)) @@ -9,6 +26,12 @@ function formatTime(ms: number): string { return `${m}:${s.toString().padStart(2, '0')}` } +/** Nombre corto y divulgativo de una pieza, el mismo que usan los carteles. */ +function pieceName(id: string): string { + const def = COMPONENT_BY_ID[id] + return def ? IDENTIFY_LABEL[def.kind] : id +} + export function FinishModal() { const mode = useGameStore((s) => s.mode) const errors = useGameStore((s) => s.errors) @@ -17,6 +40,8 @@ export function FinishModal() { const startedAt = useGameStore((s) => s.startedAt) const finishedAt = useGameStore((s) => s.finishedAt) const attempts = useGameStore((s) => s.attempts) + const misses = useGameStore((s) => s.misses) + const unplaced = useGameStore((s) => s.unplaced) const reset = useGameStore((s) => s.reset) const backToMenu = useGameStore((s) => s.backToMenu) @@ -76,6 +101,39 @@ export function FinishModal() { : 'Practica de nuevo para bajar el tiempo.'}

+ {isExam && misses.length > 0 && ( +
+

Qué falló

+
    + {misses.map((miss, i) => { + const mount = MOUNT_BY_ID[miss.mountId] + const correct = mount ? componentForMount(mount) : undefined + return ( +
  • + Pusiste «{pieceName(miss.componentId)}» en{' '} + «{mount?.label ?? 'otro sitio'}» → ahí va{' '} + «{correct ? IDENTIFY_LABEL[correct.kind] : 'otra pieza'}» +
  • + ) + })} +
+
+ )} + + {isExam && unplaced.length > 0 && ( +
+

Te quedó por colocar

+
    + {unplaced.map((item) => ( +
  • + {pieceName(item.componentId)}{' '} + ({STAGE_WORD[item.stage]}) +
  • + ))} +
+
+ )} +