1245 lines
62 KiB
C++
1245 lines
62 KiB
C++
#include "game/scenes/game_scene.hpp"
|
||
|
||
#include <SDL3/SDL.h>
|
||
|
||
#include <algorithm>
|
||
#include <cmath>
|
||
#include <cstddef>
|
||
#include <cstdint>
|
||
#include <filesystem>
|
||
#include <iostream>
|
||
#include <iterator>
|
||
#include <utility>
|
||
#include <variant>
|
||
#include <vector>
|
||
|
||
#include "core/audio/audio.hpp"
|
||
#include "core/input/gamepad_manager.hpp"
|
||
#include "core/input/global_inputs.hpp"
|
||
#include "core/input/key_config.hpp"
|
||
#include "core/locale/locale.hpp"
|
||
#include "core/rendering/notifier.hpp"
|
||
#include "core/rendering/render_info.hpp"
|
||
#include "core/rendering/screen.hpp"
|
||
#include "core/rendering/text.hpp"
|
||
#include "core/resources/resource_cache.hpp"
|
||
#include "core/resources/resource_helper.hpp"
|
||
#include "core/system/event_buffer.hpp"
|
||
#include "core/ui/ui_config.hpp"
|
||
#include "external/fkyaml_node.hpp"
|
||
#include "game/entities/concrete/dust.hpp"
|
||
#include "game/entities/concrete/whip_attack.hpp"
|
||
#include "game/entities/entity.hpp"
|
||
#include "game/entities/entity_factory.hpp"
|
||
#include "game/entities/entity_loader.hpp"
|
||
#include "game/entities/event_bus.hpp"
|
||
#include "game/entities/player.hpp"
|
||
#include "game/levels/level_manager.hpp"
|
||
#include "game/map/map.hpp"
|
||
#include "game/options.hpp"
|
||
#include "game/ui/hud.hpp"
|
||
#include "utils/defaults.hpp"
|
||
#include "utils/defines.hpp"
|
||
#include "utils/easing.hpp"
|
||
|
||
namespace {
|
||
|
||
// La geometria ha de quedar inicialitzada abans que Tilemap parsejar el
|
||
// mateix YAML i abans que el ctor de Player consulte Map::playfield_*.
|
||
// Construïm el Tilemap via un helper que primer carrega la geometria
|
||
// i després retorna el Tilemap per valor (move-construït).
|
||
auto makeTilemap(const std::string& yaml_path) -> Tilemap {
|
||
Map::loadFrom(yaml_path);
|
||
return Tilemap(yaml_path);
|
||
}
|
||
|
||
// Llegeix un camp sencer opcional del YAML del mapa. Si no hi és,
|
||
// retorna `fallback` (per defecte -1, que al Player indica "no pinta").
|
||
// S'usa per `player1_paint_tile` i `player2_paint_tile`: cada jugador
|
||
// pinta amb l'índex declarat al mapa, i un mapa sense aquests camps
|
||
// simplement no deixa rastre visual.
|
||
auto readOptionalInt(const std::string& yaml_path, const std::string& key, int fallback = -1) -> int {
|
||
auto bytes = Resource::Helper::loadFile(yaml_path);
|
||
if (bytes.empty()) { return fallback; }
|
||
const auto* begin = reinterpret_cast<const char*>(bytes.data());
|
||
const auto* end = begin + bytes.size();
|
||
auto yaml = fkyaml::node::deserialize(begin, end);
|
||
if (!yaml.contains(key)) { return fallback; }
|
||
return yaml[key].get_value<int>();
|
||
}
|
||
|
||
auto readOptionalFloat(const std::string& yaml_path, const std::string& key, float fallback) -> float {
|
||
auto bytes = Resource::Helper::loadFile(yaml_path);
|
||
if (bytes.empty()) { return fallback; }
|
||
const auto* begin = reinterpret_cast<const char*>(bytes.data());
|
||
const auto* end = begin + bytes.size();
|
||
auto yaml = fkyaml::node::deserialize(begin, end);
|
||
if (!yaml.contains(key)) { return fallback; }
|
||
return yaml[key].get_value<float>();
|
||
}
|
||
|
||
// Comprova si `a` i `b` se solapen amb una tolerància positiva al
|
||
// contacte perfecte (cantos que es toquen = no-solapament). SDL3 ja
|
||
// ofereix `SDL_HasRectIntersectionFloat`, però aquí volem la mateixa
|
||
// semàntica que al Player::collidesWithWorld.
|
||
auto rectsOverlap(const SDL_FRect& a, const SDL_FRect& b) -> bool {
|
||
return SDL_HasRectIntersectionFloat(&a, &b);
|
||
}
|
||
|
||
// Resolver jugador↔jugador. Opera sobre les velocitats intencionades
|
||
// (escrites per `Player::tick`, abans d'`applyMovement`) per a
|
||
// implementar les regles:
|
||
// - Sense overlap previst → no toca res.
|
||
// - Ambdós amb input que els faria col·lidir → head-on: velocitats
|
||
// anul·lades per als dos.
|
||
// - Un quiet, l'altre empentant-lo → push: ambdues velocitats
|
||
// escalades a `push_factor` i el pushed hereta la direcció del
|
||
// pusher. Si el pushed no pot avançar (mur/sarc), `tryMove` ja ho
|
||
// gestionarà a `applyMovement` mogent la distància parcial possible.
|
||
// Amb dos jugadors només és un parell; si en un futur n'hi ha més,
|
||
// la mateixa lògica es pot aplicar pairwise.
|
||
// Important: el criteri "mou-se vs parat" consulta INPUT cru (no la
|
||
// velocitat física), així un jugador que coasta per inèrcia sense
|
||
// input compta com a parat — ni dispara head-on ni empeny l'altre.
|
||
// L'overlap predictiu sí que usa velocitat física (intended), perquè
|
||
// és el desplaçament real d'aquest frame.
|
||
void resolvePlayerVsPlayer(const std::vector<Player*>& ps, float dt, float push_factor) {
|
||
if (ps.size() < 2) { return; }
|
||
auto* a = ps[0];
|
||
auto* b = ps[1];
|
||
if (a == nullptr || b == nullptr) { return; }
|
||
|
||
const bool A_AIR = a->isAirborne();
|
||
const bool B_AIR = b->isAirborne();
|
||
|
||
// HURT a un costat o l'altre: el resolver no s'activa — el HURT
|
||
// surt projectat sense control i els dos es travessen. Coincideix
|
||
// amb la regla d'`Player::collidesWithWorld`.
|
||
if (a->isHurt() || b->isHurt()) { return; }
|
||
// EXITED/DISABLED: jugadors fora del joc, no participen al resolver.
|
||
// Si hi entrés un, la seua presència física frenaria l'altre (per
|
||
// overlap predictiu) tot i que `applyMovement` és no-op. Sortir
|
||
// d'aquí garanteix que el jugador actiu pot creuar la porta o
|
||
// moure's lliurement sense interferència d'un slot inactiu.
|
||
if (a->isOutOfPlay() || b->isOutOfPlay()) { return; }
|
||
|
||
// Aire ↔ tierra: el saltador es travessa net del caminant. Sense
|
||
// resolver, sense head-on, sense push. Els dos continuen amb la
|
||
// velocitat que portaven. Important: això NO és el filtre per
|
||
// altura del cos — és binari. Coincideix amb la regla d'`Player::
|
||
// collidesWithWorld`: durant un salt no hi ha mai col·lisió amb
|
||
// un caminant, ni baix ni alt.
|
||
if (A_AIR != B_AIR) { return; }
|
||
|
||
// Calculem l'overlap previst en l'espai de la hit-box petita:
|
||
// els jugadors han de poder acostar-se molt (caixes de moviment
|
||
// 16×16 quasi tocant) sense disparar el resolver; només quan el
|
||
// contacte real (hit-box 8×8) s'activaria, neutralitzem o apliquem
|
||
// push (a terra) o invertim velocitats (en l'aire).
|
||
const SDL_FRect BOX_A = a->getHitBox();
|
||
const SDL_FRect BOX_B = b->getHitBox();
|
||
const SDL_FRect NEXT_A{
|
||
.x = BOX_A.x + (a->getIntendedVX() * dt),
|
||
.y = BOX_A.y + (a->getIntendedVY() * dt),
|
||
.w = BOX_A.w,
|
||
.h = BOX_A.h,
|
||
};
|
||
const SDL_FRect NEXT_B{
|
||
.x = BOX_B.x + (b->getIntendedVX() * dt),
|
||
.y = BOX_B.y + (b->getIntendedVY() * dt),
|
||
.w = BOX_B.w,
|
||
.h = BOX_B.h,
|
||
};
|
||
if (!rectsOverlap(NEXT_A, NEXT_B)) { return; }
|
||
|
||
// Tots dos en l'aire (A_AIR == B_AIR == true aquí, ja descartat
|
||
// l'altre cas a dalt): rebot mid-aire si els rangs verticals
|
||
// [z, z+body_height] solapen. Si la diferència d'alçades és
|
||
// massa gran, ja no hi ha contacte real (un està molt més amunt
|
||
// que l'altre) i no fem res. Si solapen, invertim ambdues
|
||
// velocitats horitzontals i els deixem rebotar fins a aterrar
|
||
// — els dos cauen en stun, com a càstig per abusar del salt.
|
||
if (A_AIR) {
|
||
// cppcheck-suppress knownConditionTrueFalse
|
||
const auto BODY_H = static_cast<float>(Player::body_height);
|
||
// cppcheck-suppress knownConditionTrueFalse
|
||
if (BODY_H <= 0.0F) { return; }
|
||
if (std::fabs(a->getJumpZ() - b->getJumpZ()) >= BODY_H) { return; }
|
||
// Saltadors estàtics horitzontalment (cas degenerat: tots dos
|
||
// jumpejant verticals al mateix x): no hi ha velocitat a
|
||
// invertir, deixem-los — el predictiu no tornarà a disparar
|
||
// perquè en el següent frame ja no hi haurà velocitat
|
||
// d'acostament.
|
||
const float REL_VX = a->getIntendedVX() - b->getIntendedVX();
|
||
const float REL_VY = a->getIntendedVY() - b->getIntendedVY();
|
||
if (REL_VX == 0.0F && REL_VY == 0.0F) { return; }
|
||
a->setIntendedVelocity(-a->getIntendedVX(), -a->getIntendedVY());
|
||
b->setIntendedVelocity(-b->getIntendedVX(), -b->getIntendedVY());
|
||
return;
|
||
}
|
||
|
||
const bool MOVING_A = (a->getInputVX() != 0.0F) || (a->getInputVY() != 0.0F);
|
||
const bool MOVING_B = (b->getInputVX() != 0.0F) || (b->getInputVY() != 0.0F);
|
||
|
||
if (MOVING_A && MOVING_B) {
|
||
// Head-on o encreuament amb col·lisió: cap dels dos es mou.
|
||
a->setIntendedVelocity(0.0F, 0.0F);
|
||
b->setIntendedVelocity(0.0F, 0.0F);
|
||
return;
|
||
}
|
||
|
||
auto* pusher = MOVING_A ? a : b;
|
||
auto* pushed = MOVING_A ? b : a;
|
||
const float VX = pusher->getIntendedVX() * push_factor;
|
||
const float VY = pusher->getIntendedVY() * push_factor;
|
||
pusher->setIntendedVelocity(VX, VY);
|
||
pushed->setIntendedVelocity(VX, VY);
|
||
// El pushed es mourà sense input propi: marquem-ho perquè
|
||
// `applyMovement` no active la variant `walk` de l'animació.
|
||
pushed->setBeingPushed(true);
|
||
}
|
||
|
||
// Ordre d'`applyMovement`: en push, el pushed ha de moure's abans
|
||
// perquè quan el pusher faça `tryMove` veja el pushed a la seua
|
||
// posició nova i no li bloqueje. Heurística barata: el jugador amb
|
||
// `intended*dt` que entra a l'altre és el pusher; però com a totes
|
||
// les configuracions push els dos tenen la mateixa velocitat resultant
|
||
// i comparteixen direcció, usem el producte (dot) del vector d'acció
|
||
// i el vector cap a l'altre jugador: positiu = pusher.
|
||
auto orderedForMovement(const std::vector<Player*>& ps) -> std::vector<Player*> {
|
||
std::vector<Player*> out = ps;
|
||
if (out.size() < 2 || out[0] == nullptr || out[1] == nullptr) { return out; }
|
||
const auto* a = out[0];
|
||
const auto* b = out[1];
|
||
const SDL_FRect BOX_A = a->getBaseBox();
|
||
const SDL_FRect BOX_B = b->getBaseBox();
|
||
const float DX = (BOX_B.x + (BOX_B.w / 2.0F)) - (BOX_A.x + (BOX_A.w / 2.0F));
|
||
const float DY = (BOX_B.y + (BOX_B.h / 2.0F)) - (BOX_A.y + (BOX_A.h / 2.0F));
|
||
// dot(v_a, dir_a→b): positiu = a empenta a b → b es mou primer.
|
||
const float DOT_A = (a->getIntendedVX() * DX) + (a->getIntendedVY() * DY);
|
||
if (DOT_A > 0.0F) { std::swap(out[0], out[1]); }
|
||
return out;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
auto GameScene::defaultConfig() -> Config {
|
||
// El path del mapa el dicta el LevelManager (que ja ha llegit `levels.yaml`
|
||
// a la primera invocació). Així `defaultConfig()` no coneix cap fitxer
|
||
// concret de sala — només cap a on apunta la progressió ara mateix.
|
||
return {
|
||
.map_yaml = Levels::LevelManager::get().currentPath(),
|
||
.player_sprite = Defaults::Pyramid::PLAYER_SPRITE,
|
||
.player_yaml = Defaults::Pyramid::PLAYER_YAML,
|
||
.player2_sprite = Defaults::Pyramid::PLAYER_SPRITE_P2,
|
||
.player2_yaml = Defaults::Pyramid::PLAYER_YAML_P2,
|
||
.music_track = Defaults::Pyramid::MUSIC_TRACK,
|
||
.starting_players = DebugConfig::StartingPlayers::BOTH,
|
||
};
|
||
}
|
||
|
||
GameScene::GameScene(Config config)
|
||
: config_(std::move(config)),
|
||
tilemap_(makeTilemap(config_.map_yaml)) {
|
||
// El text d'INTRO i el HUD demanaran piràmide/sala directament al
|
||
// LevelManager — `GameScene` ja no manté un mirror local. La
|
||
// sincronització "una vegada per càrrega de sala" la fa `loadRoom`
|
||
// (per al HUD) i `renderRoomText` (per al text d'INTRO, llegint el
|
||
// valor a cada frame de la transició).
|
||
|
||
// Convenció: el Resource::Cache indexa per basename del fitxer, perquè
|
||
// la mateixa clau l'usa el parseador d'animació (YAML > tileSetFile).
|
||
// Lambda perquè tenim dos conjunts d'assets (sam/morcus) i l'alta al
|
||
// cache és idèntica per als dos; la clau d'animació ens la quedem per
|
||
// passar-la al ctor del Player corresponent.
|
||
auto load_player_assets = [](const std::string& sprite, const std::string& yaml) -> std::string {
|
||
const auto SPRITE_KEY = std::filesystem::path(sprite).filename().string();
|
||
const auto ANIM_KEY = std::filesystem::path(yaml).filename().string();
|
||
Resource::Cache::get().loadSurface(SPRITE_KEY, sprite);
|
||
Resource::Cache::get().loadAnimation(ANIM_KEY, yaml);
|
||
return ANIM_KEY;
|
||
};
|
||
|
||
const bool USE_P1 = (config_.starting_players == DebugConfig::StartingPlayers::P1 || config_.starting_players == DebugConfig::StartingPlayers::BOTH);
|
||
const bool USE_P2 = (config_.starting_players == DebugConfig::StartingPlayers::P2 || config_.starting_players == DebugConfig::StartingPlayers::BOTH);
|
||
players_starts_active_ = {USE_P1, USE_P2};
|
||
|
||
// Carreguem assets per als DOS jugadors sempre, encara que algun no
|
||
// arranque actiu: l'slot existeix igualment al vector com a DISABLED
|
||
// i podria reactivar-se amb un futur "join in progress". El cost de
|
||
// tenir el sprite cachejat sense usar-se és negligible (cap render).
|
||
const std::string ANIM_P1 = load_player_assets(config_.player_sprite, config_.player_yaml);
|
||
const std::string ANIM_P2 = load_player_assets(config_.player2_sprite, config_.player2_yaml);
|
||
|
||
// Geometria de col·lisió del jugador (caixa 16×16, base 12): carregada
|
||
// del mateix YAML que l'animació. Ha d'estar feta abans d'instanciar
|
||
// Player perquè el spawn consulta `collision_*`. Els membres de Player
|
||
// són `static inline`, compartits per tots els jugadors; carregar des
|
||
// del YAML de P1 és suficient (els dos personatges comparteixen
|
||
// geometria — morcus és un repaletat de sam).
|
||
Player::loadFrom(config_.player_yaml);
|
||
|
||
// Carrega les entitats del nivell (antorxa, sarcòfags, porta, momia,
|
||
// etc.). Requereix que Map::loadFrom ja s'hagi cridat (ho fa
|
||
// `makeTilemap`), perquè consulta `Map::tile_size` i `getSarcRect`.
|
||
// Les surfaces de les entitats es carreguen al Resource::Cache aquí.
|
||
entity_manager_.loadFromLevel(config_.map_yaml);
|
||
|
||
// Subscripcions bàsiques al bus del nivell. `level_complete` també
|
||
// dispara la transició a ROOM_OUTRO si encara estem a PLAYING; les
|
||
// re-emissions es filtren per estat per no re-entrar mai des d'una
|
||
// transició ja en marxa.
|
||
entity_manager_.getBus().subscribe("level_complete", [this](const Event& ev) {
|
||
std::cout << "[LEVEL COMPLETE] player_id=" << ev.player_id << '\n';
|
||
if (state_ == RoomFsm::State::PLAYING) { setState(RoomFsm::State::ROOM_OUTRO); }
|
||
});
|
||
entity_manager_.getBus().subscribe("player_hit", [](const Event& ev) {
|
||
const auto* dmg = std::get_if<int>(&ev.payload);
|
||
std::cout << "[PLAYER HIT] player_id=" << ev.player_id
|
||
<< " dmg=" << (dmg != nullptr ? *dmg : 0) << '\n';
|
||
});
|
||
|
||
// Polvet: precarreguem el surface del Dust una sola vegada perquè
|
||
// els spawns runtime de l'entitat no facin disc al primer salt.
|
||
// També ens subscrivim a `player_jumped` i `player_landed` per
|
||
// spawnejar el polvet de despegue / aterratge a la posició dels
|
||
// peus que el Player ens dóna al payload.
|
||
Resource::Cache::get().loadSurface("dust.gif", "data/entities/dust/dust.gif");
|
||
entity_manager_.getBus().subscribe("player_jumped", [this](const Event& ev) {
|
||
const auto* pos = std::get_if<WorldPos>(&ev.payload);
|
||
if (pos != nullptr) { spawnDust(pos->x, pos->y, false); }
|
||
});
|
||
entity_manager_.getBus().subscribe("player_landed", [this](const Event& ev) {
|
||
const auto* pos = std::get_if<WorldPos>(&ev.payload);
|
||
if (pos != nullptr) { spawnDust(pos->x, pos->y, true); }
|
||
});
|
||
|
||
// Whip: precarreguem les 4 surfaces (una per cardinal) abans del
|
||
// primer spawn perquè el Player no facte disc al primer atac.
|
||
// Subscriu `whip_spawn` per crear el `WhipAttack` corresponent.
|
||
Resource::Cache::get().loadSurface("whip_east.gif", "data/entities/whip_attack/whip_east.gif");
|
||
Resource::Cache::get().loadSurface("whip_west.gif", "data/entities/whip_attack/whip_west.gif");
|
||
Resource::Cache::get().loadSurface("whip_north.gif", "data/entities/whip_attack/whip_north.gif");
|
||
Resource::Cache::get().loadSurface("whip_south.gif", "data/entities/whip_attack/whip_south.gif");
|
||
entity_manager_.getBus().subscribe("whip_spawn", [this](const Event& ev) {
|
||
const auto* p = std::get_if<WhipSpawnPayload>(&ev.payload);
|
||
if (p != nullptr) { spawnWhip(*p); }
|
||
});
|
||
|
||
// Controls per jugador: P1 amb fletxes, P2 amb WASD. Cada Player
|
||
// escolta les seues pròpies tecles dins del seu `update`, no hi ha
|
||
// estat global d'input.
|
||
const Player::Controls CONTROLS_P1{
|
||
.up = Defaults::KeysGame::UP,
|
||
.down = Defaults::KeysGame::DOWN,
|
||
.left = Defaults::KeysGame::LEFT,
|
||
.right = Defaults::KeysGame::RIGHT,
|
||
.jump = Defaults::KeysGame::JUMP,
|
||
.attack = Defaults::KeysGame::ATTACK,
|
||
};
|
||
const Player::Controls CONTROLS_P2{
|
||
.up = Defaults::KeysGameP2::UP,
|
||
.down = Defaults::KeysGameP2::DOWN,
|
||
.left = Defaults::KeysGameP2::LEFT,
|
||
.right = Defaults::KeysGameP2::RIGHT,
|
||
.jump = Defaults::KeysGameP2::JUMP,
|
||
.attack = Defaults::KeysGameP2::ATTACK,
|
||
};
|
||
// Spawn lligat a la porta: tots els jugadors apareixen davant del
|
||
// passadís de sortida. `computeSpawn` deriva les coordenades de
|
||
// `Map::door_col` perquè canviar la columna de la porta al YAML
|
||
// moga automàticament l'spawn — no hi ha cap valor hardcoded.
|
||
const auto SPAWN = computeSpawn();
|
||
const bool BOTH_ACTIVE = (USE_P1 && USE_P2);
|
||
|
||
// Índex de tile amb què pinta cada jugador — llegit del YAML del mapa
|
||
// (camps opcionals `player1_paint_tile` / `player2_paint_tile`). Si no
|
||
// hi són, `paint_tile_index_ = -1` i el Player no deixa rastre.
|
||
// Ids canònics dels jugadors: 0 = P1, 1 = P2. Els events del bus els
|
||
// porten per permetre dispatch correcte al HUD i a futures regles de
|
||
// puntuació (veure `Event::player_id`).
|
||
constexpr int ID_P1 = 0;
|
||
constexpr int ID_P2 = 1;
|
||
const int P1_PAINT = readOptionalInt(config_.map_yaml, "player1_paint_tile", -1);
|
||
const int P2_PAINT = readOptionalInt(config_.map_yaml, "player2_paint_tile", -1);
|
||
|
||
// Sempre instanciem els dos slots. Mode single-player: l'únic actiu
|
||
// queda centrat sota la porta (= SPAWN.p1_x + tile_size); l'inactiu
|
||
// ocupa la posició BOTH per simetria, però com es queda DISABLED tot
|
||
// el cicle no es nota. Així `players_[0]` sempre és P1 i `players_[1]`
|
||
// sempre és P2, independentment de quins juguen.
|
||
const auto TILE_F = static_cast<float>(Map::tile_size);
|
||
float p1_x = SPAWN.p1_x;
|
||
float p2_x = SPAWN.p2_x;
|
||
if (!BOTH_ACTIVE) {
|
||
if (USE_P1) { p1_x = SPAWN.p1_x + TILE_F; }
|
||
if (USE_P2) { p2_x = SPAWN.p2_x - TILE_F; }
|
||
}
|
||
players_.emplace_back(std::make_unique<Player>(ANIM_P1, ID_P1, p1_x, SPAWN.y, CONTROLS_P1, Map::makeInfo(), P1_PAINT));
|
||
players_.emplace_back(std::make_unique<Player>(ANIM_P2, ID_P2, p2_x, SPAWN.y, CONTROLS_P2, Map::makeInfo(), P2_PAINT));
|
||
|
||
// Tots els slots arrenquen DISABLED. Els actius els reactivarà
|
||
// `updateRoomTransition` quan s'esgote el delay corresponent dins
|
||
// de ROOM_INTRO (`player_spawn_delay_ms` per al primer, i
|
||
// `player2_spawn_delay_ms` per al segon en mode BOTH). Els inactius
|
||
// es queden DISABLED tot el cicle de sala.
|
||
for (auto& p : players_) { p->disable(); }
|
||
|
||
// PaintLayer: font única del pintat del nivell. Es crea després de
|
||
// `loadFromLevel` perquè l'EntityManager ja ha de tenir els sarcòfags
|
||
// registrats (tot i que el sarc escolta via fan-out, no directament
|
||
// al bus; l'ordre de subscripcions aquí és irrellevant per la
|
||
// correcció, però sí pel log d'arrencada). Connectat al bus del
|
||
// manager per subscriure's a `on_tile_entered` i publicar `tile_painted`.
|
||
paint_layer_ = std::make_unique<PaintLayer>(tilemap_, &entity_manager_.getBus());
|
||
|
||
// Connectem tots els jugadors al bus perquè emeten `on_tile_entered`;
|
||
// el PaintLayer (subscrit ja) processa el pintat i el fan-out cap als
|
||
// sarcòfags passa per `tile_painted`.
|
||
for (auto& p : players_) { p->setEventBus(&entity_manager_.getBus()); }
|
||
|
||
// Física del contacte entre jugadors. El factor viu al YAML del mapa
|
||
// com a propietat de la piràmide (0.30 per defecte). La vista plana
|
||
// `players_view_` i el `setOtherPlayers` s'han de fer ara perquè a
|
||
// cada `applyMovement` la callback de col·lisió consulti els altres
|
||
// jugadors com a sòlids.
|
||
push_factor_ = readOptionalFloat(config_.map_yaml, "push_factor", Defaults::Player::PUSH_FACTOR);
|
||
players_view_.reserve(players_.size());
|
||
std::ranges::transform(players_, std::back_inserter(players_view_), [](const auto& p) { return p.get(); });
|
||
for (auto& p : players_) { p->setOtherPlayers(&players_view_); }
|
||
|
||
// Comptador d'EXITED a EntityManager: publica `level_complete` quan
|
||
// tots els jugadors *actius* han travessat la porta. Els slots DISABLED
|
||
// permanents (single-player) NO compten — d'altra manera la sala mai
|
||
// es completaria, perquè un slot DISABLED no pot eixir per la porta.
|
||
const int ACTIVE_COUNT = std::ranges::count(players_starts_active_, true);
|
||
entity_manager_.setExpectedPlayerCount(ACTIVE_COUNT);
|
||
// El snap també empeny lateralment si l'altre jugador ocupa el carril:
|
||
// passem el mateix factor perquè vaja al mateix ritme que el resolver.
|
||
for (auto& p : players_) { p->setPushFactor(push_factor_); }
|
||
|
||
// Marcador (HUD) de la franja superior. Reutilitza la font 8bithud i
|
||
// la paleta "ui" ja precarregades pel Director — no toca disc des
|
||
// d'aquí. Subscrit al bus per reaccionar a `player_hit`,
|
||
// `level_complete`, etc. amb shake/flash i futurs canvis de mode.
|
||
const std::string HUD_FONT_GIF_KEY = UiConfig::get().hudFont().name + ".gif";
|
||
auto hud_font_surface = Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY);
|
||
auto hud_font = std::make_shared<Text>(hud_font_surface, UiConfig::get().hudFont().fnt);
|
||
hud_font->setDefaultKerning(UiConfig::get().hudFont().kerning);
|
||
hud_ = std::make_unique<Hud>(entity_manager_.getBus(), hud_font);
|
||
// Si hi ha P2 actiu, activem el seu bloc (per defecte el HUD naix amb
|
||
// només P1). Mentre els events del bus no porten `player_id`, els hits
|
||
// segueixen aplicant-se a P1 (veure TODO a Hud::onPlayerHit).
|
||
if (USE_P2) { hud_->activatePlayer(2); }
|
||
// Sincronització inicial dels comptadors de piràmide/sala del HUD amb
|
||
// el LevelManager (després de cada `loadRoom` també es refresca; ací
|
||
// és el primer ajust per a la sala d'arrencada).
|
||
{
|
||
const auto& mgr = Levels::LevelManager::get();
|
||
hud_->setPyramidRoom(mgr.currentPyramid(), mgr.currentRoom());
|
||
}
|
||
|
||
// La música de fons no s'arrenca ací: la sala comença a ROOM_INTRO,
|
||
// que és silenciós per disseny. La música la posa en marxa
|
||
// `setState(PLAYING)` quan la cinemàtica acaba (i la torna a aturar
|
||
// `enterRoomIntro` quan entrem a una nova sala). El track ja està
|
||
// al Resource::Cache (preload al Director), per tant la primera
|
||
// crida a playMusic és immediata.
|
||
|
||
// Font de l'overlay de pausa: reutilitzem la Surface ja cachejada al
|
||
// Director (PLACEHOLDER_NAME + ".gif"). Text només parseja el .fnt a
|
||
// l'arrencada de l'escena, un sol cop.
|
||
const std::string PAUSE_FONT_KEY = UiConfig::get().placeholderFont().name + ".gif";
|
||
auto pause_surface = Resource::Cache::get().getSurface(PAUSE_FONT_KEY);
|
||
pause_font_ = std::make_shared<Text>(pause_surface, UiConfig::get().placeholderFont().fnt);
|
||
pause_font_->setDefaultKerning(UiConfig::get().placeholderFont().kerning);
|
||
|
||
// Font del rètol de transició de sala. Mateixa convenció que la pausa
|
||
// (Surface preloaded pel Director, només construïm el `Text` ací).
|
||
const std::string ROOM_TEXT_FONT_KEY = UiConfig::get().roomTextFont().name + ".gif";
|
||
auto room_text_surface = Resource::Cache::get().getSurface(ROOM_TEXT_FONT_KEY);
|
||
room_text_font_ = std::make_shared<Text>(room_text_surface, UiConfig::get().roomTextFont().fnt);
|
||
room_text_font_->setDefaultKerning(UiConfig::get().roomTextFont().kerning);
|
||
|
||
// Timings i corbes de la FSM de sala. Llegits del YAML una sola
|
||
// vegada; canvis requereixen reiniciar el joc.
|
||
transition_config_ = RoomFsm::loadConfig(Defaults::GameScene::CONFIG_YAML);
|
||
|
||
// Preload del so del rètol d'INTRO (no hi ha manifest de sons; el
|
||
// pack agafa tot `data/` automàticament i ací registrem la clau
|
||
// al Resource::Cache perquè Audio::playSound la trobe). Si el camp
|
||
// del YAML està buit, no carreguem res — el dispar consultarà el
|
||
// cache i caurà al silenci sense crash.
|
||
if (!transition_config_.intro_text_sound.empty()) {
|
||
Resource::Cache::get().loadSound(
|
||
transition_config_.intro_text_sound,
|
||
"data/sounds/" + transition_config_.intro_text_sound);
|
||
}
|
||
|
||
// Log d'estat inicial: la FSM arrenca a ROOM_INTRO. En aquesta fita
|
||
// les transicions són instantànies (sense fades ni text) i el primer
|
||
// tick d'`updateRoomTransition` ja saltarà a PLAYING.
|
||
std::cout << "[ROOM] state=ROOM_INTRO (initial)" << '\n';
|
||
}
|
||
|
||
GameScene::~GameScene() = default;
|
||
|
||
auto GameScene::computeSpawn() -> PlayerSpawn {
|
||
// Centre de la columna del passadís en píxels canvas. La caixa de
|
||
// col·lisió del jugador (12 px) cap dins del passadís (16 px) amb 2
|
||
// px de marge a banda i banda; alinear el sprite al centre de la
|
||
// columna garanteix que la base del jugador i la del passadís
|
||
// coincideixin verticalment.
|
||
const float DOOR_CENTER_X = static_cast<float>((Map::door_col * Map::tile_size) + Map::tilemap_origin_x) + (static_cast<float>(Map::tile_size) / 2.0F);
|
||
const float CENTER_TL_X = DOOR_CENTER_X - (static_cast<float>(Player::sprite_w) / 2.0F);
|
||
|
||
// Y: centre del corredor exterior superior (mateix valor que abans;
|
||
// el corredor és simètric respecte a `playfield_y`).
|
||
const float CORRIDOR_CENTER_Y = static_cast<float>(Map::playfield_y) + (static_cast<float>(Map::outer_corridor) / 2.0F);
|
||
const float SPAWN_Y = CORRIDOR_CENTER_Y - static_cast<float>(Player::collision_y_offset) - (static_cast<float>(Player::collision_size) / 2.0F);
|
||
|
||
// Mode BOTH: P1 a -tile_size, P2 a +tile_size respecte al centre.
|
||
// Els callsites individuals decideixen si usar aquests valors o si
|
||
// estem en mode single-player (un sol jugador → centrat exactament).
|
||
return {
|
||
.p1_x = CENTER_TL_X - static_cast<float>(Map::tile_size),
|
||
.p2_x = CENTER_TL_X + static_cast<float>(Map::tile_size),
|
||
.y = SPAWN_Y,
|
||
};
|
||
}
|
||
|
||
void GameScene::loadRoom(const std::string& yaml_path) {
|
||
// Recarregar les entitats des del YAML donat: la porta torna a
|
||
// CLOSED (amb l'open=false a Map al loadFrom), els sarcs tornen a
|
||
// CLOSED amb visited buit, momies i altres actors a la posició inicial.
|
||
config_.map_yaml = yaml_path;
|
||
Map::loadFrom(yaml_path);
|
||
entity_manager_.reload(yaml_path);
|
||
// Tornem a fixar el comptador de "esperats" amb només els slots
|
||
// actius — un slot DISABLED permanent (single-player) no compta.
|
||
const int ACTIVE_COUNT = std::ranges::count(players_starts_active_, true);
|
||
entity_manager_.setExpectedPlayerCount(ACTIVE_COUNT);
|
||
if (paint_layer_) { paint_layer_->clear(); }
|
||
|
||
// Tornem tots els slots a DISABLED. Els actius els reactivarà el
|
||
// trigger d'spawn aeri dins de `updateRoomTransition` quan torni a
|
||
// disparar-se el delay del nou cicle d'INTRO. Així cada cicle de
|
||
// sala repeteix la cinemàtica de caiguda de la mateixa manera que
|
||
// el primer.
|
||
for (auto& p : players_) { p->disable(); }
|
||
|
||
// Refresca el marcador (piràmide / sala) amb el LevelManager. El
|
||
// GameScene no manté els valors a part: la lectura sempre és a
|
||
// través del singleton, així no es desincronitzen mai entre
|
||
// hotkeys de salt, debug.yaml i creuament natural de porta.
|
||
if (hud_) {
|
||
const auto& mgr = Levels::LevelManager::get();
|
||
hud_->setPyramidRoom(mgr.currentPyramid(), mgr.currentRoom());
|
||
}
|
||
|
||
std::cout << "[ROOM] load " << yaml_path << ": entities reloaded, paint cleared, "
|
||
<< ACTIVE_COUNT << " active player(s) disabled (awaiting spawn cinematic)\n";
|
||
}
|
||
|
||
void GameScene::advanceToNextRoom() {
|
||
auto& mgr = Levels::LevelManager::get();
|
||
if (!mgr.advance()) {
|
||
// Política temporal mentre no hi haja pantalla de "Fi de joc":
|
||
// arribar al final del levels.yaml fa loop a la primera sala.
|
||
std::cout << "[ROOM] final del levels.yaml — bucle a la primera sala\n";
|
||
mgr.restart();
|
||
}
|
||
loadRoom(mgr.currentPath());
|
||
enterRoomIntro();
|
||
}
|
||
|
||
void GameScene::enterRoomIntro() {
|
||
// Anotem la transició al log si veníem d'un altre estat; si ja
|
||
// estàvem a INTRO (cas típic d'un salt debug) també queda ben clar.
|
||
if (state_ != RoomFsm::State::ROOM_INTRO) {
|
||
std::cout << "[ROOM] " << RoomFsm::stateName(state_) << " -> ROOM_INTRO\n";
|
||
} else {
|
||
std::cout << "[ROOM] ROOM_INTRO restart (cinemàtica reiniciada)\n";
|
||
}
|
||
state_ = RoomFsm::State::ROOM_INTRO;
|
||
transition_ = {};
|
||
room_intro_elapsed_ms_ = 0;
|
||
players_spawned_in_intro_.fill(false);
|
||
intro_text_sound_played_ = false;
|
||
// La cinemàtica d'entrada és silenciosa per disseny: és INTRO qui
|
||
// posseeix l'aturada. L'OUTRO no toca la música — així el cicle
|
||
// OUTRO → INTRO transmet la sensació de "tall" en entrar a la sala
|
||
// nova, no de "fade-out" anterior. Si ja no sona res, stopMusic és
|
||
// no-op.
|
||
if (Audio::get() != nullptr) { Audio::get()->stopMusic(); }
|
||
}
|
||
|
||
void GameScene::triggerPlayerSpawn(std::size_t slot_index) {
|
||
if (slot_index >= players_spawned_in_intro_.size()) { return; }
|
||
if (players_spawned_in_intro_[slot_index]) { return; }
|
||
players_spawned_in_intro_[slot_index] = true;
|
||
|
||
// Recalcula spawn per si door_col canvia entre sales (avui no, però
|
||
// la lògica ja queda preparada per a l'itinerari).
|
||
const auto SPAWN = computeSpawn();
|
||
const bool BOTH_ACTIVE = (players_starts_active_[0] && players_starts_active_[1]);
|
||
// Mode single-player: l'únic actiu queda centrat sota la porta. Els
|
||
// valors de SPAWN.p1_x/p2_x estan fets per al cas BOTH (offset ±tile);
|
||
// amb un sol actiu, el reposicionem al centre exacte.
|
||
const float SINGLE_X = SPAWN.p1_x + static_cast<float>(Map::tile_size);
|
||
|
||
float x = SINGLE_X;
|
||
if (BOTH_ACTIVE) { x = (slot_index == 0) ? SPAWN.p1_x : SPAWN.p2_x; }
|
||
players_[slot_index]->spawnFromAir(x, SPAWN.y);
|
||
// Arma el reverb del primer LAND com a part de la cinemàtica
|
||
// d'INTRO. Una sola vegada per spawn aeri: el respawn post-mort
|
||
// (que no passa per ací) cau al camí sec. Si el preset està buit
|
||
// al YAML, l'arma queda buida i el LAND és sec igualment.
|
||
players_[slot_index]->armNextLandReverb(transition_config_.intro_land_reverb_preset);
|
||
|
||
std::cout << "[ROOM] spawn cinematic P" << (slot_index + 1) << ": airborne entry\n";
|
||
}
|
||
|
||
void GameScene::setState(RoomFsm::State new_state) {
|
||
if (new_state == state_) { return; }
|
||
std::cout << "[ROOM] " << RoomFsm::stateName(state_) << " -> " << RoomFsm::stateName(new_state) << '\n';
|
||
state_ = new_state;
|
||
transition_ = {};
|
||
// En entrar a un nou cicle d'INTRO, resetegem el timer i el flag de
|
||
// l'spawn aeri perquè es torne a disparar al delay configurat.
|
||
if (new_state == RoomFsm::State::ROOM_INTRO) {
|
||
room_intro_elapsed_ms_ = 0;
|
||
players_spawned_in_intro_.fill(false);
|
||
intro_text_sound_played_ = false;
|
||
}
|
||
// En sortir d'INTRO cap a PLAYING, la música arrenca: és el moment
|
||
// en què la cinemàtica d'entrada acaba i el jugador agafa control.
|
||
// L'OUTRO no atura la música; ho fa el següent `enterRoomIntro`.
|
||
if (new_state == RoomFsm::State::PLAYING && Audio::get() != nullptr) {
|
||
Audio::get()->playMusic(config_.music_track, -1);
|
||
}
|
||
// L'avanç de sala (OUTRO → INTRO) NO viu ací: el dispara
|
||
// `updateRoomTransition` quan la FSM detecta el final de l'OUTRO,
|
||
// cridant directament `advanceToNextRoom`. Això manté `setState`
|
||
// com una transició pura, sense efectes col·laterals que
|
||
// recarreguen sales — necessari per als salts debug que també
|
||
// entren a INTRO sense passar per OUTRO.
|
||
}
|
||
|
||
void GameScene::updateRoomTransition(int delta_ms) {
|
||
// Disparador d'spawn aeri: timer propi (independent de les fases de
|
||
// la FSM) que arrenca a l'entrada de ROOM_INTRO. La separació deixa
|
||
// el delay desacoblat del fade-in i, demà, reutilitzable per al
|
||
// respawn post-mort sense que la FSM de sala hi haja d'intervenir.
|
||
if (state_ == RoomFsm::State::ROOM_INTRO) {
|
||
room_intro_elapsed_ms_ += delta_ms;
|
||
// Cada slot actiu pot caure en un moment distint quan els dos
|
||
// estan actius (mode BOTH): P1 amb player_spawn_delay_ms i P2
|
||
// amb player2_spawn_delay_ms. En single-player (P1 o P2 sols)
|
||
// l'slot actiu fa servir player_spawn_delay_ms — el segon valor
|
||
// queda inactiu, com ha demanat el disseny.
|
||
const bool BOTH_ACTIVE = players_starts_active_[0] && players_starts_active_[1];
|
||
for (std::size_t i = 0; i < players_starts_active_.size(); ++i) {
|
||
if (!players_starts_active_[i]) { continue; }
|
||
if (players_spawned_in_intro_[i]) { continue; }
|
||
const int DELAY = (i == 1 && BOTH_ACTIVE)
|
||
? transition_config_.player2_spawn_delay_ms
|
||
: transition_config_.player_spawn_delay_ms;
|
||
if (room_intro_elapsed_ms_ >= DELAY) {
|
||
triggerPlayerSpawn(i);
|
||
}
|
||
}
|
||
}
|
||
|
||
const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms);
|
||
|
||
// Disparador del so del rètol: la FSM acaba d'avançar; si estem a
|
||
// INTRO i ja hem deixat enrere phase 0 (fade_in), reproduïm el so
|
||
// una sola vegada. El so es dispara al final del fade-in, anticipant
|
||
// el text que apareix a phase 2 (text_enter): així sembla que el so
|
||
// "anuncia" l'arribada del rètol enlloc de coincidir amb la seva
|
||
// entrada visual.
|
||
if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase >= 1 && !intro_text_sound_played_ && !transition_config_.intro_text_sound.empty() && Audio::get() != nullptr) {
|
||
Audio::get()->playSound(transition_config_.intro_text_sound, Audio::Group::GAME);
|
||
intro_text_sound_played_ = true;
|
||
}
|
||
if (NEXT != state_) {
|
||
// El final de l'OUTRO no es resol amb un setState normal: cal
|
||
// demanar al LevelManager el següent nivell i carregar-lo abans
|
||
// d'entrar a INTRO. `advanceToNextRoom` fa tot el bloc i acaba
|
||
// amb la FSM a ROOM_INTRO (via `enterRoomIntro`), així que no
|
||
// fa falta el setState d'aquesta branca.
|
||
if (NEXT == RoomFsm::State::ROOM_INTRO && state_ == RoomFsm::State::ROOM_OUTRO) {
|
||
advanceToNextRoom();
|
||
} else {
|
||
setState(NEXT);
|
||
}
|
||
}
|
||
}
|
||
|
||
void GameScene::renderRoomFade() {
|
||
// Fade negre sobre el món, pintat un cop els actors ja estan al
|
||
// canvas i abans del HUD. Tres casos:
|
||
// ROOM_INTRO/fase 0: 255 → 0 (entrada de sala, món emergent)
|
||
// ROOM_OUTRO/fase 3: 0 → 255 (sortida de sala, món s'apaga)
|
||
// ROOM_OUTRO/fase 4: alpha=255 fix (pausa en negre opac abans del
|
||
// rellançament de la pròxima sala)
|
||
if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase == 4) {
|
||
Screen::get().blendRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, 0x00000000U, 255);
|
||
return;
|
||
}
|
||
|
||
int dur = 0;
|
||
float t = 0.0F;
|
||
bool fade_in = false;
|
||
if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase == 0) {
|
||
dur = transition_config_.intro_fade_in_ms;
|
||
fade_in = true;
|
||
} else if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase == 3) {
|
||
dur = transition_config_.outro_fade_out_ms;
|
||
fade_in = false;
|
||
} else {
|
||
return;
|
||
}
|
||
t = (dur > 0)
|
||
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur), 0.0F, 1.0F)
|
||
: 1.0F;
|
||
const float ALPHA_F = fade_in ? (1.0F - t) : t;
|
||
const auto ALPHA = static_cast<std::uint8_t>(255.0F * ALPHA_F);
|
||
if (ALPHA == 0) { return; }
|
||
Screen::get().blendRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, 0x00000000U, ALPHA);
|
||
}
|
||
|
||
void GameScene::renderRoomText() {
|
||
// Orquestrador del rètol de transició de sala. La feina pesada
|
||
// (animació de X, animació d'altura del panel i pintat) la fan
|
||
// quatre helpers privats; aquesta funció només decideix quin
|
||
// text dibuixar i amb quines fases segons l'estat.
|
||
//
|
||
// Mapatge per estat (només els estats amb text actiu hi entren):
|
||
// ROOM_INTRO: phases 2..4 → enter / hold / exit; dues línies
|
||
// (piràmide damunt, sala davall) que travessen la
|
||
// pantalla en direccions oposades.
|
||
// ROOM_OUTRO: phases 0..2 → enter / hold / exit; dues línies
|
||
// oposades ("fase" / "completada"). Phase 3 és el
|
||
// fade-out i 4 la pausa en negre — cap text en cap
|
||
// de les dues.
|
||
if (!room_text_font_) { return; }
|
||
|
||
constexpr int KERNING = 0;
|
||
// Centre vertical de la zona de joc (canvas sense la HUD). Reemplaça
|
||
// l'antic GameCanvas::CENTER_Y per descomptar la franja del marcador
|
||
// i tenir el rètol equilibrat sobre el playfield.
|
||
const int PLAY_CENTER_Y = (GameCanvas::HEIGHT + Map::hud_h) / 2;
|
||
// Colors del rètol via UiConfig (`data/ui.yaml > colors.room_text` i
|
||
// `room_text_outline`). Independents dels colors de notificació.
|
||
const Uint8 FILL = UiConfig::get().roomText();
|
||
const Uint8 OUTLINE = UiConfig::get().roomTextOutline();
|
||
const int LINE_HEIGHT = room_text_font_->getLineHeight();
|
||
|
||
if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase >= 2 && transition_.phase <= 4) {
|
||
const int DUR_ENTER = transition_config_.intro_text_enter_ms;
|
||
const int DUR_EXIT = transition_config_.intro_text_exit_ms;
|
||
// Panel primer (queda sota el text gràcies a l'ordre de blits).
|
||
drawRoomTextPanel(PLAY_CENTER_Y,
|
||
computeRoomTextPanelHeight(2, 3, 4, DUR_ENTER, DUR_EXIT));
|
||
|
||
const auto& mgr = Levels::LevelManager::get();
|
||
const std::string LINE_PYRAMID = RoomFsm::formatIntroPyramidLine(mgr.currentPyramid());
|
||
const std::string LINE_ROOM = RoomFsm::formatIntroRoomLine(mgr.currentRoom());
|
||
// Direccions oposades: piràmide entra per l'esquerra i surt per
|
||
// la dreta; sala fa el contrari.
|
||
const int X_PYRAMID = computeRoomTextX(room_text_font_->length(LINE_PYRAMID, KERNING),
|
||
2,
|
||
4,
|
||
DUR_ENTER,
|
||
DUR_EXIT,
|
||
/*from_right=*/false);
|
||
const int X_ROOM = computeRoomTextX(room_text_font_->length(LINE_ROOM, KERNING),
|
||
2,
|
||
4,
|
||
DUR_ENTER,
|
||
DUR_EXIT,
|
||
/*from_right=*/true);
|
||
// Bloc de dues línies centrat al voltant del PLAY_CENTER_Y. El
|
||
// gap (`intro_text_line_gap_px`) es reparteix simètricament:
|
||
// mig amunt a la piràmide, mig avall a la sala.
|
||
const int GAP = transition_config_.intro_text_line_gap_px;
|
||
drawRoomTextLine(LINE_PYRAMID, X_PYRAMID, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
|
||
drawRoomTextLine(LINE_ROOM, X_ROOM, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
|
||
} else if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase >= 0 && transition_.phase <= 2) {
|
||
const int DUR_ENTER = transition_config_.outro_text_enter_ms;
|
||
const int DUR_EXIT = transition_config_.outro_text_exit_ms;
|
||
drawRoomTextPanel(PLAY_CENTER_Y,
|
||
computeRoomTextPanelHeight(0, 1, 2, DUR_ENTER, DUR_EXIT));
|
||
|
||
const std::string LINE_1 = Locale::get().text("game.room_complete_line1");
|
||
const std::string LINE_2 = Locale::get().text("game.room_complete_line2");
|
||
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING),
|
||
0,
|
||
2,
|
||
DUR_ENTER,
|
||
DUR_EXIT,
|
||
/*from_right=*/false);
|
||
const int X_2 = computeRoomTextX(room_text_font_->length(LINE_2, KERNING),
|
||
0,
|
||
2,
|
||
DUR_ENTER,
|
||
DUR_EXIT,
|
||
/*from_right=*/true);
|
||
const int GAP = transition_config_.outro_text_line_gap_px;
|
||
drawRoomTextLine(LINE_1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
|
||
drawRoomTextLine(LINE_2, X_2, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
|
||
}
|
||
}
|
||
|
||
auto GameScene::computeRoomTextX(int width, int phase_enter, int phase_exit, int dur_enter, int dur_exit, bool from_right) const -> int {
|
||
// X animada d'una línia: entra des de fora de pantalla, s'atura al
|
||
// CENTER_X durant el hold (fora del rang d'aquesta funció — el caller
|
||
// garanteix que cau a la branca return final) i surt pel costat
|
||
// oposat. `from_right=false` és el patró clàssic (esquerra → dreta);
|
||
// `true` el flipa.
|
||
const int OFF_LEFT = -width;
|
||
const int OFF_RIGHT = GameCanvas::WIDTH;
|
||
const int OFF_ENTER = from_right ? OFF_RIGHT : OFF_LEFT;
|
||
const int OFF_EXIT = from_right ? OFF_LEFT : OFF_RIGHT;
|
||
if (transition_.phase == phase_enter) {
|
||
const float T = (dur_enter > 0)
|
||
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_enter), 0.0F, 1.0F)
|
||
: 1.0F;
|
||
const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear;
|
||
return Easing::lerpInt(OFF_ENTER, GameCanvas::CENTER_X, CURVE(T));
|
||
}
|
||
if (transition_.phase == phase_exit) {
|
||
const float T = (dur_exit > 0)
|
||
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_exit), 0.0F, 1.0F)
|
||
: 1.0F;
|
||
const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear;
|
||
return Easing::lerpInt(GameCanvas::CENTER_X, OFF_EXIT, CURVE(T));
|
||
}
|
||
return GameCanvas::CENTER_X;
|
||
}
|
||
|
||
auto GameScene::computeRoomTextPanelHeight(int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int {
|
||
// Altura animada del panel translúcid sota el rètol. Creix de 0 a
|
||
// `text_panel_height_px` amb `text_enter_curve` durant l'enter, es
|
||
// manté plena durant el hold i decreix amb `text_exit_curve`
|
||
// durant l'exit. Fora d'aquestes tres fases retorna 0.
|
||
const int MAX = transition_config_.text_panel_height_px;
|
||
if (transition_.phase == phase_enter) {
|
||
const float T = (dur_enter > 0)
|
||
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_enter), 0.0F, 1.0F)
|
||
: 1.0F;
|
||
const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear;
|
||
return Easing::lerpInt(0, MAX, CURVE(T));
|
||
}
|
||
if (transition_.phase == phase_hold) { return MAX; }
|
||
if (transition_.phase == phase_exit) {
|
||
const float T = (dur_exit > 0)
|
||
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_exit), 0.0F, 1.0F)
|
||
: 1.0F;
|
||
const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear;
|
||
return Easing::lerpInt(MAX, 0, CURVE(T));
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
void GameScene::drawRoomTextPanel(int center_y, int panel_height) const {
|
||
// No-op si l'altura és nul·la o l'alpha està a zero — així evitem
|
||
// un blendRect inútil i deixem el caller cridar incondicionalment.
|
||
if (panel_height <= 0 || transition_config_.text_panel_alpha == 0) { return; }
|
||
Screen::get().blendRect(0,
|
||
center_y - (panel_height / 2),
|
||
GameCanvas::WIDTH,
|
||
panel_height,
|
||
transition_config_.text_panel_color,
|
||
transition_config_.text_panel_alpha);
|
||
}
|
||
|
||
void GameScene::drawRoomTextLine(const std::string& text, int x, int y, std::uint8_t fill_color, std::uint8_t outline_color) const {
|
||
constexpr int KERNING = 0;
|
||
constexpr Uint8 STROKE_DIST = 1;
|
||
room_text_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG,
|
||
x,
|
||
y,
|
||
text,
|
||
KERNING,
|
||
fill_color,
|
||
STROKE_DIST,
|
||
outline_color);
|
||
}
|
||
|
||
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
|
||
// constant. La caixa de render es centra horitzontalment al peu i
|
||
// s'alinea al sòl amb un pixel de marge cap amunt perquè la sombra
|
||
// quede pintada per davant del polvet i el polvet, en l'y-sort,
|
||
// entre per davant del jugador (mateixa lògica que la sombra).
|
||
constexpr float DUST_W = 40.0F;
|
||
constexpr float DUST_H = 16.0F;
|
||
auto dust = std::make_unique<Dust>(EntityLoader::loadTemplate("dust"));
|
||
dust->setVariant(is_landing ? Dust::Variant::LAND : Dust::Variant::JUMP);
|
||
dust->setSurface(Resource::Cache::get().getSurface("dust.gif"));
|
||
// Recolzat al sòl, amb la base del rect 1 px per sobre del peu del
|
||
// jugador → en l'y-sort surt amb feet_y un pèl menor i, per
|
||
// estabilitat de stable_sort, queda darrere del jugador.
|
||
dust->setPixelRect({
|
||
.x = feet_x_center - (DUST_W * 0.5F),
|
||
.y = feet_y - DUST_H - 1.0F,
|
||
.w = DUST_W,
|
||
.h = DUST_H,
|
||
});
|
||
entity_manager_.add(std::move(dust));
|
||
}
|
||
|
||
void GameScene::spawnWhip(const WhipSpawnPayload& payload) {
|
||
// El YAML ja viu cacheat al `EntityLoader`; les 4 surfaces les hem
|
||
// precarregat al ctor. `configure()` selecciona la mida segons
|
||
// direcció i posa pixel_rect; aquí resolem la surface via
|
||
// `Resource::Cache` (mateix patró que `spawnDust`) per evitar que
|
||
// l'entitat depengui directament del cache.
|
||
auto whip_yaml = EntityLoader::loadTemplate("whip_attack");
|
||
auto whip = std::make_unique<WhipAttack>(whip_yaml);
|
||
whip->setOwner(payload.owner);
|
||
whip->configure(
|
||
static_cast<WhipAttack::Direction>(payload.direction),
|
||
payload.spawn_x,
|
||
payload.spawn_y,
|
||
payload.range_px);
|
||
whip->setSurface(Resource::Cache::get().getSurface(whip->getSurfaceKey()));
|
||
entity_manager_.add(std::move(whip));
|
||
}
|
||
|
||
void GameScene::handlePauseInput() {
|
||
#ifdef _DEBUG
|
||
// Mutex amb l'editor: si està actiu, ignorem la tecla de pausa.
|
||
if (editor_.active()) { return; }
|
||
#endif
|
||
const SDL_Scancode PAUSE_SC = KeyConfig::get().scancode("pause");
|
||
SDL_Gamepad* pad = GamepadManager::get().getPrimary();
|
||
for (const auto& event : EventBuffer::events) {
|
||
const bool KEY_HIT = (event.type == SDL_EVENT_KEY_DOWN && PAUSE_SC != SDL_SCANCODE_UNKNOWN && event.key.scancode == PAUSE_SC);
|
||
const bool PAD_HIT = (event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && event.gbutton.which == SDL_GetGamepadID(pad) && event.gbutton.button == SDL_GAMEPAD_BUTTON_START);
|
||
if (!KEY_HIT && !PAD_HIT) { continue; }
|
||
|
||
// Determinem cap a quin estat anem. Si estem quiets (IDLE o
|
||
// PAUSED) arranquem la transició contrària. Si estem en marxa
|
||
// (ENTERING o EXITING) invertim: la `scale` actual es manté com
|
||
// a `from_scale` nou i la durada es recalcula proporcional a la
|
||
// distància restant — així mai es produeix un salt de velocitat.
|
||
const bool GOING_TO_PAUSE = (pause_.state == PauseState::IDLE || pause_.state == PauseState::EXITING);
|
||
|
||
// Reprenem l'stream ABANS de començar a pujar el volum: quan
|
||
// venim de PAUSED l'àudio està aturat de debò (ho fem al final
|
||
// d'ENTERING, veure updatePauseTransition); reprenem just al
|
||
// moment de començar EXITING per no perdre el primer frame
|
||
// d'stream. A les inversions mid-flight (ENTERING → EXITING
|
||
// sense tocar PAUSED) l'stream no s'ha aturat mai, així que no
|
||
// cal reprendre.
|
||
if (!GOING_TO_PAUSE && pause_.state == PauseState::PAUSED) {
|
||
Audio::get()->resumeMusic();
|
||
}
|
||
|
||
pause_.from_scale = pause_.scale;
|
||
pause_.to_scale = GOING_TO_PAUSE ? 0.0F : 1.0F;
|
||
const int TARGET_MS = GOING_TO_PAUSE ? UiConfig::get().pauseEnterMs() : UiConfig::get().pauseExitMs();
|
||
const float DISTANCE = std::fabs(pause_.to_scale - pause_.from_scale);
|
||
pause_.duration_ms = std::max(1, static_cast<int>(static_cast<float>(TARGET_MS) * DISTANCE));
|
||
pause_.elapsed_ms = 0;
|
||
pause_.curve = GOING_TO_PAUSE ? UiConfig::get().pauseEnterCurve() : UiConfig::get().pauseExitCurve();
|
||
pause_.state = GOING_TO_PAUSE ? PauseState::ENTERING : PauseState::EXITING;
|
||
|
||
std::cout << "[PAUSE] " << (GOING_TO_PAUSE ? "entering" : "exiting") << " from_scale=" << pause_.from_scale << " duration_ms=" << pause_.duration_ms << '\n';
|
||
}
|
||
}
|
||
|
||
void GameScene::updatePauseTransition(int delta_ms) {
|
||
if (pause_.state != PauseState::ENTERING && pause_.state != PauseState::EXITING) { return; }
|
||
|
||
pause_.elapsed_ms += delta_ms;
|
||
bool just_reached_pause = false;
|
||
if (pause_.elapsed_ms >= pause_.duration_ms) {
|
||
// Transició completa: enganxem al valor objectiu exacte i
|
||
// promocionem l'estat al terminal corresponent.
|
||
pause_.scale = pause_.to_scale;
|
||
const PauseState NEXT = (pause_.to_scale == 0.0F) ? PauseState::PAUSED : PauseState::IDLE;
|
||
just_reached_pause = (NEXT == PauseState::PAUSED);
|
||
pause_.state = NEXT;
|
||
} else {
|
||
const float T = static_cast<float>(pause_.elapsed_ms) / static_cast<float>(pause_.duration_ms);
|
||
const auto CURVE = (pause_.curve != nullptr) ? pause_.curve : &Easing::linear;
|
||
pause_.scale = std::lerp(pause_.from_scale, pause_.to_scale, CURVE(T));
|
||
}
|
||
|
||
// Fade del volum de música durant la transició. La base és el que
|
||
// l'usuari ha configurat a Options; multipliquem per la `scale` per
|
||
// obtenir el volum efectiu aquest frame.
|
||
const float BASE_VOLUME = Options::audio().music.volume;
|
||
Audio::get()->setMusicVolume(BASE_VOLUME * pause_.scale);
|
||
|
||
// En arribar a PAUSED aturem l'stream: així quan despausem la música
|
||
// tornarà des d'on s'ha quedat, no des d'on hauria anat a parar
|
||
// durant el temps que el joc ha estat en pausa. El `resumeMusic` es
|
||
// fa a `handlePauseInput` al començar l'EXITING, perquè la música
|
||
// sone ja mentre el volum puja des de 0.
|
||
if (just_reached_pause) {
|
||
Audio::get()->pauseMusic();
|
||
}
|
||
}
|
||
|
||
#ifdef _DEBUG
|
||
namespace {
|
||
auto detectEditorToggle() -> bool {
|
||
const SDL_Scancode SC = KeyConfig::get().scancode("editor_toggle");
|
||
if (SC == SDL_SCANCODE_UNKNOWN) { return false; }
|
||
return std::ranges::any_of(EventBuffer::events, [SC](const SDL_Event& ev) {
|
||
return ev.type == SDL_EVENT_KEY_DOWN && ev.key.scancode == SC;
|
||
});
|
||
}
|
||
|
||
// Retorna -1 si s'ha premut prev_room, +1 si next_room, 0 si cap.
|
||
// Si tots dos arriben en el mateix frame (poc probable amb teclat),
|
||
// prev té prioritat — l'usuari sempre pot tornar a prémer next.
|
||
auto detectRoomJump() -> int {
|
||
const SDL_Scancode PREV_SC = KeyConfig::get().scancode("prev_room");
|
||
const SDL_Scancode NEXT_SC = KeyConfig::get().scancode("next_room");
|
||
for (const auto& ev : EventBuffer::events) {
|
||
if (ev.type != SDL_EVENT_KEY_DOWN) { continue; }
|
||
if (PREV_SC != SDL_SCANCODE_UNKNOWN && ev.key.scancode == PREV_SC) { return -1; }
|
||
if (NEXT_SC != SDL_SCANCODE_UNKNOWN && ev.key.scancode == NEXT_SC) { return +1; }
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
auto detectOpenDoor() -> bool {
|
||
const SDL_Scancode SC = KeyConfig::get().scancode("open_door");
|
||
if (SC == SDL_SCANCODE_UNKNOWN) { return false; }
|
||
return std::ranges::any_of(EventBuffer::events, [SC](const SDL_Event& ev) {
|
||
return ev.type == SDL_EVENT_KEY_DOWN && ev.key.scancode == SC;
|
||
});
|
||
}
|
||
} // namespace
|
||
#endif
|
||
|
||
void GameScene::update(int delta_ms) {
|
||
handlePauseInput();
|
||
updatePauseTransition(delta_ms);
|
||
|
||
// Transició de sala: viu en temps real (no escalada per la pausa)
|
||
// perquè és UI. Avança avans del bloc de món perquè quan PLAYING
|
||
// arrenque ja sigui efectiu en el mateix frame.
|
||
updateRoomTransition(delta_ms);
|
||
|
||
#ifdef _DEBUG
|
||
// Toggle de l'editor: només si no hi ha cap transició de pausa ni
|
||
// pausa consolidada. L'editor mateix no consumeix la tecla — la
|
||
// llegim ací per decidir la coexistència.
|
||
if (pause_.state == PauseState::IDLE && detectEditorToggle()) {
|
||
editor_.toggle(tilemap_);
|
||
}
|
||
|
||
// Salts debug entre sales (`[` / `]`). Mútuament excloents amb la
|
||
// pausa i amb l'editor: si qualsevol està actiu, ignorem la tecla
|
||
// perquè el salt recarrega entitats i invalidaria l'estat editat.
|
||
if (pause_.state == PauseState::IDLE && !editor_.active()) {
|
||
const int DELTA = detectRoomJump();
|
||
if (DELTA != 0) {
|
||
auto& mgr = Levels::LevelManager::get();
|
||
mgr.setIndex(mgr.index() + DELTA);
|
||
loadRoom(mgr.currentPath());
|
||
enterRoomIntro();
|
||
}
|
||
}
|
||
|
||
// Força l'obertura de la porta (Keypad 1). Útil per saltar el
|
||
// pintat/sarcs durant proves de progressió. La resta del flux
|
||
// (creuar → player_exited → level_complete → OUTRO) es manté.
|
||
// Mateixos gates que el salt entre sales.
|
||
if (pause_.state == PauseState::IDLE && !editor_.active() && detectOpenDoor()) {
|
||
std::cout << "[DEBUG] força obertura de la porta\n";
|
||
entity_manager_.forceOpenDoor();
|
||
}
|
||
#endif
|
||
|
||
GlobalInputs::update(delta_ms);
|
||
if (GlobalInputs::wantsQuit()) { wants_quit_ = true; }
|
||
|
||
// El món avança amb `delta_ms * pause_.scale`: scale=1 corre a velocitat
|
||
// real, scale=0 el congela. Quan l'editor de debug està actiu també
|
||
// es salta completament (no l'escalem, el saltem). Notifier,
|
||
// RenderInfo i HUD reben el delta real, per això viuen fora d'aquest
|
||
// bloc i no es ralenteixen amb el dial de pausa.
|
||
#ifdef _DEBUG
|
||
const bool EDITOR_FROZEN = editor_.active();
|
||
#else
|
||
constexpr bool EDITOR_FROZEN = false;
|
||
#endif
|
||
const int SCALED_DELTA_MS = static_cast<int>(static_cast<float>(delta_ms) * pause_.scale);
|
||
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.
|
||
// Cap gate per estat de sala: els jugadors fora del joc (DISABLED
|
||
// durant l'INTRO abans del trigger d'spawn, o EXITED post-porta)
|
||
// s'auto-salten a `tick`/`applyMovement`/`render`. Així la
|
||
// cinemàtica de spawn aeri pot transcorrer mentre la fase d'INTRO
|
||
// encara es mou.
|
||
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
|
||
// als sarcòfags via la subscripció del bus, i aquest `update` pot
|
||
// aplicar la transició OPENING → OPEN en el mateix frame.
|
||
entity_manager_.update(DT);
|
||
|
||
// Detecció de contacte player↔entitat. `checkPlayerCollisions` ja
|
||
// filtra jugadors fora del joc (`isOutOfPlay`), així que durant
|
||
// l'INTRO previ a l'spawn no es disparen contactes amb les
|
||
// entitats que ja s'animen al fons.
|
||
entity_manager_.checkPlayerCollisions(players_view_);
|
||
}
|
||
|
||
#ifdef _DEBUG
|
||
editor_.update(delta_ms, tilemap_);
|
||
#endif
|
||
|
||
// El HUD continua avançant les seues animacions encara que el món
|
||
// estigui congelat (pausa o editor): així l'animació d'entrada acaba
|
||
// sola i quan es despausa ja està al seu lloc, igual que el Notifier.
|
||
if (hud_) { hud_->update(delta_ms); }
|
||
|
||
RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiConfig::get().renderInfoText(), UiConfig::get().renderInfoShadow()));
|
||
Notifier::get().update(delta_ms);
|
||
}
|
||
|
||
void GameScene::render() {
|
||
const auto BG_ARGB = Resource::Cache::get().getPalette("ui")[UiConfig::get().canvasBg()];
|
||
Screen::get().fillRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, BG_ARGB);
|
||
|
||
// Mapa visual: textura prerenderitzada del tilemap. Els sarcòfags
|
||
// queden "foradats" (cel·les transparents) perquè passaran a ser
|
||
// actors independents; Map::collides encara els bloqueja per a les
|
||
// col·lisions del jugador.
|
||
tilemap_.render();
|
||
|
||
#ifdef _DEBUG
|
||
// En mode editor amaguem entitats i jugador: el tilemap queda net
|
||
// per a poder clicar cel·la per cel·la sense interferència visual.
|
||
// El HUD futur (quan s'afegeixi) també farà cas d'aquest flag.
|
||
const bool SKIP_ACTORS = editor_.active();
|
||
#else
|
||
constexpr bool SKIP_ACTORS = false;
|
||
#endif
|
||
// Capa de pintat: va entre el tilemap base i els actors, perquè els
|
||
// sarcòfags i el jugador la tapen. S'amaga al mode editor (decisió
|
||
// 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 no està a l'aire (i, com `render()`, també no-op si
|
||
// `isOutOfPlay`). Cap gate per estat de sala: durant la
|
||
// cinemàtica d'spawn aeri ja volem que la sombra es vegi.
|
||
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
|
||
// queda tapat per ell, i un sarc a davant el tapa a ell.
|
||
struct Renderable {
|
||
float feet_y;
|
||
Entity* entity; // nullable; si és nullptr, s'usa `player`.
|
||
Player* player; // nullable; si és nullptr, s'usa `entity`.
|
||
};
|
||
std::vector<Renderable> order;
|
||
order.reserve(entity_manager_.entityCount() + players_.size());
|
||
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};
|
||
});
|
||
// Tots els jugadors entren al z-sort. Els fora de joc fan no-op a
|
||
// `render()`; els actius (inclosos AIRBORNE durant l'spawn aeri)
|
||
// es pinten amb el seu feet_y normal. La sombra que afecta la
|
||
// sombra del salt s'ha pintat ja a la passada anterior.
|
||
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;
|
||
});
|
||
for (const auto& r : order) {
|
||
if (r.entity != nullptr) {
|
||
EntityManager::renderEntity(*r.entity);
|
||
} else if (r.player != nullptr) {
|
||
r.player->render();
|
||
}
|
||
}
|
||
|
||
// 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
|
||
editor_.render(tilemap_);
|
||
#endif
|
||
|
||
// Overlay de pausa: text amb stroke blanc (índex 255) centrat amb X
|
||
// depenent de l'estat del dial. Entra lliscant des de l'esquerra
|
||
// (ENTERING), es queda al centre (PAUSED) i surt cap a la dreta
|
||
// (EXITING). La X es deriva directament de `pause_.scale`, que ja
|
||
// porta la corba aplicada — així text i món frenen alhora.
|
||
if (pause_.state != PauseState::IDLE && pause_font_) {
|
||
constexpr Uint8 OUTLINE_WHITE = 255;
|
||
constexpr Uint8 STROKE_DIST = 1;
|
||
constexpr int PAUSE_KERNING = 0;
|
||
const std::string TEXT = Locale::get().text("input.paused");
|
||
const int TEXT_WIDTH = pause_font_->length(TEXT, PAUSE_KERNING);
|
||
const int OFF_LEFT = -TEXT_WIDTH;
|
||
const int OFF_RIGHT = GameCanvas::WIDTH + TEXT_WIDTH;
|
||
int x = GameCanvas::CENTER_X;
|
||
if (pause_.state == PauseState::ENTERING) {
|
||
x = Easing::lerpInt(OFF_LEFT, GameCanvas::CENTER_X, 1.0F - pause_.scale);
|
||
} else if (pause_.state == PauseState::EXITING) {
|
||
x = Easing::lerpInt(GameCanvas::CENTER_X, OFF_RIGHT, pause_.scale);
|
||
}
|
||
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,
|
||
PAUSE_KERNING,
|
||
UiConfig::get().notificationText(),
|
||
STROKE_DIST,
|
||
OUTLINE_WHITE);
|
||
}
|
||
|
||
Notifier::get().render();
|
||
RenderInfo::get().render();
|
||
Screen::get().present();
|
||
}
|