diff --git a/.gitignore b/.gitignore
index ec24193..49b7cbc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,4 @@ vendor/model-viewer.min.js
# Capturas de documentacion: solo los PNG finales se versionan.
.playwright-cli/
output/playwright/
+vendor/three/
diff --git a/assets/npcs/alchemist.png b/assets/npcs/alchemist.png
new file mode 100644
index 0000000..fc3881b
Binary files /dev/null and b/assets/npcs/alchemist.png differ
diff --git a/assets/npcs/armorer.png b/assets/npcs/armorer.png
new file mode 100644
index 0000000..05238e3
Binary files /dev/null and b/assets/npcs/armorer.png differ
diff --git a/assets/npcs/guard.png b/assets/npcs/guard.png
new file mode 100644
index 0000000..89d9aac
Binary files /dev/null and b/assets/npcs/guard.png differ
diff --git a/assets/npcs/king.png b/assets/npcs/king.png
new file mode 100644
index 0000000..0dbc9f7
Binary files /dev/null and b/assets/npcs/king.png differ
diff --git a/assets/npcs/priest.png b/assets/npcs/priest.png
new file mode 100644
index 0000000..129e86b
Binary files /dev/null and b/assets/npcs/priest.png differ
diff --git a/assets/npcs/resident.png b/assets/npcs/resident.png
new file mode 100644
index 0000000..0f52f2f
Binary files /dev/null and b/assets/npcs/resident.png differ
diff --git a/assets/npcs/smith.png b/assets/npcs/smith.png
new file mode 100644
index 0000000..c91c236
Binary files /dev/null and b/assets/npcs/smith.png differ
diff --git a/assets/npcs/tavern.png b/assets/npcs/tavern.png
new file mode 100644
index 0000000..7e33de4
Binary files /dev/null and b/assets/npcs/tavern.png differ
diff --git a/assets/npcs/villager.png b/assets/npcs/villager.png
new file mode 100644
index 0000000..abb9d7b
Binary files /dev/null and b/assets/npcs/villager.png differ
diff --git a/bin.mjs b/bin.mjs
index 1823e0f..da8a5fc 100644
--- a/bin.mjs
+++ b/bin.mjs
@@ -17,7 +17,8 @@ const cmd = command(
flag('--storage
', 'custom storage directory'),
flag('--no-updates', 'disable OTA updates for this run'),
flag('--name ', 'name other players see in the town'),
- flag('--solo', 'play without presence, nobody sees you and you see nobody')
+ flag('--solo', 'play without presence, nobody sees you and you see nobody'),
+ flag('--teclas', 'accept keys from the 3D viewer page (scripts/tripo-viewer.js /heroe)')
)
cmd.parse(Bare.argv.slice(isDev ? 2 : 1))
@@ -114,9 +115,55 @@ app.on('updated', () => news('el mundo cambio. reinicia para verlo.'))
app.on('update-applied', () => news('el mundo cambio. reinicia para verlo.'))
app.on('error', () => {})
+// Teclas desde la pagina 3D del visor. Cada una llega como un archivo en
+// /keys y entra al juego por el mismo camino que la terminal, asi que las
+// reglas no cambian: el navegador solo aprieta teclas. Apagado salvo --teclas.
+let stopKeys = () => {}
+if (cmd.flags.teclas) {
+ const { default: fs } = await import('bare-fs')
+ const inbox = path.join(dir, 'keys')
+ fs.mkdirSync(inbox, { recursive: true })
+ // Las teclas que quedaron de otra sesion no se juegan.
+ for (const name of fs.readdirSync(inbox)) fs.unlinkSync(path.join(inbox, name))
+ // Latido: el visor da la terminal por abierta mientras "alive" sea reciente;
+ // si no, la pagina pasa al modo navegador y se juega sola.
+ const alive = path.join(inbox, 'alive')
+ let beat = 0
+ const timer = setInterval(() => {
+ if (beat++ % 25 === 0) {
+ try {
+ fs.writeFileSync(alive, String(Date.now()))
+ } catch {
+ // La proxima vuelta lo intenta de nuevo.
+ }
+ }
+ let names
+ try {
+ names = fs
+ .readdirSync(inbox)
+ .filter((name) => name.endsWith('.key'))
+ .sort()
+ } catch {
+ return
+ }
+ for (const name of names) {
+ const file = path.join(inbox, name)
+ try {
+ const bytes = fs.readFileSync(file)
+ fs.unlinkSync(file)
+ if (program.input) program.input.emit('data', bytes)
+ } catch {
+ // Otro lector la tomo o todavia se esta escribiendo: sigue en la proxima.
+ }
+ }
+ }, 40)
+ stopKeys = () => clearInterval(timer)
+}
+
try {
await program.run()
} finally {
+ stopKeys()
runa.saveCurrent()
await app.close().catch(() => {})
}
diff --git a/docs/demo/runa-tripo-focused-en.mp4 b/docs/demo/runa-tripo-focused-en.mp4
new file mode 100644
index 0000000..c52c985
Binary files /dev/null and b/docs/demo/runa-tripo-focused-en.mp4 differ
diff --git a/docs/heroe-3d.md b/docs/heroe-3d.md
new file mode 100644
index 0000000..7399af3
--- /dev/null
+++ b/docs/heroe-3d.md
@@ -0,0 +1,257 @@
+# Heroe 3D
+
+La pagina `/heroe` del visor muestra al stickman de RUNA en 3D, vestido con el
+equipo de la ultima partida guardada. Equipas algo en el inventario de la
+terminal y aparece en el heroe 3D, con un aviso. En los mapas camina el heroe de
+Tripo, con esqueleto y animaciones de Higgsfield (ver "El heroe animado"), y
+lleva el yelmo de Tripo cuando lo equipas. Al entrar a las ruinas volcanicas
+aparece el Coloso de Tripo y cae ceniza.
+
+La misma pagina se publica como sitio estatico en GitHub Pages, en modo demo:
+ahi el heroe camina en el navegador, sin el juego. Ver "Sitio web" abajo.
+
+El stickman esta hecho con formas simples: no gasta creditos de Tripo y su mano
+sirve de enganche para cualquier arma. Las reglas del juego no cambian: no se
+toco `lib/`. `bin.mjs` suma una opcion, `--teclas`, para recibir teclas de la
+pagina; sin ella el juego queda igual que antes.
+
+## Jugar desde el navegador
+
+Hay dos formas, y la pagina elige sola segun si la terminal esta abierta.
+
+**Sin la terminal (modo navegador).** Alcanza con el visor:
+
+```powershell
+npm.cmd run tripo:viewer
+```
+
+Si el juego no esta corriendo, `/heroe` se juega sola desde la ultima partida
+guardada: el heroe arranca donde quedo, con su equipo, y camina con WASD o
+flechas con los mismos choques que el juego (paredes, agua, NPC, la lava y el
+cuerpo del Coloso). Los portones llevan al otro mapa 3D y se llega al lado del
+porton de regreso, como en la terminal: el K de la ciudad al castillo y el B de
+vuelta, el N a NOX y el R de vuelta. Los caminos a la pradera y a la cripta, y
+las puertas de las casas y tiendas, avisan que eso se juega en la terminal. E
+habla con el NPC de al lado (cada uno dice su frase del juego) e I abre la
+mochila de la partida para equipar en la vista. Nada de esto escribe la partida
+guardada.
+
+**Con la terminal.** El juego manda y la pagina lo sigue:
+
+```powershell
+npm.cmd start -- --teclas
+npm.cmd run tripo:viewer
+```
+
+Con `--teclas`, el juego crea `/../keys`, escribe ahi
+`alive` cada segundo (el latido que el visor reporta como `listening` en
+`/state.json`; si tiene mas de 3 s, la pagina vuelve al modo navegador) y revisa
+la carpeta cada 40 ms. La pagina manda WASD o flechas, E, I, Enter, Esc y cualquier letra; cada
+tecla llega como un archivo y entra al juego por el mismo camino que la
+terminal (`program.input`), asi que las reglas no cambian: el navegador solo
+aprieta teclas. Las teclas de una sesion anterior se descartan al arrancar.
+
+Solo la pagina del visor puede mandar teclas: el pedido lleva un header propio,
+que obliga a cualquier otra web a un preflight que el visor no contesta, y su
+Origin tiene que ser el del visor. Ctrl+C no esta en la tabla de teclas. Si el
+juego no se arranco con `--teclas`, la pagina lo avisa.
+
+Probado de punta a punta el 8 de octubre con el juego real en Windows: `n` y
+Enter desde el visor crearon una partida en la ciudad, y seis `d` la movieron
+seis celdas. Sin el header o desde otro Origin el visor responde 403.
+
+## Correrlo
+
+```powershell
+node scripts/tripo-viewer.js --fetch-lib
+npm.cmd run tripo:viewer
+```
+
+`--fetch-lib` se corre una vez, con wifi. Baja model-viewer 4.3.1 y three.js
+0.186.1 (7 archivos) a `vendor/`, fuera de git, y rechaza cualquiera cuyo sha256
+no coincida. Despues el visor imprime dos URL: `/` con los GLB y `/heroe` con el
+stickman. AbrĂ `/heroe` al lado de la terminal del juego.
+
+## De donde sale el estado
+
+El juego reescribe la ranura en cada tecla (`saveCurrent` en `lib/game.js`). El
+visor lee la ranura mas reciente de `%APPDATA%\runa\saves\slot-N.json`, la misma
+carpeta que usa `bin.mjs`. En Linux es `~/.local/share/runa/saves` y en macOS
+`~/Library/Application Support/runa/saves` (sin probar). Si el juego corre con
+`--storage `, arrancá el visor con `RUNA_STORAGE=`.
+
+`/state.json` devuelve el nombre, el nivel, el lugar, la escena, la celda del
+mapa y el equipo por hueco. El visor lee la ranura cuando cambia, tras 40 ms que
+juntan las rafagas: al caminar el juego guarda unas 15 veces por segundo. Leerla
+no traba el guardado del juego: medido en Windows, un escritor con bare-fs
+(`.tmp` y `renameSync` cada 66 ms, como `SaveStore`) contra lecturas de Node
+cada 1 ms no fallo ningun rename en 127 guardados.
+
+Con el guardado llegan el equipo y el mapa. Los golpes, la vida durante una
+pelea y el jefe en tiempo real no: eso necesita el puente `--live` que propone
+el memo de investigacion.
+
+## Que se ve
+
+| Hueco | En 3D |
+| ------------- | ------------------------------------------------------------- |
+| `izq` (armas) | daga, espada, lanza, martillo, arco largo o ballesta |
+| `der` | escudo con runa |
+| `pecho` | cuero, cota de malla o placas con hombreras |
+| `casco` | capucha de cuero, o el yelmo de hierro de Tripo (`yelmo.glb`) |
+| `botas` | botas de cuero |
+
+| Escena | Fondo |
+| ----------------------------- | --------------------------- |
+| Ruinas volcanicas (`boss`) | Coloso de Tripo y ceniza |
+| Ciudades e interiores (`map`) | Paisaje del reino de Tripo |
+| Pradera, campamentos, cripta | Solo el suelo, con su color |
+
+Un GLB llamado como el id del item en `assets/tripo/` (por ejemplo `spear.glb`
+o `shield.glb`) reemplaza a la forma simple sin tocar codigo. Las armas se paran
+sobre su eje mas largo y se toman a un tercio de la base. Los modelos de Tripo
+llegan mirando hacia +x y el heroe mira hacia +z: el yelmo se gira 90 grados.
+Para otro ajuste, la tabla `SKINS` de `scripts/tripo-heroe.html` recibe tamano,
+giro y altura por item.
+
+Lo que va en la cabeza tambien lo lleva el heroe de Tripo que camina. La pagina
+mide la tapa de su cabeza en la malla (lo que queda en el 8% mas alto del
+cuerpo; con esqueleto, sobre los vertices ya deformados) y la pieza la envuelve;
+en el heroe animado la pieza cuelga del hueso `Head`, asi sigue la animacion.
+`AVATARS[].fits` dice, por heroe, cuanto la envuelve (`grow`), cuanto sube su
+techo (`lift`) y cuanto se corre hacia la nuca (`back`). El yelmo usa 1,5, 0,24
+y 0,1 en el heroe animado y 1,55, 0,22 y 0,12 en el original, calibrados en
+Chrome desde la consola con `runaView.tune('iron_helmet', { grow, lift, back })`:
+asi la cara se ve por la abertura y no asoma la vincha.
+
+## El heroe animado
+
+`heroe-anim.glb` es el heroe de Tripo con esqueleto humanoide (24 huesos, de
+`Hips` a los dedos de los pies) y dos clips, `Idle` y `Walk`. Quieto respira con
+`Idle`; al caminar pasa a `Walk` con un fundido de 0,2 s, y vuelve a `Idle` un
+cuarto de segundo despues del ultimo paso, asi no parpadea entre celda y celda.
+Si el archivo no esta, camina `heroe.glb` como antes, con el balanceo.
+
+Como se hizo, el 9 de octubre, con la CLI de Higgsfield:
+
+1. El auto-rig de Higgsfield (`3d_rigging`) rechazo cuatro veces el heroe
+ original (`heroe.glb`), incluso girado hacia +z, con su altura real y sin
+ animacion: tiene el escudo sobre el torso y la espada pegada al cuerpo, y el
+ rig pide los brazos separados. Los intentos fallidos se reintegran.
+2. Con una vista del heroe original como referencia, Nano Banana Pro dibujo el
+ mismo personaje en pose A, con las manos vacias (2 creditos).
+3. Tripo H3.1, desde Higgsfield, la paso a 3D con hasta 10.000 caras y sin PBR
+ (9 creditos). La malla sigue siendo de Tripo.
+4. `node scripts/glb-orient.js` la dejo mirando hacia +z y parada en y = 0, y
+ `3d_rigging` le puso el esqueleto y `Casual_Walk` (8 creditos); un segundo
+ `3d_rigging` sobre ese resultado agrego `Idle` (8 creditos).
+5. `glb_merge_anims.py` de las skills de Higgsfield junto los dos clips en un
+ solo GLB de 1,5 MB, mas liviano que el original de 2,2 MB.
+
+Total: 27 creditos de Higgsfield. El heroe original sigue en la estatua de la
+plaza, en el ASCII de la terminal y como respaldo.
+
+## El mundo en 3D
+
+En la ciudad, NOX, el castillo, el Coliseo y la arena del Coloso, la pagina arma
+ese mapa en 3D con el mismo arte ASCII y la tabla `TILES` del juego
+(`scripts/tripo-mundo.js`, servido en `/mundo.js`; los mapas salen de
+`/world/.json`):
+
+- Cada edificio cerrado del arte (un grupo lleno de paredes y relleno que no
+ toca el borde) es una caja de 4,5 a 9 de alto, el doble a cuatro veces y media
+ el heroe, con techo a dos aguas y ventanas encendidas por piso. Todos los
+ edificios de un mapa van en una sola malla.
+- Lo que queda suelto sube por tramos de una fila: las murallas `#` a 3,4,
+ cercos, mamposteria y adornos mas bajos.
+- Las letras que el arte pinta en los edificios y carteles quedan como bloques
+ con su letra, asi que "mercado del alba" se lee en 3D. Una `o` o una `t`
+ pegada a otras letras es letra, no piedra ni arbol.
+- El suelo caminable lleva el color de su caracter: calle, pasto, adoquines,
+ grava. Hay arboles, piedras, flores, agua y, en la arena, rios de lava.
+- Cada puerta tiene una columna de luz y su nombre. Los NPC son stickmen del
+ color que usa la terminal, con su nombre.
+- La estatua de los heroes de la plaza es el heroe de Tripo (`heroe.glb`) y el
+ Coloso de Tripo (`coloso.glb`) espera en su arena.
+
+El heroe camina hacia la celda del guardado, con las piernas en movimiento, y la
+camara lo sigue desde el sur, mirando al norte como la terminal, en una vista
+2.5D: lente cerrada y desde lejos. Con el mouse se gira y se acerca. Una columna
+mide 0,5 y una fila 1: la celda de terminal es el doble de alta que de ancha.
+
+Medido en la ciudad: 88 llamadas de dibujo y unos 59.000 triangulos por cuadro.
+Los NPC son tres mallas compartidas y la resolucion se limita a 1,5x. Mientras un
+mapa se arma no se vuelve a pedir, aunque la consulta llegue cada 200 ms.
+
+La pradera, los campamentos y la cripta todavia muestran la vitrina: el terreno
+de la pradera sale de la semilla del dia, que el guardado no trae.
+
+## Sitio web
+
+Para quien no instala nada: https://bitcoindefi.github.io/runa/ sirve la misma
+pagina en modo demo, en ingles. El heroe de Tripo camina con WASD o flechas
+(en pantallas tactiles, con una cruceta por la que se puede deslizar el dedo),
+1 a 6 cambian de lugar (ciudad, NOX, castillo, Coliseo, el Coloso y la vitrina
+con el reino de Tripo), los portones llevan al otro mapa como en el modo
+navegador, E habla con los NPC, I abre una mochila de ejemplo (yelmo de Tripo,
+espada, escudo, placas y botas) y H pone o saca el yelmo. El link de abajo lleva
+a la galeria de los modelos, donde el heroe animado camina.
+
+Los choques son los del juego: en los mapas, la tabla `TILES` (`isSolid` de
+`lib/map.js`) y los NPC (`npcAt` de `lib/game.js`); en las ruinas, `isWalkable`
+de `lib/boss-zone.js` y el cuerpo del Coloso (`distanceToBody` de
+`lib/world-boss-event.js`). Cada tecla es un paso de celda, a unos 5 m/s. Como
+la terminal mueve un eje por vez, una diagonal solo pasa si una de las dos
+celdas de al lado esta libre, y si choca se desliza por el eje libre. En las
+ruinas, lo que el juego deja pisar se dibuja al ras, como escombro, y el heroe
+llega a 10 m del Coloso.
+
+En un celular el texto de arriba lleva fondo y los lugares van en una fila que se
+desliza, con botones de 44 px; los links quedan a la izquierda de la cruceta y
+los avisos por encima. En una pantalla vertical la lente vertical se abre hasta
+que el ancho visible vuelve a ser el de la base, para que el Coloso entre en
+cuadro.
+
+```powershell
+node scripts/tripo-viewer.js --fetch-lib
+npm.cmd run web:build
+```
+
+`scripts/build-web.js` arma `out/web/` con las piezas que sirve el visor: las
+dos paginas con `data-demo="1"` y sus textos fijos ya en ingles (tambien la
+descripcion para la vista previa de un link), `mundo.js`, los mapas en `world/`, three.js y
+model-viewer de `vendor/`, los GLB de `assets/tripo/` y `.nojekyll`. Todo va con
+rutas relativas, asi anda bajo `/runa/`. Los GLB y three.js se publican solo en
+la rama `gh-pages`: `main` los sigue ignorando. Para probar el modo demo sin
+publicar, abri `/heroe?demo` en el visor local.
+
+En un Chrome recien abierto el primer pedido puede fallar con "Failed to fetch";
+la lista de modelos, los mapas y los GLB se reintentan antes de rendirse (salvo
+una respuesta 4xx, que no va a cambiar), porque sin la lista no aparece ningun
+modelo de Tripo. En el modo en vivo, si la lista llega tarde, el heroe de Tripo
+se carga en ese momento y no recien al cambiar de mapa.
+
+## Prueba
+
+El 8 de octubre se probo en Chrome headless (SwiftShader) con la partida real y
+con una partida de prueba, manejando una sola pagina abierta por CDP mientras
+cambiaba el guardado: cambio de arma, casco, pecho y escudo con su aviso; la
+plaza de RUNA, doce pasos al este, la arena del Coloso, el salon del trono y la
+vuelta a la pradera. La consola no mostro errores.
+
+El 9 de octubre el sitio se probo servido bajo `/runa/`, como en GitHub Pages,
+en Chrome headless sobre la GPU y con un perfil nuevo por corrida: el heroe de
+Tripo aparece en la ciudad, H le pone el yelmo, camina diez celdas al este y
+cuatro o cinco al sur, carga cada mapa, en las ruinas se frena contra el cuerpo del
+Coloso (celda 92) y la galeria carga los cuatro GLB. En dos de tres corridas el
+primer pedido de `models.json` fallo y el reintento lo recupero.
+
+Despues, tres verificadores independientes probaron el sitio publicado en un
+celular emulado, el modo en vivo con el juego y el codigo. Lo que encontraron
+quedo corregido: el heroe ya no atraviesa NPC ni bloques de las ruinas, la
+diagonal no se cuela entre paredes, un fondo de la vitrina ya no queda duplicado
+al cambiar rapido de lugar, el aviso de equipar no se pierde si el heroe termina
+de cargar en ese momento, y en el celular los links y los avisos ya no quedan
+bajo la cruceta. Con el heroe animado, en un celular emulado (390 x 844, tactil)
+la cruceta lo mueve diez celdas, deslizar el dedo a la flecha de abajo cambia la
+direccion, los dos links se pueden tocar y el Coloso se ve al llegar a las ruinas.
diff --git a/package.json b/package.json
index f9d6f95..82f5e81 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"make": "node scripts/make.js",
"tripo": "node scripts/tripo-generate.js",
"tripo:viewer": "node scripts/tripo-viewer.js",
+ "web:build": "node scripts/build-web.js",
"make:darwin-arm64": "bare-build --name runa --standalone --host darwin-arm64 --out ./out/darwin-arm64 bin.mjs",
"make:darwin-x64": "bare-build --name runa --standalone --host darwin-x64 --out ./out/darwin-x64 bin.mjs",
"make:linux-arm64": "bare-build --name runa --standalone --host linux-arm64 --out ./out/linux-arm64 bin.mjs",
diff --git a/scripts/build-web.js b/scripts/build-web.js
new file mode 100644
index 0000000..1a18f0c
--- /dev/null
+++ b/scripts/build-web.js
@@ -0,0 +1,100 @@
+#!/usr/bin/env node
+'use strict'
+
+// Arma el sitio estatico de la vista 2.5D, para GitHub Pages: la pagina del
+// heroe en modo demo (camina en el navegador, sin el juego), la galeria, los
+// mapas del juego en JSON, three.js y los GLB de Tripo. Todo sale de las mismas
+// piezas que sirve el visor, asi el sitio no se separa de lo que se prueba local.
+//
+// node scripts/tripo-viewer.js --fetch-lib una vez, con wifi
+// node scripts/build-web.js [carpeta] por defecto out/web
+
+const fs = require('fs')
+const path = require('path')
+const viewer = require('./tripo-viewer.js')
+
+const out = path.resolve(process.argv[2] || path.join(__dirname, '..', 'out', 'web'))
+
+function write(name, data) {
+ const file = path.join(out, name)
+ fs.mkdirSync(path.dirname(file), { recursive: true })
+ fs.writeFileSync(file, data)
+}
+
+// En el sitio las dos paginas van en ingles y en modo demo. Los textos fijos se
+// traducen aca, asi el primer cuadro (y la vista previa de un link) ya sale en
+// ingles antes de que cargue three.js.
+const ABOUT =
+ "Walk Runa's world in 2.5D in your browser. The hero, the helmet, the Colossus and " +
+ 'the kingdom are 3D models generated with Tripo.'
+const META = [
+ ``,
+ '',
+ '',
+ ``
+].join('\n ')
+const ENGLISH = {
+ 'tripo-heroe.html': [
+ ['', ''],
+ ['Runa / Heroe', `Runa x Tripo / 2.5D\n ${META}`],
+ ['
[ RUNA / HEROE ]
', '
[ RUNA x TRIPO ]
'],
+ ['buscando la partida...', 'loading the 2.5D world...'],
+ [
+ 'WASD o flechas: caminar. E: hablar. I: inventario.',
+ 'WASD or arrows: walk. 1-6: change place. H: Tripo helmet.'
+ ]
+ ],
+ 'tripo-viewer.html': [
+ ['', ''],
+ ['Runa / Tripo', `Runa x Tripo / models\n ${META}`],
+ ['Runa sigue en la terminal. Aca solo los GLB.', "Runa's 3D models."]
+ ]
+}
+function page(file) {
+ let html = fs.readFileSync(file, 'utf8')
+ for (const [from, to] of ENGLISH[path.basename(file)]) {
+ if (!html.includes(from)) throw new Error(`${path.basename(file)} ya no tiene: ${from}`)
+ html = html.replace(from, to)
+ }
+ return html
+}
+
+const missing = Object.keys(viewer.LIBS).filter(
+ (name) => !fs.existsSync(path.join(viewer.VENDOR, name))
+)
+if (missing.length) {
+ console.error(`Faltan ${missing.join(', ')}: corre node scripts/tripo-viewer.js --fetch-lib`)
+ process.exit(1)
+}
+const models = viewer.listModels()
+if (!models.length) {
+ console.error('No hay GLB en assets/tripo: el sitio quedaria sin los modelos de Tripo.')
+ process.exit(1)
+}
+
+write('index.html', page(viewer.PAGES['/heroe']))
+write('models.html', page(viewer.PAGES['/']))
+write('mundo.js', fs.readFileSync(viewer.WORLD_JS))
+write('models.json', JSON.stringify(models))
+const ids = [...viewer.WORLD_MAPS, 'boss']
+for (const id of ids) {
+ const data = viewer.worldMap(id)
+ if (!data) throw new Error(`no pude armar el mapa ${id}`)
+ write(`world/${id}.json`, JSON.stringify(data))
+}
+// La galeria pide model-viewer en la raiz; three.js va bajo vendor/.
+for (const name of Object.keys(viewer.LIBS)) {
+ const dest = name === 'model-viewer.min.js' ? name : `vendor/${name}`
+ write(dest, fs.readFileSync(path.join(viewer.VENDOR, name)))
+}
+for (const model of models) {
+ write(`assets/tripo/${model.name}`, fs.readFileSync(path.join(viewer.MODELS, model.name)))
+}
+// Los NPC pintados de la vista 2.5D, un PNG por tipo.
+const npcArt = path.join(__dirname, '..', 'assets', 'npcs')
+for (const name of fs.readdirSync(npcArt).filter((file) => /^[a-z]+\.png$/.test(file))) {
+ write(`assets/npcs/${name}`, fs.readFileSync(path.join(npcArt, name)))
+}
+// Sin Jekyll, GitHub Pages sirve cada archivo tal cual.
+write('.nojekyll', '')
+console.log(`${out}: ${models.length} modelos de Tripo y ${ids.length} mapas`)
diff --git a/scripts/glb-orient.js b/scripts/glb-orient.js
new file mode 100644
index 0000000..28fd4a7
--- /dev/null
+++ b/scripts/glb-orient.js
@@ -0,0 +1,95 @@
+#!/usr/bin/env node
+'use strict'
+
+// Deja un GLB de Tripo como lo espera un auto-rig: mirando hacia +z (la
+// convencion de glTF) y parado en y = 0. Tripo entrega los modelos mirando
+// hacia +x; el giro de -90 grados sobre y y la subida se aplican a los vertices,
+// asi la malla y las texturas quedan iguales.
+//
+// node scripts/glb-orient.js entrada.glb salida.glb
+
+const fs = require('fs')
+
+const [input, output] = process.argv.slice(2)
+if (!input || !output) {
+ console.error('Uso: node scripts/glb-orient.js entrada.glb salida.glb')
+ process.exit(1)
+}
+const glb = fs.readFileSync(input)
+if (glb.toString('utf8', 0, 4) !== 'glTF') throw new Error(`${input} no es un GLB`)
+const jsonLength = glb.readUInt32LE(12)
+const json = JSON.parse(glb.toString('utf8', 20, 20 + jsonLength))
+const binStart = 20 + jsonLength
+const bin = Buffer.from(glb.subarray(binStart + 8, binStart + 8 + glb.readUInt32LE(binStart)))
+
+for (const node of json.nodes) {
+ if (node.matrix || node.rotation || node.translation || node.scale) {
+ throw new Error(`el nodo ${node.name} tiene su propia transformacion`)
+ }
+}
+
+// (x, y, z) -> (-z, y, x): -90 grados sobre y, +x pasa a +z.
+const turn = (x, y, z) => [-z, y, x]
+
+function each(index, visit) {
+ const accessor = json.accessors[index]
+ if (accessor.componentType !== 5126) throw new Error('se esperaba float32')
+ const view = json.bufferViews[accessor.bufferView]
+ const stride = view.byteStride || (accessor.type === 'VEC4' ? 16 : 12)
+ const base = (view.byteOffset || 0) + (accessor.byteOffset || 0)
+ for (let i = 0; i < accessor.count; i++) {
+ const at = base + i * stride
+ const next = visit([0, 1, 2].map((k) => bin.readFloatLE(at + k * 4)))
+ next.forEach((value, k) => bin.writeFloatLE(value, at + k * 4))
+ }
+ return accessor
+}
+
+let floor = Infinity
+for (const mesh of json.meshes) {
+ for (const primitive of mesh.primitives) {
+ floor = Math.min(floor, json.accessors[primitive.attributes.POSITION].min[1])
+ }
+}
+const done = new Set()
+for (const mesh of json.meshes) {
+ for (const primitive of mesh.primitives) {
+ const { POSITION, NORMAL, TANGENT } = primitive.attributes
+ if (!done.has(POSITION)) {
+ done.add(POSITION)
+ const min = [Infinity, Infinity, Infinity]
+ const max = [-Infinity, -Infinity, -Infinity]
+ const accessor = each(POSITION, ([x, y, z]) => {
+ const point = turn(x, y - floor, z)
+ point.forEach((value, k) => {
+ min[k] = Math.min(min[k], value)
+ max[k] = Math.max(max[k], value)
+ })
+ return point
+ })
+ accessor.min = min
+ accessor.max = max
+ }
+ for (const index of [NORMAL, TANGENT]) {
+ if (index === undefined || done.has(index)) continue
+ done.add(index)
+ each(index, ([x, y, z]) => turn(x, y, z))
+ }
+ }
+}
+
+const pad = (data, fill) => Buffer.concat([data, Buffer.alloc((4 - (data.length % 4)) % 4, fill)])
+const chunk = (data, type) => {
+ const head = Buffer.alloc(8)
+ head.writeUInt32LE(data.length, 0)
+ head.writeUInt32LE(type, 4)
+ return Buffer.concat([head, data])
+}
+const jsonChunk = chunk(pad(Buffer.from(JSON.stringify(json), 'utf8'), 0x20), 0x4e4f534a)
+const binChunk = chunk(pad(bin, 0), 0x004e4942)
+const header = Buffer.alloc(12)
+header.write('glTF', 0)
+header.writeUInt32LE(2, 4)
+header.writeUInt32LE(12 + jsonChunk.length + binChunk.length, 8)
+fs.writeFileSync(output, Buffer.concat([header, jsonChunk, binChunk]))
+console.log(`${output}: mirando hacia +z, subido ${(-floor).toFixed(3)}`)
diff --git a/scripts/tripo-heroe.html b/scripts/tripo-heroe.html
new file mode 100644
index 0000000..9f771d0
--- /dev/null
+++ b/scripts/tripo-heroe.html
@@ -0,0 +1,2038 @@
+
+
+
+
+
+ Runa / Heroe
+
+
+
+
+
+
+
+
[ RUNA / HEROE ]
+
buscando la partida...
+
+
WASD o flechas: caminar. E: hablar. I: inventario.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/tripo-mundo.js b/scripts/tripo-mundo.js
new file mode 100644
index 0000000..f052e4f
--- /dev/null
+++ b/scripts/tripo-mundo.js
@@ -0,0 +1,2864 @@
+// El mundo de RUNA en 2.5D. El mapa ASCII del juego se levanta con la tabla TILES
+// del juego (lib/map.js): cada edificio cerrado del arte es una caja alta con
+// techo a dos aguas y ventanas encendidas; las murallas, cercos y carteles sueltos
+// suben por tramos; el suelo lleva el color de su caracter. Todo va en pocas
+// mallas para que ande liviano: los edificios de un mapa son una sola.
+import * as THREE from 'three'
+import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'
+
+// Este modulo corre en el navegador; el lint del repo lo revisa como codigo de Node.
+const { document } = globalThis
+
+// Una celda de terminal es el doble de alta que de ancha (lib/map.js): en 3D una
+// columna mide CX y una fila CZ. El heroe mide 2.
+export const CX = 0.5
+export const CZ = 1
+
+// Suelo caminable, por caracter. Los que tienen nombre van bajo los arboles
+// (musgo), el agua (lecho) y la lava (quemado), en las ruinas (ceniza) y donde
+// un adoquin suelto asoma entre el pasto (piedras).
+const GROUND = {
+ '`': 0x3b5029,
+ '.': 0x8c7b5c,
+ ',': 0x43602c,
+ '"': 0x3d6128,
+ ';': 0x7a756c,
+ '%': 0x8b7d66,
+ '*': 0x43602c,
+ '=': 0x4d4540,
+ moss: 0x2e3b22,
+ bed: 0x233739,
+ ash: 0x2e2521,
+ scorch: 0x5c2512,
+ stones: 0x827f71
+}
+const UNDER = 0x1a1814
+
+// Colores de los edificios por mapa.
+// Cada edificio toma su tono alrededor de estos; roofs son las familias de
+// techo. pitch es la pendiente del techo, floor la altura de cada piso y arch la
+// forma de las puertas. timber pone entramado de madera, masonry paredes de
+// sillares, vein enciende las juntas y spire levanta agujas. keep, slate y
+// banner visten el castillo.
+const PALETTES = {
+ city: {
+ wall: 0xdcccab,
+ roofs: [0xa8462c, 0xa8462c, 0x8a5434, 0x5f6670],
+ glow: 0xffc879,
+ stone: 0x8a8272,
+ trim: 0x5c3f2a,
+ frame: 0x4b3424,
+ door: 0x6a4126,
+ shutters: [0x3f6e4c, 0x39598a, 0x8a3b2c, 0x9a7a34],
+ arch: ['round', 'flat'],
+ pitch: 0.62,
+ floor: 2.8,
+ chimneys: 0.75,
+ timber: true,
+ keep: 0xbdb4a3,
+ slate: 0x56606e,
+ banner: 0xb8362a
+ },
+ nox: {
+ wall: 0x3a3347,
+ roofs: [0x1f2838, 0x2c2040, 0x16302f],
+ glow: 0xb78cff,
+ stone: 0x26222d,
+ trim: 0x8a80a6,
+ frame: 0x16121e,
+ door: 0x2a1f38,
+ vein: 0x5fe8c8,
+ arch: ['pointed'],
+ pitch: 1.15,
+ floor: 3,
+ chimneys: 0.5,
+ spire: true
+ },
+ castle: {
+ wall: 0xaaa293,
+ roofs: [0x7a3328, 0x6c3a2c],
+ glow: 0xffc879,
+ stone: 0x6f685d,
+ trim: 0xc2b9a6,
+ frame: 0x5e574c,
+ door: 0x4e3424,
+ arch: ['round'],
+ pitch: 0.75,
+ floor: 2.8,
+ chimneys: 0.5,
+ masonry: true
+ },
+ coliseum: {
+ wall: 0xb9a27c,
+ roofs: [0x7a5a3a],
+ glow: 0xffc879,
+ stone: 0x7a6a52,
+ trim: 0xd2bf98,
+ frame: 0x5e4a33,
+ door: 0x4e3424,
+ arch: ['round'],
+ pitch: 0.6,
+ floor: 2.8,
+ chimneys: 0
+ },
+ boss: {
+ wall: 0x5a4a42,
+ roofs: [0x3a2a24],
+ glow: 0xff7a2a,
+ stone: 0x2e2420,
+ trim: 0x7a5a48,
+ frame: 0x2a1d18,
+ door: 0x2a1d18,
+ arch: ['pointed'],
+ pitch: 0.9,
+ floor: 2.8,
+ chimneys: 0
+ }
+}
+
+// Lo que sube fuera de los edificios: altura y material por clase.
+const LOOSE = {
+ rampart: { h: 3.4, color: 0x8b867b },
+ wall: { h: 2.2, color: 0x8b867b },
+ masonry: { h: 1.1, color: 0x6e6a62 },
+ roof: { h: 2.6, color: 0x8a3f2e },
+ window: { h: 2.2, color: 0xf1c27a, glow: 0.8 },
+ ornament: { h: 1.6, color: 0xc9a245, metal: true },
+ building: { h: 2.4, color: 0x9c968a },
+ body: { h: 2.2, color: 0x77726a },
+ frame: { h: 2, color: 0x5b564e },
+ fountain: { h: 0.7, color: 0x5aa0c8, glow: 0.35 },
+ sign: { h: 1.4, color: 0xe0c060, glyph: true },
+ door: { h: 0.12, color: 0xe0c060, glow: 1, glyph: true },
+ water: { h: 0.08, color: 0x2f6f9f, glow: 0.25, sink: 0.05 },
+ lava: { h: 0.12, color: 0xff5a1a, glow: 1.6, sink: 0.04 },
+ // El portal y el escombro de las ruinas se pisan: van al ras del piso.
+ portal: { h: 0.15, color: 0x9a6bff, glow: 1.4 },
+ rubble: { h: 0.16, color: 0x5a4a42 }
+}
+
+// Lo que puede formar parte de un edificio.
+const STRUCTURE = new Set([
+ 'rampart',
+ 'wall',
+ 'masonry',
+ 'roof',
+ 'window',
+ 'ornament',
+ 'building',
+ 'body',
+ 'frame',
+ 'sign',
+ 'fountain'
+])
+
+// En el arte, una "o" o una "t" dentro de una palabra es una letra del cartel,
+// no una piedra ni un arbol.
+const letter = (ch) => ch >= 'a' && ch <= 'z'
+function inWord(row, x) {
+ return letter(row[x - 1] || '') || letter(row[x + 1] || '')
+}
+
+function classify(ch, tile, kind) {
+ if (kind === 'boss') {
+ if (ch === '~') return 'lava'
+ if (ch === '=' || ch === '.' || ch === ',') return null
+ if (ch === 'O') return 'portal'
+ // En las ruinas solo frenan # | - + (BossZone.isWalkable): el resto se pisa,
+ // asi que se dibuja como escombro bajo y no como un bloque que el heroe
+ // atravesaria.
+ if (!'#|-+'.includes(ch)) return 'rubble'
+ }
+ if (ch === ' ') return 'body'
+ if (!tile) return 'frame'
+ if (!tile.solid) return tile.id.startsWith('door.') || tile.id.startsWith('gate.') ? 'door' : null
+ if (tile.id === 'wall') return ch === '#' ? 'rampart' : 'wall'
+ if (tile.id === 'tree') return 'tree'
+ if (tile.id === 'rock') return 'rock'
+ if (tile.id === 'water') return 'water'
+ if (tile.id === 'nowhere') return 'frame'
+ return LOOSE[tile.id] ? tile.id : 'frame'
+}
+
+// Atlas de glifos ASCII 32..127: 16 columnas por 8 filas de 64 px.
+let atlas = null
+function glyphAtlas() {
+ if (atlas) return atlas
+ const canvas = document.createElement('canvas')
+ canvas.width = 1024
+ canvas.height = 512
+ const g = canvas.getContext('2d')
+ g.fillStyle = '#ffffff'
+ g.fillRect(0, 0, canvas.width, canvas.height)
+ g.fillStyle = '#2a1e0a'
+ g.font = 'bold 52px ui-monospace, Consolas, monospace'
+ g.textAlign = 'center'
+ g.textBaseline = 'middle'
+ for (let code = 32; code < 128; code++) {
+ const i = code - 32
+ g.fillText(String.fromCharCode(code), (i % 16) * 64 + 32, Math.floor(i / 16) * 64 + 34)
+ }
+ atlas = new THREE.CanvasTexture(canvas)
+ atlas.colorSpace = THREE.SRGBColorSpace
+ atlas.anisotropy = 4
+ return atlas
+}
+
+function glyphCell(ch) {
+ const i = Math.max(0, Math.min(95, ch.charCodeAt(0) - 32))
+ return [i % 16, 7 - Math.floor(i / 16)]
+}
+
+function material(spec) {
+ const options = { color: spec.color, roughness: spec.metal ? 0.35 : 0.85 }
+ if (spec.metal) options.metalness = 0.8
+ if (spec.glow) {
+ options.emissive = spec.color
+ options.emissiveIntensity = spec.glow
+ }
+ if (spec.glyph) options.map = glyphAtlas()
+ const m = new THREE.MeshStandardMaterial(options)
+ if (spec.glyph) {
+ // Cada instancia lee su letra del atlas.
+ m.onBeforeCompile = (shader) => {
+ shader.vertexShader =
+ 'attribute vec2 glyphCell;\n' +
+ shader.vertexShader.replace(
+ '#include ',
+ '#include \n#ifdef USE_MAP\nvMapUv = (vMapUv + glyphCell) * vec2(1.0 / 16.0, 1.0 / 8.0);\n#endif'
+ )
+ }
+ }
+ return m
+}
+
+// Una etiqueta que siempre mira a la camara.
+export function label(text, color = '#e0c060') {
+ const canvas = document.createElement('canvas')
+ const g = canvas.getContext('2d')
+ const font = '28px ui-monospace, Consolas, monospace'
+ g.font = font
+ canvas.width = Math.ceil(g.measureText(text).width) + 24
+ canvas.height = 44
+ g.font = font
+ g.fillStyle = 'rgba(11, 11, 12, 0.72)'
+ g.fillRect(0, 0, canvas.width, canvas.height)
+ g.fillStyle = color
+ g.textBaseline = 'middle'
+ g.fillText(text, 12, 23)
+ const texture = new THREE.CanvasTexture(canvas)
+ texture.colorSpace = THREE.SRGBColorSpace
+ const sprite = new THREE.Sprite(
+ new THREE.SpriteMaterial({ map: texture, depthWrite: false, transparent: true })
+ )
+ sprite.scale.set(canvas.width / 80, canvas.height / 80, 1)
+ sprite.renderOrder = 10
+ return sprite
+}
+
+function hash(x, y) {
+ let h = (x * 374761393 + y * 668265263) | 0
+ h = Math.imul(h ^ (h >>> 13), 1274126177)
+ return ((h ^ (h >>> 16)) >>> 0) / 4294967295
+}
+
+// Terreno: el suelo con textura fina y lo que crece encima.
+
+// Lo que lleva cada suelo de GROUND ademas de su color: su grano suelto, el
+// dibujo fino que le pone el shader (1 adoquines, -1 grava) y su familia: dentro
+// de una familia los suelos se funden suave, como pasto con pasto; entre
+// familias el borde es neto, como el de un camino.
+const SOIL = {
+ '`': { grain: 0.05, family: 1 },
+ ',': { grain: 0.06, family: 1 },
+ '"': { grain: 0.06, family: 1 },
+ '*': { grain: 0.06, family: 1 },
+ moss: { grain: 0.06, family: 1 },
+ bed: { grain: 0.03, family: 1 },
+ stones: { grain: 0.04, pattern: 1, family: 1 },
+ '.': { grain: 0.12, pattern: -0.45, family: 2 },
+ ';': { grain: 0.04, pattern: 1, family: 3 },
+ '%': { grain: 0.12, pattern: -1, family: 4 },
+ '=': { grain: 0.04, pattern: 1, family: 5 },
+ ash: { grain: 0.1, pattern: -0.5, family: 6 },
+ scorch: { grain: 0.05, family: 6 }
+}
+
+// El tono de cada mapa: un factor sobre los colores del suelo (tint) y el de las
+// manchas grandes que pinta el shader en los suelos lisos y oscuros, como el
+// pasto (patch). En los claros o con dibujo (tierra, grava, adoquines, ceniza)
+// las manchas solo oscurecen: asi el borde entre dos de ellos no se aclara. El
+// pasto tiene manchas secas; NOX es de noche y frio, con manchas lilas; en las
+// ruinas el suelo junto a la lava se aclara.
+const MOOD = {
+ city: { tint: [1, 1, 1], patch: [1.9, 1.45, 1.15] },
+ nox: { tint: [0.72, 0.8, 1.02], patch: [1.35, 1.2, 1.7] },
+ boss: { tint: [1, 1, 1], patch: [1.7, 1.55, 1.5] }
+}
+
+// Cuanto oscurece el suelo al pie de lo que ocupa toda su celda. Los arboles y
+// las piedras no la llenan: su sombra es redonda, desde el centro.
+const SHADE = {
+ rampart: 1,
+ wall: 1,
+ masonry: 0.8,
+ roof: 1,
+ window: 1,
+ ornament: 0.8,
+ building: 1,
+ body: 1,
+ frame: 1,
+ sign: 0.8,
+ fountain: 0.7,
+ rubble: 0.5
+}
+const ROUND = { tree: [1.05, 0.34], rock: [0.5, 0.3] }
+
+// Colores de las flores por mapa; en NOX ademas brillan.
+const BLOOM = {
+ city: [0xe86fa3, 0xf2d24b, 0xf4efe6, 0xd9493b, 0x9a7be0, 0xf09a3e],
+ nox: [0x6fe0e8, 0xb78cff, 0xe06fe0, 0x8cb4ff],
+ castle: [0xd9493b, 0xf4efe6, 0xf2d24b, 0xe86fa3]
+}
+
+// Copas de pino, roble y alamo por mapa; de vez en cuando un roble de otono.
+const LEAVES = {
+ city: [
+ [0x3b7038, 0x356a3e, 0x44773a],
+ [0x4b7a32, 0x5a8636, 0x6a8b3a, 0xb0682c],
+ [0x5f8f3b, 0x709b43]
+ ],
+ nox: [
+ [0x2a4253, 0x2a3a4e],
+ [0x4a416e, 0x3e4a72],
+ [0x37596d, 0x3b5068]
+ ]
+}
+
+const smooth01 = (t) => t * t * (3 - 2 * t)
+
+// Ruido de valor suave en una grilla de nx por nz puntos: cada octava es
+// [tamano del rasgo en puntos, peso, semilla]. Da manchas que no siguen la
+// grilla del mapa.
+function noiseField(nx, nz, octaves) {
+ const out = new Float32Array(nx * nz)
+ for (const [size, weight, seed] of octaves) {
+ const lx = Math.ceil(nx / size) + 2
+ const lz = Math.ceil(nz / size) + 2
+ const lattice = new Float32Array(lx * lz)
+ for (let i = 0; i < lattice.length; i++) {
+ lattice[i] = hash((i % lx) + seed * 1013, Math.floor(i / lx) - seed * 7919)
+ }
+ for (let z = 0; z < nz; z++) {
+ const j = Math.floor(z / size)
+ const tz = smooth01(z / size - j)
+ for (let x = 0; x < nx; x++) {
+ const i = Math.floor(x / size)
+ const tx = smooth01(x / size - i)
+ const k = j * lx + i
+ const top = lattice[k] + (lattice[k + 1] - lattice[k]) * tx
+ const bottom = lattice[k + lx] + (lattice[k + lx + 1] - lattice[k + lx]) * tx
+ out[z * nx + x] += (top + (bottom - top) * tz) * weight
+ }
+ }
+ }
+ return out
+}
+
+// Numeros al azar fijos para el grano de cada texel: mas barato que un hash.
+const DICE = new Float32Array(65536).map((_, i) => hash(i, 977))
+const dice = (u, v, seed) =>
+ DICE[(Math.imul(u, 0x9e3779b1) ^ Math.imul(v + seed, 0x85ebca77) ^ seed) >>> 16]
+
+// Grano fino del suelo, el mismo para todos los mapas: R y A grano en dos
+// escalas, G adoquines y B grava. Cubre 2 x 2 unidades y se repite.
+let soilGrain = null
+function soilDetail() {
+ if (soilGrain) return soilGrain
+ const n = 256
+ // Ruido de valor que da la vuelta en el borde, con `cells` rasgos por lado.
+ const wrapped = (cells, seed) => {
+ const out = new Float32Array(n * n)
+ const step = n / cells
+ const lattice = new Float32Array((cells + 1) * (cells + 1))
+ for (let j = 0; j <= cells; j++) {
+ for (let i = 0; i <= cells; i++) {
+ lattice[j * (cells + 1) + i] = hash((i % cells) + seed * 977, (j % cells) - seed * 613)
+ }
+ }
+ for (let y = 0; y < n; y++) {
+ const j = Math.floor(y / step)
+ const ty = smooth01(y / step - j)
+ for (let x = 0; x < n; x++) {
+ const i = Math.floor(x / step)
+ const tx = smooth01(x / step - i)
+ const k = j * (cells + 1) + i
+ const top = lattice[k] + (lattice[k + 1] - lattice[k]) * tx
+ const bottom =
+ lattice[k + cells + 1] + (lattice[k + cells + 2] - lattice[k + cells + 1]) * tx
+ out[y * n + x] = top + (bottom - top) * ty
+ }
+ }
+ return out
+ }
+ // Piedras: cada una es la zona del punto mas cercano, con un punto movido al
+ // azar por casilla de una grilla de `cells` por lado. Da el tono de cada
+ // piedra y cuanto falta para la junta, en texeles.
+ const stones = (cells, seed) => {
+ const tone = new Float32Array(n * n)
+ const edge = new Float32Array(n * n)
+ const step = n / cells
+ const sites = []
+ for (let j = 0; j < cells; j++) {
+ for (let i = 0; i < cells; i++) {
+ sites.push([
+ 0.15 + hash(i + seed, j) * 0.7,
+ 0.15 + hash(j - seed, i + seed * 3) * 0.7,
+ hash(i * 7 + seed, j * 11)
+ ])
+ }
+ }
+ for (let y = 0; y < n; y++) {
+ for (let x = 0; x < n; x++) {
+ const ci = Math.floor(x / step)
+ const cj = Math.floor(y / step)
+ let d1 = Infinity
+ let d2 = Infinity
+ let t = 0
+ for (let dj = -1; dj <= 1; dj++) {
+ for (let di = -1; di <= 1; di++) {
+ const i = ci + di
+ const j = cj + dj
+ const site =
+ sites[(((j % cells) + cells) % cells) * cells + (((i % cells) + cells) % cells)]
+ const dx = x + 0.5 - (i + site[0]) * step
+ const dy = y + 0.5 - (j + site[1]) * step
+ const d = Math.sqrt(dx * dx + dy * dy)
+ if (d < d1) {
+ d2 = d1
+ d1 = d
+ t = site[2]
+ } else if (d < d2) d2 = d
+ }
+ }
+ tone[y * n + x] = t
+ edge[y * n + x] = (d2 - d1) / 2
+ }
+ }
+ return { tone, edge }
+ }
+ const fine = wrapped(32, 1)
+ const fine2 = wrapped(16, 2)
+ const broad = wrapped(8, 3)
+ const cobble = stones(8, 4)
+ const gravel = stones(32, 5)
+ const pixels = new Uint8ClampedArray(n * n * 4)
+ for (let i = 0; i < n * n; i++) {
+ const joint = smooth01(Math.min(1, cobble.edge[i] / 5))
+ const pebble = smooth01(Math.min(1, gravel.edge[i] / 1.6))
+ pixels[i * 4] = (fine[i] * 0.6 + fine2[i] * 0.4) * 255
+ pixels[i * 4 + 1] = (0.6 + cobble.tone[i] * 0.4) * (0.3 + joint * 0.7) * 255
+ pixels[i * 4 + 2] = (0.35 + gravel.tone[i] * 0.65) * (0.45 + pebble * 0.55) * 255
+ pixels[i * 4 + 3] = broad[i] * 255
+ }
+ soilGrain = new THREE.DataTexture(new Uint8Array(pixels.buffer), n, n)
+ soilGrain.wrapS = THREE.RepeatWrapping
+ soilGrain.wrapT = THREE.RepeatWrapping
+ soilGrain.magFilter = THREE.LinearFilter
+ soilGrain.minFilter = THREE.LinearMipmapLinearFilter
+ soilGrain.generateMipmaps = true
+ soilGrain.anisotropy = 8
+ soilGrain.needsUpdate = true
+ return soilGrain
+}
+
+// Cuantos texeles lleva cada columna del suelo (cada fila el doble, asi el texel
+// es cuadrado): hasta 4, sin pasar de unos 600 mil por mapa, porque se arma en
+// la CPU cada vez que se entra al mapa. El grano mas fino lo pone el shader.
+function soilDensity(width, height) {
+ const fit = Math.floor(Math.sqrt(6e5 / (width * height * 2)))
+ return Math.max(1, Math.min(4, fit, Math.floor(4096 / width), Math.floor(2048 / height)))
+}
+
+// El color de un suelo en 0..255 con el tono del mapa.
+function soilColor(hex, tint) {
+ return [((hex >> 16) & 255) * tint[0], ((hex >> 8) & 255) * tint[1], (hex & 255) * tint[2]]
+}
+
+// Pinta una fila de texeles del suelo (ver soilTexture). Mezcla las cuatro
+// celdas que rodean cada texel: si son el mismo suelo no hay nada que mezclar;
+// si no, el borde se tuerce con ruido y se funde segun la familia. Despues van el
+// grano, las flores, la sombra al pie de lo alto y bajo las copas, y el resplandor
+// de la lava. Sin crear objetos: corre medio millon de veces por mapa.
+function soilRow(job, v) {
+ const { width, height, tx, tw, th, soil, shade, heat, near, base, grain, pattern } = job
+ const { family, warp, own, colA, colB, along, gridF, pixels } = job
+ const reach = 0.42
+ const glow = 0.95
+ const cy = (v + 0.5) / (tx * 2)
+ const cellY = Math.floor(cy)
+ const top = Math.floor(cy - 0.5)
+ const down = cy - 0.5 - top
+ const ya = Math.max(0, top) * width
+ const yb = Math.min(height - 1, top + 1) * width
+ const gz = (v + 0.5) / tx
+ const nx = width + 40
+ const g = Math.floor(gz) * nx
+ const gzf = gz - Math.floor(gz)
+ const pz = cy * CZ
+ let o = (th - 1 - v) * tw * 4
+ for (let u = 0; u < tw; u++, o += 4) {
+ const s00 = soil[ya + colA[u]]
+ const s10 = soil[ya + colB[u]]
+ const s01 = soil[yb + colA[u]]
+ const s11 = soil[yb + colB[u]]
+ let r = 0
+ let gr = 0
+ let b = 0
+ let loose = 0
+ let draw = 0
+ if (s00 === s10 && s00 === s01 && s00 === s11) {
+ r = base[s00 * 3]
+ gr = base[s00 * 3 + 1]
+ b = base[s00 * 3 + 2]
+ loose = grain[s00]
+ draw = pattern[s00]
+ } else {
+ const k = g + own[u]
+ const f = gridF[u]
+ const top0 = warp[k] + (warp[k + 1] - warp[k]) * f
+ const wx = top0 + (warp[k + nx] + (warp[k + nx + 1] - warp[k + nx]) * f - top0) * gzf - 0.5
+ const top1 = warp[k + 37] + (warp[k + 38] - warp[k + 37]) * f
+ const wz =
+ top1 + (warp[k + nx + 37] + (warp[k + nx + 38] - warp[k + nx + 37]) * f - top1) * gzf - 0.5
+ const f00 = family[s00]
+ const same = f00 === family[s10] && f00 === family[s01] && f00 === family[s11]
+ const fx = Math.min(1, Math.max(0, along[u] + wx * 0.5))
+ const fz = Math.min(1, Math.max(0, down + wz * 0.25))
+ const sx = smooth01(same ? fx : Math.min(1, Math.max(0, (fx - 0.5) * 2.5 + 0.5)))
+ const sz = smooth01(same ? fz : Math.min(1, Math.max(0, (fz - 0.5) * 5 + 0.5)))
+ for (let c = 0; c < 4; c++) {
+ const s = c === 0 ? s00 : c === 1 ? s10 : c === 2 ? s01 : s11
+ const w = (c & 1 ? sx : 1 - sx) * (c & 2 ? sz : 1 - sz)
+ r += base[s * 3] * w
+ gr += base[s * 3 + 1] * w
+ b += base[s * 3 + 2] * w
+ loose += grain[s] * w
+ draw += pattern[s] * w
+ }
+ }
+ const speck = (dice(u, v, 1) - 0.5) * loose
+ r += speck
+ gr += speck
+ b += speck * 0.8
+ // Flores: el pasto florido se tine apenas con sus colores.
+ const cell = cellY * width + own[u]
+ if (soil[cell] === job.flowery && dice(u, v, 2) < 0.12) {
+ const hex = job.blooms[Math.floor(dice(u, v, 3) * job.blooms.length)]
+ r += (((hex >> 16) & 255) - r) * 0.35
+ gr += (((hex >> 8) & 255) - gr) * 0.35
+ b += ((hex & 255) - b) * 0.35
+ }
+ let lit = 1
+ if (near[cell]) {
+ const px = ((u + 0.5) * CX) / tx
+ if (near[cell] & 5) {
+ let occ = 0
+ let warm = 0
+ for (let ny = Math.max(0, cellY - 1); ny <= Math.min(height - 1, cellY + 1); ny++) {
+ const z0 = ny * CZ
+ const dz = pz < z0 ? z0 - pz : pz > z0 + CZ ? pz - z0 - CZ : 0
+ if (dz >= glow) continue
+ for (let cx = Math.max(0, own[u] - 1); cx <= Math.min(width - 1, own[u] + 1); cx++) {
+ const n = ny * width + cx
+ if (!shade[n] && !heat[n]) continue
+ const x0 = cx * CX
+ const dx = px < x0 ? x0 - px : px > x0 + CX ? px - x0 - CX : 0
+ const d = Math.sqrt(dx * dx + dz * dz)
+ if (d < reach) occ = Math.max(occ, shade[n] * (1 - d / reach) * (1 - d / reach))
+ if (heat[n] && d < glow) warm = Math.max(warm, (1 - d / glow) * (1 - d / glow))
+ }
+ }
+ lit -= 0.4 * occ
+ r += (176 - r) * warm * 0.6
+ gr += (64 - gr) * warm * 0.6
+ b += (22 - b) * warm * 0.6
+ }
+ if (near[cell] & 2) {
+ const casters = job.roundAt.get(cell)
+ for (let k = 0; k < casters.length; k++) {
+ const caster = casters[k]
+ const ax = px - caster[0]
+ const az = pz - caster[1]
+ const d = Math.sqrt(ax * ax + az * az) / caster[2]
+ if (d < 1) lit -= caster[3] * (1 - d) * (1 - d)
+ }
+ }
+ lit = Math.max(0.45, lit)
+ }
+ pixels[o] = r * lit
+ pixels[o + 1] = gr * lit
+ pixels[o + 2] = b * lit
+ pixels[o + 3] = 128 + 127 * draw
+ }
+}
+
+// El suelo del mapa como textura: el color de cada celda, con grano y bordes que
+// se funden con ruido, mas la sombra al pie de las paredes, piedras y arboles.
+// Las manchas grandes y el relieve los pinta el shader. El alfa le dice que
+// dibujo fino lleva: 0,5 ninguno, mas es adoquin y menos es grava.
+function soilTexture(data, kinds) {
+ const { width, height, rows } = data
+ const boss = data.kind === 'boss'
+ const { tint } = MOOD[data.id] || MOOD.city
+ const keys = Object.keys(SOIL)
+ // El ultimo suelo es lo que queda tapado del todo, dentro de un edificio.
+ const count = keys.length + 1
+ const hidden = count - 1
+ const base = new Float32Array(count * 3)
+ const grain = new Float32Array(count)
+ const pattern = new Float32Array(count)
+ const family = new Uint8Array(count)
+ keys.forEach((key, k) => {
+ base.set(soilColor(GROUND[key], tint), k * 3)
+ grain[k] = SOIL[key].grain * 255
+ pattern[k] = SOIL[key].pattern || 0
+ family[k] = SOIL[key].family
+ })
+ base.set(soilColor(UNDER, tint), hidden * 3)
+ family[hidden] = 9
+ const index = Object.fromEntries(keys.map((key, k) => [key, k]))
+ const blooms = BLOOM[data.id] || BLOOM.city
+
+ // El suelo de cada celda. Lo alto (paredes, edificios) toma el de una vecina,
+ // asi su borde no se oscurece de golpe: la sombra al pie va aparte.
+ const cells = width * height
+ const NONE = 255
+ const soil = new Uint8Array(cells).fill(NONE)
+ const shade = new Float32Array(cells)
+ const heat = new Uint8Array(cells)
+ const round = []
+ const paved = (x, y) => ((rows[y] || '')[x] === ';' ? 1 : 0)
+ for (let y = 0; y < height; y++) {
+ const row = rows[y] || ''
+ for (let x = 0; x < width; x++) {
+ const i = y * width + x
+ const kind = kinds[i]
+ const ch = row[x] || ' '
+ let key = null
+ if (boss) {
+ if (kind === 'lava') key = 'scorch'
+ else if (!kind) key = ch === '=' ? '=' : 'ash'
+ } else if (!kind) key = SOIL[ch] ? ch : '`'
+ else if (kind === 'tree') key = 'moss'
+ else if (kind === 'water') key = 'bed'
+ // Un adoquin suelto entre el pasto es una piedra que asoma, no una baldosa.
+ if (
+ key === ';' &&
+ paved(x + 1, y) + paved(x - 1, y) + paved(x, y + 1) + paved(x, y - 1) < 2
+ ) {
+ key = 'stones'
+ }
+ if (key) soil[i] = index[key]
+ shade[i] = SHADE[kind] || 0
+ heat[i] = kind === 'lava' ? 1 : 0
+ if (ROUND[kind]) round.push([(x + 0.5) * CX, (y + 0.5) * CZ, ...ROUND[kind]])
+ }
+ }
+ for (let pass = 0; pass < 4; pass++) {
+ for (let i = 0; i < cells; i++) {
+ if (soil[i] !== NONE) continue
+ const x = i % width
+ if (x > 0 && soil[i - 1] !== NONE) soil[i] = soil[i - 1]
+ else if (x < width - 1 && soil[i + 1] !== NONE) soil[i] = soil[i + 1]
+ else if (i >= width && soil[i - width] !== NONE) soil[i] = soil[i - width]
+ else if (i + width < cells && soil[i + width] !== NONE) soil[i] = soil[i + width]
+ }
+ }
+ for (let i = 0; i < cells; i++) if (soil[i] === NONE) soil[i] = hidden
+
+ // Las celdas con algo alto al lado (bit 1), bajo una sombra redonda (bit 2) o
+ // junto a la lava (bit 4): solo ahi se calcula sombra o calor.
+ const near = new Uint8Array(cells)
+ for (let i = 0; i < cells; i++) {
+ if (!shade[i] && !heat[i]) continue
+ const x = i % width
+ const y = (i - x) / width
+ for (let ny = Math.max(0, y - 1); ny <= Math.min(height - 1, y + 1); ny++) {
+ for (let nx = Math.max(0, x - 1); nx <= Math.min(width - 1, x + 1); nx++) {
+ near[ny * width + nx] |= shade[i] ? 1 : 4
+ }
+ }
+ }
+ const roundAt = new Map()
+ for (const caster of round) {
+ const [wx, wz, far] = caster
+ const x0 = Math.max(0, Math.floor((wx - far) / CX))
+ const x1 = Math.min(width - 1, Math.floor((wx + far) / CX))
+ const y0 = Math.max(0, Math.floor((wz - far) / CZ))
+ const y1 = Math.min(height - 1, Math.floor((wz + far) / CZ))
+ for (let y = y0; y <= y1; y++) {
+ for (let x = x0; x <= x1; x++) {
+ const i = y * width + x
+ near[i] |= 2
+ if (!roundAt.has(i)) roundAt.set(i, [])
+ roundAt.get(i).push(caster)
+ }
+ }
+ }
+ // Lo que necesita cada fila de texeles. Siempre con las mismas claves, asi el
+ // pintor de filas se compila una vez y queda rapido para todos los mapas.
+ const tx = soilDensity(width, height)
+ const tw = width * tx
+ const th = height * tx * 2
+ const job = {
+ width,
+ height,
+ tx,
+ tw,
+ th,
+ soil,
+ shade,
+ heat,
+ near,
+ roundAt,
+ base,
+ grain,
+ pattern,
+ family,
+ flowery: index['*'],
+ blooms,
+ // Ruido que tuerce los bordes entre suelos, en una grilla de media unidad
+ // (una columna de ancho, media fila de alto). El de z se lee corrido.
+ warp: noiseField(width + 40, height * 2 + 2, [[3, 1, 5]]),
+ // Por columna de texeles: su celda, las dos celdas que mezcla (a izquierda y
+ // derecha de su punto), cuanto avanzo hacia la segunda y su punto de ruido.
+ own: new Int32Array(tw),
+ colA: new Int32Array(tw),
+ colB: new Int32Array(tw),
+ along: new Float32Array(tw),
+ gridF: new Float32Array(tw),
+ pixels: new Uint8ClampedArray(tw * th * 4)
+ }
+ for (let u = 0; u < tw; u++) {
+ const cx = (u + 0.5) / tx
+ const left = Math.floor(cx - 0.5)
+ job.own[u] = Math.floor(cx)
+ job.colA[u] = Math.max(0, left)
+ job.colB[u] = Math.min(width - 1, left + 1)
+ job.along[u] = cx - 0.5 - left
+ job.gridF[u] = cx - job.own[u]
+ }
+ for (let v = 0; v < th; v++) soilRow(job, v)
+ const texture = new THREE.DataTexture(new Uint8Array(job.pixels.buffer), tw, th)
+ texture.magFilter = THREE.LinearFilter
+ texture.minFilter = THREE.LinearMipmapLinearFilter
+ texture.generateMipmaps = true
+ texture.anisotropy = 8
+ texture.colorSpace = THREE.SRGBColorSpace
+ texture.needsUpdate = true
+ return texture
+}
+
+// El campo alrededor del mapa, para que no flote en el vacio: un plano grande y
+// apenas mas bajo, del pasto que mas hay en el borde (ceniza en las ruinas), que
+// se oscurece lejos del mapa hasta perderse en la niebla.
+function soilSkirt(data) {
+ const { width, height, rows } = data
+ const w = width * CX
+ const d = height * CZ
+ const far = 70
+ const seen = {}
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ if (x > 2 && y > 2 && x < width - 3 && y < height - 3) continue
+ const ch = (rows[y] || '')[x]
+ if (SOIL[ch] && SOIL[ch].family === 1) seen[ch] = (seen[ch] || 0) + 1
+ }
+ }
+ const meadow = Object.keys(seen).sort((a, b) => seen[b] - seen[a])[0] || '`'
+ const mood = MOOD[data.id] || MOOD.city
+ const color = soilColor(GROUND[data.kind === 'boss' ? 'ash' : meadow], mood.tint)
+ const n = 32
+ const pixels = new Uint8ClampedArray(n * n * 4)
+ for (let y = 0; y < n; y++) {
+ for (let x = 0; x < n; x++) {
+ const wx = ((x + 0.5) / n) * (w + far * 2) - far
+ const wz = (1 - (y + 0.5) / n) * (d + far * 2) - far
+ const fade = 1 - 0.55 * Math.min(1, Math.max(0, -wx, wx - w, -wz, wz - d) / far)
+ pixels.set([color[0] * fade, color[1] * fade, color[2] * fade, 128], (y * n + x) * 4)
+ }
+ }
+ const texture = new THREE.DataTexture(new Uint8Array(pixels.buffer), n, n)
+ texture.magFilter = THREE.LinearFilter
+ texture.minFilter = THREE.LinearFilter
+ texture.colorSpace = THREE.SRGBColorSpace
+ texture.needsUpdate = true
+ const skirt = new THREE.Mesh(
+ new THREE.PlaneGeometry(w + far * 2, d + far * 2),
+ soilMaterial(texture, data, w + far * 2, d + far * 2, -far)
+ )
+ skirt.rotation.x = -Math.PI / 2
+ skirt.position.set(w / 2, -0.02, d / 2)
+ skirt.receiveShadow = true
+ return skirt
+}
+
+// El material del suelo: la textura del mapa con, encima, manchas grandes del
+// tono del mapa, relieve y grano fino, y segun el alfa adoquines o grava. Todo va
+// a escala del mundo: el plano mide w por d unidades y empieza en `from`, asi el
+// campo de afuera sigue las mismas manchas que el mapa.
+function soilMaterial(texture, data, w, d, from = 0) {
+ const material = new THREE.MeshStandardMaterial({ map: texture, roughness: 1 })
+ const { patch } = MOOD[data.id] || MOOD.city
+ material.onBeforeCompile = (shader) => {
+ shader.uniforms.soilDetail = { value: soilDetail() }
+ shader.uniforms.soilSize = { value: new THREE.Vector3(w, d, from) }
+ shader.uniforms.soilPatch = { value: new THREE.Vector3(...patch) }
+ shader.fragmentShader = shader.fragmentShader
+ .replace(
+ '#include ',
+ `#include
+ uniform sampler2D soilDetail;
+ uniform vec3 soilSize;
+ uniform vec3 soilPatch;`
+ )
+ .replace(
+ '#include ',
+ `#ifdef USE_MAP
+ vec4 sampledDiffuseColor = texture2D( map, vMapUv );
+ vec2 soilAt = vec2( vMapUv.x, 1.0 - vMapUv.y ) * soilSize.xy + soilSize.z;
+ vec4 fine = texture2D( soilDetail, soilAt * 0.5 );
+ float broad = texture2D( soilDetail, soilAt * 0.11 + vec2( 0.37, 0.61 ) ).a;
+ float spots = texture2D( soilDetail, soilAt * 0.031 + vec2( 0.13, 0.71 ) ).a;
+ float cobble = clamp( ( sampledDiffuseColor.a - 0.53 ) * 2.2, 0.0, 1.0 );
+ float gravel = clamp( ( 0.47 - sampledDiffuseColor.a ) * 2.2, 0.0, 1.0 );
+ float lum = dot( sampledDiffuseColor.rgb, vec3( 0.3, 0.59, 0.11 ) );
+ float plain = ( 1.0 - smoothstep( 0.02, 0.1, abs( sampledDiffuseColor.a - 0.5 ) ) ) *
+ ( 1.0 - smoothstep( 0.1, 0.14, lum ) );
+ vec3 tone = mix( vec3( 0.86, 0.83, 0.8 ), soilPatch, plain );
+ vec3 soil = sampledDiffuseColor.rgb * mix( vec3( 1.0 ), tone, smoothstep( 0.34, 0.66, spots ) );
+ soil *= 0.82 + 0.36 * ( fine.r * 0.4 + broad * 0.6 );
+ soil *= mix( 1.0, 0.4 + 0.9 * fine.g, cobble );
+ soil *= mix( 1.0, 0.62 + 0.62 * fine.b, gravel );
+ diffuseColor.rgb *= soil;
+ #endif`
+ )
+ }
+ return material
+}
+
+// Las partes de una planta o piedra, listas para juntarse en una forma: sin uv,
+// con normales planas (el look de pocos poligonos), su color y `tint`, cuanto
+// toma del color de cada copia (el tronco 0, la copa 1). `shade` oscurece la
+// parte de abajo: una sombra propia barata.
+function plantPart(geometry, shade = 0, hex = 0xffffff, tint = 1) {
+ const flat = geometry.index ? geometry.toNonIndexed() : geometry
+ if (flat.attributes.uv) flat.deleteAttribute('uv')
+ flat.computeVertexNormals()
+ flat.computeBoundingBox()
+ const { min, max } = flat.boundingBox
+ const span = max.y - min.y || 1
+ const position = flat.attributes.position
+ const color = new THREE.Color(hex)
+ const colors = new Float32Array(position.count * 3)
+ for (let i = 0; i < position.count; i++) {
+ const k = 1 - shade * (1 - (position.getY(i) - min.y) / span)
+ colors.set([color.r * k, color.g * k, color.b * k], i * 3)
+ }
+ flat.setAttribute('color', new THREE.BufferAttribute(colors, 3))
+ flat.setAttribute(
+ 'tint',
+ new THREE.BufferAttribute(new Float32Array(position.count).fill(tint), 1)
+ )
+ return flat
+}
+
+// Mueve cada vertice de un poliedro segun su lugar, siempre igual para el mismo
+// punto: asi las caras vecinas no se separan y la piedra sale irregular.
+function lumpy(geometry, amount, squash) {
+ const position = geometry.attributes.position
+ for (let i = 0; i < position.count; i++) {
+ const x = position.getX(i)
+ const y = position.getY(i)
+ const z = position.getZ(i)
+ const k =
+ 1 -
+ amount +
+ hash(Math.round(x * 997), Math.round(y * 991) * 7 + Math.round(z * 983)) * amount * 2
+ position.setXYZ(i, x * k, y * k * squash, z * k)
+ }
+ return geometry
+}
+
+// Pino, roble y alamo de pocos poligonos, de unos 3 de alto (el heroe mide 2,1).
+// El tronco no toma el color de la copia; la copa si, mas clara arriba.
+function treeGeometries() {
+ const gray = (k) => new THREE.Color(k, k, k)
+ const trunk = (r, h) => {
+ const bark = new THREE.CylinderGeometry(r * 0.65, r, h, 6, 1, true)
+ return plantPart(bark.translate(0, h / 2, 0), 0, 0x5b3d24, 0)
+ }
+ // Pino: tres conos, mas oscuros abajo. [radio, alto, base, gris]
+ const pine = [
+ [0.66, 1.3, 0.55, 0.78],
+ [0.52, 1.15, 1.25, 0.9],
+ [0.36, 1.0, 1.9, 1]
+ ].map(([r, h, y, k]) =>
+ plantPart(new THREE.ConeGeometry(r, h, 7).translate(0, y + h / 2, 0), 0.25, gray(k))
+ )
+ // Roble: una copa de cuatro bolas facetadas. [radio, x, y, z, gris]
+ const oak = [
+ [0.78, 0, 2, 0, 0.9],
+ [0.56, 0.44, 1.72, 0.2, 0.74],
+ [0.56, -0.4, 1.78, -0.22, 0.74],
+ [0.5, 0.06, 2.58, 0.04, 1]
+ ].map(([r, x, y, z, k]) =>
+ plantPart(new THREE.IcosahedronGeometry(r).translate(x, y, z), 0.3, gray(k))
+ )
+ // Alamo: una copa alta y angosta.
+ const poplar = new THREE.IcosahedronGeometry(0.5).scale(1, 2.1, 1).translate(0, 1.95, 0)
+ return [
+ [trunk(0.12, 0.9), ...pine],
+ [trunk(0.15, 1.5), ...oak],
+ [trunk(0.1, 0.9), plantPart(poplar, 0.45)]
+ ].map((parts) => mergeGeometries(parts))
+}
+
+// Una mata baja que no pasa del ancho de su celda (media unidad). [radio, x, y, z]
+function bushGeometry() {
+ const lumps = [
+ [0.22, 0, 0.15, 0],
+ [0.16, 0.08, 0.12, 0.1],
+ [0.16, -0.08, 0.11, -0.09]
+ ]
+ return mergeGeometries(
+ lumps.map(([r, x, y, z]) =>
+ plantPart(lumpy(new THREE.IcosahedronGeometry(r), 0.12, 0.8).translate(x, y, z), 0.45)
+ )
+ )
+}
+
+// Una piedra irregular con dos chicas delante y detras, a lo largo de su celda,
+// que mide media unidad de ancho y una de largo. [forma, deformacion, x, y, z]
+function rockGeometry() {
+ const parts = [
+ [new THREE.DodecahedronGeometry(0.2), 0.2, 0, 0.1, 0],
+ [new THREE.OctahedronGeometry(0.09), 0.25, 0.05, 0.03, 0.3],
+ [new THREE.OctahedronGeometry(0.08), 0.25, -0.04, 0.03, -0.3]
+ ]
+ return mergeGeometries(
+ parts.map(([shape, amount, x, y, z]) =>
+ plantPart(lumpy(shape, amount, 0.7).translate(x, y, z), 0.35)
+ )
+ )
+}
+
+// Una piedrita suelta, chata.
+function pebbleGeometry() {
+ return plantPart(
+ lumpy(new THREE.OctahedronGeometry(0.075), 0.3, 0.55).translate(0, 0.015, 0),
+ 0.3
+ )
+}
+
+// Una mata de pasto: hojas angostas que se abren desde el centro, oscuras al pie
+// y claras en la punta. Las normales miran al cielo, como el suelo, asi la mata
+// no se ve negra del lado de atras.
+function tuftGeometry() {
+ const position = []
+ const color = []
+ const foot = new THREE.Color(0x2c4a1c)
+ const tip = new THREE.Color(0x8db552)
+ const blades = 7
+ for (let k = 0; k < blades; k++) {
+ const angle = (k / blades) * Math.PI * 2 + hash(k, 1) * 0.7
+ const c = Math.cos(angle)
+ const s = Math.sin(angle)
+ const out = 0.04 + hash(k, 2) * 0.05
+ const lean = out + 0.1 + hash(k, 3) * 0.14
+ const tall = 0.32 + hash(k, 4) * 0.28
+ const half = 0.035 + hash(k, 5) * 0.015
+ position.push(c * out - s * half, 0, s * out + c * half)
+ position.push(c * out + s * half, 0, s * out - c * half)
+ position.push(c * lean, tall, s * lean)
+ color.push(foot.r, foot.g, foot.b, foot.r, foot.g, foot.b, tip.r, tip.g, tip.b)
+ }
+ const count = position.length / 3
+ const geometry = new THREE.BufferGeometry()
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(position, 3))
+ const up = new Float32Array(count * 3).map((_, i) => (i % 3 === 1 ? 1 : 0))
+ geometry.setAttribute('normal', new THREE.BufferAttribute(up, 3))
+ geometry.setAttribute('color', new THREE.Float32BufferAttribute(color, 3))
+ geometry.setAttribute('tint', new THREE.BufferAttribute(new Float32Array(count).fill(1), 1))
+ return geometry
+}
+
+// Una flor: tallo fino y una corola chata que mira al cielo, para que se lea
+// desde la camara alta. El tallo queda verde; la corola toma el color de la copia.
+function flowerGeometry() {
+ const stem = (turn) => {
+ const strip = new THREE.PlaneGeometry(0.035, 0.34).translate(0, 0.17, 0).rotateY(turn)
+ return plantPart(strip, 0, 0x3f6a2a, 0)
+ }
+ const corolla = new THREE.CircleGeometry(0.13, 6).rotateX(-Math.PI / 2).translate(0, 0.34, 0)
+ corolla.attributes.position.setY(0, 0.38)
+ return mergeGeometries([stem(0), stem(Math.PI / 2), plantPart(corolla)])
+}
+
+// Material de las plantas: el color de cada copia pesa segun `tint`, la punta se
+// mece con el viento (clock, en segundos) y con `glow` lo tenido brilla solo,
+// como las flores de NOX. Con dos caras, la normal no se da vuelta atras.
+function plantMaterial(clock, { sway = 0, glow = 0, side = THREE.FrontSide } = {}) {
+ const material = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.85, side })
+ material.onBeforeCompile = (shader) => {
+ shader.uniforms.clock = clock
+ shader.uniforms.sway = { value: sway }
+ shader.uniforms.glow = { value: glow }
+ shader.vertexShader = shader.vertexShader
+ .replace(
+ '#include ',
+ '#include \nattribute float tint;\nuniform float clock;\nuniform float sway;\nvarying float vTint;'
+ )
+ .replace(
+ '#include ',
+ `vColor = vec4( 1.0 );
+ vColor.rgb *= color;
+ #ifdef USE_INSTANCING_COLOR
+ vColor.rgb *= mix( vec3( 1.0 ), instanceColor.rgb, tint );
+ #endif
+ vTint = tint;`
+ )
+ .replace(
+ '#include ',
+ `vec4 mvPosition = vec4( transformed, 1.0 );
+ #ifdef USE_INSTANCING
+ mvPosition = instanceMatrix * mvPosition;
+ #endif
+ float bend = sway * transformed.y * transformed.y;
+ mvPosition.x += sin( clock * 1.9 + mvPosition.x * 0.7 + mvPosition.z * 0.45 ) * bend;
+ mvPosition.z += sin( clock * 1.4 + mvPosition.z * 0.6 - mvPosition.x * 0.3 ) * bend * 0.6;
+ mvPosition = modelViewMatrix * mvPosition;
+ gl_Position = projectionMatrix * mvPosition;`
+ )
+ shader.fragmentShader = shader.fragmentShader
+ .replace('#include ', '#include \nuniform float glow;\nvarying float vTint;')
+ .replace(
+ '#include ',
+ '#include \n#ifdef DOUBLE_SIDED\nnormal = normalize( vNormal );\n#endif'
+ )
+ .replace(
+ '#include ',
+ '#include \ntotalEmissiveRadiance += vColor.rgb * vTint * glow;'
+ )
+ }
+ return material
+}
+
+// Muchas copias de una forma. Con `zone` van en mallas por zona del mapa: la
+// camara ve pocas a la vez y three.js descarta las que quedan fuera de cuadro.
+// Cada item: [x, z, giro, escala, alto, color, variacion, inclinacion].
+function plantMeshes(geometry, material, items, { zone = 0, shadow = false } = {}) {
+ const zones = new Map()
+ for (const item of items) {
+ const key = zone ? Math.floor(item[0] / zone) * 4096 + Math.floor(item[1] / zone) : 0
+ if (!zones.has(key)) zones.set(key, [])
+ zones.get(key).push(item)
+ }
+ const m = new THREE.Matrix4()
+ const q = new THREE.Quaternion()
+ const e = new THREE.Euler()
+ const p = new THREE.Vector3()
+ const s = new THREE.Vector3()
+ const c = new THREE.Color()
+ return [...zones.values()].map((list) => {
+ const mesh = new THREE.InstancedMesh(geometry, material, list.length)
+ list.forEach(([x, z, yaw, size, tall, hex, vary, tilt = 0], i) => {
+ e.set(tilt * Math.cos(vary * 40), yaw, tilt * Math.sin(vary * 40))
+ mesh.setMatrixAt(
+ i,
+ m.compose(p.set(x, 0, z), q.setFromEuler(e), s.set(size, size * tall, size))
+ )
+ mesh.setColorAt(i, c.setHex(hex).offsetHSL((vary - 0.5) * 0.05, 0, (vary - 0.5) * 0.1))
+ })
+ mesh.castShadow = shadow
+ mesh.receiveShadow = true
+ return mesh
+ })
+}
+
+// Agua y lava: una superficie por liquido sobre los bloques chatos de la tercera
+// pasada, con un cuadro por celda. `shore` vale 1 en las esquinas que tocan
+// tierra: ahi el agua es baja y la lava se enfria. En el borde baja un faldon
+// corto que tapa el costado del bloque de abajo.
+function liquidMesh(data, kinds, kind, y, material) {
+ const { width, height } = data
+ const wet = (x, z) => x >= 0 && z >= 0 && x < width && z < height && kinds[z * width + x] === kind
+ const dry = (x, z) => (wet(x - 1, z - 1) && wet(x, z - 1) && wet(x - 1, z) && wet(x, z) ? 0 : 1)
+ const position = []
+ const normal = []
+ const shore = []
+ // Un cuadro de cuatro esquinas (a b c d, en dos triangulos a b c y c b d) con su
+ // normal y la orilla de cada esquina.
+ const quad = (corners, n, s) => {
+ for (const k of [0, 1, 2, 2, 1, 3]) {
+ position.push(corners[k * 3], corners[k * 3 + 1], corners[k * 3 + 2])
+ normal.push(...n)
+ shore.push(s[k])
+ }
+ }
+ // El faldon de un borde seco, de a hacia b mirando hacia afuera (n), apenas
+ // corrido para quedar delante del bloque de abajo.
+ const low = -0.06
+ const wall = (ax, az, bx, bz, n) => {
+ const [ox, oz] = [n[0] * 0.004, n[2] * 0.004]
+ const corners = [
+ ax + ox,
+ y,
+ az + oz,
+ ax + ox,
+ low,
+ az + oz,
+ bx + ox,
+ y,
+ bz + oz,
+ bx + ox,
+ low,
+ bz + oz
+ ]
+ quad(corners, n, [1, 1, 1, 1])
+ }
+ for (let z = 0; z < height; z++) {
+ for (let x = 0; x < width; x++) {
+ if (!wet(x, z)) continue
+ const [x0, x1, z0, z1] = [x * CX, (x + 1) * CX, z * CZ, (z + 1) * CZ]
+ const s = [dry(x, z), dry(x, z + 1), dry(x + 1, z), dry(x + 1, z + 1)]
+ quad([x0, y, z0, x0, y, z1, x1, y, z0, x1, y, z1], [0, 1, 0], s)
+ if (!wet(x, z + 1)) wall(x0, z1, x1, z1, [0, 0, 1])
+ if (!wet(x, z - 1)) wall(x1, z0, x0, z0, [0, 0, -1])
+ if (!wet(x - 1, z)) wall(x0, z0, x0, z1, [-1, 0, 0])
+ if (!wet(x + 1, z)) wall(x1, z1, x1, z0, [1, 0, 0])
+ }
+ }
+ if (!position.length) return null
+ const geometry = new THREE.BufferGeometry()
+ geometry.setAttribute('position', new THREE.Float32BufferAttribute(position, 3))
+ geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normal, 3))
+ geometry.setAttribute('shore', new THREE.Float32BufferAttribute(shore, 1))
+ const mesh = new THREE.Mesh(geometry, material)
+ mesh.receiveShadow = true
+ return mesh
+}
+
+// Un liquido que se mueve: dos capas del grano grueso que corren en direcciones
+// distintas. `paint` es GLSL que usa wave (0..2) y shore (0..1) para cambiar
+// diffuseColor y dejar la luz propia en `liquidGlow`.
+function liquidMaterial(clock, options, paint) {
+ const material = new THREE.MeshStandardMaterial(options)
+ material.onBeforeCompile = (shader) => {
+ shader.uniforms.clock = clock
+ shader.uniforms.soilDetail = { value: soilDetail() }
+ shader.vertexShader = shader.vertexShader
+ .replace(
+ '#include ',
+ '#include \nattribute float shore;\nvarying float vShore;\nvarying vec2 vLiquid;'
+ )
+ .replace(
+ '#include ',
+ '#include \nvShore = shore;\nvLiquid = position.xz;'
+ )
+ shader.fragmentShader = shader.fragmentShader
+ .replace(
+ '#include ',
+ '#include \nuniform float clock;\nuniform sampler2D soilDetail;\nvarying float vShore;\nvarying vec2 vLiquid;'
+ )
+ .replace(
+ '#include ',
+ `#include
+ float waveA = texture2D( soilDetail, vLiquid * 0.21 + vec2( 0.031, 0.017 ) * clock ).a;
+ float waveB = texture2D( soilDetail, vLiquid * 0.27 - vec2( 0.023, -0.027 ) * clock ).a;
+ float wave = waveA + waveB;
+ float shore = vShore;
+ vec3 liquidGlow = vec3( 0.0 );
+ ${paint}`
+ )
+ .replace(
+ '#include ',
+ '#include \ntotalEmissiveRadiance += liquidGlow;'
+ )
+ }
+ return material
+}
+
+// Agua: honda en el medio y mas clara en la orilla, con ondas suaves y algunos
+// brillos chicos que corren con el viento.
+const WATER = `
+ diffuseColor.rgb *= 0.92 + 0.2 * ( wave - 1.0 );
+ diffuseColor.rgb = mix( diffuseColor.rgb, vec3( 0.2, 0.36, 0.38 ), smoothstep( 0.55, 1.0, shore ) * 0.6 );
+ float glint = texture2D( soilDetail, vLiquid * 0.37 + vec2( 0.05, -0.03 ) * clock ).b;
+ glint *= texture2D( soilDetail, vLiquid * 0.29 - vec2( 0.04, 0.035 ) * clock ).b;
+ liquidGlow = vec3( 0.75, 0.9, 1.0 ) * smoothstep( 0.62, 0.8, glint ) * ( 1.0 - shore ) * 0.9;
+`
+// Lava: placas de costra oscura (las piedras del canal G, grandes y torcidas por
+// el oleaje) que derivan despacio; entre ellas y en los charcos la roca brilla.
+// En la orilla la costra se enfria y apenas reluce.
+const LAVA = `
+ vec2 drift = vLiquid * 0.2 + vec2( 0.019, 0.011 ) * clock + ( vec2( waveA, waveB ) - 0.5 ) * 0.3;
+ float plate = texture2D( soilDetail, drift ).g;
+ float molten = max( 1.0 - smoothstep( 0.24, 0.44, plate ), smoothstep( 1.2, 1.4, wave ) );
+ molten *= 1.0 - 0.75 * smoothstep( 0.4, 1.0, shore );
+ diffuseColor.rgb *= ( 0.4 + 0.5 * plate ) * ( 1.0 - molten );
+ float flicker = 0.9 + 0.1 * sin( clock * 1.7 + vLiquid.x * 0.35 + vLiquid.y * 0.2 );
+ liquidGlow = mix( vec3( 0.22, 0.05, 0.01 ), vec3( 1.6, 0.62, 0.14 ), molten ) * flicker;
+`
+
+// Arboles, matas, piedras, flores, pasto y piedritas. Lo que se ve solido
+// (arbol, mata, piedra) va solo en celdas que no se pisan; en las que se pisan,
+// solo pasto, flores y piedritas. Las que se repiten mucho van por zonas.
+function natureMeshes(data, kinds, clock) {
+ const { width, height, rows } = data
+ const boss = data.kind === 'boss'
+ const night = data.id === 'nox'
+ const leaves = LEAVES[data.id] || LEAVES.city
+ const blooms = BLOOM[data.id] || BLOOM.city
+ const grassTone = night ? 0x8ab8b4 : 0xffffff
+ const stones = boss ? [0x2a2420, 0x3b332e, 0x4a2a1e] : [0xa39c8f, 0x857e73, 0xb8b0a0, 0x6f695f]
+ const trees = [[], [], []]
+ const bushes = []
+ const rocks = []
+ const flowers = []
+ const tufts = []
+ const pebbles = []
+ const inside = (x, y) => x >= 0 && y >= 0 && x < width && y < height
+ // Lo que no es estructura deja ver: un arbol o una piedra rodeados de edificio
+ // quedan tapados y no se dibujan.
+ const clear = (x, y) => inside(x, y) && !STRUCTURE.has(kinds[y * width + x])
+ const land = (x, y) => inside(x, y) && !kinds[y * width + x]
+ // El numero al azar k de la celda, siempre el mismo.
+ const r = (x, y, k) => hash(x * 31 + k * 7919, y * 17 - k * 104729)
+ const pick = (options, x, y, k) => options[Math.floor(r(x, y, k) * options.length)]
+ // Deja una copia en un punto al azar de la celda, sin tocar el borde: [x, z,
+ // giro, escala, alto, color, variacion, inclinacion].
+ const drop = (list, x, y, k, size, hex, tilt) =>
+ list.push([
+ (x + 0.5 + (r(x, y, k) - 0.5) * 0.68) * CX,
+ (y + 0.5 + (r(x, y, k + 50) - 0.5) * 0.84) * CZ,
+ r(x, y, k + 1) * 6.3,
+ size,
+ 1,
+ hex,
+ r(x, y, k + 2),
+ tilt
+ ])
+ for (let y = 0; y < height; y++) {
+ const row = rows[y] || ''
+ for (let x = 0; x < width; x++) {
+ const kind = kinds[y * width + x]
+ const ch = row[x] || ' '
+ const cx = (x + 0.5) * CX
+ const cz = (y + 0.5) * CZ
+ const spin = r(x, y, 4) * 6.3
+ const vary = r(x, y, 8)
+ if (kind === 'tree' || kind === 'rock') {
+ if (!clear(x + 1, y) && !clear(x - 1, y) && !clear(x, y + 1) && !clear(x, y - 1)) continue
+ const size = 0.85 + r(x, y, 5) * 0.35
+ if (kind === 'rock') {
+ // Girada de punta a punta nada mas: asi no se sale de su celda.
+ const [turn, hex] = [Math.round(spin / Math.PI) * Math.PI, night ? 0x6c6a7c : 0x857f76]
+ rocks.push([
+ cx,
+ cz,
+ turn + (vary - 0.5) * 0.4,
+ size * 0.9,
+ 0.8 + vary * 0.4,
+ hex,
+ vary,
+ 0.1
+ ])
+ continue
+ }
+ const species = r(x, y, 1) < 0.42 ? 0 : r(x, y, 1) < 0.8 ? 1 : 2
+ const [tx, tz] = [cx + (r(x, y, 2) - 0.5) * 0.08, cz + (r(x, y, 3) - 0.5) * 0.14]
+ const [tall, leaf] = [0.9 + r(x, y, 6) * 0.25, pick(leaves[species], x, y, 7)]
+ trees[species].push([tx, tz, spin, size, tall, leaf, vary, 0.05])
+ // Una mata al pie, del lado de la camara.
+ const bush = night ? 0x3b3f66 : 0x3f6e2e
+ bushes.push([tx, cz + 0.22, spin + 2, 0.8 + r(x, y, 11) * 0.2, 1, bush, vary])
+ } else if (kind === 'water') {
+ // Juncos en la orilla, dentro del agua y del lado de la tierra.
+ const side = land(x + 1, y) ? 1 : land(x - 1, y) ? -1 : 0
+ const shore = side ? 0 : land(x, y + 1) ? 1 : land(x, y - 1) ? -1 : 0
+ if ((side || shore) && r(x, y, 1) < 0.35) {
+ const [rx, rz] = [cx + side * CX * 0.3, cz + shore * CZ * 0.3]
+ tufts.push([rx, rz, spin, 0.9 + r(x, y, 3) * 0.3, 1.8, 0xd8d08a, vary, 0.05])
+ }
+ } else if (kind) continue
+ else if (boss) {
+ // En las ruinas: piedritas de ceniza y escoria.
+ const hex = pick(stones, x, y, 6)
+ if (r(x, y, 1) < 0.12) drop(pebbles, x, y, 2, 0.8 + r(x, y, 5) * 0.9, hex, 0.3)
+ } else if (ch === '*') {
+ for (let k = 0; k < 3; k++) {
+ const hex = pick(blooms, x, y, 40 + k)
+ drop(flowers, x, y, 10 + k * 3, 0.85 + r(x, y, 30 + k) * 0.45, hex, 0.12)
+ }
+ } else if (ch === '"') {
+ for (let k = 0; k < 3; k++) {
+ drop(tufts, x, y, 10 + k * 3, 1.1 + r(x, y, 30 + k) * 0.45, grassTone, 0.1)
+ }
+ } else if (ch === ',' || ch === '`') {
+ // Pasto: matas sueltas; en el suelo quieto, alguna de vez en cuando.
+ const [often, size] = ch === ',' ? [0.75, 0.6] : [0.03, 0.5]
+ if (r(x, y, 1) < often) drop(tufts, x, y, 2, size + r(x, y, 5) * 0.3, grassTone, 0.1)
+ } else if ((ch === '.' || ch === '%') && r(x, y, 1) < (ch === '.' ? 0.14 : 0.2)) {
+ drop(pebbles, x, y, 2, 0.7 + r(x, y, 5) * 0.9, pick(stones, x, y, 6), 0.3)
+ }
+ }
+ }
+ const leafy = plantMaterial(clock, { sway: 0.004 })
+ const stone = new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.92 })
+ const both = THREE.DoubleSide
+ const petals = plantMaterial(clock, { sway: 0.5, glow: night ? 0.9 : 0.12, side: both })
+ const blades = plantMaterial(clock, { sway: 0.25, side: both })
+ const [pine, oak, poplar] = treeGeometries()
+ const water = { color: 0x22607f, roughness: 0.25, metalness: 0.1, emissive: 0x082230 }
+ return [
+ ...plantMeshes(pine, leafy, trees[0], { shadow: true }),
+ ...plantMeshes(oak, leafy, trees[1], { shadow: true }),
+ ...plantMeshes(poplar, leafy, trees[2], { shadow: true }),
+ ...plantMeshes(bushGeometry(), plantMaterial(clock), bushes, { shadow: true }),
+ ...plantMeshes(rockGeometry(), stone, rocks, { shadow: true }),
+ ...plantMeshes(pebbleGeometry(), stone, pebbles, { zone: 48 }),
+ ...plantMeshes(flowerGeometry(), petals, flowers, { zone: 48 }),
+ ...plantMeshes(tuftGeometry(), blades, tufts, { zone: 48 }),
+ liquidMesh(data, kinds, 'water', 0.045, liquidMaterial(clock, water, WATER)),
+ liquidMesh(data, kinds, 'lava', 0.095, liquidMaterial(clock, { color: 0x3a1408 }, LAVA))
+ ].filter(Boolean)
+}
+
+// Pinta una geometria de un color, para juntar muchas en una sola malla.
+function paint(geometry, hex) {
+ const flat = geometry.index ? geometry.toNonIndexed() : geometry
+ flat.deleteAttribute('uv')
+ const c = new THREE.Color(hex)
+ const colors = new Float32Array(flat.attributes.position.count * 3)
+ for (let i = 0; i < colors.length; i += 3) colors.set([c.r, c.g, c.b], i)
+ flat.setAttribute('color', new THREE.BufferAttribute(colors, 3))
+ return flat
+}
+
+const UP = [0, 1, 0]
+const DOWN = [0, -1, 0]
+const mix = (p, q, s) => [
+ p[0] + (q[0] - p[0]) * s,
+ p[1] + (q[1] - p[1]) * s,
+ p[2] + (q[2] - p[2]) * s
+]
+
+// Un color cerca de otro: cada edificio tiene su tono sin salirse de la paleta.
+// k va de 0 a 1; hue y light dicen cuanto se puede mover el tono y la luz.
+function vary(hex, k, hue, light) {
+ const c = new THREE.Color(hex)
+ const hsl = c.getHSL({ h: 0, s: 0, l: 0 }, THREE.SRGBColorSpace)
+ const l = hsl.l + (hash(Math.floor(k * 9973), 31) - 0.5) * light
+ const h = hsl.h + (k - 0.5) * hue + 1
+ return c.setHSL(h, hsl.s, Math.min(0.92, Math.max(0.03, l)), THREE.SRGBColorSpace)
+}
+
+// Caras planas con color por vertice: todos los edificios de un mapa terminan
+// en una sola malla, o sea en un solo dibujado.
+function mason() {
+ const position = []
+ const normal = []
+ const color = []
+ const ab = new THREE.Vector3()
+ const ac = new THREE.Vector3()
+ // Un triangulo o un cuadrilatero convexo, de un color o de uno por punto. Con
+ // `out` la cara se da vuelta hasta mirar hacia ese lado: no importa el orden.
+ function face(points, tint, out) {
+ const [a, b, c, d] = points
+ ab.set(b[0] - a[0], b[1] - a[1], b[2] - a[2])
+ ac.set(c[0] - a[0], c[1] - a[1], c[2] - a[2])
+ ab.cross(ac)
+ if (ab.lengthSq() < 1e-10 && d) {
+ ab.set(c[0] - a[0], c[1] - a[1], c[2] - a[2])
+ ac.set(d[0] - a[0], d[1] - a[1], d[2] - a[2])
+ ab.cross(ac)
+ }
+ let list = points
+ let tints = Array.isArray(tint) ? tint : null
+ if (out && ab.x * out[0] + ab.y * out[1] + ab.z * out[2] < 0) {
+ list = [...points].reverse()
+ if (tints) tints = [...tints].reverse()
+ ab.negate()
+ }
+ ab.normalize()
+ for (const k of list.length === 4 ? [0, 1, 2, 0, 2, 3] : [0, 1, 2]) {
+ const p = list[k]
+ const t = tints ? tints[k] : tint
+ position.push(p[0], p[1], p[2])
+ normal.push(ab.x, ab.y, ab.z)
+ color.push(t.r, t.g, t.b)
+ }
+ }
+ // Caja alineada con los ejes. Sin piso: siempre apoya en algo. Cada esquina
+ // es un numero: el bit 1 elige x, el 2 la altura y el 4 z.
+ function box(x0, y0, z0, x1, y1, z1, tint) {
+ const corner = (i) => [i & 1 ? x1 : x0, i & 2 ? y1 : y0, i & 4 ? z1 : z0]
+ for (const quad of [
+ [4, 5, 7, 6],
+ [1, 0, 2, 3],
+ [5, 1, 3, 7],
+ [0, 4, 6, 2],
+ [6, 7, 3, 2]
+ ]) {
+ face(quad.map(corner), tint)
+ }
+ }
+ // Una viga de seccion cuadrada entre dos puntos: cumbreras, limatones y
+ // mensulas.
+ function beam(a, b, size, tint) {
+ const d = new THREE.Vector3(b[0] - a[0], b[1] - a[1], b[2] - a[2]).normalize()
+ const side = new THREE.Vector3(-d.z, 0, d.x)
+ if (side.lengthSq() < 1e-8) side.set(1, 0, 0)
+ side.setLength(size / 2)
+ const up = new THREE.Vector3().crossVectors(side, d).setLength(size / 2)
+ const turns = [
+ [-1, -1],
+ [1, -1],
+ [1, 1],
+ [-1, 1]
+ ]
+ const ring = (p) =>
+ turns.map(([s, u]) => [
+ p[0] + side.x * s + up.x * u,
+ p[1] + side.y * s + up.y * u,
+ p[2] + side.z * s + up.z * u
+ ])
+ const A = ring(a)
+ const B = ring(b)
+ for (let i = 0; i < 4; i++) {
+ const j = (i + 1) % 4
+ const s = turns[i][0] + turns[j][0]
+ const u = turns[i][1] + turns[j][1]
+ const o = [side.x * s + up.x * u, side.y * s + up.y * u, side.z * s + up.z * u]
+ face([A[i], A[j], B[j], B[i]], tint, o)
+ }
+ face(A, tint, [-d.x, -d.y, -d.z])
+ face(B, tint, [d.x, d.y, d.z])
+ }
+ // Una aguja de cuatro caras.
+ function spike(x, y, z, size, height, tint) {
+ const tip = [x, y + height, z]
+ const base = [
+ [x - size, y, z - size],
+ [x + size, y, z - size],
+ [x + size, y, z + size],
+ [x - size, y, z + size]
+ ]
+ base.forEach((p, i) => {
+ const q = base[(i + 1) % 4]
+ face([p, q, tip], tint, [(p[0] + q[0]) / 2 - x, 0, (p[2] + q[2]) / 2 - z])
+ })
+ }
+ // Un faldon por hiladas paralelas al alero, con una linea oscura abajo de
+ // cada una: de lejos se lee como tejas y no como un plano liso.
+ function slope(e0, e1, t1, t0, tint, seed) {
+ const run = Math.hypot(
+ (t0[0] + t1[0] - e0[0] - e1[0]) / 2,
+ (t0[1] + t1[1] - e0[1] - e1[1]) / 2,
+ (t0[2] + t1[2] - e0[2] - e1[2]) / 2
+ )
+ const rows = Math.max(2, Math.round(run / 0.8))
+ const line = tint.clone().multiplyScalar(0.55)
+ for (let i = 0; i < rows; i++) {
+ const s0 = i / rows
+ const s1 = (i + 1) / rows
+ const s2 = s0 + 0.2 / rows
+ face([mix(e0, t0, s0), mix(e1, t1, s0), mix(e1, t1, s2), mix(e0, t0, s2)], line, UP)
+ const row = tint.clone().multiplyScalar(0.9 + 0.16 * hash(seed, i))
+ face([mix(e0, t0, s2), mix(e1, t1, s2), mix(e1, t1, s1), mix(e0, t0, s1)], row, UP)
+ }
+ }
+ function geometry() {
+ const g = new THREE.BufferGeometry()
+ g.setAttribute('position', new THREE.Float32BufferAttribute(position, 3))
+ g.setAttribute('normal', new THREE.Float32BufferAttribute(normal, 3))
+ g.setAttribute('color', new THREE.Float32BufferAttribute(color, 3))
+ return g
+ }
+ return { face, box, beam, spike, slope, geometry, count: () => position.length / 3 }
+}
+
+// Techo con espesor sobre el rectangulo de las paredes: a cuatro aguas o a dos
+// aguas. El alero sale siguiendo la pendiente, el canto muestra el espesor y
+// arriba va la cumbrera. Devuelve la cumbrera y la altura del techo en cada
+// punto, para apoyar las chimeneas.
+function roof(m, spec) {
+ const { x0, x1, z0, z1, H, rise, hip, alongX, tint } = spec
+ const t = spec.thick || 0.3
+ const reach = spec.reach || 0.9
+ const cx = (x0 + x1) / 2
+ const cz = (z0 + z1) / 2
+ const hu = (alongX ? x1 - x0 : z1 - z0) / 2
+ const hv = (alongX ? z1 - z0 : x1 - x0) / 2
+ const P = (u, y, v) => (alongX ? [cx + u, y, cz + v] : [cx + v, y, cz + u])
+ const dir = (u, v) => (alongX ? [u, 0, v] : [v, 0, u])
+ // r: media cumbrera. En el de cuatro aguas los faldones de las puntas caen
+ // mas parados, asi el alero queda a la misma altura alrededor.
+ const r = hip ? Math.min(hu * 0.7, Math.max(0, hu - hv) + hu * (spec.ridge ?? 0.2)) : hu
+ const s = rise / hv
+ const ov = Math.min(reach, Math.max(0.2, 0.4 / s))
+ const drop = s * ov
+ const ou = hip ? (drop * (hu - r)) / rise : Math.min(reach, 0.45)
+ const U = hu + ou
+ const V = hv + ov
+ const ye = H + t - drop
+ const yr = H + t + rise
+ const faces = hip
+ ? [
+ [P(-U, ye, V), P(U, ye, V), P(r, yr, 0), P(-r, yr, 0)],
+ [P(U, ye, -V), P(-U, ye, -V), P(-r, yr, 0), P(r, yr, 0)],
+ [P(U, ye, V), P(U, ye, -V), P(r, yr, 0), P(r, yr, 0)],
+ [P(-U, ye, -V), P(-U, ye, V), P(-r, yr, 0), P(-r, yr, 0)]
+ ]
+ : [
+ [P(-U, ye, V), P(U, ye, V), P(U, yr, 0), P(-U, yr, 0)],
+ [P(U, ye, -V), P(-U, ye, -V), P(-U, yr, 0), P(U, yr, 0)]
+ ]
+ const under = tint.clone().multiplyScalar(0.4)
+ const low = (p) => [p[0], p[1] - t, p[2]]
+ faces.forEach(([e0, e1, t1, t0], i) => {
+ m.slope(e0, e1, t1, t0, tint, spec.seed + i)
+ m.face([low(e0), low(e1), low(t1), low(t0)], under, DOWN)
+ })
+ // El canto del techo: de lejos lo despega de la pared.
+ const rim = (a, b, o) => m.face([a, b, low(b), low(a)], spec.edge, o)
+ rim(P(-U, ye, V), P(U, ye, V), dir(0, 1))
+ rim(P(U, ye, -V), P(-U, ye, -V), dir(0, -1))
+ if (hip) {
+ rim(P(U, ye, V), P(U, ye, -V), dir(1, 0))
+ rim(P(-U, ye, -V), P(-U, ye, V), dir(-1, 0))
+ m.beam(P(-r - 0.15, yr + 0.04, 0), P(r + 0.15, yr + 0.04, 0), 0.36, spec.cap)
+ for (const [a, b] of [
+ [1, 1],
+ [1, -1],
+ [-1, 1],
+ [-1, -1]
+ ]) {
+ m.beam(P(a * r, yr, 0), P(a * U, ye, b * V), 0.26, spec.cap)
+ }
+ } else {
+ for (const k of [-1, 1]) {
+ rim(P(k * U, ye, V), P(k * U, yr, 0), dir(k, 0))
+ rim(P(k * U, yr, 0), P(k * U, ye, -V), dir(k, 0))
+ // El hastial: la pared en triangulo bajo el techo.
+ if (spec.wall) {
+ m.face([P(k * hu, H, -hv), P(k * hu, H, hv), P(k * hu, H + rise, 0)], spec.wall, dir(k, 0))
+ }
+ }
+ m.beam(P(-U - 0.08, yr + 0.04, 0), P(U + 0.08, yr + 0.04, 0), 0.36, spec.cap)
+ }
+ // Venas encendidas sobre la cumbrera, los limatones y los bordes del hastial:
+ // de lejos dibujan la silueta del techo.
+ if (spec.veins) {
+ const { m: glow, tint: vein } = spec.veins
+ const lines = hip
+ ? [-1, 1].flatMap((a) => [
+ [P(a * r, yr + 0.17, 0), P(a * U, ye + 0.17, V)],
+ [P(a * r, yr + 0.17, 0), P(a * U, ye + 0.17, -V)]
+ ])
+ : [-1, 1].flatMap((a) => [
+ [P(a * U, yr + 0.05, 0), P(a * U, ye + 0.05, V)],
+ [P(a * U, yr + 0.05, 0), P(a * U, ye + 0.05, -V)]
+ ])
+ lines.push([P(hip ? -r - 0.1 : -U, yr + 0.24, 0), P(hip ? r + 0.1 : U, yr + 0.24, 0)])
+ for (const [a, b] of lines) glow.beam(a, b, 0.07, vein)
+ }
+ // Cresteria: puntas de hierro a lo largo de la cumbrera.
+ const ridge = hip ? r : U
+ if (spec.crest) {
+ const count = Math.floor((2 * ridge) / 0.8)
+ for (let i = 0; i <= count; i++) {
+ const p = P(count ? -ridge + ((2 * ridge) / count) * i : 0, yr + 0.15, 0)
+ m.spike(p[0], p[1], p[2], 0.07, 0.6, spec.cap)
+ }
+ }
+ const height = (x, z) => {
+ const u = Math.abs(alongX ? x - cx : z - cz)
+ const v = Math.abs(alongX ? z - cz : x - cx)
+ const y = yr - ((yr - ye) / V) * v
+ return hip ? Math.min(y, yr - ((yr - ye) / (U - r)) * Math.max(0, u - r)) : y
+ }
+ return { top: yr, height, r, U, V, ends: [P(-ridge, yr, 0), P(ridge, yr, 0)] }
+}
+
+// Chimenea apoyada en el faldon, con su sombrerete oscuro.
+function chimney(m, x, z, roofAt, tint) {
+ const s = 0.45
+ const ys = [
+ roofAt(x - s, z - s),
+ roofAt(x + s, z - s),
+ roofAt(x + s, z + s),
+ roofAt(x - s, z + s)
+ ]
+ const top = Math.max(...ys) + 1.5
+ m.box(x - s, Math.min(...ys) - 0.4, z - s, x + s, top, z + s, tint)
+ const cap = tint.clone().multiplyScalar(0.4)
+ m.box(x - s - 0.12, top, z - s - 0.12, x + s + 0.12, top + 0.22, z + s + 0.12, cap)
+}
+
+// Una buhardilla sobre un faldon: el frente con su hastial, las mejillas en
+// triangulo y un techito a dos aguas que muere justo contra el techo grande.
+// at(u, y, d) lleva al mundo: u a lo largo del faldon, d desde el frente hacia
+// la cumbrera; slope es la pendiente del faldon.
+function dormer(m, spec) {
+ const { at, y, slope, rise, tint, cap, wall, out, side } = spec
+ const half = 0.75
+ const wide = half + 0.15
+ const high = 1.45
+ const t = 0.15
+ const yw = y + high
+ const yr = yw + t + rise
+ const eave = high / slope
+ const ridge = (yr - y) / slope
+ const lip = -0.2
+ m.face(
+ [at(-half, y - 0.2, 0), at(half, y - 0.2, 0), at(half, yw, 0), at(-half, yw, 0)],
+ wall,
+ out
+ )
+ m.face([at(-half, yw, 0), at(half, yw, 0), at(0, yr - t, 0)], wall, out)
+ for (const k of [-1, 1]) {
+ const cheek = [side[0] * k, 0, side[2] * k]
+ m.face([at(k * half, y, 0), at(k * half, yw, 0), at(k * half, yw, eave)], wall, cheek)
+ m.slope(
+ at(k * wide, yw, lip),
+ at(k * wide, yw, eave),
+ at(0, yr, ridge),
+ at(0, yr, lip),
+ tint,
+ spec.seed + k
+ )
+ m.face(
+ [at(k * wide, yw, lip), at(0, yr, lip), at(0, yr - t, lip), at(k * wide, yw - t, lip)],
+ cap,
+ out
+ )
+ }
+ m.beam(at(0, yr + 0.02, lip), at(0, yr + 0.02, ridge), 0.22, cap)
+}
+
+// Una casa: zocalo de piedra por bloques, paredes con bandas entre pisos y
+// esquineros, techo con alero, buhardillas y chimeneas, ventanas con marco y la
+// puerta en la fachada de su celda de puerta, metida en la pared para que la
+// celda se vea. opts cambia pisos, pendiente, techo, la corona (almenas en vez
+// de techo) y suma el porton grande, las torrecillas, el estandarte o el
+// campanario. Devuelve la altura mas alta.
+function house(site, rect, opts) {
+ const { m, look } = site
+ const rand = (i) => hash(opts.seed, i)
+ const { x0, x1, z0, z1 } = rect
+ const W = x1 - x0
+ const D = z1 - z0
+ const cx = (x0 + x1) / 2
+ const cz = (z0 + z1) / 2
+ const narrow = Math.min(W, D)
+ const tower = narrow < 4.5 && Math.max(W, D) < 8
+ const floors = opts.floors || (tower ? 3 : Math.min(3, Math.max(1, Math.round(narrow / 9))))
+ const F = look.floor
+ const base = 0.8
+ const H = base + floors * F
+ const wall = vary(look.wall, rand(1), 0.06, 0.16)
+ const stone = vary(look.stone, rand(2), 0.02, 0.1)
+ const trim = vary(look.trim, rand(3), 0.03, 0.08)
+ const family = opts.roof || look.roofs[Math.floor(rand(4) * look.roofs.length)]
+ const tile = vary(family, rand(5), 0.03, 0.12)
+ const cap = tile.clone().multiplyScalar(0.55)
+ const edge = vary(look.frame, rand(6), 0.02, 0.06)
+ const shade = (v) => wall.clone().multiplyScalar(0.68 + 0.32 * Math.min(1, v / H))
+ // Donde ira el campanario, si es una iglesia: ahi no van ventanas.
+ const belfry = opts.belfry ? { x0: x0 - 0.4, x1: x0 + 4.6, z0: z1 - 4.6, z1: z1 + 0.4 } : null
+ const sides = [
+ { id: 0, o: [cx, z1], u: [1, 0], n: [0, 1], len: W },
+ { id: 1, o: [cx, z0], u: [-1, 0], n: [0, -1], len: W },
+ { id: 2, o: [x1, cz], u: [0, -1], n: [1, 0], len: D },
+ { id: 3, o: [x0, cz], u: [0, 1], n: [-1, 0], len: D }
+ ]
+ // De coordenadas de fachada (a lo largo, altura, hacia afuera) al mundo.
+ const at = (s, u, v, n = 0) => [
+ s.o[0] + s.u[0] * u + s.n[0] * n,
+ v,
+ s.o[1] + s.u[1] * u + s.n[1] * n
+ ]
+ const out = (s) => [s.n[0], 0, s.n[1]]
+ const block = (mm, s, u0, u1, v0, v1, n0, n1, tint) => {
+ const a = at(s, u0, v0, n0)
+ const b = at(s, u1, v1, n1)
+ mm.box(
+ Math.min(a[0], b[0]),
+ v0,
+ Math.min(a[2], b[2]),
+ Math.max(a[0], b[0]),
+ v1,
+ Math.max(a[2], b[2]),
+ tint
+ )
+ }
+
+ // Paredes lisas, oscuras abajo y claras arriba; en los mapas de piedra, por
+ // hiladas de sillares trabados, cada uno de su tono.
+ const course = 1.05
+ const joint = 2.6
+ const rows = (a, b, fn) => {
+ for (let row = Math.floor(a / course); row * course < b - 1e-3; row++) {
+ fn(row, Math.max(a, row * course), Math.min(b, (row + 1) * course))
+ }
+ }
+ const ashlar = (s, row, u, v) => {
+ const k = Math.floor((u + (row % 2) * joint * 0.5) / joint) + 4096
+ return shade(v).multiplyScalar(0.84 + 0.26 * hash(k, row * 7 + s.id + opts.seed))
+ }
+ const panel = (s, u0, u1, v0, v1) => {
+ if (!look.masonry) {
+ m.face(
+ [at(s, u0, v0), at(s, u1, v0), at(s, u1, v1), at(s, u0, v1)],
+ [shade(v0), shade(v0), shade(v1), shade(v1)],
+ out(s)
+ )
+ return
+ }
+ rows(v0, v1, (row, a, b) => {
+ const shift = (row % 2) * joint * 0.5
+ let p = u0
+ while (p < u1 - 1e-3) {
+ const next = (Math.floor((p + shift) / joint + 1e-6) + 1) * joint - shift
+ const q = Math.min(u1, next > p + 1e-3 ? next : p + joint)
+ const tone = ashlar(s, row, (p + q) / 2, (a + b) / 2)
+ m.face([at(s, p, a), at(s, q, a), at(s, q, b), at(s, p, b)], tone, out(s))
+ p = q
+ }
+ })
+ }
+ // La pared sobre el hueco de la puerta baja hasta el arco.
+ const above = (s, ua, va, ub, vb) => {
+ if (!look.masonry) {
+ m.face(
+ [at(s, ua, va), at(s, ub, vb), at(s, ub, H), at(s, ua, H)],
+ [shade(va), shade(vb), shade(H), shade(H)],
+ out(s)
+ )
+ return
+ }
+ const low = Math.max(va, vb)
+ const first = Math.min(H, (Math.floor(low / course) + 1) * course)
+ const mid = (ua + ub) / 2
+ m.face(
+ [at(s, ua, va), at(s, ub, vb), at(s, ub, first), at(s, ua, first)],
+ ashlar(s, Math.floor(low / course), mid, low),
+ out(s)
+ )
+ rows(first, H, (row, a, b) => {
+ const tone = ashlar(s, row, mid, (a + b) / 2)
+ m.face([at(s, ua, a), at(s, ub, a), at(s, ub, b), at(s, ua, b)], tone, out(s))
+ })
+ }
+
+ // La puerta: un hueco con arco o dintel, hondo, con la hoja de tablas al fondo.
+ // El porton de un castillo es mas ancho y sube dos pisos.
+ let door = null
+ if (opts.door) {
+ const s = sides[opts.door.side]
+ const half = opts.grand ? 1.5 : 0.95
+ const jamb = opts.grand ? 0.42 : 0.32
+ const room = s.len / 2 - half - jamb - 0.5
+ const along = (opts.door.x - s.o[0]) * s.u[0] + (opts.door.z - s.o[1]) * s.u[1]
+ if (room > 0) {
+ const style = look.arch[Math.floor(rand(7) * look.arch.length)]
+ const storeys = opts.grand && floors > 2 ? 2 : 1
+ const limit = floors > storeys ? base + storeys * F - 0.3 : H - 0.6
+ const k = style === 'pointed' ? Math.sqrt(3) : 1
+ const spring = style === 'flat' ? limit - jamb : limit - k * half - jamb
+ const u = Math.max(-room, Math.min(room, along))
+ // El borde del hueco: t va de 0 a 1 de un arranque al otro; grow lo agranda.
+ const curve = (t, grow) => {
+ if (style === 'flat') return [u - half - grow + 2 * (half + grow) * t, spring + grow]
+ if (style === 'round') {
+ const a = Math.PI * (1 - t)
+ return [u + (half + grow) * Math.cos(a), spring + (half + grow) * Math.sin(a)]
+ }
+ const left = t <= 0.5
+ const a = Math.PI - ((2 * Math.PI) / 3) * (left ? t : 1 - t)
+ const r = 2 * half + grow
+ const du = half + r * Math.cos(a)
+ return [u + (left ? du : -du), spring + r * Math.sin(a)]
+ }
+ // La altura del hueco a una distancia du del centro.
+ const heightAt = (du) => {
+ if (style === 'flat') return spring
+ if (style === 'round') return spring + Math.sqrt(Math.max(0, half * half - du * du))
+ return spring + Math.sqrt(Math.max(0, 4 * half * half - (Math.abs(du) + half) ** 2))
+ }
+ const top = style === 'flat' ? spring + jamb : curve(0.5, jamb)[1] + 0.16
+ door = { side: opts.door.side, u, half, jamb, spring, curve, heightAt, style, top }
+ }
+ }
+
+ // Paredes; en la de la puerta queda el hueco.
+ const N = 10
+ for (const s of sides) {
+ if (!door || door.side !== s.id) {
+ panel(s, -s.len / 2, s.len / 2, 0, H)
+ continue
+ }
+ panel(s, -s.len / 2, door.u - door.half, 0, H)
+ panel(s, door.u + door.half, s.len / 2, 0, H)
+ for (let i = 0; i < N; i++) {
+ const [ua, va] = door.curve(i / N, 0)
+ const [ub, vb] = door.curve((i + 1) / N, 0)
+ above(s, ua, va, ub, vb)
+ }
+ }
+
+ if (door) {
+ const s = sides[door.side]
+ const { u, half, jamb, spring, style } = door
+ const deep = 0.95
+ const jut = 0.18
+ const dark = shade(0).multiplyScalar(0.4)
+ const frame = look.vein ? edge : trim
+ // Los costados y el techo del hueco.
+ for (const k of [-1, 1]) {
+ const e = u + k * half
+ m.face([at(s, e, 0), at(s, e, 0, -deep), at(s, e, spring, -deep), at(s, e, spring)], dark, [
+ -k * s.u[0],
+ 0,
+ -k * s.u[1]
+ ])
+ }
+ for (let i = 0; i < N; i++) {
+ const [ua, va] = door.curve(i / N, 0)
+ const [ub, vb] = door.curve((i + 1) / N, 0)
+ const du = u - (ua + ub) / 2
+ const dv = spring - (va + vb) / 2
+ const inward = style === 'flat' ? DOWN : [du * s.u[0], dv, du * s.u[1]]
+ m.face(
+ [at(s, ua, va), at(s, ub, vb), at(s, ub, vb, -deep), at(s, ua, va, -deep)],
+ dark,
+ inward
+ )
+ // El marco: el arco (o el dintel) que sobresale de la pared.
+ const [oa, ova] = door.curve(i / N, jamb)
+ const [ob, ovb] = door.curve((i + 1) / N, jamb)
+ m.face(
+ [at(s, ua, va, jut), at(s, ub, vb, jut), at(s, ob, ovb, jut), at(s, oa, ova, jut)],
+ frame,
+ out(s)
+ )
+ // En NOX el borde de adentro del arco brilla, como un portal.
+ if (look.vein) {
+ const [ga, gva] = door.curve(i / N, 0.08)
+ const [gb, gvb] = door.curve((i + 1) / N, 0.08)
+ const n = jut + 0.01
+ site.glow.face(
+ [at(s, ua, va, n), at(s, ub, vb, n), at(s, gb, gvb, n), at(s, ga, gva, n)],
+ site.vein,
+ out(s)
+ )
+ }
+ const ou = (oa + ob) / 2 - u
+ const outward = style === 'flat' ? UP : [ou * s.u[0], (ova + ovb) / 2 - spring, ou * s.u[1]]
+ m.face(
+ [at(s, oa, ova), at(s, ob, ovb), at(s, ob, ovb, jut), at(s, oa, ova, jut)],
+ frame,
+ outward
+ )
+ }
+ for (const k of [-1, 1]) {
+ const e = u + k * half
+ block(m, s, e, e + k * jamb, 0, spring, -0.05, jut, frame)
+ if (look.vein) block(site.glow, s, e, e + k * 0.08, 0, spring, jut, jut + 0.01, site.vein)
+ }
+ if (style !== 'flat') {
+ const apex = door.curve(0.5, 0)[1]
+ block(m, s, u - 0.2, u + 0.2, apex - 0.1, apex + jamb + 0.16, -0.05, jut + 0.07, frame)
+ }
+ // La hoja: tablas de dos tonos y flejes de hierro.
+ const leaf = vary(look.door, rand(8), 0.03, 0.12)
+ const planks = opts.grand ? 8 : 6
+ for (let i = 0; i < planks; i++) {
+ const a = u - half + (2 * half * i) / planks
+ const b = a + (2 * half) / planks
+ m.face(
+ [
+ at(s, a, 0, -deep),
+ at(s, b, 0, -deep),
+ at(s, b, door.heightAt(b - u), -deep),
+ at(s, a, door.heightAt(a - u), -deep)
+ ],
+ leaf.clone().multiplyScalar(i % 2 ? 0.82 : 1),
+ out(s)
+ )
+ }
+ for (const v of [spring * 0.26, spring * 0.68]) {
+ block(m, s, u - half, u + half, v, v + 0.1, -deep, -deep + 0.03, dark)
+ }
+ // Sobre la puerta de dintel, un tejadito con dos mensulas.
+ if (style === 'flat') {
+ const y = spring + jamb + 0.12
+ const a = at(s, u - 1.4, 0, -0.1)
+ const b = at(s, u + 1.4, 0, 0.95)
+ roof(m, {
+ x0: Math.min(a[0], b[0]),
+ x1: Math.max(a[0], b[0]),
+ z0: Math.min(a[2], b[2]),
+ z1: Math.max(a[2], b[2]),
+ H: y,
+ rise: 0.5,
+ hip: false,
+ alongX: s.n[0] !== 0,
+ tint: tile,
+ cap,
+ edge,
+ reach: 0.25,
+ thick: 0.16,
+ seed: opts.seed + 9
+ })
+ for (const k of [-1, 1]) {
+ m.beam(at(s, u + k * 1.2, y - 0.8, 0), at(s, u + k * 1.2, y, 0.85), 0.14, trim)
+ }
+ }
+ }
+
+ // Zocalo de piedra: bloques de distinto tono a lo largo de la base.
+ const plinth = (s, from, to) => {
+ const front = 0.2
+ const ledge = stone.clone().multiplyScalar(0.92)
+ m.face(
+ [
+ at(s, from, base, -0.3),
+ at(s, to, base, -0.3),
+ at(s, to, base, front),
+ at(s, from, base, front)
+ ],
+ ledge,
+ UP
+ )
+ for (const [e, sign] of [
+ [from, -1],
+ [to, 1]
+ ]) {
+ m.face(
+ [at(s, e, 0, -0.3), at(s, e, 0, front), at(s, e, base, front), at(s, e, base, -0.3)],
+ stone,
+ [sign * s.u[0], 0, sign * s.u[1]]
+ )
+ }
+ let a = from
+ let i = s.id * 97
+ while (a < to - 0.02) {
+ const b = Math.min(to, a + 1.2 + rand(i) * 0.9)
+ const tone = stone.clone().multiplyScalar(0.8 + 0.34 * rand(300 + i))
+ m.face(
+ [at(s, a, 0, front), at(s, b, 0, front), at(s, b, base, front), at(s, a, base, front)],
+ tone,
+ out(s)
+ )
+ a = b
+ i++
+ }
+ }
+ for (const s of sides) {
+ const end = s.id < 2 ? s.len / 2 + 0.26 : s.len / 2 - 0.3
+ if (door && door.side === s.id) {
+ plinth(s, -end, door.u - door.half - door.jamb)
+ plinth(s, door.u + door.half + door.jamb, end)
+ } else plinth(s, -end, end)
+ }
+
+ // Esquineros y bandas entre pisos: de lejos dibujan las aristas y los pisos.
+ const post = 0.3
+ const postTop = look.spire ? H + 1.2 : H - 0.3
+ const corners = [
+ [x0, z0],
+ [x1, z0],
+ [x1, z1],
+ [x0, z1]
+ ]
+ for (const [x, z] of corners) {
+ m.box(x - post, base, z - post, x + post, postTop, z + post, trim)
+ if (look.spire) m.spike(x, postTop, z, post + 0.05, 1.3, trim)
+ }
+ for (const s of sides) {
+ const end = s.id < 2 ? s.len / 2 + 0.17 : s.len / 2
+ for (let f = 1; f < floors; f++) {
+ const v = base + f * F
+ const cut = door && door.side === s.id && door.top > v - 0.12
+ const pieces = cut
+ ? [
+ [-s.len / 2, door.u - door.half - door.jamb],
+ [door.u + door.half + door.jamb, s.len / 2]
+ ]
+ : [[-s.len / 2, s.len / 2]]
+ for (const [a, b] of pieces) {
+ block(m, s, a, b, v - 0.12, v + 0.12, -0.05, 0.1, trim)
+ if (look.vein) block(site.glow, s, a, b, v - 0.03, v + 0.03, 0.1, 0.12, site.vein)
+ }
+ }
+ if (look.vein) block(site.glow, s, -s.len / 2, s.len / 2, base, base + 0.05, 0, 0.05, site.vein)
+ if (opts.crown !== 'battlement') block(m, s, -end, end, H - 0.34, H, -0.05, 0.17, trim)
+ }
+
+ // Ventanas por piso en las cuatro fachadas, parejas y lejos de las esquinas;
+ // se saltean las que pisarian la puerta.
+ const shutter = look.shutters
+ ? vary(look.shutters[Math.floor(rand(9) * look.shutters.length)], rand(10), 0.02, 0.1)
+ : null
+ const addWindow = (s, u, v, panels = shutter) => {
+ const p = at(s, u, v)
+ const hidden =
+ belfry &&
+ p[0] > belfry.x0 - 1.3 &&
+ p[0] < belfry.x1 + 1.3 &&
+ p[2] > belfry.z0 - 1.3 &&
+ p[2] < belfry.z1 + 1.3
+ if (!hidden) site.windows.push([p[0], v, p[2], Math.atan2(s.n[0], s.n[1]), panels])
+ }
+ const columns = (s) => {
+ const span = s.len - 2.4
+ if (s.len < 2) return []
+ if (span < 0 || opts.windows === 'single') return [0]
+ const count = Math.floor(span / 3.2) + 1
+ if (count < 2) return [0]
+ return Array.from({ length: count }, (_, i) => -span / 2 + (span / (count - 1)) * i)
+ }
+ if (opts.windows !== false) {
+ for (const s of sides) {
+ for (let f = 0; f < floors; f++) {
+ const v = base + f * F + 1.45
+ for (const u of columns(s)) {
+ const blocked =
+ door &&
+ door.side === s.id &&
+ Math.abs(u - door.u) < door.half + door.jamb + 0.75 &&
+ v - 0.7 < door.top
+ if (blocked) continue
+ addWindow(s, u, v)
+ }
+ }
+ }
+ }
+ // Entramado: en algunas casas de la ciudad, pies derechos de madera entre las
+ // ventanas de los pisos de arriba.
+ if (look.timber && rand(30) < 0.6 && opts.windows !== false) {
+ for (const s of sides) {
+ const us = columns(s)
+ const studs = us.slice(1).map((u, i) => (u + us[i]) / 2)
+ for (let f = 1; f < floors; f++) {
+ const v0 = base + f * F + 0.12
+ const v1 = f === floors - 1 ? H - 0.34 : base + (f + 1) * F - 0.12
+ for (const u of studs) block(m, s, u - 0.09, u + 0.09, v0, v1, -0.05, 0.06, trim)
+ }
+ }
+ }
+
+ // Saeteras: rendijas oscuras en los muros sin ventanas.
+ if (opts.slits) {
+ const slit = shade(0).multiplyScalar(0.3)
+ for (const s of sides) {
+ const count = Math.floor((s.len - 2) / 3)
+ for (let i = 0; i < count; i++) {
+ const u = (i - (count - 1) / 2) * 3
+ const v0 = base + 1.6
+ const v1 = base + 2.9
+ m.face(
+ [
+ at(s, u - 0.1, v0, 0.02),
+ at(s, u + 0.1, v0, 0.02),
+ at(s, u + 0.1, v1, 0.02),
+ at(s, u - 0.1, v1, 0.02)
+ ],
+ slit,
+ out(s)
+ )
+ }
+ }
+ }
+
+ // Corona: almenas sobre un adarve, o el techo. En una muralla las almenas van
+ // solo del lado de afuera (opts.outer).
+ if (opts.crown === 'battlement') {
+ const walk = stone.clone().multiplyScalar(0.75)
+ m.face(
+ [
+ [x0, H, z1],
+ [x1, H, z1],
+ [x1, H, z0],
+ [x0, H, z0]
+ ],
+ walk,
+ UP
+ )
+ for (const s of sides) {
+ const end = s.id < 2 ? s.len / 2 + 0.1 : s.len / 2 - 0.5
+ block(m, s, -end, end, H - 0.3, H, -0.05, 0.14, trim)
+ if (opts.outer !== undefined && opts.outer !== s.id) continue
+ const count = Math.max(1, Math.round((2 * end) / 1.5))
+ for (let i = 0; i < count; i++) {
+ const a = -end + ((2 * end) / count) * i
+ block(m, s, a, a + 0.85, H, H + 0.9, -0.5, 0.1, shade(H))
+ }
+ }
+ return H + 0.9
+ }
+ const hip = tower || Math.max(W, D) / narrow < 1.3
+ const alongX = hip && !tower ? true : W >= D
+ const pitch = (opts.pitch || look.pitch) * (tower ? 2 : 1)
+ const hu = (alongX ? W : D) / 2
+ const hv = (alongX ? D : W) / 2
+ const rise = pitch * hv
+ const top = roof(m, {
+ x0,
+ x1,
+ z0,
+ z1,
+ H,
+ rise,
+ hip,
+ alongX,
+ tint: tile,
+ cap,
+ edge,
+ wall: shade(H),
+ crest: look.spire,
+ veins: look.vein ? { m: site.glow, tint: site.vein.clone().multiplyScalar(0.75) } : null,
+ ridge: opts.ridge ?? (tower ? 0.1 : 0.2),
+ seed: opts.seed
+ })
+ let peak = top.top
+ // En los hastiales, una ventana de altillo.
+ if (!hip && rise > 2.2 && opts.windows !== false) {
+ for (const s of alongX ? [sides[2], sides[3]] : [sides[0], sides[1]]) {
+ addWindow(s, 0, H + rise * 0.36)
+ }
+ }
+ // Buhardillas en los faldones que miran al sur y al norte, los que ve la
+ // camara: cortan el plano del techo y prenden una ventana mas.
+ const L = (u, v) => (alongX ? [cx + u, cz + v] : [cx + v, cz + u])
+ if (opts.dormers !== false && !tower && alongX && rise > 2.4) {
+ const vf = hv * 0.62
+ const lift = Math.max(0.5, pitch * 0.8)
+ const back = vf - 1.45 / pitch
+ const usable = (hip ? top.r + (top.U - top.r) * (back / top.V) : top.U) - 1.4
+ const fits = usable > 0 && vf - (1.6 + lift) / pitch > 0.2
+ const count = fits ? Math.min(3, Math.floor((2 * usable) / 3.4) + 1) : 0
+ for (const k of [-1, 1]) {
+ for (let i = 0; i < count; i++) {
+ const du = (i - (count - 1) / 2) * 3.4
+ const [fx, fz] = L(du, k * vf)
+ const y = top.height(fx, fz)
+ dormer(m, {
+ at: (u, h, d) => {
+ const [x, z] = L(du + u, k * (vf - d))
+ return [x, h, z]
+ },
+ y,
+ slope: pitch,
+ out: [0, 0, k],
+ side: [1, 0, 0],
+ rise: lift,
+ tint: tile,
+ cap,
+ wall: shade(H),
+ seed: opts.seed + 20 + i
+ })
+ site.windows.push([fx, y + 0.78, fz, k > 0 ? 0 : Math.PI, null])
+ }
+ }
+ }
+ // Chimeneas cerca de la cumbrera, lejos de las buhardillas.
+ const chance = opts.chimneys ?? look.chimneys
+ const chimneys = rand(11) < chance ? (W * D > 260 ? 2 : 1) : 0
+ for (let i = 0; i < chimneys; i++) {
+ const a = (i ? 1 : -1) * hu * (0.3 + 0.25 * rand(12 + i))
+ const b = (rand(14 + i) < 0.5 ? -1 : 1) * hv * (0.04 + 0.1 * rand(16 + i))
+ const [x, z] = L(a, b)
+ chimney(m, x, z, top.height, stone.clone().multiplyScalar(1.15))
+ }
+ if (look.spire) {
+ for (const p of top.ends) m.spike(p[0], p[1], p[2], 0.2, tower ? 2.6 : 1.8, trim)
+ peak += tower ? 2.6 : 1.8
+ }
+ // Un estandarte en la punta del techo.
+ if (opts.flag) {
+ const [x, y, z] = top.ends[1]
+ m.beam([x, y - 0.3, z], [x, y + 2.8, z], 0.12, edge)
+ const cloth = new THREE.Color(opts.flag)
+ const flag = [
+ [x + 0.06, y + 2.7, z],
+ [x + 1.8, y + 2.45, z],
+ [x + 1.8, y + 1.8, z],
+ [x + 0.06, y + 1.95, z]
+ ]
+ m.face(flag, cloth, [0, 0, 1])
+ m.face(flag, cloth.clone().multiplyScalar(0.7), [0, 0, -1])
+ peak = Math.max(peak, y + 2.8)
+ }
+ // Torrecillas en las esquinas, sobre mensulas y con su cono.
+ if (opts.turrets) {
+ const y = H - 2.6
+ for (const [x, z] of corners) {
+ const tx = x + (x === x0 ? -0.4 : 0.4)
+ const tz = z + (z === z0 ? -0.4 : 0.4)
+ m.box(tx - 1, y - 0.8, tz - 1, tx + 1, y, tz + 1, trim)
+ m.box(tx - 1.35, y, tz - 1.35, tx + 1.35, H + 1.6, tz + 1.35, shade(H))
+ m.spike(tx, H + 1.6, tz, 1.65, 3.6, tile)
+ }
+ peak = Math.max(peak, H + 5.2)
+ }
+ // El campanario de una iglesia: una torre de piedra con aguja en la esquina
+ // del frente, sin postigos y, en la ciudad, con un remate dorado.
+ if (belfry) {
+ const plain = { ...look, masonry: true, shutters: null, timber: false }
+ const spec = {
+ seed: opts.seed + 77,
+ floors: floors + 2,
+ pitch: 2.4,
+ ridge: 0,
+ chimneys: 0,
+ dormers: false,
+ windows: 'single'
+ }
+ const tip = house({ ...site, look: plain }, belfry, spec)
+ if (!look.spire) {
+ const x = (belfry.x0 + belfry.x1) / 2
+ const z = (belfry.z0 + belfry.z1) / 2
+ m.spike(x, tip - 0.1, z, 0.14, 1.6, new THREE.Color(look.glow))
+ }
+ peak = Math.max(peak, tip + 1.5)
+ }
+ return peak
+}
+
+// El castillo: torres en las esquinas, murallas almenadas entre ellas, el
+// porton grande sobre su celda de puerta y la torre del homenaje en el patio.
+function fortress(site, rect, opts) {
+ const { x0, x1, z0, z1 } = rect
+ const T = 7.5
+ const thick = 2.4
+ const ward = new THREE.Color(site.look.stone).multiplyScalar(0.7)
+ site.m.face(
+ [
+ [x0, 0.03, z1],
+ [x1, 0.03, z1],
+ [x1, 0.03, z0],
+ [x0, 0.03, z0]
+ ],
+ ward,
+ UP
+ )
+ // Todo de sillares, con arcos redondos y sin postigos de colores.
+ const look = {
+ ...site.look,
+ wall: site.look.keep || site.look.wall,
+ trim: site.look.stone,
+ arch: ['round'],
+ shutters: null,
+ timber: false,
+ masonry: true
+ }
+ const castle = { ...site, look }
+ let top = 0
+ let parts = 0
+ const raise = (r, more) => {
+ const seed = opts.seed + ++parts * 7919
+ top = Math.max(top, house(castle, r, { seed, ...more }))
+ }
+ const tower = {
+ floors: 3,
+ pitch: 1.5,
+ ridge: 0,
+ chimneys: 0,
+ dormers: false,
+ windows: 'single',
+ roof: site.look.slate,
+ flag: site.look.banner || site.look.glow
+ }
+ for (const [x, z] of [
+ [x0, z0],
+ [x1 - T, z0],
+ [x0, z1 - T],
+ [x1 - T, z1 - T]
+ ]) {
+ raise({ x0: x, x1: x + T, z0: z, z1: z + T }, tower)
+ }
+ // Las murallas, cortadas donde va el porton.
+ const gate = opts.door
+ const curtains = [
+ [0, { x0: x0 + T, x1: x1 - T, z0: z1 - thick, z1 }],
+ [1, { x0: x0 + T, x1: x1 - T, z0, z1: z0 + thick }],
+ [2, { x0: x1 - thick, x1, z0: z0 + T, z1: z1 - T }],
+ [3, { x0, x1: x0 + thick, z0: z0 + T, z1: z1 - T }]
+ ]
+ for (const [side, r] of curtains) {
+ const curtain = {
+ floors: 2,
+ crown: 'battlement',
+ outer: side,
+ windows: false,
+ chimneys: 0,
+ slits: true
+ }
+ if (!gate || gate.side !== side) {
+ raise(r, curtain)
+ continue
+ }
+ const alongX = side < 2
+ const at = alongX ? gate.x : gate.z
+ const [lo, hi] = alongX ? [r.x0, r.x1] : [r.z0, r.z1]
+ const g0 = Math.max(lo + 1, at - 3.5)
+ const g1 = Math.min(hi - 1, at + 3.5)
+ const piece = (a, b) => (alongX ? { ...r, x0: a, x1: b } : { ...r, z0: a, z1: b })
+ if (g0 - lo > 1) raise(piece(lo, g0), curtain)
+ if (hi - g1 > 1) raise(piece(g1, hi), curtain)
+ // El porton entra en el patio mas que la muralla.
+ const deep = thick + 1.6
+ const gr = alongX
+ ? { x0: g0, x1: g1, z0: side === 0 ? z1 - deep : z0, z1: side === 0 ? z1 : z0 + deep }
+ : { x0: side === 2 ? x1 - deep : x0, x1: side === 2 ? x1 : x0 + deep, z0: g0, z1: g1 }
+ raise(gr, { floors: 3, crown: 'battlement', chimneys: 0, door: gate, grand: true })
+ }
+ // La torre del homenaje, en el medio del patio y un poco hacia atras.
+ const mx = (x1 - x0) * 0.27
+ raise(
+ { x0: x0 + mx, x1: x1 - mx, z0: z0 + (z1 - z0) * 0.2, z1: z1 - (z1 - z0) * 0.34 },
+ {
+ floors: 3,
+ chimneys: 1,
+ roof: site.look.roofs[0],
+ turrets: true,
+ door: { side: 0, x: (x0 + x1) / 2, z: z1 }
+ }
+ )
+ return top
+}
+
+// Donde va la puerta de un edificio: su celda de puerta en el borde, o la de un
+// porton. Si no tiene, el hueco por donde entra el camino en la fila de abajo,
+// entre dos celdas del edificio; y si tampoco, el medio de la fachada sur.
+// side: 0 sur, 1 norte, 2 este, 3 oeste.
+function doorway(box, doors, tiles, kinds, width) {
+ for (const prefix of ['door.', 'gate.']) {
+ for (const d of doors) {
+ const tile = tiles[d.ch]
+ if (!tile || !tile.id.startsWith(prefix)) continue
+ if (d.x < box.x0 - 1 || d.x > box.x1 + 1 || d.y < box.y0 - 1 || d.y > box.y1 + 1) continue
+ const gaps = [
+ (box.y1 - d.y) * CZ,
+ (d.y - box.y0) * CZ,
+ (box.x1 - d.x) * CX,
+ (d.x - box.x0) * CX
+ ]
+ const side = gaps.indexOf(Math.min(...gaps))
+ if (Math.abs(gaps[side]) <= 1) return { side, x: (d.x + 0.5) * CX, z: (d.y + 0.5) * CZ }
+ }
+ }
+ const z = (box.y1 + 0.5) * CZ
+ let start = -1
+ for (let x = box.x0; x <= box.x1; x++) {
+ const open = !STRUCTURE.has(kinds[box.y1 * width + x])
+ if (open && start < 0) start = x
+ if (!open && start >= 0) {
+ if (start > box.x0 && x - start <= 9) return { side: 0, x: ((start + x) / 2) * CX, z }
+ start = -1
+ }
+ }
+ return { side: 0, x: ((box.x0 + box.x1 + 1) / 2) * CX, z }
+}
+
+// Las palabras dentro de un edificio: la mas larga es su nombre.
+function nameOf(rows, box) {
+ let best = ''
+ for (let y = box.y0; y <= box.y1; y++) {
+ const words = (rows[y] || '').slice(box.x0, box.x1 + 1).match(/[a-z]+( [a-z]+)*/g) || []
+ for (const word of words) if (word.length > best.length) best = word
+ }
+ return best.length >= 3 ? best : ''
+}
+
+// NPC pintados: un PNG por tipo en assets/npcs, recortado de una hoja hecha con
+// Higgsfield en el estilo del heroe de Tripo. Los tipos salen de NPC_SPRITES
+// (lib/map.js); uno sin dibujo usa la figura de la pagina.
+const NPC_ART = new Set([
+ 'priest',
+ 'resident',
+ 'tavern',
+ 'alchemist',
+ 'smith',
+ 'armorer',
+ 'guard',
+ 'king',
+ 'villager'
+])
+// La figura mediana mide 256 px y 1,8 en el mundo. El cartel no se achica al
+// mirar desde arriba: asi se ve de la altura del heroe con la camara 2.5D.
+const NPC_PX = 1.8 / 256
+
+// Una textura y un material por tipo, compartidos por todos los mapas. Si el PNG
+// no llega, se olvida para volver a pedirlo en el proximo mapa.
+const npcLooks = new Map()
+function npcLook(kind) {
+ if (!npcLooks.has(kind)) {
+ const look = new THREE.TextureLoader()
+ .loadAsync('assets/npcs/' + kind + '.png')
+ .then((texture) => {
+ texture.colorSpace = THREE.SRGBColorSpace
+ const material = new THREE.SpriteMaterial({ map: texture, alphaTest: 0.05 })
+ material.onBeforeCompile = npcShader
+ return material
+ })
+ .catch(() => {
+ npcLooks.delete(kind)
+ return null
+ })
+ npcLooks.set(kind, look)
+ }
+ return npcLooks.get(kind)
+}
+
+// Un cartel que mira a la camara queda entero a la profundidad de sus pies: desde
+// arriba se hundiria en la pared de atras y el heroe que pasa detras le taparia
+// la cabeza. Cada vertice se acerca a la camara por su propio rayo segun su
+// altura en el cartel, como una figura parada; en pantalla se ve igual.
+// Ademas la textura se lee medio nivel de mipmap mas nitida: de lejos el NPC
+// mide unos 60 px y el filtro lo borronea.
+function npcShader(shader) {
+ shader.vertexShader = shader.vertexShader.replace(
+ 'gl_Position = projectionMatrix * mvPosition;',
+ 'mvPosition.xyz *= 1.0 - alignedPosition.y * 0.9 / length( mvPosition.xyz );\n' +
+ 'gl_Position = projectionMatrix * mvPosition;'
+ )
+ shader.fragmentShader = shader.fragmentShader.replace(
+ '#include ',
+ 'diffuseColor *= texture2D( map, vMapUv, -0.5 );'
+ )
+}
+
+// La sombra blanda al pie de cada NPC, para que no flote.
+function blobTexture() {
+ const canvas = document.createElement('canvas')
+ canvas.width = 64
+ canvas.height = 64
+ const g = canvas.getContext('2d')
+ const shade = g.createRadialGradient(32, 32, 0, 32, 32, 32)
+ shade.addColorStop(0, 'rgba(0, 0, 0, 0.55)')
+ shade.addColorStop(0.6, 'rgba(0, 0, 0, 0.3)')
+ shade.addColorStop(1, 'rgba(0, 0, 0, 0)')
+ g.fillStyle = shade
+ g.fillRect(0, 0, 64, 64)
+ return new THREE.CanvasTexture(canvas)
+}
+
+/**
+ * Arma el mapa. `data` es /world/.json. `extras` trae lo que vive en la
+ * pagina: figure(color) para los NPC y glb(file) para los modelos de Tripo.
+ */
+export function buildWorld(data, extras = {}) {
+ const { width, height, rows, tiles } = data
+ const palette = PALETTES[data.id] || PALETTES.city
+ const group = new THREE.Group()
+ const toWorld = (x, y) => new THREE.Vector3(x * CX + CX / 2, 0, y * CZ + CZ / 2)
+
+ // Primera pasada: que es cada celda y donde van las puertas.
+ const kinds = new Array(width * height).fill(null)
+ const doors = []
+ for (let y = 0; y < height; y++) {
+ const row = rows[y] || ''
+ for (let x = 0; x < width; x++) {
+ const ch = row[x] || ' '
+ const tile = tiles[ch]
+ const word = data.kind !== 'boss' && (ch === 'o' || ch === 't') && inWord(row, x)
+ const kind = word ? 'sign' : classify(ch, tile, data.kind)
+ kinds[y * width + x] = kind
+ if (kind === 'door') doors.push({ x, y, ch, name: tile ? tile.name : ch })
+ }
+ }
+ // El suelo se lee como terreno y no como una grilla de cuadritos: varios
+ // texeles por celda, manchas, bordes que se funden y sombra al pie de lo alto.
+ // El shader le suma el grano fino y el dibujo de adoquines y grava.
+ const clock = { value: 0 }
+ const ground = new THREE.Mesh(
+ new THREE.PlaneGeometry(width * CX, height * CZ),
+ soilMaterial(soilTexture(data, kinds), data, width * CX, height * CZ)
+ )
+ ground.rotation.x = -Math.PI / 2
+ ground.position.set((width * CX) / 2, 0, (height * CZ) / 2)
+ ground.receiveShadow = true
+ // El viento en el pasto y las olas del agua y la lava siguen al reloj en cada
+ // cuadro en que se dibuja el suelo: no hace falta que la pagina lo llame.
+ ground.onBeforeRender = () => {
+ clock.value = globalThis.performance.now() / 1000
+ }
+ group.add(ground, soilSkirt(data))
+
+ // Segunda pasada: edificios. Un grupo cerrado y lleno de celdas de estructura
+ // que no toca el borde del mapa es un edificio.
+ const inBuilding = new Uint8Array(width * height)
+ const seen = new Uint8Array(width * height)
+ const buildings = []
+ for (let start = 0; start < kinds.length; start++) {
+ if (seen[start] || !STRUCTURE.has(kinds[start])) continue
+ const stack = [start]
+ const cells = []
+ seen[start] = 1
+ const box = { x0: width, y0: height, x1: 0, y1: 0 }
+ while (stack.length) {
+ const i = stack.pop()
+ cells.push(i)
+ const x = i % width
+ const y = (i - x) / width
+ box.x0 = Math.min(box.x0, x)
+ box.x1 = Math.max(box.x1, x)
+ box.y0 = Math.min(box.y0, y)
+ box.y1 = Math.max(box.y1, y)
+ for (const [nx, ny] of [
+ [x + 1, y],
+ [x - 1, y],
+ [x, y + 1],
+ [x, y - 1]
+ ]) {
+ if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue
+ const n = ny * width + nx
+ if (!seen[n] && STRUCTURE.has(kinds[n])) {
+ seen[n] = 1
+ stack.push(n)
+ }
+ }
+ }
+ const bw = box.x1 - box.x0 + 1
+ const bh = box.y1 - box.y0 + 1
+ const edge = box.x0 === 0 || box.y0 === 0 || box.x1 === width - 1 || box.y1 === height - 1
+ const fill = cells.length / (bw * bh)
+ if (!edge && cells.length >= 30 && bw >= 6 && bh >= 4 && fill >= 0.55) {
+ for (const i of cells) inBuilding[i] = 1
+ buildings.push(box)
+ }
+ }
+
+ // Los edificios van en una sola malla con color por vertice; las ventanas, sus
+ // marcos y los postigos se repiten con InstancedMesh. El mas grande del mapa,
+ // si es enorme, es un castillo.
+ const site = {
+ look: palette,
+ m: mason(),
+ glow: mason(),
+ vein: new THREE.Color(palette.vein || palette.glow),
+ windows: []
+ }
+ for (const box of buildings) {
+ const rect = {
+ x0: box.x0 * CX + 0.25,
+ x1: (box.x1 + 1) * CX - 0.25,
+ z0: box.y0 * CZ + 0.25,
+ z1: (box.y1 + 1) * CZ - 0.25
+ }
+ const name = nameOf(rows, box)
+ const opts = {
+ seed: box.x0 * 92821 + box.y0 * 68917,
+ door: doorway(box, doors, tiles, kinds, width),
+ belfry: /iglesia|santuario|templo|capilla/.test(name)
+ }
+ const big = Math.min(rect.x1 - rect.x0, rect.z1 - rect.z0) >= 30
+ const top = big ? fortress(site, rect, opts) : house(site, rect, opts)
+ if (name) {
+ const tag = label(name)
+ tag.position.set((rect.x0 + rect.x1) / 2, top + 1.2, (rect.z0 + rect.z1) / 2)
+ group.add(tag)
+ }
+ }
+ if (site.m.count()) {
+ const houses = new THREE.Mesh(
+ site.m.geometry(),
+ new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.85 })
+ )
+ houses.castShadow = true
+ houses.receiveShadow = true
+ group.add(houses)
+ }
+ // Las juntas encendidas de NOX no reciben luz: brillan solas.
+ if (site.glow.count()) {
+ const veins = new THREE.MeshBasicMaterial({ vertexColors: true })
+ group.add(new THREE.Mesh(site.glow.geometry(), veins))
+ }
+ if (site.windows.length) {
+ const count = site.windows.length
+ // El vidrio va apenas delante del marco y el parteluz delante del vidrio.
+ const lit = new THREE.InstancedMesh(
+ new THREE.PlaneGeometry(0.6, 1).translate(0, 0, 0.075),
+ new THREE.MeshStandardMaterial({
+ color: palette.glow,
+ emissive: palette.glow,
+ emissiveIntensity: 0.9
+ }),
+ count
+ )
+ // El marco, el alfeizar y el parteluz, solo con las caras que se ven.
+ const wood = palette.frame
+ const plate = (w, h, x, y, z, tilt = 0, turn = 0) =>
+ paint(new THREE.PlaneGeometry(w, h).rotateX(tilt).rotateY(turn).translate(x, y, z), wood)
+ const flat = -Math.PI / 2
+ const frames = new THREE.InstancedMesh(
+ mergeGeometries([
+ plate(0.86, 1.26, 0, 0, 0.06),
+ plate(0.86, 0.06, 0, 0.63, 0.03, flat),
+ plate(0.06, 1.26, -0.43, 0, 0.03, 0, flat),
+ plate(0.06, 1.26, 0.43, 0, 0.03, 0, -flat),
+ plate(1.04, 0.1, 0, -0.66, 0.24),
+ plate(1.04, 0.24, 0, -0.61, 0.12, flat),
+ plate(0.24, 0.1, -0.52, -0.66, 0.12, 0, flat),
+ plate(0.24, 0.1, 0.52, -0.66, 0.12, 0, -flat),
+ plate(0.07, 1, 0, 0, 0.09),
+ plate(0.6, 0.07, 0, 0.12, 0.092)
+ ]),
+ new THREE.MeshStandardMaterial({ vertexColors: true, roughness: 0.8 }),
+ count
+ )
+ frames.receiveShadow = true
+ // Postigos a los lados, del color de cada casa.
+ const shut = site.windows.filter((w) => w[4]).length
+ const shutters = shut
+ ? new THREE.InstancedMesh(
+ mergeGeometries([
+ new THREE.PlaneGeometry(0.36, 1.2).translate(-0.64, 0, 0.05),
+ new THREE.PlaneGeometry(0.36, 1.2).translate(0.64, 0, 0.05)
+ ]),
+ new THREE.MeshStandardMaterial({ roughness: 0.8 }),
+ shut
+ )
+ : null
+ const pose = new THREE.Matrix4()
+ const q = new THREE.Quaternion()
+ const up = new THREE.Vector3(0, 1, 0)
+ const one = new THREE.Vector3(1, 1, 1)
+ const spot = new THREE.Vector3()
+ let k = 0
+ site.windows.forEach(([x, y, z, yaw, tint], i) => {
+ pose.compose(spot.set(x, y, z), q.setFromAxisAngle(up, yaw), one)
+ lit.setMatrixAt(i, pose)
+ frames.setMatrixAt(i, pose)
+ if (!tint) return
+ shutters.setMatrixAt(k, pose)
+ shutters.setColorAt(k++, tint)
+ })
+ group.add(lit, frames)
+ if (shutters) {
+ shutters.receiveShadow = true
+ group.add(shutters)
+ }
+ }
+
+ // Tercera pasada: lo que sube fuera de los edificios, por tramos de una fila.
+ // Las letras y puertas van de a una, porque cada una lleva su glifo.
+ const runs = {}
+ for (let y = 0; y < height; y++) {
+ let x = 0
+ while (x < width) {
+ const i = y * width + x
+ const kind = kinds[i]
+ if (!kind || inBuilding[i] || !LOOSE[kind]) {
+ x++
+ continue
+ }
+ let end = x + 1
+ if (!LOOSE[kind].glyph) {
+ while (end < width && kinds[y * width + end] === kind && !inBuilding[y * width + end]) end++
+ }
+ if (!runs[kind]) runs[kind] = []
+ runs[kind].push([x, end - x, y, (rows[y] || '')[x] || ' '])
+ x = end
+ }
+ }
+ const m = new THREE.Matrix4()
+ const q = new THREE.Quaternion()
+ for (const [kind, list] of Object.entries(runs)) {
+ const spec = LOOSE[kind]
+ const geometry = new THREE.BoxGeometry(CX, spec.h, CZ * 0.98)
+ if (spec.glyph) {
+ const glyphs = new Float32Array(list.length * 2)
+ list.forEach(([, , , ch], i) => glyphs.set(glyphCell(ch), i * 2))
+ geometry.setAttribute('glyphCell', new THREE.InstancedBufferAttribute(glyphs, 2))
+ }
+ const mesh = new THREE.InstancedMesh(geometry, material(spec), list.length)
+ list.forEach(([x, len, y], i) => {
+ const position = new THREE.Vector3(
+ (x + len / 2) * CX,
+ spec.h / 2 - (spec.sink || 0),
+ y * CZ + CZ / 2
+ )
+ mesh.setMatrixAt(i, m.compose(position, q, new THREE.Vector3(len * 0.98, 1, 1)))
+ })
+ mesh.castShadow = spec.h > 1.5
+ mesh.receiveShadow = true
+ group.add(mesh)
+ }
+
+ // Arboles, piedras y flores, de pocos poligonos: tres especies de arbol, matas,
+ // piedras, flores con tallo, pasto y piedritas en los caminos, cada copia con
+ // su tamano, giro y color. El agua y la lava llevan una superficie que se mueve.
+ for (const mesh of natureMeshes(data, kinds, clock)) group.add(mesh)
+
+ // Puertas: una columna de luz y el nombre.
+ for (const door of doors) {
+ const p = toWorld(door.x, door.y)
+ const beam = new THREE.Mesh(
+ new THREE.CylinderGeometry(0.12, 0.12, 3.4, 8, 1, true),
+ new THREE.MeshBasicMaterial({
+ color: 0xe0c060,
+ transparent: true,
+ opacity: 0.35,
+ depthWrite: false,
+ side: THREE.DoubleSide
+ })
+ )
+ beam.position.set(p.x, 1.7, p.z)
+ group.add(beam)
+ const tag = label(door.name)
+ tag.position.set(p.x, 3.9, p.z)
+ group.add(tag)
+ }
+
+ // NPC: el dibujo de su tipo, parado en su celda y mirando a la camara, con una
+ // sombra al pie y el nombre arriba. Sin dibujo, la figura de la pagina.
+ const npcs = data.npcs || []
+ const idlers = []
+ if (npcs.length) {
+ const blobs = new THREE.InstancedMesh(
+ new THREE.PlaneGeometry(1, 0.6).rotateX(-Math.PI / 2),
+ new THREE.MeshBasicMaterial({ map: blobTexture(), transparent: true, depthWrite: false }),
+ npcs.length
+ )
+ const spot = new THREE.Matrix4()
+ npcs.forEach((npc, i) => {
+ const p = toWorld(npc.x, npc.y)
+ blobs.setMatrixAt(i, spot.makeTranslation(p.x, 0.02, p.z))
+ })
+ // Antes que los carteles, que la tapan donde se cruzan.
+ blobs.renderOrder = -1
+ group.add(blobs)
+ }
+ // 0,6 es lo que se ve de una altura con la camara 2.5D: asi el nombre queda
+ // justo sobre la cabeza de un cartel de alto h.
+ const over = (h) => (h + 0.4) / 0.6
+ for (const npc of npcs) {
+ const p = toWorld(npc.x, npc.y)
+ const tag = label(npc.name, '#d8d8d0')
+ tag.position.set(p.x, over(NPC_PX * 256), p.z)
+ group.add(tag)
+ const figure = () => {
+ if (!extras.figure) return group.remove(tag)
+ tag.position.y = 2.6
+ const body = extras.figure(npc.color)
+ body.position.copy(p)
+ body.rotation.y = hash(npc.x, npc.y) * Math.PI * 2
+ group.add(body)
+ }
+ if (!NPC_ART.has(npc.kind)) {
+ figure()
+ continue
+ }
+ npcLook(npc.kind).then((material) => {
+ if (!material) return figure()
+ // Dos NPC del mismo tipo no miden exactamente lo mismo.
+ const size = NPC_PX * (0.96 + hash(npc.y, npc.x) * 0.08)
+ const sprite = new THREE.Sprite(material)
+ sprite.center.set(0.5, 0)
+ sprite.position.copy(p)
+ sprite.scale.set(material.map.image.width * size, material.map.image.height * size, 1)
+ // Comparte textura y material con los demas NPC de su tipo.
+ sprite.userData.shared = true
+ group.add(sprite)
+ tag.position.y = over(sprite.scale.y)
+ idlers.push({ sprite, w: sprite.scale.x, h: sprite.scale.y, phase: hash(npc.x, npc.y) * 7 })
+ })
+ }
+
+ // Modelos de Tripo como hitos: la estatua de los heroes es el heroe de Tripo,
+ // y el Coloso vive en su arena.
+ const loads = []
+ // Tripo entrega los modelos mirando hacia +x: yaw los gira hacia donde tienen
+ // que mirar.
+ const place = (file, center, size, yaw = 0) => {
+ if (!extras.glb) return
+ loads.push(
+ extras
+ .glb(file)
+ .then((object) => {
+ if (!object) return
+ object.rotation.y = yaw
+ object.updateMatrixWorld(true)
+ const box = new THREE.Box3().setFromObject(object)
+ object.scale.multiplyScalar(size / box.getSize(new THREE.Vector3()).y)
+ object.updateMatrixWorld(true)
+ const placed = new THREE.Box3().setFromObject(object)
+ const mid = placed.getCenter(new THREE.Vector3())
+ object.position.x += center.x - mid.x
+ object.position.z += center.z - mid.z
+ object.position.y += center.y - placed.min.y
+ object.traverse((child) => {
+ if (child.isMesh) child.castShadow = true
+ })
+ // Es una copia del GLB en cache: comparte geometria y materiales.
+ object.userData.shared = true
+ group.add(object)
+ })
+ .catch(() => {})
+ )
+ }
+ for (const mark of data.landmarks || []) {
+ if (mark.id !== 'hero-statue' || !mark.bounds) continue
+ const { x1, y1, x2, y2 } = mark.bounds
+ const center = toWorld((x1 + x2) / 2, (y1 + y2) / 2)
+ const plinth = new THREE.Mesh(
+ new THREE.CylinderGeometry(1.4, 1.6, 0.8, 16),
+ new THREE.MeshStandardMaterial({ color: 0x6e6a62, roughness: 0.9 })
+ )
+ plinth.position.set(center.x, 0.4, center.z)
+ plinth.castShadow = true
+ group.add(plinth)
+ // La estatua mira al sur, hacia la plaza.
+ place('heroe.glb', center.setY(0.8), 3.2, -Math.PI / 2)
+ const tag = label(mark.name)
+ tag.position.set(center.x, 4.8, center.z)
+ group.add(tag)
+ }
+ // El Coloso mira al oeste, hacia el portal por donde llega el heroe.
+ if (data.boss) place('coloso.glb', toWorld(data.boss.x, data.boss.y), 7, Math.PI)
+
+ return {
+ group,
+ toWorld,
+ buildings: buildings.length,
+ loading: Promise.all(loads),
+ // Los NPC respiran: se estiran apenas desde los pies, cada uno a su ritmo.
+ update(time) {
+ for (const n of idlers) {
+ const k = Math.sin(time * 2.2 + n.phase)
+ n.sprite.scale.set(n.w * (1 - k * 0.012), n.h * (1 + k * 0.025), 1)
+ }
+ },
+ dispose() {
+ const free = (node) => {
+ if (node.userData.shared) return
+ // Las instancias tienen sus propios buffers de posiciones y colores.
+ if (node.isInstancedMesh) node.dispose()
+ if (node.geometry) node.geometry.dispose()
+ for (const mat of node.material ? [node.material].flat() : []) {
+ if (mat.map && mat.map !== atlas) mat.map.dispose()
+ mat.dispose()
+ }
+ for (const child of node.children) free(child)
+ }
+ free(group)
+ }
+ }
+}
diff --git a/scripts/tripo-viewer.html b/scripts/tripo-viewer.html
index 945d482..2ab7edf 100644
--- a/scripts/tripo-viewer.html
+++ b/scripts/tripo-viewer.html
@@ -11,7 +11,7 @@
// alcanza y no sale a la red. Una ruta que el server no sirva rompe todos los GLB.
self.ModelViewerElement = { meshoptDecoderLocation: 'data:text/javascript,' }
-
+