feat: estat DISABLED i spawn aeri al començar la sala — v0.25.0

This commit is contained in:
JailDesigner committed 2026-04-26 14:17:17 +02:00
1 parent 645db217fa
commit 580c13e854
8 files changed
+280 -104

No files matched your search

+134 -87
View File
@@ -113,12 +113,12 @@ namespace {
// surt projectat sense control i els dos es travessen. Coincideix
// amb la regla d'`Player::collidesWithWorld`.
if (a->isHurt() || b->isHurt()) { return; }
// EXITED: el jugador despawnat no és tangible. Si entrés al
// resolver, la seua presència física frenaria l'altre (per
// 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 segon jugador pot creuar la porta
// sense interferència del primer ja eixit.
if (a->isExited() || b->isExited()) { return; }
// 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
@@ -250,19 +250,22 @@ GameScene::GameScene(Config config)
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};
std::string anim_p1;
std::string anim_p2;
if (USE_P1) { anim_p1 = load_player_assets(config_.player_sprite, config_.player_yaml); }
if (USE_P2) { anim_p2 = load_player_assets(config_.player2_sprite, config_.player2_yaml); }
// 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: cridem
// `loadFrom` amb el YAML del primer jugador actiu (P1 o P2). Els dos
// personatges usen la mateixa geometria (morcus és un repaletat de sam).
Player::loadFrom(USE_P1 ? config_.player_yaml : config_.player2_yaml);
// 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
@@ -321,7 +324,7 @@ GameScene::GameScene(Config config)
// `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 = (USE_P1 && USE_P2);
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
@@ -331,16 +334,29 @@ GameScene::GameScene(Config config)
// puntuació (veure `Event::player_id`).
constexpr int ID_P1 = 0;
constexpr int ID_P2 = 1;
if (USE_P1) {
const int P1_PAINT = readOptionalInt(config_.map_yaml, "player1_paint_tile", -1);
const float X = BOTH ? SPAWN.p1_x : SPAWN.p1_x + static_cast<float>(Map::tile_size);
players_.emplace_back(std::make_unique<Player>(anim_p1, ID_P1, X, SPAWN.y, CONTROLS_P1, Map::makeInfo(), P1_PAINT));
}
if (USE_P2) {
const int P2_PAINT = readOptionalInt(config_.map_yaml, "player2_paint_tile", -1);
const float X = BOTH ? SPAWN.p2_x : SPAWN.p2_x - static_cast<float>(Map::tile_size);
players_.emplace_back(std::make_unique<Player>(anim_p2, ID_P2, X, SPAWN.y, CONTROLS_P2, Map::makeInfo(), P2_PAINT));
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 `player_spawn_delay_ms_` dins
// de ROOM_INTRO (cinemàtica de caiguda); 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
@@ -366,10 +382,11 @@ GameScene::GameScene(Config config)
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. Cap setExpectedPlayerCount
// ha d'inicialitzar-se abans del primer `player_exited`; ho fem aquí
// perquè ja sabem el nombre real d'instàncies.
entity_manager_.setExpectedPlayerCount(static_cast<int>(players_.size()));
// 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_); }
@@ -444,25 +461,44 @@ void GameScene::resetRoom() {
// CLOSED amb visited buit, momies i altres actors a la posició inicial.
Map::loadFrom(config_.map_yaml);
entity_manager_.reload(config_.map_yaml);
entity_manager_.setExpectedPlayerCount(static_cast<int>(players_.size()));
// 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(); }
// Respawn dels jugadors. Recalcular spawn per si door_col canvia
// (avui el mapa no canvia entre cicles, però la lògica ja queda
// preparada per a l'itinerari futur).
const auto SPAWN = computeSpawn();
const bool BOTH = (players_.size() >= 2);
// Mode single-player: l'únic jugador queda centrat (= SPAWN.p1_x +
// tile_size, perquè SPAWN.p1_x és el centre menys un tile).
const float SINGLE_X = SPAWN.p1_x + static_cast<float>(Map::tile_size);
for (std::size_t i = 0; i < players_.size(); ++i) {
float x = SINGLE_X;
if (BOTH) { x = (i == 0) ? SPAWN.p1_x : SPAWN.p2_x; }
players_[i]->respawn(x, SPAWN.y);
}
// 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(); }
std::cout << "[ROOM] reset: entities reloaded, paint cleared, "
<< players_.size() << " player(s) respawned\n";
<< ACTIVE_COUNT << " active player(s) disabled (awaiting spawn cinematic)\n";
}
void GameScene::triggerActivePlayerSpawn() {
if (players_spawned_in_intro_) { return; }
players_spawned_in_intro_ = 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);
for (std::size_t i = 0; i < players_.size(); ++i) {
if (!players_starts_active_[i]) { continue; }
float x = SINGLE_X;
if (BOTH_ACTIVE) { x = (i == 0) ? SPAWN.p1_x : SPAWN.p2_x; }
players_[i]->spawnFromAir(x, SPAWN.y);
}
std::cout << "[ROOM] spawn cinematic: airborne entry for active players\n";
}
void GameScene::setState(RoomFsm::State new_state) {
@@ -471,6 +507,12 @@ void GameScene::setState(RoomFsm::State new_state) {
const auto PREV = state_;
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_ = false;
}
// En tornar a ROOM_INTRO des de ROOM_OUTRO (placeholder de
// "torna a la mateixa sala"), reseteja completament l'estat del
// nivell. Si la transició és la inicial (PREV també és ROOM_INTRO,
@@ -481,6 +523,17 @@ void GameScene::setState(RoomFsm::State new_state) {
}
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;
if (!players_spawned_in_intro_ && room_intro_elapsed_ms_ >= transition_config_.player_spawn_delay_ms) {
triggerActivePlayerSpawn();
}
}
const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms);
if (NEXT != state_) { setState(NEXT); }
}
@@ -725,29 +778,27 @@ 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_ == RoomFsm::State::PLAYING);
if (!EDITOR_FROZEN && SCALED_DELTA_MS > 0) {
const float DT = static_cast<float>(SCALED_DELTA_MS) / 1000.0F;
// Durant ROOM_INTRO/ROOM_OUTRO els jugadors no s'actualitzen ni
// poden col·lisionar amb entitats. Les entitats sí, perquè el
// disseny demana que el món continui animant-se durant les
// transicions de sala.
if (GAMEPLAY_ACTIVE) {
// Pipeline de moviment en tres passes per poder intercalar la
// resolució de contactes jugador↔jugador entre input i posició:
// 1) tick: cada jugador calcula la velocitat intencionada
// (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); }
}
// 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
@@ -755,12 +806,11 @@ void GameScene::update(int delta_ms) {
// 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. Reutilitzem la vista plana que ja
// manté el GameScene per al resolver player↔player.
if (GAMEPLAY_ACTIVE) {
entity_manager_.checkPlayerCollisions(players_view_);
}
// 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
@@ -799,19 +849,14 @@ void GameScene::render() {
// explícita: l'editor edita el tilemap, no la capa de pintat).
if (!SKIP_ACTORS && paint_layer_) { paint_layer_->render(); }
if (!SKIP_ACTORS) {
// Durant ROOM_INTRO/ROOM_OUTRO els jugadors no es veuen ni es mouen.
// Les entitats sí que es renderitzen perquè el disseny demana que
// el món continui animant-se durant el fade i el text.
const bool RENDER_PLAYERS = (state_ == RoomFsm::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(); }
}
// 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
@@ -822,16 +867,18 @@ void GameScene::render() {
Player* player; // nullable; si és nullptr, s'usa `entity`.
};
std::vector<Renderable> order;
order.reserve(entity_manager_.entityCount() + (RENDER_PLAYERS ? players_.size() : 0));
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};
});
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()};
});
}
// 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;
});
+27
View File
@@ -1,5 +1,6 @@
#pragma once
#include <array>
#include <cstdint>
#include <memory>
#include <string>
@@ -84,8 +85,34 @@ class GameScene : public Scene {
// Vector des del principi: AEE suportarà multijugador local; cada Player
// té els seus propis Controls i entra al vector amb una crida extra.
// Sempre conté **dos** entrades (P1 a [0], P2 a [1]) tant en mode BOTH
// com en single-player: l'slot inactiu es queda en estat DISABLED de
// manera permanent, deixant el camí preparat per al "join in progress"
// futur (el segon jugador podrà entrar a meitat de partida sense
// recrear estructures). Veure `players_starts_active_`.
std::vector<std::unique_ptr<Player>> players_;
// Quins slots juguen aquesta partida segons `config_.starting_players`.
// Index = id del jugador (0 = P1, 1 = P2). Determina a qui aplica el
// spawn aeri al final de la fase 0 d'INTRO; un slot amb `false` es
// queda DISABLED tot el cicle de sala.
std::array<bool, 2> players_starts_active_{false, false};
// Mil·lisegons des de l'entrada en ROOM_INTRO, per disparar la
// cinemàtica de spawn aeri quan s'arriba a
// `transition_config_.player_spawn_delay_ms`. Es reseteja a
// `setState(ROOM_INTRO)`. `players_spawned_in_intro_` garanteix
// que el trigger només es dispara una vegada per cicle.
int room_intro_elapsed_ms_{0};
bool players_spawned_in_intro_{false};
// Cinemàtica de spawn: posa els slots actius en AIRBORNE des d'una
// altura fora de pantalla. Cridat per `updateRoomTransition` quan
// `room_intro_elapsed_ms_` supera el delay configurat. Idempotent
// (consulta `players_spawned_in_intro_`). També és el punt al qual
// es connectarà el respawn post-mort en el futur.
void triggerActivePlayerSpawn();
// Vista plana de punters crus — la fa servir Player com a llista
// d'obstacles mòbils a la callback de col·lisió. Es construeix un sol
// cop al ctor (els punters són estables perquè `players_` no es
+1
View File
@@ -42,6 +42,7 @@ namespace RoomFsm {
cfg.intro_text_enter_ms = readChildInt(n, "text_enter_ms", cfg.intro_text_enter_ms);
cfg.intro_text_hold_ms = readChildInt(n, "text_hold_ms", cfg.intro_text_hold_ms);
cfg.intro_text_exit_ms = readChildInt(n, "text_exit_ms", cfg.intro_text_exit_ms);
cfg.player_spawn_delay_ms = readChildInt(n, "player_spawn_delay_ms", cfg.player_spawn_delay_ms);
}
if (yaml.contains("room_outro")) {
const auto& n = yaml["room_outro"];
+7
View File
@@ -42,6 +42,13 @@ namespace RoomFsm {
int outro_text_exit_ms{500};
int outro_fade_out_ms{500};
// Mil·lisegons des de l'inici de ROOM_INTRO en què cau la
// cinemàtica d'spawn aeri dels jugadors actius. Independent del
// fade-in perquè el mateix valor servirà per al respawn post-mort
// (que no té fade que el sincronitze). 500 ms = típicament el
// moment en què el fade-in acaba i el text encara no ha entrat.
int player_spawn_delay_ms{500};
UiConfig::CurveFn text_enter_curve{nullptr};
UiConfig::CurveFn text_exit_curve{nullptr};
};