Merge branch 'feat/assets-pipeline'

This commit is contained in:
JailDesigner committed 2026-05-11 22:55:50 +02:00
commit a358d8ddbd
42 files changed
+550 -176

No files matched your search

+1
View File
@@ -94,6 +94,7 @@ set(APP_SOURCES
source/core/rendering/sprite/animated_sprite_parser.cpp
source/core/rendering/effects/heat_haze.cpp
source/core/rendering/effects/scan_reveal.cpp
source/core/rendering/effects/shadow.cpp
source/core/rendering/transitions/mosaic.cpp
source/core/rendering/transitions/fade_to_black.cpp
source/core/rendering/sdl3gpu/sdl3gpu_shader.cpp
+7 -1
View File
@@ -293,8 +293,14 @@ rebuild: clean all
# Empaqueta data/ a $(BUILDDIR)/resources.pack (format AE26). Força un rebuild
# del pack encara que res no hagi canviat dins data/. L'empaquetat també es fa
# automàticament a cada `make`/`make debug` via el target CMake `resource_pack`.
#
# NO passem `-DCMAKE_BUILD_TYPE`: així respectem el tipus ja configurat al
# `CMakeCache.txt` (Debug si vens de `make debug`, Release si vens de
# `make release`). Si forcéssim Release ací, el següent build de debug
# invalidaria la cache i recompilaria-ho tot. El binari `pack_resources`
# és una utilitat petita on el build type és irrellevant.
pack:
@cmake -S . -B $(BUILDDIR) $(CMAKE_GEN) -DCMAKE_BUILD_TYPE=Release $(CMAKE_DEFS)
@cmake -S . -B $(BUILDDIR) $(CMAKE_GEN) $(CMAKE_DEFS)
@cmake --build $(BUILDDIR) --target pack_resources
@./$(BUILDDIR)/pack_resources data $(BUILDDIR)/resources.pack
Binary file not shown.
+24 -6
View File
@@ -17,15 +17,15 @@ colors:
menu_cursor: 71
menu_disabled: 16
hud_bg: 9
hud_label: 58
hud_value: 187
hud_flash: 237
hud_flash: 63
# Rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO). Independents
# de notification_* perquè la fase i les notificacions són dos canals
# visuals distints: ací pots canviar el color del rètol sense tocar
# els missatges del HUD.
room_text: 160
room_text_outline: 253
# els missatges del HUD. Comparteix la família del CENTER del HUD
# (or sobre marró) perquè "Piràmide N / Sala N" sigui sempre daurat
# arreu del joc.
room_text: 55 # or brillant (#FFDA00, = hud_center.value)
room_text_outline: 10 # marró cremat (#482400, = hud_center.shadow)
timings:
notification_ms: 4000
@@ -74,6 +74,24 @@ hud:
flash_ms: 250 # durada del flash quan un jugador rep un hit
shake_ms: 300 # durada del shake que acompanya el flash
# Famílies de color del HUD per bloc. Cada bloc té `label` (to apagat
# per etiquetes), `value` (to viu per a la xifra) i `shadow` (to fosc
# del mateix matís — efecte embossing 1 px a baix-dreta). Si manca un
# camp, cau al default compilat a `Defaults::UI`. El `colors.hud_flash`
# és comú a totes tres famílies.
hud_p1:
label: 7 # vermell pur
value: 183 # crema-préssec
shadow: 2 # vermell fosc
hud_center:
label: 29 # ambre cremat
value: 55 # or brillant
shadow: 10 # marró cremat
hud_p2:
label: 248 # cian pur
value: 253 # blau pàl·lid
shadow: 64 # blau marí
# Geometria del popup superior (Notifier). En píxels del canvas, llevat de
# slide_ms (durada en ms de l'animació d'entrada i d'eixida; constant
# independentment de la mida de la caixa). El padding vertical és asimètric a
Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 B

After

Width:  |  Height:  |  Size: 303 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

+6 -7
View File
@@ -36,18 +36,17 @@ animations:
walk_north: { frames: [4, 5, 6, 7], fps: 8, loop: true }
walk_east: { frames: [8, 9, 10, 11], fps: 8, loop: true }
walk_west: { frames: [8, 9, 10, 11], fps: 8, loop: true, flip_h: true }
raise: { frames: [12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27], fps: 8 , loop: false }
ai:
type: corridor_wanderer
direction_choice: random
initial_direction: east
# Estat SPAWN: la mòmia s'instancia visible al pixel_rect_ inicial just
# quan GameScene crida `spawnPendingActors()` (a la subfase text-exit
# de ROOM_INTRO, fase 4). El `triggerSpawnExit` es dispara llavors
# mateix; passats `duration_s` segons transita a ROAMING i empieza a
# deambular. Sense aquest bloc, default 0 ⇒ ACTIVE immediat.
spawn:
duration_s: 1.0
# Estat SPAWN: la mòmia s'instancia al pixel_rect_ inicial quan
# GameScene crida `spawnPendingActors()` (subfase text-exit de
# ROOM_INTRO, fase 4) amb l'animació `raise` activa. La duració del
# SPAWN la marca la pròpia animació (no-loop): al `completed()`,
# transita a ROAMING i comença a deambular.
stats:
speed: 30
damage: 1
+4 -7
View File
@@ -30,10 +30,10 @@ animations:
stun_east: { frames: [18] }
stun_west: { frames: [18], flip_h: true }
walk_south: { frames: [0, 1, 2, 3], frame_ms: 100 }
walk_north: { frames: [4, 5, 6, 7], frame_ms: 100 }
walk_east: { frames: [8, 9, 10, 11], frame_ms: 100 }
walk_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true }
walk_south: { frames: [1, 2, 3, 0], frame_ms: 100 }
walk_north: { frames: [5, 6, 7, 4], frame_ms: 100 }
walk_east: { frames: [9, 10, 11, 8], frame_ms: 100 }
walk_west: { frames: [9, 10, 11, 8], frame_ms: 100, flip_h: true }
hurt_south: { frames: [0, 1, 2, 3], frame_ms: 100 }
hurt_north: { frames: [4, 5, 6, 7], frame_ms: 100 }
@@ -97,13 +97,10 @@ turn:
south_to_north: west
# Salt vertical (px de z, no toca la posició al món).
# `shadow_y_offset`: puja la sombra per compensar la franja
# transparent sota els peus del sprite.
jump:
impulse: 200
gravity: 600
landing_stun_ms: 400
shadow_y_offset: 3
sound: jump.wav
land_sound: land.wav
+4 -7
View File
@@ -30,10 +30,10 @@ animations:
stun_east: { frames: [18] }
stun_west: { frames: [18], flip_h: true }
walk_south: { frames: [0, 1, 2, 3], frame_ms: 100 }
walk_north: { frames: [4, 5, 6, 7], frame_ms: 100 }
walk_east: { frames: [8, 9, 10, 11], frame_ms: 100 }
walk_west: { frames: [8, 9, 10, 11], frame_ms: 100, flip_h: true }
walk_south: { frames: [1, 2, 3, 0], frame_ms: 100 }
walk_north: { frames: [5, 6, 7, 4], frame_ms: 100 }
walk_east: { frames: [9, 10, 11, 8], frame_ms: 100 }
walk_west: { frames: [9, 10, 11, 8], frame_ms: 100, flip_h: true }
hurt_south: { frames: [0, 1, 2, 3], frame_ms: 100 }
hurt_north: { frames: [4, 5, 6, 7], frame_ms: 100 }
@@ -97,13 +97,10 @@ turn:
south_to_north: west
# Salt vertical (px de z, no toca la posició al món).
# `shadow_y_offset`: puja la sombra per compensar la franja
# transparent sota els peus del sprite.
jump:
impulse: 200
gravity: 600
landing_stun_ms: 400
shadow_y_offset: 3
sound: jump.wav
land_sound: land.wav
Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 B

After

Width:  |  Height:  |  Size: 1.6 KiB

