Un homenaje a los shooters en primera persona de los años 90.
FPS 3D retro para el navegador, hecho con Three.js y Rapier. Tiene el ritmo de aquellos clásicos: niveles laberínticos y verticales, muchos enemigos, llaves y secretos. Todo el contenido (modelos, texturas, sonidos, niveles y nombres) es original y se genera por código.
![]() Fundición Cero: el canal de lava visto desde la galería. |
![]() Pozos de Ceniza: un centinela al borde de la sima. |
![]() Combate con la remachadora. El arco rojo indica de dónde llega el daño. |
![]() El automapa (Tab) se descubre al explorar. |
![]() Menú principal. |
![]() Fundición Cero: dos centinelas vigilan la nave. Planos y rutas en la guía del jugador. |
Demo: https://carte1972.github.io/forja-abisal/ (juega en el navegador, sin instalar nada)
Guía del jugador: INSTRUCCIONES.md, con cómo jugar, daño y premios, el automapa y los planos y rutas de cada nivel.
Tráiler: botón «Ver tráiler» en el menú principal del juego. Se genera por código dentro del repositorio: cómo se hizo.
Índice: Qué hay que hacer · Funcionalidad · Controles · Instalación y ejecución · Arquitectura · Cómo crear niveles nuevos · Desarrollo · Vídeo de presentación · Recursos empleados · Créditos y licencia
La guía completa, con los planos de los niveles, está en INSTRUCCIONES.md.
Desciendes por una forja abandonada e infestada, nivel a nivel. En cada nivel tienes que llegar al interruptor de salida y pulsarlo con E. Por el camino, las puertas con franja de color te cierran el paso hasta que encuentras su llave.
Tres niveles originales, de dificultad creciente y cada vez más verticales. En cada uno hay que conseguir las tres llaves (roja, azul y amarilla) para llegar a la salida, y cada uno esconde al menos dos secretos.
| Nivel | Nombre | Qué te espera |
|---|---|---|
| 1 | Fundición Cero | Una fundición con un canal de lava, un sótano al que se baja en ascensor, una galería elevada y un patio exterior. 13 enemigos. |
| 2 | Pozos de Ceniza | Una sima a cielo abierto con tres alturas, un pozo de ácido del que solo se sale en ascensor, un puente sobre el vacío, un crematorio y unas galerías elevadas. 21 enemigos. |
| 3 | Núcleo Abisal | Una caverna con un lago de lava cruzado por pasarelas, una torre en una isla, una armería, una sala fría con un canal de ácido y una emboscada final. 34 enemigos. |
- Llaves: las puertas con franja roja, azul o amarilla solo se abren con la llave de ese color. Si no la tienes, verás un aviso. En el automapa (Tab) aparecen las llaves de las zonas que ya has descubierto.
- Secretos: algunas paredes esconden pasadizos que se abren con E. Encontrarlos no es obligatorio, pero cuentan para la puntuación.
- Entre niveles: las armas, la munición, la salud y el blindaje pasan al siguiente nivel. Al terminar uno puedes seguir o repetirlo con lo que tenías al entrar.
- Si caes: puedes reintentar el nivel con lo que tenías al entrar o volver al menú.
Al pulsar la salida se muestra el resumen del nivel:
| Dato | Qué mide |
|---|---|
| Tiempo | Lo que has tardado en completar el nivel. |
| Enemigos | Porcentaje de enemigos eliminados. |
| Objetos | Porcentaje de objetos recogidos. |
| Secretos | Porcentaje de zonas secretas encontradas. |
Terminar un nivel basta para avanzar; el reto es hacerlo al 100 % en las tres categorías, o hacerlo lo más rápido posible.
| Tecla | Arma | Disparo principal | Disparo alternativo |
|---|---|---|---|
| 1 | Martillo de pistón | Golpe rápido | Golpe cargado, más lento y con empuje |
| 2 | Pistola de servicio | Tiro preciso (cargador de 12) | Ráfaga de tres balas |
| 3 | Escopeta de dispersión | 8 perdigones (2 cartuchos) | Los dos cañones a la vez |
| 4 | Remachadora | Ráfaga continua (cargador de 50) | Modo sobrecargado: más cadencia y menos precisión |
| 5 | Lanzacargas | Carga explosiva que estalla al impactar | Carga rebotadora con espoleta |
- Cada arma recarga sola al vaciar el cargador. Con R se recarga antes.
- Sin munición, el arma hace clic en vacío y se cambia sola a otra que tenga balas (nunca al lanzacargas).
- Las explosiones empujan: disparar el lanzacargas al suelo justo después de saltar lanza al jugador por los aires (rocket jump).
| Enemigo | Aspecto | Ataque | Salud |
|---|---|---|---|
| Centinela | Soldado acorazado con visor rojo | Ráfagas de tres disparos a distancia | 60 |
| Rastrero | Criatura encorvada de brazos largos, muy rápida | Zarpazos cuerpo a cuerpo | 45 |
| Escupidor | Mole con sacos de ácido brillantes | Bolas de ácido que caen en parábola | 130 |
| Vigía | Orbe volador con un ojo y aletas giratorias | Descargas de energía | 55 |
- Percepción: los enemigos te ven dentro de su cono de visión si nada se interpone, y oyen los disparos (el ruido viaja por los pasillos, no a través de las paredes).
- Tras descubrirte: te persiguen por la malla de navegación: suben escaleras, rodean columnas, abren puertas normales y siguen tu última posición conocida.
- Si uno hiere a otro, se pelean entre ellos hasta que uno muere.
- Al recibir daño a veces se encogen de dolor, lo que interrumpe su ataque.
| Objeto | Efecto |
|---|---|
| Vial de suero | +5 de salud (permite pasar de 100, hasta 200) |
| Botiquín / Botiquín de campaña | +25 / +50 de salud (hasta 100) |
| Placa de blindaje | +5 de blindaje (hasta 200) |
| Blindaje de forja | Blindaje al 100 |
| Caja de munición, cartuchos, cargas explosivas | Munición para cada tipo de arma |
| Escopeta, remachadora, lanzacargas | El arma, con algo de munición (si es nueva, se saca al momento) |
| Llaves roja, azul y amarilla | Abren las puertas de su color |
Los objetos que no necesitas (salud llena, munición al máximo) se quedan en el suelo. El blindaje absorbe un tercio del daño.
- Puertas: se abren con E y se cierran solas al cabo de unos segundos. No se cierran si hay alguien debajo.
- Ascensores: súbete y pulsa E (o pulsa E desde abajo para que baje). Esperan unos segundos y vuelven.
- Paredes secretas: parecen paredes normales, pero se abren con E.
- Suelos peligrosos: la lava y el ácido hacen daño mientras los pisas.
- Escaleras, rampas, puentes y balcones: los niveles tienen varias alturas; se puede pasar por encima y por debajo de pasarelas.
- Barra inferior: munición y reserva, salud, icono del jugador, blindaje, armas que llevas (1-5, la actual resaltada) y llaves.
- Icono del jugador: un casco cuyo visor cambia de color con la salud y se agrieta. Mira hacia el lado del que te atacan.
- Avisos visuales: un arco rojo alrededor del punto de mira indica de dónde llega el daño. La pantalla destella en rojo al recibir daño y del color del objeto al recogerlo.
- Panel de rendimiento (F3): FPS, tiempo de frame, draw calls y triángulos.
- Menú principal: nueva partida, elegir nivel, ver el tráiler, opciones y controles. Con
?nivel=2(o3) en la dirección se salta el menú y se entra directamente en ese nivel. - Pausa (Esc): continuar, opciones, controles, reiniciar el nivel o salir al menú.
- Opciones (se guardan en el navegador y se aplican al momento, también en plena partida):
| Grupo | Opción |
|---|---|
| Controles | Sensibilidad del ratón, invertir eje vertical |
| Cámara | Campo de visión (60–100°), balanceo al andar, retroceso al disparar |
| Sonido | Volumen general |
| Gráficos | Sombras, post-procesado (brillo y viñeta), filtro de píxeles, resolución, panel de rendimiento |
- Resolución: en pantallas de alta densidad (Retina) empieza al 75 %, que apenas se nota y va mucho más fluido. Si tu equipo va sobrado, súbela al 100 %.
Con Tab se abre un plano de las zonas por las que has pasado, con el norte arriba y una flecha que marca tu posición. Muestra paredes, escalones, lava y ácido, puertas (con el color de su llave), ascensores, las llaves que quedan por coger y la salida, pero solo en zonas ya descubiertas. Las paredes secretas no se delatan.
Todos los efectos se sintetizan por código al arrancar (no hay archivos de audio). Los sonidos del mundo son posicionales: se oyen más fuerte cerca y a un lado u otro según de dónde vengan. Cada enemigo tiene su voz de alerta, de dolor y de muerte, y hay un zumbido ambiental de fondo. El navegador no deja sonar nada hasta el primer clic.
| Acción | Tecla |
|---|---|
| Moverse | W A S D / flechas |
| Mirar | Ratón |
| Saltar | Espacio |
| Agacharse | C (o Ctrl) |
| Correr | Shift |
| Disparar | Clic izquierdo |
| Disparo alternativo | Clic derecho |
| Cambiar de arma | 1-5 / rueda |
| Recargar | R |
| Usar (puertas, ascensores, interruptores) | E |
| Automapa | Tab |
| Panel de rendimiento | F3 |
| Pausa | Esc |
¿Por qué C para agacharse? En los navegadores, Ctrl+W cierra la pestaña y una página web no puede impedirlo. Ctrl también funciona, pero C es más seguro.
La forma más sencilla de jugar es la demo en el navegador. Para jugar en local, sin conexión, usa el lanzador.
Descarga el proyecto (botón Code → Download ZIP en GitHub, o git clone) y haz doble clic en el lanzador de tu sistema:
| Sistema | Lanzador | Cómo abrirlo |
|---|---|---|
| macOS | jugar.command |
Doble clic en Finder. La primera vez, clic derecho → Abrir (ver Solución de problemas). |
| Windows | jugar.bat |
Doble clic en el Explorador. |
| Linux | jugar.sh |
Doble clic ("Ejecutar" o "Ejecutar en terminal") o ./jugar.sh desde una terminal. |
El lanzador hace todo lo necesario, y solo lo que haga falta:
- Node.js: si tu equipo tiene Node.js 22.12 o superior, lo usa. Si no lo tiene (o es más antiguo), descarga una copia de Node.js LTS desde nodejs.org en la carpeta
.forja_node/del juego, tras comprobar su suma SHA-256. No necesita permisos de administrador ni toca nada fuera de la carpeta del juego, y no interfiere con otras versiones de Node.js. Para quitarla, basta con borrar la carpeta. - Dependencias: ejecuta
npm installsolo si faltan o sipackage-lock.jsonha cambiado. - Compilación: hace el build de producción solo si no existe o si el código ha cambiado.
- Juego: lo sirve en local en un puerto libre (desde el 4173) y abre tu navegador. En la terminal verás la URL. Para cerrar el juego, pulsa Ctrl+C o cierra la ventana.
La primera vez necesita conexión a internet (para descargar las dependencias y, si hace falta, Node.js) y tarda uno o dos minutos. Las siguientes arranca en segundos y sin conexión.
Requisitos: macOS 11 o posterior, Windows 10 u 11 (64 bits), o Linux de 64 bits (x64 o ARM) con glibc 2.28 o posterior. Un navegador moderno con WebGL 2 (Chrome, Edge, Firefox o Safari).
Requisitos: Node.js 22.12 o superior (se recomienda la versión LTS).
npm install
npm run dev # servidor de desarrollo en http://localhost:5173
npm run build # build de producción en dist/
npm run preview # sirve dist/ en local- macOS dice que no puede abrir
jugar.commandporque es de un desarrollador no identificado. La primera vez, haz clic derecho sobre el archivo → Abrir → Abrir. A partir de ahí, el doble clic funciona. Si no aparece la opción, ve a Ajustes del Sistema → Privacidad y seguridad → Abrir igualmente. - Windows muestra "Windows protegió su PC". Pulsa Más información → Ejecutar de todas formas.
- Linux no ejecuta
jugar.shcon doble clic. Si descargaste el ZIP, puede haber perdido el permiso de ejecución: abre una terminal en la carpeta y ejecutachmod +x jugar.sh && ./jugar.sh. Algunos gestores de archivos abren los scripts en el editor; en sus preferencias se puede elegir "Ejecutar". - "No se ha podido preparar Node.js automáticamente". El lanzador no ha podido descargar Node.js (sin conexión, un proxy o un sistema no compatible). Comprueba la conexión y vuelve a abrirlo, o instala la versión LTS desde nodejs.org.
- Puerto ocupado. El lanzador busca solo un puerto libre a partir del 4173. Con
npm run dev, si el 5173 está ocupado, Vite usa el siguiente y lo indica en la terminal. - El ratón no queda capturado. Haz clic dentro del juego. Si acabas de pulsar Esc, espera un segundo antes de volver a hacer clic: el navegador impone esa pausa.
- Va a tirones. En Opciones → Gráficos baja la resolución o desactiva las sombras y el post-procesado. Con F3 ves los FPS.
- No suena nada. El navegador no permite sonido hasta que haces clic en la página. Comprueba también el volumen en Opciones.
flowchart TD
UI["UI (React)<br/>menús, opciones, pantallas"] -->|crea, pausa y configura| GAME["Game<br/>bucle de juego"]
CAMPAIGN["Niveles<br/>JSON → parser → geometría"] --> GAME
GAME --> INPUT["Input<br/>teclado, ratón, pointer lock"]
GAME --> PLAYER["Jugador<br/>controlador de personaje"]
GAME --> WORLD["Mundo<br/>puertas, ascensores, objetos, llaves"]
GAME --> WEAPONS["Armas<br/>disparos, proyectiles, explosiones"]
GAME --> ENEMIES["Entidades<br/>enemigos, modelos, animación"]
ENEMIES --> AI["IA<br/>estados, percepción, navmesh"]
INPUT --> PLAYER
INPUT --> WEAPONS
PLAYER --> PHYSICS["Física (Rapier)<br/>colisiones, raycasts"]
WORLD --> PHYSICS
WEAPONS --> PHYSICS
ENEMIES --> PHYSICS
WORLD -->|eventos| BUS(("EventBus"))
WEAPONS -->|eventos| BUS
ENEMIES -->|eventos| BUS
BUS --> AUDIO["Audio<br/>síntesis, sonido posicional"]
BUS --> HUD["HUD y automapa"]
GAME --> RENDER["Render (Three.js)<br/>texturas, luces, post-procesado"]
CAMPAIGN --> RENDER
CAMPAIGN --> PHYSICS
CAMPAIGN --> AI
- Motor (
src/engine/) y juego (src/game/) están separados: el motor no sabe nada de armas ni enemigos. - La lógica del juego es pura siempre que se puede (movimiento, armas, IA, puertas, objetos, estadísticas, síntesis de sonido, generación de geometría): funciones sin Three.js ni DOM, con tests en Node.
- Los sistemas se comunican por eventos (
EventBus): un disparo emitenoise(lo oyen los enemigos) ysound(lo reproduce el audio); una recogida emitepickup(lo cuentan las estadísticas y lo muestra el HUD). - React solo pinta los menús. El HUD del juego es DOM directo, que solo se reescribe cuando cambia algo.
El bucle (engine/core/game_loop.ts) usa requestAnimationFrame con un paso fijo de 60 Hz:
- Acumulador: el tiempo real de cada frame se suma a un acumulador (máximo 0,25 s, para no entrar en espiral si la pestaña se congela) y se ejecutan tantos pasos de 1/60 s como quepan, hasta 8 por frame.
- Paso fijo (
fixedUpdate), siempre en este orden:- Mundo: puertas y ascensores (que arrastran al jugador si está encima), objetos, secretos y daño del suelo.
- Jugador: movimiento con el controlador de personaje.
- Uso (E) y armas.
- Enemigos: percepción, IA, caminos y ataques.
physics.step()de Rapier: proyectiles y colisiones.
- Render (cada frame): la vista del ratón se aplica aquí, sin esperar al siguiente paso, para que responda al instante. La cámara y los enemigos se interpolan entre los dos últimos pasos, así el movimiento es suave a cualquier tasa de refresco. Después se actualizan luces, partículas, HUD y automapa y se dibuja con el post-procesado.
La simulación es igual en un monitor de 60 Hz que en uno de 144 Hz, y la física no depende de los FPS.
ui/campaign.tselige el nivel (el siguiente de la campaña o el de?nivel=N) de la listasrc/levels/index.ts.Game.createinicializa en paralelo los módulos WebAssembly de Rapier (física) y Recast (navegación).parseLevelvalida el JSON y lo normaliza (sentido de los polígonos, valores por defecto). Si hay errores, los muestra todos juntos.buildLevelGeometrygenera la geometría de sectores y losas (ver abajo), ybuildLevelcrea con ella las mallas de Three.js, la malla de colisión de Rapier y los cuerpos cinemáticos de puertas y ascensores. Las texturas se generan en canvas al vuelo (ProceduralMaterials).- Con la geometría de colisión se construye la navmesh de los enemigos.
- Se colocan las cosas del nivel: jugador (con el inventario del nivel anterior), lámparas, enemigos, objetos y salida. Se crean el HUD, el automapa y el audio.
- El juego queda en estado
readyy espera el clic para capturar el ratón.
Un nivel es una lista de vértices 2D y de sectores: polígonos que los referencian, cada uno con altura de suelo y de techo. A partir de ahí, engine/level/sector_geometry.ts (código puro, con tests) genera todo:
- Contigüidad: dos sectores son vecinos si comparten una arista (los mismos dos índices de vértice). Por eso se rechazan las uniones en T.
- Paredes: una arista sin vecino es una pared completa. Entre vecinos se crea un escalón (del suelo bajo al alto) y un dintel (del techo alto al bajo). Entre dos zonas a cielo abierto no hay dintel.
- Suelos y techos: se triangulan con una triangulación de Delaunay restringida sobre una rejilla de 2 m. Evita triángulos largos y finos, que algunas GPU no dibujan bien. Admiten huecos (columnas, fosos) y rampas.
- Losas: prismas sólidos flotantes (puentes, balcones) con cara superior, inferior y laterales.
- Puertas y ascensores: prismas convexos aparte, con un cuerpo cinemático que sube y baja.
- Texturas y luz: coordenadas UV en el espacio del mundo (una textura cada 2 m, sin costuras entre sectores) y la luz de cada sector en el color de los vértices.
- Colisiones: la geometría estática se une en una sola malla triangular de Rapier.
├── jugar.command, jugar.bat, jugar.sh Lanzadores de doble clic (macOS, Windows, Linux)
├── scripts/
│ ├── launcher.mjs Lógica común de los lanzadores (instalar, compilar, servir)
│ ├── check_node.cjs Comprueba la versión de Node.js contra package.json
│ ├── node_portable.sh / .ps1 Descarga Node.js si falta (macOS y Linux / Windows)
│ ├── build_levels.ts Genera los JSON de la campaña (npm run levels)
│ ├── build_plans.ts Genera los planos SVG de docs/planos/ (npm run planos)
│ └── levels/ Fuentes de los niveles y kit de autoría (level_kit.ts)
├── src/
│ ├── main.tsx Punto de entrada
│ ├── engine/ Motor, independiente del juego
│ │ ├── core/ Bucle, paso fijo, eventos, aleatoriedad, utilidades
│ │ ├── render/ Renderer, post-procesado, luces, cielo, partículas, marcas
│ │ ├── textures/ Texturas procedurales, ruido y normal maps
│ │ ├── physics/ Mundo de Rapier, controlador de personaje, grupos de colisión
│ │ ├── level/ Parser, generador de geometría, triangulación y construcción
│ │ ├── ai/ Navmesh y búsqueda de caminos
│ │ ├── audio/ Síntesis de sonidos y audio posicional
│ │ └── input/ Teclado, ratón, pointer lock y asignación de teclas
│ ├── game/ El juego
│ │ ├── game.ts Crea y une todos los sistemas
│ │ ├── player/ Movimiento, cámara y salud del jugador
│ │ ├── weapons/ Armas, proyectiles y modelos en primera persona
│ │ ├── enemies/ Enemigos: datos, modelos, IA y percepción
│ │ ├── world/ Puertas, ascensores, objetos, llaves y salida
│ │ └── rules/ Daño, salud y estadísticas del nivel
│ ├── hud/ HUD, punto de mira, panel F3 y automapa
│ ├── ui/ Menús y pantallas (React), ajustes y campaña
│ ├── recording/ Modo de grabación de clips para el vídeo (solo desarrollo)
│ ├── levels/ Niveles en JSON y tests de jugabilidad
│ └── types/ Tipos de librerías que no los traen
├── public/ Archivos estáticos
├── docs/ Capturas del juego y planos de los niveles (SVG)
├── video/ Vídeo de presentación: guion, clips, narración y Remotion
├── INSTRUCCIONES.md Guía del jugador
└── .github/workflows/ CI (lint, formato, tipos, tests) y despliegue en GitHub Pages
Cada nivel es un archivo JSON en src/levels/. Está formado por sectores: polígonos 2D con altura de suelo y de techo. El juego extruye las paredes entre sectores, crea los escalones cuando las alturas son distintas, triangula suelos y techos y genera las colisiones.
Los tres niveles de la campaña no se escriben a mano: se generan con un pequeño kit de autoría en scripts/levels/. Permite describir salas, escaleras y puertas con coordenadas, y reparte solo los vértices compartidos y parte las uniones en T. Tras editar un nivel, ejecuta:
npm run levelsEsto regenera src/levels/level_0N.json. Los tests comprueban con un recorrido del grafo de sectores que cada nivel se puede completar:
- Las llaves se consiguen en orden.
- La salida exige las tres llaves.
- Los secretos, los objetos y los enemigos están en zonas alcanzables.
- En metros.
xcrece hacia el este yzhacia el sur; el norte es-z.yes la altura. - Los ángulos van en grados:
0mira al norte y90al oeste (sentido antihorario visto desde arriba).
Dos salas unidas por un escalón de 50 cm:
{
"version": 1,
"name": "Mi nivel",
"vertices": [
[0, 0],
[8, 0],
[8, 8],
[0, 8],
[14, 0],
[14, 8]
],
"sectors": [
{
"vertices": [0, 1, 2, 3],
"floor": { "height": 0, "texture": "stone_floor" },
"ceiling": { "height": 4, "texture": "metal_ceiling" },
"walls": "brick",
"light": 0.8
},
{
"vertices": [1, 4, 5, 2],
"floor": { "height": 0.5, "texture": "metal_floor" },
"ceiling": { "height": 4, "texture": "metal_ceiling" },
"walls": "tech_wall"
}
],
"things": [{ "type": "player_start", "x": 4, "z": 6, "angle": 0 }]
}Los dos sectores comparten la arista entre los vértices 1 y 2. Las aristas compartidas deben usar los mismos índices de vértice: así es como el generador sabe que dos sectores están conectados.
| Campo | Tipo | Descripción |
|---|---|---|
version |
número | Siempre 1. |
name |
texto | Nombre del nivel. |
vertices |
lista de [x, z] |
Todos los vértices del nivel; se referencian por su índice. |
sectors |
lista | Los sectores (ver abajo). |
slabs |
lista (opcional) | Losas: plataformas sólidas flotantes (ver abajo). |
environment |
objeto (opcional) | Niebla, cielo, luz ambiental y sol (ver abajo). |
things |
lista | Jugador, lámparas, enemigos, objetos y salida. |
| Campo | Tipo | Descripción |
|---|---|---|
vertices |
lista de índices | Contorno del sector, en cualquier sentido (se normaliza). Mínimo 3, sin cortes. |
holes |
lista de listas (opcional) | Huecos dentro del sector: columnas macizas, o el contorno de otro sector interior (por ejemplo, un foso). |
floor |
{ height, texture, slope? } |
Suelo. slope lo convierte en rampa: { "from": [x, z], "to": [x, z], "toHeight": h }. El suelo mide height en from y toHeight en to. |
ceiling |
{ height, texture, slope? } o { height, sky: true } |
Techo. Con sky: true no hay techo y se ve el cielo; height marca hasta dónde suben las paredes exteriores. |
walls |
texto o { middle, upper?, lower? } |
Textura de las paredes. lower se usa en los escalones que suben hacia este sector y upper en los dinteles que cuelgan de su techo. |
light |
número 0–1 (opcional, 0,8) | Nivel de luz del sector. |
secret |
booleano (opcional) | Zona secreta: cuenta para el % de secretos al entrar. |
special |
objeto (opcional) | Comportamiento especial (ver abajo). |
id |
texto (opcional) | Nombre para identificar el sector. |
type |
Campos | Comportamiento |
|---|---|---|
damage |
damagePerSecond |
Suelo que hace daño (lava, ácido). |
door |
key? (red, blue, yellow), speed?, waitTime?, texture?, hidden? |
Puerta que sube. El ceiling.height del sector es la altura abierta; empieza cerrada. waitTime son los segundos que tarda en cerrarse sola (0 = no se cierra). hidden: true la disfraza de pared (pared secreta). |
lift |
lowHeight, speed?, waitTime?, texture? |
Ascensor: el suelo está arriba (floor.height) y baja hasta lowHeight. |
Las puertas y los ascensores deben ser polígonos convexos, sin huecos y con suelo y techo planos.
Una losa es un bloque sólido flotante: permite pasar por encima y por debajo, como en un puente o un balcón.
| Campo | Tipo | Descripción |
|---|---|---|
vertices |
lista de índices | Contorno de la losa. |
bottom |
número | Altura de la cara inferior. |
top |
número | Altura de la cara superior. |
texture |
texto o { top, bottom, side } |
Texturas. |
light |
número (opcional) | Si no se indica, se usa la luz del sector en el que está. |
| Campo | Tipo | Descripción |
|---|---|---|
type |
texto | player_start (obligatorio y único), lamp (lámpara), enemy (enemigo), pickup (objeto), exit (interruptor de salida) o model (modelo glTF opcional). |
x, z |
número | Posición. Debe estar dentro de un sector. |
y |
número (opcional) | Altura; por defecto, la del suelo del sector. |
angle |
grados (opcional) | Orientación. |
El resto de campos se guardan como propiedades. Por ejemplo, un model usa url (ruta dentro de public/, por ejemplo models/estatua.glb) y scale.
El player_start admite el inventario inicial: "weapons": ["pistol", "shotgun", "riveter", "launcher"] (el martillo va siempre) y "ammo": { "bullets": 50, "shells": 10, "charges": 4 }. Si no se indica, se empieza con martillo, pistola y 50 balas.
item es uno de: health_small, health, health_large, armor_small, armor, ammo_bullets, ammo_shells, ammo_charges, weapon_shotgun, weapon_riveter, weapon_launcher, key_red, key_blue o key_yellow. Con y se puede colocar encima de una losa.
Ejemplo: { "type": "pickup", "item": "key_red", "x": 7.25, "z": -9.5, "y": 2 }
Interruptor que termina el nivel al pulsarlo con E. angle indica hacia dónde mira su pantalla. Todo nivel necesita al menos uno.
| Campo | Tipo | Descripción |
|---|---|---|
kind |
texto | sentinel (centinela), crawler (rastrero), spitter (escupidor) o watcher (vigía, volador). |
angle |
grados | Hacia dónde mira al empezar (solo ve dentro de su cono de visión). |
patrol |
lista de [x, z] (opcional) |
Puntos de patrulla: el enemigo va y viene entre su posición inicial y estos puntos. Sin patrulla, espera quieto. |
Ejemplo: { "type": "enemy", "kind": "sentinel", "x": 3, "z": 1.5, "angle": 180, "patrol": [[13, 1.5]] }
Por defecto, una lámpara cuelga del techo de su sector. En zonas con cielo se coloca sobre una farola de 3 m. Con y se fija su altura exacta.
| Campo | Tipo | Por defecto | Descripción |
|---|---|---|---|
color |
"#rrggbb" |
#ffd8a8 |
Color de la luz y de la pantalla. |
intensity |
número | 1 |
Intensidad relativa. |
radius |
número | 10 |
Alcance en metros. |
flicker |
texto | steady |
steady (fija), flicker (tiembla), pulse (late), strobe (intermitente) o broken (averiada, con apagones). |
shadows |
booleano | false |
Proyecta sombras. Resérvalo para las luces principales: solo una lámpara a la vez puede tener sombra real. |
Solo las 6 lámparas más cercanas a la cámara iluminan de verdad. Las demás se siguen viendo encendidas por su pantalla brillante.
Todos los campos son opcionales. Los colores van en formato "#rrggbb".
| Campo | Por defecto | Descripción |
|---|---|---|
fog.color |
#1b1512 |
Color de la niebla y del fondo. |
fog.near, fog.far |
14, 75 |
Distancia (m) a la que empieza la niebla y a la que ya lo tapa todo. |
sky.top, sky.horizon, sky.bottom |
tonos de brasa | Degradado del cielo procedural. |
sky.clouds |
0.55 |
Nubosidad, de 0 (despejado) a 1 (cubierto). |
ambient.color, ambient.intensity |
#c8b8ac, 0.9 |
Luz ambiental base. Se multiplica por la light de cada sector. |
sun |
sol cálido | { "color", "intensity", "direction": [x, y, z] } o null para no tener sol. Solo existe si el nivel tiene sectores con cielo. Proyecta sombras, así que no entra en interiores. |
Todas se generan por código al arrancar. Un nombre desconocido se muestra con un damero magenta (missing) y un aviso en la consola.
brick, stone_floor, stone_step, metal_floor, metal_ceiling, metal_grate, tech_wall (con tiras luminosas), tech_panel (circuitos), door_metal, door_frame, lift_top, rock, dirt, lava y acid (emisivas y animadas).
Cada textura mide 64×64 píxeles y cubre 2×2 metros. La de las puertas visibles se ajusta al tamaño de la hoja.
Al cargar, el nivel se valida y los errores se muestran todos juntos, con la ruta del campo afectado. Se comprueba, entre otras cosas:
- Que los índices de vértice existen.
- Que los polígonos no se cortan.
- Que no hay uniones en T: un vértice apoyado en mitad de la arista de otro sector. Hay que añadir ese vértice también al otro sector.
- Que ningún sector se solapa con otro.
- Que el techo queda por encima del suelo.
- Que hay exactamente un
player_start.
- Guarda el JSON en
src/levels/(por ejemplo,src/levels/mi_nivel.json). - Impórtalo en
src/levels/index.tsy añádelo a la listaLEVELS, en la posición en la que quieras jugarlo. - Pruébalo con
npm run devabriendohttp://localhost:5173/?nivel=N, dondeNes su posición en la lista. También aparecerá en "Elegir nivel", y los tests comprobarán que se puede completar.
| Script | Qué hace |
|---|---|
npm run dev |
Servidor de desarrollo con recarga en caliente |
npm run build |
Typecheck + build de producción en dist/ |
npm run preview |
Sirve el build de producción |
npm test |
Ejecuta los tests (Vitest) |
npm run test:watch |
Tests en modo observación |
npm run lint |
ESLint |
npm run format |
Formatea el código con Prettier |
npm run typecheck |
Comprobación de tipos de TypeScript |
npm run levels |
Regenera los niveles JSON desde scripts/levels/ |
npm run planos |
Regenera los planos SVG de los niveles en docs/planos/ |
npm run video |
Genera el vídeo de presentación (ver video/README.md) |
Los tests (Vitest) están junto al código, como *.test.ts, y se ejecutan en Node. Cubren la lógica pura: movimiento, armas, IA, percepción, daño, puertas y ascensores, objetos, estadísticas, síntesis de sonido, texturas, parser y generador de geometría, además de la jugabilidad de cada nivel (llaves, salida y secretos alcanzables), los planos de los niveles y, para el vídeo de presentación, los recorridos de cámara, la temporización de acciones del modo de grabación y el lector del guion.
npm test # todos
npx vitest run src/game/weapons/weapon_logic.test.ts # un archivo
npx vitest run -t "puertas" # por nombre- TypeScript estricto, ESLint y Prettier. Nombres de archivo en
snake_case. - Los commits siguen Conventional Commits en español (
feat:,fix:,refactor:,test:,docs:,chore:). - La CI ejecuta lint, formato, typecheck y tests en cada push y pull request. Cada push a
mainse despliega en GitHub Pages. - En desarrollo,
window.__forjada acceso al juego desde la consola. Por ejemplo,__forja.debugState()muestra la posición y la velocidad del jugador, y__forja.debugSetPlaying(true)entra en modo juego sin capturar el ratón (útil para pruebas automatizadas).
-
Haz un fork y crea una rama para tu cambio.
-
Antes de abrir el pull request, comprueba que todo pasa:
npm run lint && npm run format:check && npm run typecheck && npm test
-
Si el cambio se ve en el juego, pruébalo con
npm run dev. -
Abre el pull request explicando qué cambia y por qué.
Todo el contenido debe ser original: no se aceptan modelos, texturas, sonidos, nombres ni niveles de otros juegos. Las texturas y los sonidos se generan por código.
El repositorio incluye un vídeo de presentación de unos 2 minutos y medio: cuenta la historia del juego («Lo que arde abajo», en tono de terror industrial) y enseña, con imágenes reales, los controles, las armas, los enemigos, los tres niveles con sus planos y los trucos. Se genera entero por código:
- El propio juego graba sus clips con un modo de grabación (
?grabar=<clip>, solo en desarrollo): recorridos de cámara y escenas preparadas, fotograma a fotograma y siempre igual. - La narración sale del guion (
video/guion.md) con una voz de macOS, provisional hasta sustituirla por grabaciones reales. - La música se sintetiza por código.
- Remotion lo monta todo, con textos animados y los planos de los niveles, y renderiza el MP4 (1920×1080, 60 fps).
npm install --prefix video # dependencias del vídeo (una vez)
npm run video # clips, narración, música y render → video/out/forja_abisal_presentacion.mp4
npm run video:studio # previsualizar y ajustar en Remotion StudioEl vídeo se puede ver desde el propio juego con el botón «Ver tráiler» del menú principal, que reproduce una versión ligera para web (public/trailer/, unos 29 MB). Los clips, los audios y el MP4 original no se suben al repositorio: se regeneran con los comandos. Cómo funciona, cómo editar el guion, cómo sustituir la voz y cómo añadir clips: video/README.md.
El juego y su vídeo de presentación se desarrollaron con Claude Code (CLI, versión 2.1.283), a partir de dos especificaciones escritas por el autor (especificacion_proyecto.md y especificacion_video.md). El autor tomó las decisiones de diseño, revisó y probó el resultado al final de cada fase.
| Recurso | Juego (hasta la versión 1.0.0) | Vídeo de presentación |
|---|---|---|
| Modelo principal | Claude Opus 5.5 (claude-opus-5-5), 644 llamadas |
Claude Opus 5.5 (claude-opus-5-5), 203 llamadas |
| Esfuerzo de razonamiento | Alto (high) |
Alto (high) |
| Modelo auxiliar | Claude Sonnet 5: analizó los niveles para la guía del jugador (6 llamadas) | — |
| Tiempo de trabajo activo | ~3 h 35 min | ~1 h 57 min |
| Tokens totales procesados | ~297 millones | ~145,5 millones |
| Tokens generados | ~937.000 | ~327.000 |
| Resultado | 10 fases, versión 1.0.0, capturas y guía del jugador, 254 tests | 5 fases: guion, 24 clips, montaje y vídeo de 2 min 30 s; 277 tests en total |
En total: unas 5 h 32 min de trabajo activo y unos 443 millones de tokens procesados, de los que unos 1,26 millones son generados (código, documentación y respuestas).
Casi todos los tokens procesados son contexto (el código, los resultados de las herramientas y el historial) que el modelo vuelve a leer de caché en cada paso. Los datos corresponden al 26 de septiembre de 2026. El tiempo activo es una estimación que no incluye las pausas para probar y revisar entre fases.
Todo el contenido del juego y de su vídeo de presentación es original y se genera por código: modelos, texturas, sonidos, niveles, nombres, clips del vídeo y música. No se usan assets, nombres ni niveles de otros juegos.
Hecho con Three.js, Rapier, recast-navigation-js, postprocessing, React y Vite, cada uno con su propia licencia.
El vídeo de presentación se monta con Remotion, que tiene licencia propia: es gratuito para particulares y empresas de hasta 3 personas (condiciones). Usa las tipografías Big Shoulders Display, Barlow Condensed e IBM Plex Mono, de Google Fonts, con licencia OFL.
Publicado bajo licencia MIT.






