juegos

Juegos de terminal de Pancho: Catan (TUI, GUI, web, servidor, PicoCalc), ajedrez, calculadora y minijuegos
git clone https://git.lu3dhn.xyz/juegos.git
Log | Files | Refs

commit bfca84137e742c1f661b0f61088aee91a00b051c
parent bf9052015988e5d4bfc0faaeb88f0a90e441ec3b
Author: Francisco Armando Rosmino <franciscorosmino@rosmino.com.ar>
Date:   Sat,  3 Oct 2026 12:19:18 -0300

Web: no perder lo guardado, una sola instancia, service worker consistente

- pre.js: autoPersist recien cuando la carga de IndexedDB salio bien; si fallo,
  Module.noPersist y paths_flush no escribe (syncfs borraba de IndexedDB todo lo
  que no estaba en memoria).
- shell.html: candado de instancia con Web Locks (dos pestanas se pisaban las
  partidas); la recarga por version nueva solo si ya habia un service worker
  (la primera visita se recargaba sola); placeholder 'toca aca para escribir'.
- sw.js: addAll con cache: 'reload' (podia quedar un index.js viejo con un wasm
  nuevo); Makefile: la version del SW sale de todo lo publicado, no solo del
  wasm; release/publish en .PHONY; regla de iconos con patron.
- app.c: sin 'web_user' como nombre por defecto; el tope de los campos de texto
  va al cuadro del navegador (cortaba utf-8 a la mitad); text_input distingue
  cancelado de 'no hay cuadro' (cancelar el chat dejaba el teclado en modo chat).
- guikit.c: se descartan los toques hechos con el cuadro abierto; el arrastre
  se suelta si el boton ya no esta apretado; con timeout 0 cede al navegador;
  sin FILL_DOCUMENT (no hacia nada con el canvas por CSS).
- paths.c: paths_flush espera solo a su propio sync; session.c: un sync por
  turno en vez de uno por movida.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcatan/Makefile | 6+++---
Mcatan/linux/app.c | 18+++++++++++-------
Mcatan/linux/frontend.h | 5+++--
Mcatan/linux/gui_sdl.c | 2+-
Mcatan/linux/session.c | 2+-
Mcatan/web/pre.js | 18++++++++++++++----
Mcatan/web/shell.html | 35+++++++++++++++++++++++++++++++----
Mcatan/web/sw.js | 6+++++-
Mcomun/linux/guikit.c | 27+++++++++++++++++----------
Mcomun/linux/guikit.h | 6+++---
Mcomun/linux/paths.c | 13+++++++++----
11 files changed, 98 insertions(+), 40 deletions(-)