+14 -2
View File
@@ -20,11 +20,23 @@ flags:
interactable: true
animations:
closed: { frames: [0], fps: 1, loop: true }
opening: { frames: [0], fps: 6, loop: false }
open: { frames: [0], fps: 1, loop: true }
opening: { frames: [1, 2], fps: 6, loop: false }
open: { frames: [3], fps: 1, loop: true }
behaviour:
states: [CLOSED, OPENING, OPEN]
initial_state: CLOSED
on_perimeter_complete: OPENING
on_opening_end: OPEN
emits_event_on_open: sarcophagus_opened
# Sons opcionals. `opening` es dispara un sol cop en la transició
# CLOSED → OPENING (quan el jugador completa el perímetre). Si la clau
# manca, no sona res. El basename val tant de clau al Resource::Cache
# com de path (`Defaults::Paths::AUDIO_SOUNDS_DIR + key`).
#
# `opening_pitch_spread`: variació uniforme del ratio de reproducció
# centrada a 1.0. Amb 0.05 cada obertura sona entre 0.95× i 1.05× la
# velocitat normal (puja/baixa també el to, efecte arcade clàssic).
# 0 ⇒ sempre 1.0 (sense variació).
sound:
opening: open_sarc.wav
opening_pitch_spread: 0.5
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 B

