TLDR
Intro
Existen varios (ou polo menos uns cantos) ports en liña do Space Cadet Pinball (é dicir, o mítico pinball de Windows) que funcionan perfectamente nun teclado de EUA ou do Reino Unido, pero que están rotos nunha distribución española. O problema é simple: o flipper dereito está asignado á tecla /. Nun teclado español, o / prodúcese premendo Shift+7, o cal a) non é moi ergonómico e b) aínda facéndoo, o xogo simplemente non o rexistra ben. Na realidade, a versión española usa a tecla -, que está na posición equivalente ao / da versión inglesa.
Así que, como non había ningunha versión que funcionase ben, decidín facer a miña propia, agora dispoñible en 3D Pinball - Space Cadet.
En lugar de simplemente hackear un fork antigo, decidín mesturar os diferentes repositorios. Por unha banda temos a descompilación orixinal de k4zmu2a e, pola outra, o port a Emscripten de Alula. O repo de k4zmu2a ten varias melloras respecto ao de Alula, que foi forkeado deste hai 5 anos. Así que o meu obxectivo era portar os cambios de Emscripten de Alula á nova base e logo engadir as miñas melloras para a distribución do teclado.
O arreglo da distribución do teclado
O erro existe porque SDL2 reporta dous identificadores diferentes para cada evento de tecla:
event.key.keysym.sym: Un keycode (ou keysym), que representa o carácter real que produce a tecla. Isto depende da distribución (layout-dependent).event.key.keysym.scancode: Un scancode, que representa a posición física da tecla. Isto é independente da distribución (layout-independent).
O código orixinal (upstream) gardaba as asignacións do teclado como keycodes (como SDLK_SLASH) e comparaba os eventos sym entrantes con eles. Como unha distribución española nunca reporta de forma natural o SDLK_SLASH sen o modificador shift, a coincidencia nunca se daba (e mesmo premendo shift, tampouco funcionaba). A solución foi facer que o sistema de asignacións falase en scancodes no seu lugar, usando SDL_SCANCODE_SLASH para denotar a tecla física situada inmediatamente á esquerda do shift dereito, sen importar que carácter imprima.
static void InputDown(GameInput input, int keyboardKeycode = 0);
O scancode manexa as accións reais do xogo (flipper/tirador), mentres que o keyboardKeycode (layoutw-aware) pásase de forma segura aos switches dos códigos de trucos.
Problemas coa Física
Tras fusionar ambos repositorios e arranxar o teclado, notei un problema. Ás veces a bóla stuttereaba no sitio, sen chegar a deterse por completo nin a moverse de todo. Este problema facía que o tirador para lanzar a bóla ás veces fallara. Isto foi reportado no repositorio orixinal pero nunca se resolveu. A versión de Alula non tiña isto, polo que foi algo introducido nunha reescritura da física bastante grande no upstream. O meu “apaño” foi crear un límite de velocidade para o contacto en repouso dentro do código de colisións (maths::basic_collision):
constexpr float RestThreshold = 0.25f;
if (reboundSpeed < RestThreshold) {
ball->Speed -= elasticity * reboundSpeed;
}
Se a velocidade de rebote entrante da bóla cae por debaixo do RestThreshold, a enerxía residual disípase por completo. Esta amortiguación obriga á bóla a quedar quieta sobre as superficies sen alterar as colisións normais do xogo, que ocorren a niveis de enerxía moito máis altos. Non obstante, este arranxo non está exento de problemas, xa que me pareceu notar algunhas pequenas desaceleracións cando a bóla roza lentamente contra os flippers (aínda que isto puido ser unha suxestión miña, xa que sei que o arranxo non é moi correcto).
A maiores disto, tamén engadín un pequeno tope de seguridade no acumulador de física para evitar problemas en dispositivos con taxas de refresco variables (algo agora común tanto en móbiles como en PCs). Cabe destacar que isto non foi probado de forma exhaustiva nin se descubriu que faga dano (nin moito ben en realidade).
Arquitectura Web: Pausa e Persistencia
Mover un xogo nativo de escritorio ao navegador require xestionar como se comportan realmente as páxinas web.
Primeiro, Pausa de lapelas en segundo plano: Cando un usuario cambia de lapela, os navegadores limitan automaticamente o bucle requestAnimationFrame a aproximadamente 1 Hz. Se o motor do xogo seguise calculando a física cun tempo delta (dt) acumulado masivo durante este período, ao volver á lapela provocaríase un pico catastrófico na física. Para solucionalo, o xogo agora consulta document.hidden en cada fotograma; se a lapela está oculta, sáltase o tick da física e reinicia o acumulador, garantindo un regreso completamente fluído sen penalizacións de rendemento.
Segundo, Persistencia (Gardado de Puntuacións Altas): Para xestionar os estados de gardado na web, as opcións e as puntuacións altas agora sobreviven realmente ás recargas da páxina usando IndexedDB. Nunha build nativa de escritorio, a configuración e as puntuacións escríbense no disco durante o proceso de peche cando o xogador sae do xogo de forma limpa. Non obstante, na web, os xogadores simplemente pechan a lapela, o que significa que esas rutas de peche nunca se executan. Para solucionalo, montei un sistema de ficheiros IDBFS dentro do shell de Emscripten en /libsdl/SpaceCadetPinball. Despois, creei unha función en C++ (WebFlushPersistence) que forza as opcións en memoria de ImGui e as táboas de puntuacións altas no almacenamento local. Esta función está conectada aos eventos visibilitychange, pagehide e beforeunload do navegador, e tamén se executa periodicamente cada 5 segundos para garantir que as túas puntuacións nunca se perdan.
Táctil
Unha vez estabilizado o motor principal e feitos os scancodes, o feature creep funcións empezou a asomar. O seguinte obxectivo era facer isto minimamente xogable en teléfonos móbiles. Para variar, iso trouxo algúns problemas.
Tradución de Coordenadas
Un dos primeiros problemas coas entradas táctiles no lenzo web é a relación de aspecto. Os eventos táctiles len os desprazamentos de coordenadas en relación á caixa do elemento, non á vista real do xogo. Por exemplo, un toque que visualmente aterra preto da parte inferior do taboleiro lese como ~67% cara abaixo no elemento, pero corresponde a ~95% na apariencia real do xogo. Isto é un problema cos menús e opcións incluídos no port.
Para arranxar isto, engadiuse un paso de cálculo personalizado (gameCoords()) dentro do HTML. Desfai automaticamente o escalado e os desprazamentos introducidos polo letterboxing, restrinxindo os datos a unha matriz de espazo de xogo uniforme [0, 1].
Flippers independentes e mapeo
Despois de mapear correctamente as entradas ao espazo do xogo, o lenzo táctil divídese en tres rexións distintas por dedo:
- Banda inferior central: Mapeada ao tirador; ao soltar o dedo, dispara a mecánica de lanzamento.
- Metade esquerda (espazo restante): Activa o flipper esquerdo.
- Metade dereita (espazo restante): Activa o flipper dereito.
Cada zona activa monitorízase de forma independente, permitindo xogar cos dous polgares, permitindo, por exemplo, que un xogador manteña un flipper levantado cun polgar mentres usa o flipper oposto co outro.
Accións do Xogo vs. Toques na Interface
Como tocar o lenzo activa por defecto os flippers, os menús e os overlays de ImGui (puntuacións altas, selección de mesa, etc.) eran orixinalmente inaccesibles en móbiles tras engadir as rexións mapeadas a flippers e tiradores. Para resolver isto, o manexador de eventos de JavaScript consulta dous getters exportados en C++ en cada evento táctil:
-
web_menu_bar_height(): Detecta se a fila superior do menú de ImGui está visible. -
web_modal_open(): Consulta se calquera xanela emerxente (popup ou modal) está debuxada.
Se un toque aterra no límite da barra de menú, ou se hai un modal aberto, o motor omite por completo o mapeo dos flippers. No seu lugar, crea un SDL_MOUSEMOTION sintético e accións de clic estándar para permitir que os usuarios interactúen cos elementos do menú, escriban as súas iniciais nas puntuacións altas (isto funciona un pouco regular, para ser sinceros) ou cambien a configuración.
Titorial: Overlay Táctil de Primeira Execución
Para garantir que o xogo axude un pouco aos usuarios de móbiles, engadín un overlay translúcido con pistas táctiles para a primeira execución. Deste xeito, os usuarios móbiles saben onde tocar e poden ver que o pinball funciona en móbiles (máis ou menos).
Usando unha comprobación multimedia de CSS apoiada por Emscripten ((pointer: coarse)), o sistema detecta dispositivos móbiles e debuxa tres rectángulos etiquetados directamente sobre a capa frontal de ImGui. As coordenadas coinciden coas porcentaxes utilizadas polo manexador de entradas. Unha vez que un xogador activa o seu primeiro comando táctil, ou tras un tempo de espera de 7 segundos, o overlay esvaécese e garda no disco un flag booleano persistente ShowTouchHints para que os xogadores que regresen nunca o volvan ver (quizais teña que cambiar ou alterar isto, teño que pensalo).
Persistencia (Puntuacións Altas)
Outro obxectivo do proxecto era xestionar os estados de gardado na web para que as opcións e as puntuacións altas sobrevivan realmente ás recargas da páxina. Isto fíxose usando IndexedDB. Nunha build nativa de escritorio, a configuración e as puntuacións escríbense no disco durante o proceso de peche cando o xogador sae do xogo de forma limpa. Non obstante, na web, os xogadores simplemente pechan a lapela, o que significa que esas rutas de peche nunca se executan. Para solucionalo, o sistema de ficheiros IDBFS móntase dentro do shell de Emscripten en /libsdl/SpaceCadetPinball. Despois, unha función en C++ (WebFlushPersistence) forza as opcións en memoria de ImGui e as táboas de puntuacións altas ao almacenamento local. Esta función está conectada aos eventos visibilitychange, pagehide e beforeunload do navegador, e tamén se executa periodicamente cada 5 segundos para garantir que as puntuacións altas nunca se perdan. Isto pode ser excesivo, pero realmente non consume moitos recursos.
Pausa en Segundo Plano
Outra optimización centrada na web foi a xestión de lapelas en segundo plano. Cando unha lapela do navegador está oculta, os navegadores modernos limitan o requestAnimationFrame para aforrar recursos. Se o motor do xogo seguise calculando a física durante esta limitación, acumularía unha delta de tempo (dt) xigante e causaría un enorme pico de física no momento en que o usuario volvese á lapela. Para solucionalo, o xogo agora consulta document.hidden directamente en cada frame. Mentres está oculto, reinicia o acumulador de física e sae antes de tempo, pausando de forma efectiva o bucle do xogo por completo para que non mande a bóla voando ao baleiro cando o usuario regrese á lapela.
Optimizando a Carga de Activos e Soporte de Dúas Mesas
A pegada de compilación de activos de escritorio estándar sitúase en aproximadamente ~6.9 MB. Como os assets de WebAssembly sérvense a través de redes estáticas, minimizar a pegada de datos inicial é fundamental para tempos de carga de páxina rápidos, especialmente en teléfonos móbiles e conexións de datos limitadas.
O principal contribuínte a este exceso foi o SoundFont MIDI Xeral do motor de son (gm.sf2), que pesaba 3.2 MB por si só. Dado que as pistas de son do Space Cadet Pinball utilizan só un subconxunto de instrumentos, escribín un script en Python dedicado (trim_gm_sf2.py) para extraer as pistas en busca de comandos de cambio de programa e reconstruír as táboas RIFF internas usando só os instrumentos necesarios.
Ademais, o entorno web agora empaqueta datos de activos para ambas versións: o 3D Pinball orixinal de Windows (PINBALL.DAT) e a variante de maior resolución Full Tilt Pinball (CADET.DAT). Podes saltar dunha mesa a outra directamente desde un menú personalizado no nivel superior ou forzar unha mesa específica no arranque usando un parámetro de consulta na URL (p. ex., ?table=ft ou ?table=3dpb). Para os que non o saiban, Full Tilt Pinball é a versión comercial do xogo. En comparación coa versión estándar de Windows 3D Pinball, Full Tilt presenta sprites de maior resolución, un conxunto de sons máis amplo que inclúe efectos de multibóla e voces do motor, e pistas de música MIDI máis longas e ricas. Porén, para este port, ambas mesas son funcionalmente iguais, as únicas cousas que cambian son os activos (maior calidade na versión Full Tilt), así como a música (máis longa, máis variada no Full Tilt).
Aínda con ambas mesas, o tamaño final dos activos do xogo redúcese a ~4.1 MB sen facer ningún sacrificio.