diff --git a/AGENTS.md b/AGENTS.md
index af8f3c4..229a38e 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,15 @@ 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 (fase de conectores) y en §18 (fase 1, donde el «hueco» es el cartel con el nombre del componente: allí el mismo aviso decía la respuesta entera). |
+| 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. |
+| 59 | **La ventanita del zoom se monta una vez por fase, no por pieza** | Montarla condicionada a la pieza seleccionada la desmontaba **en cada colocación** de la fase 1 (18 por partida) y **cada montaje creaba un contexto WebGL nuevo**; el que el navegador se lleva por agotamiento es el más antiguo, o sea el de la **escena principal**, y la vista 3D dejaría de pintar. Ahora el panel (y su lienzo) viven toda la fase 1 y sin selección se **ocultan** con `zoom--oculto` (así el lienzo conserva su tamaño y su contexto); fuera de la fase 1 se desmontan, porque ahí no hacen falta. Hallazgo `R4-001` de la revisión nativa; cubierto en §17, donde el ciclo **seleccionar → colocar (que deselecciona) → seleccionar** se comprueba de verdad y un marcador sobre el nodo del lienzo detecta cualquier remontaje (falsado: con el montaje condicional fallan las cinco comprobaciones de ese ciclo). |
+| 60 | **Los carteles de la fase 1 se reparten en 6 × 3, con las filas a 1,75 u** | Cada cartel acepta **un solo tipo** de pieza, así que la pieza nunca se coloca en el cartel equivocado: el defecto que reportó el alumnado era de **conteo**. `endDrag` cuenta fallo cuando **no** hay cartel correcto a `snapRadius` (0,6) y **sí** uno ajeno a `wrongRadius` (0,6), y con las filas a 0,95 u esa banda empezaba **a 0,35 u del centro**, o sea 0,15 u más allá del borde visible de la tarjeta (1,0 × 0,4): soltar cerca del borde contaba el vecino y **restaba puntos** (medido en examen: 100 → 97, 0 → 1 fallos). Con 1,75 u las dos regiones ya no se solapan (1,75 ≥ 0,6 + 0,6): fallar de largo solo deja la pieza sin colocar, sin coste. El peor hueco en pantalla entre dos tarjetas pasa de **23 px a 31 px**. Cuatro filas de cinco columnas no caben más separadas —a 1,25 u la de atrás queda a 0,1 u del borde de la mesa y a 1,3 se sale—, así que la forma pasa a 6 × 3 (18 celdas exactas, sin huecos). Todo medido con la proyección de la cámara, y el agarre de la pieza en el arnés quedó unificado en `COGER_PIEZA` (el monitor caía 0,15 u por encima del punto calculado y no se dejaba coger). Cubierto en §19, con la regla geométrica, el hueco en pantalla y la suelta real a 0,7 u; falsado: con 0,95 u fallan los cuatro. |
## 7. Pipeline de assets 3D
@@ -314,7 +323,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 +356,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..4d6d2bd 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,12 @@ 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). El panel se monta
+ durante toda la fase y se oculta sin selección: montarlo y
+ desmontarlo con cada colocación creaba un contexto WebGL por
+ ciclo (R4-001). */}
+ {stage.id === 'identify' && }
{phase === 'building' && }
>
)}
diff --git a/src/data/components.ts b/src/data/components.ts
index 696e8b4..f05ec4a 100644
--- a/src/data/components.ts
+++ b/src/data/components.ts
@@ -654,10 +654,29 @@ export function rotationPreviewDef(target: RotationTarget, rotation: Vec3): Comp
}
}
-/** Rejilla de los carteles de la identificación, más separada que la bandeja. */
-const PLATE_COLS = [-3.6, -1.8, 0, 1.8, 3.6]
-const PLATE_Z0 = -1.05
-const PLATE_DZ = 0.95
+/**
+ * Rejilla de los carteles de la identificación: 6 columnas × 3 filas (18 carteles,
+ * uno por pieza, así que no sobra ninguna celda).
+ *
+ * La separación entre filas no es estética: en la fase 1 la colocación se resuelve
+ * por cercanía al centro del cartel con `snapRadius` 0,6, y el fallo se cuenta
+ * cuando un cartel AJENO está a `wrongRadius` (0,6) del punto donde se suelta
+ * (`endDrag` de `useGameStore`). Con las filas a 0,95 u —lo que había— la banda
+ * que cuenta el vecino empezaba a 0,35 u del centro, o sea **0,15 u más allá del
+ * borde visible de la tarjeta** (1,0 × 0,4): soltar cerca del borde contaba como
+ * fallo del cartel de al lado y restaba puntos. Ahora la fila mide 1,75 u, así que
+ * las dos regiones ya no se solapan (1,75 ≥ 0,6 + 0,6): fallar de largo solo
+ * significa que la pieza no se coloca, y el peor hueco en pantalla entre dos
+ * tarjetas pasa de 23 px a 31 px.
+ *
+ * Todo medido con la proyección de la cámara (`tests/`, sonda de la fase 1): cuatro
+ * filas de 5 columnas no caben más separadas —con 1,25 la de atrás queda a 0,1 u
+ * del borde de la mesa y con 1,3 se sale— y seis columnas dejan 55 px de hueco
+ * horizontal con tres filas de 1,75.
+ */
+const PLATE_COLS = [-3.75, -2.25, -0.75, 0.75, 2.25, 3.75]
+const PLATE_Z0 = -0.95
+const PLATE_DZ = 1.75
function plateSlot(index: number, rowCount: number): [number, number] {
const col = Math.min(Math.floor(index / rowCount), PLATE_COLS.length - 1)
@@ -753,12 +772,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..2fe4b5a 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,112 @@ 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;
+}
+
+/* Sin pieza seleccionada el panel se queda montado pero invisible: así su lienzo
+ (y su contexto WebGL) sobrevive a las 18 colocaciones de la fase 1 y no se
+ crea uno nuevo en cada una (R4-001). */
+.zoom--oculto {
+ visibility: hidden;
+}
+
+.zoom__view {
+ position: relative;
+ height: 132px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* El lienzo se queda montado aunque se enseñe el PNG: montarlo y desmontarlo al
+ cambiar de pieza creaba un contexto WebGL por ciclo (ver ComponentZoom). */
+.zoom__canvas {
+ position: absolute;
+ inset: 0;
+}
+
+.zoom__canvas--tapado {
+ visibility: hidden;
+}
+
+.zoom__img {
+ position: relative;
+ 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 +1056,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..e685f3e
--- /dev/null
+++ b/src/ui/ComponentZoom.tsx
@@ -0,0 +1,113 @@
+import { Suspense, useRef, useState, type ReactNode } from 'react'
+import { Canvas, useFrame, useThree } from '@react-three/fiber'
+import * as THREE from 'three'
+import { connectorImageUrl } from '../data/assets'
+import { COMPONENT_BY_ID } from '../data/components'
+import { ComponentVisual } from '../three/ComponentModel'
+import type { GameMode } from '../types'
+
+/** Dirección desde la que se mira la pieza: tres cuartos desde arriba. */
+const MIRADA = new THREE.Vector3(0.45, 0.72, 0.85).normalize()
+
+/**
+ * Encuadra la pieza: mide su caja envolvente real y coloca la cámara mirando a su
+ * centro desde tres cuartos, a la distancia que la deja entera.
+ *
+ * Hace falta porque las piezas no comparten forma ni orientación: la CPU y el SSD
+ * van tumbados (de frente solo se ve el canto), el disipador es bajito (de frente
+ * se ve su parte de abajo) y los altavoces miran hacia atrás. Y la distancia se
+ * mide en vez de estimarse porque la RAM es larga y estrecha: con perspectiva su
+ * extremo cercano se agranda, así que el margen tiene que cubrir el radio ENTERO
+ * visto desde el lado próximo (de ahí el 1,6).
+ *
+ * Se hace una sola vez, en el primer fotograma en que la pieza ya tiene geometría
+ * (con `Suspense` los primeros fotogramas están vacíos), y con `key` por pieza
+ * para que cada una se encuadre al seleccionarla.
+ */
+function Encuadre({ children }: { children: ReactNode }) {
+ const camera = useThree((s) => s.camera)
+ const grupo = useRef(null)
+ const listo = useRef(false)
+
+ useFrame(() => {
+ if (listo.current) return
+ const g = grupo.current
+ if (!g) return
+ const caja = new THREE.Box3().setFromObject(g)
+ if (caja.isEmpty()) return
+ const centro = caja.getCenter(new THREE.Vector3())
+ const radio = caja.getSize(new THREE.Vector3()).length() / 2 || 0.2
+ const fov = 'fov' in camera ? camera.fov : 30
+ const distancia = (radio / Math.tan((fov * Math.PI) / 360)) * 1.6
+ camera.position.copy(centro).addScaledVector(MIRADA, distancia)
+ camera.lookAt(centro)
+ camera.updateProjectionMatrix()
+ listo.current = true
+ })
+
+ return {children}
+}
+
+/**
+ * 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 enseña el PNG (más nítido que el
+ * dibujo 3D) y para el resto la vista 3D de cerca. **El panel entero se queda
+ * montado durante toda la fase 1**, también sin pieza seleccionada: montarlo y
+ * desmontarlo con cada colocación (18 por partida) creaba un contexto WebGL por
+ * ciclo, y el que el navegador se lleva por agotamiento es el más antiguo, el de
+ * la escena principal. Sin selección se oculta (`zoom--oculto`), que mantiene el
+ * lienzo y su tamaño.
+ *
+ * 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 | null; mode: GameMode }) {
+ const def = id ? COMPONENT_BY_ID[id] : undefined
+ /** Pieza cuyo PNG no cargó: se cae a la vista 3D en vez de dejar la imagen rota. */
+ const [pngRoto, setPngRoto] = useState(null)
+
+ const conImagen = def?.category === 'conector' ? connectorImageUrl(def.kind) : undefined
+ const imagen = def && pngRoto !== def.id ? conImagen : 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 = def ? 1.15 / Math.max(0.12, def.size) : 1
+
+ return (
+