# 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 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 de `FrameAnimator` (camell 8×160ms). Scrollers manuals amb acumulador ms per palmeres/horitzó. Parpalleig "polsa tecla" time-based.
- ✅ **Step 4** — `IntroNewLogoScene` (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 5** — `SlidesScene` (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 6** — `CreditsScene` (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 7** — `SecretaScene` (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 8** — `IntroScene` (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 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 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](source/core/system/director.cpp) 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 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](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](source/utils/easing.hpp) y los `JD8_*` existentes.
2. Cada escena nueva: **~20–80 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]

```cpp
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]

```cpp
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]

```cpp
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]

```cpp
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]

```cpp
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]

```cpp
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]

```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](source/core/system/director.cpp) — concretamente `gameFiberEntry()` en el namespace anónimo — para que consulte el `SceneRegistry` antes de caer al viejo `ModuleSequence::Go()`:

```cpp
// 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](source/scenes/scene.hpp) | 0 | nuevo, interfaz base |
| [source/scenes/timeline.hpp](source/scenes/timeline.hpp) + .cpp | 0 | nuevo, helper central |
| [source/scenes/sprite_mover.hpp](source/scenes/sprite_mover.hpp) + .cpp | 0 | nuevo |
| [source/scenes/frame_animator.hpp](source/scenes/frame_animator.hpp) + .cpp | 0 | nuevo |
| [source/scenes/palette_fade.hpp](source/scenes/palette_fade.hpp) + .cpp | 0 | nuevo |
| [source/scenes/surface_handle.hpp](source/scenes/surface_handle.hpp) + .cpp | 0 | nuevo, RAII |
| [source/scenes/scene_registry.hpp](source/scenes/scene_registry.hpp) + .cpp | 0 | nuevo, factory |
| [source/scenes/*_scene.hpp](source/scenes/) + .cpp | 1–9 | una por paso |
| [source/core/system/director.cpp](source/core/system/director.cpp) | 0 | modificar `gameFiberEntry` |
| [source/game/modulesequence.cpp](source/game/modulesequence.cpp) | 1–9 | borrar funciones `doX()` una a una |
| [CMakeLists.txt](CMakeLists.txt) | 0–9 | añadir archivos nuevos |

## Reusables existentes

- [source/utils/easing.hpp](source/utils/easing.hpp) — `Easing::linear`, `outQuad`, `outCubic`, `inOutQuad`, `lerp`, `lerpInt`. Usados por `SpriteMover` y cualquier step de `Timeline` que reciba progress.
- [source/core/jail/jdraw8.hpp](source/core/jail/jdraw8.hpp) — `JD8_FadeStartOut`, `JD8_FadeStartToPal`, `JD8_FadeTickStep`, `JD8_FadeIsActive`. Usados por `PaletteFade`.
- [source/core/jail/jail_audio.hpp](source/core/jail/jail_audio.hpp) — `JA_PlayMusic`, `JA_FadeOutMusic`, `JA_PauseMusic`, `JA_ResumeMusic`.
- [source/core/locale/locale.hpp](source/core/locale/locale.hpp) — `Locale::get("key")` para strings de UI en las escenas.
- [source/core/rendering/overlay.hpp](source/core/rendering/overlay.hpp) — sigue siendo responsabilidad del Director; las escenas no tocan overlay.
- [source/core/jail/jinput.hpp](source/core/jail/jinput.hpp) — `JI_AnyKey`, `JI_KeyPressed` para detectar skip y navegación de menú.

---

## Ejemplos concretos

### `MortScene` (Step 1) — ~20 líneas de lógica

```cpp
// 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

```cpp
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 (0–9) se cierra con build verde + validación visual antes de arrancar el siguiente. No encadeno pasos automáticamente.
