diff --git a/CMakeLists.txt b/CMakeLists.txt index 98da76f..77dada9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/Makefile b/Makefile index 4315e0c..c11c0a3 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/data/audio/sounds/open_sarc.wav b/data/audio/sounds/open_sarc.wav new file mode 100644 index 0000000..48415e5 Binary files /dev/null and b/data/audio/sounds/open_sarc.wav differ diff --git a/data/config/ui.yaml b/data/config/ui.yaml index d5875a7..7d8e0b2 100644 --- a/data/config/ui.yaml +++ b/data/config/ui.yaml @@ -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 diff --git a/data/entities/dust/dust.gif b/data/entities/dust/dust.gif index 5aa1062..8f017fa 100644 Binary files a/data/entities/dust/dust.gif and b/data/entities/dust/dust.gif differ diff --git a/data/entities/mummy/mummy.gif b/data/entities/mummy/mummy.gif index 9352a96..7b56a4a 100644 Binary files a/data/entities/mummy/mummy.gif and b/data/entities/mummy/mummy.gif differ diff --git a/data/entities/mummy/mummy.yaml b/data/entities/mummy/mummy.yaml index 3c42890..39071e0 100644 --- a/data/entities/mummy/mummy.yaml +++ b/data/entities/mummy/mummy.yaml @@ -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 diff --git a/data/entities/player/morcus.yaml b/data/entities/player/morcus.yaml index d9e2b66..0b485a2 100644 --- a/data/entities/player/morcus.yaml +++ b/data/entities/player/morcus.yaml @@ -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 diff --git a/data/entities/player/sam.yaml b/data/entities/player/sam.yaml index a1c20b0..b806a48 100644 --- a/data/entities/player/sam.yaml +++ b/data/entities/player/sam.yaml @@ -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 diff --git a/data/entities/sarcophagus/sarcophagus.gif b/data/entities/sarcophagus/sarcophagus.gif index 51acd90..a07ef53 100644 Binary files a/data/entities/sarcophagus/sarcophagus.gif and b/data/entities/sarcophagus/sarcophagus.gif differ diff --git a/data/entities/sarcophagus/sarcophagus.yaml b/data/entities/sarcophagus/sarcophagus.yaml index 701e0f3..108f818 100644 --- a/data/entities/sarcophagus/sarcophagus.yaml +++ b/data/entities/sarcophagus/sarcophagus.yaml @@ -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 diff --git a/data/entities/shadow/shadow.gif b/data/entities/shadow/shadow.gif new file mode 100644 index 0000000..905ac95 Binary files /dev/null and b/data/entities/shadow/shadow.gif differ diff --git a/data/locale/ca.yaml b/data/locale/ca.yaml index 6b74413..cd5afa9 100644 --- a/data/locale/ca.yaml +++ b/data/locale/ca.yaml @@ -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" diff --git a/data/locale/en.yaml b/data/locale/en.yaml index 8ea27ad..fcea27f 100644 --- a/data/locale/en.yaml +++ b/data/locale/en.yaml @@ -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" diff --git a/data/scenes/game/game.yaml b/data/scenes/game/game.yaml index 5bb349b..d2ca56c 100644 --- a/data/scenes/game/game.yaml +++ b/data/scenes/game/game.yaml @@ -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 diff --git a/source/core/audio/audio.cpp b/source/core/audio/audio.cpp index 7477cd9..e54b3c5 100644 --- a/source/core/audio/audio.cpp +++ b/source/core/audio/audio.cpp @@ -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(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. diff --git a/source/core/audio/audio.hpp b/source/core/audio/audio.hpp index 1f930a4..891c00d 100644 --- a/source/core/audio/audio.hpp +++ b/source/core/audio/audio.hpp @@ -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 diff --git a/source/core/audio/jail_audio.cpp b/source/core/audio/jail_audio.cpp index 83ec17a..20e57d8 100644 --- a/source/core/audio/jail_audio.cpp +++ b/source/core/audio/jail_audio.cpp @@ -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) { diff --git a/source/core/audio/jail_audio.hpp b/source/core/audio/jail_audio.hpp index 88db498..3e46dca 100644 --- a/source/core/audio/jail_audio.hpp +++ b/source/core/audio/jail_audio.hpp @@ -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 diff --git a/source/core/input/global_inputs.cpp b/source/core/input/global_inputs.cpp index 96454a1..bc8d0dc 100644 --- a/source/core/input/global_inputs.cpp +++ b/source/core/input/global_inputs.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #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)); } } diff --git a/source/core/rendering/effects/shadow.cpp b/source/core/rendering/effects/shadow.cpp new file mode 100644 index 0000000..242b8ce --- /dev/null +++ b/source/core/rendering/effects/shadow.cpp @@ -0,0 +1,43 @@ +#include "core/rendering/effects/shadow.hpp" + +#include + +#include + +#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((1.0F - CLAMPED) * static_cast(FRAME_COUNT)); + frame_index = std::clamp(frame_index, 0, FRAME_COUNT - 1); + + SDL_FRect clip{ + static_cast(frame_index * FRAME_SIZE), + 0.0F, + static_cast(FRAME_SIZE), + static_cast(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 diff --git a/source/core/rendering/effects/shadow.hpp b/source/core/rendering/effects/shadow.hpp new file mode 100644 index 0000000..33df742 --- /dev/null +++ b/source/core/rendering/effects/shadow.hpp @@ -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 diff --git a/source/core/rendering/notifier.cpp b/source/core/rendering/notifier.cpp index 02bedc1..6133969 100644 --- a/source/core/rendering/notifier.cpp +++ b/source/core/rendering/notifier.cpp @@ -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& 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& 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::vector 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; } diff --git a/source/core/rendering/screen.cpp b/source/core/rendering/screen.cpp index eb1c504..b1392aa 100644 --- a/source/core/rendering/screen.cpp +++ b/source/core/rendering/screen.cpp @@ -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(iy) * canvas_width_); std::fill(row + X0, row + X1, argb); diff --git a/source/core/rendering/screenshot.cpp b/source/core/rendering/screenshot.cpp index c520563..32242b1 100644 --- a/source/core/rendering/screenshot.cpp +++ b/source/core/rendering/screenshot.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #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: + // `/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 copy(buffer, buffer + (static_cast(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 diff --git a/source/core/rendering/screenshot.hpp b/source/core/rendering/screenshot.hpp index 624e69c..1a91588 100644 --- a/source/core/rendering/screenshot.hpp +++ b/source/core/rendering/screenshot.hpp @@ -3,11 +3,30 @@ #include #include -// 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 diff --git a/source/core/system/director.cpp b/source/core/system/director.cpp index 53209a0..bb42584 100644 --- a/source/core/system/director.cpp +++ b/source/core/system/director.cpp @@ -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() { diff --git a/source/core/ui/ui_config.cpp b/source/core/ui/ui_config.cpp index e2ed476..5d55ace 100644 --- a/source/core/ui/ui_config.cpp +++ b/source/core/ui/ui_config.cpp @@ -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_); diff --git a/source/core/ui/ui_config.hpp b/source/core/ui/ui_config.hpp index a6e78b8..1cb687b 100644 --- a/source/core/ui/ui_config.hpp +++ b/source/core/ui/ui_config.hpp @@ -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_; diff --git a/source/game/editor/editor.cpp b/source/game/editor/editor.cpp index a5adaac..9d2155c 100644 --- a/source/game/editor/editor.cpp +++ b/source/game/editor/editor.cpp @@ -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, diff --git a/source/game/entities/concrete/mummy.cpp b/source/game/entities/concrete/mummy.cpp index b293655..a281b3d 100644 --- a/source/game/entities/concrete/mummy.cpp +++ b/source/game/entities/concrete/mummy.cpp @@ -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("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(); + // 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 diff --git a/source/game/entities/concrete/mummy.hpp b/source/game/entities/concrete/mummy.hpp index 3a766a9..e94eb92 100644 --- a/source/game/entities/concrete/mummy.hpp +++ b/source/game/entities/concrete/mummy.hpp @@ -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). diff --git a/source/game/entities/concrete/sarcophagus.cpp b/source/game/entities/concrete/sarcophagus.cpp index d001b7b..82181b5 100644 --- a/source/game/entities/concrete/sarcophagus.cpp +++ b/source/game/entities/concrete/sarcophagus.cpp @@ -1,14 +1,26 @@ #include "game/entities/concrete/sarcophagus.hpp" +#include #include #include +#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 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(); + 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(); + } + } } 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 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); + } + } } } diff --git a/source/game/entities/concrete/sarcophagus.hpp b/source/game/entities/concrete/sarcophagus.hpp index df70bfc..de4c930 100644 --- a/source/game/entities/concrete/sarcophagus.hpp +++ b/source/game/entities/concrete/sarcophagus.hpp @@ -2,6 +2,7 @@ #include #include +#include #include #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}; }; diff --git a/source/game/entities/hazard.hpp b/source/game/entities/hazard.hpp index b200b57..31e6fe3 100644 --- a/source/game/entities/hazard.hpp +++ b/source/game/entities/hazard.hpp @@ -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_; } diff --git a/source/game/entities/player.cpp b/source/game/entities/player.cpp index d20d0b2..6924bc2 100644 --- a/source/game/entities/player.cpp +++ b/source/game/entities/player.cpp @@ -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(); } if (j.contains("gravity")) { jump_config.gravity_px_per_s2 = j["gravity"].get_value(); } if (j.contains("landing_stun_ms")) { jump_config.landing_stun_ms = j["landing_stun_ms"].get_value(); } - if (j.contains("shadow_y_offset")) { jump_config.shadow_y_offset = j["shadow_y_offset"].get_value(); } // 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(static_cast(base_size) * SHRINK); - const float CX = getPosX() + (static_cast(sprite_w) * 0.5F); - const float GROUND_Y = getGroundFeetY(); - const auto SX = static_cast(CX - (static_cast(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(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(getPosX() + (static_cast(sprite_w) * 0.5F)); + const auto FEET_Y = static_cast(getGroundFeetY()); + Effects::Shadow::render(CENTER_X, FEET_Y, HEIGHT_FACTOR); } // Sufix d'animació per cardinal — empat 1:1 amb l'enum `Facing` i el diff --git a/source/game/entities/player.hpp b/source/game/entities/player.hpp index a574873..33a3ce2 100644 --- a/source/game/entities/player.hpp +++ b/source/game/entities/player.hpp @@ -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). }; diff --git a/source/game/ui/hud.cpp b/source/game/ui/hud.cpp index 455858c..cf9a286 100644 --- a/source/game/ui/hud.cpp +++ b/source/game/ui/hud.cpp @@ -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(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: "