24 KiB
Reescritura de cinemáticas: capa scenes:: + migración escena a escena
Current Status (actualitzat 2026-04-16)
Steps completats — capa scenes:: estable i 7 de 9 escenes migrades:
- ✅ Step 0 — Infraestructura:
Scene,Timeline,SpriteMover,FrameAnimator,PaletteFade,SurfaceHandle,SceneRegistry,scene_utils, dispatch algameFiberEntry. - ✅ Step 1 —
MortScene(state 100). Pantalla game over + fade-in/out + música "00000001.ogg" → "00000003.ogg". - ✅ Step 2 —
BannerScene(states 2..5). Banner pre-piràmide amb les 4 variants consolidades a(idx%2)*160, (idx/2)*75. - ✅ Step 3 —
MenuScene(state 0). Primera ús real deFrameAnimator(camell 8×160ms). Scrollers manuals amb acumulador ms per palmeres/horitzó. Parpalleig "polsa tecla" time-based. - ✅ Step 4 —
IntroNewLogoScene(state 255, condicional ause_new_logo). Revelat lletra a lletra + cicle de paleta 256 passos. Delega temporalment aModuleSequence::doIntroSprites()viaSurfaceHandle::release()perquè el legacy alliberagfxinternament. La delegació desapareixerà al Step 9. - ✅ Step 5 —
SlidesScene(states 1 i 7). Wipe suau ambEasing::outCubic(el "rasca" del vell s'ha evaporat). Redirect6→7replicat algameFiberEntryabans deltryCreateperquè el flux "no tens prou diners" caiga a slides de fracàs. - ✅ Step 6 —
CreditsScene(state 8). Scroll vertical + parallax condicional sidiamants == 16. Música heretable (només arranca si no en sona cap ja). Escriutrick.inial final. - ✅ Step 7 —
SecretaScene(state 6). 11 fases amb swap detomba1.gif→tomba2.gifviaSurfaceHandle::reset()i efecte "red pulse" sobre els índexs 254/253 de la paleta. Primera ús d'InitialFadeOut(fade-out sobre la paleta prèvia abans de muntar la nova).
Steps pendents — ataquen el cor de la intro:
- 📋 Step 8 —
IntroScene(state 255 quanuse_new_logo == false). 11 passos lineals del wordmark "JAILGAMES" llegat + cicle de paleta. Delegaria adoIntroSpriteslegacy igual queIntroNewLogoScene. Estimació: ~150 línies. Complexitat Media-Alta, però lineal. - 📋 Step 9 —
IntroSpritesScene. El hueso.switch (rand() % 3)amb 3 variants completament diferents (~900–1100 frames cada una), 6–8 loops anidats per variant, frames subsamplejats amb màscares diferents. Mateix arxiugfxque la intro que la crida. Si l'API escala mal, s'acceptarà untick()manual sense Timeline. En migrar aquest step s'elimina la delegació temporalIntroNewLogoScene → doIntroSpritesidoIntroSpritespot passar depublica privat/eliminat. Complexitat Alta. - 📋 Step 10 — Neteja final.
ModuleSequence::doIntro()legacy també desapareix quanIntroScene+IntroSpritesSceneestan fetes.wait_frame_or_skip()helper s'elimina.ModuleSequence::Go()queda reduït a ~5 línies o desapareix del tot si es pot treure delgameFiberEntry. Pot ser també aquí on s'elimine elfiberper fi quanModuleGamesiga tick-based, però això és un altre capítol.
Configuració per a proves ràpides — afegits al Options::game (persistents a config.yaml):
piramide_inicial(ja existia) — state d'entrada. Valors útils:255= intro normal,0= menú,5= banner piràmide 5,6= SecretaScene,8= Credits,100= Mort.habitacio_inicial(ja existia) — sala d'entrada dins la piràmide (1..5).vides(ja existia).diamants_inicial— per al final "bo" dels crèdits amb parallax + cotxe, posar a16.diners_inicial— necessari posar200per entrar aSecretaScenesense el redirect a slides-fracàs (si entres directament en state 6 o hi arribes des del gameplay).show_title_credits(ja existia) — desactivar-ho accelera els tests.
Bugs notables resolts al llarg del camí (mantenir present — poden reaparèixer si es toca codi similar):
JI_Update()no es cridava dins del mini-while del fiber →JI_AnyKey()no es refrescava → les escenes ignoraven les tecles de skip. Fix a director.cpp:gameFiberEntry al Step 3.IntroNewLogoScene::~doble-free degfx_perquèdoIntroSpritessempre allibera elgfxque rep (tant al final normal com als paths de skip). Fix:SurfaceHandle::release()abans de delegar. Step 4.IntroNewLogoSceneno mutavainfo::ctx.num_piramide = 0al terminar, el fiber tornava a crear la mateixa escena — loop infinit. ElGo()vell ho feia post-switch. Step 4.- Skip per tecla durant el revelat del logo nou saltava només les lletres i executava igualment
doIntroSprites. El vell retornava abans de cridar doIntroSprites. Fix al Step 4:Phase::Donedirect en skip,Phase::Delegatenomés per terminació natural.
Context
Las fases 0–7b del plan anterior están completas. El runtime de AEE ya es moderno: fibers cooperativos, audio streaming sin SDL_AddTimer, callbacks SDL_AppInit/Iterate/Event/Quit, C++ idiomático en la capa jail. Lo que queda de legacy pesado es source/game/modulesequence.cpp: 1309 líneas con 9 funciones de cinemáticas lineales, 38+ wait_frame_or_skip() calls, constantes mágicas esparcidas, tres sub-variantes aleatorias en doIntroSprites, y código procedural difícil de editar.
Un refactor mecánico de eso no tiene sentido — las escenas son contenido, no plumbing. Cada una tiene su propia lógica específica y no se benefician de una state machine genérica ni de sed. Lo que sí tiene sentido es reescribirlas de arriba a abajo sobre una capa fina scenes:: reutilizable (Timeline, SpriteMover, FrameAnimator, PaletteFade, surface handle RAII), convirtiendo cada función en una clase Scene. Cada escena migrada elimina su código legacy del modulesequence, hasta que la función Go() sólo quede como un delegador hacia el registry.
Objetivos:
- Capa
scenes::pequeña y reutilizable — helpers obvios, sin sobreingeniería, reusando easing.hpp y losJD8_*existentes. - Cada escena nueva: ~20–80 líneas de código declarativo (vs los cientos actuales).
- Fácil de añadir escenas nuevas — derivar de
scenes::Scene, llenar un Timeline o untick()directo, registrar en elSceneRegistry. - Time-based: todo
delta_msexplícito. Las escenas no tocan fibers, no tienen whiles, no llamanJG_ShouldUpdate. - Migración gradual: el fiber existente sigue corriendo por debajo. Las escenas nuevas se ejecutan dentro del fiber (por debajo del capó) pero su código es puro tick-based. Cuando las 9 estén migradas + ModuleGame también, el fiber se elimina de una pieza.
- Zero regresiones visuales — cada escena nueva debe verse/sonar indistinguible de la vieja antes de eliminar el código legacy asociado.
Capa scenes:: — API
Namespace scenes:: (plano, consistente con Overlay::, Screen::, Menu::).
scenes::Scene — interfaz base [source/scenes/scene.hpp]
class Scene {
public:
virtual ~Scene() = default;
// Llamado una vez cuando el Director la activa. Buen sitio para arrancar
// música o disparar un fade-in. Los assets pueden cargarse aquí o en el
// constructor (ambos válidos).
virtual void onEnter() {}
// Un paso de la escena. No debe bloquear, no debe llamar a JD8_Flip
// (lo hace el caller). delta_ms = tiempo real transcurrido desde el
// tick anterior.
virtual void tick(int delta_ms) = 0;
// True cuando la escena ha acabado y el Director debe pasar a la siguiente.
virtual bool done() const = 0;
// Valor de retorno equivalente al int que devolvía Go(). El caller lo
// usa para decidir el siguiente módulo. Consultado sólo cuando done().
virtual int nextState() const { return 1; }
};
scenes::Timeline — secuencia de steps [source/scenes/timeline.hpp]
class Timeline {
public:
using StepFn = std::function<void(float progress_0_1)>;
// Step con duración y callback que recibe el progreso [0..1] cada tick.
// Si fn es nullptr, el step es una espera pura.
Timeline& step(int duration_ms, StepFn fn = nullptr);
// Step que se ejecuta una sola vez al entrar (pinta algo estático y listo).
Timeline& once(std::function<void()> fn);
void tick(int delta_ms);
void skip(); // marca todos los steps restantes como done inmediatamente
void reset();
bool done() const;
int currentStepIndex() const;
float currentProgress() const; // 0..1 dentro del step actual
};
scenes::SpriteMover — movimiento 2D con easing [source/scenes/sprite_mover.hpp]
class SpriteMover {
public:
using EaseFn = float(*)(float);
void moveTo(int x0, int y0, int x1, int y1, int duration_ms,
EaseFn ease = Easing::linear);
void tick(int delta_ms);
int x() const;
int y() const;
bool done() const;
};
No gestiona surfaces — sólo posición. La escena hace JD8_BlitCK(mover.x(), mover.y(), gfx, ...) ella misma. Reutilizable para el coche de créditos, slides, Sam caminando, etc.
scenes::FrameAnimator — iteración de frames subsampleados [source/scenes/frame_animator.hpp]
class FrameAnimator {
public:
FrameAnimator(int num_frames, int frame_ms, bool loop = true);
void tick(int delta_ms);
int frame() const; // índice [0, num_frames)
bool done() const; // sólo relevante si loop=false
void reset();
};
Cubre camello (8 frames × 4 ticks), palmeras (4 × 8 ticks), Sam caminando con (i/5) % fr.
scenes::PaletteFade — wrapper time-based de JD8_Fade* [source/scenes/palette_fade.hpp]
class PaletteFade {
public:
void startFadeOut();
void startFadeTo(JD8_Palette target);
void tick(int delta_ms); // avanza un step de fade por tick
bool active() const;
bool done() const;
};
Wrapper sobre JD8_FadeStartOut / JD8_FadeStartToPal / JD8_FadeTickStep que ya existen.
scenes::SurfaceHandle — RAII para JD8_Surface [source/scenes/surface_handle.hpp]
class SurfaceHandle {
public:
SurfaceHandle() = default;
explicit SurfaceHandle(const char* file);
~SurfaceHandle();
SurfaceHandle(const SurfaceHandle&) = delete;
SurfaceHandle& operator=(const SurfaceHandle&) = delete;
SurfaceHandle(SurfaceHandle&&) noexcept;
SurfaceHandle& operator=(SurfaceHandle&&) noexcept;
operator JD8_Surface() const; // conversión implícita → pasable a JD8_Blit*
JD8_Surface get() const;
bool valid() const;
void reset(const char* file); // libera + recarga (doSecreta lo necesita)
};
scenes::SceneRegistry — factory [source/scenes/scene_registry.hpp/cpp]
class SceneRegistry {
public:
using Factory = std::function<std::unique_ptr<Scene>()>;
// Llamado al boot para registrar cada escena migrada.
void registerScene(int state_key, Factory f);
// Intenta crear la escena para un state dado. nullptr si no registrada.
// El caller (gameFiberEntry) cae al viejo Go() legacy si devuelve null.
std::unique_ptr<Scene> tryCreate(int state_key) const;
// Singleton accedido desde el Director al boot.
static SceneRegistry& instance();
};
El state_key es un valor sintético que combina info::ctx.num_piramide con el módulo objetivo (sequence vs game). Los valores exactos los resolvemos al implementar — podría ser el propio num_piramide si es suficiente para distinguir (255=intro, 0=menu, 1/7=slides, 2-5=banner, 6=secreta, 8=credits, 100=mort).
Organización de archivos
source/scenes/
├── scene.hpp
├── scene_registry.hpp/.cpp
├── timeline.hpp/.cpp
├── sprite_mover.hpp/.cpp
├── frame_animator.hpp/.cpp
├── palette_fade.hpp/.cpp
├── surface_handle.hpp/.cpp
├── mort_scene.hpp/.cpp # orden de migración
├── banner_scene.hpp/.cpp
├── menu_scene.hpp/.cpp
├── intro_new_logo_scene.hpp/.cpp
├── slides_scene.hpp/.cpp
├── credits_scene.hpp/.cpp
├── secreta_scene.hpp/.cpp
├── intro_scene.hpp/.cpp
└── intro_sprites_scene.hpp/.cpp
Estructura plana — sin subdirectorios helpers/ o concrete/. Añadir archivo nuevo = una línea al CMakeLists.txt.
Integración con el Director existente
No creo un Director nuevo. Modifico source/core/system/director.cpp — concretamente gameFiberEntry() en el namespace anónimo — para que consulte el SceneRegistry antes de caer al viejo ModuleSequence::Go():
// pseudocodigo dentro de gameFiberEntry()
int state = 1;
while (state != -1 && !JG_Quitting()) {
// Intentamos resolver la escena por el state actual.
if (auto scene = SceneRegistry::instance().tryCreate(info::ctx.num_piramide)) {
scene->onEnter();
Uint32 last = SDL_GetTicks();
while (!scene->done() && !JG_Quitting()) {
Uint32 now = SDL_GetTicks();
scene->tick(static_cast<int>(now - last));
last = now;
JD8_Flip(); // yields al Director (presenta con overlay encima)
}
state = scene->nextState();
continue;
}
// Fallback: todavía no migrada, usa el Go() legacy
if (state == 1) {
auto* ms = new ModuleSequence();
state = ms->Go();
delete ms;
} else if (state == 0) {
auto* mg = new ModuleGame();
state = mg->Go();
delete mg;
}
}
Claves:
- Las escenas nuevas son puras tick-based.
tick(delta_ms)no sabe del fiber. - El mini-while que las ejecuta vive en
gameFiberEntry, que sí corre dentro del fiber.JD8_Flip()es el que hace el yield al Director — igual que ahora. - Cuando todas las escenas +
ModuleGameestén migradas, este mini-while migra alDirector::iterate()directo y se eliminagameFiberEntry+GameFiber::*. Pero eso no es para esta tanda. - Registro de escenas: se hace en
Director::init()llamando aSceneRegistry::instance().registerScene(state_key, []{ return std::make_unique<scenes::MortScene>(); })para cada escena ya migrada.
Orden de migración (simple → complejo)
Cada paso = una PR / commit / validación visual antes de seguir. Al migrar una escena, se elimina la función legacy correspondiente de modulesequence.cpp.
Step 0 — Infraestructura
Crear los archivos de la capa scenes:: (scene, timeline, sprite_mover, frame_animator, palette_fade, surface_handle, scene_registry) sin ninguna escena concreta todavía. Compilar para confirmar que la capa es sólida.
Step 1 — MortScene (complejidad Baja)
Reemplaza ModuleSequence::doMort(). ~15 líneas originales: blit fullscreen gameover.gif + JD8_FadeToPal + música 00000001.ogg + espera 1000ms o tecla + info::ctx.vida = 5. Es la primera víctima: valida la API mínima (Scene + PaletteFade + SurfaceHandle).
Step 2 — BannerScene (complejidad Baja)
Reemplaza ModuleSequence::doBanner(). Blits estáticos "PIRÀMIDE X" + número + fade entrada + espera 5000ms + JA_FadeOutMusic(250) + fade salida. Primera prueba de Timeline::step() con once().
Step 3 — MenuScene (complejidad Media-Baja)
Reemplaza ModuleSequence::doMenu(). Primera prueba de FrameAnimator (palmeras, camello, horizonte). Bucle infinito hasta input. Lee/escribe info::ctx.pepe_activat y info::ctx.nou_personatge. Texto condicional con Locale::get.
Step 4 — IntroNewLogoScene (complejidad Media)
Reemplaza ModuleSequence::doIntroNewLogo(). Revelado letra a letra (9 letras × 150ms) + cursor parpadeando + logo completo + ciclo de paleta 256 pasos. Timeline con 20+ steps. Mantiene la llamada final a doIntroSprites (que aún no está migrada — delegación legacy temporal).
Step 5 — SlidesScene (complejidad Media)
Reemplaza ModuleSequence::doSlides(). 3 slides con scroll entrada-derecha + espera + scroll salida-izquierda. Primera prueba seria de SpriteMover con Easing::outCubic. Elige asset según info::ctx.num_piramide + info::ctx.diners. Fade de música al final.
Step 6 — CreditsScene (complejidad Media)
Reemplaza ModuleSequence::doCredits(). Scroll vertical largo (~3100 frames = ~62s a 20ms) + scroll parallax condicional si info::ctx.diamants == 16 con animación de coche. Escribe info::ctx.nou_personatge = true y crea trick.ini.
Step 7 — SecretaScene (complejidad Media-Alta)
Reemplaza ModuleSequence::doSecreta(). 11 estados originales: scroll + recarga de asset a mitad (SurfaceHandle::reset) + animación RGB dinámica del rojo (JD8_SetPaletteColor). Primera escena que usa SurfaceHandle::reset().
Step 8 — IntroScene (complejidad Media-Alta)
Reemplaza ModuleSequence::doIntro() (el logo JAILGAMES legacy). 11 pasos lineales de construcción del wordmark + ciclo de paleta + delegación a IntroSpritesScene. Timeline con muchos once() + step().
Step 9 — IntroSpritesScene (complejidad Alta)
Reemplaza ModuleSequence::doIntroSprites(). La bestia: switch(rand() % 3) con 3 variantes completamente distintas (~900-1100 frames cada una). Cada variante tiene 6-8 loops anidados. Aquí probablemente hace falta combinar Timeline + SpriteMover + FrameAnimator + lógica ad-hoc. Si la API no escala limpia, se acepta que esta escena tenga tick() manual sin Timeline.
Step 10 — Limpieza final
En este punto ModuleSequence ya no tiene ninguna función doX() — sólo el Go() que delega al registry. Se puede:
- Eliminar
ModuleSequencecompleto y mover el dispatch algameFiberEntrydirecto. - Eliminar el helper
wait_frame_or_skip(). - Eliminar el include de
fiber.hppdesdejgame.cppsiModuleGametambién es tick-based (fuera de scope de este plan, pero queda preparado).
Invariantes por escena
Cada paso debe cumplir:
- Visualmente indistinguible de la vieja versión (mismo timing, mismas transiciones, mismo feel). Validar jugándolo.
- Skip por tecla funciona idéntico (misma tecla, mismo momento).
- Build nativo compila limpio, sin warnings nuevos.
- Audio sigue: música arranca, fades suaves, no hay cortes.
- Overlay sigue animándose encima (pause, notificaciones, render info) — lo hace el Director sin tocar la escena.
- La función legacy
doX()se elimina en el mismo commit que suXScene, no se deja código muerto.
Fuera de scope (explícito)
ModuleGame(gameplay puro). Sigue con Go() + fiber. Se migrará más tarde con otra estructura (probablemente no Scene — es interactivo y no lineal).- Emscripten fiber backend + build WASM (fases 7c/7d del plan anterior). Cuando estén migradas las escenas + ModuleGame, los fibers se eliminan y este punto se vuelve trivial.
- Fase 6 (time-based total con accumulator pattern). La saltamos — no aporta valor real con el framerate actual.
- Multi-language de textos en escenas. Se usa
Locale::getdirectamente donde haga falta, sin envoltorio nuevo.
Critical files
| Archivo | Step | Tipo |
|---|---|---|
| source/scenes/scene.hpp | 0 | nuevo, interfaz base |
| source/scenes/timeline.hpp + .cpp | 0 | nuevo, helper central |
| source/scenes/sprite_mover.hpp + .cpp | 0 | nuevo |
| source/scenes/frame_animator.hpp + .cpp | 0 | nuevo |
| source/scenes/palette_fade.hpp + .cpp | 0 | nuevo |
| source/scenes/surface_handle.hpp + .cpp | 0 | nuevo, RAII |
| source/scenes/scene_registry.hpp + .cpp | 0 | nuevo, factory |
| source/scenes/*_scene.hpp + .cpp | 1–9 | una por paso |
| source/core/system/director.cpp | 0 | modificar gameFiberEntry |
| source/game/modulesequence.cpp | 1–9 | borrar funciones doX() una a una |
| CMakeLists.txt | 0–9 | añadir archivos nuevos |
Reusables existentes
- source/utils/easing.hpp —
Easing::linear,outQuad,outCubic,inOutQuad,lerp,lerpInt. Usados porSpriteMovery cualquier step deTimelineque reciba progress. - source/core/jail/jdraw8.hpp —
JD8_FadeStartOut,JD8_FadeStartToPal,JD8_FadeTickStep,JD8_FadeIsActive. Usados porPaletteFade. - source/core/jail/jail_audio.hpp —
JA_PlayMusic,JA_FadeOutMusic,JA_PauseMusic,JA_ResumeMusic. - source/core/locale/locale.hpp —
Locale::get("key")para strings de UI en las escenas. - source/core/rendering/overlay.hpp — sigue siendo responsabilidad del Director; las escenas no tocan overlay.
- source/core/jail/jinput.hpp —
JI_AnyKey,JI_KeyPressedpara detectar skip y navegación de menú.
Ejemplos concretos
MortScene (Step 1) — ~20 líneas de lógica
// mort_scene.hpp
namespace scenes {
class MortScene : public Scene {
public:
void onEnter() override;
void tick(int delta_ms) override;
bool done() const override { return done_; }
int nextState() const override { return 1; } // igual que doMort → vuelve a seq
private:
SurfaceHandle gfx_;
PaletteFade fade_;
int remaining_ms_ = 1000;
bool done_ = false;
};
}
// mort_scene.cpp
void MortScene::onEnter() {
// Lo que hacía ModuleSequence::doMort() linealmente, declarativo.
int size = 0;
char* buf = file_getfilebuffer("00000001.ogg", size);
JA_PlayMusic(JA_LoadMusic((Uint8*)buf, size, "00000001.ogg"));
JI_DisableKeyboard(60);
info::ctx.vida = 5;
gfx_ = SurfaceHandle("gameover.gif");
JD8_Palette pal = JD8_LoadPalette("gameover.gif");
JD8_ClearScreen(0);
JD8_Blit(gfx_);
fade_.startFadeTo(pal);
}
void MortScene::tick(int delta_ms) {
fade_.tick(delta_ms);
if (JI_AnyKey()) { done_ = true; return; }
remaining_ms_ -= delta_ms;
if (remaining_ms_ <= 0) done_ = true;
}
BannerScene (Step 2) — Timeline + fades
void BannerScene::onEnter() {
play_music("00000004.ogg");
gfx_ = SurfaceHandle("ffase.gif");
JD8_Palette pal = JD8_LoadPalette("ffase.gif");
timeline_
.once([this]{
JD8_ClearScreen(0);
// blits del banner + número según info::ctx.num_piramide
fade_in_.startFadeTo(pal);
})
.step(5000); // espera. Cualquier tecla hace timeline_.skip().
}
void BannerScene::tick(int delta_ms) {
fade_in_.tick(delta_ms);
if (!timeline_.done()) {
if (JI_AnyKey()) timeline_.skip();
timeline_.tick(delta_ms);
if (timeline_.done() && !fade_out_started_) {
JA_FadeOutMusic(250);
fade_out_.startFadeOut();
fade_out_started_ = true;
}
} else {
fade_out_.tick(delta_ms);
}
}
bool BannerScene::done() const { return timeline_.done() && fade_out_.done(); }
Verification
Tras cada step:
cmake --build buildlimpio, sin warnings nuevos.- Ejecutar el juego entero desde intro hasta muerte, con atención específica a la escena migrada. Comparar con un git stash temporal del viejo código si hace falta.
- Skip por tecla en la escena migrada — debe saltar a la siguiente igual que antes.
- Pausa F11 durante la escena — el juego se congela, el overlay sigue animándose.
- Menú F12 durante la escena — debe abrir encima.
- Cerrar ventana durante la escena — responde al instante (sin el viejo bug de congelamiento).
- Audio — la música debe arrancar cuando toca, los fades suaves, sin cortes.
- ESC doble-press — sale limpiamente.
Tras el step 10 (limpieza final):
modulesequence.cpptiene ~50 líneas (soloGo()mínimo) o desaparece del todo.- El juego entero es jugable de principio a fin.
- FPS estable ≥60 con vsync.
- Cero referencias a
wait_frame_or_skipen el código.
Cadencia
Igual que antes: paso a paso con pausa. Cada step (0–9) se cierra con build verde + validación visual antes de arrancar el siguiente. No encadeno pasos automáticamente.