+1 -1
View File
@@ -14,7 +14,7 @@ input:
shader_off: "Shaders desactivats"
vsync_on: "VSync activat"
vsync_off: "VSync desactivat"
screenshot: "Captura"
screenshot: "Imatge {} guardada a {}"
shader: "Shader"
preset: "Preset"
scaling_disabled: "Escalat sense"
+1 -1
View File
@@ -14,7 +14,7 @@ input:
shader_off: "Shaders off"
vsync_on: "VSync on"
vsync_off: "VSync off"
screenshot: "Screenshot"
screenshot: "Image {} saved at {}"
shader: "Shader"
preset: "Preset"
scaling_disabled: "Scaling off"
+5 -2
View File
@@ -43,8 +43,11 @@ text:
# text i decreix amb l'exit_curve. `color` són tres canals RGB (0..255);
# `alpha` és la transparència del blend (0 = invisible, 255 = opac).
text_panel:
color: [36, 182, 170]
alpha: 128
# Violeta cerimonial: contrasta amb l'or del text (room_text), no
# compite amb el blau del bloc P2 del HUD, i deixa la sorra de les
# sales respirar al darrere.
color: [72, 36, 85]
alpha: 160
height_px: 32
# Sub-FSM de game over: dispara CONTINUE? quan tots els jugadors
+16
View File
@@ -136,6 +136,22 @@ void Audio::playSound(Ja::Sound* sound, Group group) {
}
}
// Variant amb velocitat (i to) escalats. Apliquem el ratio al canal
// just retornat per `playSound`: així el `SDL_AudioStream` recent creat
// processa tot el sample amb el ratio des del primer pull del callback.
// Si l'engine torna -1 (sense canal lliure) o el so no existeix, no fem
// la crida al ratio — sense efectes col·laterals.
// NOLINTNEXTLINE(readability-make-member-function-const)
void Audio::playSound(const std::string& name, Group group, float speed) {
if (!sound_enabled_) { return; }
auto* sound = AudioResource::getSound(name);
if (sound == nullptr) { return; }
const int CH = engine_->playSound(sound, 0, static_cast<int>(group));
if (CH >= 0 && speed != 1.0F) {
engine_->setChannelSpeed(CH, speed);
}
}
// Reprodueix un so processat per un eco definit a sounds.yaml. Si el preset no
// existeix o l'engine retorna -1 (cap de canals d'efecte plé), cau a playSound
// sec — l'usuari sent el so encara que la cua no s'apliqui.
+6
View File
@@ -85,6 +85,12 @@ class Audio {
// --- Control de sons ---
void playSound(const std::string& name, Group group = Group::GAME); // Reproduir so puntual per nom (muta globals de Ja)
void playSound(Ja::Sound* sound, Group group = Group::GAME); // Reproduir so puntual per punter (muta globals de Ja)
// Reprodueix un so amb la velocitat (i to) escalats per `speed`:
// 1.0 = normal, 0.95 ≈ -5% (més greu i lent), 1.05 ≈ +5% (més
// agut i ràpid). Mateixa semàntica que `setMusicSpeed`. Útil per a
// variacions subtils que eviten la fatiga d'escoltar el mateix
// sample idèntic (p.ex. obertures de sarcòfag, picks d'ítems).
void playSound(const std::string& name, Group group, float speed);
// Reprodueix un so processat per un efecte definit a data/config/sounds.yaml
// (preset_name busca a SoundEffectsConfig). Si el preset no existeix
// o el motor està al cap de canals amb efecte, fa fallback a playSound
+6
View File
@@ -430,6 +430,12 @@ namespace Ja {
return channel;
}
void Engine::setChannelSpeed(const int channel, const float ratio) {
if (channel < 0 || channel >= MAX_SIMULTANEOUS_CHANNELS) { return; }
if (channels_[channel].stream == nullptr) { return; }
SDL_SetAudioStreamFrequencyRatio(channels_[channel].stream, ratio);
}
void Engine::pauseChannel(const int channel) {
forEachTargetChannel(channel, [](Channel& ch) {
if (ch.state == ChannelState::PLAYING) {
+8
View File
@@ -192,6 +192,14 @@ namespace Ja {
// --- So ---
auto playSound(Sound* sound, int loop = 0, int group = 0) -> int;
auto playSoundOnChannel(Sound* sound, int channel, int loop = 0, int group = 0) -> int;
// Ajusta la velocitat de reproducció d'un canal actiu via
// `SDL_SetAudioStreamFrequencyRatio`. 1.0 = normal. Igual que a
// `setMusicSpeed`, puja/baixa el to junt amb la velocitat
// (efecte "chipmunk"); per a SFX curts arcade és el que volem.
// No-op si el canal no està actiu. Cridar-lo just després de
// `playSound`/`playSoundOnChannel` perquè el ratio cobreixi
// tota la reproducció.
void setChannelSpeed(int channel, float ratio);
// Reproducció amb so processat per un efecte. Retorna el canal
// assignat o -1 si no queden slots d'efecte (MAX_EFFECT_CHANNELS).
// El sound original només s'usa per consultar el spec/buffer; el
+17 -5
View File
@@ -3,6 +3,7 @@
#include <SDL3/SDL.h>
#include <algorithm>
#include <format>
#include <string>
#include "core/input/key_config.hpp"
@@ -69,15 +70,26 @@ namespace GlobalInputs {
notifyLocale(NEW_VSYNC ? "input.vsync_on" : "input.vsync_off");
}
// Volca el canvas ARGB actual a PNG dins de `screenshots/`. Si ha
// fallat l'escriptura, el handler no mostra notificació (retorn buit).
// Volca el canvas ARGB actual a PNG dins del directori de
// captures (configurat pel Director). La notificació informa
// del nom i de la ruta absoluta — així l'usuari sap on anar
// a buscar-la encara que el binari s'executi des d'un sitio
// estrany. Si l'escriptura falla, no es mostra notificació.
void onScreenshot() {
const auto* buffer = Screen::get().getCanvasArgb();
const int W = Screen::get().getCanvasWidth();
const int H = Screen::get().getCanvasHeight();
const std::string FILENAME = Screenshot::save(buffer, W, H);
if (!FILENAME.empty()) {
Notification::notify(Locale::get().text("input.screenshot") + ' ' + FILENAME);
const auto R = Screenshot::save(buffer, W, H);
if (!R.filename.empty()) {
// `make_format_args` retorna un store que conté referències
// als arguments; cal lligar-lo a una lvalue local perquè
// sobrevisqui fins que `vformat` el consumeixi. Passar-lo
// directament a `vformat` és UB en alguns compiladors
// (DR de C++20 / fix de C++23) i ha provocat un segfault
// post-captura en aquest projecte.
const std::string TEMPLATE = Locale::get().text("input.screenshot");
auto args = std::make_format_args(R.filename, R.folder);
Notification::notify(std::vformat(TEMPLATE, args));
}
}
+43
View File
@@ -0,0 +1,43 @@
#include "core/rendering/effects/shadow.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#include "core/rendering/surface.hpp"
#include "core/resources/resource_cache.hpp"
namespace Effects::Shadow {
void render(int center_x, int feet_y, float height_factor) {
// Lookup únic al primer ús: la Surface viu a Cache durant tota la
// sessió, així evitem la cerca lineal a cada frame.
static const auto SURFACE = Resource::Cache::get().getSurface(CACHE_KEY);
const int FRAME_COUNT = SURFACE->getWidth() / FRAME_SIZE;
if (FRAME_COUNT <= 0) { return; }
// Mapping a bandes uniformes: factor 0 → últim (gran), factor 1 →
// primer (petit). Per a 3 fotogrames: [0, 1/3) → 2, [1/3, 2/3) → 1,
// [2/3, 1] → 0. Generalitza a qualsevol nombre de fotogrames per
// si afegim mides intermèdies.
const float CLAMPED = std::clamp(height_factor, 0.0F, 1.0F);
int frame_index = static_cast<int>((1.0F - CLAMPED) * static_cast<float>(FRAME_COUNT));
frame_index = std::clamp(frame_index, 0, FRAME_COUNT - 1);
SDL_FRect clip{
static_cast<float>(frame_index * FRAME_SIZE),
0.0F,
static_cast<float>(FRAME_SIZE),
static_cast<float>(FRAME_SIZE),
};
// Ancoratge: vora inferior del fotograma a `feet_y` — així el
// quadrat 16x16 del sprite encaixa exactament al 16x16 inferior
// d'una entitat 16x16/16x32 centrada en `center_x`.
const int X = center_x - (FRAME_SIZE / 2);
const int Y = feet_y - FRAME_SIZE;
SURFACE->render(X, Y, &clip);
}
} // namespace Effects::Shadow
+25
View File
@@ -0,0 +1,25 @@
#pragma once
namespace Effects::Shadow {
// Mida de cada fotograma del sprite compartit de la sombra.
// El sprite és una tira horitzontal de fotogrames de 16x16, ordenats
// del més petit (índex 0) al més gran (últim índex).
constexpr int FRAME_SIZE = 16;
// Clau amb què `Resource::Cache` té carregada la Surface compartida.
// El precarrega el Director al boot.
constexpr const char* CACHE_KEY = "shadow";
// Pinta la sombra al canvas. `height_factor` selecciona el fotograma:
// 0.0 (entitat al terra) → fotograma més gran, 1.0 (pic del salt o
// entitat més alta del terra) → fotograma més petit. La tira es
// divideix en bandes uniformes; valors fora de [0, 1] es clampen.
//
// El fotograma s'ancora al quadrat 16x16 inferior d'una entitat de
// 16x16/16x32 centrada en `center_x` amb peus a `feet_y`: la vora
// inferior del sprite queda exactament a `feet_y`. La posició
// visual de l'oval dins del fotograma la decideix l'artista al GIF.
void render(int center_x, int feet_y, float height_factor);
} // namespace Effects::Shadow
+48 -21
View File
@@ -12,11 +12,52 @@
namespace {
// Parteix `text` en una llista de línies que caben cadascuna dins
// `max_width`. Algoritme de projecte_2026: tall per espais, la paraula
// que no cap passa a la línia següent. Les paraules aïllades més
// llargues que `max_width` es queden a la seva pròpia línia sense
// trencar-les (cas improbable amb els pretty_names de gamepad retallats
// a ~25 caràcters).
// Trenca una "paraula" llarga (sense espais) en trossos contigus
// que càpiguen a `max_width`. Si un sol caràcter ja excedeix
// (font enorme), el deixa sol per garantir progrés. La part final
// que encara cap queda a `current` per a fusionar-se amb paraules
// posteriors via espai.
void hardWrapWord(const std::string& word, int max_width, const Text& font, int kerning, std::vector<std::string>& lines, std::string& current) {
std::string buf;
for (char c : word) {
std::string candidate = buf;
candidate += c;
if (buf.empty() || font.length(candidate, kerning) <= max_width) {
buf = std::move(candidate);
} else {
lines.push_back(buf);
buf = std::string(1, c);
}
}
current = std::move(buf);
}
// Posa la `word` a la `current` line si hi cap (amb separador
// implícit si current no està buida), o fa flush + reintenta a
// línia neta. Si tampoc cap sola, hard-wrap caràcter a caràcter.
void appendWord(const std::string& word, int max_width, const Text& font, int kerning, std::vector<std::string>& lines, std::string& current) {
if (!current.empty()) {
std::string candidate = current;
candidate += ' ';
candidate += word;
if (font.length(candidate, kerning) <= max_width) {
current = std::move(candidate);
return;
}
lines.push_back(current);
current.clear();
}
if (font.length(word, kerning) <= max_width) {
current = word;
} else {
hardWrapWord(word, max_width, font, kerning, lines, current);
}
}
// `max_width`. Tall per espais; les paraules més llargues que
// `max_width` (rutes, URLs...) es trenquen caràcter a caràcter
// — sense això, una caixa que excedís el canvas faria que
// `fillRect` rebés rangs invertits i petés.
auto wrapToWidth(const std::string& text, int max_width, const Text& font, int kerning) -> std::vector<std::string> {
std::vector<std::string> lines;
if (text.empty()) {
@@ -30,26 +71,12 @@ namespace {
const std::size_t SPACE_POS = text.find(' ', i);
const bool LAST = (SPACE_POS == std::string::npos);
const std::string WORD = text.substr(i, LAST ? std::string::npos : SPACE_POS - i);
if (current.empty()) {
current = WORD;
} else {
std::string candidate = current;
candidate += ' ';
candidate += WORD;
if (font.length(candidate, kerning) <= max_width) {
current = std::move(candidate);
} else {
lines.push_back(current);
current = WORD;
}
}
appendWord(WORD, max_width, font, kerning, lines, current);
if (LAST) { break; }
i = SPACE_POS + 1;
}
lines.push_back(current);
if (!current.empty()) { lines.push_back(current); }
return lines;
}
+6
View File
@@ -226,6 +226,12 @@ void Screen::fillRect(int x, int y, int w, int h, std::uint32_t argb) {
const int Y0 = std::max(y, CLIP.y);
const int X1 = std::min(x + w, CLIP.x + CLIP.w);
const int Y1 = std::min(y + h, CLIP.y + CLIP.h);
// Bail-out defensiu: si el rang és buit o invertit després del clip,
// `std::fill(first, last)` amb `first > last` és UB i a la pràctica
// entra en bucle infinit (vist en un crash a l'overflow del Notifier
// quan la caixa quedava més ampla que el canvas). El `blendRect`
// d'aquí baix ja és segur perquè usa `for(ix; ix < X1; ...)`.
if (X1 <= X0 || Y1 <= Y0) { return; }
for (int iy = Y0; iy < Y1; ++iy) {
std::uint32_t* row = canvas_.data() + (static_cast<std::ptrdiff_t>(iy) * canvas_width_);
std::fill(row + X0, row + X1, argb);
+32 -20
View File
@@ -5,6 +5,7 @@
#include <ctime>
#include <filesystem>
#include <string>
#include <utility>
#include <vector>
#include "external/stb_image_write.h"
@@ -13,21 +14,18 @@ namespace Screenshot {
namespace {
// Crea la carpeta `screenshots/` si no existeix i torna la ruta
// completa al fitxer. El paràmetre `filename` rep el nom nu (sense
// directori) per poder mostrar-lo a la notificació.
auto generateFilePath(std::string& filename) -> std::string {
std::filesystem::create_directories("screenshots");
// Estat de mòdul: ruta base on s'escriu la subcarpeta
// `screenshots/`. Buida per defecte ⇒ relativa al CWD (fallback
// si el Director no ha pogut establir la ruta per plataforma).
std::string g_base_dir; // NOLINT(*-avoid-non-const-global-variables)
const auto NOW = std::chrono::system_clock::now();
const std::time_t TIME = std::chrono::system_clock::to_time_t(NOW);
const std::tm* tm = std::localtime(&TIME);
char timestamp[20];
std::strftime(timestamp, sizeof(timestamp), "%Y%m%d_%H%M%S", tm);
filename = std::string("scr_") + timestamp + ".png";
return "screenshots/" + filename;
// Construeix la ruta del directori on van les captures:
// `<base>/screenshots/`. Si la base és buida, retorna
// "screenshots/" relativa al CWD.
auto screenshotsDir() -> std::string {
if (g_base_dir.empty()) { return "screenshots/"; }
const bool ENDS_WITH_SEP = (g_base_dir.back() == '/' || g_base_dir.back() == '\\');
return g_base_dir + (ENDS_WITH_SEP ? "" : "/") + "screenshots/";
}
// Converteix ARGB8888 → RGBA8888 in-place i escriu el PNG. stb_image_write
@@ -47,14 +45,28 @@ namespace Screenshot {
} // namespace
auto save(const std::uint32_t* buffer, int width, int height) -> std::string {
void setBaseDir(std::string dir) {
g_base_dir = std::move(dir);
}
auto save(const std::uint32_t* buffer, int width, int height) -> Result {
const std::string FOLDER = screenshotsDir();
std::error_code ec;
std::filesystem::create_directories(FOLDER, ec);
const auto NOW = std::chrono::system_clock::now();
const std::time_t TIME = std::chrono::system_clock::to_time_t(NOW);
const std::tm* tm = std::localtime(&TIME);
char timestamp[20];
std::strftime(timestamp, sizeof(timestamp), "%Y%m%d_%H%M%S", tm);
const std::string FILENAME = std::string("scr_") + timestamp + ".png";
const std::string FILEPATH = FOLDER + FILENAME;
// Còpia local per no tocar el canvas original durant la conversió.
std::vector<std::uint32_t> copy(buffer, buffer + (static_cast<size_t>(width) * height));
std::string filename;
const std::string FILEPATH = generateFilePath(filename);
return writePng(copy.data(), width, height, FILEPATH) ? filename : "";
if (!writePng(copy.data(), width, height, FILEPATH)) { return {}; }
return {.filename = FILENAME, .folder = FOLDER};
}
} // namespace Screenshot
+23 -4
View File
@@ -3,11 +3,30 @@
#include <cstdint>
#include <string>
// Volca un buffer ARGB8888 a un fitxer PNG dins de la carpeta `screenshots/`
// (relativa al CWD). El nom es genera amb un timestamp i la funció retorna
// el nom del fitxer creat, o una cadena buida si l'escriptura ha fallat.
// Volca un buffer ARGB8888 a un fitxer PNG dins de la subcarpeta
// `screenshots/` del directori configurat amb `setBaseDir`. El nom es
// genera amb un timestamp i la funció retorna el basename i la ruta
// del directori on s'ha escrit (o un Result amb `filename` buit si
// l'escriptura ha fallat).
namespace Screenshot {
auto save(const std::uint32_t* buffer, int width, int height) -> std::string;
// Configura el directori base on s'escriuran les captures (s'hi
// crea una subcarpeta `screenshots/`). El Director el crida una
// vegada al boot amb `system_folder_` perquè les captures vagin
// al mateix lloc per plataforma que la resta de fitxers generats
// (config.yaml, gamepad_configs.yaml, shaders/, etc.). Si no es
// crida, el fallback és el CWD — comportament antic.
void setBaseDir(std::string dir);
// Resultat d'una captura: el `filename` és el basename del PNG
// (per a notificacions/títol) i el `folder` és la ruta absoluta
// on s'ha guardat (per a poder dir "guardada a {folder}"). Si
// l'escriptura falla, ambdós camps tornen buits.
struct Result {
std::string filename;
std::string folder;
};
auto save(const std::uint32_t* buffer, int width, int height) -> Result;
} // namespace Screenshot
+13
View File
@@ -18,9 +18,11 @@
#include "core/input/key_config.hpp"
#include "core/input/mouse.hpp"
#include "core/locale/locale.hpp"
#include "core/rendering/effects/shadow.hpp"
#include "core/rendering/notifier.hpp"
#include "core/rendering/render_info.hpp"
#include "core/rendering/screen.hpp"
#include "core/rendering/screenshot.hpp"
#include "core/rendering/surface.hpp"
#include "core/rendering/text.hpp"
#include "core/resources/resource_cache.hpp"
@@ -162,6 +164,11 @@ Director::Director() {
Resource::Cache::get().loadSurface(ROOM_TEXT_FONT_GIF_KEY, UiConfig::get().roomTextFont().gif);
Resource::Cache::get().getSurface(ROOM_TEXT_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui"));
// Sprite compartit de la sombra (tira horitzontal de fotogrames de
// 16x16, de petit a gran). El consumeix `Effects::Shadow::render`,
// que el reaprofiten Player i qualsevol entitat futura compatible.
Resource::Cache::get().loadSurface(Effects::Shadow::CACHE_KEY, "data/entities/shadow/shadow.gif");
// Pistes de música del cicle de sala (intro/playing/outro). Es preloden
// ací perquè formen part del preload d'assets, com la font i la paleta;
// la lògica de quina sona en cada estat viu a `GameScene`.
@@ -373,6 +380,12 @@ void Director::createSystemFolder() {
if (error) {
Log::error("Director", "no s'ha pogut crear {}: {}", system_folder_, error.message());
}
// Les captures de pantalla viuen sota la mateixa carpeta per
// plataforma. Sense aquesta crida, `Screenshot::save` cauria al
// CWD del procés (raïz del projecte si llances `build/aee_arcade`
// a mà, lloc imprevisible en una release de macOS/Windows).
Screenshot::setBaseDir(system_folder_);
}
void Director::processEvents() {
+24 -4
View File
@@ -82,8 +82,15 @@ UiConfig::UiConfig()
menu_cursor_(Defaults::UI::MENU_CURSOR),
menu_disabled_(Defaults::UI::MENU_DISABLED),
hud_bg_(Defaults::UI::HUD_BG),
hud_label_(Defaults::UI::HUD_LABEL),
hud_value_(Defaults::UI::HUD_VALUE),
hud_p1_label_(Defaults::UI::HUD_P1_LABEL),
hud_p1_value_(Defaults::UI::HUD_P1_VALUE),
hud_p1_shadow_(Defaults::UI::HUD_P1_SHADOW),
hud_center_label_(Defaults::UI::HUD_CENTER_LABEL),
hud_center_value_(Defaults::UI::HUD_CENTER_VALUE),
hud_center_shadow_(Defaults::UI::HUD_CENTER_SHADOW),
hud_p2_label_(Defaults::UI::HUD_P2_LABEL),
hud_p2_value_(Defaults::UI::HUD_P2_VALUE),
hud_p2_shadow_(Defaults::UI::HUD_P2_SHADOW),
hud_flash_(Defaults::UI::HUD_FLASH),
room_text_(Defaults::UI::ROOM_TEXT),
room_text_outline_(Defaults::UI::ROOM_TEXT_OUTLINE),
@@ -218,13 +225,26 @@ void UiConfig::load(const std::string& file_path) {
readColorIndex(c, "menu_cursor", menu_cursor_);
readColorIndex(c, "menu_disabled", menu_disabled_);
readColorIndex(c, "hud_bg", hud_bg_);
readColorIndex(c, "hud_label", hud_label_);
readColorIndex(c, "hud_value", hud_value_);
readColorIndex(c, "hud_flash", hud_flash_);
readColorIndex(c, "room_text", room_text_);
readColorIndex(c, "room_text_outline", room_text_outline_);
}
// Famílies de color del HUD: blocs `hud_p1`, `hud_center`,
// `hud_p2` amb claus `label`, `value`, `shadow`. Cada bloc i
// cada clau són opcionals — el que manqui cau al default
// compilat a `Defaults::UI`.
auto read_hud_family = [&](const std::string& key, Uint8& label, Uint8& value, Uint8& shadow) {
if (!yaml.contains(key)) { return; }
const auto& f = yaml[key];
readColorIndex(f, "label", label);
readColorIndex(f, "value", value);
readColorIndex(f, "shadow", shadow);
};
read_hud_family("hud_p1", hud_p1_label_, hud_p1_value_, hud_p1_shadow_);
read_hud_family("hud_center", hud_center_label_, hud_center_value_, hud_center_shadow_);
read_hud_family("hud_p2", hud_p2_label_, hud_p2_value_, hud_p2_shadow_);
if (yaml.contains("timings")) {
const auto& t = yaml["timings"];
readField(t, "notification_ms", notification_ms_);
+22 -4
View File
@@ -66,8 +66,19 @@ class UiConfig {
[[nodiscard]] auto menuCursor() const -> Uint8 { return menu_cursor_; }
[[nodiscard]] auto menuDisabled() const -> Uint8 { return menu_disabled_; }
[[nodiscard]] auto hudBg() const -> Uint8 { return hud_bg_; }
[[nodiscard]] auto hudLabel() const -> Uint8 { return hud_label_; }
[[nodiscard]] auto hudValue() const -> Uint8 { return hud_value_; }
// Famílies de color del HUD per bloc (LEFT/CENTER/RIGHT). Cada
// família té label (etiqueta apagada), value (valor destacat) i
// shadow (to fosc per a la sombra-embossing). El flash és comú a
// les tres famílies (groc neó al guanyar punts).
[[nodiscard]] auto hudP1Label() const -> Uint8 { return hud_p1_label_; }
[[nodiscard]] auto hudP1Value() const -> Uint8 { return hud_p1_value_; }
[[nodiscard]] auto hudP1Shadow() const -> Uint8 { return hud_p1_shadow_; }
[[nodiscard]] auto hudCenterLabel() const -> Uint8 { return hud_center_label_; }
[[nodiscard]] auto hudCenterValue() const -> Uint8 { return hud_center_value_; }
[[nodiscard]] auto hudCenterShadow() const -> Uint8 { return hud_center_shadow_; }
[[nodiscard]] auto hudP2Label() const -> Uint8 { return hud_p2_label_; }
[[nodiscard]] auto hudP2Value() const -> Uint8 { return hud_p2_value_; }
[[nodiscard]] auto hudP2Shadow() const -> Uint8 { return hud_p2_shadow_; }
[[nodiscard]] auto hudFlash() const -> Uint8 { return hud_flash_; }
[[nodiscard]] auto roomText() const -> Uint8 { return room_text_; }
[[nodiscard]] auto roomTextOutline() const -> Uint8 { return room_text_outline_; }
@@ -209,8 +220,15 @@ class UiConfig {
Uint8 menu_cursor_;
Uint8 menu_disabled_;
Uint8 hud_bg_;
Uint8 hud_label_;
Uint8 hud_value_;
Uint8 hud_p1_label_;
Uint8 hud_p1_value_;
Uint8 hud_p1_shadow_;
Uint8 hud_center_label_;
Uint8 hud_center_value_;
Uint8 hud_center_shadow_;
Uint8 hud_p2_label_;
Uint8 hud_p2_value_;
Uint8 hud_p2_shadow_;
Uint8 hud_flash_;
Uint8 room_text_;
Uint8 room_text_outline_;
+4 -2
View File
@@ -739,8 +739,10 @@ void Editor::renderEditorHud(const EntityManager& entities) const {
? "(" + std::to_string(cursor_col_) + "," + std::to_string(cursor_row_) + ")"
: "(--,--)";
const auto LBL = UiConfig::get().hudLabel();
const auto VAL = UiConfig::get().hudValue();
// El HUD ara té tres famílies (P1/CENTER/P2); l'editor n'agafa la
// del CENTER (ambre/or) per ser neutra i contrastar amb el mapa.
const auto LBL = UiConfig::get().hudCenterLabel();
const auto VAL = UiConfig::get().hudCenterValue();
const auto FLASH = UiConfig::get().hudFlash();
const int KERN = UiConfig::get().hudFont().kerning;
// Mateixos Y que el HUD del joc per simetria visual: línia 1 = 4,
+28 -10
View File
@@ -106,7 +106,8 @@ Mummy::Mummy(const fkyaml::node& yaml)
: anim_walk_south_(buildAnimation(yaml, "walk_south")),
anim_walk_north_(buildAnimation(yaml, "walk_north")),
anim_walk_east_(buildAnimation(yaml, "walk_east")),
anim_walk_west_(buildAnimation(yaml, "walk_west")) {
anim_walk_west_(buildAnimation(yaml, "walk_west")),
anim_raise_(buildAnimation(yaml, "raise")) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("mummy");
collides_ = true;
interactable_ = false;
@@ -141,16 +142,18 @@ Mummy::Mummy(const fkyaml::node& yaml)
changeDirection(0.0F, 1.0F);
}
// Estat SPAWN inicial: la mòmia naix visible i quieta, esperant que el
// GameScene li armi el comptador via `triggerSpawnExit()` (cridat al
// final de la subfase text-exit de ROOM_INTRO). Si el YAML no declara
// `spawn.duration_s`, default 0 ⇒ al primer update post-trigger passa
// directament a ACTIVE.
float spawn_duration_s = 0.0F;
if (yaml.contains("spawn") && yaml["spawn"].contains("duration_s")) {
spawn_duration_s = yaml["spawn"]["duration_s"].get_value<float>();
// Estat SPAWN inicial: la mòmia naix en SPAWNING amb l'animació
// `raise` activa (16 frames @ 8fps, no-loop). La duració del SPAWN
// la marca la pròpia animació — `Mummy::update` la tickeja, i quan
// `completed()`, crida `endSpawn()` i restaura la `walk_*` per la
// direcció actual. Passem 0 al countdown del DynamicHazard perquè
// no s'utilitza en aquest cas (el nostre `update` retorna abans).
enterSpawn(0.0F);
anim_ = anim_raise_;
if (anim_.has_value()) {
anim_->reset();
flip_ = anim_->flipMode();
}
enterSpawn(spawn_duration_s);
// Configuració de l'estat HURT: opcional. Si el bloc no apareix, la
// mòmia no entrarà mai a HURT efectiu (defensiu); el WhipAttack la
@@ -692,6 +695,21 @@ void Mummy::hurtStep(float delta_time) {
}
void Mummy::update(float delta_time) {
if (isSpawning()) {
// SPAWN dirigit per animació: la `raise` és no-loop, així que
// `completed()` ens marca el final. Mentre dura, NO cridem
// `DynamicHazard::update`: la base hi té el countdown i ja
// hauria saltat a ACTIVE (passem `enterSpawn(0)`), i el `step()`
// d'IA mouria la mòmia abans de "aixecar-se". En completar,
// tornem a la `walk_*` que toca segons la direcció ja triada
// al ctor (initial_direction de l'IA).
if (anim_.has_value()) { anim_->tick(delta_time); }
if (anim_.has_value() && anim_->completed()) {
endSpawn();
changeDirection(getDirX(), getDirY());
}
return;
}
if (state_ == State::HURT) {
// Avancem l'animació inline (no podem cridar `Entity::update` ni
// `DynamicHazard::update`: el primer salta una capa virtual i el
+6
View File
@@ -177,6 +177,12 @@ class Mummy : public DynamicHazard {
AnimationComponent anim_walk_east_;
AnimationComponent anim_walk_west_;
// Animació d'aparició (no-loop). Mentre `isSpawning()`, `update()`
// la tickeja sense moure la mòmia; quan `completed()`, la mòmia
// transita a ACTIVE via `endSpawn()` i conmuta a la walk_* que
// toca segons direcció.
AnimationComponent anim_raise_;
// Estat HURT i la seua configuració. `hurt_speed_` és l'efectiva
// (resultat d'aplicar el `speed` del bloc `hurt:` o, si val 0, la
// velocitat normal de la mòmia).
@@ -1,14 +1,26 @@
#include "game/entities/concrete/sarcophagus.hpp"
#include <random>
#include <string>
#include <vector>
#include "core/audio/audio.hpp"
#include "core/resources/resource_cache.hpp"
#include "game/entities/entity_factory.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/entities/event_bus.hpp"
#include "utils/defaults.hpp"
namespace {
// RNG local al mòdul, mateix patró que `mummy.cpp`: llavor fixa per
// facilitar la depuració. Si algun dia volem aleatoritat real entre
// partides, prou amb llegir-la de std::random_device.
auto rng() -> std::mt19937& {
static std::mt19937 k_rng_{0xCAFEU};
return k_rng_;
}
auto buildAnimation(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 1.0F;
@@ -57,6 +69,20 @@ Sarcophagus::Sarcophagus(const fkyaml::node& yaml)
interactable_ = true;
readTileSize(yaml, tile_size_w_, tile_size_h_);
anim_ = anim_closed_;
// Sons opcionals (bloc `sound:` del YAML). El basename val tant de
// clau al Resource::Cache com de path dins `Defaults::Paths::AUDIO_SOUNDS_DIR`.
// Mateix patró que `mummy.hurt.sound` i `player.jump.sound`.
if (yaml.contains("sound")) {
const auto& s = yaml["sound"];
if (s.contains("opening")) {
opening_sound_key_ = s["opening"].get_value<std::string>();
Resource::Cache::get().loadSound(opening_sound_key_, std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + opening_sound_key_);
}
if (s.contains("opening_pitch_spread")) {
opening_pitch_spread_ = s["opening_pitch_spread"].get_value<float>();
}
}
}
void Sarcophagus::place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked) {
@@ -122,6 +148,18 @@ void Sarcophagus::notifyTilePainted(int col, int row) {
state_ = State::OPENING;
anim_ = anim_opening_;
if (anim_.has_value()) { anim_->reset(); }
if (!opening_sound_key_.empty() && Audio::get() != nullptr) {
// Variació de pitch/velocitat per evitar la fatiga d'escoltar
// el mateix sample idèntic a cada obertura. Si l'spread és 0
// (default), reproduïm amb ratio 1.0; en cas contrari sortegem
// uniformement [1 - spread, 1 + spread].
if (opening_pitch_spread_ > 0.0F) {
std::uniform_real_distribution<float> dist(1.0F - opening_pitch_spread_, 1.0F + opening_pitch_spread_);
Audio::get()->playSound(opening_sound_key_, Audio::Group::GAME, dist(rng()));
} else {
Audio::get()->playSound(opening_sound_key_, Audio::Group::GAME);
}
}
}
}
@@ -2,6 +2,7 @@
#include <cstdint>
#include <functional>
#include <string>
#include <unordered_set>
#include "external/fkyaml_node.hpp"
@@ -83,5 +84,12 @@ class Sarcophagus : public Interactable {
AnimationComponent anim_opening_;
AnimationComponent anim_open_;
// Clau (basename del fitxer) al Resource::Cache del so que es dispara
// a la transició CLOSED → OPENING. Buit ⇒ no sona res.
std::string opening_sound_key_;
// Variació uniforme del ratio de reproducció al voltant de 1.0. Cada
// obertura sorteja [1 - spread, 1 + spread]. 0 ⇒ ratio fix a 1.0.
float opening_pitch_spread_{0.0F};
bool opened_event_emitted_{false};
};
+5
View File
@@ -96,6 +96,11 @@ class DynamicHazard : public Hazard {
// Arma el comptador del SPAWN. A partir d'aquí, cada `update(dt)`
// descompta `dt` fins arribar a 0 i transitar a ACTIVE.
void triggerSpawnExit() { spawn_exit_armed_ = true; }
// Transició directa a ACTIVE, sense passar pel countdown. Pensat
// per a subclasses amb SPAWN dirigit per animació: gestionen el
// seu propi `update()` mentre `isSpawning()` i criden ací quan la
// seua animació d'aparició ha completat.
void endSpawn() { spawn_state_ = SpawnState::ACTIVE; }
[[nodiscard]] auto isSpawning() const -> bool { return spawn_state_ == SpawnState::SPAWNING; }
[[nodiscard]] auto getSpawnDurationS() const -> float { return spawn_duration_s_; }
+8 -27
View File
@@ -13,6 +13,7 @@
#include "core/input/gamepad_manager.hpp"
#include "core/input/input_state.hpp"
#include "core/rendering/canvas.hpp"
#include "core/rendering/effects/shadow.hpp"
#include "core/rendering/screen.hpp"
#include "core/rendering/surface.hpp"
#include "core/resources/palette_loader.hpp"
@@ -179,7 +180,6 @@ void Player::readJumpBlock(const fkyaml::node& yaml) {
if (j.contains("impulse")) { jump_config.impulse_px_per_s = j["impulse"].get_value<float>(); }
if (j.contains("gravity")) { jump_config.gravity_px_per_s2 = j["gravity"].get_value<float>(); }
if (j.contains("landing_stun_ms")) { jump_config.landing_stun_ms = j["landing_stun_ms"].get_value<int>(); }
if (j.contains("shadow_y_offset")) { jump_config.shadow_y_offset = j["shadow_y_offset"].get_value<int>(); }
// Sons opcionals: el nom del fitxer (relatiu a Defaults::Paths::AUDIO_SOUNDS_DIR)
// val tant de clau al Resource::Cache com de basename per a la càrrega.
// Si el camp manca, la clau queda buida i `playSound` no es crida.
@@ -1435,36 +1435,17 @@ void Player::renderShadow() const {
// que `enterDying` netegi el flag.
if (falling_through_hole_) { return; }
// Sombra centrada al sprite, recolzada a la línia dels peus reals
// (ignorant `jump_z_`). L'amplada es redueix proporcional a l'altura
// del salt — al pic queda més estreta, donant la sensació de
// "elevació" sense necessitat de coordenades 3D reals. Si el
// jump_config té un divisor zero (cas degenerat), només dibuixem
// la sombra a mida plena per evitar nan.
// `height_factor`: 0 al terra → fotograma més gran, 1 al pic → més
// petit. Si el `jump_config` té un divisor zero (cas degenerat) caem
// a 0 per no propagar nan al renderer.
const auto PEAK = (jump_config.gravity_px_per_s2 > 0.0F)
? (jump_config.impulse_px_per_s * jump_config.impulse_px_per_s) / (2.0F * jump_config.gravity_px_per_s2)
: 0.0F;
const float NORM_Z = (PEAK > 0.0F) ? std::clamp(jump_z_ / PEAK, 0.0F, 1.0F) : 0.0F;
constexpr float MAX_SHRINK = 0.4F; // Fins a un 40% més estreta al pic.
const float SHRINK = 1.0F - (NORM_Z * MAX_SHRINK);
const float HEIGHT_FACTOR = (PEAK > 0.0F) ? std::clamp(jump_z_ / PEAK, 0.0F, 1.0F) : 0.0F;
constexpr int SHADOW_H = 3;
const auto SHADOW_W = static_cast<int>(static_cast<float>(base_size) * SHRINK);
const float CX = getPosX() + (static_cast<float>(sprite_w) * 0.5F);
const float GROUND_Y = getGroundFeetY();
const auto SX = static_cast<int>(CX - (static_cast<float>(SHADOW_W) * 0.5F));
// Pugem la sombra `shadow_y_offset` px per compensar la franja
// transparent que el sprite del jugador té sota els peus reals
// (perquè la silueta camine pel mig del passadís en falsa
// perspectiva). Sense aquest ajust, la sombra apareix per sota
// del passadís.
const auto SY = static_cast<int>(GROUND_Y) - SHADOW_H - jump_config.shadow_y_offset;
// ARGB negre amb alpha ~40%. `blendRect` mescla sobre el canvas; així
// la sombra fosqueja el sòl (pintat o no) sense tapar-lo del tot.
constexpr std::uint32_t SHADOW_ARGB = 0x00000000U;
constexpr std::uint8_t SHADOW_ALPHA = 100;
Screen::get().blendRect(SX, SY, SHADOW_W, SHADOW_H, SHADOW_ARGB, SHADOW_ALPHA);
const auto CENTER_X = static_cast<int>(getPosX() + (static_cast<float>(sprite_w) * 0.5F));
const auto FEET_Y = static_cast<int>(getGroundFeetY());
Effects::Shadow::render(CENTER_X, FEET_Y, HEIGHT_FACTOR);
}
// Sufix d'animació per cardinal — empat 1:1 amb l'enum `Facing` i el
-1
View File
@@ -633,7 +633,6 @@ class Player : public AnimatedSprite {
float impulse_px_per_s{0.0F}; // Velocitat vertical inicial.
float gravity_px_per_s2{0.0F}; // Acceleració descendent constant.
int landing_stun_ms{0}; // Stun en aterrar (no es mou ni salta).
int shadow_y_offset{0}; // Px que puja la sombra (compensa transparències al peu del sprite).
std::string sound_key; // Clau al Resource::Cache del so de despegue (buit = silenci).
std::string land_sound_key; // Clau del so d'aterratge (buit = silenci).
};
+47 -13
View File
@@ -40,6 +40,25 @@ namespace {
// Separació extra entre camps dins d'una mateixa línia.
constexpr int FIELD_GAP = 6; // px entre "Vides 3" i "Tres. 00"
// Desplaçament de l'ombra del text en píxels (sota-dreta). Petit
// perquè la font del HUD és 8 px d'alçada — qualsevol valor més
// gran tapa el següent caràcter.
constexpr int SHADOW_DX = 1;
constexpr int SHADOW_DY = 1;
// Helpers per pintar text amb ombra. Primer la sombra desplaçada,
// després el text principal al damunt — l'ordre garanteix que la
// sombra mai cobreix el text. El kerning queda al default del
// Text (1 px), igual que la resta del HUD.
void drawShadowed(Text& font, int x, int y, const std::string& s, Uint8 color, Uint8 shadow) {
font.writeColored(x + SHADOW_DX, y + SHADOW_DY, s, shadow);
font.writeColored(x, y, s, color);
}
void drawMonoShadowed(Text& font, int x, int y, const std::string& s, Uint8 color, Uint8 shadow, int cell_w) {
font.writeMonoDigits(x + SHADOW_DX, y + SHADOW_DY, s, shadow, cell_w);
font.writeMonoDigits(x, y, s, color, cell_w);
}
// Format zero-padded a 6 dígits per a la puntuació.
auto formatScore(int v) -> std::string {
const int CLAMPED = std::clamp(v, 0, 999999);
@@ -261,6 +280,16 @@ void Hud::renderBlock(BlockId id) const {
void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index, const std::string& label) const {
const auto& p = players_[static_cast<std::size_t>(player_index)];
// Família de color del bloc: P1 (vermell-crema-vermell-fosc) per al
// jugador 0, P2 (cian-blau-blau-marí) per al jugador 1. Mantenir el
// mateix matís a label/value/shadow dóna jerarquia visual coherent
// entre línies sense empastar (la sombra és un to fosc del mateix
// matís → efecte embossing).
const auto& ui = UiConfig::get();
const Uint8 LBL_C = (player_index == 0) ? ui.hudP1Label() : ui.hudP2Label();
const Uint8 VAL_C = (player_index == 0) ? ui.hudP1Value() : ui.hudP2Value();
const Uint8 SHD_C = (player_index == 0) ? ui.hudP1Shadow() : ui.hudP2Shadow();
// Slot inactiu amb prompt actiu: substituïm el render de zeros pel
// missatge "PULSA START / PARA JUGAR" o "ESPERA / PARA JUGAR" segons
// el mode fixat per la GameScene cada frame. El render de zeros
@@ -275,12 +304,12 @@ void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index
const int L2_W = font_->length(L2);
const int X1 = x + ((w - L1_W) / 2);
const int X2 = x + ((w - L2_W) / 2);
font_->writeColored(X1, y + LINE1_Y, L1, UiConfig::get().hudLabel());
font_->writeColored(X2, y + LINE2_Y, L2, UiConfig::get().hudValue());
drawShadowed(*font_, X1, y + LINE1_Y, L1, LBL_C, SHD_C);
drawShadowed(*font_, X2, y + LINE2_Y, L2, VAL_C, SHD_C);
return;
}
const Uint8 SCORE_C = (blk.flash_ms_left > 0) ? UiConfig::get().hudFlash() : UiConfig::get().hudValue();
const Uint8 SCORE_C = (blk.flash_ms_left > 0) ? ui.hudFlash() : VAL_C;
// --- Línia 1: "<label>-000000" centrada ---
const std::string LBL1 = label + SEPARATOR;
@@ -289,9 +318,9 @@ void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index
const int SC_W = font_->lengthMonoDigits(SCORE, MONO_DIGIT_W);
const int LINE1_W = LBL_W + SC_W;
int xx = x + ((w - LINE1_W) / 2);
font_->writeColored(xx, y + LINE1_Y, LBL1, UiConfig::get().hudLabel());
drawShadowed(*font_, xx, y + LINE1_Y, LBL1, LBL_C, SHD_C);
xx += LBL_W;
font_->writeMonoDigits(xx, y + LINE1_Y, SCORE, SCORE_C, MONO_DIGIT_W);
drawMonoShadowed(*font_, xx, y + LINE1_Y, SCORE, SCORE_C, SHD_C, MONO_DIGIT_W);
// --- Línia 2: "<vides>-<n> <tresors>-<n>" centrada ---
const std::string V_LBL = Locale::get().text("hud.lives") + SEPARATOR;
@@ -304,16 +333,21 @@ void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index
const int T_VW = font_->lengthMonoDigits(T_VAL, MONO_DIGIT_W);
const int LINE2_W = V_LW + V_VW + FIELD_GAP + T_LW + T_VW;
xx = x + ((w - LINE2_W) / 2);
font_->writeColored(xx, y + LINE2_Y, V_LBL, UiConfig::get().hudLabel());
drawShadowed(*font_, xx, y + LINE2_Y, V_LBL, LBL_C, SHD_C);
xx += V_LW;
font_->writeMonoDigits(xx, y + LINE2_Y, V_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W);
drawMonoShadowed(*font_, xx, y + LINE2_Y, V_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
xx += V_VW + FIELD_GAP;
font_->writeColored(xx, y + LINE2_Y, T_LBL, UiConfig::get().hudLabel());
drawShadowed(*font_, xx, y + LINE2_Y, T_LBL, LBL_C, SHD_C);
xx += T_LW;
font_->writeMonoDigits(xx, y + LINE2_Y, T_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W);
drawMonoShadowed(*font_, xx, y + LINE2_Y, T_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
}
void Hud::renderIdleCenter(int x, int y, int w) const {
const auto& ui = UiConfig::get();
const Uint8 LBL_C = ui.hudCenterLabel();
const Uint8 VAL_C = ui.hudCenterValue();
const Uint8 SHD_C = ui.hudCenterShadow();
// --- Línia 1: "<piramide>-<n>" centrada ---
const std::string PIR_LBL = Locale::get().text("hud.pyramid") + SEPARATOR;
const std::string PIR_VAL = std::to_string(pyramid_);
@@ -321,9 +355,9 @@ void Hud::renderIdleCenter(int x, int y, int w) const {
const int PV_W = font_->lengthMonoDigits(PIR_VAL, MONO_DIGIT_W);
const int LINE1_W = PL_W + PV_W;
int xx = x + ((w - LINE1_W) / 2);
font_->writeColored(xx, y + LINE1_Y, PIR_LBL, UiConfig::get().hudLabel());
drawShadowed(*font_, xx, y + LINE1_Y, PIR_LBL, LBL_C, SHD_C);
xx += PL_W;
font_->writeMonoDigits(xx, y + LINE1_Y, PIR_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W);
drawMonoShadowed(*font_, xx, y + LINE1_Y, PIR_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
// --- Línia 2: "<sala>-<r>" centrada ---
const std::string SALA_LBL = Locale::get().text("hud.room") + SEPARATOR;
@@ -332,7 +366,7 @@ void Hud::renderIdleCenter(int x, int y, int w) const {
const int SV_W = font_->lengthMonoDigits(SALA_VAL, MONO_DIGIT_W);
const int LINE2_W = SL_W + SV_W;
xx = x + ((w - LINE2_W) / 2);
font_->writeColored(xx, y + LINE2_Y, SALA_LBL, UiConfig::get().hudLabel());
drawShadowed(*font_, xx, y + LINE2_Y, SALA_LBL, LBL_C, SHD_C);
xx += SL_W;
font_->writeMonoDigits(xx, y + LINE2_Y, SALA_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W);
drawMonoShadowed(*font_, xx, y + LINE2_Y, SALA_VAL, VAL_C, SHD_C, MONO_DIGIT_W);
}
+19 -11
View File
@@ -217,20 +217,28 @@ namespace Defaults {
constexpr Uint8 MENU_DISABLED = 16; // Label en gris quan no aplica.
// Índexs de paleta per al marcador de joc (HUD de la GameScene).
// Arranca reciclant els colors del menú per coherència visual;
// separant els noms a constants pròpies, tocar-los a posteriori
// no afecta el menú.
// Tres "famílies" de color — un parell (label, value) + sombra per
// cada bloc — donen jerarquia visual: P1 càlid (vermell+crema),
// CENTER ambiental (ambre+or), P2 fred (cian+blau pàl·lid). La
// sombra és un to més fosc del mateix matís per donar volum.
constexpr Uint8 HUD_BG = 0; // Fons de cada bloc.
constexpr Uint8 HUD_LABEL = 58; // Etiquetes ("P1", "V", "T").
constexpr Uint8 HUD_VALUE = 187; // Valors (score, vides, tresors).
constexpr Uint8 HUD_FLASH = 237; // Color temporal del valor en flash.
constexpr Uint8 HUD_P1_LABEL = 7; // P1 label: vermell pur (#ff0000).
constexpr Uint8 HUD_P1_VALUE = 183; // P1 value: crema-préssec (#ffdaaa).
constexpr Uint8 HUD_P1_SHADOW = 2; // P1 ombra: vermell fosc (#480000).
constexpr Uint8 HUD_CENTER_LABEL = 29; // CENTER label: ambre cremat (#b66d00).
constexpr Uint8 HUD_CENTER_VALUE = 55; // CENTER value: or brillant (#ffda00).
constexpr Uint8 HUD_CENTER_SHADOW = 10; // CENTER ombra: marró cremat (#482400).
constexpr Uint8 HUD_P2_LABEL = 248; // P2 label: cian pur (#00ffff).
constexpr Uint8 HUD_P2_VALUE = 253; // P2 value: blau pàl·lid (#b6ffff).
constexpr Uint8 HUD_P2_SHADOW = 64; // P2 ombra: blau marí (#000055).
constexpr Uint8 HUD_FLASH = 63; // Flash (groc neó, comú a totes les famílies).
// Rètol de transició de sala (ROOM_INTRO i ROOM_OUTRO).
// Independent dels colors de notificació perquè el rètol de
// fase és un canal visual diferent. `ROOM_TEXT_OUTLINE` és el
// contorn que abans estava hardcodejat al `renderRoomText`.
constexpr Uint8 ROOM_TEXT = 232;
constexpr Uint8 ROOM_TEXT_OUTLINE = 255;
// Comparteix la família CENTER del HUD: or sobre marró cremat,
// per coherència visual ("Piràmide N / Sala N" és sempre
// daurat arreu del joc).
constexpr Uint8 ROOM_TEXT = 55; // Or brillant (= HUD_CENTER_VALUE).
constexpr Uint8 ROOM_TEXT_OUTLINE = 10; // Marró cremat (= HUD_CENTER_SHADOW).
// Timings d'animació del HUD. L'entrada són els tres blocs lliscant
// a la seua posició; flash i shake acompanyen un `player_hit`.
+3
View File
@@ -18,6 +18,9 @@ auto Audio::get() -> Audio* {
void Audio::playSound(const std::string& /*name*/, Audio::Group /*group*/) {
}
void Audio::playSound(const std::string& /*name*/, Audio::Group /*group*/, float /*speed*/) {
}
namespace Resource {
auto Cache::get() -> Cache& {
+20 -15
View File
@@ -69,13 +69,14 @@ namespace {
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra).
// Equival al que fa GameScene al final del text-exit de ROOM_INTRO: arma
// el comptador i consumeix la durada amb un update. Tests que operin
// directament sobre HURT/ROAMING l'han de cridar abans.
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra
// amb l'animació `raise` activa). Tickem amb dt gran fins sortir de
// SPAWNING — mateix path que `Mummy::update` executa al final de
// l'animació en partida real. El for limita el bucle defensivament.
void exitMummySpawn(Mummy& mummy) {
mummy.triggerSpawnExit();
mummy.update(mummy.getSpawnDurationS() + 0.001F);
for (int i = 0; i < 32 && mummy.isSpawning(); ++i) {
mummy.update(0.5F);
}
}
void testFactory() {
@@ -165,9 +166,11 @@ namespace {
REQUIRE(sarc->getVisitedCount() == 8);
REQUIRE(sarc->getState() == Sarcophagus::State::OPENING);
// Un update amb l'animació `opening` d'1 frame loop=false completa
// la transició a OPEN.
sarc->update(0.0F);
// Tickem prou per consumir l'animació `opening` (4 frames @ 6fps =
// ~0.67s). Quan completa, la transició OPENING → OPEN es dispara.
for (int i = 0; i < 16 && sarc->getState() == Sarcophagus::State::OPENING; ++i) {
sarc->update(0.1F);
}
REQUIRE(sarc->getState() == Sarcophagus::State::OPEN);
}
@@ -207,12 +210,14 @@ namespace {
forceOpen(s1);
forceOpen(s2);
// Un primer tick: cada sarc transiciona OPENING → OPEN i publica;
// l'handler del manager compta i, en arribar al total, crida
// door->open() — que en aquest mateix tick (la iteració sobre
// entities continua) farà transitar la porta OPENING → OPEN perquè
// l'animació és d'1 frame loop=false.
manager.update(0.0F);
// Tickem prou per consumir l'animació `opening` dels sarcs (4
// frames @ 6fps) i la de la porta. Cada sarc transiciona
// OPENING → OPEN i publica; l'handler del manager compta i, en
// arribar al total, crida `door->open()`, que també necessita
// tickar fins acabar la seua animació d'obertura.
for (int i = 0; i < 20 && door_ptr->getState() != Door::State::OPEN; ++i) {
manager.update(0.1F);
}
REQUIRE(manager.openedSarcophagusCount() == 2);
REQUIRE(door_ptr->getState() == Door::State::OPEN);
+8 -5
View File
@@ -32,12 +32,15 @@ namespace {
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra). Els
// tests del whip necessiten que la mòmia estigui ACTIVE per reaccionar
// al fueteig — sense això, `enterHurt` torna early per `isSpawning()`.
// Treu la mòmia de l'estat SPAWN inicial (Mummy::Mummy ara hi entra
// amb l'animació `raise` activa). Els tests del whip necessiten que la
// mòmia estigui ACTIVE per reaccionar al fueteig — sense això,
// `enterHurt` torna early per `isSpawning()`. Tickem amb dt gran fins
// sortir de SPAWNING (mateix path real, només accelerat).
void exitMummySpawn(Mummy& mummy) {
mummy.triggerSpawnExit();
mummy.update(mummy.getSpawnDurationS() + 0.001F);
for (int i = 0; i < 32 && mummy.isSpawning(); ++i) {
mummy.update(0.5F);
}
}
// Caja de jugador "estàndard" AEE: 16×32 amb cantonada (x, y).