Merge branch 'feat/game-states' — FSM de sala, EXITED, bucle complet

This commit is contained in:
JailDesigner committed 2026-04-26 10:51:45 +02:00
commit 645db217fa
25 files changed
+1209 -87

No files matched your search

+25
View File
@@ -95,6 +95,7 @@ set(APP_SOURCES
source/game/scenes/title_scene.cpp
source/game/scenes/intro_scene.cpp
source/game/scenes/game_scene.cpp
source/game/scenes/room_fsm.cpp
source/game/entities/player.cpp
source/game/map/map.cpp
source/game/map/tilemap.cpp
@@ -374,4 +375,28 @@ if(BUILD_TESTS)
add_test(NAME tilemap_writer
COMMAND test_tilemap_writer
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
# Smoke de la FSM de sala (RoomFsm). Cobreix loadConfig (timings i
# corbes des de YAML), advance (transicions entre estats), i
# formatRoomTitle (locale + substitució `{n}`). Stand-alone: no
# arrossega Audio/Screen/Player/EntityManager — només els helpers
# purs de la FSM, Locale i UiConfig.
add_executable(test_game_fsm
tests/test_game_fsm.cpp
source/game/scenes/room_fsm.cpp
source/core/locale/locale.cpp
source/core/ui/ui_config.cpp
source/core/resources/resource_helper.cpp
source/core/resources/resource_pack.cpp
source/utils/easing.cpp
)
target_include_directories(test_game_fsm PRIVATE
"${CMAKE_SOURCE_DIR}/source"
"${CMAKE_BINARY_DIR}"
)
target_link_libraries(test_game_fsm PRIVATE SDL3::SDL3)
target_compile_options(test_game_fsm PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME game_fsm
COMMAND test_game_fsm
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
endif()
+8
View File
@@ -5,6 +5,14 @@ sprite:
size: [48, 32]
tile_size: [3, 2]
collision:
# Caixa de col·lisió: 16×24 centrada al sprite 48×32. Coincideix amb el
# tile central transitable (els 16 px del passadís de sortida) i es
# restringeix a la franja del mur trasser (24 px d'alçada). Així
# `checkPlayerCollisions` només dispara `onPlayerContact` quan el
# jugador entra al forat real, no als marcs flanquejants ni als 8 px
# superiors que queden tapats pel HUD.
size: [16, 24]
offset: [16, 8]
# Porta = mur infranquejable. Igual que el sarc, alçada efectiva
# infinita per impedir que un jugador la salte per damunt.
height: 999
+7
View File
@@ -36,6 +36,13 @@ hud:
pyramid: "Piramide"
room: "Sala"
# Textos de la transició d'entrada/sortida de sala (FSM de GameScene).
# `room_intro` accepta el placeholder `{n}` que es substitueix amb
# l'índex de sala actual; veure `GameScene::formatRoomTitle`.
game:
room_intro: "Sala {n}"
room_complete: "Fase completada"
menu:
titles:
root: "Menú de servei"
+7
View File
@@ -36,6 +36,13 @@ hud:
pyramid: "Pyramid"
room: "Room"
# Room transition texts (GameScene FSM intro/outro). The `room_intro`
# string accepts a `{n}` placeholder that is replaced with the current
# room index; see `GameScene::formatRoomTitle`.
game:
room_intro: "Room {n}"
room_complete: "Stage complete"
menu:
titles:
root: "Service menu"
+9 -1
View File
@@ -42,6 +42,12 @@ geometry:
hud_h: 24
back_wall_h: 24
side_wall_w: 24
# Columna central del tile transitable de la porta. La porta es renderitza
# com a sprite 48×32 (3 tiles d'amplada) centrat sobre aquesta columna; els
# 16 px centrals (just aquesta columna) són el "forat" del mur trasser per
# on el jugador surt de la sala. Les 2 columnes flanquejants segueixen sent
# mur visible. El spawn dels jugadors també s'alinea amb aquesta columna.
door_col: 10
data:
- [ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
@@ -123,7 +129,9 @@ entities:
- type: sarcophagus
sarc_position: [3, 4]
- type: door
position: [9, 1]
# Sense `position`: la porta sempre viu al mur trasser. La columna
# transitable la dicta `geometry.door_col`; el render i la caixa
# de col·lisió es deriven d'aquesta dada al loadFromLevel.
- type: mummy
position: [10, 7]
- type: mummy
+24
View File
@@ -0,0 +1,24 @@
# Configuració de la GameScene: timings i corbes de la FSM de sala
# (ROOM_INTRO i ROOM_OUTRO). Tots els valors són ms i tota la geometria
# i font del text es reutilitzen del tema UI (data/ui.yaml > pause +
# notification). Si manca el fitxer o alguna clau, GameScene cau als
# fallbacks compilats; veure `readSceneConfig` a game_scene.cpp.
room_intro:
fade_in_ms: 500 # negre opac → món visible
text_delay_ms: 500 # espera amb el món net abans de sortir el text
text_enter_ms: 500 # text esquerra → centre
text_hold_ms: 1000 # text quiet al centre
text_exit_ms: 500 # text centre → fora per l'esquerra
room_outro:
text_enter_ms: 500
text_hold_ms: 1000
text_exit_ms: 500
fade_out_ms: 500 # món visible → negre opac
text:
# Noms d'easing tal com els resol UiConfig::curveByName (vegeu
# `data/ui.yaml`). El text entra suau i surt accelerant.
enter_curve: outCubic
exit_curve: inCubic
+18 -4
View File
@@ -6,6 +6,7 @@
#include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp"
#include "game/map/map.hpp"
namespace {
@@ -65,21 +66,34 @@ void Door::update(float delta_time) {
}
void Door::onPlayerContact(Player* player) {
if (state_ != State::OPEN || level_complete_emitted_) { return; }
// Només la porta OPEN despawna jugadors. CLOSED/OPENING ignoren el
// contacte (i a més, en CLOSED, el `Map::collides` ja bloqueja el
// moviment, així que el contacte no es dóna en condicions normals).
if (state_ != State::OPEN || player == nullptr || player->isExited()) { return; }
// Marquem el jugador com a EXITED i publiquem l'event d'agregació.
// El check `isExited` de la guarda anterior garanteix que només
// emetem l'event en la primera transició — `EntityManager` compta
// emissions per decidir quan publicar `level_complete`.
player->exit();
if (bus_ != nullptr) {
bus_->publish(Event{
.name = "level_complete",
.name = "player_exited",
.payload = std::monostate{},
.player_id = (player != nullptr) ? player->getId() : -1,
.player_id = player->getId(),
});
}
level_complete_emitted_ = true;
}
void Door::open() {
if (state_ != State::CLOSED) { return; }
state_ = State::OPENING;
setAnimation(anim_opening_);
// Marquem el passadís com a transitable: a partir d'aquest tic
// `Map::collides` deixa de bloquejar el rect del passadís. La
// transició d'animació OPENING → OPEN encara dura el seu temps,
// però la geometria ja és permeable — coherent amb el feel de
// "la porta s'obri i el jugador pot avançar al moment".
Map::door_open = true;
// Edge case: si `opening` té ≤1 frame i és loop=true, mai es
// completarà. Per força, forcem loop=false del frame únic aquí. Els
// placeholders defineixen `loop: false` explícitament al YAML, així
+7 -5
View File
@@ -7,11 +7,14 @@
#include "game/entities/interactable.hpp"
// Porta de sortida del nivell. Tres estats:
// CLOSED → inicial; bloqueja el pas.
// CLOSED → inicial; el passadís de Map bloqueja el pas via `Map::collides`.
// OPENING → animació d'obertura en curs. Quan el component acaba →
// transita a OPEN.
// OPEN → accessible. Al contacte amb el jugador publica
// `level_complete` al bus (si n'hi ha).
// transita a OPEN. El passadís ja és transitable (s'allibera
// a `open()` amb `Map::door_open = true`).
// OPEN → accessible. Al contacte amb un jugador, crida `Player::exit()`
// — el jugador queda despawnat i emet `player_exited` al bus.
// L'agregació "tots els jugadors han eixit → `level_complete`"
// viu a EntityManager.
//
// El que decideix quan s'obri és extern (`EntityManager` comptant sarcs
// oberts) via `open()`. L'animació `opening` es fa servir com a
@@ -38,7 +41,6 @@ class Door : public Interactable {
void setAnimation(const AnimationComponent& anim);
State state_{State::CLOSED};
bool level_complete_emitted_{false};
// Les 3 animacions precalculades al ctor des del YAML. L'activa
// s'assigna a `anim_` (heretat d'Entity) segons l'estat.
+26
View File
@@ -32,11 +32,37 @@ EntityManager::EntityManager() {
const auto* tile = std::get_if<TilePos>(&ev.payload);
if (tile != nullptr) { notifyPlayerTilePainted(tile->col, tile->row); }
});
// Subscripció a `player_exited` (publicat per Door en travessar la
// porta oberta amb un jugador): comptem una vegada per jugador i,
// quan tots els jugadors actius han eixit, emetem `level_complete`
// — l'event d'agregat que GameScene escolta per disparar la
// transició FSM a ROOM_OUTRO. La idempotència de Door (check
// `isExited`) garanteix que cada jugador suma com a màxim un cop.
exit_sub_id_ = bus_.subscribe("player_exited", [this](const Event& /*ev*/) {
++exited_players_;
if (level_complete_emitted_) { return; }
if (expected_players_ > 0 && exited_players_ >= expected_players_) {
level_complete_emitted_ = true;
bus_.publish(Event{
.name = "level_complete",
.payload = std::monostate{},
.player_id = -1,
});
}
});
}
EntityManager::~EntityManager() {
bus_.unsubscribe(sarc_sub_id_);
bus_.unsubscribe(tile_sub_id_);
bus_.unsubscribe(exit_sub_id_);
}
void EntityManager::setExpectedPlayerCount(int n) {
expected_players_ = n;
exited_players_ = 0;
level_complete_emitted_ = false;
}
auto EntityManager::add(std::unique_ptr<Entity> entity) -> Entity* {
+25
View File
@@ -90,10 +90,27 @@ class EntityManager {
// `Map::tile_size`, `tilemap_origin_*` i `getSarcRect`).
void loadFromLevel(const std::string& level_yaml_path);
// Reset complet del nivell: destrueix totes les entitats, reseteja
// els comptadors interns (sarcòfags oberts, total, exited, flag de
// level_complete) i torna a cridar `loadFromLevel` per regenerar les
// entitats des del YAML. El bus i les seues subscripcions
// sobreviuen — això inclou les subscripcions externes (paint_layer_,
// hud_, players, GameScene). Cridat des de GameScene a la transició
// ROOM_OUTRO → ROOM_INTRO. `setExpectedPlayerCount` cal cridar-lo de
// nou després si vols agregació de level_complete al nou cicle.
void reload(const std::string& level_yaml_path);
// Fixa quants jugadors actius es consideren al recompte d'EXITED.
// Cridat per GameScene un cop instanciats els jugadors. Si manca,
// `expected_players_ = 0` i mai s'emet `level_complete` automàticament.
// Cridar-lo de nou al respawn (futur reload) reseteja el comptador.
void setExpectedPlayerCount(int n);
[[nodiscard]] auto getBus() -> EventBus& { return bus_; }
[[nodiscard]] auto entityCount() const -> std::size_t { return entities_.size(); }
[[nodiscard]] auto sarcophagusCount() const -> std::size_t { return total_sarcs_; }
[[nodiscard]] auto openedSarcophagusCount() const -> std::size_t { return opened_sarcs_; }
[[nodiscard]] auto exitedPlayerCount() const -> std::size_t { return exited_players_; }
private:
EventBus bus_;
@@ -101,6 +118,14 @@ class EntityManager {
Door* door_{nullptr};
std::size_t total_sarcs_{0};
std::size_t opened_sarcs_{0};
// Recompte d'`player_exited`: incrementat a la subscripció al bus,
// resetejat per `setExpectedPlayerCount`. Quan `exited_players_` arriba
// a `expected_players_` (i tots dos > 0), publica `level_complete`
// amb `player_id = -1` (event d'agregat, no atribuible a un jugador).
int expected_players_{0};
int exited_players_{0};
bool level_complete_emitted_{false};
EventBus::SubId sarc_sub_id_{0};
EventBus::SubId tile_sub_id_{0};
EventBus::SubId exit_sub_id_{0};
};
@@ -65,7 +65,7 @@ void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) {
const auto BOX = entity->getCollisionRect();
const int E_HEIGHT = entity->getHeight();
for (auto* p : players) {
if (p == nullptr) { continue; }
if (p == nullptr || p->isExited()) { continue; }
// Filtre per altura: si el jugador està en l'aire i està per
// damunt de l'alçada del cos de l'entitat, no hi ha contacte.
// És el mecanisme del salt per esquivar momies, roques, etc.
@@ -291,6 +291,34 @@ namespace {
return true;
}
// Col·loca la porta al mur trasser segons `Map::door_col`. La porta
// viu sempre al `wall_north`; només la columna varia per nivell. El
// sprite (típicament 48×32) es centra horitzontalment sobre la
// columna transitable i s'alinea verticalment de manera que la seua
// vora inferior coincideixi amb la base del strip del mur trasser
// (= `playfield_y`). Amb sprite 48×32 i `back_wall_h = 24` això vol
// dir que els 8 px superiors del sprite queden dins del HUD i hi
// queden tapats — comportament intencional.
auto placeDoor(EntityManager& manager, std::unique_ptr<Entity> entity, const SpriteInfo& sprite) -> bool {
if (Map::door_col <= 0 || Map::tile_size <= 0) {
std::cerr << "EntityManager: door requereix `geometry.door_col` definit al mapa\n";
return false;
}
const float CENTER_X = static_cast<float>((Map::door_col * Map::tile_size) + Map::tilemap_origin_x) + (static_cast<float>(Map::tile_size) / 2.0F);
const SDL_FRect RENDER{
.x = CENTER_X - (sprite.width / 2.0F),
.y = static_cast<float>(Map::playfield_y) - sprite.height,
.w = sprite.width,
.h = sprite.height,
};
entity->setPixelRect(RENDER);
auto* door_ptr = dynamic_cast<Door*>(entity.get());
manager.add(std::move(entity));
if (door_ptr != nullptr) { manager.setDoor(door_ptr); }
return true;
}
auto placeGeneric(EntityManager& manager, std::unique_ptr<Entity> entity, const std::string& type, const SpriteInfo& sprite, const std::string& anchor, const fkyaml::node& spawn) -> bool {
if (!spawn.contains("position")) {
std::cerr << "EntityManager: " << type << " sense position\n";
@@ -322,17 +350,30 @@ namespace {
}
entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height});
if (type == "door") {
auto* door_ptr = dynamic_cast<Door*>(entity.get());
manager.add(std::move(entity));
if (door_ptr != nullptr) { manager.setDoor(door_ptr); }
} else {
manager.add(std::move(entity));
}
manager.add(std::move(entity));
return true;
}
} // namespace
void EntityManager::reload(const std::string& level_yaml_path) {
// Destrucció ordenada: les entitats moren aquí; les seues
// subscripcions implícites desapareixen amb la seua vida (cap d'elles
// se subscriu directament al bus — només publiquen). El bus i les
// subscripcions externes (PaintLayer, HUD, GameScene) no es toquen.
entities_.clear();
door_ = nullptr;
total_sarcs_ = 0;
opened_sarcs_ = 0;
// No reset de `expected_players_`: GameScene el tornarà a fixar
// (amb el mateix valor o un de nou) després del reload, com a part
// del respawn dels jugadors. Reseteig dels comptadors d'EXITED i
// del flag d'emissió perquè el cicle següent pugui reagregar net.
exited_players_ = 0;
level_complete_emitted_ = false;
loadFromLevel(level_yaml_path);
}
void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path);
int loaded = 0;
@@ -346,9 +387,15 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
auto entity = buildEntity(TYPE, sprite, anchor);
if (entity == nullptr) { continue; }
const bool OK = (TYPE == "sarcophagus")
? placeSarcophagus(*this, std::move(entity), sprite, spawn)
: placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn);
bool ok = false;
if (TYPE == "sarcophagus") {
ok = placeSarcophagus(*this, std::move(entity), sprite, spawn);
} else if (TYPE == "door") {
ok = placeDoor(*this, std::move(entity), sprite);
} else {
ok = placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn);
}
const bool OK = ok;
if (OK) { ++loaded; }
}
+62 -2
View File
@@ -328,6 +328,10 @@ void Player::update(float delta_time) {
}
auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool {
// EXITED: jugador despawnat. No xoca amb res ni res xoca amb ell.
// També salta jugadors EXITED del check actor↔actor més avall via
// el filtre de `isExited` (vegeu el predicat any_of).
if (state_ == State::EXITED) { return false; }
if (map_.collides(box)) { return true; }
if (all_players_ == nullptr) { return false; }
// Per al contacte actor-actor usem la hit-box petita a banda i banda:
@@ -353,6 +357,7 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool {
const float MY_Z = jump_z_;
return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT, MY_AIR, MY_HURT, MY_Z](const Player* p) {
if (p == nullptr || p == this) { return false; }
if (p->isExited()) { return false; }
if (MY_HURT || p->isHurt()) { return false; }
if (MY_AIR != p->isAirborne()) { return false; }
// Aquí els dos estats coincideixen. Si tots dos volen, el filtre
@@ -398,7 +403,15 @@ void Player::integrateAxis(float& phys, float input, float delta_time, float mot
}
}
// Pipeline d'estats i input separats; extracció a helpers empitjoraria la
// llegibilitat. La complexitat puja 1 per la guarda EXITED.
// NOLINTNEXTLINE(readability-function-cognitive-complexity)
void Player::tick(float delta_time) {
// EXITED: jugador despawnat, no fa res. Cap input, cap física, cap
// bus event. Continua sent al vector de jugadors per simplicitat
// (no s'esborra) però queda completament inert fins al respawn.
if (state_ == State::EXITED) { return; }
// Reset per frame: el resolver del GameScene el tornarà a posar a
// true si aquest frame hi ha push actiu. Així quan el push acaba
// (un deixa d'empentar, l'altre comença a caminar), l'animació torna
@@ -740,6 +753,9 @@ void Player::resolveLandingOverlap() {
}
void Player::applyMovement(float delta_time) {
// EXITED: cap moviment ni col·lisió. Idèntic al gating de `tick`.
if (state_ == State::EXITED) { return; }
// Integració del salt: si estem AIRBORNE, avança jump_z_/jump_vz_.
// El facing i intended_v* es mantenen del moment del despegue (no es
// recalculen). Si toquem el sòl aquest frame, transicionem a
@@ -859,6 +875,10 @@ void Player::publishTileEnteredIfChanged() {
}
void Player::render() {
// EXITED: invisible. La crida al render base es salta sencera; el
// jugador no apareix al z-sort tot i seguir vivint al vector.
if (state_ == State::EXITED) { return; }
// Mentre el jugador està enlaire, el sprite es desplaça visualment
// cap amunt `jump_z_` píxels. Modifiquem `pos_.y` (la posició entera
// de render) abans de cridar el render base, i la restaurem després
@@ -987,11 +1007,51 @@ void Player::clampToPlayfield() {
const auto MIN_X = static_cast<float>(map_.playfield_x - collision_x_offset);
const auto MAX_X = static_cast<float>(
map_.playfield_x + map_.playfield_w - collision_x_offset - collision_size);
const auto MIN_Y = static_cast<float>(map_.playfield_y - collision_y_offset);
auto min_y = static_cast<float>(map_.playfield_y - collision_y_offset);
const auto MAX_Y = static_cast<float>(
map_.playfield_y + map_.playfield_h - collision_y_offset - collision_size);
// Passadís de sortida: si la base box queda enterament dins del rang
// X del passadís de la porta, deixem que pugi al strip del mur trasser
// (fins a `door_corridor.y`). Fora del rang, MIN_Y es queda al
// playfield. La regla "enterament dins" exigeix alinear-se al passadís
// abans de pujar — la base box (12 px) hi cap amb 4 px de marge.
if (map_.door_corridor_w > 0) {
const float BASE_X = getPosX() + static_cast<float>(collision_x_offset);
const float BASE_RIGHT = BASE_X + static_cast<float>(collision_size);
const auto CORRIDOR_LEFT = static_cast<float>(map_.door_corridor_x);
const auto CORRIDOR_RIGHT = static_cast<float>(map_.door_corridor_x + map_.door_corridor_w);
if (BASE_X >= CORRIDOR_LEFT && BASE_RIGHT <= CORRIDOR_RIGHT) {
min_y = static_cast<float>(map_.door_corridor_y - collision_y_offset);
}
}
setPosX(std::clamp(getPosX(), MIN_X, MAX_X));
setPosY(std::clamp(getPosY(), MIN_Y, MAX_Y));
setPosY(std::clamp(getPosY(), min_y, MAX_Y));
}
void Player::respawn(float x, float y) {
state_ = State::GROUNDED;
intended_vx_ = 0.0F;
intended_vy_ = 0.0F;
input_vx_ = 0.0F;
input_vy_ = 0.0F;
being_pushed_ = false;
jump_z_ = 0.0F;
jump_vz_ = 0.0F;
landing_stun_remaining_ms_ = 0.0F;
turning_remaining_ms_ = 0.0F;
pending_cardinal_ = Cardinal::NONE;
intermediate_cardinal_ = Cardinal::NONE;
last_input_cardinal_ = Cardinal::NONE;
// Reseteig del facing al d'spawn original (mirant cap a la càmera):
// sense això, un EXITED que entrava per la porta amb facing UP
// re-aparix d'esquena al pròxim cicle. Mateixa lògica que el ctor.
facing_ = Facing::DOWN;
setFlip(SDL_FLIP_NONE);
setCurrentAnimation("stand_down");
setPosX(x);
setPosY(y);
}
auto Player::getBaseBox() const -> SDL_FRect {
+36
View File
@@ -44,6 +44,12 @@ class Player : public AnimatedSprite {
AIRBORNE,
LANDING_STUN,
HURT,
// Jugador que ha eixit per la porta de la sala. Despawn lògic:
// no es renderitza, no processa input, no es mou, no col·lisiona
// amb res ni rep contacte d'entitats. Idempotent: les crides
// posteriors a `exit()` són no-op. El reset el porta `respawn()`
// (cridada per GameScene en transició OUTRO → INTRO).
EXITED,
};
// Geometria carregada des del YAML del player (p.ex. `data/player/sam.yaml`).
@@ -177,12 +183,42 @@ class Player : public AnimatedSprite {
[[nodiscard]] auto getState() const -> State { return state_; }
[[nodiscard]] auto isAirborne() const -> bool { return state_ == State::AIRBORNE; }
[[nodiscard]] auto isHurt() const -> bool { return state_ == State::HURT; }
[[nodiscard]] auto isExited() const -> bool { return state_ == State::EXITED; }
// Tangible per a contactes jugador↔jugador: només els estats a terra
// amb cos sòlid. AIRBORNE i HURT són intangibles (es travessen).
// EXITED tampoc — el jugador ha eixit lògicament i ja no és al món.
[[nodiscard]] auto isPvpTangible() const -> bool {
return state_ == State::GROUNDED || state_ == State::LANDING_STUN;
}
// Despawn lògic: el jugador acaba de creuar la porta de sortida.
// Idempotent — només la primera crida fa la transició; les
// posteriors són no-op. La publicació del `player_exited` al bus
// la fa el caller (típicament `Door::onPlayerContact`) per
// mantenir aquesta capçalera lleugera (sense incloure event_bus).
// Definit inline perquè els consumidors de baix nivell (test
// d'entitats) puguen enllaçar Door sense arrossegar player.cpp.
void exit() {
if (state_ == State::EXITED) { return; }
state_ = State::EXITED;
intended_vx_ = 0.0F;
intended_vy_ = 0.0F;
// Important: també resetegem `input_v*` perquè el resolver
// jugador↔jugador (`resolvePlayerVsPlayer`) consulta
// `getInputVX/Y` per decidir si un jugador "es mou". Un EXITED
// amb input fantasma del moment d'entrar a la porta dispararia
// la branca head-on i bloquejaria l'altre jugador en sec.
input_vx_ = 0.0F;
input_vy_ = 0.0F;
being_pushed_ = false;
}
// Reset complet a l'estat inicial: posició a (x, y), velocitats a 0,
// estat GROUNDED, qualsevol flag de control reinicialitzat. Cridat
// per GameScene a la transició ROOM_OUTRO → ROOM_INTRO. Idempotent
// (es pot cridar des de qualsevol estat).
void respawn(float x, float y);
// Alçada actual del salt (píxels visuals per damunt del sòl).
// 0 quan està a terra. L'usa el render i el sistema de col·lisions
// per altura.
+31
View File
@@ -75,9 +75,24 @@ namespace Map {
playfield_w = board_w + (2 * outer_corridor);
playfield_h = board_h + (2 * outer_corridor);
// Porta i passadís de sortida. `door_col` és la columna central
// transitable; el passadís són exactament els 16 px d'eixa columna
// dins del strip del mur trasser. Si manca la clau, `door_col=0`
// i el passadís queda a x=0 amb mida zero — pràcticament inactiu
// (el bloqueig del clamp continua sent el rectangle del playfield).
door_col = g.contains("door_col") ? g["door_col"].get_value<int>() : 0;
door_corridor_x = (door_col * tile_size) + tilemap_origin_x;
door_corridor_y = hud_h;
door_corridor_w = tile_size;
door_corridor_h = back_wall_h;
// Cada càrrega de nivell deixa la porta tancada; l'entitat Door
// canviarà el flag a true quan transiti a OPEN.
door_open = false;
std::cout << "Map: geometria " << grid_cols << "x" << grid_rows
<< " sarcs " << sarc_w << "x" << sarc_h
<< " pitch " << pitch_x << "x" << pitch_y
<< " door_col " << door_col
<< " des de " << yaml_path << '\n';
}
@@ -99,6 +114,18 @@ namespace Map {
if (overlapStrict(base_box, getSarcRect(col, row))) { return true; }
}
}
// Porta tancada: el passadís de sortida actua com a paret. Quan la
// porta passa a OPEN, l'entitat Door posa `door_open = true` i
// aquesta clàusula deixa de bloquejar — el jugador pot creuar.
if (!door_open && door_corridor_w > 0 && door_corridor_h > 0) {
const SDL_FRect CORRIDOR{
.x = static_cast<float>(door_corridor_x),
.y = static_cast<float>(door_corridor_y),
.w = static_cast<float>(door_corridor_w),
.h = static_cast<float>(door_corridor_h),
};
if (overlapStrict(base_box, CORRIDOR)) { return true; }
}
return false;
}
@@ -136,6 +163,10 @@ namespace Map {
.tile_size = tile_size,
.tilemap_origin_x = tilemap_origin_x,
.tilemap_origin_y = tilemap_origin_y,
.door_corridor_x = door_corridor_x,
.door_corridor_y = door_corridor_y,
.door_corridor_w = door_corridor_w,
.door_corridor_h = door_corridor_h,
.collides = &collides,
.inside_playfield = &insidePlayfield,
};
+25
View File
@@ -62,6 +62,24 @@ namespace Map {
inline int playfield_w = 0;
inline int playfield_h = 0;
// Porta de sortida del nivell. La porta sempre viu al mur trasser
// (`wall_north`); només la columna varia per nivell. `door_col` és la
// columna central transitable: el sprite 48×32 es dibuixa centrat
// sobre aquesta columna i els 16 px centrals (just `door_col`) són
// l'únic forat del mur trasser per on el jugador surt. Derivat de
// `geometry.door_col` al YAML.
inline int door_col = 0;
inline int door_corridor_x = 0;
inline int door_corridor_y = 0;
inline int door_corridor_w = 0;
inline int door_corridor_h = 0;
// Estat del passadís: si la porta està oberta, el `door_corridor` no
// bloqueja el moviment del jugador (vegeu `Map::collides`). Qui muta
// aquest flag és l'entitat `Door` quan transita CLOSED → OPEN; el
// reset el porta `Map::loadFrom` (cada càrrega de nivell el torna a
// posar a false).
inline bool door_open = false;
// Carrega el bloc `geometry:` del YAML i recalcula tots els derivats.
// Ha de cridar-se abans d'instanciar `Player` o consultar `collides()`:
// GameScene ho fa al ctor.
@@ -104,6 +122,13 @@ namespace Map {
int tile_size{0};
int tilemap_origin_x{0};
int tilemap_origin_y{0};
// Passadís de sortida (forat del mur trasser a la columna de la
// porta). El Player l'usa des del clamp per permetre pujar al
// strip del mur quan la base box queda dins del rang X.
int door_corridor_x{0};
int door_corridor_y{0};
int door_corridor_w{0};
int door_corridor_h{0};
bool (*collides)(const SDL_FRect&){nullptr};
bool (*inside_playfield)(const SDL_FRect&){nullptr};
};
+7
View File
@@ -82,6 +82,13 @@ auto PaintLayer::isPainted(int col, int row) const -> bool {
return painted_[static_cast<std::size_t>(row)][static_cast<std::size_t>(col)] >= 0;
}
void PaintLayer::clear() {
// Mateixa geometria que al ctor: matriu de tiles a -1 (no pintat) i
// buffer ARGB a zero (alpha=0 = transparent → render no escriu res).
painted_.assign(static_cast<std::size_t>(rows_), std::vector<int>(static_cast<std::size_t>(cols_), -1));
std::ranges::fill(pixels_, 0U);
}
auto PaintLayer::tryPaint(int col, int row, int tile_index, int player_id) -> bool {
if (col < 0 || col >= cols_ || row < 0 || row >= rows_) { return false; }
if (tile_index < 0) { return false; }
+7
View File
@@ -48,6 +48,13 @@ class PaintLayer {
[[nodiscard]] auto isPainted(int col, int row) const -> bool;
// Buida el pintat acumulat. Marca tots els tiles com a no pintats i
// reseteja el buffer ARGB a alpha=0 (transparent). Cridat per
// GameScene a la transició ROOM_OUTRO → ROOM_INTRO. La subscripció
// al bus es manté — el PaintLayer continuarà reaccionant a
// `on_tile_entered` un cop els jugadors hagin reaparegut.
void clear();
private:
void blitPaintTile(int col, int row, int tile_index);
+264 -50
View File
@@ -5,6 +5,7 @@
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <iostream>
#include <iterator>
@@ -112,6 +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
// 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; }
// Aire ↔ tierra: el saltador es travessa net del caminant. Sense
// resolver, sense head-on, sense push. Els dos continuen amb la
@@ -263,11 +270,13 @@ GameScene::GameScene(Config config)
// 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) {
// 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);
@@ -307,23 +316,12 @@ GameScene::GameScene(Config config)
.right = Defaults::KeysGameP2::RIGHT,
.jump = Defaults::KeysGameP2::JUMP,
};
// 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<float>(GameCanvas::CENTER_X);
const auto CORRIDOR_CENTER_Y =
static_cast<float>(Map::playfield_y) + (static_cast<float>(Map::outer_corridor) / 2.0F);
const auto SPAWN_X = CORRIDOR_CENTER_X - (static_cast<float>(Player::sprite_w) / 2.0F);
const auto SPAWN_Y = CORRIDOR_CENTER_Y - static_cast<float>(Player::collision_y_offset) - (static_cast<float>(Player::collision_size) / 2.0F);
// En mode BOTH separem els jugadors un tile a banda i banda del
// centre perquè no naixen superposats. En mode single-player
// (P1 o P2 aïllats per a proves de dev) l'únic jugador arrenca al
// centre exacte.
// 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 = (USE_P1 && USE_P2);
const auto P1_SPAWN_X = BOTH ? SPAWN_X - static_cast<float>(Map::tile_size) : SPAWN_X;
const auto P2_SPAWN_X = BOTH ? SPAWN_X + static_cast<float>(Map::tile_size) : SPAWN_X;
// Índex de tile amb què pinta cada jugador — llegit del YAML del mapa
// (camps opcionals `player1_paint_tile` / `player2_paint_tile`). Si no
@@ -335,11 +333,13 @@ GameScene::GameScene(Config config)
constexpr int ID_P2 = 1;
if (USE_P1) {
const int P1_PAINT = readOptionalInt(config_.map_yaml, "player1_paint_tile", -1);
players_.emplace_back(std::make_unique<Player>(anim_p1, ID_P1, P1_SPAWN_X, SPAWN_Y, CONTROLS_P1, Map::makeInfo(), P1_PAINT));
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);
players_.emplace_back(std::make_unique<Player>(anim_p2, ID_P2, P2_SPAWN_X, SPAWN_Y, CONTROLS_P2, Map::makeInfo(), P2_PAINT));
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));
}
// PaintLayer: font única del pintat del nivell. Es crea després de
@@ -364,6 +364,12 @@ GameScene::GameScene(Config config)
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. 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()));
// 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_); }
@@ -395,10 +401,185 @@ GameScene::GameScene(Config config)
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);
// 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);
// 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::resetRoom() {
// Recarregar les entitats des del YAML del mapa: 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.
Map::loadFrom(config_.map_yaml);
entity_manager_.reload(config_.map_yaml);
entity_manager_.setExpectedPlayerCount(static_cast<int>(players_.size()));
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);
}
std::cout << "[ROOM] reset: entities reloaded, paint cleared, "
<< players_.size() << " player(s) respawned\n";
}
void GameScene::setState(RoomFsm::State new_state) {
if (new_state == state_) { return; }
std::cout << "[ROOM] " << RoomFsm::stateName(state_) << " -> " << RoomFsm::stateName(new_state) << '\n';
const auto PREV = state_;
state_ = new_state;
transition_ = {};
// 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,
// imposible amb el guard de dalt) o ve d'un altre estat, no fa res.
if (new_state == RoomFsm::State::ROOM_INTRO && PREV == RoomFsm::State::ROOM_OUTRO) {
resetRoom();
}
}
void GameScene::updateRoomTransition(int delta_ms) {
const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms);
if (NEXT != state_) { setState(NEXT); }
}
void GameScene::renderRoomFade() {
// Fade negre sobre el món, pintat un cop els actors ja estan al
// canvas i abans del HUD. Dos 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)
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() {
// Text de la transició de sala. Reusa la font cachejada per a la
// pausa (mateix tipus de lletra, mateix índex de color), i fa el
// mateix patró esquerra → centre → esquerra que el text de pausa
// però amb les durades pròpies de la transició.
//
// Mapatge per estat (només els estats amb text actiu hi entren):
// ROOM_INTRO: phases 2..4 → enter / hold / exit; text = sala N
// ROOM_OUTRO: phases 0..2 → enter / hold / exit; text = "fase completada"
if (!pause_font_) { return; }
std::string text;
int phase_enter = 0;
int phase_exit = 0;
int dur_enter = 0;
int dur_exit = 0;
if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase >= 2 && transition_.phase <= 4) {
text = RoomFsm::formatRoomTitle(room_index_);
phase_enter = 2;
phase_exit = 4;
dur_enter = transition_config_.intro_text_enter_ms;
dur_exit = transition_config_.intro_text_exit_ms;
} else if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase >= 0 && transition_.phase <= 2) {
text = Locale::get().text("game.room_complete");
phase_enter = 0;
phase_exit = 2;
dur_enter = transition_config_.outro_text_enter_ms;
dur_exit = transition_config_.outro_text_exit_ms;
} else {
return;
}
constexpr int KERNING = 0;
const int W = pause_font_->length(text, KERNING);
// El text entra des de l'esquerra (off-screen amb el seu cantó dret
// a x=0) i, després del hold, surt cap a la dreta (off-screen amb
// el seu cantó esquerre a x=WIDTH). Així el text "travessa" la
// pantalla en una sola direcció en compte de tornar enrere.
const int OFF_LEFT = -W;
const int OFF_RIGHT = GameCanvas::WIDTH;
int x = GameCanvas::CENTER_X;
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;
x = Easing::lerpInt(OFF_LEFT, GameCanvas::CENTER_X, CURVE(T));
} else 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;
x = Easing::lerpInt(GameCanvas::CENTER_X, OFF_RIGHT, CURVE(T));
}
constexpr Uint8 OUTLINE_WHITE = 255;
constexpr Uint8 STROKE_DIST = 1;
const int Y = GameCanvas::CENTER_Y - (pause_font_->getLineHeight() / 2);
pause_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG,
x,
Y,
text,
KERNING,
UiConfig::get().notificationText(),
STROKE_DIST,
OUTLINE_WHITE);
}
void GameScene::spawnDust(float feet_x_center, float feet_y, bool is_landing) {
// Mida del sprite de Dust llegida del seu YAML. Hardcoded per
// simplificar — si canvia, només cal tocar dust.yaml + aquesta
@@ -516,6 +697,11 @@ 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
@@ -539,22 +725,29 @@ void GameScene::update(int delta_ms) {
constexpr bool EDITOR_FROZEN = false;
#endif
const int SCALED_DELTA_MS = static_cast<int>(static_cast<float>(delta_ms) * pause_.scale);
const bool GAMEPLAY_ACTIVE = (state_ == RoomFsm::State::PLAYING);
if (!EDITOR_FROZEN && SCALED_DELTA_MS > 0) {
const float DT = static_cast<float>(SCALED_DELTA_MS) / 1000.0F;
// Pipeline de moviment en tres passes per poder intercalar la
// resolució de contactes jugador↔jugador entre input i posició:
// 1) tick: cada jugador calcula la velocitat intencionada
// (teclat + mando + facing).
// 2) resolve: si els dos es trobarien, anul·lem velocitats
// (head-on) o escalem a push_factor (empenta).
// 3) applyMovement: en l'ordre "pushed primer" perquè quan el
// pusher faça `tryMove`, el pushed ja estiga fora de la
// seua caixa. La callback també tracta l'altre jugador com
// a sòlid: xarxa de seguretat davant de qualsevol inexactitud.
for (auto& player : players_) { player->tick(DT); }
resolvePlayerVsPlayer(players_view_, DT, push_factor_);
for (auto* player : orderedForMovement(players_view_)) { player->applyMovement(DT); }
// Durant ROOM_INTRO/ROOM_OUTRO els jugadors no s'actualitzen ni
// poden col·lisionar amb entitats. Les entitats sí, perquè el
// disseny demana que el món continui animant-se durant les
// transicions de sala.
if (GAMEPLAY_ACTIVE) {
// Pipeline de moviment en tres passes per poder intercalar la
// resolució de contactes jugador↔jugador entre input i posició:
// 1) tick: cada jugador calcula la velocitat intencionada
// (teclat + mando + facing).
// 2) resolve: si els dos es trobarien, anul·lem velocitats
// (head-on) o escalem a push_factor (empenta).
// 3) applyMovement: en l'ordre "pushed primer" perquè quan el
// pusher faça `tryMove`, el pushed ja estiga fora de la
// seua caixa. La callback també tracta l'altre jugador com
// a sòlid: xarxa de seguretat davant de qualsevol inexactitud.
for (auto& player : players_) { player->tick(DT); }
resolvePlayerVsPlayer(players_view_, DT, push_factor_);
for (auto* player : orderedForMovement(players_view_)) { player->applyMovement(DT); }
}
// El tick d'entitats DESPRÉS del tick dels jugadors: si el jugador ha
// emès `on_tile_entered` aquest frame, l'EntityManager ja l'ha encaminat
@@ -565,7 +758,9 @@ void GameScene::update(int delta_ms) {
// Detecció de contacte player↔entitat després que totes les entitats
// hagin mogut aquest frame. Reutilitzem la vista plana que ja
// manté el GameScene per al resolver player↔player.
entity_manager_.checkPlayerCollisions(players_view_);
if (GAMEPLAY_ACTIVE) {
entity_manager_.checkPlayerCollisions(players_view_);
}
}
#ifdef _DEBUG
@@ -604,12 +799,19 @@ void GameScene::render() {
// explícita: l'editor edita el tilemap, no la capa de pintat).
if (!SKIP_ACTORS && paint_layer_) { paint_layer_->render(); }
if (!SKIP_ACTORS) {
// Passada de sombres dels jugadors saltant. Va per davant del
// pintat (al damunt) però per darrere de TOTS els actors —
// entitats i jugadors, inclòs el saltador mateix— així la sombra
// queda al sòl sense tapar res. `renderShadow` és no-op si el
// jugador està a terra, així que no cal filtrar al callsite.
for (const auto& p : players_) { p->renderShadow(); }
// Durant ROOM_INTRO/ROOM_OUTRO els jugadors no es veuen ni es mouen.
// Les entitats sí que es renderitzen perquè el disseny demana que
// el món continui animant-se durant el fade i el text.
const bool RENDER_PLAYERS = (state_ == 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(); }
}
// 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
@@ -620,14 +822,16 @@ void GameScene::render() {
Player* player; // nullable; si és nullptr, s'usa `entity`.
};
std::vector<Renderable> order;
order.reserve(entity_manager_.entityCount() + players_.size());
order.reserve(entity_manager_.entityCount() + (RENDER_PLAYERS ? players_.size() : 0));
const auto ENTITIES = entity_manager_.getEntities();
std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) {
return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr};
});
std::ranges::transform(players_, std::back_inserter(order), [](const auto& p) {
return Renderable{.feet_y = p->getFeetY(), .entity = nullptr, .player = p.get()};
});
if (RENDER_PLAYERS) {
std::ranges::transform(players_, std::back_inserter(order), [](const auto& p) {
return Renderable{.feet_y = p->getFeetY(), .entity = nullptr, .player = p.get()};
});
}
std::ranges::stable_sort(order, [](const Renderable& a, const Renderable& b) {
return a.feet_y < b.feet_y;
});
@@ -638,11 +842,21 @@ void GameScene::render() {
r.player->render();
}
}
// HUD: a sobre dels actors perquè la franja superior tape qualsevol
// sprite que (durant l'animació d'entrada) puga sortir més amunt
// del playfield. S'amaga en mode editor (l'editor edita el tilemap;
// el HUD distrauria) però segueix actualitzant-se per fora.
// Fade del món: pintat ENTRE actors i HUD perquè el marcador, les
// notificacions i el service menu queden per damunt sense afectar-se.
// Inactiu si l'estat és PLAYING.
renderRoomFade();
// HUD: a sobre dels actors (i del fade) perquè la franja superior
// tape qualsevol sprite que (durant l'animació d'entrada) puga
// sortir més amunt del playfield. S'amaga en mode editor però
// segueix actualitzant-se per fora.
if (hud_) { hud_->render(); }
// Text de la transició de sala (entrada o sortida): per damunt del
// HUD perquè és UI de transició i ha de ser sempre llegible.
renderRoomText();
}
#ifdef _DEBUG
+56
View File
@@ -11,6 +11,7 @@
#include "game/entities/entity_manager.hpp"
#include "game/map/paint_layer.hpp"
#include "game/map/tilemap.hpp"
#include "game/scenes/room_fsm.hpp"
#ifdef _DEBUG
#include "game/editor/editor.hpp"
@@ -45,6 +46,17 @@ class GameScene : public Scene {
void render() override;
private:
// Punt centralitzat per canviar d'estat de sala. Imprimeix la
// transició i reinicialitza els acumuladors de la `RoomFsm::Transition`.
// Cap estat s'ha de canviar fora d'aquí.
void setState(RoomFsm::State new_state);
// Avança la transició activa via `RoomFsm::advance`. S'executa cada
// frame amb delta real (no escalat per la pausa) perquè la transició
// viu en temps de UI. Si l'estat retornat és diferent de l'actual,
// crida `setState` (que reseteja la `Transition`).
void updateRoomTransition(int delta_ms);
// Consumeix events d'aquest frame (F11 del KeyConfig "pause" o botó
// START del gamepad primari) per disparar les transicions de pausa.
// No alterna un flag binari: inicia (o inverteix mid-flight) un
@@ -131,6 +143,50 @@ class GameScene : public Scene {
UiConfig::CurveFn curve{nullptr}; // Corba activa.
} pause_;
// Configuració de la FSM de sala (intro/outro). Carregada al ctor des
// de `data/scenes/game.yaml`; valors per defecte si manca el YAML.
RoomFsm::Config transition_config_{};
// Estat actual de la FSM de sala. S'inicialitza a ROOM_INTRO al ctor
// perquè cada vegada que entres al joc passes per la presentació de
// la sala. La transició a PLAYING l'orquestra `updateRoomTransition`.
RoomFsm::State state_{RoomFsm::State::ROOM_INTRO};
// Sub-fase i temps acumulat dins de l'estat actual. `setState` el
// reseteja a {0, 0}.
RoomFsm::Transition transition_{};
// Índex de sala que es passa com a paràmetre {n} al text d'intro.
// Avui és sempre 1 (no hi ha itinerari de sales encara). Quan
// arribi l'itinerari, és el camp que es modificarà al transicionar
// de ROOM_OUTRO → ROOM_INTRO per saltar a la pròxima.
int room_index_{1};
// Píxels del fade negre i del text de la transició de sala.
// Cridats des de `render()` entre el bloc d'actors i el HUD —
// així marcador, notificacions i service menu queden per damunt.
void renderRoomFade();
void renderRoomText();
// Posició inicial dels jugadors derivada de `Map::door_col`. Tots
// els jugadors comparteixen Y (centre del corredor exterior superior);
// X depèn del nombre actiu: amb un jugador queda centrat sota la
// porta; amb dos, separats ±tile_size respecte al centre. Estructura
// exposada perquè els tests puguen verificar-la sense entrar al ctor
// sencer.
struct PlayerSpawn {
float p1_x{0.0F};
float p2_x{0.0F};
float y{0.0F};
};
[[nodiscard]] static auto computeSpawn() -> PlayerSpawn;
// Reset complet de la sala: reload de l'EntityManager (sarcs/porta/
// momies des de zero), neteja del PaintLayer i respawn dels jugadors
// a la posició calculada per `computeSpawn`. Cridat des de
// `setState(ROOM_INTRO)` quan la transició anterior era OUTRO.
void resetRoom();
#ifdef _DEBUG
// Editor modal del tilemap. Mútuament excloent amb la pausa: si la
// pausa no està IDLE la tecla de toggle de l'editor s'ignora, i
+131
View File
@@ -0,0 +1,131 @@
#include "game/scenes/room_fsm.hpp"
#include <array>
#include <iostream>
#include <string_view>
#include "core/locale/locale.hpp"
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
namespace {
// Llegeix un sub-camp sencer d'un node YAML. Si manca, retorna el
// fallback. Dissenyat per al consum puntual de `loadConfig`.
auto readChildInt(const fkyaml::node& parent, const char* key, int fallback) -> int {
if (!parent.contains(key)) { return fallback; }
return parent[key].get_value<int>();
}
} // namespace
namespace RoomFsm {
auto loadConfig(const std::string& yaml_path) -> Config {
Config cfg{};
cfg.text_enter_curve = UiConfig::curveByName("outCubic");
cfg.text_exit_curve = UiConfig::curveByName("inCubic");
auto bytes = Resource::Helper::loadFile(yaml_path);
if (bytes.empty()) {
std::cout << "RoomFsm: " << yaml_path << " absent — uso fallbacks compilats\n";
return cfg;
}
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("room_intro")) {
const auto& n = yaml["room_intro"];
cfg.intro_fade_in_ms = readChildInt(n, "fade_in_ms", cfg.intro_fade_in_ms);
cfg.intro_text_delay_ms = readChildInt(n, "text_delay_ms", cfg.intro_text_delay_ms);
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);
}
if (yaml.contains("room_outro")) {
const auto& n = yaml["room_outro"];
cfg.outro_text_enter_ms = readChildInt(n, "text_enter_ms", cfg.outro_text_enter_ms);
cfg.outro_text_hold_ms = readChildInt(n, "text_hold_ms", cfg.outro_text_hold_ms);
cfg.outro_text_exit_ms = readChildInt(n, "text_exit_ms", cfg.outro_text_exit_ms);
cfg.outro_fade_out_ms = readChildInt(n, "fade_out_ms", cfg.outro_fade_out_ms);
}
if (yaml.contains("text")) {
const auto& n = yaml["text"];
if (n.contains("enter_curve")) {
cfg.text_enter_curve = UiConfig::curveByName(n["enter_curve"].get_value<std::string>());
}
if (n.contains("exit_curve")) {
cfg.text_exit_curve = UiConfig::curveByName(n["exit_curve"].get_value<std::string>());
}
}
std::cout << "RoomFsm: timings carregats des de " << yaml_path
<< " (intro fade " << cfg.intro_fade_in_ms
<< "ms, outro fade " << cfg.outro_fade_out_ms << "ms)\n";
return cfg;
}
auto advance(State current, Transition& t, const Config& cfg, int delta_ms) -> State {
if (current == State::PLAYING) { return current; }
t.elapsed_ms += delta_ms;
if (current == State::ROOM_INTRO) {
const std::array<int, 5> DURS = {
cfg.intro_fade_in_ms,
cfg.intro_text_delay_ms,
cfg.intro_text_enter_ms,
cfg.intro_text_hold_ms,
cfg.intro_text_exit_ms,
};
while (t.phase < static_cast<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) {
t.elapsed_ms -= DURS[t.phase];
++t.phase;
}
if (t.phase >= static_cast<int>(DURS.size())) { return State::PLAYING; }
return current;
}
// ROOM_OUTRO
const std::array<int, 4> DURS = {
cfg.outro_text_enter_ms,
cfg.outro_text_hold_ms,
cfg.outro_text_exit_ms,
cfg.outro_fade_out_ms,
};
while (t.phase < static_cast<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) {
t.elapsed_ms -= DURS[t.phase];
++t.phase;
}
// Placeholder fins a l'itinerari de sales: l'OUTRO recau a INTRO.
// TODO(itinerari): aquest mateix retorn sortirà cap a una nova
// sala (incrementant `room_index_` i recreant tilemap/entitats al
// GameScene) abans de tornar a ROOM_INTRO.
if (t.phase >= static_cast<int>(DURS.size())) { return State::ROOM_INTRO; }
return current;
}
auto formatRoomTitle(int n) -> std::string {
auto tpl = Locale::get().text("game.room_intro");
constexpr std::string_view PLACEHOLDER = "{n}";
auto pos = tpl.find(PLACEHOLDER);
if (pos != std::string::npos) {
tpl.replace(pos, PLACEHOLDER.size(), std::to_string(n));
}
return tpl;
}
auto stateName(State s) -> const char* {
switch (s) {
case State::ROOM_INTRO:
return "ROOM_INTRO";
case State::PLAYING:
return "PLAYING";
case State::ROOM_OUTRO:
return "ROOM_OUTRO";
}
return "?";
}
} // namespace RoomFsm
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include <cstdint>
#include <string>
#include "core/ui/ui_config.hpp"
// FSM de sala (intro/outro) extreta de GameScene per a testabilitat. La
// lògica que viu aquí és pura: no toca SDL, ni Audio, ni cap singleton
// excepte `Locale::get()` (per a la formatació del text). El consumidor
// actual és `GameScene`, que cridarà `RoomFsm::advance` cada frame.
namespace RoomFsm {
enum class State : std::uint8_t {
ROOM_INTRO, // Entrada de sala: fade in + text d'entrada.
PLAYING, // Joc normal.
ROOM_OUTRO, // Sortida de sala: text de sortida + fade out.
};
// Sub-fase i temps acumulat dins de l'estat actual. Es reseteja a
// {0, 0} quan es canvia d'estat. Significat de `phase` per estat:
// ROOM_INTRO: 0=fade_in, 1=text_delay, 2=text_enter, 3=text_hold, 4=text_exit
// ROOM_OUTRO: 0=text_enter, 1=text_hold, 2=text_exit, 3=fade_out
// PLAYING: no s'usa.
struct Transition {
int phase{0};
int elapsed_ms{0};
};
// Timings i corbes carregats de `data/scenes/game.yaml`. Els valors
// per defecte coincideixen amb els del YAML versionat: si manca el
// fitxer, la FSM funciona igualment amb aquests fallbacks.
struct Config {
int intro_fade_in_ms{500};
int intro_text_delay_ms{500};
int intro_text_enter_ms{500};
int intro_text_hold_ms{1000};
int intro_text_exit_ms{500};
int outro_text_enter_ms{500};
int outro_text_hold_ms{1000};
int outro_text_exit_ms{500};
int outro_fade_out_ms{500};
UiConfig::CurveFn text_enter_curve{nullptr};
UiConfig::CurveFn text_exit_curve{nullptr};
};
// Llegeix la configuració des del YAML. Si el fitxer manca o falten
// claus, els camps no presents conserven els seus fallbacks. Les
// corbes es resolen amb `UiConfig::curveByName` (mateix mecanisme
// que la pausa); si el nom és desconegut cau a `Easing::linear`.
[[nodiscard]] auto loadConfig(const std::string& yaml_path) -> Config;
// Avança la transició consumint `delta_ms` des del temps real.
// Retorna l'estat resultant (potser el mateix que l'entrant si
// encara queden sub-fases). Si retorna un estat diferent, el caller
// ha de resetejar la `Transition` a {0, 0} abans del següent frame
// (això ho fa `GameScene::setState`). Funció pura sobre l'argument.
[[nodiscard]] auto advance(State current, Transition& t, const Config& cfg, int delta_ms) -> State;
// Substitueix `{n}` de la clau "game.room_intro" del Locale per
// l'enter passat. Si la clau no està traduïda, retorna la pròpia
// clau (fallback de Locale::text).
[[nodiscard]] auto formatRoomTitle(int n) -> std::string;
// Nom textual per a logs i tests. No es fa servir per a lògica.
[[nodiscard]] auto stateName(State s) -> const char*;
} // namespace RoomFsm
+7
View File
@@ -220,6 +220,13 @@ namespace Defaults {
} // namespace Editor
#endif
// Configuració de l'escena de joc (timings i corbes de la FSM de sala).
// Independent de la piràmide concreta — quan un dia hi haja diferents
// ritmes per piràmide, aquest YAML pot adoptar variants per nivell.
namespace GameScene {
constexpr const char* CONFIG_YAML = "data/scenes/game.yaml";
} // namespace GameScene
// Rutes d'assets per piràmide. Centralitzats ací perquè `GameScene` no
// en contingui cap literal; afegir una piràmide nova serà un altre
// conjunt de constants + un Config distint passat al ctor.
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Texts {
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner";
constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
constexpr const char* VERSION = "0.23.0"; // Single source of truth — Makefile el grep
constexpr const char* VERSION = "0.24.0"; // Single source of truth — Makefile el grep
} // namespace Texts
namespace GameCanvas {
+58 -13
View File
@@ -7,7 +7,9 @@
// T4 sarcòfag — perímetre computat correctament; notifyTilePainted
// passa a OPENING quan es visiten tots els tiles.
// T5 manager — 2 sarcs + 1 door: forçant l'obertura dels sarcs, la porta
// s'obri sola; contactar la porta OPEN emet `level_complete`.
// s'obri sola. El contacte de la porta amb un jugador
// nul·l és no-op segur (la porta ja no emet `level_complete`
// directament; ho fa EntityManager comptant `player_exited`).
// T6 animation — 3 frames @ 10 fps, `tick(0.15)` → frame índex == 1.
// T7 plantilles — carrega els 7 YAML d'entitat sense excepcions.
// T8 decoration — Torch no col·lisiona ni és interactable.
@@ -20,6 +22,10 @@
// sprite sencer (pixel_rect).
// T13 mummy step — camina en línia recta pel playfield i gira en topar
// un mur (insidePlayfield=false) o sarc (Map::collides).
// T14 exited aggregator — EntityManager compta `player_exited` i emet
// `level_complete` quan iguala `expected_players`. Verifica
// idempotència (3r event no re-emet) i reset del comptador
// via `setExpectedPlayerCount` (cicle següent torna a emetre).
//
// Execució (des de la ruta del repo):
// make build && cd build && ctest --output-on-failure
@@ -173,12 +179,6 @@ namespace {
manager.add(std::move(door));
manager.setDoor(door_ptr);
// Subscriptor local per capturar `level_complete`.
bool level_complete_fired = false;
manager.getBus().subscribe("level_complete", [&](const Event&) {
level_complete_fired = true;
});
// Forcem l'obertura dels dos sarcs visitant els seus perímetres.
auto forceOpen = [](Sarcophagus* s) {
const int COL0 = s->getTilePos().col;
@@ -206,12 +206,16 @@ namespace {
REQUIRE(manager.openedSarcophagusCount() == 2);
REQUIRE(door_ptr->getState() == Door::State::OPEN);
// Contacte amb la porta oberta → level_complete.
door_ptr->onPlayerContact(nullptr);
REQUIRE(level_complete_fired);
// Un segon contacte NO torna a disparar (idempotent).
level_complete_fired = false;
// Contacte sense jugador (nullptr): no-op segur. La porta ja no
// emet `level_complete` directament — ara és EntityManager qui
// l'emet quan tots els jugadors han eixit. La cobertura
// d'aquest agregat viu al test específic d'EntityManager
// (test_game_fsm) perquè requereix instanciar Player, fora
// d'abast d'aquest smoke purament d'entitats.
bool level_complete_fired = false;
manager.getBus().subscribe("level_complete", [&](const Event&) {
level_complete_fired = true;
});
door_ptr->onPlayerContact(nullptr);
REQUIRE(!level_complete_fired);
}
@@ -449,6 +453,46 @@ namespace {
REQUIRE(torch.getAnimation()->currentFrame() == 2);
}
void testExitedAggregator() {
std::cout << "[T14] EntityManager: player_exited → level_complete\n";
EntityManager manager;
// Comptador inicialitzat per a 2 jugadors. Sense aquesta crida,
// l'agregador no faria res (expected_players_ = 0).
manager.setExpectedPlayerCount(2);
int level_complete_count = 0;
manager.getBus().subscribe("level_complete", [&](const Event& ev) {
++level_complete_count;
// Event d'agregat: no atribuïble a un jugador concret.
REQUIRE(ev.player_id == -1);
});
// Primer player_exited: comptador a 1 < 2, no emet encara.
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 0});
REQUIRE(manager.exitedPlayerCount() == 1);
REQUIRE(level_complete_count == 0);
// Segon player_exited: comptador a 2 == expected, emet una vegada.
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 1});
REQUIRE(manager.exitedPlayerCount() == 2);
REQUIRE(level_complete_count == 1);
// Un tercer player_exited (no hauria de passar amb 2 jugadors,
// però defensa contra publicacions extra): no torna a emetre.
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 0});
REQUIRE(level_complete_count == 1);
// setExpectedPlayerCount torna a obrir el cicle (això simularia
// el que fa GameScene després d'un reload de sala). El comptador
// d'EXITED es reinicia a 0 i el flag d'emissió també.
manager.setExpectedPlayerCount(2);
REQUIRE(manager.exitedPlayerCount() == 0);
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 0});
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 1});
REQUIRE(level_complete_count == 2); // segon cicle ha emès una vegada més
}
} // namespace
auto main() -> int {
@@ -467,6 +511,7 @@ auto main() -> int {
testCollisionBox();
testMummyStep();
testTorchAnimation();
testExitedAggregator();
if (g_failures == 0) {
std::cout << "ALL OK\n";
+240
View File
@@ -0,0 +1,240 @@
// Smoke test de la FSM de sala (RoomFsm) extreta de GameScene. Cobreix:
// T1 loadConfig — fitxer real → tots els camps llegits.
// T2 loadConfig — fitxer absent → fallbacks compilats; corbes default
// resoltes per UiConfig::curveByName.
// T3 loadConfig — claus parcials → camps absents conserven fallback.
// T4 advance — ROOM_INTRO consumeix les 5 sub-fases en ordre.
// T5 advance — ROOM_INTRO desemboca a PLAYING quan acaba l'última fase.
// T6 advance — PLAYING és sumidor: no avança res ni canvia d'estat.
// T7 advance — ROOM_OUTRO consumeix les 4 sub-fases i recau a ROOM_INTRO.
// T8 advance — un delta gran salta més d'una sub-fase en un sol pas.
// T9 formatRoomTitle — substitueix `{n}` quan el Locale té la clau.
// T10 formatRoomTitle — sense Locale, retorna la pròpia clau (fallback).
// T11 stateName — strings esperats per a cada State.
//
// El render no es testeja: la FSM és pura i no pinta res. Les regles de
// gating d'input dels jugadors viuen a `GameScene::update` i estan
// cobertes pel smoke run del binari (xvfb-run) — la part testable és
// que `advance` retorna l'estat correcte, que és el senyal que activa
// el gating al callsite.
//
// Execució:
// make build && cd build && ctest --output-on-failure
// O bé:
// ./build/test_game_fsm
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <string>
#include "core/locale/locale.hpp"
#include "core/ui/ui_config.hpp"
#include "game/scenes/room_fsm.hpp"
namespace {
int g_failures = 0;
void check(bool cond, const char* msg, const char* file, int line) {
if (!cond) {
std::cerr << " FAIL: " << msg << " (" << file << ":" << line << ")\n";
++g_failures;
}
}
#define CHECK(cond) check((cond), #cond, __FILE__, __LINE__)
auto writeTemp(const std::string& path, const std::string& content) -> std::string {
std::ofstream file(path);
file << content;
return path;
}
void testLoadConfigFull() {
std::cout << "T1 loadConfig — fitxer real complet\n";
const auto PATH = (std::filesystem::temp_directory_path() / "aee_test_full.yaml").string();
writeTemp(PATH,
"room_intro:\n"
" fade_in_ms: 100\n"
" text_delay_ms: 200\n"
" text_enter_ms: 300\n"
" text_hold_ms: 400\n"
" text_exit_ms: 500\n"
"room_outro:\n"
" text_enter_ms: 600\n"
" text_hold_ms: 700\n"
" text_exit_ms: 800\n"
" fade_out_ms: 900\n"
"text:\n"
" enter_curve: outQuad\n"
" exit_curve: inQuad\n");
const auto CFG = RoomFsm::loadConfig(PATH);
CHECK(CFG.intro_fade_in_ms == 100);
CHECK(CFG.intro_text_delay_ms == 200);
CHECK(CFG.intro_text_enter_ms == 300);
CHECK(CFG.intro_text_hold_ms == 400);
CHECK(CFG.intro_text_exit_ms == 500);
CHECK(CFG.outro_text_enter_ms == 600);
CHECK(CFG.outro_text_hold_ms == 700);
CHECK(CFG.outro_text_exit_ms == 800);
CHECK(CFG.outro_fade_out_ms == 900);
CHECK(CFG.text_enter_curve == UiConfig::curveByName("outQuad"));
CHECK(CFG.text_exit_curve == UiConfig::curveByName("inQuad"));
std::filesystem::remove(PATH);
}
void testLoadConfigMissing() {
std::cout << "T2 loadConfig — fitxer absent (fallbacks)\n";
const auto CFG = RoomFsm::loadConfig("/tmp/aee_2026_does_not_exist.yaml");
CHECK(CFG.intro_fade_in_ms == 500);
CHECK(CFG.intro_text_delay_ms == 500);
CHECK(CFG.intro_text_enter_ms == 500);
CHECK(CFG.intro_text_hold_ms == 1000);
CHECK(CFG.intro_text_exit_ms == 500);
CHECK(CFG.outro_text_enter_ms == 500);
CHECK(CFG.outro_text_hold_ms == 1000);
CHECK(CFG.outro_text_exit_ms == 500);
CHECK(CFG.outro_fade_out_ms == 500);
CHECK(CFG.text_enter_curve != nullptr);
CHECK(CFG.text_exit_curve != nullptr);
}
void testLoadConfigPartial() {
std::cout << "T3 loadConfig — claus parcials (mantenir fallback)\n";
const auto PATH = (std::filesystem::temp_directory_path() / "aee_test_partial.yaml").string();
writeTemp(PATH,
"room_intro:\n"
" fade_in_ms: 42\n");
const auto CFG = RoomFsm::loadConfig(PATH);
CHECK(CFG.intro_fade_in_ms == 42);
CHECK(CFG.intro_text_delay_ms == 500); // fallback
CHECK(CFG.outro_fade_out_ms == 500); // fallback
std::filesystem::remove(PATH);
}
auto makeShortConfig() -> RoomFsm::Config {
// Durades curtes per fer els tests d'`advance` deterministes i
// independents dels valors per defecte.
RoomFsm::Config cfg{};
cfg.intro_fade_in_ms = 10;
cfg.intro_text_delay_ms = 20;
cfg.intro_text_enter_ms = 30;
cfg.intro_text_hold_ms = 40;
cfg.intro_text_exit_ms = 50;
cfg.outro_text_enter_ms = 11;
cfg.outro_text_hold_ms = 22;
cfg.outro_text_exit_ms = 33;
cfg.outro_fade_out_ms = 44;
return cfg;
}
void testAdvanceIntroPhases() {
std::cout << "T4 advance — ROOM_INTRO consumeix sub-fases en ordre\n";
const auto CFG = makeShortConfig();
RoomFsm::Transition t{};
auto s = RoomFsm::State::ROOM_INTRO;
s = RoomFsm::advance(s, t, CFG, 5); // dins fase 0
CHECK(s == RoomFsm::State::ROOM_INTRO && t.phase == 0 && t.elapsed_ms == 5);
s = RoomFsm::advance(s, t, CFG, 10); // surt de fase 0, entra a fase 1 (5 ms dins)
CHECK(s == RoomFsm::State::ROOM_INTRO && t.phase == 1 && t.elapsed_ms == 5);
s = RoomFsm::advance(s, t, CFG, 20); // surt de fase 1 (15 dins), entra a fase 2 amb 5 ms (5+20-20=5)
CHECK(s == RoomFsm::State::ROOM_INTRO && t.phase == 2 && t.elapsed_ms == 5);
}
void testAdvanceIntroToPlaying() {
std::cout << "T5 advance — ROOM_INTRO acaba a PLAYING\n";
const auto CFG = makeShortConfig();
RoomFsm::Transition t{};
const int TOTAL = CFG.intro_fade_in_ms + CFG.intro_text_delay_ms +
CFG.intro_text_enter_ms + CFG.intro_text_hold_ms +
CFG.intro_text_exit_ms;
const auto S = RoomFsm::advance(RoomFsm::State::ROOM_INTRO, t, CFG, TOTAL);
CHECK(S == RoomFsm::State::PLAYING);
}
void testAdvancePlayingNoOp() {
std::cout << "T6 advance — PLAYING és sumidor\n";
const auto CFG = makeShortConfig();
RoomFsm::Transition t{42, 999};
const auto S = RoomFsm::advance(RoomFsm::State::PLAYING, t, CFG, 1000);
CHECK(S == RoomFsm::State::PLAYING);
CHECK(t.phase == 42); // intacte
CHECK(t.elapsed_ms == 999);
}
void testAdvanceOutroToIntro() {
std::cout << "T7 advance — ROOM_OUTRO recau a ROOM_INTRO\n";
const auto CFG = makeShortConfig();
RoomFsm::Transition t{};
const int TOTAL = CFG.outro_text_enter_ms + CFG.outro_text_hold_ms +
CFG.outro_text_exit_ms + CFG.outro_fade_out_ms;
const auto S = RoomFsm::advance(RoomFsm::State::ROOM_OUTRO, t, CFG, TOTAL);
CHECK(S == RoomFsm::State::ROOM_INTRO);
}
void testAdvanceLargeDeltaJumps() {
std::cout << "T8 advance — un delta gran salta múltiples sub-fases\n";
const auto CFG = makeShortConfig();
RoomFsm::Transition t{};
// 10+20+5 = 35 ms → consumeix fase 0 i 1, queda a fase 2 amb 5 ms.
const auto S = RoomFsm::advance(RoomFsm::State::ROOM_INTRO, t, CFG, 35);
CHECK(S == RoomFsm::State::ROOM_INTRO);
CHECK(t.phase == 2);
CHECK(t.elapsed_ms == 5);
}
void testFormatRoomTitleWithLocale() {
std::cout << "T9 formatRoomTitle — substitució amb Locale\n";
const auto PATH = (std::filesystem::temp_directory_path() / "aee_test_locale.yaml").string();
writeTemp(PATH, "game:\n room_intro: \"Sala {n}\"\n");
Locale::get().load(PATH);
CHECK(RoomFsm::formatRoomTitle(1) == "Sala 1");
CHECK(RoomFsm::formatRoomTitle(7) == "Sala 7");
std::filesystem::remove(PATH);
}
void testFormatRoomTitleNoLocale() {
std::cout << "T10 formatRoomTitle — fallback sense traducció\n";
const auto PATH = (std::filesystem::temp_directory_path() / "aee_test_locale_empty.yaml").string();
writeTemp(PATH, "other: foo\n");
Locale::get().load(PATH);
// Sense la clau "game.room_intro", Locale retorna la pròpia clau,
// i com que no porta `{n}`, la substitució no canvia res.
CHECK(RoomFsm::formatRoomTitle(3) == "game.room_intro");
std::filesystem::remove(PATH);
}
void testStateName() {
std::cout << "T11 stateName — strings esperats\n";
CHECK(std::string(RoomFsm::stateName(RoomFsm::State::ROOM_INTRO)) == "ROOM_INTRO");
CHECK(std::string(RoomFsm::stateName(RoomFsm::State::PLAYING)) == "PLAYING");
CHECK(std::string(RoomFsm::stateName(RoomFsm::State::ROOM_OUTRO)) == "ROOM_OUTRO");
}
} // namespace
auto main() -> int {
testLoadConfigFull();
testLoadConfigMissing();
testLoadConfigPartial();
testAdvanceIntroPhases();
testAdvanceIntroToPlaying();
testAdvancePlayingNoOp();
testAdvanceOutroToIntro();
testAdvanceLargeDeltaJumps();
testFormatRoomTitleWithLocale();
testFormatRoomTitleNoLocale();
testStateName();
if (g_failures == 0) {
std::cout << "OK: tots els tests de RoomFsm passen\n";
return 0;
}
std::cerr << g_failures << " tests han fallat\n";
return 1;
}