From 8ded9cc4c84f7adf551628c25ae5f6fef86f19e5 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Sun, 14 Jun 2026 23:51:40 +0000 Subject: [PATCH] Initial commit: NovelasVM platform with multi-engine support and Umineko Web integration --- .gitignore | 28 ++ README.md | 237 ++++++++++++++ bin/build-novela.sh | 290 +++++++++++++++++ bin/detect-engine.sh | 38 +++ bin/umineko-web.sh | 44 +++ config/nginx-snippet.template | 4 + docs/API.md | 79 +++++ docs/ARCHITECTURE.md | 99 ++++++ docs/DESIGN.md | 152 +++++++++ docs/ENGINES.md | 140 ++++++++ docs/INDEX.md | 161 ++++++++++ docs/INSTALL.md | 209 ++++++++++++ docs/PORTAL.md | 59 ++++ docs/SECURITY.md | 43 +++ docs/THEMES.md | 59 ++++ docs/TROUBLESHOOTING.md | 51 +++ docs/UMINEKO.md | 109 +++++++ scripts/setup.sh | 94 ++++++ templates/portal/app.js | 220 +++++++++++++ templates/portal/index.html | 98 ++++++ templates/portal/styles.css | 574 +++++++++++++++++++++++++++++++++ var-www/assets/app.js | 224 +++++++++++++ var-www/assets/styles.css | 578 ++++++++++++++++++++++++++++++++++ var-www/index.html | 98 ++++++ 24 files changed, 3688 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100755 bin/build-novela.sh create mode 100755 bin/detect-engine.sh create mode 100755 bin/umineko-web.sh create mode 100644 config/nginx-snippet.template create mode 100644 docs/API.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/DESIGN.md create mode 100644 docs/ENGINES.md create mode 100644 docs/INDEX.md create mode 100644 docs/INSTALL.md create mode 100644 docs/PORTAL.md create mode 100644 docs/SECURITY.md create mode 100644 docs/THEMES.md create mode 100644 docs/TROUBLESHOOTING.md create mode 100644 docs/UMINEKO.md create mode 100755 scripts/setup.sh create mode 100644 templates/portal/app.js create mode 100644 templates/portal/index.html create mode 100644 templates/portal/styles.css create mode 100644 var-www/assets/app.js create mode 100644 var-www/assets/styles.css create mode 100644 var-www/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd2174f --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Proyectos y assets con copyright +projects/ +games/ + +# Builds intermedias +builds/ + +# Herramientas de terceros (descargables) +tools/renpy/ +tools/onscripter-yuri/ +tools/umineko-web-asm/ + +# Credenciales y entorno +.env +*.env +*.key +*.pem + +# Logs +*.log + +# IDEs +.vscode/ +.idea/ + +# Sistema +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c57929 --- /dev/null +++ b/README.md @@ -0,0 +1,237 @@ +# NovelasVM + +> Plataforma web aliada de Afterlife para alojar, compilar y ejecutar novelas visuales directamente desde el navegador. + +NovelasVM es una solución autoalojada multi-motor pensada para equipos que necesitan probar, distribuir o archivar novelas visuales sin depender de servicios de terceros. Soporta Ren'Py, Unity WebGL, proyectos HTML5 genéricos y ONScripter-RU (Umineko Web). + +--- + +## Tabla de contenidos + +1. [Características principales](#características-principales) +2. [Requisitos](#requisitos) +3. [Instalación rápida](#instalación-rápida) +4. [Arquitectura general](#arquitectura-general) +5. [Motores soportados](#motores-soportados) +6. [Uso básico](#uso-básico) +7. [El portal web](#el-portal-web) +8. [Umineko Web](#umineko-web) +9. [Documentación adicional](#documentación-adicional) +10. [Seguridad](#seguridad) +11. [Licencia y créditos](#licencia-y-créditos) + +--- + +## Características principales + +- **Multi-motor**: Ren'Py, Unity WebGL, HTML5 genérico, ONScripter-RU/Umineko. +- **Portal moderno**: catálogo con tarjetas, portadas, metadatos y selector de tres temas (oscuro, claro, inmersivo). +- **Compilación automatizada**: script `build-novela.sh` que detecta el motor, compila si es necesario y publica. +- **Configuración nginx optimizada**: cache de assets, headers COOP/COEP condicionales para Ren'Py Web. +- **Soporte de Umineko Web**: contenedor Docker con ONScripter-RU compilado a WebAssembly. +- **Diseño extensible**: tokens CSS, sistema de diseño documentado en `DESIGN.md`. + +--- + +## Requisitos + +### Hardware recomendado + +| Componente | Mínimo | Recomendado | +|------------|--------|-------------| +| CPU | 4 vCPUs | 8 vCPUs | +| RAM | 8 GB | 16 GB | +| Disco | 50 GB | 150 GB (Umineko requiere ~15 GB) | +| Red | Acceso LAN | IP fija o dominio | + +### Software + +- Ubuntu 22.04/24.04 LTS +- nginx +- Docker + docker-compose +- Python 3 +- Node.js + npm +- xvfb +- p7zip-full +- git +- Ren'Py SDK 8.3.4+ con web package + +--- + +## Instalación rápida + +```bash +# 1. Clonar este repositorio +git clone https://git.consultoria-as.com/consultoria-as/novelasvm.git +cd novelasvm + +# 2. Ejecutar el script de instalación +sudo bash scripts/setup.sh + +# 3. Copiar el portal a /var/www/novelas +sudo cp -r var-www/* /var/www/novelas/ +sudo chown -R www-data:www-data /var/www/novelas + +# 4. Recargar nginx +sudo nginx -t && sudo nginx -s reload +``` + +Para la instalación detallada paso a paso, consulta [`docs/INSTALL.md`](docs/INSTALL.md). + +--- + +## Arquitectura general + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Navegador │ +│ │ +│ http://192.168.10.111/ http://192.168.10.111:8081/ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Portal web │ │ Umineko Web │ │ +│ │ (nginx 80) │ ─── redirect ───▶ │ (Docker/nginx 80)│ │ +│ └──────────────┘ └──────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ Juegos web │ │ +│ │ /var/www/... │ │ +│ └──────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + +/opt/novelas/ Código, proyectos y herramientas +/var/www/novelas/ Raíz servida por nginx +``` + +Para más detalles técnicos, consulta [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). + +--- + +## Motores soportados + +| Motor | Script/carpetas esperadas | Compilación | Notas | +|-------|---------------------------|-------------|-------| +| **Ren'Py** | `project.json` o `game/script.rpy` | Sí (`renpy.sh web_build`) | Requiere COOP/COEP | +| **Unity WebGL** | `Build/`, `TemplateData/`, `index.html` | No | Copia directa | +| **Web genérico** | `index.html` | No | Copia directa | +| **ONScripter** | `0.txt`, `nscript.dat`, `ons.cfg` | No | Usa OnscripterYuri web | +| **ONScripter-RU / Umineko** | `*.file`, `default.cfg`, `chiru.file` | Contenedor Docker | Motor específico | + +Más información en [`docs/ENGINES.md`](docs/ENGINES.md). + +--- + +## Uso básico + +### Agregar una novela + +```bash +sudo /opt/novelas/bin/build-novela.sh /opt/novelas/projects/ [motor] +``` + +Ejemplos: + +```bash +# Ren'Py +sudo /opt/novelas/bin/build-novela.sh demo /opt/novelas/projects/demo + +# Unity WebGL +sudo /opt/novelas/bin/build-novela.sh mi-juego /opt/novelas/projects/mi-juego unity + +# Web genérico +sudo /opt/novelas/bin/build-novela.sh web-novel /opt/novelas/projects/web-novel web +``` + +### Metadatos opcionales + +Crea un archivo `meta.json` en la raíz del proyecto: + +```json +{ + "title": "Mi Novela", + "subtitle": "Subtítulo opcional", + "description": "Breve descripción.", + "author": "Autor", + "version": "1.0", + "cover": "/games/mi-novela/cover.jpg" +} +``` + +--- + +## El portal web + +El portal principal está en `/var/www/novelas/` y ofrece: + +- Catálogo dinámico cargado desde `/games.json`. +- Selector de temas: oscuro, claro, inmersivo. +- Tarjetas con portada, título, subtítulo, badge del motor, descripción. +- Diseño responsive y accesible (sin emojis, iconos SVG). + +Para personalizar el diseño, revisa [`docs/DESIGN.md`](docs/DESIGN.md) y [`docs/PORTAL.md`](docs/PORTAL.md). + +--- + +## Umineko Web + +Umineko utiliza un motor especial que corre dentro de un contenedor Docker. + +```bash +# Iniciar +sudo /opt/novelas/bin/umineko-web.sh start + +# Detener +sudo /opt/novelas/bin/umineko-web.sh stop + +# Reiniciar +sudo /opt/novelas/bin/umineko-web.sh restart + +# Ver logs +sudo /opt/novelas/bin/umineko-web.sh logs +``` + +URL: `http://192.168.10.111/games/umineko/` + +Más detalles en [`docs/UMINEKO.md`](docs/UMINEKO.md). + +--- + +## Documentación adicional + +- [`docs/INSTALL.md`](docs/INSTALL.md) — Instalación detallada. +- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — Arquitectura y flujo de datos. +- [`docs/ENGINES.md`](docs/ENGINES.md) — Guía por motor. +- [`docs/PORTAL.md`](docs/PORTAL.md) — Personalización del portal. +- [`docs/THEMES.md`](docs/THEMES.md) — Sistema de temas. +- [`docs/API.md`](docs/API.md) — Especificación de `games.json` y metadatos. +- [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) — Solución de problemas. +- [`docs/SECURITY.md`](docs/SECURITY.md) — Consideraciones de seguridad. +- [`docs/UMINEKO.md`](docs/UMINEKO.md) — Integración de Umineko Web. +- [`docs/DESIGN.md`](docs/DESIGN.md) — Sistema de diseño. + +--- + +## Seguridad + +- Cambia las contraseñas de root y del usuario `novelas` tras la instalación. +- Considera habilitar HTTPS con certbot o Caddy. +- No publiques el repositorio si incluye assets con copyright. +- Los headers COOP/COEP solo se aplican a juegos Ren'Py; no se aplican globalmente. + +Lee más en [`docs/SECURITY.md`](docs/SECURITY.md). + +--- + +## Licencia y créditos + +El código de la plataforma (scripts, portal, documentación) se provee tal cual para uso interno de Afterlife / Consultoría AS. + +Componentes de terceros: +- Ren'Py SDK: MIT +- OnscripterYuri: licencia del proyecto original +- Umineko Web (`umineko_web_asm`): por VictoriqueMoe y colaboradores +- Fuentes Google (Inter, JetBrains Mono): SIL Open Font License + +Los juegos publicados por los usuarios conservan sus propias licencias. diff --git a/bin/build-novela.sh b/bin/build-novela.sh new file mode 100755 index 0000000..f2556ac --- /dev/null +++ b/bin/build-novela.sh @@ -0,0 +1,290 @@ +#!/bin/bash +set -e + +# build-novela.sh - Compila y publica una novela visual en NovelasVM +# Uso: build-novela.sh [motor] +# motor: renpy | unity | web | onscripter (si no se indica, se detecta automaticamente) +# Ejemplo: build-novela.sh demo /opt/novelas/projects/demo + +NAME="$1" +PROJECT="$2" +FORCE_ENGINE="$3" + +RENPY_HOME="/opt/novelas/tools/renpy" +BUILDS_DIR="/opt/novelas/builds" +WWW_ROOT="/var/www/novelas" +WWW_GAMES="$WWW_ROOT/games" +NGINX_SNIPPETS_DIR="/etc/nginx/snippets/novelas-games" +META_FILE="$PROJECT/meta.json" + +# ----------------------------------------------------------------------------- +# Validaciones basicas +# ----------------------------------------------------------------------------- +if [ -z "$NAME" ] || [ -z "$PROJECT" ]; then + echo "Uso: $0 [renpy|unity|web|onscripter]" + exit 1 +fi + +if [ ! -d "$PROJECT" ]; then + echo "ERROR: No existe el proyecto $PROJECT" + exit 1 +fi + +# Slug seguro: minusculas, numeros, guiones +SAFE_NAME=$(echo "$NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//') +if [ -z "$SAFE_NAME" ]; then + echo "ERROR: El slug '$NAME' no es valido" + exit 1 +fi + +# ----------------------------------------------------------------------------- +# Deteccion / validacion de motor +# ----------------------------------------------------------------------------- +if [ -n "$FORCE_ENGINE" ]; then + ENGINE="$FORCE_ENGINE" + echo "[*] Motor forzado: $ENGINE" +else + ENGINE=$(/opt/novelas/bin/detect-engine.sh "$PROJECT") + echo "[*] Motor detectado: $ENGINE" +fi + +case "$ENGINE" in + renpy|unity|web|onscripter) + ;; + *) + echo "ERROR: Motor no soportado: $ENGINE" + echo "Estructura no reconocida. Se esperaba:" + echo " - renpy: project.json o game/script.rpy" + echo " - unity: Build/ + TemplateData/ + index.html" + echo " - web: index.html" + echo " - onscripter: 0.txt/nscript.dat, ons.cfg o onscripter*.exe" + exit 1 + ;; +esac + +# ----------------------------------------------------------------------------- +# Validacion especifica por motor +# ----------------------------------------------------------------------------- +if [ "$ENGINE" = "unity" ]; then + if [ ! -d "$PROJECT/Build" ] || [ ! -d "$PROJECT/TemplateData" ] || [ ! -f "$PROJECT/index.html" ]; then + echo "ERROR: El proyecto Unity WebGL no tiene la estructura esperada (Build/, TemplateData/, index.html)" + exit 1 + fi +fi + +if [ "$ENGINE" = "web" ]; then + if [ ! -f "$PROJECT/index.html" ]; then + echo "ERROR: El proyecto web no contiene index.html" + exit 1 + fi +fi + +if [ "$ENGINE" = "onscripter" ]; then + if [ ! -f "$PROJECT/0.txt" ] && [ ! -f "$PROJECT/nscript.dat" ] && [ ! -f "$PROJECT/ons.cfg" ] && [ ! -f "$PROJECT/onscripter-ru.exe" ] && [ ! -f "$PROJECT/onscripter.exe" ]; then + echo "ERROR: El proyecto ONScripter no tiene archivos reconocibles (0.txt, nscript.dat, ons.cfg, onscripter*.exe)" + exit 1 + fi +fi + +# ----------------------------------------------------------------------------- +# Variables adicionales por motor +# ----------------------------------------------------------------------------- +ONSCRIPTER_WEB_DIR="/opt/novelas/tools/onscripter-yuri/onsyuri_web" + +# ----------------------------------------------------------------------------- +# Preparar directorios +# ----------------------------------------------------------------------------- +DEST="$BUILDS_DIR/$SAFE_NAME" +rm -rf "$DEST" +mkdir -p "$DEST" + +# ----------------------------------------------------------------------------- +# Compilar / copiar segun motor +# ----------------------------------------------------------------------------- +if [ "$ENGINE" = "renpy" ]; then + echo "[+] Compilando '$SAFE_NAME' (Ren'Py) desde $PROJECT ..." + cd "$RENPY_HOME" + # El web_build de Ren'Py ocasionalmente se cuelga al finalizar; usamos timeout + timeout 600 xvfb-run -a ./renpy.sh launcher web_build "$PROJECT" --destination "$DEST" || true + + if [ ! -f "$DEST/index.html" ]; then + echo "ERROR: No se genero index.html. Revisa el log del proyecto." + exit 1 + fi +elif [ "$ENGINE" = "onscripter" ]; then + echo "[+] Empaquetando '$SAFE_NAME' (ONScripter) desde $PROJECT ..." + cp -r "$PROJECT"/* "$DEST/" + + echo "[+] Copiando motor OnscripterYuri web ..." + cp "$ONSCRIPTER_WEB_DIR/onsyuri.js" "$DEST/onsyuri.js" + cp "$ONSCRIPTER_WEB_DIR/onsyuri.wasm" "$DEST/onsyuri.wasm" + + echo "[+] Generando onsyuri_index.json ..." + python3 "$ONSCRIPTER_WEB_DIR/onsyuri_index.py" \ + -i "$DEST" \ + -o "$DEST/onsyuri_index.json" \ + --title "$SAFE_NAME" \ + --gamedir "/games/$SAFE_NAME" \ + --savedir "/onsyuri_save/$SAFE_NAME" \ + --urlbase "" \ + --lazyload +else + echo "[+] Copiando '$SAFE_NAME' ($ENGINE) desde $PROJECT ..." + cp -r "$PROJECT"/* "$DEST/" + + if [ ! -f "$DEST/index.html" ]; then + echo "ERROR: No se encontro index.html en el proyecto." + exit 1 + fi +fi + +# ----------------------------------------------------------------------------- +# Leer metadatos (meta.json opcional) o usar defaults +# ----------------------------------------------------------------------------- +if [ -f "$META_FILE" ]; then + echo "[*] Cargando metadatos desde $META_FILE" + TITLE=$(python3 -c "import json; print(json.load(open('$META_FILE')).get('title','$SAFE_NAME'))") + SUBTITLE=$(python3 -c "import json; print(json.load(open('$META_FILE')).get('subtitle',''))") + DESCRIPTION=$(python3 -c "import json; print(json.load(open('$META_FILE')).get('description',''))") + AUTHOR=$(python3 -c "import json; print(json.load(open('$META_FILE')).get('author',''))") + VERSION=$(python3 -c "import json; print(json.load(open('$META_FILE')).get('version','1.0'))") + COVER=$(python3 -c "import json; print(json.load(open('$META_FILE')).get('cover',''))") +else + TITLE="$SAFE_NAME" + SUBTITLE="" + DESCRIPTION="" + AUTHOR="" + VERSION="1.0" + COVER="" +fi + +# Cover por defecto segun motor / disponibilidad +if [ -z "$COVER" ]; then + if [ "$ENGINE" = "renpy" ] && [ -f "$DEST/web-presplash.jpg" ]; then + COVER="/games/$SAFE_NAME/web-presplash.jpg" + elif [ -f "$DEST/cover.jpg" ]; then + COVER="/games/$SAFE_NAME/cover.jpg" + elif [ -f "$DEST/cover.png" ]; then + COVER="/games/$SAFE_NAME/cover.png" + else + COVER="" + fi +fi + +# Generar index.html para ONScripter tras tener metadatos reales +if [ "$ENGINE" = "onscripter" ]; then + echo "[+] Adaptando onsyuri.html como index.html ..." + sed -e "s|[^<]*|$(echo "$TITLE" | sed 's/[&/\\]/\\&/g')|" \ + "$ONSCRIPTER_WEB_DIR/onsyuri.html" > "$DEST/index.html" + + if [ ! -f "$DEST/index.html" ]; then + echo "ERROR: No se genero index.html para ONScripter." + exit 1 + fi +fi + +# Determinar si requiere headers COOP/COEP (solo Ren'Py Web los necesita) +if [ "$ENGINE" = "renpy" ]; then + COOP_COEP="true" +else + COOP_COEP="false" +fi + +CREATED_AT=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + +# ----------------------------------------------------------------------------- +# Generar game.json +# ----------------------------------------------------------------------------- +cat > "$DEST/game.json" < +# ----------------------------------------------------------------------------- +echo "[+] Publicando en $WWW_GAMES/$SAFE_NAME ..." +rm -rf "$WWW_GAMES/$SAFE_NAME" +mkdir -p "$WWW_GAMES/$SAFE_NAME" +cp -r "$DEST"/* "$WWW_GAMES/$SAFE_NAME/" +chown -R www-data:www-data "$WWW_GAMES/$SAFE_NAME" +chmod -R 755 "$WWW_GAMES/$SAFE_NAME" + +# ----------------------------------------------------------------------------- +# Generar snippet nginx para Ren'Py +# ----------------------------------------------------------------------------- +mkdir -p "$NGINX_SNIPPETS_DIR" +SNIPPET_FILE="$NGINX_SNIPPETS_DIR/$SAFE_NAME.conf" + +if [ "$COOP_COEP" = "true" ]; then + sed "s/__SLUG__/$SAFE_NAME/g" /opt/novelas/config/nginx-snippet.template > "$SNIPPET_FILE" + echo "[+] Snippet nginx generado: $SNIPPET_FILE" +else + rm -f "$SNIPPET_FILE" + echo "[*] No se requiere snippet COOP/COEP para $ENGINE" +fi + +# ----------------------------------------------------------------------------- +# Actualizar catalogo global /var/www/novelas/games.json +# ----------------------------------------------------------------------------- +echo "[+] Actualizando catalogo global ..." +python3 </games/$SAFE_NAME/" diff --git a/bin/detect-engine.sh b/bin/detect-engine.sh new file mode 100755 index 0000000..7b2f247 --- /dev/null +++ b/bin/detect-engine.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# detect-engine.sh - Detecta el motor de una novela visual según su estructura +# Uso: detect-engine.sh +# Salida: renpy | unity | web | onscripter | unknown + +PROJECT="${1:-}" + +if [ -z "$PROJECT" ] || [ ! -d "$PROJECT" ]; then + echo "unknown" + exit 1 +fi + +# Ren'Py: tiene project.json o game/script.rpy +if [ -f "$PROJECT/project.json" ] || [ -f "$PROJECT/game/script.rpy" ]; then + echo "renpy" + exit 0 +fi + +# ONScripter: script 0.txt/nscript.dat, ons.cfg o ejecutable onscripter +if [ -f "$PROJECT/0.txt" ] || [ -f "$PROJECT/nscript.dat" ] || [ -f "$PROJECT/ons.cfg" ] || [ -f "$PROJECT/onscripter-ru.exe" ] || [ -f "$PROJECT/onscripter.exe" ]; then + echo "onscripter" + exit 0 +fi + +# Unity WebGL: estructura típica +if [ -d "$PROJECT/Build" ] && [ -d "$PROJECT/TemplateData" ] && [ -f "$PROJECT/index.html" ]; then + echo "unity" + exit 0 +fi + +# Web genérico: solo necesita index.html +if [ -f "$PROJECT/index.html" ]; then + echo "web" + exit 0 +fi + +echo "unknown" +exit 1 diff --git a/bin/umineko-web.sh b/bin/umineko-web.sh new file mode 100755 index 0000000..5e82330 --- /dev/null +++ b/bin/umineko-web.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# umineko-web.sh - Gestiona el contenedor Docker de Umineko Web +# Uso: umineko-web.sh [start|stop|restart|status|logs|update] + +set -e + +COMPOSE_DIR="/opt/novelas/tools/umineko-web-asm" +CONTAINER_NAME="umineko-web-asm_umineko-web_1" + +cd "$COMPOSE_DIR" + +case "${1:-start}" in + start|up) + echo "[+] Levantando Umineko Web..." + ./run-umineko-web.sh + ;; + stop|down) + echo "[+] Deteniendo Umineko Web..." + docker-compose down + ;; + restart) + echo "[+] Reiniciando Umineko Web..." + docker-compose down + ./run-umineko-web.sh + ;; + status) + echo "[*] Estado del contenedor:" + docker ps --filter "name=$CONTAINER_NAME" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + ;; + logs) + echo "[*] Logs del contenedor (Ctrl+C para salir):" + docker logs -f "$CONTAINER_NAME" + ;; + update) + echo "[+] Actualizando imagen y reiniciando..." + docker-compose down + docker-compose pull + ./run-umineko-web.sh + ;; + *) + echo "Uso: $0 [start|stop|restart|status|logs|update]" + exit 1 + ;; +esac diff --git a/config/nginx-snippet.template b/config/nginx-snippet.template new file mode 100644 index 0000000..1d8a280 --- /dev/null +++ b/config/nginx-snippet.template @@ -0,0 +1,4 @@ +location /games/__SLUG__/ { + add_header Cross-Origin-Opener-Policy "same-origin" always; + add_header Cross-Origin-Embedder-Policy "require-corp" always; +} diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..b2c7efb --- /dev/null +++ b/docs/API.md @@ -0,0 +1,79 @@ + +## Catalogo global + +### GET /games.json + +Devuelve el catalogo completo de novelas publicadas. + +```json +{ + "games": [ + { + "slug": "demo", + "title": "The Question", + "subtitle": "Demo oficial de Ren'Py", + "engine": "renpy", + "description": "...", + "cover": "/games/demo/web-presplash.jpg", + "version": "7.0", + "author": "Ren'Py Team", + "createdAt": "2026-06-14T10:35:09Z", + "entryPoint": "/games/demo/index.html", + "coopCoep": true + } + ], + "updatedAt": "2026-06-14T22:07:10Z", + "version": "1.0" +} +``` + +## Metadatos por juego + +### GET /games//game.json + +Generado por `build-novela.sh`. Contiene metadatos y configuracion del motor. + +| Campo | Tipo | Descripcion | +|-------|------|-------------| +| `slug` | string | Identificador URL-friendly | +| `title` | string | Titulo mostrado | +| `subtitle` | string | Subtitulo opcional | +| `engine` | string | Motor: renpy, unity, web, onscripter, umineko-ru | +| `description` | string | Descripcion corta | +| `cover` | string | URL de la portada | +| `version` | string | Version del juego | +| `author` | string | Autor | +| `createdAt` | string ISO | Fecha de publicacion | +| `entryPoint` | string | URL para jugar | +| `coopCoep` | boolean | Si requiere headers COOP/COEP | + +## meta.json de entrada + +Archivo opcional en `/opt/novelas/projects//meta.json`: + +```json +{ + "title": "Mi Novela", + "subtitle": "Subtitulo", + "description": "Descripcion.", + "author": "Autor", + "version": "1.0", + "cover": "/games/mi-novela/cover.jpg" +} +``` + +## Headers HTTP + +### COOP/COEP (solo Ren'Py) + +``` +Cross-Origin-Opener-Policy: same-origin +Cross-Origin-Embedder-Policy: require-corp +``` + +Se aplican mediante snippets en `/etc/nginx/snippets/novelas-games/.conf`. + +## Cache + +- `games.json` y `game.json`: `no-store, no-cache, must-revalidate`. +- Assets estaticos: `public, immutable`, 30 dias. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..fd78aad --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,99 @@ +# Arquitectura de NovelasVM + +Este documento describe la arquitectura técnica de NovelasVM, sus componentes y el flujo de publicación de una novela visual. + +## Componentes principales + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Cliente │ +│ (navegador web) │ +└───────────────────────────────┬──────────────────────────────────────┘ + │ HTTP +┌───────────────────────────────▼──────────────────────────────────────┐ +│ nginx (puerto 80) │ +│ ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────────┐ │ +│ │ Portal web │ │ Juegos web │ │ /games/umineko/ │ │ +│ │ /var/www/... │ │ /var/www/games/ │ │ → redirect :8081 │ │ +│ └─────────────────┘ └──────────────────┘ └─────────────────────┘ │ +└───────────────────────────────┬──────────────────────────────────────┘ + │ + ┌───────────────────────┼───────────────────────┐ + │ │ │ +┌───────▼───────┐ ┌──────────▼─────────┐ ┌────────▼────────┐ +│ build-novela │ │ detect-engine │ │ Umineko Web │ +│ .sh │ │ .sh │ │ Docker :8081 │ +└───────┬───────┘ └────────────────────┘ └─────────────────┘ + │ +┌───────▼───────┐ +│ Ren'Py SDK │ +│ /opt/novelas │ +│ /tools/renpy│ +└───────────────┘ +``` + +## Flujo de publicación + +1. El administrador coloca el proyecto en `/opt/novelas/projects//`. +2. Opcionalmente añade `meta.json` con metadatos. +3. Ejecuta `/opt/novelas/bin/build-novela.sh [motor]`. +4. `detect-engine.sh` identifica el motor si no se especifica. +5. Según el motor: + - **Ren'Py**: `renpy.sh launcher web_build` genera archivos web. + - **Unity/Web**: se copian tal cual. + - **ONScripter**: se copia motor OnscripterYuri + índice. +6. Se genera `game.json` con metadatos. +7. Se copia a `/var/www/novelas/games//`. +8. Se genera snippet nginx si requiere COOP/COEP. +9. Se actualiza `/var/www/novelas/games.json`. +10. Se recarga nginx. + +## Archivos clave + +| Ruta | Propósito | +|------|-----------| +| `/opt/novelas/bin/build-novela.sh` | Pipeline de build y publicación | +| `/opt/novelas/bin/detect-engine.sh` | Detección de motor | +| `/opt/novelas/bin/umineko-web.sh` | Gestión del contenedor Umineko | +| `/opt/novelas/config/nginx-snippet.template` | Plantilla de snippet COOP/COEP | +| `/var/www/novelas/index.html` | Portal principal | +| `/var/www/novelas/assets/app.js` | Lógica del portal | +| `/var/www/novelas/assets/styles.css` | Estilos y temas | +| `/var/www/novelas/games.json` | Catálogo global | +| `/var/www/novelas/games//game.json` | Metadatos por juego | +| `/etc/nginx/snippets/novelas-games/*.conf` | Headers específicos por juego | + +## Catálogo global `games.json` + +```json +{ + "games": [ + { + "slug": "demo", + "title": "The Question", + "subtitle": "Demo oficial de Ren'Py", + "engine": "renpy", + "description": "...", + "cover": "/games/demo/web-presplash.jpg", + "version": "7.0", + "author": "Ren'Py Team", + "createdAt": "2026-06-14T10:35:09Z", + "entryPoint": "/games/demo/index.html", + "coopCoep": true + } + ], + "updatedAt": "2026-06-14T22:07:10Z", + "version": "1.0" +} +``` + +## Seguridad de headers + +- `COOP`/`COEP` se aplican **solo** a juegos Ren'Py porque requieren `SharedArrayBuffer`. +- Unity WebGL y juegos web genéricos no los reciben para evitar bloqueo de recursos cross-origin. + +## Escalabilidad + +- Cada juego es independiente en `/var/www/novelas/games//`. +- El portal carga el catálogo asíncronamente, por lo que el número de juegos solo afecta el tamaño de `games.json`. +- Para Umineko, el contenedor Docker es independiente y puede escalarse o moverse a otro host. diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..aa92ef5 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,152 @@ +# Design System: NovelasVM Portal + +--- +name: NovelasVM Portal +type: Gaming / Visual Novel Catalog +themes: + - dark + - light + - immersive +principles: + - Clean, modern, game-store aesthetic. + - Theme switcher as a first-class feature. + - No emojis in UI; use inline SVG icons only. + - Animations restricted to transform and opacity. + - Responsive, mobile-first layout. +--- + +## Overview + +Portal para descubrir, listar y lanzar novelas visuales alojadas en NovelasVM. Debe sentirse como una tienda de juegos indie o un launcher elegante, no como un directorio de archivos. + +## Color Palette + +### Theme: Dark Modern +- `--bg-primary`: #0a0a0a (main background) +- `--bg-surface`: #141414 (cards, navbar) +- `--bg-elevated`: #1f1f1f (hover states, dropdowns) +- `--text-primary`: #f5f5f5 +- `--text-secondary`: #a0a0a0 +- `--text-muted`: #666666 +- `--accent`: #4e85bf +- `--accent-hover`: #3a6da3 +- `--border`: #2a2a2a +- `--shadow`: rgba(0, 0, 0, 0.4) +- `--success`: #2ecc71 +- `--warning`: #f1c40f + +### Theme: Light Minimal +- `--bg-primary`: #fafafa +- `--bg-surface`: #ffffff +- `--bg-elevated`: #f3f4f6 +- `--text-primary`: #111827 +- `--text-secondary`: #4b5563 +- `--text-muted`: #9ca3af +- `--accent`: #2563eb +- `--accent-hover`: #1d4ed8 +- `--border`: #e5e7eb +- `--shadow`: rgba(0, 0, 0, 0.08) + +### Theme: Immersive Atmospheric +- `--bg-primary`: #050510 (deep indigo-black) +- `--bg-surface`: rgba(20, 20, 40, 0.6) +- `--bg-elevated`: rgba(30, 30, 60, 0.7) +- `--text-primary`: #ffffff +- `--text-secondary`: rgba(255, 255, 255, 0.75) +- `--text-muted`: rgba(255, 255, 255, 0.45) +- `--accent`: #8b5cf6 +- `--accent-hover`: #7c3aed +- `--border`: rgba(255, 255, 255, 0.12) +- `--shadow`: rgba(0, 0, 0, 0.5) +- `--gradient-start`: #1e1b4b +- `--gradient-mid`: #4c1d95 +- `--gradient-end`: #0f172a + +## Typography + +- **Primary font**: Inter, system-ui, sans-serif +- **Display font**: Inter, weight 700 +- **Monospace**: JetBrains Mono, monospace + +Scale: +- Hero title: clamp(2rem, 5vw, 3.5rem) +- Section title: 1.75rem / weight 700 +- Card title: 1.125rem / weight 600 +- Body: 1rem / line-height 1.6 +- Caption: 0.875rem +- Small: 0.75rem + +## Spacing + +Base unit: 0.25rem (4px) +- xs: 0.5rem +- sm: 0.75rem +- md: 1rem +- lg: 1.5rem +- xl: 2rem +- 2xl: 3rem + +Max content width: 1280px, centered, with 1rem side padding. + +## Components + +### Navbar +- Fixed top, full width. +- Background: `--bg-surface` with `backdrop-filter: blur(12px)` in immersive theme. +- Border-bottom: 1px solid `--border`. +- Height: 64px. +- Logo left, theme switcher right. + +### Game Card +- Aspect ratio cover image (16:9 or 4:3) at top. +- Rounded corners: 12px (theme dark/light) or 16px (immersive). +- Background: `--bg-surface`. +- Border: 1px solid `--border`. +- Shadow: subtle, lifting on hover (`transform: translateY(-4px)`). +- Badge showing engine (Ren'Py, Unity, Web). +- Title, subtitle, description (2 lines max). +- Primary CTA: "Jugar" button. + +### Buttons +- Primary: accent background, white text, rounded-lg (8px), padding 0.6rem 1.2rem. +- Hover: darken accent, slight lift. +- Ghost: transparent with border. + +### Theme Switcher +- Pill-shaped segmented control. +- Icons: moon (dark), sun (light), sparkle/glass (immersive). + +### Engine Badge +- Small pill with icon + label. +- Ren'Py: purple/indigo tint. +- Unity: dark/black tint. +- Web: blue tint. + +## Layout + +- Header hero with title and subtitle. +- Filter bar (optional v1: just engine badges count or simple text). +- Grid of cards: 1 column mobile, 2 tablet, 3 desktop, 4 large. +- Footer with credits. + +## Motion + +- Entry: fade + translateY(16px → 0), 500ms ease-out. +- Card hover: translateY(-4px) + shadow increase, 200ms ease. +- Theme transition: 300ms ease on color/background/border properties. +- Loading skeleton: shimmer animation. + +## Responsive + +- Breakpoints: sm 640px, md 768px, lg 1024px, xl 1280px. +- Navbar collapses gracefully; theme switcher stays visible. +- Cards stack to single column below 640px. + +## Do's and Don'ts + +- Do use the CSS variables for theming. +- Do ensure WCAG AA contrast on all themes. +- Don't use emojis in the UI. +- Don't animate layout properties (width, height, margin). +- Don't use pure black (#000) in dark theme. +- Don't rely on external images for UI icons; use inline SVG. diff --git a/docs/ENGINES.md b/docs/ENGINES.md new file mode 100644 index 0000000..e403be4 --- /dev/null +++ b/docs/ENGINES.md @@ -0,0 +1,140 @@ +# Guía de motores soportados + +NovelasVM soporta varios motores de novelas visuales. Esta guía describe cómo preparar y publicar cada uno. + +## Tabla de motores + +| Motor | Extensión/detección | Build | URL resultante | +|-------|---------------------|-------|----------------| +| Ren'Py | `project.json`, `game/script.rpy` | Compilación web | `/games//` | +| Unity WebGL | `Build/`, `TemplateData/`, `index.html` | Copia directa | `/games//` | +| Web genérico | `index.html` | Copia directa | `/games//` | +| ONScripter | `0.txt`, `nscript.dat`, `ons.cfg` | Copia + OnscripterYuri | `/games//` | +| ONScripter-RU / Umineko | `*.file`, `default.cfg`, `chiru.file` | Contenedor Docker | `/games/umineko/` | + +--- + +## Ren'Py + +### Estructura esperada + +``` +mi-novela/ +├── project.json +├── game/ +│ ├── script.rpy +│ ├── images/ +│ └── audio/ +└── meta.json +``` + +### Comando + +```bash +sudo /opt/novelas/bin/build-novela.sh mi-novela /opt/novelas/projects/mi-novela renpy +``` + +### Consideraciones + +- Requiere `xvfb` para compilar sin display. +- El build puede tardar varios minutos. +- Se generan `index.html`, `renpy.js`, `renpy.wasm`, `renpy.data`, `game.zip`. +- Recibe headers `COOP`/`COEP` automáticamente. + +--- + +## Unity WebGL + +### Estructura esperada + +``` +mi-juego/ +├── index.html +├── Build/ +│ ├── mi-juego.data.gz +│ ├── mi-juego.framework.js.gz +│ ├── mi-juego.loader.js +│ └── mi-juego.wasm.gz +└── TemplateData/ + ├── style.css + └── UnityProgress.js +``` + +### Comando + +```bash +sudo /opt/novelas/bin/build-novela.sh mi-juego /opt/novelas/projects/mi-juego unity +``` + +### Consideraciones + +- No requiere compilación adicional. +- Asegúrate de que los nombres de archivo en `index.html` coincidan con los de `Build/`. + +--- + +## Web genérico + +### Estructura esperada + +``` +mi-web/ +├── index.html +├── css/ +├── js/ +└── assets/ +``` + +### Comando + +```bash +sudo /opt/novelas/bin/build-novela.sh mi-web /opt/novelas/projects/mi-web web +``` + +### Consideraciones + +- Cualquier proyecto con un `index.html` funciona. +- Si carga recursos externos, asegúrate de que CORS lo permita. + +--- + +## ONScripter (genérico) + +### Estructura esperada + +``` +mi-ons/ +├── 0.txt # o nscript.dat +├── default.ttf +├── ons.cfg +├── backgrounds/ +├── sprites/ +└── sound/ +``` + +### Comando + +```bash +sudo /opt/novelas/bin/build-novela.sh mi-ons /opt/novelas/projects/mi-ons onscripter +``` + +### Consideraciones + +- Usa OnscripterYuri web. +- Si el script está en `nscript.dat`, OnscripterYuri debería soportarlo. +- No soporta los archivos `.file` encriptados de ONScripter-RU (ver Umineko). + +--- + +## ONScripter-RU / Umineko + +Ver [`docs/UMINEKO.md`](UMINEKO.md) para la guía completa. + +Resumen: + +```bash +sudo /opt/novelas/bin/umineko-web.sh start +``` + +- Se sirve desde contenedor Docker en `http://IP:8081/`. +- El portal redirige desde `/games/umineko/`. diff --git a/docs/INDEX.md b/docs/INDEX.md new file mode 100644 index 0000000..dafd0eb --- /dev/null +++ b/docs/INDEX.md @@ -0,0 +1,161 @@ +# NovelasVM + +Plataforma web aliada de Afterlife para ejecutar y probar novelas visuales directamente en el navegador. Soporta multiples motores: Ren'Py (WebAssembly), Unity WebGL, proyectos HTML5 personalizados y ONScripter-RU/Umineko Web. + +## Información general + +| Campo | Valor | +|-------|-------| +| VMID | 111 | +| Host Proxmox | Cisco1 (192.168.10.185) | +| IP estática | 192.168.10.111 | +| Gateway | 192.168.10.254 | +| DNS | 8.8.8.8, 8.8.4.4 | +| Recursos | 8 vCPUs, 16 GB RAM, 150 GB disco | +| SO | Ubuntu 24.04 LTS | +| Usuario admin | `novelas` | +| Web | http://192.168.10.111 | + +## Estructura de directorios + +``` +/opt/novelas/ +├── bin/ +│ ├── build-novela.sh # Script principal: compila y publica novelas +│ └── detect-engine.sh # Detecta el motor de un proyecto +├── builds/ # Builds intermedias generadas +├── projects/ # Proyectos fuente de novelas +├── tools/ +│ └── renpy/ # Ren'Py SDK 8.3.4 + web package +├── config/ +│ └── nginx-snippet.template# Plantilla para snippets nginx +├── templates/ +│ └── portal/ # Plantillas del portal web (NO USADAS EN RUNTIME) +└── docs/ + ├── README.md # Esta documentación + └── DESIGN.md # Sistema de diseño del portal + +/var/www/novelas/ # Raíz servida por nginx +├── index.html # Portal principal +├── assets/ # CSS, JS e iconos del portal +│ ├── styles.css +│ ├── app.js +│ └── icons/ +├── games.json # Catálogo global de juegos +└── games/ + ├── demo/ # The Question (Ren'Py) + ├── web-demo/ # Ejemplo HTML5 + ├── unity-demo/ # Ejemplo Unity WebGL + └── umineko/ # Solo cover.jpg; juego en contenedor Docker + +/etc/nginx/snippets/novelas-games/ +└── *.conf # Headers COOP/COEP por juego Ren'Py +``` + +## Motores soportados + +| Motor | Detección automática | Compilación | Headers COOP/COEP | +|-------|---------------------|-------------|-------------------| +| Ren'Py | `project.json` o `game/script.rpy` | Si, via `renpy.sh launcher web_build` | Si | +| Unity WebGL | `Build/`, `TemplateData/` e `index.html` | No, copia directa | No | +| Web genérico | `index.html` | No, copia directa | No | +| ONScripter estándar | `0.txt`, `nscript.dat`, `ons.cfg` | Copia + OnscripterYuri web | No | +| ONScripter-RU / Umineko | `*.file` + `default.cfg` | Contenedor Docker `umineko_web_asm` | No | + +## Cómo agregar una novela visual + +1. Coloca el proyecto en `/opt/novelas/projects/`. +2. (Opcional) Añade un archivo `meta.json` en la raíz del proyecto: + ```json + { + "title": "Mi Novela", + "subtitle": "Subtítulo opcional", + "description": "Breve descripción de la novela.", + "author": "Tu nombre", + "version": "1.0", + "cover": "/games/mi-novela/cover.jpg" + } + ``` +3. Ejecuta como root o con sudo: + ```bash + /opt/novelas/bin/build-novela.sh /opt/novelas/projects/ + ``` + También puedes forzar el motor: + ```bash + /opt/novelas/bin/build-novela.sh /opt/novelas/projects/ renpy + ``` +4. La novela quedará disponible en `http://192.168.10.111/games//`. + +## Umineko no Naku Koro ni (ONScripter-RU Web) + +Umineko utiliza el motor ONScripter-RU compilado a WebAssembly mediante el proyecto `umineko_web_asm`. Se sirve desde un contenedor Docker dedicado y se accede a traves de una redireccion en nginx. + +### Estructura + +``` +/opt/novelas/tools/umineko-web-asm/ # Fuente del contenedor +├── run-umineko-web.sh # Script para levantar/actualizar +├── docker-compose.yml +└── ... + +/opt/novelas/projects/umineko/ # Archivos del juego +/var/www/novelas/games/umineko/cover.jpg # Portada para el portal +``` + +### URL + +- Portal: `http://192.168.10.111/games/umineko/` (redirige al contenedor) +- Directa: `http://192.168.10.111:8081/` + +### Gestión del contenedor + +```bash +# Levantar o recrear +cd /opt/novelas/tools/umineko-web-asm +./run-umineko-web.sh + +# Detener +docker-compose down + +# Estado +docker ps +docker logs -f umineko-web-asm_umineko-web_1 +``` + +### Modos de hosting + +Edita `run-umineko-web.sh` para cambiar `HOSTING_MODE`: + +- `local` (actual): sirve los archivos originales. Inicio rapido. +- `production`: convierte PNG→WebP, MP4→WebM, etc. Tarda en iniciar la primera vez. +- `remote`: el usuario sube sus propios archivos desde el navegador. + +## Portal web + +El portal principal (`http://192.168.10.111/`) muestra el catálogo de novelas disponibles con: +- Diseño moderno y responsive. +- Selector de temas: oscuro, claro e inmersivo. +- Tarjetas con portada, metadatos y badge del motor. +- Carga dinámica desde `/games.json`. + +La preferencia de tema se guarda en `localStorage` del navegador. + +## Servicios instalados + +- nginx (puerto 80) +- ufw (firewall: 22, 80, 443) +- docker + docker-compose +- nodejs + npm +- python3 + pip +- xvfb (para compilar Ren'Py Web sin display) + +## Diseño + +El sistema de diseño del portal está documentado en `/opt/novelas/docs/DESIGN.md`. Se inspiró en los design systems de [designmd.ai](https://designmd.ai) y define tokens para los tres temas visuales. + +## Notas de seguridad + +- Cambiar el password de root y del usuario `novelas` si aun no se ha hecho. +- Considerar habilitar HTTPS con Caddy o certbot. +- Evaluar autenticación si las novelas son privadas. +- Los headers `Cross-Origin-Opener-Policy` y `Cross-Origin-Embedder-Policy` solo se aplican a juegos Ren'Py, ya que son los unicos que requieren `SharedArrayBuffer`. Unity WebGL y juegos web genericos no los necesitan y podrian romperse con ellos. diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..0b45076 --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,209 @@ +# Guía de instalación + +Esta guía describe cómo instalar NovelasVM desde cero en un servidor Ubuntu. + +## Tabla de contenidos + +1. [Preparación del sistema](#preparación-del-sistema) +2. [Usuario y directorios](#usuario-y-directorios) +3. [Instalación de dependencias](#instalación-de-dependencias) +4. [Instalación de Ren'Py SDK](#instalación-de-renpy-sdk) +5. [Instalación de OnscripterYuri web](#instalación-de-onscripteryuri-web) +6. [Configuración de nginx](#configuración-de-nginx) +7. [Instalación del portal](#instalación-del-portal) +8. [Configuración de Umineko Web](#configuración-de-umineko-web) +9. [Verificación final](#verificación-final) + +--- + +## Preparación del sistema + +Actualiza el sistema: + +```bash +sudo apt update && sudo apt upgrade -y +``` + +Configura una IP estática. Edita `/etc/netplan/00-installer-config.yaml` o el archivo correspondiente: + +```yaml +network: + version: 2 + ethernets: + eth0: + addresses: + - 192.168.10.111/24 + routes: + - to: default + via: 192.168.10.254 + nameservers: + addresses: + - 8.8.8.8 + - 8.8.4.4 +``` + +Aplica: + +```bash +sudo netplan apply +``` + +--- + +## Usuario y directorios + +Crea el usuario `novelas` y los directorios base: + +```bash +sudo useradd -m -s /bin/bash novelas +sudo mkdir -p /opt/novelas/{bin,builds,projects,tools,config,templates/portal,docs} +sudo mkdir -p /var/www/novelas/{games,assets} +sudo chown -R novelas:novelas /opt/novelas +``` + +--- + +## Instalación de dependencias + +```bash +sudo apt install -y \ + nginx \ + docker.io \ + docker-compose \ + python3 \ + python3-pip \ + nodejs \ + npm \ + xvfb \ + p7zip-full \ + git \ + curl \ + wget \ + imagemagick \ + ufw +``` + +Habilita Docker: + +```bash +sudo systemctl enable --now docker +sudo usermod -aG docker novelas +``` + +--- + +## Instalación de Ren'Py SDK + +Descarga Ren'Py SDK con el paquete web: + +```bash +sudo mkdir -p /opt/novelas/tools/renpy +cd /opt/novelas/tools/renpy +sudo curl -sL -O https://www.renpy.org/dl/8.3.4/renpy-8.3.4-sdk.tar.bz2 +sudo tar -xjf renpy-8.3.4-sdk.tar.bz2 +sudo mv renpy-8.3.4-sdk/* . +sudo rm -rf renpy-8.3.4-sdk renpy-8.3.4-sdk.tar.bz2 +sudo chown -R novelas:novelas /opt/novelas/tools/renpy +``` + +Verifica: + +```bash +/opt/novelas/tools/renpy/renpy.sh --version +``` + +--- + +## Instalación de OnscripterYuri web + +Descarga la última versión web: + +```bash +sudo mkdir -p /opt/novelas/tools/onscripter-yuri +cd /opt/novelas/tools/onscripter-yuri +VERSION="0.7.6" +curl -sL -o onsyuri_v${VERSION}_web.7z \ + https://github.com/YuriSizuku/OnscripterYuri/releases/download/v${VERSION}/onsyuri_v${VERSION}_web.7z +7z x -y onsyuri_v${VERSION}_web.7z +rm onsyuri_v${VERSION}_web.7z +sudo chown -R novelas:novelas /opt/novelas/tools/onscripter-yuri +``` + +--- + +## Configuración de nginx + +Copia la configuración del repositorio: + +```bash +sudo cp /opt/novelas/repo/config/nginx.conf /etc/nginx/sites-available/novelas +sudo ln -sf /etc/nginx/sites-available/novelas /etc/nginx/sites-enabled/novelas +sudo rm -f /etc/nginx/sites-enabled/default +sudo nginx -t +sudo systemctl reload nginx +``` + +> Nota: el archivo `config/nginx.conf` del repositorio es una plantilla; ajústala según tu IP/dominio. + +--- + +## Instalación del portal + +Copia los archivos del portal: + +```bash +sudo cp -r /opt/novelas/repo/var-www/* /var/www/novelas/ +sudo chown -R www-data:www-data /var/www/novelas +sudo find /var/www/novelas -type f -exec chmod 644 {} \; +sudo find /var/www/novelas -type d -exec chmod 755 {} \; +``` + +Copia los scripts: + +```bash +sudo cp /opt/novelas/repo/bin/* /opt/novelas/bin/ +sudo chmod +x /opt/novelas/bin/*.sh +sudo chown -R novelas:novelas /opt/novelas/bin +``` + +--- + +## Configuración de Umineko Web + +Clona el repositorio del contenedor: + +```bash +sudo mkdir -p /opt/novelas/tools +cd /opt/novelas/tools +sudo git clone --depth 1 https://github.com/VictoriqueMoe/umineko_web_asm.git umineko-web-asm +sudo cp /opt/novelas/repo/bin/umineko-web.sh umineko-web-asm/run-umineko-web.sh +sudo chmod +x umineko-web-asm/run-umineko-web.sh +``` + +Edita `run-umineko-web.sh` con la ruta correcta a los archivos del juego y la IP del servidor. + +Coloca los archivos de Umineko en `/opt/novelas/projects/umineko/` y luego: + +```bash +sudo /opt/novelas/bin/umineko-web.sh start +``` + +--- + +## Verificación final + +```bash +# nginx +sudo systemctl is-active nginx + +# Docker +sudo docker ps + +# Portal +curl -s http://localhost/games.json + +# Umineko redirect +curl -sI http://localhost/games/umineko/ +``` + +Accede con tu navegador a `http://192.168.10.111/`. diff --git a/docs/PORTAL.md b/docs/PORTAL.md new file mode 100644 index 0000000..7f1a9c7 --- /dev/null +++ b/docs/PORTAL.md @@ -0,0 +1,59 @@ +# Portal web + +El portal web es la interfaz principal de NovelasVM. Muestra el catálogo de novelas disponibles y permite cambiar entre temas visuales. + +## Archivos + +- `var-www/index.html` — Estructura del portal. +- `var-www/assets/styles.css` — Tokens y estilos de los tres temas. +- `var-www/assets/app.js` — Lógica de carga del catálogo, renderizado y temas. + +## Funcionalidades + +- **Catálogo dinámico**: carga `/games.json` y renderiza tarjetas. +- **Selector de temas**: oscuro, claro, inmersivo. Persistencia en `localStorage`. +- **Badges de motor**: cada tarjeta muestra el motor (Ren'Py, Unity, Web, ONScripter-RU). +- **Portadas**: si `cover` está definido en `game.json`, se muestra; si no, placeholder. +- **Responsive**: de 1 columna en móvil hasta 4 en pantallas grandes. +- **Sin emojis**: todos los iconos son SVG inline. + +## Metadatos mostrados + +Cada tarjeta muestra: +- Portada (16:9) +- Badge del motor +- Título y subtítulo +- Descripción (máximo 2 líneas) +- Versión, autor y fecha +- Botón "Jugar" + +## Personalización + +### Cambiar tipografía + +Edita la fuente en `var-www/index.html`: + +```html + +``` + +Y actualiza `--font-sans` en `styles.css`. + +### Añadir un nuevo tema + +1. Añade `[data-theme="mi-tema"]` en `styles.css` con sus tokens. +2. Añade un botón en `index.html` con `data-theme-value="mi-tema"`. +3. Opcionalmente actualiza `THEMES` en `app.js`. + +### Añadir motor/badge + +1. Añade el color `--engine-` en cada tema de `styles.css`. +2. Añade clase `.engine-badge.`. +3. Añade entrada en `engineConfig` de `app.js`. +4. Añade orden en `engineOrder` del sort. + +## Ordenación + +Los juegos se ordenan: +1. Por motor: `renpy`, `umineko-ru`, `unity`, `web`. +2. Por título alfabéticamente dentro de cada motor. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..1d71e0b --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,43 @@ + +## Credenciales + +- Cambia las contrasenas de root y novelas tras la instalacion. +- No compartas el token de Gitea ni otras credenciales. + +## HTTPS + +Se recomienda habilitar HTTPS con certbot: + +```bash +sudo apt install certbot python3-certbot-nginx +sudo certbot --nginx -d tu-dominio.com +``` + +## Headers COOP/COEP + +Se aplican solo a juegos Ren'Py. Aplicarlos globalmente romperia Unity WebGL y juegos web que carguen recursos externos. + +## Firewall + +Mantener solo los puertos necesarios abiertos: + +```bash +sudo ufw allow 22/tcp +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw allow 8081/tcp # Umineko Web +``` + +## Contenido con copyright + +No subas a repositorios publicos: +- Archivos de juegos (`/opt/novelas/projects/*`). +- Builds publicadas (`/var/www/novelas/games/*`). +- SDKs de terceros (`/opt/novelas/tools/renpy`, `onscripter-yuri`). + +Este repositorio solo debe contener el codigo de la plataforma. + +## Docker + +- El contenedor de Umineko expone el puerto 8081. +- Considera restringir el acceso a 8081 si solo se usara mediante nginx. diff --git a/docs/THEMES.md b/docs/THEMES.md new file mode 100644 index 0000000..f967fe2 --- /dev/null +++ b/docs/THEMES.md @@ -0,0 +1,59 @@ + +## Temas disponibles + +El portal incluye tres temas visuales: + +1. **Oscuro moderno** (`data-theme="dark"`) +2. **Claro minimalista** (`data-theme="light"`) +3. **Atmosferico inmersivo** (`data-theme="immersive"`) + +## Tokens CSS + +Los tokens se definen en `var-www/assets/styles.css` bajo los selectores `[data-theme="..."]`. + +### Tokens comunes + +| Token | Uso | +|-------|-----| +| `--bg-primary` | Fondo principal | +| `--bg-surface` | Fondo de tarjetas y navbar | +| `--bg-elevated` | Superficies elevadas | +| `--text-primary` | Texto principal | +| `--text-secondary` | Texto secundario | +| `--text-muted` | Texto atenuado | +| `--accent` | Color de acento | +| `--accent-hover` | Acento en hover | +| `--border` | Bordes | +| `--shadow-*` | Sombras | +| `--engine-*` | Colores de badges de motor | + +### Ejemplo: tema oscuro + +```css +[data-theme="dark"] { + --bg-primary: #0a0a0a; + --bg-surface: #141414; + --bg-elevated: #1f1f1f; + --text-primary: #f5f5f5; + --text-secondary: #a0a0a0; + --accent: #4e85bf; + --border: #2a2a2a; +} +``` + +## Tipografia + +- **Sans**: Inter, system-ui +- **Mono**: JetBrains Mono + +## Transiciones + +Los cambios de tema animan `background-color`, `color` y `border-color` durante 300ms. + +## Anadir un tema nuevo + +1. Copia el bloque de un tema existente en `styles.css`. +2. Cambia el selector a `[data-theme="nuevo"]`. +3. Define nuevos valores para todos los tokens. +4. Anade un boton en `index.html` con `data-theme-value="nuevo"`. +5. Verifica contraste WCAG AA. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..a2524b6 --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,51 @@ + +## El juego no aparece en el portal + +1. Verifica que `games.json` existe: + ```bash + curl -s http://localhost/games.json + ``` +2. Revisa los logs de nginx: + ```bash + sudo tail -50 /var/log/nginx/novelas-error.log + ``` +3. Asegurate de que los permisos sean correctos: + ```bash + sudo chown -R www-data:www-data /var/www/novelas + ``` + +## Ren'Py no genera index.html + +- Revisa el log del proyecto en `/opt/novelas/builds//log.txt`. +- Asegurate de que `xvfb` este instalado. +- Aumenta el timeout en `build-novela.sh` si el proyecto es grande. + +## Unity WebGL no carga + +- Verifica que `index.html` referencie correctamente los archivos de `Build/`. +- Abre la consola del navegador para ver errores 404. + +## Umineko no carga + +1. Verifica que el contenedor este corriendo: + ```bash + sudo docker ps + ``` +2. Revisa los logs: + ```bash + sudo /opt/novelas/bin/umineko-web.sh logs + ``` +3. Verifica que el manifest se genero: + ```bash + curl -sI http://localhost:8081/manifest.json + ``` + +## Error 403/404 en assets + +- Revisa permisos de `/var/www/novelas`. +- Verifica que nginx tenga acceso a los archivos. + +## Cambio de tema no persiste + +- Asegurate de que `localStorage` no este deshabilitado. +- El tema se guarda bajo la clave `novelasvm-theme`. diff --git a/docs/UMINEKO.md b/docs/UMINEKO.md new file mode 100644 index 0000000..3d4106c --- /dev/null +++ b/docs/UMINEKO.md @@ -0,0 +1,109 @@ + +## Descripcion + +Umineko no Naku Koro ni utiliza el motor **ONScripter-RU**, que no es compatible con OnscripterYuri generico porque usa scripts encriptados en archivos `.file` (`en.file`, `es.file`, `ru.file`). + +Para ejecutarlo en el navegador se utiliza el proyecto **umineko_web_asm**, que compila ONScripter-RU a WebAssembly mediante Emscripten. + +## Requisitos + +- Docker y docker-compose instalados. +- ~15 GB de espacio libre para la imagen y los assets. +- Archivos del juego en `/opt/novelas/projects/umineko/`. + +## Estructura esperada del juego + +``` +/opt/novelas/projects/umineko/ +├── en.file # Script principal (ingles) +├── es.file # Script en espanol +├── ru.file # Script en ruso +├── chiru.file # Coordenadas de imagenes +├── default.cfg # Configuracion del juego +├── game.hash # Hash de integridad +├── fonts/ +├── backgrounds/ +├── sprites/ +├── graphics/ +├── sound/ +└── video/ +``` + +## Instalacion del contenedor + +```bash +sudo mkdir -p /opt/novelas/tools +cd /opt/novelas/tools +sudo git clone --depth 1 https://github.com/VictoriqueMoe/umineko_web_asm.git umineko-web-asm +sudo cp /opt/novelas/repo/bin/umineko-web.sh umineko-web-asm/run-umineko-web.sh +sudo chmod +x umineko-web-asm/run-umineko-web.sh +``` + +## Configuracion + +Edita `/opt/novelas/tools/umineko-web-asm/run-umineko-web.sh`: + +```bash +export GAME_PATH=/opt/novelas/projects/umineko +export PORT=8081 +export HOSTING_MODE=local +export SITE_URL=http://192.168.10.111/games/umineko/ +``` + +### Modos de hosting + +- `local`: sirve los archivos originales. Inicio rapido. +- `production`: convierte PNG a WebP, MP4 a WebM, etc. Reduce tamano pero tarda en iniciar. +- `remote`: el usuario sube sus propios archivos desde el navegador. + +## Gestion del contenedor + +```bash +sudo /opt/novelas/bin/umineko-web.sh start +sudo /opt/novelas/bin/umineko-web.sh stop +sudo /opt/novelas/bin/umineko-web.sh restart +sudo /opt/novelas/bin/umineko-web.sh status +sudo /opt/novelas/bin/umineko-web.sh logs +sudo /opt/novelas/bin/umineko-web.sh update +``` + +## Integracion con nginx + +El portal redirige `/games/umineko/` al puerto 8081: + +```nginx +location /games/umineko/ { + return 301 http://192.168.10.111:8081/; +} +``` + +Asegurate de que el puerto 8081 este abierto en ufw. + +## Portada + +Coloca una imagen en: + +```bash +/var/www/novelas/games/umineko/cover.jpg +``` + +Sera mostrada en el portal. + +## Solucion de problemas + +### El contenedor no arranca + +```bash +sudo docker logs umineko-web-asm_umineko-web_1 +``` + +### El juego no carga + +- Verifica que `default.cfg` apunte a `game-script=en.file`. +- Comprueba que `chiru.file` y `game.hash` existan. +- Revisa la consola del navegador. + +### Rendimiento lento + +- Considera cambiar a `production` para assets optimizados. +- Asegurate de tener suficiente RAM y CPU. diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..2340104 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# setup.sh - Instalacion inicial de NovelasVM +set -e + +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +NOVELAS_DIR="/opt/novelas" +WWW_DIR="/var/www/novelas" + +echo "[+] Instalando dependencias..." +apt-get update +apt-get install -y nginx docker.io docker-compose python3 python3-pip nodejs npm \ + xvfb p7zip-full git curl wget imagemagick ufw + +systemctl enable --now docker +usermod -aG docker novelas || true + +echo "[+] Creando directorios..." +mkdir -p "$NOVELAS_DIR"/{bin,builds,projects,tools,config,templates/portal,docs} +mkdir -p "$WWW_DIR"/{games,assets} +chown -R novelas:novelas "$NOVELAS_DIR" + +echo "[+] Copiando scripts..." +cp "$REPO_DIR/bin/"*.sh "$NOVELAS_DIR/bin/" +chmod +x "$NOVELAS_DIR/bin/"*.sh +chown -R novelas:novelas "$NOVELAS_DIR/bin" + +echo "[+] Copiando configuracion..." +cp "$REPO_DIR/config/nginx-snippet.template" "$NOVELAS_DIR/config/" + +echo "[+] Copiando portal..." +cp "$REPO_DIR/var-www/index.html" "$WWW_DIR/" +cp "$REPO_DIR/var-www/assets/"* "$WWW_DIR/assets/" +chown -R www-data:www-data "$WWW_DIR" +find "$WWW_DIR" -type f -exec chmod 644 {} \; +find "$WWW_DIR" -type d -exec chmod 755 {} \; + +echo "[+] Configurando nginx..." +if [ ! -f /etc/nginx/sites-available/novelas ]; then + cat > /etc/nginx/sites-available/novelas <<'NGINX' +server { + listen 80; + server_name _; + root /var/www/novelas; + index index.html; + + location / { + try_files $uri $uri/ =404; + } + + location /games/umineko/ { + return 301 http://192.168.10.111:8081/; + } + + include /etc/nginx/snippets/novelas-games/*.conf; + + location ~* \.(wasm|data|js|png|jpg|jpeg|gif|ico|svg|opus|ogg|mp3|mp4|webm|zip)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + location ~* \.wasm$ { + add_header Content-Type "application/wasm"; + } + + location ~* ^/(games\.json|games/[^/]+/game\.json)$ { + expires -1; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + try_files $uri =404; + } + + error_log /var/log/nginx/novelas-error.log; + access_log /var/log/nginx/novelas-access.log; +} +NGINX +fi + +ln -sf /etc/nginx/sites-available/novelas /etc/nginx/sites-enabled/novelas +rm -f /etc/nginx/sites-enabled/default +nginx -t +systemctl reload nginx + +echo "[+] Configurando firewall..." +ufw allow 22/tcp +ufw allow 80/tcp +ufw allow 443/tcp +ufw allow 8081/tcp + +echo "[+] Instalacion completada." +echo "Recuerda:" +echo " 1. Instalar Ren'Py SDK en $NOVELAS_DIR/tools/renpy" +echo " 2. Instalar OnscripterYuri web en $NOVELAS_DIR/tools/onscripter-yuri" +echo " 3. Configurar Umineko Web segun docs/UMINEKO.md" +echo " 4. Cambiar contrasenas de root y novelas" diff --git a/templates/portal/app.js b/templates/portal/app.js new file mode 100644 index 0000000..74a9f9e --- /dev/null +++ b/templates/portal/app.js @@ -0,0 +1,220 @@ +/** + * NovelasVM Portal + * Carga el catalogo de juegos y gestiona temas. + */ + +(function () { + 'use strict'; + + const THEME_KEY = 'novelasvm-theme'; + const THEMES = ['dark', 'light', 'immersive']; + + const engineConfig = { + renpy: { + label: 'Ren\'Py', + icon: '' + }, + unity: { + label: 'Unity', + icon: '' + }, + web: { + label: 'Web', + icon: '' + } + }; + + // --- Theme handling ------------------------------------------------------ + + function getSavedTheme() { + const saved = localStorage.getItem(THEME_KEY); + if (saved && THEMES.includes(saved)) return saved; + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) { + return 'light'; + } + return 'dark'; + } + + function setTheme(theme) { + if (!THEMES.includes(theme)) return; + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem(THEME_KEY, theme); + updateThemeButtons(theme); + } + + function updateThemeButtons(activeTheme) { + document.querySelectorAll('.theme-btn').forEach(btn => { + const btnTheme = btn.getAttribute('data-theme-value'); + btn.classList.toggle('active', btnTheme === activeTheme); + btn.setAttribute('aria-pressed', btnTheme === activeTheme ? 'true' : 'false'); + }); + } + + function initThemeSwitcher() { + setTheme(getSavedTheme()); + document.querySelectorAll('.theme-btn').forEach(btn => { + btn.addEventListener('click', () => setTheme(btn.getAttribute('data-theme-value'))); + }); + } + + // --- Catalog rendering --------------------------------------------------- + + function formatDate(isoString) { + if (!isoString) return ''; + try { + const date = new Date(isoString); + return date.toLocaleDateString('es-ES', { + year: 'numeric', month: 'short', day: 'numeric' + }); + } catch (e) { + return ''; + } + } + + function getEngineBadge(engine) { + const cfg = engineConfig[engine] || engineConfig.web; + return `${cfg.icon}${cfg.label}`; + } + + function getCoverImage(game) { + if (game.cover) { + return `Portada de ${escapeHtml(game.title)}`; + } + return ` +
+ +
+ `; + } + + function escapeHtml(text) { + if (text === null || text === undefined) return ''; + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function renderCard(game, index) { + const engine = game.engine || 'web'; + const metaParts = []; + if (game.version) metaParts.push(`v${escapeHtml(game.version)}`); + if (game.author) metaParts.push(escapeHtml(game.author)); + const date = formatDate(game.createdAt); + if (date) metaParts.push(date); + + return ` +
+
+ ${getCoverImage(game)} + ${getEngineBadge(engine)} +
+
+

