Rama de desarrollo experimental sobre el Oasis de epsylon, sin afiliacion con el proyecto original. Base: 0.9.5. Trae el modulo Karvan —salas efimeras en memoria con autodestruccion por TTL, chat que funciona sin JavaScript y llamadas de audio y video sobre WebRTC— con el enlace en el menu lateral por el mismo patron que el resto de modulos: renderKarvanLink calcado de renderPollsLink, entrada en modules_view y en la lista de /modules. Los estilos del modulo van en su propia hoja, styles/karvan.css, siguiendo el patron de highlight.css: asi se ve igual con cualquier tema, sin depender del tema movil. Sin servidores ICE de terceros: no hay STUN de fabrica, porque un STUN aprende la IP publica y el momento de cada llamada. Solo candidatos host salvo que se configure un TURN propio en oasis-config.json, con credenciales efimeras por el mecanismo REST de coturn. El codigo propio vive separado —views/fork/, backend/fork_routes.js, models/karvan_model.js y translations/fork/— de modo que sobre los ficheros de upstream solo hay enganches de una linea y cada version nueva se integra sin arrastrar nada. Respecto al arbol de Android: fuera el wrapper y main.js, que es su arranque; el tema vuelve a Dark-SNH y se restauran los modulos que en movil se recortan por peso. Verificado arrancando sin OASIS_MOBILE: nueve rutas OK, menu lateral de upstream con 27 grupos y el enlace de Karvan, cero rastro de la interfaz movil (ni hexagonos, ni topbar, ni barra inferior), y una sala creada y servida.
216 lines
9.1 KiB
Markdown
216 lines
9.1 KiB
Markdown
# 3. El módulo de juegos y la anatomía de un módulo
|
||
|
||
> **Hallazgo clave que cambia las expectativas:** el módulo de juegos de Oasis
|
||
> **NO es un sistema multijugador asíncrono por SSB**. Es mucho más simple:
|
||
>
|
||
> - Cada juego es un **único `index.html` autocontenido** (HTML + CSS + JS
|
||
> inline), 100% client-side, servido como archivo estático y embebido en un
|
||
> `<iframe>`. No hay JS externos, ni sprites, ni carpetas de assets: cada
|
||
> carpeta de juego tiene **solo 2 archivos**: `index.html` + `thumbnail.svg`.
|
||
> - La **única integración con SSB** es publicar un mensaje tipo `gameScore`
|
||
> (puntuación individual) para un **Hall of Fame** (ranking global). No hay
|
||
> retos, ni partidas, ni movimientos sincronizados. El "multijugador" se
|
||
> reduce a comparar las puntuaciones de distintos autores leyendo el log SSB.
|
||
|
||
---
|
||
|
||
## 3.1 El modelo: `src/models/games_model.js` (67 líneas)
|
||
|
||
Minimalista. Solo gestiona puntuaciones. Patrón factory estándar:
|
||
`module.exports = ({ cooler }) => {...}`.
|
||
|
||
- **Líneas 5-9**: lista blanca `VALID_GAMES` (Set de ids permitidos). ⚠️ Incluye
|
||
14 juegos pero **NO** `rockpaperscissors` ni `audiopendulum` (que sí están en
|
||
la vista): esos dos **no pueden enviar score** (el modelo los rechaza).
|
||
- **Líneas 18-25** `readAll()`: vuelca todo el log con `createLogStream`.
|
||
- **Líneas 28-38** `submitScore(game, score)` — único método de escritura:
|
||
|
||
```js
|
||
async submitScore(game, score) {
|
||
if (!VALID_GAMES.has(game)) throw new Error('invalid game');
|
||
const n = Number(score);
|
||
if (!Number.isFinite(n) || n < 0 || n > 9999999) throw new Error('invalid score');
|
||
const ssbClient = await openSsb();
|
||
return new Promise((resolve, reject) => {
|
||
ssbClient.publish({ type: 'gameScore', game, score: Math.round(n) },
|
||
(err, msg) => err ? reject(err) : resolve(msg));
|
||
});
|
||
}
|
||
```
|
||
|
||
El mensaje SSB resultante: `{ type: 'gameScore', game: '<id>', score: <int> }`.
|
||
|
||
- **Líneas 40-65** `getHallOfFame()`: recorre el log, filtra
|
||
`content.type === 'gameScore'`, y por cada par `game:author` guarda **solo la
|
||
mejor puntuación**. Devuelve `hall[game] = [{author, score, game, ts}, ...]`
|
||
ordenado descendente y truncado a top-10 por juego.
|
||
|
||
---
|
||
|
||
## 3.2 La vista: `src/views/games_view.js` (151 líneas)
|
||
|
||
- **Líneas 5-22** `getGames()`: catálogo hardcodeado de **16 juegos**. Cada
|
||
entrada es `{ id, title: () => i18n.<key>, desc: () => i18n.<key> }`. Es el
|
||
registro maestro de la vista.
|
||
- **Línea 58** `VALID_GAME_IDS`: un **tercer** Set, usado para validar la ruta
|
||
del shell. Incluye los 16.
|
||
- **Líneas 26-56** `renderHallOfFame(hall)`: tabla por juego con thumbnail
|
||
(`/game-assets/${id}/thumbnail.svg`), título, descripción y filas (posición,
|
||
`userLink(author)`, score, fecha).
|
||
- **Líneas 60-93** `gameShellView(name)`: así se **embebe cada juego**. Valida
|
||
`name` contra `VALID_GAME_IDS` y renderiza un iframe:
|
||
|
||
```js
|
||
iframe({
|
||
src: `/game-assets/${name}/index.html`,
|
||
class: `game-iframe game-iframe-${name}`,
|
||
scrolling: 'no',
|
||
allowfullscreen: true
|
||
})
|
||
```
|
||
|
||
- **Líneas 95-151** `gamesView(filter, hall)`: el **lobby**. Barra de filtros
|
||
(`all` / `scoring`). En modo `all` lista cada juego con thumbnail, título,
|
||
récord top y botón PLAY → `/games/${id}`. En modo `scoring` invoca
|
||
`renderHallOfFame`.
|
||
|
||
> ⚠️ Hay **TRES listas** que deben mantenerse sincronizadas: `getGames()` y
|
||
> `VALID_GAME_IDS` (vista) + `VALID_GAMES` (modelo). La inconsistencia de
|
||
> `rockpaperscissors`/`audiopendulum` lo demuestra.
|
||
|
||
---
|
||
|
||
## 3.3 Estructura de un juego: `src/games/<nombre>/`
|
||
|
||
17 carpetas: `8ball, arkanoid, artillery, asteroids, audiopendulum, cocoland,
|
||
cocoman, ecoinflow, flipflop, labyrinth, neoninfiltrator, pingpong,
|
||
rockpaperscissors, spaceinvaders, tetris, tiktaktoe`.
|
||
|
||
**TODOS los juegos tienen exactamente 2 archivos**:
|
||
- `index.html` (6-10 KB): documento completo y autosuficiente, con `<style>` y
|
||
`<script>` inline. Sin dependencias externas, sin CDN.
|
||
- `thumbnail.svg` (~1-2 KB): miniatura SVG vectorial (viewBox ~`0 0 400 220`).
|
||
|
||
No existe distinción "arcade" vs "social SSB": todos son arcade HTML5/canvas o
|
||
DOM puro, client-side, en iframe. La única diferencia es si **envían score** o
|
||
no.
|
||
|
||
**Mecanismo de envío de score** (idéntico en todos los juegos puntuables;
|
||
ejemplo `tiktaktoe/index.html`):
|
||
|
||
```html
|
||
<div id="scoreSubmit" style="display:none">
|
||
<form method="POST" action="/games/submit-score" target="_top">
|
||
<input type="hidden" name="game" value="tiktaktoe">
|
||
<input type="hidden" id="scoreInput" name="score" value="0">
|
||
<button type="submit">Submit Score to Hall of Fame</button>
|
||
</form>
|
||
</div>
|
||
```
|
||
|
||
Claves del patrón:
|
||
- `target="_top"` → el POST **rompe el iframe** y navega la ventana superior
|
||
(necesario porque el juego corre sandboxeado dentro de `/games/<name>`).
|
||
- El JS del juego rellena `#scoreInput` al terminar
|
||
(`document.getElementById('scoreInput').value = score;`) y muestra el `div`.
|
||
- Cada juego tiene una topbar con `<a href="/games" target="_top">← Back</a>`.
|
||
|
||
El resto del `index.html` es lógica de juego propia en JS vanilla (tiktaktoe usa
|
||
minimax con poda alfa-beta; tetris usa canvas).
|
||
|
||
`gameScore` además es un tipo SSB de primera clase **indexado en búsqueda**
|
||
(`src/views/search_view.js` lo mapea a `/games/<game>` y lo renderiza con su
|
||
thumbnail).
|
||
|
||
---
|
||
|
||
## 3.4 Cómo se sirve y enruta
|
||
|
||
**Servido estático** — `src/client/middleware.js` (líneas 109-111):
|
||
|
||
```js
|
||
const gamesStatic = new Koa();
|
||
gamesStatic.use(koaStatic(join(__dirname, "..", "games")));
|
||
app.use(mount("/game-assets", gamesStatic));
|
||
```
|
||
|
||
Sub-app Koa con `koa-static` sobre `src/games/`, montada en `/game-assets`. Por
|
||
eso `/game-assets/tetris/index.html` sirve `src/games/tetris/index.html`.
|
||
**Independiente del router principal** → una carpeta nueva queda servida
|
||
automáticamente.
|
||
|
||
**Rutas dinámicas** — `src/backend/backend.js`:
|
||
- **Línea 598**: `const gamesModel = require('../models/games_model')({ cooler });`
|
||
- **Línea 1168**: `const { gamesView } = require("../views/games_view");`
|
||
- **Líneas 1358-1373**:
|
||
|
||
```js
|
||
.get('/games', async (ctx) => {
|
||
if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; }
|
||
const filter = ctx.query.filter === 'scoring' ? 'scoring' : 'all';
|
||
const hall = await gamesModel.getHallOfFame();
|
||
ctx.body = gamesView(filter, hall);
|
||
})
|
||
.get('/games/:name', async (ctx) => {
|
||
if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; }
|
||
const { gameShellView } = require('../views/games_view');
|
||
ctx.body = gameShellView(ctx.params.name);
|
||
})
|
||
.post('/games/submit-score', koaBody(), async (ctx) => {
|
||
if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; }
|
||
const { game, score } = ctx.request.body;
|
||
try { await gamesModel.submitScore(game, score); } catch (_) {}
|
||
ctx.redirect('/games?filter=scoring');
|
||
})
|
||
```
|
||
|
||
Flujo completo:
|
||
|
||
```
|
||
/games (lobby)
|
||
└─ PLAY → /games/:name (shell con iframe)
|
||
└─ iframe carga /game-assets/:name/index.html
|
||
└─ al terminar: POST /games/submit-score (target="_top")
|
||
└─ redirect /games?filter=scoring (Hall of Fame)
|
||
```
|
||
|
||
El guard `checkMod(ctx, 'gamesMod')` (líneas 170-177) revisa la config del
|
||
servidor + una cookie por-usuario; default `on`. Si está off → `/modules`.
|
||
|
||
---
|
||
|
||
## 3.5 Cómo se registra el módulo (puntos de integración)
|
||
|
||
| Lugar | Archivo | Qué hay |
|
||
|---|---|---|
|
||
| **Menú** | `src/views/main_views.js:898` | `renderGamesLink()` (emite el `navLink` si `gamesMod==="on"`); se invoca en línea 1280 dentro del `navGroup` "creative" |
|
||
| **Config** | `src/configs/oasis-config.json` | `"gamesMod": "on"` dentro de `modules` |
|
||
| **Página /modules** | `src/views/modules_view.js:23` | `{ name: 'games', label, description }` |
|
||
| **Listas de backend** | `src/backend/backend.js` | `'games'` en línea 1346 (GET /modules), 7054 (POST /save-modules), 7038 (`ALL_MODULES`) y presets (7040-7042) |
|
||
| **i18n** | `src/client/assets/translations/oasis_*.js` (×11) | `gamesTitle`, `gamesDescription`, `modulesGamesLabel/Description`, y por juego `games<Nombre>Title` + `games<Nombre>Desc` |
|
||
|
||
> El nombre del módulo está **hardcodeado y duplicado en muchos sitios**; no hay
|
||
> un registro central único. Por eso, al crear un módulo, cada punto es
|
||
> obligatorio. → [04-tutorial-modulo-desde-cero.md](04-tutorial-modulo-desde-cero.md)
|
||
|
||
---
|
||
|
||
## 3.6 Receta rápida: añadir un juego NUEVO al sistema existente
|
||
|
||
Para añadir un juego `foobar`:
|
||
|
||
1. **`src/games/foobar/index.html`** — HTML autocontenido (CSS+JS inline).
|
||
Incluir la topbar `← Back` y, si puntúa, el form oculto a
|
||
`/games/submit-score` con `target="_top"` y `name="game" value="foobar"`.
|
||
2. **`src/games/foobar/thumbnail.svg`** — miniatura.
|
||
3. **`src/views/games_view.js`**:
|
||
- Añadir a `getGames()`:
|
||
`{ id: 'foobar', title: () => i18n.gamesFoobarTitle, desc: () => i18n.gamesFoobarDesc }`.
|
||
- Añadir `'foobar'` a `VALID_GAME_IDS`.
|
||
4. **`src/models/games_model.js`** — añadir `'foobar'` a `VALID_GAMES`
|
||
(**obligatorio si puntúa**, o `submitScore` lo rechaza).
|
||
5. **i18n** — añadir `gamesFoobarTitle` y `gamesFoobarDesc` en TODOS los
|
||
`oasis_*.js`.
|
||
|
||
No hay que tocar el backend ni el static serving (la carpeta nueva ya se sirve
|
||
por `/game-assets/`).
|