feat(game-scene): ROOM_INTRO — fade in del món i text d'entrada de sala

This commit is contained in:
JailDesigner committed 2026-04-26 09:00:15 +02:00
1 parent 368f9d2d6a
commit 67b06c0a10
2 files changed
+155 -14

No files matched your search

+132 -14
View File
@@ -3,11 +3,14 @@
#include <SDL3/SDL.h>
#include <algorithm>
#include <array>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <iostream>
#include <iterator>
#include <string_view>
#include <utility>
#include <variant>
#include <vector>
@@ -490,18 +493,34 @@ void GameScene::setState(State new_state) {
if (new_state == state_) { return; }
std::cout << "[ROOM] " << stateName(state_) << " -> " << stateName(new_state) << '\n';
state_ = new_state;
// Els acumuladors de la transició de sala (sub-fase, elapsed) viuran
// aquí als commits següents. De moment no en tenim cap.
transition_ = {}; // sub-fase i elapsed s'inicialitzen a zero a cada estat.
}
void GameScene::updateRoomTransition(int /*delta_ms*/) {
// Implementació mínima: no hi ha sub-fases ni fades encara, així que
// ROOM_INTRO i ROOM_OUTRO són efectivament instantanis. El bucle de
// sala queda demostrat (INTRO → PLAYING; al level_complete OUTRO →
// INTRO → PLAYING) sense efectes visuals. Els commits 3 i 4
// afegiran timings i sub-fases reals.
void GameScene::updateRoomTransition(int delta_ms) {
if (state_ == State::PLAYING) { return; }
transition_.elapsed_ms += delta_ms;
// Avança totes les sub-fases que càpiguen al delta acumulat (típicament
// una, però si el delta és gran o la sub-fase és molt curta podria
// saltar més d'una en un sol frame). En sortir del bucle, o bé estem
// dins d'una sub-fase amb temps restant, o bé hem consumit totes les
// sub-fases de l'estat i toca canviar d'estat.
if (state_ == State::ROOM_INTRO) {
const std::array<int, 5> DURS = {
transition_config_.intro_fade_in_ms,
transition_config_.intro_text_delay_ms,
transition_config_.intro_text_enter_ms,
transition_config_.intro_text_hold_ms,
transition_config_.intro_text_exit_ms,
};
while (transition_.phase < static_cast<int>(DURS.size()) && transition_.elapsed_ms >= DURS[transition_.phase]) {
transition_.elapsed_ms -= DURS[transition_.phase];
++transition_.phase;
}
if (transition_.phase >= static_cast<int>(DURS.size())) {
setState(State::PLAYING);
}
return;
}
if (state_ == State::ROOM_OUTRO) {
@@ -510,12 +529,83 @@ void GameScene::updateRoomTransition(int /*delta_ms*/) {
// l'estat OPEN, com era l'acord.
// TODO(itinerari): seleccionar la pròxima sala (incrementar
// `room_index_`, intercanviar `config_.map_yaml`, recrear
// tilemap/entitats) abans de tornar a ROOM_INTRO.
// tilemap/entitats) abans de tornar a ROOM_INTRO. La
// implementació de les sub-fases d'OUTRO arribarà al commit 4.
setState(State::ROOM_INTRO);
return;
}
}
auto GameScene::formatRoomTitle(int n) -> std::string {
// Substitució mínima `{n}` → enter. Locale::text retorna la pròpia
// clau si no troba traducció, així que quan els fitxers de locale
// encara no porten la clau el text serà visible com a "game.room_intro"
// i no com una cadena buida (útil per a debug en pre-locales).
auto tpl = Locale::get().text("game.room_intro");
constexpr std::string_view PLACEHOLDER = "{n}";
auto pos = tpl.find(PLACEHOLDER);
if (pos != std::string::npos) {
tpl.replace(pos, PLACEHOLDER.size(), std::to_string(n));
}
return tpl;
}
void GameScene::renderRoomFade() {
// Fade negre opac → transparent durant ROOM_INTRO/fase 0. Pintat
// sobre el món un cop els actors ja s'han dibuixat. Al commit 4
// s'afegirà el fade out simètric per a ROOM_OUTRO/fase final.
if (state_ != State::ROOM_INTRO || transition_.phase != 0) { return; }
const int DUR = transition_config_.intro_fade_in_ms;
const float T = (DUR > 0)
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(DUR), 0.0F, 1.0F)
: 1.0F;
const auto ALPHA = static_cast<std::uint8_t>(255.0F * (1.0F - T));
if (ALPHA == 0) { return; }
Screen::get().blendRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, 0x00000000U, ALPHA);
}
void GameScene::renderRoomText() {
// Text d'entrada de sala. Visible només durant les sub-fases 2-4
// de ROOM_INTRO (enter, hold, exit). Reusa la font cachejada per
// a la pausa: mateix tipus de lletra i mateix índex de color.
if (state_ != State::ROOM_INTRO || transition_.phase < 2 || transition_.phase > 4) { return; }
if (!pause_font_) { return; }
const std::string TEXT = formatRoomTitle(room_index_);
constexpr int KERNING = 0;
const int W = pause_font_->length(TEXT, KERNING);
const int OFF_LEFT = -W;
int x = GameCanvas::CENTER_X;
if (transition_.phase == 2) {
const int DUR = transition_config_.intro_text_enter_ms;
const float T = (DUR > 0)
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(DUR), 0.0F, 1.0F)
: 1.0F;
const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear;
x = Easing::lerpInt(OFF_LEFT, GameCanvas::CENTER_X, CURVE(T));
} else if (transition_.phase == 4) {
const int DUR = transition_config_.intro_text_exit_ms;
const float T = (DUR > 0)
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(DUR), 0.0F, 1.0F)
: 1.0F;
const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear;
x = Easing::lerpInt(GameCanvas::CENTER_X, OFF_LEFT, CURVE(T));
}
constexpr Uint8 OUTLINE_WHITE = 255;
constexpr Uint8 STROKE_DIST = 1;
const int Y = GameCanvas::CENTER_Y - (pause_font_->getLineHeight() / 2);
pause_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG,
x,
Y,
TEXT,
KERNING,
UiConfig::get().notificationText(),
STROKE_DIST,
OUTLINE_WHITE);
}
void GameScene::spawnDust(float feet_x_center, float feet_y, bool is_landing) {
// Mida del sprite de Dust llegida del seu YAML. Hardcoded per
// simplificar — si canvia, només cal tocar dust.yaml + aquesta
@@ -661,9 +751,15 @@ void GameScene::update(int delta_ms) {
constexpr bool EDITOR_FROZEN = false;
#endif
const int SCALED_DELTA_MS = static_cast<int>(static_cast<float>(delta_ms) * pause_.scale);
const bool GAMEPLAY_ACTIVE = (state_ == State::PLAYING);
if (!EDITOR_FROZEN && SCALED_DELTA_MS > 0) {
const float DT = static_cast<float>(SCALED_DELTA_MS) / 1000.0F;
// Durant ROOM_INTRO/ROOM_OUTRO els jugadors no s'actualitzen ni
// poden col·lisionar amb entitats. Les entitats sí, perquè el
// disseny demana que el món continui animant-se durant les
// transicions de sala.
if (GAMEPLAY_ACTIVE) {
// Pipeline de moviment en tres passes per poder intercalar la
// resolució de contactes jugador↔jugador entre input i posició:
// 1) tick: cada jugador calcula la velocitat intencionada
@@ -677,6 +773,7 @@ void GameScene::update(int delta_ms) {
for (auto& player : players_) { player->tick(DT); }
resolvePlayerVsPlayer(players_view_, DT, push_factor_);
for (auto* player : orderedForMovement(players_view_)) { player->applyMovement(DT); }
}
// El tick d'entitats DESPRÉS del tick dels jugadors: si el jugador ha
// emès `on_tile_entered` aquest frame, l'EntityManager ja l'ha encaminat
@@ -687,8 +784,10 @@ void GameScene::update(int delta_ms) {
// Detecció de contacte player↔entitat després que totes les entitats
// hagin mogut aquest frame. Reutilitzem la vista plana que ja
// manté el GameScene per al resolver player↔player.
if (GAMEPLAY_ACTIVE) {
entity_manager_.checkPlayerCollisions(players_view_);
}
}
#ifdef _DEBUG
editor_.update(delta_ms, tilemap_);
@@ -726,12 +825,19 @@ void GameScene::render() {
// explícita: l'editor edita el tilemap, no la capa de pintat).
if (!SKIP_ACTORS && paint_layer_) { paint_layer_->render(); }
if (!SKIP_ACTORS) {
// Durant ROOM_INTRO/ROOM_OUTRO els jugadors no es veuen ni es mouen.
// Les entitats sí que es renderitzen perquè el disseny demana que
// el món continui animant-se durant el fade i el text.
const bool RENDER_PLAYERS = (state_ == State::PLAYING);
if (RENDER_PLAYERS) {
// Passada de sombres dels jugadors saltant. Va per davant del
// pintat (al damunt) però per darrere de TOTS els actors —
// entitats i jugadors, inclòs el saltador mateix— així la sombra
// queda al sòl sense tapar res. `renderShadow` és no-op si el
// jugador està a terra, així que no cal filtrar al callsite.
for (const auto& p : players_) { p->renderShadow(); }
}
// Z-order interleaving entitats ↔ players per `getFeetY()` ascendent:
// els actors amb els peus més amunt queden dibuixats primer (al fons),
// els més avall al davant. Així un sarc situat per darrere d'un player
@@ -742,14 +848,16 @@ void GameScene::render() {
Player* player; // nullable; si és nullptr, s'usa `entity`.
};
std::vector<Renderable> order;
order.reserve(entity_manager_.entityCount() + players_.size());
order.reserve(entity_manager_.entityCount() + (RENDER_PLAYERS ? players_.size() : 0));
const auto ENTITIES = entity_manager_.getEntities();
std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) {
return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr};
});
if (RENDER_PLAYERS) {
std::ranges::transform(players_, std::back_inserter(order), [](const auto& p) {
return Renderable{.feet_y = p->getFeetY(), .entity = nullptr, .player = p.get()};
});
}
std::ranges::stable_sort(order, [](const Renderable& a, const Renderable& b) {
return a.feet_y < b.feet_y;
});
@@ -760,11 +868,21 @@ void GameScene::render() {
r.player->render();
}
}
// HUD: a sobre dels actors perquè la franja superior tape qualsevol
// sprite que (durant l'animació d'entrada) puga sortir més amunt
// del playfield. S'amaga en mode editor (l'editor edita el tilemap;
// el HUD distrauria) però segueix actualitzant-se per fora.
// Fade del món: pintat ENTRE actors i HUD perquè el marcador, les
// notificacions i el service menu queden per damunt sense afectar-se.
// Inactiu si l'estat és PLAYING.
renderRoomFade();
// HUD: a sobre dels actors (i del fade) perquè la franja superior
// tape qualsevol sprite que (durant l'animació d'entrada) puga
// sortir més amunt del playfield. S'amaga en mode editor però
// segueix actualitzant-se per fora.
if (hud_) { hud_->render(); }
// Text de la transició de sala (entrada o sortida): per damunt del
// HUD perquè és UI de transició i ha de ser sempre llegible.
renderRoomText();
}
#ifdef _DEBUG
+23
View File
@@ -185,12 +185,35 @@ class GameScene : public Scene {
// la sala. La transició a PLAYING l'orquestra `updateRoomTransition`.
State state_{State::ROOM_INTRO};
// Sub-fase i temps acumulat dins de l'estat actual. `setState` el
// reseteja a {0, 0}. La interpretació depèn de l'estat:
// ROOM_INTRO: 0=fade_in, 1=text_delay, 2=text_enter, 3=text_hold, 4=text_exit
// ROOM_OUTRO: 0=text_enter, 1=text_hold, 2=text_exit, 3=fade_out
// PLAYING: no s'usa (updateRoomTransition no avança res).
struct RoomTransition {
int phase{0};
int elapsed_ms{0};
} transition_{};
// Índex de sala que es passa com a paràmetre {n} al text d'intro.
// Avui és sempre 1 (no hi ha itinerari de sales encara). Quan
// arribi l'itinerari, és el camp que es modificarà al transicionar
// de ROOM_OUTRO → ROOM_INTRO per saltar a la pròxima.
int room_index_{1};
// Substitueix `{n}` de `Locale::get().text("game.room_intro")` per
// l'enter passat. Pensat com a primer pas mínim cap a un format
// genèric: si apareix més text parametritzat, val la pena ascendir
// a un `Locale::format` propi. Estàtica perquè no depèn d'estat de
// l'escena (només consulta el Locale singleton).
[[nodiscard]] static auto formatRoomTitle(int n) -> std::string;
// Píxels del fade negre i del text de la transició de sala.
// Cridats des de `render()` entre el bloc d'actors i el HUD —
// així marcador, notificacions i service menu queden per damunt.
void renderRoomFade();
void renderRoomText();
#ifdef _DEBUG
// Editor modal del tilemap. Mútuament excloent amb la pausa: si la
// pausa no està IDLE la tecla de toggle de l'editor s'ignora, i