feat(game-scene): ROOM_INTRO — fade in del món i text d'entrada de sala
This commit is contained in:
1 parent
368f9d2d6a
commit
67b06c0a10
2 files changed
+179
-38
No files matched your search
@@ -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) {
|
||||
setState(State::PLAYING);
|
||||
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,22 +751,29 @@ 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;
|
||||
|
||||
// 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
|
||||
// (teclat + mando + facing).
|
||||
// 2) resolve: si els dos es trobarien, anul·lem velocitats
|
||||
// (head-on) o escalem a push_factor (empenta).
|
||||
// 3) applyMovement: en l'ordre "pushed primer" perquè quan el
|
||||
// pusher faça `tryMove`, el pushed ja estiga fora de la
|
||||
// seua caixa. La callback també tracta l'altre jugador com
|
||||
// a sòlid: xarxa de seguretat davant de qualsevol inexactitud.
|
||||
for (auto& player : players_) { player->tick(DT); }
|
||||
resolvePlayerVsPlayer(players_view_, DT, push_factor_);
|
||||
for (auto* player : orderedForMovement(players_view_)) { player->applyMovement(DT); }
|
||||
// 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
|
||||
// (teclat + mando + facing).
|
||||
// 2) resolve: si els dos es trobarien, anul·lem velocitats
|
||||
// (head-on) o escalem a push_factor (empenta).
|
||||
// 3) applyMovement: en l'ordre "pushed primer" perquè quan el
|
||||
// pusher faça `tryMove`, el pushed ja estiga fora de la
|
||||
// seua caixa. La callback també tracta l'altre jugador com
|
||||
// a sòlid: xarxa de seguretat davant de qualsevol inexactitud.
|
||||
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,7 +784,9 @@ 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.
|
||||
entity_manager_.checkPlayerCollisions(players_view_);
|
||||
if (GAMEPLAY_ACTIVE) {
|
||||
entity_manager_.checkPlayerCollisions(players_view_);
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef _DEBUG
|
||||
@@ -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) {
|
||||
// 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(); }
|
||||
// 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};
|
||||
});
|
||||
std::ranges::transform(players_, std::back_inserter(order), [](const auto& p) {
|
||||
return Renderable{.feet_y = p->getFeetY(), .entity = nullptr, .player = p.get()};
|
||||
});
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user