${escapeHtml(game.title || game.slug)}

+ ${game.subtitle ? `

${escapeHtml(game.subtitle)}

` : ''} + ${game.description ? `

${escapeHtml(game.description)}

` : ''} +
${metaParts.join(' · ')}
+ +
+
+ `; + } + + function renderLegend() { + const legend = document.getElementById('engineLegend'); + if (!legend) return; + legend.innerHTML = Object.entries(engineConfig).map(([key, cfg]) => ` + + + ${cfg.label} + + `).join(''); + } + + function showSkeletons(grid) { + grid.innerHTML = Array.from({ length: 6 }).map(() => ` +
+
+
+
+
+
+
+
+
+ `).join(''); + } + + async function loadCatalog() { + const grid = document.getElementById('gamesGrid'); + const stats = document.getElementById('stats'); + const empty = document.getElementById('emptyState'); + + if (!grid || !stats || !empty) return; + + showSkeletons(grid); + grid.setAttribute('aria-busy', 'true'); + + try { + const response = await fetch('/games.json', { cache: 'no-store' }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + const games = Array.isArray(data.games) ? data.games : []; + + // Ordenar: primero por motor (renpy, unity, web) luego por titulo + games.sort((a, b) => { + const engineOrder = { renpy: 0, unity: 1, web: 2 }; + const ea = engineOrder[a.engine] ?? 99; + const eb = engineOrder[b.engine] ?? 99; + if (ea !== eb) return ea - eb; + return (a.title || a.slug).localeCompare(b.title || b.slug); + }); + + if (games.length === 0) { + grid.innerHTML = ''; + grid.classList.add('hidden'); + empty.classList.remove('hidden'); + stats.textContent = 'No hay novelas publicadas'; + } else { + empty.classList.add('hidden'); + grid.classList.remove('hidden'); + grid.innerHTML = games.map((game, i) => renderCard(game, i)).join(''); + const countText = games.length === 1 ? '1 novela disponible' : `${games.length} novelas disponibles`; + stats.textContent = countText; + } + } catch (err) { + console.error('Error cargando catalogo:', err); + grid.innerHTML = ''; + grid.classList.add('hidden'); + empty.classList.remove('hidden'); + empty.querySelector('h2').textContent = 'No se pudo cargar el catalogo'; + empty.querySelector('p').textContent = 'Revisa que /games.json exista o intenta recargar la pagina.'; + stats.textContent = 'Error de carga'; + } finally { + grid.setAttribute('aria-busy', 'false'); + } + } + + // --- Init ---------------------------------------------------------------- + + document.addEventListener('DOMContentLoaded', () => { + initThemeSwitcher(); + renderLegend(); + loadCatalog(); + }); +})(); diff --git a/templates/portal/index.html b/templates/portal/index.html new file mode 100644 index 0000000..53a6f59 --- /dev/null +++ b/templates/portal/index.html @@ -0,0 +1,98 @@ + + + + + + NovelasVM - Plataforma de Novelas Visuales + + + + + + + + + +
+
+

Tu biblioteca de novelas visuales

+

Juega, prueba y comparte novelas visuales desde el navegador. Soporta Ren'Py, Unity WebGL y proyectos web personalizados.

+
+
+ +
+
+
Cargando novelas...
+
+
+ +
+ +
+ + +
+ +
+ +
+ + + + diff --git a/templates/portal/styles.css b/templates/portal/styles.css new file mode 100644 index 0000000..68dcdff --- /dev/null +++ b/templates/portal/styles.css @@ -0,0 +1,574 @@ +:root { + --font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-mono: 'JetBrains Mono', monospace; + + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-xl: 20px; + --radius-pill: 9999px; + + --transition-fast: 150ms ease; + --transition-base: 250ms ease; + --transition-theme: 300ms ease; + + --max-width: 1280px; + --navbar-height: 64px; +} + +/* Tema oscuro (default) */ +[data-theme="dark"] { + --bg-primary: #0a0a0a; + --bg-surface: #141414; + --bg-elevated: #1f1f1f; + --bg-hover: #262626; + --text-primary: #f5f5f5; + --text-secondary: #a0a0a0; + --text-muted: #666666; + --accent: #4e85bf; + --accent-hover: #3a6da3; + --border: #2a2a2a; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.45); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.55); + --engine-renpy: #8b5cf6; + --engine-unity: #1f1f1f; + --engine-web: #3b82f6; + --code-bg: #1a1a1a; +} + +/* Tema claro */ +[data-theme="light"] { + --bg-primary: #fafafa; + --bg-surface: #ffffff; + --bg-elevated: #f3f4f6; + --bg-hover: #e5e7eb; + --text-primary: #111827; + --text-secondary: #4b5563; + --text-muted: #9ca3af; + --accent: #2563eb; + --accent-hover: #1d4ed8; + --border: #e5e7eb; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.1); + --engine-renpy: #7c3aed; + --engine-unity: #111827; + --engine-web: #2563eb; + --code-bg: #f3f4f6; +} + +/* Tema inmersivo */ +[data-theme="immersive"] { + --bg-primary: #050510; + --bg-surface: rgba(20, 20, 45, 0.62); + --bg-elevated: rgba(32, 32, 66, 0.72); + --bg-hover: rgba(45, 45, 90, 0.8); + --text-primary: #ffffff; + --text-secondary: rgba(255, 255, 255, 0.78); + --text-muted: rgba(255, 255, 255, 0.5); + --accent: #8b5cf6; + --accent-hover: #7c3aed; + --border: rgba(255, 255, 255, 0.12); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35); + --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.45); + --shadow-lg: 0 20px 48px rgba(0, 0, 0, 0.55); + --engine-renpy: #a78bfa; + --engine-unity: #0f172a; + --engine-web: #60a5fa; + --code-bg: rgba(15, 15, 35, 0.7); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + font-family: var(--font-sans); + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + line-height: 1.6; + transition: background-color var(--transition-theme), color var(--transition-theme); +} + +[data-theme="immersive"] body { + background: + radial-gradient(circle at 15% 25%, rgba(76, 29, 149, 0.25) 0%, transparent 35%), + radial-gradient(circle at 85% 70%, rgba(59, 130, 246, 0.18) 0%, transparent 30%), + linear-gradient(180deg, #050510 0%, #0f172a 100%); + background-attachment: fixed; +} + +.container { + width: 100%; + max-width: var(--max-width); + margin: 0 auto; + padding: 0 1rem; +} + +/* Navbar */ +.navbar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: var(--navbar-height); + background: var(--bg-surface); + border-bottom: 1px solid var(--border); + z-index: 100; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + transition: background-color var(--transition-theme), border-color var(--transition-theme); +} + +.navbar-inner { + display: flex; + align-items: center; + justify-content: space-between; + height: 100%; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.6rem; + color: var(--text-primary); + text-decoration: none; + font-weight: 700; + font-size: 1.25rem; + letter-spacing: -0.02em; +} + +.brand-icon { + width: 1.6rem; + height: 1.6rem; + color: var(--accent); +} + +.theme-switcher { + display: inline-flex; + align-items: center; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-pill); + padding: 0.25rem; + gap: 0.15rem; + transition: background-color var(--transition-theme), border-color var(--transition-theme); +} + +.theme-btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.65rem; + border: none; + border-radius: var(--radius-pill); + background: transparent; + color: var(--text-secondary); + font-family: inherit; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: background-color var(--transition-fast), color var(--transition-fast), transform var(--transition-fast); +} + +.theme-btn svg { + width: 0.95rem; + height: 0.95rem; +} + +.theme-btn:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +.theme-btn.active { + background: var(--accent); + color: #fff; + box-shadow: var(--shadow-sm); +} + +.theme-btn span { + display: none; +} + +@media (min-width: 640px) { + .theme-btn span { + display: inline; + } + .theme-btn { + padding: 0.45rem 0.9rem; + } +} + +/* Hero */ +.hero { + padding-top: calc(var(--navbar-height) + 3rem); + padding-bottom: 2.5rem; + text-align: center; +} + +.hero-title { + font-size: clamp(2rem, 5vw, 3.25rem); + font-weight: 700; + line-height: 1.15; + margin: 0 0 1rem; + letter-spacing: -0.03em; +} + +.hero-subtitle { + font-size: clamp(1rem, 2vw, 1.25rem); + color: var(--text-secondary); + max-width: 680px; + margin: 0 auto; + line-height: 1.6; +} + +/* Main content */ +.main-content { + padding-bottom: 4rem; +} + +.toolbar { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 1.75rem; +} + +@media (min-width: 768px) { + .toolbar { + flex-direction: row; + align-items: center; + justify-content: space-between; + } +} + +.stats { + color: var(--text-secondary); + font-size: 0.95rem; +} + +.engine-legend { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.legend-item { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--text-secondary); + padding: 0.25rem 0.6rem; + border-radius: var(--radius-pill); + border: 1px solid var(--border); + background: var(--bg-surface); +} + +.legend-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; +} + +/* Games grid */ +.games-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; +} + +@media (min-width: 640px) { + .games-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 1024px) { + .games-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (min-width: 1280px) { + .games-grid { + grid-template-columns: repeat(4, 1fr); + } +} + +/* Game card */ +.game-card { + display: flex; + flex-direction: column; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-sm); + transition: transform var(--transition-base), box-shadow var(--transition-base), background-color var(--transition-theme), border-color var(--transition-theme); + animation: cardEnter 0.5s ease-out both; +} + +@keyframes cardEnter { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.game-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); +} + +.game-cover { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + background: var(--bg-elevated); + overflow: hidden; +} + +.game-cover img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform var(--transition-base); +} + +.game-card:hover .game-cover img { + transform: scale(1.04); +} + +.game-cover-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); +} + +.game-cover-placeholder svg { + width: 3rem; + height: 3rem; + opacity: 0.5; +} + +.engine-badge { + position: absolute; + top: 0.75rem; + right: 0.75rem; + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.3rem 0.6rem; + border-radius: var(--radius-pill); + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #fff; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.engine-badge.renpy { background: var(--engine-renpy); } +.engine-badge.unity { background: var(--engine-unity); } +.engine-badge.web { background: var(--engine-web); } + +.engine-badge svg { + width: 0.75rem; + height: 0.75rem; +} + +.game-info { + display: flex; + flex-direction: column; + flex: 1; + padding: 1rem; + gap: 0.4rem; +} + +.game-title { + font-size: 1.1rem; + font-weight: 600; + margin: 0; + line-height: 1.3; + color: var(--text-primary); +} + +.game-subtitle { + font-size: 0.85rem; + color: var(--text-secondary); + margin: 0; +} + +.game-description { + font-size: 0.875rem; + color: var(--text-secondary); + margin: 0.25rem 0 0; + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + flex: 1; +} + +.game-meta { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 0.75rem; + font-size: 0.75rem; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.game-actions { + margin-top: 1rem; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.6rem 1.1rem; + border-radius: var(--radius-md); + font-family: inherit; + font-size: 0.9rem; + font-weight: 600; + text-decoration: none; + cursor: pointer; + border: 1px solid transparent; + transition: background-color var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-fast); +} + +.btn-primary { + background: var(--accent); + color: #fff; + width: 100%; +} + +.btn-primary:hover { + background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.btn-primary svg { + width: 0.9rem; + height: 0.9rem; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 4rem 1rem; + background: var(--bg-surface); + border: 1px dashed var(--border); + border-radius: var(--radius-lg); + transition: background-color var(--transition-theme), border-color var(--transition-theme); +} + +.empty-state.hidden { + display: none; +} + +.empty-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 4rem; + height: 4rem; + border-radius: 50%; + background: var(--bg-elevated); + color: var(--text-muted); + margin-bottom: 1rem; +} + +.empty-icon svg { + width: 2rem; + height: 2rem; +} + +.empty-state h2 { + margin: 0 0 0.5rem; + font-size: 1.25rem; + color: var(--text-primary); +} + +.empty-state p { + margin: 0; + color: var(--text-secondary); +} + +.empty-state code { + display: inline-block; + background: var(--code-bg); + padding: 0.15rem 0.4rem; + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: 0.85rem; + color: var(--text-primary); +} + +/* Skeleton loading */ +.skeleton { + background: linear-gradient(90deg, var(--bg-elevated) 25%, var(--bg-hover) 50%, var(--bg-elevated) 75%); + background-size: 200% 100%; + animation: shimmer 1.4s infinite; + border-radius: var(--radius-md); +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* Footer */ +.footer { + margin-top: auto; + padding: 1.75rem 0; + border-top: 1px solid var(--border); + background: var(--bg-surface); + transition: background-color var(--transition-theme), border-color var(--transition-theme); +} + +.footer-inner { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + color: var(--text-muted); + text-align: center; +} + +@media (min-width: 640px) { + .footer-inner { + flex-direction: row; + justify-content: space-between; + } +} + +/* Utility */ +.hidden { + display: none !important; +} diff --git a/var-www/assets/app.js b/var-www/assets/app.js new file mode 100644 index 0000000..2bd8567 --- /dev/null +++ b/var-www/assets/app.js @@ -0,0 +1,224 @@ +/** + * NovelasVM Portal + * Carga el catalogo de juegos y gestiona temas. + */ + +(function () { + 'use strict'; + + const THEME_KEY = 'novelasvm-theme'; + const THEMES = ['dark', 'light', 'immersive']; + + const engineConfig = { + renpy: { + label: 'Ren\'Py', + icon: '' + }, + 'umineko-ru': { + label: 'ONScripter-RU', + icon: '' + }, + unity: { + label: 'Unity', + icon: '' + }, + web: { + label: 'Web', + icon: '' + } + }; + + // --- Theme handling ------------------------------------------------------ + + function getSavedTheme() { + const saved = localStorage.getItem(THEME_KEY); + if (saved && THEMES.includes(saved)) return saved; + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) { + return 'light'; + } + return 'dark'; + } + + function setTheme(theme) { + if (!THEMES.includes(theme)) return; + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem(THEME_KEY, theme); + updateThemeButtons(theme); + } + + function updateThemeButtons(activeTheme) { + document.querySelectorAll('.theme-btn').forEach(btn => { + const btnTheme = btn.getAttribute('data-theme-value'); + btn.classList.toggle('active', btnTheme === activeTheme); + btn.setAttribute('aria-pressed', btnTheme === activeTheme ? 'true' : 'false'); + }); + } + + function initThemeSwitcher() { + setTheme(getSavedTheme()); + document.querySelectorAll('.theme-btn').forEach(btn => { + btn.addEventListener('click', () => setTheme(btn.getAttribute('data-theme-value'))); + }); + } + + // --- Catalog rendering --------------------------------------------------- + + function formatDate(isoString) { + if (!isoString) return ''; + try { + const date = new Date(isoString); + return date.toLocaleDateString('es-ES', { + year: 'numeric', month: 'short', day: 'numeric' + }); + } catch (e) { + return ''; + } + } + + function getEngineBadge(engine) { + const cfg = engineConfig[engine] || engineConfig.web; + return `${cfg.icon}${cfg.label}`; + } + + function getCoverImage(game) { + if (game.cover) { + return `Portada de ${escapeHtml(game.title)}`; + } + return ` +
+ +
+ `; + } + + function escapeHtml(text) { + if (text === null || text === undefined) return ''; + return String(text) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + function renderCard(game, index) { + const engine = game.engine || 'web'; + const metaParts = []; + if (game.version) metaParts.push(`v${escapeHtml(game.version)}`); + if (game.author) metaParts.push(escapeHtml(game.author)); + const date = formatDate(game.createdAt); + if (date) metaParts.push(date); + + return ` +
+
+ ${getCoverImage(game)} + ${getEngineBadge(engine)} +
+
+

