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.
9.1 KiB
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.htmlautocontenido (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 NOrockpaperscissorsniaudiopendulum(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 concreateLogStream. - Líneas 28-38
submitScore(game, score)— único método de escritura:
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, filtracontent.type === 'gameScore', y por cada pargame:authorguarda solo la mejor puntuación. Devuelvehall[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. ValidanamecontraVALID_GAME_IDSy renderiza un iframe:
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 modoalllista cada juego con thumbnail, título, récord top y botón PLAY →/games/${id}. En modoscoringinvocarenderHallOfFame.
⚠️ Hay TRES listas que deben mantenerse sincronizadas:
getGames()yVALID_GAME_IDS(vista) +VALID_GAMES(modelo). La inconsistencia derockpaperscissors/audiopendulumlo 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):
<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
#scoreInputal terminar (document.getElementById('scoreInput').value = score;) y muestra eldiv. - 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):
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:
.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
3.6 Receta rápida: añadir un juego NUEVO al sistema existente
Para añadir un juego foobar:
src/games/foobar/index.html— HTML autocontenido (CSS+JS inline). Incluir la topbar← Backy, si puntúa, el form oculto a/games/submit-scorecontarget="_top"yname="game" value="foobar".src/games/foobar/thumbnail.svg— miniatura.src/views/games_view.js:- Añadir a
getGames():{ id: 'foobar', title: () => i18n.gamesFoobarTitle, desc: () => i18n.gamesFoobarDesc }. - Añadir
'foobar'aVALID_GAME_IDS.
- Añadir a
src/models/games_model.js— añadir'foobar'aVALID_GAMES(obligatorio si puntúa, osubmitScorelo rechaza).- i18n — añadir
gamesFoobarTitleygamesFoobarDescen TODOS losoasis_*.js.
No hay que tocar el backend ni el static serving (la carpeta nueva ya se sirve
por /game-assets/).