#include "game/scenes/game_scene.hpp" #include #include #include #include #include #include #include #include #include #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/entity.hpp" #include "game/entities/event_bus.hpp" #include "game/entities/player.hpp" #include "game/map/map.hpp" #include "game/options.hpp" #include "game/ui/hud.hpp" #include "utils/defaults.hpp" #include "utils/defines.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(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(); } } // namespace auto GameScene::defaultConfig() -> Config { return { .map_yaml = Defaults::Pyramid::MAP_YAML, .player_sprite = Defaults::Pyramid::PLAYER_SPRITE, .player_yaml = Defaults::Pyramid::PLAYER_YAML, .music_track = Defaults::Pyramid::MUSIC_TRACK, }; } GameScene::GameScene(Config config) : config_(std::move(config)), tilemap_(makeTilemap(config_.map_yaml)) { // Convenció: el Resource::Cache indexa per basename del fitxer, perquè // la mateixa clau l'usa el parseador d'animació (YAML > tileSetFile). const auto SPRITE_KEY = std::filesystem::path(config_.player_sprite).filename().string(); const auto ANIM_KEY = std::filesystem::path(config_.player_yaml).filename().string(); Resource::Cache::get().loadSurface(SPRITE_KEY, config_.player_sprite); Resource::Cache::get().loadAnimation(ANIM_KEY, config_.player_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_*`. 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: de moment només logs, sense // tocar ni la vida del jugador ni l'estat de la escena. Serà la base // sobre la qual cablejar HUD i fi de nivell més endavant. entity_manager_.getBus().subscribe("level_complete", [](const Event& /*ev*/) { std::cout << "[LEVEL COMPLETE]" << '\n'; }); entity_manager_.getBus().subscribe("player_hit", [](const Event& ev) { const auto* dmg = std::get_if(&ev.payload); std::cout << "[PLAYER HIT] dmg=" << (dmg != nullptr ? *dmg : 0) << '\n'; }); // Jugador 0: fletxes del teclat (Defaults::KeysGame). Instanciar-ne un // segon només requereix afegir una altra entrada amb uns Controls // distints (p.ex. WASD). const Player::Controls CONTROLS_P1{ .up = Defaults::KeysGame::UP, .down = Defaults::KeysGame::DOWN, .left = Defaults::KeysGame::LEFT, .right = Defaults::KeysGame::RIGHT, }; // Spawn centrat al passadís exterior superior: la caixa de col·lisió // queda dins del passadís vertical del mig (entre columnes 1 i 2) i // centrada en Y al passadís exterior superior. Així no naix dins de // cap sarcòfag. constexpr auto CORRIDOR_CENTER_X = static_cast(GameCanvas::CENTER_X); const auto CORRIDOR_CENTER_Y = static_cast(Map::playfield_y) + (static_cast(Map::outer_corridor) / 2.0F); const auto SPAWN_X = CORRIDOR_CENTER_X - (static_cast(Player::sprite_w) / 2.0F); const auto SPAWN_Y = CORRIDOR_CENTER_Y - static_cast(Player::collision_y_offset) - (static_cast(Player::collision_size) / 2.0F); // Í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. const int P1_PAINT = readOptionalInt(config_.map_yaml, "player1_paint_tile", -1); players_.emplace_back(std::make_unique(ANIM_KEY, SPAWN_X, SPAWN_Y, CONTROLS_P1, Map::makeInfo(), P1_PAINT)); // 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(tilemap_, &entity_manager_.getBus()); // Connectem el Player al bus perquè emeti `on_tile_entered` i el // PaintLayer (subscrit ja) processe el pintat; el fan-out cap als // sarcòfags passa per `tile_painted`. players_.back()->setEventBus(&entity_manager_.getBus()); // 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(hud_font_surface, UiConfig::get().hudFont().fnt); hud_font->setDefaultKerning(UiConfig::get().hudFont().kerning); hud_ = std::make_unique(entity_manager_.getBus(), hud_font); // Música de fons en bucle infinit. El track ja està al Resource::Cache // (preload al Director), així que playMusic només mou el punter cap a // jail_audio i obri l'stream. Audio::get() està garantit no-null: // Director::init crida Audio::init abans d'instanciar GameScene. Audio::get()->playMusic(config_.music_track, -1); // 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(pause_surface, UiConfig::get().placeholderFont().fnt); pause_font_->setDefaultKerning(UiConfig::get().placeholderFont().kerning); } GameScene::~GameScene() = default; 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; } paused_ = !paused_; if (paused_) { Audio::get()->pauseMusic(); } else { Audio::get()->resumeMusic(); } } } #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; }); } } // namespace #endif void GameScene::update(int delta_ms) { handlePauseInput(); #ifdef _DEBUG // Toggle de l'editor: només si la pausa no està activa (mútuament // excloents). L'editor mateix no consumeix la tecla — la llegim ací // per decidir la coexistència. if (!paused_ && detectEditorToggle()) { editor_.toggle(tilemap_); } #endif GlobalInputs::update(delta_ms); if (GlobalInputs::wantsQuit()) { wants_quit_ = true; } // El món es congela durant la pausa i durant el mode editor: ni // jugadors ni entitats avancen, ni es resolen col·lisions. Notifier i // RenderInfo sí continuen perquè els usa la UI de sistema. #ifdef _DEBUG const bool WORLD_FROZEN = paused_ || editor_.active(); #else const bool WORLD_FROZEN = paused_; #endif if (!WORLD_FROZEN) { const float DT = static_cast(delta_ms) / 1000.0F; for (auto& player : players_) { player->update(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 després que totes les entitats // hagin mogut aquest frame. La porta (si està oberta) emet // `level_complete`; la momia/trampa emet `player_hit`. Els sarcs, encara // que tinguen collides=true, tenen la caixa lògica fora del camí del // jugador (gràcies a Map::collides al Player::update), així que aquí // no generen falsos contactes. std::vector player_ptrs; player_ptrs.reserve(players_.size()); std::ranges::transform(players_, std::back_inserter(player_ptrs), [](const auto& p) { return p.get(); }); entity_manager_.checkPlayerCollisions(player_ptrs); } #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) { // 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 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}; }); 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(); } } // 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. if (hud_) { hud_->render(); } } #ifdef _DEBUG editor_.render(tilemap_); #endif // Overlay de pausa: text centrat amb stroke de 8 veïns (STROKE_FLAG) // que fa de contorn. Fem el contorn blanc (índex 255 de sega-cube.pal) // i el cos en color de notificació, prou contrastat amb el fons del // nivell (parets fosques). Text::writeDX aplica el stroke abans que // el fill, així que l'ordre visual queda correcte sense passos extra. if (paused_ && 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 Y = GameCanvas::CENTER_Y - (pause_font_->getLineHeight() / 2); pause_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG, GameCanvas::CENTER_X, Y, TEXT, PAUSE_KERNING, UiConfig::get().notificationText(), STROKE_DIST, OUTLINE_WHITE); } Notifier::get().render(); RenderInfo::get().render(); Screen::get().present(); }