${escapeHtml(game.title || game.slug)}

+ ${game.subtitle ? `

${escapeHtml(game.subtitle)}

` : ''} + ${game.description ? `

${escapeHtml(game.description)}

` : ''} +
${metaParts.join(' · ')}
+ +
+
+ `; + } + + function renderLegend() { + const legend = document.getElementById('engineLegend'); + if (!legend) return; + legend.innerHTML = Object.entries(engineConfig).map(([key, cfg]) => ` + + + ${cfg.label} + + `).join(''); + } + + function showSkeletons(grid) { + grid.innerHTML = Array.from({ length: 6 }).map(() => ` +
+
+
+
+
+
+
+
+
+ `).join(''); + } + + async function loadCatalog() { + const grid = document.getElementById('gamesGrid'); + const stats = document.getElementById('stats'); + const empty = document.getElementById('emptyState'); + + if (!grid || !stats || !empty) return; + + showSkeletons(grid); + grid.setAttribute('aria-busy', 'true'); + + try { + const response = await fetch('/games.json', { cache: 'no-store' }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + const games = Array.isArray(data.games) ? data.games : []; + + // Ordenar: primero por motor (renpy, unity, web) luego por titulo + games.sort((a, b) => { + const engineOrder = { renpy: 0, 'umineko-ru': 1, unity: 2, web: 3 }; + const ea = engineOrder[a.engine] ?? 99; + const eb = engineOrder[b.engine] ?? 99; + if (ea !== eb) return ea - eb; + return (a.title || a.slug).localeCompare(b.title || b.slug); + }); + + if (games.length === 0) { + grid.innerHTML = ''; + grid.classList.add('hidden'); + empty.classList.remove('hidden'); + stats.textContent = 'No hay novelas publicadas'; + } else { + empty.classList.add('hidden'); + grid.classList.remove('hidden'); + grid.innerHTML = games.map((game, i) => renderCard(game, i)).join(''); + const countText = games.length === 1 ? '1 novela disponible' : `${games.length} novelas disponibles`; + stats.textContent = countText; + } + } catch (err) { + console.error('Error cargando catalogo:', err); + grid.innerHTML = ''; + grid.classList.add('hidden'); + empty.classList.remove('hidden'); + empty.querySelector('h2').textContent = 'No se pudo cargar el catalogo'; + empty.querySelector('p').textContent = 'Revisa que /games.json exista o intenta recargar la pagina.'; + stats.textContent = 'Error de carga'; + } finally { + grid.setAttribute('aria-busy', 'false'); + } + } + + // --- Init ---------------------------------------------------------------- + + document.addEventListener('DOMContentLoaded', () => { + initThemeSwitcher(); + renderLegend(); + loadCatalog(); + }); +})(); diff --git a/var-www/assets/styles.css b/var-www/assets/styles.css new file mode 100644 index 0000000..29ed6a1 --- /dev/null +++ b/var-www/assets/styles.css @@ -0,0 +1,578 @@ +:root { + --font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-mono: 'JetBrains Mono', monospace; + + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + --radius-xl: 20px; + --radius-pill: 9999px; + + --transition-fast: 150ms ease; + --transition-base: 250ms ease; + --transition-theme: 300ms ease; + + --max-width: 1280px; + --navbar-height: 64px; +} + +/* Tema oscuro (default) */ +[data-theme="dark"] { + --bg-primary: #0a0a0a; + --bg-surface: #141414; + --bg-elevated: #1f1f1f; + --bg-hover: #262626; + --text-primary: #f5f5f5; + --text-secondary: #a0a0a0; + --text-muted: #666666; + --accent: #4e85bf; + --accent-hover: #3a6da3; + --border: #2a2a2a; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.45); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.55); + --engine-renpy: #8b5cf6; + --engine-unity: #1f1f1f; + --engine-web: #3b82f6; + --engine-umineko-ru: #06b6d4; + --code-bg: #1a1a1a; +} + +/* Tema claro */ +[data-theme="light"] { + --bg-primary: #fafafa; + --bg-surface: #ffffff; + --bg-elevated: #f3f4f6; + --bg-hover: #e5e7eb; + --text-primary: #111827; + --text-secondary: #4b5563; + --text-muted: #9ca3af; + --accent: #2563eb; + --accent-hover: #1d4ed8; + --border: #e5e7eb; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); + --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.1); + --engine-renpy: #7c3aed; + --engine-unity: #111827; + --engine-web: #2563eb; + --engine-umineko-ru: #0891b2; + --code-bg: #f3f4f6; +} + +/* Tema inmersivo */ +[data-theme="immersive"] { + --bg-primary: #050510; + --bg-surface: rgba(20, 20, 45, 0.62); + --bg-elevated: rgba(32, 32, 66, 0.72); + --bg-hover: rgba(45, 45, 90, 0.8); + --text-primary: #ffffff; + --text-secondary: rgba(255, 255, 255, 0.78); + --text-muted: rgba(255, 255, 255, 0.5); + --accent: #8b5cf6; + --accent-hover: #7c3aed; + --border: rgba(255, 255, 255, 0.12); + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35); + --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.45); + --shadow-lg: 0 20px 48px rgba(0, 0, 0, 0.55); + --engine-renpy: #a78bfa; + --engine-unity: #0f172a; + --engine-web: #60a5fa; + --engine-umineko-ru: #22d3ee; + --code-bg: rgba(15, 15, 35, 0.7); +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + font-family: var(--font-sans); + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + line-height: 1.6; + transition: background-color var(--transition-theme), color var(--transition-theme); +} + +[data-theme="immersive"] body { + background: + radial-gradient(circle at 15% 25%, rgba(76, 29, 149, 0.25) 0%, transparent 35%), + radial-gradient(circle at 85% 70%, rgba(59, 130, 246, 0.18) 0%, transparent 30%), + linear-gradient(180deg, #050510 0%, #0f172a 100%); + background-attachment: fixed; +} + +.container { + width: 100%; + max-width: var(--max-width); + margin: 0 auto; + padding: 0 1rem; +} + +/* Navbar */ +.navbar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: var(--navbar-height); + background: var(--bg-surface); + border-bottom: 1px solid var(--border); + z-index: 100; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + transition: background-color var(--transition-theme), border-color var(--transition-theme); +} + +.navbar-inner { + display: flex; + align-items: center; + justify-content: space-between; + height: 100%; +} + +.brand { + display: inline-flex; + align-items: center; + gap: 0.6rem; + color: var(--text-primary); + text-decoration: none; + font-weight: 700; + font-size: 1.25rem; + letter-spacing: -0.02em; +} + +.brand-icon { + width: 1.6rem; + height: 1.6rem; + color: var(--accent); +} + +.theme-switcher { + display: inline-flex; + align-items: center; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-pill); + padding: 0.25rem; + gap: 0.15rem; + transition: background-color var(--transition-theme), border-color var(--transition-theme); +} + +.theme-btn { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.4rem 0.65rem; + border: none; + border-radius: var(--radius-pill); + background: transparent; + color: var(--text-secondary); + font-family: inherit; + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: background-color var(--transition-fast), color var(--transition-fast), transform var(--transition-fast); +} + +.theme-btn svg { + width: 0.95rem; + height: 0.95rem; +} + +.theme-btn:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +.theme-btn.active { + background: var(--accent); + color: #fff; + box-shadow: var(--shadow-sm); +} + +.theme-btn span { + display: none; +} + +@media (min-width: 640px) { + .theme-btn span { + display: inline; + } + .theme-btn { + padding: 0.45rem 0.9rem; + } +} + +/* Hero */ +.hero { + padding-top: calc(var(--navbar-height) + 3rem); + padding-bottom: 2.5rem; + text-align: center; +} + +.hero-title { + font-size: clamp(2rem, 5vw, 3.25rem); + font-weight: 700; + line-height: 1.15; + margin: 0 0 1rem; + letter-spacing: -0.03em; +} + +.hero-subtitle { + font-size: clamp(1rem, 2vw, 1.25rem); + color: var(--text-secondary); + max-width: 680px; + margin: 0 auto; + line-height: 1.6; +} + +/* Main content */ +.main-content { + padding-bottom: 4rem; +} + +.toolbar { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 1.75rem; +} + +@media (min-width: 768px) { + .toolbar { + flex-direction: row; + align-items: center; + justify-content: space-between; + } +} + +.stats { + color: var(--text-secondary); + font-size: 0.95rem; +} + +.engine-legend { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.legend-item { + display: inline-flex; + align-items: center; + gap: 0.35rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--text-secondary); + padding: 0.25rem 0.6rem; + border-radius: var(--radius-pill); + border: 1px solid var(--border); + background: var(--bg-surface); +} + +.legend-dot { + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; +} + +/* Games grid */ +.games-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; +} + +@media (min-width: 640px) { + .games-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (min-width: 1024px) { + .games-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (min-width: 1280px) { + .games-grid { + grid-template-columns: repeat(4, 1fr); + } +} + +/* Game card */ +.game-card { + display: flex; + flex-direction: column; + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-sm); + transition: transform var(--transition-base), box-shadow var(--transition-base), background-color var(--transition-theme), border-color var(--transition-theme); + animation: cardEnter 0.5s ease-out both; +} + +@keyframes cardEnter { + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.game-card:hover { + transform: translateY(-4px); + box-shadow: var(--shadow-lg); +} + +.game-cover { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + background: var(--bg-elevated); + overflow: hidden; +} + +.game-cover img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform var(--transition-base); +} + +.game-card:hover .game-cover img { + transform: scale(1.04); +} + +.game-cover-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); +} + +.game-cover-placeholder svg { + width: 3rem; + height: 3rem; + opacity: 0.5; +} + +.engine-badge { + position: absolute; + top: 0.75rem; + right: 0.75rem; + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.3rem 0.6rem; + border-radius: var(--radius-pill); + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + color: #fff; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(8px); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.engine-badge.renpy { background: var(--engine-renpy); } +.engine-badge.unity { background: var(--engine-unity); } +.engine-badge.web { background: var(--engine-web); } +.engine-badge.umineko-ru { background: var(--engine-umineko-ru); } + +.engine-badge svg { + width: 0.75rem; + height: 0.75rem; +} + +.game-info { + display: flex; + flex-direction: column; + flex: 1; + padding: 1rem; + gap: 0.4rem; +} + +.game-title { + font-size: 1.1rem; + font-weight: 600; + margin: 0; + line-height: 1.3; + color: var(--text-primary); +} + +.game-subtitle { + font-size: 0.85rem; + color: var(--text-secondary); + margin: 0; +} + +.game-description { + font-size: 0.875rem; + color: var(--text-secondary); + margin: 0.25rem 0 0; + line-height: 1.5; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + flex: 1; +} + +.game-meta { + display: flex; + align-items: center; + gap: 0.75rem; + margin-top: 0.75rem; + font-size: 0.75rem; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.game-actions { + margin-top: 1rem; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.6rem 1.1rem; + border-radius: var(--radius-md); + font-family: inherit; + font-size: 0.9rem; + font-weight: 600; + text-decoration: none; + cursor: pointer; + border: 1px solid transparent; + transition: background-color var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-fast); +} + +.btn-primary { + background: var(--accent); + color: #fff; + width: 100%; +} + +.btn-primary:hover { + background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + +.btn-primary svg { + width: 0.9rem; + height: 0.9rem; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 4rem 1rem; + background: var(--bg-surface); + border: 1px dashed var(--border); + border-radius: var(--radius-lg); + transition: background-color var(--transition-theme), border-color var(--transition-theme); +} + +.empty-state.hidden { + display: none; +} + +.empty-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 4rem; + height: 4rem; + border-radius: 50%; + background: var(--bg-elevated); + color: var(--text-muted); + margin-bottom: 1rem; +} + +.empty-icon svg { + width: 2rem; + height: 2rem; +} + +.empty-state h2 { + margin: 0 0 0.5rem; + font-size: 1.25rem; + color: var(--text-primary); +} + +.empty-state p { + margin: 0; + color: var(--text-secondary); +} + +.empty-state code { + display: inline-block; + background: var(--code-bg); + padding: 0.15rem 0.4rem; + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: 0.85rem; + color: var(--text-primary); +} + +/* Skeleton loading */ +.skeleton { + background: linear-gradient(90deg, var(--bg-elevated) 25%, var(--bg-hover) 50%, var(--bg-elevated) 75%); + background-size: 200% 100%; + animation: shimmer 1.4s infinite; + border-radius: var(--radius-md); +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* Footer */ +.footer { + margin-top: auto; + padding: 1.75rem 0; + border-top: 1px solid var(--border); + background: var(--bg-surface); + transition: background-color var(--transition-theme), border-color var(--transition-theme); +} + +.footer-inner { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + font-size: 0.85rem; + color: var(--text-muted); + text-align: center; +} + +@media (min-width: 640px) { + .footer-inner { + flex-direction: row; + justify-content: space-between; + } +} + +/* Utility */ +.hidden { + display: none !important; +} diff --git a/var-www/index.html b/var-www/index.html new file mode 100644 index 0000000..53a6f59 --- /dev/null +++ b/var-www/index.html @@ -0,0 +1,98 @@ + + + + + + NovelasVM - Plataforma de Novelas Visuales + + + + + + + + + +
+
+

Tu biblioteca de novelas visuales

+

Juega, prueba y comparte novelas visuales desde el navegador. Soporta Ren'Py, Unity WebGL y proyectos web personalizados.

+
+
+ +
+
+
Cargando novelas...
+
+
+ +
+ +
+ + +
+ +
+ +
+ + + +