Files
aee/docs/scenes-migration-plan.md

24 KiB
Raw Permalink Blame History

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 al gameFiberEntry.
  • Step 1MortScene (state 100). Pantalla game over + fade-in/out + música "00000001.ogg" → "00000003.ogg".
  • Step 2BannerScene (states 2..5). Banner pre-piràmide amb les 4 variants consolidades a (idx%2)*160, (idx/2)*75.
  • Step 3MenuScene (state 0). Primera ús real de FrameAnimator (camell 8×160ms). Scrollers manuals amb acumulador ms per palmeres/horitzó. Parpalleig "polsa tecla" time-based.
  • Step 4IntroNewLogoScene (state 255, condicional a use_new_logo). Revelat lletra a lletra + cicle de paleta 256 passos. Delega temporalment a ModuleSequence::doIntroSprites() via SurfaceHandle::release() perquè el legacy allibera gfx internament. La delegació desapareixerà al Step 9.
  • Step 5SlidesScene (states 1 i 7). Wipe suau amb Easing::outCubic (el "rasca" del vell s'ha evaporat). Redirect 6→7 replicat al gameFiberEntry abans del tryCreate perquè el flux "no tens prou diners" caiga a slides de fracàs.
  • Step 6CreditsScene (state 8). Scroll vertical + parallax condicional si diamants == 16. Música heretable (només arranca si no en sona cap ja). Escriu trick.ini al final.
  • Step 7SecretaScene (state 6). 11 fases amb swap de tomba1.gif→tomba2.gif via SurfaceHandle::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 8IntroScene (state 255 quan use_new_logo == false). 11 passos lineals del wordmark "JAILGAMES" llegat + cicle de paleta. Delegaria a doIntroSprites legacy igual que IntroNewLogoScene. Estimació: ~150 línies. Complexitat Media-Alta, però lineal.
  • 📋 Step 9IntroSpritesScene. El hueso. switch (rand() % 3) amb 3 variants completament diferents (~9001100 frames cada una), 68 loops anidats per variant, frames subsamplejats amb màscares diferents. Mateix arxiu gfx que la intro que la crida. Si l'API escala mal, s'acceptarà un tick() manual sense Timeline. En migrar aquest step s'elimina la delegació temporal IntroNewLogoScene → doIntroSprites i doIntroSprites pot passar de public a privat/eliminat. Complexitat Alta.
  • 📋 Step 10 — Neteja final. ModuleSequence::doIntro() legacy també desapareix quan IntroScene + IntroSpritesScene estan 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 del gameFiberEntry. Pot ser també aquí on s'elimine el fiber per fi quan ModuleGame siga 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 a 16.
  • diners_inicial — necessari posar 200 per entrar a SecretaScene sense 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):

  1. 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.
  2. IntroNewLogoScene::~ doble-free de gfx_ perquè doIntroSprites sempre allibera el gfx que rep (tant al final normal com als paths de skip). Fix: SurfaceHandle::release() abans de delegar. Step 4.
  3. IntroNewLogoScene no mutava info::ctx.num_piramide = 0 al terminar, el fiber tornava a crear la mateixa escena — loop infinit. El Go() vell ho feia post-switch. Step 4.
  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::Done direct en skip, Phase::Delegate només per terminació natural.

Context

Las fases 07b 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:

  1. Capa scenes:: pequeña y reutilizable — helpers obvios, sin sobreingeniería, reusando easing.hpp y los JD8_* existentes.
  2. Cada escena nueva: ~2080 líneas de código declarativo (vs los cientos actuales).
  3. Fácil de añadir escenas nuevas — derivar de scenes::Scene, llenar un Timeline o un tick() directo, registrar en el SceneRegistry.
  4. Time-based: todo delta_ms explícito. Las escenas no tocan fibers, no tienen whiles, no llaman JG_ShouldUpdate.
  5. 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.
  6. 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 + ModuleGame estén migradas, este mini-while migra al Director::iterate() directo y se elimina gameFiberEntry + GameFiber::*. Pero eso no es para esta tanda.
  • Registro de escenas: se hace en Director::init() llamando a SceneRegistry::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 ModuleSequence completo y mover el dispatch al gameFiberEntry directo.
  • Eliminar el helper wait_frame_or_skip().
  • Eliminar el include de fiber.hpp desde jgame.cpp si ModuleGame también es tick-based (fuera de scope de este plan, pero queda preparado).

Invariantes por escena

Cada paso debe cumplir:

  1. Visualmente indistinguible de la vieja versión (mismo timing, mismas transiciones, mismo feel). Validar jugándolo.
  2. Skip por tecla funciona idéntico (misma tecla, mismo momento).
  3. Build nativo compila limpio, sin warnings nuevos.
  4. Audio sigue: música arranca, fades suaves, no hay cortes.
  5. Overlay sigue animándose encima (pause, notificaciones, render info) — lo hace el Director sin tocar la escena.
  6. La función legacy doX() se elimina en el mismo commit que su XScene, 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::get directamente 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 19 una por paso
source/core/system/director.cpp 0 modificar gameFiberEntry
source/game/modulesequence.cpp 19 borrar funciones doX() una a una
CMakeLists.txt 09 añadir archivos nuevos

Reusables existentes


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:

  1. cmake --build build limpio, sin warnings nuevos.
  2. 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.
  3. Skip por tecla en la escena migrada — debe saltar a la siguiente igual que antes.
  4. Pausa F11 durante la escena — el juego se congela, el overlay sigue animándose.
  5. Menú F12 durante la escena — debe abrir encima.
  6. Cerrar ventana durante la escena — responde al instante (sin el viejo bug de congelamiento).
  7. Audio — la música debe arrancar cuando toca, los fades suaves, sin cortes.
  8. ESC doble-press — sale limpiamente.

Tras el step 10 (limpieza final):

  • modulesequence.cpp tiene ~50 líneas (solo Go() 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_skip en el código.

Cadencia

Igual que antes: paso a paso con pausa. Cada step (09) se cierra con build verde + validación visual antes de arrancar el siguiente. No encadeno pasos automáticamente.