diff --git a/catan/Makefile b/catan/Makefile @@ -102,10 +102,10 @@ web: $(WEB_SRC) $(HDRS) $(WEB_SDL)/lib/libSDL3.a web/shell.html web/pre.js web/s mkdir -p $(WEB_OUT) $(EMCC) $(CPPFLAGS) -I$(WEB_SDL)/include $(WEB_CFLAGS) $(WEB_LDFLAGS) -o $(WEB_OUT)/index.html $(WEB_SRC) $(WEB_SDL)/lib/libSDL3.a cp web/manifest.json web/icon-192.png web/icon-512.png $(WEB_OUT)/ - sed "s/@VERSION@/$$(sha1sum $(WEB_OUT)/index.wasm | cut -c1-12)/" web/sw.js > $(WEB_OUT)/sw.js + sed "s/@VERSION@/$$(cat $(WEB_OUT)/index.html $(WEB_OUT)/index.js $(WEB_OUT)/index.wasm $(WEB_OUT)/manifest.json web/sw.js | sha1sum | cut -c1-12)/" web/sw.js > $(WEB_OUT)/sw.js @ls -la $(WEB_OUT)/index.wasm | awk '{ print "wasm:", $$5, "bytes" }' -web/icon-192.png web/icon-512.png: web/mkicon.py +web/icon-%.png: web/mkicon.py python3 web/mkicon.py # sube web/dist al VPS (Caddy sirve /var/www/catan en https://catan.lu3dhn.xyz); sin rsync @@ -143,4 +143,4 @@ clean: rm -f catan catan-server catan-picosim catan.exe rm -rf pico/build -.PHONY: all debug test clean pico catan-server-static windows web deploy-web +.PHONY: all debug test clean pico catan-server-static windows web deploy-web release publish diff --git a/catan/linux/app.c b/catan/linux/app.c @@ -24,8 +24,12 @@ void settings_load(Settings *st) st->bot_hard = 1; st->layout = LAYOUT_WIDE; st->scale = 2; +#ifdef __EMSCRIPTEN__ + const char *u = 0; /* emscripten inventa USER=web_user */ +#else const char *u = getenv("USER"); if (!u || !*u) u = getenv("USERNAME"); /* Windows */ +#endif snprintf(st->name, sizeof st->name, "%s", u && *u ? u : "Yo"); FILE *f = fopen(config_path(), "r"); if (!f) return; @@ -200,10 +204,9 @@ int run_form(Frontend *fe, Form *f, void (*on_change)(Form *, void *), void *ctx if (r == FORM_NONE && x && x->type == F_TEXT && !x->disabled && fe->text_input) { char tmp[sizeof x->text]; /* la web: cuadro de texto del navegador */ snprintf(tmp, sizeof tmp, "%s", x->text); - if (fe->text_input(fe, x->label, tmp, sizeof tmp)) { - if (x->maxlen && strlen(tmp) > x->maxlen) tmp[x->maxlen] = 0; - snprintf(x->text, sizeof x->text, "%s", tmp); - } + /* el tope en bytes va al cuadro, que corta en borde de caracter utf-8 */ + int cap = x->maxlen && x->maxlen + 1 < (int)sizeof tmp ? x->maxlen + 1 : (int)sizeof tmp; + if (fe->text_input(fe, x->label, tmp, cap) > 0) snprintf(x->text, sizeof x->text, "%s", tmp); } } } @@ -323,7 +326,7 @@ void app_play(Frontend *fe, Session *s, Settings *st) if (ev.type == EV_CLICK && !modal && !help) { /* botones de la pantalla tactil: valen como teclas */ const Hit *h = hit_find(&fe->hits, ev.x, ev.y); if (h && h->kind == PK_APP) { - int k = h->id == 7 ? K_BACK : h->id == 9 ? K_OK : h->id == 10 ? 'c' : h->id == 11 ? 'L' : 0; + int k = h->id == 7 ? K_BACK : h->id == 9 ? K_OK : h->id == 10 ? 'c' : 0; if (k) { ev.type = EV_KEY; ev.key = k; typing = false; } } } @@ -414,12 +417,13 @@ void app_play(Frontend *fe, Session *s, Settings *st) if (app && h->id == 6) { /* caja de chat */ char tmp[sizeof chat]; snprintf(tmp, sizeof tmp, "%s", chat); - if (fe->text_input && fe->text_input(fe, "Chat", tmp, sizeof tmp)) { /* la web */ + int ti = fe->text_input ? fe->text_input(fe, "Chat", tmp, sizeof tmp) : -1; /* la web: cuadro propio */ + if (ti > 0) { c.msg[0] = 0; const char *err = tmp[0] ? sess_say(s, tmp) : 0; if (err) { snprintf(c.msg, sizeof c.msg, "%s", err); c.msg_err = true; } else { chat[0] = 0; ui.log_scroll = 0; } - } else typing = true; + } else if (ti < 0) typing = true; /* sin cuadro: se escribe con el teclado; cancelado: nada */ continue; } if (app && (h->id == 4 || h->id == 5)) { /* barra de scroll */ diff --git a/catan/linux/frontend.h b/catan/linux/frontend.h @@ -13,8 +13,9 @@ struct Frontend { int (*wait)(Frontend *f, int timeout_ms, const int *fds, int nfds, InEvent *ev); void (*close)(Frontend *f); /* Opcional: pide una linea de texto con un cuadro del sistema (la web, donde no hay - * teclado tactil para el canvas). NULL o false: se escribe con el teclado. */ - bool (*text_input)(Frontend *f, const char *title, char *buf, int n); + * teclado tactil para el canvas). 1 = texto en buf, 0 = cancelado, -1 = no hay + * cuadro (NULL o -1: se escribe con el teclado). */ + int (*text_input)(Frontend *f, const char *title, char *buf, int n); bool is_gui; bool touch; /* pantalla tactil (la web en el telefono): botones en vez de teclas */ HitList hits; /* zonas clickeables del ultimo dibujo */ diff --git a/catan/linux/gui_sdl.c b/catan/linux/gui_sdl.c @@ -37,7 +37,7 @@ static void gui_close(Frontend *f) gk_close(); } -static bool gui_text_input(Frontend *f, const char *title, char *buf, int n) +static int gui_text_input(Frontend *f, const char *title, char *buf, int n) { (void)f; return gk_text_input(title, buf, n); diff --git a/catan/linux/session.c b/catan/linux/session.c @@ -139,7 +139,7 @@ static void local_on_move(void *ctx, const Move *m) move_fmt(m, line, sizeof line); fprintf(s->save, "M %s\n", line); fflush(s->save); - paths_flush(); + if (m->type == M_END) paths_flush(); /* la web: un sync por turno alcanza (autoPersist hace el resto) */ } if (s->net) net_on_local_move(s->net, m); } diff --git a/catan/web/pre.js b/catan/web/pre.js @@ -1,18 +1,28 @@ // pre.js - antes de main(): monta /data en IDBFS (IndexedDB) para que la config, las // partidas locales y los tokens (data_dir() en comun/linux/paths.c) sobrevivan a -// cerrar la pestana. autoPersist copia a IndexedDB solo; paths_flush() ademas espera -// a que termine despues de guardar algo importante. +// cerrar la pestana. Se carga primero y recien si salio bien se activa autoPersist: +// si la carga fallara y se escribiera igual, syncfs borraria de IndexedDB todo lo +// que no esta en memoria. paths_flush() ademas espera el sync tras guardar algo +// importante, y no hace nada si Module.noPersist. Module.preRun = Module.preRun || []; Module.preRun.push(function () { try { FS.mkdir('/data'); - FS.mount(IDBFS, { autoPersist: true }, '/data'); + FS.mount(IDBFS, {}, '/data'); addRunDependency('idbfs'); FS.syncfs(true, function (err) { - if (err) console.warn('IDBFS', err); + if (err) { + console.warn('IDBFS: no se pudo cargar, se juega sin guardar', err); + Module.noPersist = true; + if (Module.setStatus) Module.setStatus('Sin almacenamiento: esta sesion no guarda partidas'); + } else { + var m = FS.lookupPath('/data').node.mount; + if (m && m.opts) m.opts.autoPersist = true; + } removeRunDependency('idbfs'); }); } catch (e) { console.warn('sin persistencia', e); + Module.noPersist = true; } }); diff --git a/catan/web/shell.html b/catan/web/shell.html @@ -42,13 +42,15 @@ <div id="gk-input"> <label id="gk-label" for="gk-field"></label> <div class="row"> - <input id="gk-field" type="text" autocomplete="off" autocapitalize="off" enterkeyhint="done"> + <input id="gk-field" type="text" autocomplete="off" autocapitalize="off" enterkeyhint="done" placeholder="tocá acá para escribir"> <button id="gk-ok">OK</button> <button id="gk-cancel" class="cancel">✕</button> </div> </div> <canvas id="canvas" tabindex="-1" oncontextmenu="event.preventDefault()"></canvas> <script> + // Si otra pestana tiene el candado, no se arranca el modulo (ver mas abajo) + var _origLoad = null; // ---- entrada de texto para la app en C (gk_text_input en comun/linux/guikit.c) var gkText = { done: 0, value: '' }; var gkBox = document.getElementById('gk-input'), gkField = document.getElementById('gk-field'); @@ -91,6 +93,18 @@ // ---- el modulo emscripten var wsParam = new URLSearchParams(location.search).get('ws'); var Module = { + noStart: false, + // el candado de instancia se pide arriba de forma asincronica: antes de correr + // main se espera a saber si lo tenemos + preInit: [function () { + addRunDependency('lock'); + var tries = 0; + (function wait() { + if (Module.noStart) return; // nunca se libera: la pagina queda en el aviso + if (Module.lockDone || !(navigator.locks && navigator.locks.request) || ++tries > 50) removeRunDependency('lock'); + else setTimeout(wait, 20); + })(); + }], canvas: document.getElementById('canvas'), // los sockets BSD del programa van por este WebSocket: /ws de la misma pagina // (Caddy lo pasa al catan-server), o ?ws=ws://localhost:7373 para probar local @@ -112,11 +126,24 @@ printErr: function (t) { console.warn(t); } }; if ('serviceWorker' in navigator && location.protocol === 'https:') { - navigator.serviceWorker.register('sw.js').catch(function (e) { console.warn('sw', e); }); // la app sale de la cache (cache-first); cuando una version nueva toma el control, - // recargar si recien se abrio (todavia en el menu), para no cortar una partida + // recargar si recien se abrio (todavia en el menu), para no cortar una partida. + // En la primera visita no habia controlador: ahi el SW nuevo no es una actualizacion. + var hadSW = !!navigator.serviceWorker.controller; + navigator.serviceWorker.register('sw.js').catch(function (e) { console.warn('sw', e); }); navigator.serviceWorker.addEventListener('controllerchange', function () { - if (performance.now() < 15000) location.reload(); + if (hadSW && performance.now() < 15000) location.reload(); + }); + } + // Una sola instancia: dos pestanas (o la app instalada y una pestana) tienen cada una + // su copia de /data y la que guarda ultima pisa a la otra. Con Web Locks la segunda + // no arranca; en navegadores sin locks, arranca igual. + if (navigator.locks && navigator.locks.request) { + navigator.locks.request('catan', { ifAvailable: true }, function (lock) { + if (lock) { Module.lockDone = true; return new Promise(function () {}); } // se tiene mientras viva la pagina + document.getElementById('status-text').textContent = 'Catan ya está abierto en otra pestaña o ventana. Cerrá esa y recargá esta.'; + Module.noStart = true; + return null; }); } </script> diff --git a/catan/web/sw.js b/catan/web/sw.js @@ -6,7 +6,11 @@ var CACHE = 'catan-@VERSION@'; var FILES = ['./', 'index.html', 'index.js', 'index.wasm', 'manifest.json', 'icon-192.png', 'icon-512.png']; self.addEventListener('install', function (e) { - e.waitUntil(caches.open(CACHE).then(function (c) { return c.addAll(FILES); }).then(function () { return self.skipWaiting(); })); + // cache: 'reload' saltea la cache HTTP del navegador: si no, podia quedar un index.js + // viejo junto a un index.wasm nuevo + e.waitUntil(caches.open(CACHE).then(function (c) { + return c.addAll(FILES.map(function (f) { return new Request(f, { cache: 'reload' }); })); + }).then(function () { return self.skipWaiting(); })); }); self.addEventListener('activate', function (e) { diff --git a/comun/linux/guikit.c b/comun/linux/guikit.c @@ -171,6 +171,7 @@ static int translate(SDL_Event *e, InEvent *ev) /* arrastrar con el boton apretado (o el dedo, en pantallas tactiles) vale como * rueda sobre el punto donde empezo: cada 33 px, una muesca (3 filas del registro) */ if (!G.drag) return 0; + if (!(e->motion.state & SDL_BUTTON_LMASK)) { G.drag = false; return 0; } /* se solto fuera, o pointercancel */ int x, y; fb_pos(e, e->motion.x, e->motion.y, &x, &y); G.drag_acc += y - G.drag_last; @@ -228,6 +229,9 @@ int gk_wait(int timeout_ms, int nfds, InEvent *ev) /* con sockets abiertos no se puede bloquear en SDL: se vuelve seguido a mirarlos */ int wait = nfds > 0 && timeout_ms > 25 ? 25 : timeout_ms; if (wait < 0) wait = 0; +#ifdef __EMSCRIPTEN__ + if (wait == 0) emscripten_sleep(0); /* con timeout 0 SDL no cede: que el navegador respire igual */ +#endif if (!SDL_WaitEventTimeout(&e, wait)) return 0; do { if (translate(&e, ev)) return 1; @@ -266,8 +270,8 @@ static void fluid_initial(int *w, int *h) *w = G.minw * 2; *h = G.minh * 2; #ifdef __EMSCRIPTEN__ - /* el canvas llena el documento (FILL_DOCUMENT), pero SDL recien lo ajusta en el - * proximo "resize": arrancar ya con el tamano del viewport (en px CSS) */ + /* el canvas llena la ventana por CSS, pero SDL recien lo ajusta en el proximo + * "resize": arrancar ya con el tamano del viewport (en px CSS) */ *w = EM_ASM_INT({ return window.innerWidth; }); *h = EM_ASM_INT({ return window.innerHeight; }); if (*w < 1 || *h < 1) { *w = G.minw; *h = G.minh; } @@ -288,10 +292,9 @@ Surface *gk_open_fluid(const char *title, int min_w, int min_h) G.minh = min_h; int w, h; fluid_initial(&w, &h); + /* en la web el canvas lo dimensiona el CSS (100vw x 100dvh) y SDL lo sigue en cada + * resize del navegador, con devicePixelRatio por HIGH_PIXEL_DENSITY */ SDL_WindowFlags flags = SDL_WINDOW_RESIZABLE | SDL_WINDOW_HIGH_PIXEL_DENSITY; -#ifdef __EMSCRIPTEN__ - flags |= SDL_WINDOW_FILL_DOCUMENT; /* sigue al viewport del navegador y a devicePixelRatio */ -#endif if (!SDL_CreateWindowAndRenderer(title, w, h, flags, &G.win, &G.ren)) return NULL; SDL_SetWindowMinimumSize(G.win, min_w / 2, min_h / 2); if (!fb_resize(min_w, min_h)) return NULL; @@ -315,19 +318,23 @@ char *gk_clipboard(void) /* Un campo de texto HTML encima del canvas (web/shell.html lo define: #gk-input con * un <input>, OK y Cancelar; ver gkTextOpen/gkTextDone). Se espera cediendo al * navegador, como hace SDL con sus cuadros de dialogo en emscripten. */ -bool gk_text_input(const char *title, char *buf, int n) +int gk_text_input(const char *title, char *buf, int n) { EM_ASM({ gkTextOpen(UTF8ToString($0), UTF8ToString($1), $2); }, title, buf, n - 1); int r; while ((r = EM_ASM_INT({ return gkTextDone(); })) == 0) emscripten_sleep(16); - if (r < 0) return false; + /* lo que se toco en el canvas mientras el cuadro estaba abierto no vale */ + SDL_FlushEvents(SDL_EVENT_MOUSE_MOTION, SDL_EVENT_MOUSE_WHEEL); + SDL_FlushEvents(SDL_EVENT_FINGER_DOWN, SDL_EVENT_FINGER_CANCELED); + G.drag = false; + if (r < 0) return 0; EM_ASM({ stringToUTF8(gkTextValue(), $0, $1); }, buf, n); - return true; + return 1; } #else -bool gk_text_input(const char *title, char *buf, int n) +int gk_text_input(const char *title, char *buf, int n) { (void)title; (void)buf; (void)n; - return false; + return -1; } #endif diff --git a/comun/linux/guikit.h b/comun/linux/guikit.h @@ -23,8 +23,8 @@ void gk_close(void); char *gk_clipboard(void); /* texto del portapapeles (malloc) o NULL */ /* Pide una linea de texto con un cuadro propio del sistema. Solo existe en la web * (el navegador no muestra el teclado tactil para el canvas): ahi muestra un campo de - * entrada y devuelve true con el texto en buf (false si se cancelo). En escritorio - * devuelve false sin hacer nada y se escribe con el teclado como siempre. */ -bool gk_text_input(const char *title, char *buf, int n); + * entrada y devuelve 1 con el texto en buf, o 0 si se cancelo. En escritorio devuelve + * -1 sin hacer nada y se escribe con el teclado como siempre. */ +int gk_text_input(const char *title, char *buf, int n); #endif diff --git a/comun/linux/paths.c b/comun/linux/paths.c @@ -93,11 +93,16 @@ void paths_flush(void) #ifdef __EMSCRIPTEN__ /* FS.syncfs es asincronico: se espera (cediendo al navegador) hasta que termine, * con un tope por si IndexedDB no responde */ - EM_ASM({ - Module.fsPending = (Module.fsPending || 0) + 1; - FS.syncfs(false, function(err) { Module.fsPending--; if (err) console.warn('syncfs', err); }); + if (EM_ASM_INT({ return Module.noPersist ? 1 : 0; })) return; /* la carga fallo: no pisar IndexedDB */ + int id = EM_ASM_INT({ + var id = Module.fsSeq = (Module.fsSeq || 0) + 1; + Module.fsDone = Module.fsDone || {}; + FS.syncfs(false, function(err) { Module.fsDone[id] = true; if (err) console.warn('syncfs', err); }); + return id; }); - for (int i = 0; i < 200 && EM_ASM_INT({ return Module.fsPending || 0; }) > 0; i++) emscripten_sleep(10); + /* se espera solo a este sync (si uno viejo nunca volvio, no bloquea a los demas) */ + for (int i = 0; i < 200 && !EM_ASM_INT({ return Module.fsDone[$0] ? 1 : 0; }, id); i++) emscripten_sleep(10); + EM_ASM({ delete Module.fsDone[$0]; }, id); #endif }