Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d26abcb9d5 | ||
|
|
a358d8ddbd | ||
|
|
f7c4d62234 | ||
|
|
06a49e09ba | ||
|
|
629baaaec3 | ||
|
|
932d195eb0 | ||
|
|
3e31a88f78 | ||
|
|
cba46642c6 | ||
|
|
8c84892250 | ||
|
|
d14da0e61f | ||
|
|
8bdc994b75 | ||
|
|
6a70d6de9f | ||
|
|
2523661c8f | ||
|
|
fa27308897 | ||
|
|
e58eb10d6d | ||
|
|
a29ea641ab | ||
|
|
28ff425c89 | ||
|
|
e8418f89e0 | ||
|
|
9ca09484a6 | ||
|
|
9291ef6572 | ||
|
|
6f8ab74d6b | ||
|
|
881e0ea1f6 | ||
|
|
6a6a1a52c7 | ||
|
|
62f4b857ef | ||
|
|
3b378e2b29 | ||
|
|
041447eaa4 | ||
|
|
a6b2b02930 | ||
|
|
b7811180ba | ||
|
|
2cbf1bb6e0 | ||
|
|
8890fa9e1f | ||
|
|
23f7cb03a6 | ||
|
|
486a440668 | ||
|
|
a717411edd | ||
|
|
5e862ceadc | ||
|
|
78e164fce4 | ||
|
|
c13b11c501 | ||
|
|
8921b9fd0c | ||
|
|
1d9fea28fe | ||
|
|
d0d5cb0e66 | ||
|
|
e25ccd02fd | ||
|
|
3635cebf25 | ||
|
|
cfbcdc236b | ||
|
|
7871d3930e | ||
|
|
932b457725 | ||
|
|
e9f99a0542 |
No files matched your search
@@ -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
|
||||
@@ -107,16 +108,19 @@ set(APP_SOURCES
|
||||
source/utils/log.cpp
|
||||
source/utils/utils.cpp
|
||||
source/game/debug_config.cpp
|
||||
source/game/gameplay_config.cpp
|
||||
source/game/options.cpp
|
||||
source/game/options_apply.cpp
|
||||
source/game/ui/service_menu.cpp
|
||||
source/game/ui/hud.cpp
|
||||
source/game/scenes/placeholder_input.cpp
|
||||
source/game/scenes/start_input.cpp
|
||||
source/game/scenes/logo_scene.cpp
|
||||
source/game/scenes/title_scene.cpp
|
||||
source/game/scenes/intro_scene.cpp
|
||||
source/game/scenes/game_scene.cpp
|
||||
source/game/scenes/room_fsm.cpp
|
||||
source/game/scenes/game_over_fsm.cpp
|
||||
source/game/entities/player.cpp
|
||||
source/game/map/map.cpp
|
||||
source/game/map/tilemap.cpp
|
||||
@@ -397,6 +401,7 @@ if(BUILD_TESTS)
|
||||
tests/audio_cache_stubs.cpp
|
||||
source/core/resources/resource_helper.cpp
|
||||
source/core/resources/resource_pack.cpp
|
||||
source/game/gameplay_config.cpp
|
||||
source/game/map/map.cpp
|
||||
source/utils/log.cpp
|
||||
${ENTITY_SOURCES_BASE}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 306 B After Width: | Height: | Size: 303 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -36,10 +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 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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 848 B After Width: | Height: | Size: 1.6 KiB |
@@ -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
|
||||
|
After Width: | Height: | Size: 98 B |
@@ -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"
|
||||
@@ -38,6 +38,13 @@ hud:
|
||||
treasures: "Tres"
|
||||
pyramid: "Piramide"
|
||||
room: "Sala"
|
||||
# Prompts mostrats al bloc d'un slot inactiu (en lloc dels zeros)
|
||||
# quan un jugador encara no ha pulsat START o ha esgotat vides.
|
||||
# Dues línies per fer-los caber al bloc lateral (~106 px).
|
||||
join_prompt_can_join_l1: "Polsa start"
|
||||
join_prompt_can_join_l2: "per jugar"
|
||||
join_prompt_wait_l1: "Espera"
|
||||
join_prompt_wait_l2: "per jugar"
|
||||
|
||||
# Textos de l'escena TITLE.
|
||||
title:
|
||||
@@ -53,6 +60,12 @@ game:
|
||||
room_intro_room: "Sala {r}"
|
||||
room_complete_line1: "Fase"
|
||||
room_complete_line2: "Completada"
|
||||
# Rètol del game over (sub-FSM de la GameScene). El countdown es
|
||||
# pinta com a segona línia (un enter) durant la fase 1 de
|
||||
# CONTINUE_PROMPT — només cal traduir el rètol superior.
|
||||
continue_prompt: "Continuar?"
|
||||
game_over_line1: "Fi del"
|
||||
game_over_line2: "Joc"
|
||||
|
||||
menu:
|
||||
titles:
|
||||
@@ -62,6 +75,7 @@ menu:
|
||||
controls: "Controls"
|
||||
keyboard: "Teclat"
|
||||
gamepad: "Comandament"
|
||||
tweaks: "Jugabilitat"
|
||||
system: "Sistema"
|
||||
labels:
|
||||
player_1: "Jugador 1"
|
||||
@@ -73,6 +87,7 @@ menu:
|
||||
controls_submenu: "Controls"
|
||||
keyboard_submenu: "Teclat"
|
||||
gamepad_submenu: "Comandament"
|
||||
tweaks_submenu: "Jugabilitat"
|
||||
system_submenu: "Sistema"
|
||||
move_up: "Amunt"
|
||||
move_down: "Avall"
|
||||
@@ -101,6 +116,9 @@ menu:
|
||||
music_volume: "Volum música"
|
||||
sounds: "Sons"
|
||||
sounds_volume: "Volum sons"
|
||||
whip_pierces_walls: "Látec travessa sarcs"
|
||||
friendly_fire: "Foc amic"
|
||||
players_collide: "Col·lisió entre jugadors"
|
||||
restart: "Reiniciar"
|
||||
exit_game: "Eixir del joc"
|
||||
values:
|
||||
|
||||
@@ -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"
|
||||
@@ -38,6 +38,13 @@ hud:
|
||||
treasures: "Tre"
|
||||
pyramid: "Pyramid"
|
||||
room: "Room"
|
||||
# Prompts shown in an inactive slot's block (instead of zeros) while
|
||||
# a player hasn't pressed START yet or has run out of lives. Two
|
||||
# short lines so they fit in the side block (~106 px wide).
|
||||
join_prompt_can_join_l1: "Press start"
|
||||
join_prompt_can_join_l2: "to play"
|
||||
join_prompt_wait_l1: "Please"
|
||||
join_prompt_wait_l2: "wait"
|
||||
|
||||
# Texts of the TITLE scene.
|
||||
title:
|
||||
@@ -52,6 +59,12 @@ game:
|
||||
room_intro_room: "Room {r}"
|
||||
room_complete_line1: "Stage"
|
||||
room_complete_line2: "Complete"
|
||||
# Game over labels (sub-FSM in GameScene). The countdown is drawn as
|
||||
# a numeric second line during CONTINUE_PROMPT phase 1, so only the
|
||||
# top label needs translation.
|
||||
continue_prompt: "Continue?"
|
||||
game_over_line1: "Game"
|
||||
game_over_line2: "Over"
|
||||
|
||||
menu:
|
||||
titles:
|
||||
@@ -61,6 +74,7 @@ menu:
|
||||
controls: "Controls"
|
||||
keyboard: "Keyboard"
|
||||
gamepad: "Gamepad"
|
||||
tweaks: "Gameplay"
|
||||
system: "System"
|
||||
labels:
|
||||
player_1: "Player 1"
|
||||
@@ -72,6 +86,7 @@ menu:
|
||||
controls_submenu: "Controls"
|
||||
keyboard_submenu: "Keyboard"
|
||||
gamepad_submenu: "Gamepad"
|
||||
tweaks_submenu: "Gameplay"
|
||||
system_submenu: "System"
|
||||
move_up: "Up"
|
||||
move_down: "Down"
|
||||
@@ -100,6 +115,9 @@ menu:
|
||||
music_volume: "Music volume"
|
||||
sounds: "Sounds"
|
||||
sounds_volume: "Sound volume"
|
||||
whip_pierces_walls: "Whip pierces sarcs"
|
||||
friendly_fire: "Friendly fire"
|
||||
players_collide: "Player collision"
|
||||
restart: "Restart"
|
||||
exit_game: "Exit game"
|
||||
values:
|
||||
|
||||
@@ -43,6 +43,54 @@ 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
|
||||
# queden fora de partida durant PLAYING, fa countdown, i si ningú
|
||||
# pulsa START acaba en GAME OVER + fade-to-black + transició a LOGO.
|
||||
# Tots els valors són ms (excepte `continue_count_steps`, que és el
|
||||
# nombre de tics visibles del countdown). Si manca el fitxer o alguna
|
||||
# clau, GameScene cau als fallbacks compilats (vegeu `GameOverFsm::Config`).
|
||||
game_over:
|
||||
# CONTINUE? — animació d'entrada i sortida idèntica al room text.
|
||||
continue_text_enter_ms: 400
|
||||
# Hold del countdown: 10 s = (continue_count_start + 1) graons d'1 s.
|
||||
# La sèrie visible és [continue_count_start, ..., 1, 0]; el "0" es
|
||||
# manté l'últim segon abans del salt a GAME OVER.
|
||||
continue_text_hold_ms: 10000
|
||||
continue_text_exit_ms: 400
|
||||
continue_count_start: 9
|
||||
# GAME OVER — apareix sense enter anim (panel ja amunt del CONTINUE?),
|
||||
# es manté un temps i marxa amb la mateixa exit anim del room text.
|
||||
game_over_text_hold_ms: 1500
|
||||
game_over_text_exit_ms: 400
|
||||
game_over_wait_ms: 400
|
||||
# Fade final cap a LOGO. `black_hold_ms` és la pausa en negre opac
|
||||
# abans que el GameScene demani la transició d'escena.
|
||||
fade_out_ms: 500
|
||||
black_hold_ms: 500
|
||||
# Velocitat (frequency ratio) de la música mentre dura el countdown
|
||||
# de CONTINUE_PROMPT. 1.0 = sense canvi, 1.5 = un 50% més ràpid (i
|
||||
# amb el to més agut: efecte arcade clàssic). Es restableix a 1.0 al
|
||||
# sortir del prompt — tant si el jugador reviu com si entra GAME OVER.
|
||||
continue_music_speed: 1.05
|
||||
# Pista de música que sona durant l'estat GAME_OVER (sense loop).
|
||||
# La fase de hold del text es sincronitza amb la durada real de la
|
||||
# pista, així que el rètol "Fi del Joc" es manté visible mentre dura
|
||||
# la música; després ve l'exit anim, el wait i el fade a LOGO.
|
||||
game_over_music_track: "game_over.ogg"
|
||||
# So que sona cada vegada que apareix un nou número del countdown
|
||||
# (al primer cop que es mostra `continue_count_start`, a cada canvi,
|
||||
# i quan apareix el 0 — però NO al saltar a GAME OVER). Buit = silenci.
|
||||
countdown_sound_key: "countdown.wav"
|
||||
# Llindar a partir del qual qualsevol botó de salt o atac de
|
||||
# qualsevol jugador avança al següent número del countdown.
|
||||
# 7 → skip habilitat mentre el número visible és 7..1 (no a 0).
|
||||
# -1 = mai. Útil per accelerar la decisió un cop la urgència ja
|
||||
# és evident al jugador.
|
||||
skip_below_or_equal: 7
|
||||
@@ -113,6 +113,13 @@ void Audio::stopMusic() {
|
||||
}
|
||||
}
|
||||
|
||||
// NOLINTNEXTLINE(readability-make-member-function-const)
|
||||
void Audio::setMusicSpeed(float ratio) {
|
||||
if (music_enabled_) {
|
||||
engine_->setMusicSpeed(ratio);
|
||||
}
|
||||
}
|
||||
|
||||
// Reprodueix un so per nom
|
||||
// NOLINTNEXTLINE(readability-make-member-function-const) — muta estat via engine_; unique_ptr::operator-> no propaga const
|
||||
void Audio::playSound(const std::string& name, Group group) {
|
||||
@@ -129,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.
|
||||
|
||||
@@ -76,10 +76,21 @@ class Audio {
|
||||
void stopMusic(); // Aturar completament la música
|
||||
void fadeOutMusic(int milliseconds); // Fosa de sortida de la música (muta globals de Ja)
|
||||
void setOnMusicEnded(std::function<void()> callback); // Callback disparat quan la pista actual acaba de drenar (CONV-03)
|
||||
// Multiplicador de velocitat de la música actual. 1.0 = normal,
|
||||
// 1.5 = un 50% més ràpid (efecte "chipmunk" — també puja el to).
|
||||
// Es reseteja a 1.0 implícitament a cada `playMusic`. No-op si no
|
||||
// hi ha música activa.
|
||||
void setMusicSpeed(float ratio);
|
||||
|
||||
// --- 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
|
||||
|
||||
@@ -286,6 +286,11 @@ namespace Ja {
|
||||
}
|
||||
}
|
||||
|
||||
void Engine::setMusicSpeed(float ratio) {
|
||||
if (current_music_ == nullptr || current_music_->stream == nullptr) { return; }
|
||||
SDL_SetAudioStreamFrequencyRatio(current_music_->stream, ratio);
|
||||
}
|
||||
|
||||
void Engine::pauseMusic() {
|
||||
if (current_music_ == nullptr || current_music_->state != MusicState::PLAYING) { return; }
|
||||
|
||||
@@ -425,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) {
|
||||
|
||||
@@ -171,6 +171,14 @@ namespace Ja {
|
||||
void crossfadeMusic(Music* music, int crossfade_ms, int loop = -1);
|
||||
[[nodiscard]] auto getMusicState() const -> MusicState;
|
||||
auto setMusicVolume(float volume) -> float;
|
||||
// Multiplicador de velocitat de reproducció de la música actual
|
||||
// via `SDL_SetAudioStreamFrequencyRatio`. 1.0 = normal, 2.0 =
|
||||
// doble velocitat. Cal saber que també puja el to (efecte
|
||||
// "chipmunk") — és el comportament arcade clàssic dels comptes
|
||||
// enrere. Cada `playMusic` crea un stream nou amb ratio 1.0,
|
||||
// així que un canvi de track reseteja la velocitat
|
||||
// implícitament. No-op si no hi ha música activa.
|
||||
void setMusicSpeed(float ratio);
|
||||
// Registra un callback que es disparà quan la música actual acabi de
|
||||
// drenar naturalment (times == 0 + stream buit). Es crida DESPRÉS de
|
||||
// stopMusic, així que el callback pot invocar playMusic sense córrer.
|
||||
@@ -184,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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -29,6 +31,7 @@
|
||||
#include "core/system/event_buffer.hpp"
|
||||
#include "core/ui/ui_config.hpp"
|
||||
#include "game/debug_config.hpp"
|
||||
#include "game/gameplay_config.hpp"
|
||||
#ifdef _DEBUG
|
||||
#include "game/editor/editor_config.hpp"
|
||||
#endif
|
||||
@@ -93,6 +96,11 @@ Director::Director() {
|
||||
// el patró de coffee_crisis_arcade_edition (`gamepad_configs.json`).
|
||||
GamepadBindings::get().load(system_folder_ + "gamepad_configs.yaml");
|
||||
|
||||
// Tweaks de jugabilitat (látigo travessa sarcs, friendly fire, col·lisió
|
||||
// entre jugadors). Fitxer escrivible des del menú de servei (F9). Si
|
||||
// no existeix, GameplayConfig::load el crea amb els defaults.
|
||||
GameplayConfig::get().load(system_folder_ + "gameplay.yaml");
|
||||
|
||||
// Carrega els presets de PostFX des de shaders/postfx.yaml; si no existeix
|
||||
// es crea amb presets per defecte (off, crt, ntsc).
|
||||
Options::setPostFXFile(system_folder_ + Defaults::Video::POSTFX_FILE);
|
||||
@@ -156,12 +164,20 @@ 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`.
|
||||
Resource::Cache::get().loadMusic("room_intro.ogg", Defaults::Paths::MUSIC_ROOM_INTRO);
|
||||
Resource::Cache::get().loadMusic("playing.ogg", Defaults::Paths::MUSIC_PLAYING);
|
||||
Resource::Cache::get().loadMusic("room_outro.ogg", Defaults::Paths::MUSIC_ROOM_OUTRO);
|
||||
// Pista de game over: pre-load igual que la resta perquè el playMusic
|
||||
// del moment en què es dispara la sub-FSM no facti disc.
|
||||
Resource::Cache::get().loadMusic("game_over.ogg", Defaults::Paths::MUSIC_GAME_OVER);
|
||||
// Música de la TITLE: arrenca al constructor de l'escena i es fa
|
||||
// fade-out quan l'escena entra a STARTING.
|
||||
Resource::Cache::get().loadMusic("title.ogg", Defaults::Paths::MUSIC_TITLE);
|
||||
@@ -364,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() {
|
||||
|
||||
@@ -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_);
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include "core/audio/audio.hpp"
|
||||
#include "core/resources/resource_cache.hpp"
|
||||
#include "game/entities/concrete/whip_attack.hpp"
|
||||
#include "game/entities/entity_factory.hpp"
|
||||
#include "game/entities/entity_manager.hpp"
|
||||
#include "game/entities/event_bus.hpp"
|
||||
@@ -65,6 +66,24 @@ namespace {
|
||||
// més d'una lane (cas marginal a velocitats típiques, però pot passar
|
||||
// amb dt grans). Comparació estricta a un costat: no dispara quan la
|
||||
// caixa ja es troba alineada a la lane (hi està després del snap).
|
||||
// Mapatge WhipAttack::Direction → Mummy::Direction. Viuen amb noms i
|
||||
// valors paral·lels però són dos enums distints (cada classe modela el
|
||||
// seu propi domini). Conversió 1-a-1; `NONE` no apareix com a possible
|
||||
// sortida del whip, però la deixem per coherència defensiva.
|
||||
auto whipDirToMummyDir(WhipAttack::Direction d) -> Mummy::Direction {
|
||||
switch (d) {
|
||||
case WhipAttack::Direction::NORTH:
|
||||
return Mummy::Direction::NORTH;
|
||||
case WhipAttack::Direction::SOUTH:
|
||||
return Mummy::Direction::SOUTH;
|
||||
case WhipAttack::Direction::EAST:
|
||||
return Mummy::Direction::EAST;
|
||||
case WhipAttack::Direction::WEST:
|
||||
return Mummy::Direction::WEST;
|
||||
}
|
||||
return Mummy::Direction::NONE;
|
||||
}
|
||||
|
||||
auto findLaneCross(float prev, float delta, float base, float pitch, int max_idx) -> std::optional<float> {
|
||||
const float NEXT = prev + delta;
|
||||
if (delta > 0.0F) {
|
||||
@@ -87,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;
|
||||
@@ -122,6 +142,19 @@ Mummy::Mummy(const fkyaml::node& yaml)
|
||||
changeDirection(0.0F, 1.0F);
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
// Configuració de l'estat HURT: opcional. Si el bloc no apareix, la
|
||||
// mòmia no entrarà mai a HURT efectiu (defensiu); el WhipAttack la
|
||||
// pot ignorar amb `isWhippable() == false` si volem que no reacció.
|
||||
@@ -222,9 +255,24 @@ void Mummy::step(float delta_time) {
|
||||
const float DX = dir_x_ * speed_ * delta_time;
|
||||
const float DY = dir_y_ * speed_ * delta_time;
|
||||
|
||||
// Si no té direcció (p.ex. YAML sense `initial_direction`), el frame és
|
||||
// no-op fins que algú la injecte amb `setDirection`.
|
||||
if (DX == 0.0F && DY == 0.0F) { return; }
|
||||
// Sense direcció: pot ser un setup degenerat (YAML sense
|
||||
// `initial_direction`) o el residu d'un HURT que va sortir amb
|
||||
// `chooseHurtDirection() == NONE` (totes les direccions provades
|
||||
// bloquejades). En aquest segon cas la mòmia quedaria eternament quieta
|
||||
// — `setDirection(0,0)` a `enterHurt` no toca `anim_`, així que es
|
||||
// veuria animant-se sense moure's. Re-decidim com a una intersecció:
|
||||
// si trobem alguna perpendicular vàlida, la prenem; si no, mitja volta.
|
||||
// Si tampoc, mantenim (0,0) i tornem a provar el proper frame —
|
||||
// pràcticament impossible amb la geometria actual, però no enganxa.
|
||||
if (DX == 0.0F && DY == 0.0F) {
|
||||
auto pools = collectDirectionPools(speed_ * delta_time, /*avoid_blocked=*/true);
|
||||
if (!pools.perp.empty()) {
|
||||
applyChoice(chooseDirection(pools.perp));
|
||||
} else if (!pools.uturn.empty()) {
|
||||
applyChoice(chooseDirection(pools.uturn));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Branca creuament: si aquest frame travessa la línia central d'un
|
||||
// corredor perpendicular, amarra la caixa al centre del creuament i
|
||||
@@ -494,7 +542,19 @@ void Mummy::applyChoice(Direction dir) {
|
||||
}
|
||||
}
|
||||
|
||||
auto Mummy::onWhipHit(WhipAttack* attack) -> bool {
|
||||
// Defensiu: el filtre `shouldReceiveContact` ja salta DynamicHazards en
|
||||
// SPAWN, però blindem aquesta crida si arribara per un camí no central.
|
||||
if (isSpawning()) { return false; }
|
||||
if (!whippable_ || attack == nullptr) { return false; }
|
||||
enterHurt(whipDirToMummyDir(attack->getDirection()));
|
||||
return true;
|
||||
}
|
||||
|
||||
void Mummy::onPlayerContact(Player* player) {
|
||||
// Defensiu: el filtre central a EntityManager::checkPlayerCollisions ja
|
||||
// salta DynamicHazards en SPAWN, però blindem la crida directa.
|
||||
if (isSpawning()) { return; }
|
||||
if (bus_ == nullptr) { return; }
|
||||
const int PID = (player != nullptr) ? player->getId() : -1;
|
||||
bus_->publish(Event{
|
||||
@@ -519,6 +579,10 @@ void Mummy::onPlayerContact(Player* player) {
|
||||
}
|
||||
|
||||
void Mummy::enterHurt(Direction flee_dir) {
|
||||
// Defensiu: el whip no hauria de reaccionar contra una mòmia en SPAWN
|
||||
// (el filtre de col·lisió la salta), però blindem per si algun camí
|
||||
// futur crida directament.
|
||||
if (isSpawning()) { return; }
|
||||
state_ = State::HURT;
|
||||
hurt_remaining_s_ = hurt_config_.duration_s;
|
||||
hurt_speed_ = (hurt_config_.speed_px_per_s > 0.0F) ? hurt_config_.speed_px_per_s : speed_;
|
||||
@@ -612,13 +676,40 @@ void Mummy::hurtStep(float delta_time) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Topa amb mur o sarc: surt del HURT immediatament. La IA normal
|
||||
// re-decideix al pròxim step (no fem U-turn ni re-orientació aquí).
|
||||
// Topa amb mur del playfield: amarra la BOX a la vora exacta abans de
|
||||
// transitar a ROAMING. Sense aquest snap, el drift sub-pixel acumulat a
|
||||
// la cursa (DX = -hurt_speed·dt rarament és enter al joc real, on dt ve
|
||||
// de SDL_GetTicks) deixaria la BOX a un offset de 0.x px dins de
|
||||
// l'última cel·la. Com que la BOX és 16×16 i els passadissos també ho
|
||||
// són, qualsevol drift fa que la cantonada sobresurti cap als sarcs
|
||||
// adjacents — i el `chooseHurtDirection` del proper fueteig classifica
|
||||
// les perpendiculars com a bloquejades per micro-overlap, retornant
|
||||
// NONE i deixant la mòmia eternament quieta. Sarc-collision durant HURT
|
||||
// és un cas molt menys habitual (la cursa va sempre per corredor recte);
|
||||
// si arriba a passar, l'snap al playfield no l'arregla però la branca
|
||||
// de recovery a `step()` el cobreix.
|
||||
if (!Map::insidePlayfield(CANDIDATE)) { snapToPlayfieldEdge(DX, DY); }
|
||||
|
||||
state_ = State::ROAMING;
|
||||
hurt_remaining_s_ = 0.0F;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -64,6 +64,12 @@ class Mummy : public DynamicHazard {
|
||||
// Contacte amb el jugador: publica `player_hit` amb el dany.
|
||||
void onPlayerContact(Player* player) override;
|
||||
|
||||
// Contacte amb el fueteig: si la mòmia és whippable, entra a HURT amb
|
||||
// la direcció derivada del whip. Retorna true si efectivament ha
|
||||
// rebut dany — el WhipAttack ho usa per al `already_hit_`. Substitueix
|
||||
// el `dynamic_cast<Mummy*>` que feia el whip abans per discriminar.
|
||||
auto onWhipHit(WhipAttack* attack) -> bool override;
|
||||
|
||||
// Activa l'estat HURT amb la direcció de fuga preferida `flee_dir`.
|
||||
// Si està bloquejada, prova perpendiculars; si totes estan bloquejades,
|
||||
// la mòmia queda quieta però en HURT durant tot el timer.
|
||||
@@ -171,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,13 +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;
|
||||
@@ -56,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) {
|
||||
@@ -121,6 +148,30 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void Sarcophagus::onRemoved() {
|
||||
// El manager comptabilitza els sarcòfags actius en `total_sarcs_` per
|
||||
// saber quan ha d'obrir la porta automàticament. Quan el editor
|
||||
// n'esborra un, cal informar-lo: sense aquesta crida, `total_sarcs_`
|
||||
// quedaria descompassat i la porta no s'obriria mai. Substitueix el
|
||||
// `getName() == "sarcophagus"` que el manager feia abans —
|
||||
// polimorfisme net, no comparació de strings.
|
||||
if (entity_manager_ != nullptr) {
|
||||
entity_manager_->onSarcophagusRemoved();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
|
||||
#include "external/fkyaml_node.hpp"
|
||||
@@ -43,6 +44,7 @@ class Sarcophagus : public Interactable {
|
||||
|
||||
void update(float delta_time) override;
|
||||
void notifyTilePainted(int col, int row) override;
|
||||
void onRemoved() override;
|
||||
|
||||
[[nodiscard]] auto getState() const -> State { return state_; }
|
||||
[[nodiscard]] auto getPerimeterSize() const -> std::size_t { return perimeter_tiles_.size(); }
|
||||
@@ -82,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};
|
||||
};
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "game/entities/entity_factory.hpp"
|
||||
#include "game/entities/entity_manager.hpp"
|
||||
#include "game/entities/player.hpp"
|
||||
#include "game/gameplay_config.hpp"
|
||||
#include "game/map/map.hpp"
|
||||
|
||||
namespace {
|
||||
@@ -174,9 +175,16 @@ void WhipAttack::computeDamageRect(Direction dir, float spawn_x, float spawn_y,
|
||||
// d'atac, fins a topar amb un mur o sarc, o esgotar `range_px`.
|
||||
// L'amplada perpendicular és `tile_size` (els passadissos del joc
|
||||
// tenen exactament aquesta mida i l'atac és lateral).
|
||||
//
|
||||
// Si el tweak `whip.damage_pierces_walls` està actiu, la hitbox
|
||||
// ignora els sarcòfags (passa a través del bloc complet) però
|
||||
// continua respectant el `insidePlayfield`: fora de la sala no hi
|
||||
// ha res a colpejar i ampliar-ho generaria visuals/edge cases
|
||||
// estranys. El mur extern, doncs, sempre retalla.
|
||||
const auto TILE = static_cast<float>(Map::tile_size);
|
||||
const int MAX_PX = static_cast<int>(std::max(0.0F, range_px));
|
||||
const auto MAX = static_cast<float>(MAX_PX);
|
||||
const bool PIERCES = GameplayConfig::get().whipDamagePiercesWalls();
|
||||
float length = 0.0F;
|
||||
|
||||
auto try_probe = [&](float l) -> bool {
|
||||
@@ -198,7 +206,9 @@ void WhipAttack::computeDamageRect(Direction dir, float spawn_x, float spawn_y,
|
||||
probe = {.x = spawn_x, .y = spawn_y + (MAX - l), .w = TILE, .h = l};
|
||||
break;
|
||||
}
|
||||
return Map::insidePlayfield(probe) && !Map::collides(probe);
|
||||
if (!Map::insidePlayfield(probe)) { return false; }
|
||||
if (PIERCES) { return true; }
|
||||
return !Map::collides(probe);
|
||||
};
|
||||
|
||||
for (int i = 1; i <= MAX_PX; ++i) {
|
||||
@@ -252,19 +262,10 @@ void WhipAttack::update(float delta_time) {
|
||||
|
||||
namespace {
|
||||
|
||||
auto whipDirToMummyDir(WhipAttack::Direction d) -> Mummy::Direction {
|
||||
switch (d) {
|
||||
case WhipAttack::Direction::NORTH:
|
||||
return Mummy::Direction::NORTH;
|
||||
case WhipAttack::Direction::SOUTH:
|
||||
return Mummy::Direction::SOUTH;
|
||||
case WhipAttack::Direction::EAST:
|
||||
return Mummy::Direction::EAST;
|
||||
case WhipAttack::Direction::WEST:
|
||||
return Mummy::Direction::WEST;
|
||||
}
|
||||
return Mummy::Direction::NONE;
|
||||
}
|
||||
// (El mapping WhipAttack::Direction → Mummy::Direction viu ara a
|
||||
// mummy.cpp, on s'usa des de `Mummy::onWhipHit`. Aquí només queda el
|
||||
// mapping a Player::Cardinal — usat per al friendly-fire entre
|
||||
// jugadors, que no és polimòrfic via Entity.)
|
||||
|
||||
auto whipDirToPlayerCardinal(WhipAttack::Direction d) -> Player::Cardinal {
|
||||
switch (d) {
|
||||
@@ -286,22 +287,24 @@ namespace {
|
||||
return STATE != Player::State::HURT && STATE != Player::State::EXITED && STATE != Player::State::DISABLED;
|
||||
}
|
||||
|
||||
// Tracta una mòmia candidata: si és whippable i la seua caja solapa
|
||||
// amb `damage_rect`, la posa a HURT i la marca al set. Extret per
|
||||
// acotar la complexitat cognitiva d'`applyDamage` (clang-tidy llindar).
|
||||
void hitMummyIfApplicable(
|
||||
Entity* e,
|
||||
// Tracta una entitat candidata: si la seva caixa solapa amb
|
||||
// `damage_rect`, delega la decisió a `entity->onWhipHit(attack)`.
|
||||
// Cada entitat decideix què fer (Mummy entra HURT, Torch retorna
|
||||
// false sense efecte, RollingRock no override → no-op). El retorn
|
||||
// true marca el set `already_hit` per evitar segons impactes dins del
|
||||
// mateix swing. Polimorfisme net — sense dynamic_cast.
|
||||
void hitEntityIfApplicable(
|
||||
Entity* entity,
|
||||
const SDL_FRect& damage_rect,
|
||||
WhipAttack::Direction dir,
|
||||
WhipAttack* attack,
|
||||
std::unordered_set<void*>& already_hit) {
|
||||
if (e == nullptr || e->isDead()) { return; }
|
||||
if (already_hit.contains(static_cast<void*>(e))) { return; }
|
||||
auto* mummy = dynamic_cast<Mummy*>(e);
|
||||
if (mummy == nullptr || !mummy->isWhippable()) { return; }
|
||||
const auto BOX = mummy->getCollisionRect();
|
||||
if (entity == nullptr || entity->isDead()) { return; }
|
||||
if (already_hit.contains(static_cast<void*>(entity))) { return; }
|
||||
const auto BOX = entity->getCollisionRect();
|
||||
if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; }
|
||||
mummy->enterHurt(whipDirToMummyDir(dir));
|
||||
already_hit.insert(static_cast<void*>(e));
|
||||
if (entity->onWhipHit(attack)) {
|
||||
already_hit.insert(static_cast<void*>(entity));
|
||||
}
|
||||
}
|
||||
|
||||
void hitPlayerIfApplicable(
|
||||
@@ -311,6 +314,10 @@ namespace {
|
||||
WhipAttack::Direction dir,
|
||||
std::unordered_set<void*>& already_hit) {
|
||||
if (p == owner || !playerIsTargetable(p)) { return; }
|
||||
// Tweak `combat.friendly_fire`: si està desactivat, els altres
|
||||
// jugadors són immunes al látigo aliat. L'owner ja queda exclòs
|
||||
// per la primera guarda; aquest filtre cobreix només cross-player.
|
||||
if (!GameplayConfig::get().friendlyFire()) { return; }
|
||||
if (already_hit.contains(static_cast<void*>(p))) { return; }
|
||||
const auto BOX = p->getBaseBox();
|
||||
if (!SDL_HasRectIntersectionFloat(&damage_rect, &BOX)) { return; }
|
||||
@@ -322,8 +329,14 @@ namespace {
|
||||
|
||||
void WhipAttack::applyDamage() {
|
||||
if (entity_manager_ != nullptr) {
|
||||
for (auto* e : entity_manager_->getEntities()) {
|
||||
hitMummyIfApplicable(e, damage_rect_, direction_, already_hit_);
|
||||
// Iterem totes les entitats. La discriminació la fa cada una via
|
||||
// `onWhipHit`: Mummy entra HURT si és whippable, la resta retorna
|
||||
// false (no-op). El cost d'iterar antorxes/sarcs/forats és una
|
||||
// crida virtual buida cada una — menyspreable comparat amb la
|
||||
// claredat del callsite. Si demà un sarc vol trencar-se al
|
||||
// fueteig, només cal override de `onWhipHit` allà.
|
||||
for (auto* entity : entity_manager_->getEntities()) {
|
||||
hitEntityIfApplicable(entity, damage_rect_, this, already_hit_);
|
||||
}
|
||||
}
|
||||
if (players_ != nullptr) {
|
||||
|
||||
@@ -28,6 +28,31 @@ void Entity::notifyTilePainted(int /*col*/, int /*row*/) {
|
||||
// No-op per defecte; només `Sarcophagus` l'usa.
|
||||
}
|
||||
|
||||
auto Entity::onWhipHit(WhipAttack* /*attack*/) -> bool {
|
||||
// No-op per defecte; les entitats no-whippables (Torch, Hole, Sarc,
|
||||
// Door, RollingRock...) retornen `false`. `Mummy` n'és l'override
|
||||
// efectiu. El caller usa el retorn per marcar `already_hit_` —
|
||||
// entitats que no han rebut dany NO bloquegen futurs frames del whip.
|
||||
return false;
|
||||
}
|
||||
|
||||
void Entity::onJustSpawned() {
|
||||
// No-op per defecte. `DynamicHazard` override per disparar el seu
|
||||
// `triggerSpawnExit` (anima la fase EMERGING just després de néixer).
|
||||
}
|
||||
|
||||
void Entity::onRemoved() {
|
||||
// No-op per defecte. `Sarcophagus` override per decrementar el
|
||||
// comptador `total_sarcs_` del manager.
|
||||
}
|
||||
|
||||
auto Entity::shouldReceiveContact() const -> bool {
|
||||
// Default: l'entitat rep contacte si és sòlida (`collides_`) o si és
|
||||
// un trigger d'àrea (`triggers_on_overlap_`). `DynamicHazard` afegeix
|
||||
// el filtre de SPAWN — mentre està emergint, ignora contactes.
|
||||
return collides_ || triggers_on_overlap_;
|
||||
}
|
||||
|
||||
auto Entity::getCollisionRect() const -> SDL_FRect {
|
||||
if (!collision_box_.has_value()) { return pixel_rect_; }
|
||||
return {
|
||||
|
||||
@@ -19,6 +19,7 @@ class Player;
|
||||
class EventBus;
|
||||
class Surface;
|
||||
class EntityManager;
|
||||
class WhipAttack;
|
||||
|
||||
// Entitat genèrica del món. Actua com a dada en comú per a tots els actors
|
||||
// del joc (decoratius, interactables, perills mòbils i estàtics): manté
|
||||
@@ -86,6 +87,38 @@ class Entity {
|
||||
// (per marcar perímetre); per defecte no-op.
|
||||
virtual void notifyTilePainted(int col, int row);
|
||||
|
||||
// L'entitat ha rebut un fueteig amb solapament. Es passa el punter al
|
||||
// `WhipAttack` perquè la subclasse pugui llegir-ne direcció o atributs
|
||||
// (mateix patró que `onPlayerContact(Player*)`). Retorna `true` si
|
||||
// efectivament ha aplicat dany — el caller en marca el set
|
||||
// `already_hit_` per evitar segons impactes dins del mateix swing.
|
||||
// Default: no fa res, retorna `false`. Override a `Mummy` (whippable)
|
||||
// i a futurs enemics whippables. `RollingRock` no override → roca
|
||||
// immune al látigo per defecte.
|
||||
virtual auto onWhipHit(WhipAttack* attack) -> bool;
|
||||
|
||||
// Hook just després de néixer al món. El manager el crida quan
|
||||
// `addSpawn` / `spawnActor` / `spawnPendingActors` instancien una
|
||||
// entitat. La base no fa res; `DynamicHazard` override per disparar
|
||||
// el seu `triggerSpawnExit` (anima la fase EMERGING). Substitueix el
|
||||
// `dynamic_cast<DynamicHazard*>(observer)->triggerSpawnExit()` que el
|
||||
// manager feia abans — ara el dispatch és polimòrfic.
|
||||
virtual void onJustSpawned();
|
||||
|
||||
// Hook just abans que el manager esborri l'entitat (via
|
||||
// `removeEntity`, típicament al editor). Permet a subclasses
|
||||
// notificar al manager o publicar events per mantenir invariants
|
||||
// (p.ex. `Sarcophagus` decrementa `total_sarcs_`). Default no-op.
|
||||
virtual void onRemoved();
|
||||
|
||||
// Decisió polimòrfica de si aquesta entitat ha de rebre el callback
|
||||
// `onPlayerContact` aquest frame. La base permet contacte si `collides_`
|
||||
// o `triggers_on_overlap_` estan posats. Subclasses poden afegir
|
||||
// condicions transitòries (p.ex. `DynamicHazard` retorna `false` mentre
|
||||
// està en SPAWN). Centralitza el filtre que abans vivia al manager
|
||||
// amb dynamic_casts/isSpawning explícits.
|
||||
[[nodiscard]] virtual auto shouldReceiveContact() const -> bool;
|
||||
|
||||
[[nodiscard]] auto getName() const -> const std::string& { return name_; }
|
||||
[[nodiscard]] auto getTilePos() const -> TilePos { return tile_; }
|
||||
[[nodiscard]] auto getPixelRect() const -> SDL_FRect { return pixel_rect_; }
|
||||
|
||||
@@ -90,12 +90,15 @@ void EntityManager::setDoor(Door* door) {
|
||||
auto EntityManager::removeEntity(Entity* observer) -> bool {
|
||||
if (observer == nullptr) { return false; }
|
||||
const bool WAS_DOOR = (door_ == observer);
|
||||
const bool WAS_SARC = (observer->getName() == "sarcophagus");
|
||||
// `onRemoved` és polimòrfic: cada subclasse decideix què cal
|
||||
// notificar quan se l'esborra. Avui només Sarcophagus l'override per
|
||||
// decrementar el seu comptador. La door es detecta per comparació de
|
||||
// punter (és única, no cal RTTI).
|
||||
observer->onRemoved();
|
||||
auto it = std::ranges::find_if(entities_, [observer](const auto& up) { return up.get() == observer; });
|
||||
if (it == entities_.end()) { return false; }
|
||||
entities_.erase(it);
|
||||
if (WAS_DOOR) { door_ = nullptr; }
|
||||
if (WAS_SARC && total_sarcs_ > 0) { --total_sarcs_; }
|
||||
// No tocar `opened_sarcs_` aquí: l'editor congela el joc i no pot
|
||||
// disparar-se cap esdeveniment derivat. Quan el GameScene faça el
|
||||
// reload posterior, els comptadors es re-deriven d'inici.
|
||||
@@ -127,17 +130,19 @@ void EntityManager::update(float delta_time) {
|
||||
}
|
||||
|
||||
void EntityManager::notifyPlayerTilePainted(int col, int row) {
|
||||
// Fan-out polimòrfic a totes les entitats: cada una decideix què fer
|
||||
// (`Sarcophagus` actualitza el seu perímetre, la resta queden en
|
||||
// no-op). Cost d'iterar 40 elements amb crida virtual buida ~ 1 µs;
|
||||
// qui paga el cost guanya simplicitat al callsite.
|
||||
for (auto& e : entities_) { e->notifyTilePainted(col, row); }
|
||||
}
|
||||
|
||||
auto EntityManager::findFloorTriggerAt(const SDL_FRect& rect) const -> const Entity* {
|
||||
// Cerca lineal sobre `entities_`: cost menyspreable amb poques
|
||||
// desenes d'entitats per sala i una crida per frame des dels
|
||||
// consumidors actius (Player a l'aterratge, Mòmia a la decisió de
|
||||
// direcció). Si esdevé calent, es podria indexar per cel·la del
|
||||
// tilemap. La doble condició filtra exclusivament decals de sòl que
|
||||
// volen rebre triggers — avui Hole; en el futur pinchos plans,
|
||||
// plaques de pressió, charcos.
|
||||
// Cerca lineal: només `Hole` (i futurs decals trigger) compleixen la
|
||||
// doble condició `triggersOnOverlap` + `isFloorDecal`. Costos típics
|
||||
// (poques desenes d'entitats) menyspreables, una crida per frame des
|
||||
// dels consumidors actius (Player a l'aterratge, Mòmia a la decisió
|
||||
// de direcció).
|
||||
for (const auto& entity : entities_) {
|
||||
if (!entity->triggersOnOverlap()) { continue; }
|
||||
if (!entity->isFloorDecal()) { continue; }
|
||||
|
||||
@@ -14,6 +14,7 @@ class Entity;
|
||||
class Sarcophagus;
|
||||
class Door;
|
||||
class Player;
|
||||
class DynamicHazard;
|
||||
|
||||
// Contenidor i coordinador d'entitats d'un nivell. Responsabilitats:
|
||||
// 1. `add` registra una entitat, la connecta al bus i manté propietat.
|
||||
@@ -73,7 +74,11 @@ class EntityManager {
|
||||
static void renderEntity(const Entity& entity);
|
||||
|
||||
// Accés als punters observacionals a les entitats per a consumidors
|
||||
// externs que necessiten construir una llista ordenada (z-order).
|
||||
// externs que necessiten iterar (z-order, aplicar dany, etc.). El
|
||||
// polimorfisme dins de cada entitat fa la discriminació: l'iterador
|
||||
// recorre tot i les entitats que no hagin de respondre tornen no-op
|
||||
// (p.ex. Torch::onWhipHit retorna false sense efecte). Sense sacs
|
||||
// separats — el `if (entity_is_X)` viu dins de l'entitat, no del caller.
|
||||
[[nodiscard]] auto getEntities() const -> std::vector<Entity*>;
|
||||
|
||||
// Comprova contacte del(s) jugador(s) amb entitats `collides()=true`.
|
||||
@@ -119,6 +124,23 @@ class EntityManager {
|
||||
// d'una posició del cursor i el passa.
|
||||
auto addSpawn(const fkyaml::node& spawn) -> Entity*;
|
||||
|
||||
// Instancia tots els actors que estan en `pending_actors_` (cua que
|
||||
// `loadFromLevel` omple amb les DynamicHazards declarades al YAML del
|
||||
// mapa). Cada actor neix en estat SPAWN (per defecte des de la
|
||||
// plantilla; veure `Mummy::Mummy`) i s'arma immediatament el
|
||||
// `triggerSpawnExit()` perquè la fase EMERGING corri els seus segons
|
||||
// de `spawn.duration_s` i transiti a ACTIVE. Vacia la cua. Cridada
|
||||
// des de GameScene en una subfase concreta de ROOM_INTRO i des de
|
||||
// `respawnPlayersInstant` (salt debug).
|
||||
void spawnPendingActors();
|
||||
|
||||
// Crea un actor on-demand i el deixa en EMERGING (com a
|
||||
// `spawnPendingActors` però per a un sol). Pensat per a futurs
|
||||
// generadors (StaticHazards que produeixen DynamicHazards en runtime,
|
||||
// p.ex. gotes que cauen, roques que rodolen). Retorna el punter
|
||||
// observacional o `nullptr` si la plantilla o el factory fallen.
|
||||
auto spawnActor(const std::string& type, int col, int row) -> DynamicHazard*;
|
||||
|
||||
// Reset complet del nivell: destrueix totes les entitats, reseteja
|
||||
// els comptadors interns (sarcòfags oberts, total, exited, flag de
|
||||
// level_complete) i torna a cridar `loadFromLevel` per regenerar les
|
||||
@@ -137,6 +159,7 @@ class EntityManager {
|
||||
|
||||
[[nodiscard]] auto getBus() -> EventBus& { return bus_; }
|
||||
[[nodiscard]] auto entityCount() const -> std::size_t { return entities_.size(); }
|
||||
[[nodiscard]] auto pendingActorCount() const -> std::size_t { return pending_actors_.size(); }
|
||||
[[nodiscard]] auto sarcophagusCount() const -> std::size_t { return total_sarcs_; }
|
||||
[[nodiscard]] auto openedSarcophagusCount() const -> std::size_t { return opened_sarcs_; }
|
||||
[[nodiscard]] auto exitedPlayerCount() const -> std::size_t { return exited_players_; }
|
||||
@@ -148,9 +171,34 @@ class EntityManager {
|
||||
// sarcs. No-op si la porta ja està oberta o si el nivell no en té.
|
||||
void forceOpenDoor();
|
||||
|
||||
// Hook que `Sarcophagus::onRemoved` crida per decrementar el
|
||||
// comptador `total_sarcs_`. Públic perquè el sarcòfag és la única
|
||||
// entitat que necessita aquest manteniment, però l'API es manté
|
||||
// genèrica: si en el futur Door volgués notificar quelcom semblant,
|
||||
// afegim el seu propi callback.
|
||||
void onSarcophagusRemoved() {
|
||||
if (total_sarcs_ > 0) { --total_sarcs_; }
|
||||
}
|
||||
|
||||
private:
|
||||
EventBus bus_;
|
||||
// Un sol contenidor lògic per a totes les entitats instanciades. La
|
||||
// discriminació de tipus la fan els virtuals (`onPlayerContact`,
|
||||
// `onWhipHit`, `shouldReceiveContact`, `notifyTilePainted`...) — el
|
||||
// manager itera i cada entitat decideix què fa. Hi conviuen
|
||||
// decoracions, static_hazards, interactables i dynamic_hazards
|
||||
// (actors).
|
||||
std::vector<std::unique_ptr<Entity>> entities_;
|
||||
// Cua d'actors declarats al YAML del mapa però encara no instanciats:
|
||||
// `loadFromLevel` hi empeny tots els DynamicHazards. `spawnPendingActors`
|
||||
// o `spawnActor` consumeixen aquesta cua (o creen un actor on-demand)
|
||||
// i ho buiden. El `fkyaml::node` original es guarda perquè conté la
|
||||
// posició i qualsevol override del map respecte a la plantilla.
|
||||
struct PendingActor {
|
||||
std::string type;
|
||||
fkyaml::node spawn_node;
|
||||
};
|
||||
std::vector<PendingActor> pending_actors_;
|
||||
Door* door_{nullptr};
|
||||
std::size_t total_sarcs_{0};
|
||||
std::size_t opened_sarcs_{0};
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#include "game/entities/entity_factory.hpp"
|
||||
#include "game/entities/entity_loader.hpp"
|
||||
#include "game/entities/entity_manager.hpp"
|
||||
#include "game/entities/hazard.hpp"
|
||||
#include "game/entities/player.hpp"
|
||||
#include "game/map/map.hpp"
|
||||
#include "utils/defaults.hpp"
|
||||
@@ -56,52 +57,56 @@ void EntityManager::renderEntity(const Entity& e) {
|
||||
}
|
||||
|
||||
void EntityManager::renderAll() {
|
||||
// Sense ordenació per z: l'ordre és el d'inserció al vector. El
|
||||
// render del joc real (GameScene) construeix el seu propi vector
|
||||
// ordenat per `feet_y`; aquest helper queda per al editor i tests.
|
||||
for (const auto& e : entities_) { renderEntity(*e); }
|
||||
}
|
||||
|
||||
namespace {
|
||||
// Filtres "per què aquest jugador no rep contacte d'aquest hazard ara
|
||||
// mateix": fora de joc, DYING, caient per forat (la transició a mort
|
||||
// per forat la mana `integrateJump`, no la pot interrompre cap
|
||||
// hazard), o salt per damunt de l'alçada del cos. Extret a funció
|
||||
// per acotar la complexitat cognitiva del callsite. Veure la nota a
|
||||
// `isFallingThroughHole`: si algun dia s'elimina aquest skip, cal
|
||||
// revisar la cadena DYING(HOLE) → respawn.
|
||||
auto playerSkipsHazard(const Player& p, int hazard_height_px) -> bool {
|
||||
if (p.isOutOfPlay() || p.isDying()) { return true; }
|
||||
if (p.isFallingThroughHole()) { return true; }
|
||||
return p.isAirborne() && hazard_height_px > 0 && p.getJumpZ() > static_cast<float>(hazard_height_px);
|
||||
}
|
||||
|
||||
auto playerOverlapsEntityBox(const Player& p, const SDL_FRect& box) -> bool {
|
||||
// Hit-box petita del jugador (`getHitBox`) en lloc de la caja base
|
||||
// 16×16 — per a "tocar una entitat" volem precisió, no rebot físic.
|
||||
const auto PBOX = p.getHitBox();
|
||||
return PBOX.x + PBOX.w > box.x && box.x + box.w > PBOX.x && PBOX.y + PBOX.h > box.y && box.y + box.h > PBOX.y;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) {
|
||||
if (players.empty()) { return; }
|
||||
for (const auto& entity : entities_) {
|
||||
// Acceptem entitats sòlides (xoc físic) i triggers d'àrea (no
|
||||
// bloquegen però volen rebre `onPlayerContact`). Tanquem el gap
|
||||
// latent de FloorTrap/WallTrap (que tenien `collides_=false` i
|
||||
// mai no es disparaven) i obrim la porta a hazards no-físics
|
||||
// com `Hole`.
|
||||
if (!entity->collides() && !entity->triggersOnOverlap()) { continue; }
|
||||
// Helper local: aplica el contacte d'una entitat sobre tots els
|
||||
// jugadors, amb els filtres per-jugador a `playerSkipsHazard`. Es
|
||||
// comparteix entre els dos sacs (map_entities_ i actors_).
|
||||
auto apply_contacts = [&players](Entity* entity) {
|
||||
const auto BOX = entity->getCollisionRect();
|
||||
const int E_HEIGHT = entity->getHeight();
|
||||
for (auto* p : players) {
|
||||
// Filtrem també DYING: el jugador en cicle de mort no rep
|
||||
// contacte amb hazards (un segon toc de mòmia mentre la roda
|
||||
// ja és no-op; cap altra entitat l'ha de fer transicionar).
|
||||
if (p == nullptr || p->isOutOfPlay() || p->isDying()) { continue; }
|
||||
// Mentre el jugador cau per un forat, ignorem qualsevol altre
|
||||
// hazard: la transició a `enterDying()` ja la dispara
|
||||
// `integrateJump` quan supera `HOLE_FALL_DEPTH`.
|
||||
//
|
||||
// NOTA: aquest skip és la defensa que evita que un jugador
|
||||
// caient per un forat reba contacte d'altres hazards
|
||||
// "subterranis" durant la caiguda. La cadena de mort per
|
||||
// forat (`enterDying(HOLE)` → respawn) depén de no ser
|
||||
// interrompuda per un event `player_hit`/`player_killed`
|
||||
// mentre `falling_through_hole_=true`. Si el dia que es
|
||||
// toque aquest skip s'elimina, cal revisar la cadena
|
||||
// mort/respawn per forat.
|
||||
if (p->isFallingThroughHole()) { continue; }
|
||||
// Filtre per altura: si el jugador està en l'aire i està per
|
||||
// damunt de l'alçada del cos de l'entitat, no hi ha contacte.
|
||||
// És el mecanisme del salt per esquivar momies, roques, etc.
|
||||
// Quan height és 0 (entitat sense altura declarada), el filtre
|
||||
// no es dispara mai i la col·lisió funciona com abans.
|
||||
if (p->isAirborne() && E_HEIGHT > 0 && p->getJumpZ() > static_cast<float>(E_HEIGHT)) { continue; }
|
||||
// Contacte actor-actor amb hit-box petita: la caixa base
|
||||
// 16×16 servia per moure (xoc amb murs), però per a l'event
|
||||
// "toca una entitat" volem precisió — que la momia mate quan
|
||||
// els píxels centrals es solapen, no quan els caps es freguen.
|
||||
const auto PBOX = p->getHitBox();
|
||||
const bool OVERLAP = PBOX.x + PBOX.w > BOX.x && BOX.x + BOX.w > PBOX.x && PBOX.y + PBOX.h > BOX.y && BOX.y + BOX.h > PBOX.y;
|
||||
if (OVERLAP) { entity->onPlayerContact(p); }
|
||||
if (p == nullptr || playerSkipsHazard(*p, E_HEIGHT)) { continue; }
|
||||
if (playerOverlapsEntityBox(*p, BOX)) { entity->onPlayerContact(p); }
|
||||
}
|
||||
};
|
||||
|
||||
// Filtre polimòrfic unificat: cada entitat decideix si ha de rebre
|
||||
// contacte (`shouldReceiveContact`). Per defecte equival a `collides()
|
||||
// || triggersOnOverlap()`; `DynamicHazard` afegeix el filtre de SPAWN
|
||||
// (mentre està emergint torna false). El callsite no fa dynamic_cast
|
||||
// ni distingeix categories.
|
||||
for (const auto& entity : entities_) {
|
||||
if (!entity->shouldReceiveContact()) { continue; }
|
||||
apply_contacts(entity.get());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +207,24 @@ namespace {
|
||||
return tmpl["anchor"].get_value<std::string>();
|
||||
}
|
||||
|
||||
// Llig el camp `type:` de la plantilla (la categoria semàntica:
|
||||
// "decoration", "static_hazard", "interactable", "dynamic_hazard",
|
||||
// "effect"). Tornarà buit si la plantilla no es pot carregar o no
|
||||
// declara el camp. S'usa al loader per decidir quin sac rep l'entitat.
|
||||
auto readTemplateCategory(const std::string& type) -> std::string {
|
||||
try {
|
||||
const auto TMPL = EntityLoader::loadTemplate(type);
|
||||
if (TMPL.contains("type")) { return TMPL["type"].get_value<std::string>(); }
|
||||
} catch (const std::exception& e) {
|
||||
// Plantilla absent / parse error: deixem el caller que ho
|
||||
// gestione (típicament `buildEntity` també fallarà i deixarà
|
||||
// el seu propi log). Aquí només avisem perquè el silenci
|
||||
// total amaga problemes de YAML mal formats.
|
||||
Log::warn("EntityManager", "readTemplateCategory({}) ha fallat: {}", type, e.what());
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// Coordenades píxel (canvas) d'una cel·la (col, row) del tilemap. El
|
||||
// grid del tilemap està 16-alineat respecte a `tilemap_origin_*`, que
|
||||
// pot ser negatiu per overscan CPC.
|
||||
@@ -383,6 +406,7 @@ void EntityManager::reload(const std::string& level_yaml_path) {
|
||||
// se subscriu directament al bus — només publiquen). El bus i les
|
||||
// subscripcions externes (PaintLayer, HUD, GameScene) no es toquen.
|
||||
entities_.clear();
|
||||
pending_actors_.clear();
|
||||
door_ = nullptr;
|
||||
total_sarcs_ = 0;
|
||||
opened_sarcs_ = 0;
|
||||
@@ -419,8 +443,21 @@ auto EntityManager::addSpawn(const fkyaml::node& spawn) -> Entity* {
|
||||
|
||||
void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
|
||||
const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path);
|
||||
const int LOADED = static_cast<int>(std::ranges::count_if(SPAWNS,
|
||||
[this](const fkyaml::node& spawn) { return addSpawn(spawn) != nullptr; }));
|
||||
int loaded = 0;
|
||||
for (const auto& spawn : SPAWNS) {
|
||||
if (!spawn.contains("type")) { continue; }
|
||||
const auto TYPE = spawn["type"].get_value<std::string>();
|
||||
// Spawn diferit: els DynamicHazards (mòmies, roques, futurs
|
||||
// enemics) no s'instancien aquí. GameScene crida
|
||||
// `spawnPendingActors()` al final del text-exit de ROOM_INTRO
|
||||
// (i, defensivament, en salts debug). La instanciació = la
|
||||
// seua aparició visual.
|
||||
if (readTemplateCategory(TYPE) == "dynamic_hazard") {
|
||||
pending_actors_.push_back(PendingActor{.type = TYPE, .spawn_node = spawn});
|
||||
continue;
|
||||
}
|
||||
if (addSpawn(spawn) != nullptr) { ++loaded; }
|
||||
}
|
||||
|
||||
// Broadcast inicial: marca com a "pintats" tots els tiles ocupats per
|
||||
// decals de sòl (Hole, i futurs pinchos plans, plaques, charcos...).
|
||||
@@ -432,6 +469,8 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
|
||||
// perquè tots els sarcòfags ja estan registrats al fan-out (la
|
||||
// subscripció del manager és al ctor, anterior a qualsevol load).
|
||||
int decal_broadcast = 0;
|
||||
// Els decals de sòl (Hole, futurs pinchos plans...) són StaticHazards;
|
||||
// iterem tot `entities_` i el filtre per `isFloorDecal()` els tria.
|
||||
for (const auto& entity : entities_) {
|
||||
if (!entity->isFloorDecal()) { continue; }
|
||||
const auto BOX = entity->getCollisionRect();
|
||||
@@ -447,5 +486,40 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
|
||||
Log::info("EntityManager", "{} tile_painted broadcast inicial (decals de sòl)", decal_broadcast);
|
||||
}
|
||||
|
||||
Log::info("EntityManager", "{} entitats carregades des de {}", LOADED, level_yaml_path);
|
||||
Log::info("EntityManager", "{} entitats + {} actors pending des de {}", loaded, pending_actors_.size(), level_yaml_path);
|
||||
}
|
||||
|
||||
void EntityManager::spawnPendingActors() {
|
||||
// Itera els pending i els instancia via `addSpawn`. El dispatch d'`add()`
|
||||
// els enruta a `actors_`. `onJustSpawned()` és polimòrfic: la base
|
||||
// no fa res, DynamicHazard l'override per disparar `triggerSpawnExit()`
|
||||
// i entrar a EMERGING. Sense dynamic_cast: la decisió viu dins de
|
||||
// cada entitat.
|
||||
for (const auto& pa : pending_actors_) {
|
||||
Entity* observer = addSpawn(pa.spawn_node);
|
||||
if (observer != nullptr) { observer->onJustSpawned(); }
|
||||
}
|
||||
pending_actors_.clear();
|
||||
}
|
||||
|
||||
auto EntityManager::spawnActor(const std::string& type, int col, int row) -> DynamicHazard* {
|
||||
// Construïm un node de spawn sintètic amb el mínim que `placeGeneric`
|
||||
// necessita: `type` i `position: [col, row]`. Si el caller vol
|
||||
// overrides addicionals (orientació inicial, durada de spawn...),
|
||||
// afegirà una variant amb fkyaml::node pre-construït més endavant.
|
||||
fkyaml::node spawn;
|
||||
try {
|
||||
const std::string YAML_TEXT = "type: " + type + "\nposition: [" + std::to_string(col) + ", " + std::to_string(row) + "]\n";
|
||||
spawn = fkyaml::node::deserialize(YAML_TEXT.begin(), YAML_TEXT.end());
|
||||
} catch (const std::exception&) {
|
||||
Log::error("EntityManager", "spawnActor: no s'ha pogut construir spawn YAML per {}", type);
|
||||
return nullptr;
|
||||
}
|
||||
Entity* observer = addSpawn(spawn);
|
||||
if (observer == nullptr) { return nullptr; }
|
||||
// `onJustSpawned` polimòrfic: per a DynamicHazards arma EMERGING; per
|
||||
// a un type erroni (no dynamic_hazard) queda no-op i retornem nullptr
|
||||
// mitjançant el cast final, deixant la signatura `DynamicHazard*` neta.
|
||||
observer->onJustSpawned();
|
||||
return dynamic_cast<DynamicHazard*>(observer);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "game/entities/entity.hpp"
|
||||
|
||||
// Base d'entitats perilloses: al contacte amb el jugador, apliquen dany.
|
||||
@@ -56,8 +58,20 @@ class StaticHazard : public Hazard {
|
||||
// Perill dinàmic: es mou. Velocitat expressada en píxels/segon i una
|
||||
// direcció (vec2). Les subclasses decideixen la política de moviment
|
||||
// (IA corridor_wanderer, línia recta, etc.) sobreescribint `step(dt)`.
|
||||
//
|
||||
// Estat SPAWN: tota DynamicHazard pot estar en SPAWNING (visible, sense
|
||||
// moviment ni dany) fins que algú extern arme el countdown amb
|
||||
// `triggerSpawnExit()`. Quan el comptador arriba a zero passa a ACTIVE i
|
||||
// recupera la lògica normal (`step()` per frame). Per defecte arrenca en
|
||||
// ACTIVE — backward-compat amb subclasses que encara no usen el mecanisme.
|
||||
// El filtre central de col·lisió a `EntityManager::checkPlayerCollisions`
|
||||
// salta les entitats en SPAWN: per això el contacte no fa dany ni dispara
|
||||
// triggers durant aquesta fase.
|
||||
class DynamicHazard : public Hazard {
|
||||
public:
|
||||
enum class SpawnState : std::uint8_t { SPAWNING,
|
||||
ACTIVE };
|
||||
|
||||
DynamicHazard() = default;
|
||||
|
||||
void setSpeed(float px_per_s) { speed_ = px_per_s; }
|
||||
@@ -70,6 +84,40 @@ class DynamicHazard : public Hazard {
|
||||
[[nodiscard]] auto getDirX() const -> float { return dir_x_; }
|
||||
[[nodiscard]] auto getDirY() const -> float { return dir_y_; }
|
||||
|
||||
// Entra en SPAWNING amb una duració determinada. El comptador NO
|
||||
// descompta fins que algú crida `triggerSpawnExit()` — l'entitat queda
|
||||
// visible i quieta esperant indefinidament.
|
||||
void enterSpawn(float duration_s) {
|
||||
spawn_state_ = SpawnState::SPAWNING;
|
||||
spawn_duration_s_ = duration_s;
|
||||
spawn_remaining_s_ = duration_s;
|
||||
spawn_exit_armed_ = false;
|
||||
}
|
||||
// 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_; }
|
||||
|
||||
// Just després d'instanciar-se al món (via spawnPendingActors /
|
||||
// spawnActor), l'actor entra automàticament en la fase EMERGING:
|
||||
// arma el comptador i comença l'animació de sortida. Substitueix el
|
||||
// dynamic_cast que el manager feia abans per cridar `triggerSpawnExit`.
|
||||
void onJustSpawned() override { triggerSpawnExit(); }
|
||||
|
||||
// Mentre està en SPAWN (HIDDEN/EMERGING), l'actor NO rep contacte
|
||||
// ni amb el jugador ni amb attacks: visible però "no juga". Quan
|
||||
// passa a ACTIVE, recupera el comportament estàndard d'Entity.
|
||||
[[nodiscard]] auto shouldReceiveContact() const -> bool override {
|
||||
if (isSpawning()) { return false; }
|
||||
return Entity::shouldReceiveContact();
|
||||
}
|
||||
|
||||
// Política de moviment per frame. La base desplaça el pixel_rect_
|
||||
// proporcionalment a velocitat i direcció; les subclasses amb IA la
|
||||
// sobreescriuen per triar nova direcció a les interseccions o rebotar.
|
||||
@@ -79,6 +127,19 @@ class DynamicHazard : public Hazard {
|
||||
}
|
||||
|
||||
void update(float delta_time) override {
|
||||
if (spawn_state_ == SpawnState::SPAWNING) {
|
||||
// Sense `step()`: l'entitat es queda quieta al pixel_rect_
|
||||
// inicial. `Entity::update` tic d'animació igual: si en algun
|
||||
// moment posem un GIF de spawn aquí ja anirà.
|
||||
Entity::update(delta_time);
|
||||
if (spawn_exit_armed_) {
|
||||
spawn_remaining_s_ -= delta_time;
|
||||
if (spawn_remaining_s_ <= 0.0F) {
|
||||
spawn_state_ = SpawnState::ACTIVE;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
Entity::update(delta_time);
|
||||
step(delta_time);
|
||||
}
|
||||
@@ -87,4 +148,8 @@ class DynamicHazard : public Hazard {
|
||||
float speed_{0.0F};
|
||||
float dir_x_{0.0F};
|
||||
float dir_y_{0.0F};
|
||||
SpawnState spawn_state_{SpawnState::ACTIVE};
|
||||
float spawn_duration_s_{0.0F};
|
||||
float spawn_remaining_s_{0.0F};
|
||||
bool spawn_exit_armed_{false};
|
||||
};
|
||||
@@ -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"
|
||||
@@ -22,6 +23,7 @@
|
||||
#include "game/entities/entity.hpp"
|
||||
#include "game/entities/entity_manager.hpp"
|
||||
#include "game/entities/event_bus.hpp"
|
||||
#include "game/gameplay_config.hpp"
|
||||
#include "game/options.hpp"
|
||||
#include "utils/defaults.hpp"
|
||||
|
||||
@@ -178,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.
|
||||
@@ -414,6 +415,10 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool {
|
||||
// al check actor↔actor més avall via el predicat `isOutOfPlay`.
|
||||
if (isOutOfPlay()) { return false; }
|
||||
if (map_.collides(box)) { return true; }
|
||||
// Tweak `players.collide_with_each_other`: si està desactivat, els
|
||||
// jugadors es travessen lliurement. La col·lisió contra el món
|
||||
// (murs/sarcs) ja s'ha avaluat dalt; saltem només la part actor↔actor.
|
||||
if (!GameplayConfig::get().playersCollide()) { return false; }
|
||||
if (all_players_ == nullptr) { return false; }
|
||||
// Per al contacte actor-actor usem la hit-box petita a banda i banda:
|
||||
// projectem la hit-box des de la posició base proposada i la comparem
|
||||
@@ -1027,6 +1032,12 @@ void Player::resolveLandingOverlap() {
|
||||
// canvi, qualsevol jugador amb el qual solape entra en HURT i
|
||||
// surt projectat. Així el solapament inicial es resol per moviment
|
||||
// del solapat, no per desplaçament forçat al lloc.
|
||||
//
|
||||
// Tweak `players.collide_with_each_other`: si està desactivat, el
|
||||
// saltador travessa l'altre net en lloc de projectar-lo. Aquest
|
||||
// camí és independent de `collidesWithWorld`, així que cal aturar-lo
|
||||
// explícitament aquí.
|
||||
if (!GameplayConfig::get().playersCollide()) { return; }
|
||||
if (all_players_ == nullptr) { return; }
|
||||
for (Player* other : *all_players_) {
|
||||
if (other == nullptr || other == this) { continue; }
|
||||
@@ -1259,11 +1270,30 @@ void Player::applyDyingMovement(float delta_time) {
|
||||
}
|
||||
}
|
||||
|
||||
void Player::resetForJoin() {
|
||||
lives_remaining_ = death_config.initial_lives;
|
||||
}
|
||||
|
||||
void Player::respawnFromDying() {
|
||||
// Vides: decreix sempre. Pot anar a negatius — no hi ha game over.
|
||||
// Vides: decreix una unitat. Si el comptador arriba a 0 el jugador
|
||||
// surt de partida (DISABLED) i la GameScene/HUD reaccionen via bus
|
||||
// — això habilita el flux de "join in progress" (l'altre jugador
|
||||
// pot pulsar START per entrar) i obrirà el camí al CONTINUE? +
|
||||
// GAME OVER en una fase posterior.
|
||||
--lives_remaining_;
|
||||
death_animation_complete_ = false;
|
||||
respawn_remaining_ms_ = 0.0F;
|
||||
if (lives_remaining_ <= 0) {
|
||||
disable();
|
||||
if (bus_ != nullptr) {
|
||||
bus_->publish(Event{
|
||||
.name = "player_disabled",
|
||||
.payload = std::monostate{},
|
||||
.player_id = id_,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Cinemàtica idèntica a la d'INTRO de sala: el jugador cau des de
|
||||
// dalt fins al passadís d'spawn (`spawnFromAir` reseteja primer i
|
||||
// després posa AIRBORNE amb l'altura derivada de la geometria).
|
||||
@@ -1405,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
|
||||
@@ -1559,10 +1570,13 @@ void Player::respawn(float x, float y) {
|
||||
setPosX(x);
|
||||
setPosY(y);
|
||||
// Immunitat post-spawn: armem la finestra de gràcia amb la durada del
|
||||
// YAML. Cobreix l'aparició inicial (`spawnFromAir` → `respawn`) i el
|
||||
// respawn post-mort (`respawnFromDying` → `respawn`). Si el bloc
|
||||
// `immunity:` manca al YAML, `duration_ms` és 0 i `setImmunity` és
|
||||
// no-op — comportament idèntic al d'abans.
|
||||
// YAML. Cobreix el join mid-partida (`joinPlayer` → `spawnFromAir` →
|
||||
// `respawn`) i el respawn post-mort (`respawnFromDying` → `respawn`).
|
||||
// L'spawn inicial de ROOM_INTRO també hi passa, però `triggerPlayerSpawn`
|
||||
// crida `setImmunity(0)` immediatament després per cancel·lar-la (els
|
||||
// enemics ja estan apareixent i el jugador no ha de tindre gràcia).
|
||||
// Si el bloc `immunity:` manca al YAML, `duration_ms` és 0 i el timer
|
||||
// queda a 0 — sense gràcia, comportament idèntic al d'abans.
|
||||
setImmunity(immunity_config.duration_ms);
|
||||
}
|
||||
|
||||
@@ -1737,6 +1751,10 @@ auto Player::tryPushSnapBlockers(
|
||||
float (Player::*get_pos)() const,
|
||||
void (Player::*set_pos)(float)) -> bool {
|
||||
if (all_players_ == nullptr) { return false; }
|
||||
// Si el tweak de col·lisió entre jugadors està desactivat, mai hi
|
||||
// ha res a empentar — els jugadors són intangibles entre si i el
|
||||
// snap pot procedir lliurement sense desplaçar ningú.
|
||||
if (!GameplayConfig::get().playersCollide()) { return false; }
|
||||
|
||||
// Blockers = jugadors que ara mateix solapen la nostra hit-box (ja
|
||||
// amb el snap aplicat). Mateix criteri actor-actor que `collidesWithWorld`.
|
||||
|
||||
@@ -127,6 +127,11 @@ class Player : public AnimatedSprite {
|
||||
// Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor.
|
||||
static void loadFrom(const std::string& yaml_path);
|
||||
|
||||
// Vides amb què arrenca un jugador (camp `lives:` del YAML del
|
||||
// jugador). El consumeix el HUD per inicialitzar el comptador del
|
||||
// marcador i `Player::resetForJoin` per a re-entrades mid-partida.
|
||||
[[nodiscard]] static auto getInitialLives() -> int { return death_config.initial_lives; }
|
||||
|
||||
Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index = -1);
|
||||
|
||||
// Update en una sola crida: manté l'API anterior per als tests i
|
||||
@@ -296,8 +301,11 @@ class Player : public AnimatedSprite {
|
||||
// `death_spinning`, posició congelada, input ignorat. Idempotent —
|
||||
// si ja està DYING, EXITED o DISABLED, és no-op (un segon toc de
|
||||
// momia mentre la roda no la reinicia). Quan l'animació acaba i
|
||||
// `respawn_delay_ms` expira, `respawn(spawn_x_, spawn_y_)` torna a
|
||||
// GROUNDED i `lives_remaining_` decreix en 1 (pot anar a negatius).
|
||||
// `respawn_delay_ms` expira, `respawnFromDying()` decrementa
|
||||
// `lives_remaining_`: si encara queden vides, fa `spawnFromAir`
|
||||
// al spawn point; si arriba a 0, fa `disable()` i publica al bus
|
||||
// `player_disabled` perquè GameScene/HUD reaccionin (slot fora de
|
||||
// partida, esperant un nou START per re-entrar via "join in progress").
|
||||
void enterDying(DeathCause cause = DeathCause::GENERIC);
|
||||
|
||||
// Coordenades del spawn de respawn (cantó superior-esquerra del
|
||||
@@ -309,9 +317,11 @@ class Player : public AnimatedSprite {
|
||||
spawn_y_ = y;
|
||||
}
|
||||
|
||||
// Comptador d'intents (estil Mario, NO HP). Decreix a cada respawn;
|
||||
// no es clampa a 0 — permet anar a negatius perquè avui no hi ha
|
||||
// game over. Públic per a HUD i tests.
|
||||
// Comptador d'intents (estil Mario, NO HP). Decreix a cada respawn
|
||||
// post-mort. En arribar a 0 el jugador passa a DISABLED i publica
|
||||
// `player_disabled` al bus; pot tornar a partida via "join in
|
||||
// progress" (GameScene::joinPlayer crida `resetForJoin`). Públic
|
||||
// per a HUD i tests.
|
||||
[[nodiscard]] auto getLivesRemaining() const -> int { return lives_remaining_; }
|
||||
|
||||
// Activa la immunitat per `duration_ms` ms. Mentre `isImmune()` és
|
||||
@@ -321,11 +331,11 @@ class Player : public AnimatedSprite {
|
||||
// sortida lògica del món. Idempotent: una segona crida reescriu el
|
||||
// timer (no acumula). Visualment, el render alterna la paleta del
|
||||
// sprite amb una paleta plana cada `immunity_config.blink_period_ms`
|
||||
// ms. Si `duration_ms <= 0` és no-op (manera de cancel·lar
|
||||
// explícitament la immunitat seria `setImmunity(0)` — equivalent al
|
||||
// comportament natural quan el timer s'esgota).
|
||||
// ms. `setImmunity(0)` cancel·la la immunitat activa (sobreescriu el
|
||||
// timer a 0) — equivalent al comportament natural quan el timer
|
||||
// s'esgota. Valors negatius es clampejen a 0.
|
||||
void setImmunity(float duration_ms) {
|
||||
if (duration_ms > 0.0F) { immunity_remaining_ms_ = duration_ms; }
|
||||
immunity_remaining_ms_ = duration_ms > 0.0F ? duration_ms : 0.0F;
|
||||
}
|
||||
|
||||
// Cert mentre el jugador té immunitat activa. Comprovat per
|
||||
@@ -342,6 +352,14 @@ class Player : public AnimatedSprite {
|
||||
// sala usa `disable()` + `spawnFromAir()`.
|
||||
void respawn(float x, float y);
|
||||
|
||||
// Restaura el comptador de vides al valor inicial configurat
|
||||
// (`death_config.initial_lives`). Cridat per la GameScene quan un
|
||||
// slot inactiu pulsa START per entrar mid-partida (regla 6 de la
|
||||
// FSM de joc): el jugador entra amb les vides plenes. La resta de
|
||||
// l'estat (posició, animació, flags) la reseteja `spawnFromAir`
|
||||
// que GameScene crida tot seguit.
|
||||
void resetForJoin();
|
||||
|
||||
// Posa el jugador en el slot inactiu (DISABLED). Idempotent: la
|
||||
// segona crida és no-op. Mateix patró que `exit()` per simetria.
|
||||
// No publica res al bus — el GameScene decideix quan disparar el
|
||||
@@ -615,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).
|
||||
};
|
||||
@@ -829,8 +846,10 @@ class Player : public AnimatedSprite {
|
||||
|
||||
// Comptador d'intents (estil Mario). Inicialitzat a `death_config.
|
||||
// initial_lives` al ctor; decreix una vegada per cada respawn
|
||||
// post-mort (no per respawns de canvi de sala). Pot anar a negatius:
|
||||
// avui no hi ha game over.
|
||||
// post-mort (no per respawns de canvi de sala). En arribar a 0,
|
||||
// `respawnFromDying` posa el jugador a DISABLED i publica
|
||||
// `player_disabled` al bus. `resetForJoin()` el restaura al valor
|
||||
// inicial quan el jugador re-entra mid-partida.
|
||||
int lives_remaining_{0};
|
||||
|
||||
// Coordenades del spawn de respawn (cantó superior-esquerra del
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
#include "game/gameplay_config.hpp"
|
||||
|
||||
#include <exception>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
#include "external/fkyaml_node.hpp"
|
||||
#include "utils/log.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
auto readFile(const std::string& path) -> std::string {
|
||||
std::ifstream file(path);
|
||||
if (!file) { return {}; }
|
||||
std::stringstream buffer;
|
||||
buffer << file.rdbuf();
|
||||
return buffer.str();
|
||||
}
|
||||
|
||||
// Llig una subclau booleana obligatòria (`section.key`). Retorna
|
||||
// false si la secció o la clau no hi són o el tipus no és bool —
|
||||
// qualsevol d'aquests casos és una irregularitat que dispara
|
||||
// reescriptura del fitxer.
|
||||
auto readRequiredBool(const fkyaml::node& root, const char* section, const char* key, bool& out)
|
||||
-> bool {
|
||||
if (!root.contains(section)) { return false; }
|
||||
const auto& sec = root[section];
|
||||
if (!sec.contains(key)) { return false; }
|
||||
try {
|
||||
out = sec[key].get_value<bool>();
|
||||
return true;
|
||||
} catch (const std::exception&) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Escriu el fitxer amb els valors actuals + comentaris d'ajuda.
|
||||
// Format estable perquè l'usuari pot editar-lo a mà.
|
||||
void writeFile(const std::string& path,
|
||||
int version,
|
||||
bool whip_pierces,
|
||||
bool friendly_fire,
|
||||
bool players_collide) {
|
||||
std::ofstream file(path);
|
||||
if (!file) {
|
||||
Log::error("gameplay.yaml", "no s'ha pogut escriure {}", path);
|
||||
return;
|
||||
}
|
||||
file << "# gameplay.yaml — tweaks de jugabilitat persistents.\n";
|
||||
file << "# Edita des del menú de servei (F9 → Tweaks) o a mà.\n";
|
||||
file << "# Si esborres el fitxer (o aquesta capçalera de versió), es regenerarà\n";
|
||||
file << "# amb els defaults a la propera arrencada.\n";
|
||||
file << "\n";
|
||||
file << "version: " << version << "\n";
|
||||
file << "\n";
|
||||
file << "whip:\n";
|
||||
file << " damage_pierces_walls: " << (whip_pierces ? "true" : "false") << "\n";
|
||||
file << "\n";
|
||||
file << "combat:\n";
|
||||
file << " friendly_fire: " << (friendly_fire ? "true" : "false") << "\n";
|
||||
file << "\n";
|
||||
file << "players:\n";
|
||||
file << " collide_with_each_other: " << (players_collide ? "true" : "false") << "\n";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
auto GameplayConfig::get() -> GameplayConfig& {
|
||||
static GameplayConfig instance_;
|
||||
return instance_;
|
||||
}
|
||||
|
||||
void GameplayConfig::load(const std::string& file_path) {
|
||||
file_path_ = file_path;
|
||||
|
||||
std::error_code error;
|
||||
const bool EXISTS = std::filesystem::exists(file_path, error);
|
||||
const auto CONTENT = EXISTS ? readFile(file_path) : std::string{};
|
||||
|
||||
// Tots els valors van a temporaris; només ens els quedem si la
|
||||
// validació completa passa. Així una validació parcial (p. ex. dues
|
||||
// claus bones i una de dolenta) no deixa l'estat a mig camí.
|
||||
bool whip_pierces = whip_damage_pierces_walls_;
|
||||
bool ff = friendly_fire_;
|
||||
bool players_collide = players_collide_;
|
||||
|
||||
auto rewrite_with_defaults = [&](const char* reason) {
|
||||
// Resetem els camps als defaults compilats abans de reescriure.
|
||||
// Necessari si load() es crida diverses vegades amb fitxers
|
||||
// distints; el primer pot haver mutat l'estat.
|
||||
whip_damage_pierces_walls_ = true;
|
||||
friendly_fire_ = true;
|
||||
players_collide_ = true;
|
||||
writeFile(file_path,
|
||||
CURRENT_VERSION,
|
||||
whip_damage_pierces_walls_,
|
||||
friendly_fire_,
|
||||
players_collide_);
|
||||
Log::info("gameplay.yaml", "{} — reescrit a {} amb defaults", reason, file_path);
|
||||
};
|
||||
|
||||
if (!EXISTS) {
|
||||
rewrite_with_defaults("fitxer absent");
|
||||
return;
|
||||
}
|
||||
if (CONTENT.empty()) {
|
||||
rewrite_with_defaults("fitxer buit");
|
||||
return;
|
||||
}
|
||||
|
||||
fkyaml::node yaml;
|
||||
try {
|
||||
yaml = fkyaml::node::deserialize(CONTENT);
|
||||
} catch (const std::exception& e) {
|
||||
rewrite_with_defaults((std::string("parse error: ") + e.what()).c_str());
|
||||
return;
|
||||
}
|
||||
|
||||
// `version` ha de ser un enter present i igual a CURRENT_VERSION.
|
||||
// Qualsevol altra cosa (absent, tipus no enter, valor diferent) és
|
||||
// una irregularitat: el fitxer ve d'una versió incompatible o ha
|
||||
// estat manipulat de manera inesperada.
|
||||
if (!yaml.contains("version")) {
|
||||
rewrite_with_defaults("version absent");
|
||||
return;
|
||||
}
|
||||
int version = 0;
|
||||
try {
|
||||
version = yaml["version"].get_value<int>();
|
||||
} catch (const std::exception&) {
|
||||
rewrite_with_defaults("version no és enter");
|
||||
return;
|
||||
}
|
||||
if (version != CURRENT_VERSION) {
|
||||
rewrite_with_defaults("version desconeguda");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!readRequiredBool(yaml, "whip", "damage_pierces_walls", whip_pierces) || !readRequiredBool(yaml, "combat", "friendly_fire", ff) || !readRequiredBool(yaml, "players", "collide_with_each_other", players_collide)) {
|
||||
rewrite_with_defaults("clau requerida absent o de tipus incorrecte");
|
||||
return;
|
||||
}
|
||||
|
||||
// Tot validat: ens quedem els valors llegits.
|
||||
whip_damage_pierces_walls_ = whip_pierces;
|
||||
friendly_fire_ = ff;
|
||||
players_collide_ = players_collide;
|
||||
}
|
||||
|
||||
void GameplayConfig::save() const {
|
||||
if (file_path_.empty()) {
|
||||
// Sense path establert: cap consumidor s'ha registrat encara.
|
||||
// Pot passar en tests que no carreguen el fitxer; silenciem el cas.
|
||||
return;
|
||||
}
|
||||
writeFile(file_path_,
|
||||
CURRENT_VERSION,
|
||||
whip_damage_pierces_walls_,
|
||||
friendly_fire_,
|
||||
players_collide_);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
// Tweaks de jugabilitat persistents per usuari. Carregat de
|
||||
// `gameplay.yaml` dins la carpeta de sistema (vegeu
|
||||
// `Director::createSystemFolder` per la ubicació real per plataforma).
|
||||
//
|
||||
// Diferent d'UiConfig (data/config/ui.yaml, només lectura): aquest fitxer
|
||||
// és escrivible i el ServiceMenu en modifica els valors. Els defaults
|
||||
// compilats són els que veuen els nous usuaris fins que toquen el menú.
|
||||
//
|
||||
// Política d'integritat: davant qualsevol irregularitat (fitxer absent,
|
||||
// parse error, `version` absent o diferent, clau requerida absent o de
|
||||
// tipus incorrecte), `load()` reseteja a defaults i reescriu el fitxer.
|
||||
// No es preserven fitxers parcialment vàlids — així evitem dragar
|
||||
// versions antigues incoherents quan l'esquema evolucioni.
|
||||
class GameplayConfig {
|
||||
public:
|
||||
// Esquema actual del fitxer. Incrementar quan canviï l'estructura
|
||||
// (renomenar/treure claus o canviar semàntica). Els fitxers d'usuari
|
||||
// amb una `version` distinta es reescriuen amb els defaults nous.
|
||||
// Afegir noves claus opcionals no requereix bump si els defaults
|
||||
// compilats són equivalents al comportament anterior.
|
||||
static constexpr int CURRENT_VERSION = 1;
|
||||
|
||||
static auto get() -> GameplayConfig&;
|
||||
|
||||
GameplayConfig(const GameplayConfig&) = delete;
|
||||
GameplayConfig(GameplayConfig&&) = delete;
|
||||
auto operator=(const GameplayConfig&) -> GameplayConfig& = delete;
|
||||
auto operator=(GameplayConfig&&) -> GameplayConfig& = delete;
|
||||
|
||||
// Llig el fitxer i guarda la ruta per a `save()`. Si no existeix,
|
||||
// l'escriu amb els defaults compilats. Idempotent: pot cridar-se
|
||||
// múltiples vegades (l'última crida fixa el path actiu).
|
||||
void load(const std::string& file_path);
|
||||
|
||||
// Persisteix l'estat actual al path passat a `load()`. Cridat pel
|
||||
// ServiceMenu a cada canvi de toggle. No-op si `load()` no s'ha
|
||||
// cridat encara (sense path no podem escriure enlloc útil).
|
||||
void save() const;
|
||||
|
||||
// Tweaks. Defaults: tots true.
|
||||
//
|
||||
// - whip_damage_pierces_walls: el dany del látigo travessa sarcòfags
|
||||
// (NO el mur extern del playfield, que retalla igualment per
|
||||
// evitar que el rect surti de la sala visible).
|
||||
// - friendly_fire: un latigazo pot impactar l'altre jugador.
|
||||
// - players_collide: els jugadors col·lisionen físicament entre si
|
||||
// en lloc de travessar-se.
|
||||
[[nodiscard]] auto whipDamagePiercesWalls() const -> bool { return whip_damage_pierces_walls_; }
|
||||
[[nodiscard]] auto friendlyFire() const -> bool { return friendly_fire_; }
|
||||
[[nodiscard]] auto playersCollide() const -> bool { return players_collide_; }
|
||||
|
||||
void setWhipDamagePiercesWalls(bool v) { whip_damage_pierces_walls_ = v; }
|
||||
void setFriendlyFire(bool v) { friendly_fire_ = v; }
|
||||
void setPlayersCollide(bool v) { players_collide_ = v; }
|
||||
|
||||
private:
|
||||
GameplayConfig() = default;
|
||||
~GameplayConfig() = default;
|
||||
|
||||
// Ruta absoluta al fitxer persistit. Buida fins que algú crida
|
||||
// `load()` per primera vegada; mentre estigui buida, `save()` és no-op.
|
||||
std::string file_path_;
|
||||
|
||||
bool whip_damage_pierces_walls_{true};
|
||||
bool friendly_fire_{true};
|
||||
bool players_collide_{true};
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
#include "game/scenes/game_over_fsm.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
|
||||
#include "core/resources/resource_helper.hpp"
|
||||
#include "external/fkyaml_node.hpp"
|
||||
#include "utils/log.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
// Llegeix un sub-camp sencer d'un node YAML. Si manca, retorna el
|
||||
// fallback. Mateix patró que `RoomFsm::loadConfig`.
|
||||
auto readChildInt(const fkyaml::node& parent, const char* key, int fallback) -> int {
|
||||
if (!parent.contains(key)) { return fallback; }
|
||||
return parent[key].get_value<int>();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace GameOverFsm {
|
||||
|
||||
auto loadConfig(const std::string& yaml_path) -> Config {
|
||||
Config cfg{};
|
||||
auto bytes = Resource::Helper::loadFile(yaml_path);
|
||||
if (bytes.empty()) {
|
||||
Log::warn("GameOverFsm", "{} absent — uso fallbacks compilats", yaml_path);
|
||||
return cfg;
|
||||
}
|
||||
const auto* begin = reinterpret_cast<const char*>(bytes.data());
|
||||
const auto* end = begin + bytes.size();
|
||||
auto yaml = fkyaml::node::deserialize(begin, end);
|
||||
|
||||
if (yaml.contains("game_over")) {
|
||||
const auto& n = yaml["game_over"];
|
||||
cfg.continue_text_enter_ms = readChildInt(n, "continue_text_enter_ms", cfg.continue_text_enter_ms);
|
||||
cfg.continue_text_hold_ms = readChildInt(n, "continue_text_hold_ms", cfg.continue_text_hold_ms);
|
||||
cfg.continue_text_exit_ms = readChildInt(n, "continue_text_exit_ms", cfg.continue_text_exit_ms);
|
||||
cfg.continue_count_start = readChildInt(n, "continue_count_start", cfg.continue_count_start);
|
||||
cfg.game_over_text_hold_ms = readChildInt(n, "game_over_text_hold_ms", cfg.game_over_text_hold_ms);
|
||||
cfg.game_over_text_exit_ms = readChildInt(n, "game_over_text_exit_ms", cfg.game_over_text_exit_ms);
|
||||
cfg.game_over_wait_ms = readChildInt(n, "game_over_wait_ms", cfg.game_over_wait_ms);
|
||||
cfg.fade_out_ms = readChildInt(n, "fade_out_ms", cfg.fade_out_ms);
|
||||
cfg.black_hold_ms = readChildInt(n, "black_hold_ms", cfg.black_hold_ms);
|
||||
if (n.contains("continue_music_speed")) {
|
||||
cfg.continue_music_speed = n["continue_music_speed"].get_value<float>();
|
||||
}
|
||||
if (n.contains("game_over_music_track")) {
|
||||
cfg.game_over_music_track = n["game_over_music_track"].get_value<std::string>();
|
||||
}
|
||||
if (n.contains("countdown_sound_key")) {
|
||||
cfg.countdown_sound_key = n["countdown_sound_key"].get_value<std::string>();
|
||||
}
|
||||
cfg.skip_below_or_equal = readChildInt(n, "skip_below_or_equal", cfg.skip_below_or_equal);
|
||||
}
|
||||
|
||||
Log::info("GameOverFsm", "timings carregats des de {} (continue hold {}ms, fade {}ms)", yaml_path, cfg.continue_text_hold_ms, cfg.fade_out_ms);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
auto advance(State current, Transition& t, const Config& cfg, int delta_ms) -> State {
|
||||
if (current == State::OFF || current == State::DONE) { return current; }
|
||||
|
||||
t.elapsed_ms += delta_ms;
|
||||
|
||||
if (current == State::CONTINUE_PROMPT) {
|
||||
const std::array<int, 3> DURS = {
|
||||
cfg.continue_text_enter_ms,
|
||||
cfg.continue_text_hold_ms,
|
||||
cfg.continue_text_exit_ms,
|
||||
};
|
||||
while (t.phase < static_cast<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) {
|
||||
t.elapsed_ms -= DURS[t.phase];
|
||||
// Si acabem la fase 1 (hold) sense cancel·lació, saltem
|
||||
// directament a GAME_OVER — el panel queda amunt i no
|
||||
// es reprodueix l'exit anim. La fase 2 (exit) només
|
||||
// s'executa si el GameScene l'ha forçada amb
|
||||
// `cancelContinuePrompt`.
|
||||
if (t.phase == 1) {
|
||||
return State::GAME_OVER;
|
||||
}
|
||||
++t.phase;
|
||||
}
|
||||
if (t.phase >= static_cast<int>(DURS.size())) { return State::OFF; }
|
||||
return current;
|
||||
}
|
||||
|
||||
if (current == State::GAME_OVER) {
|
||||
const std::array<int, 3> DURS = {
|
||||
cfg.game_over_text_hold_ms,
|
||||
cfg.game_over_text_exit_ms,
|
||||
cfg.game_over_wait_ms,
|
||||
};
|
||||
while (t.phase < static_cast<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) {
|
||||
t.elapsed_ms -= DURS[t.phase];
|
||||
++t.phase;
|
||||
}
|
||||
if (t.phase >= static_cast<int>(DURS.size())) { return State::FADE_OUT; }
|
||||
return current;
|
||||
}
|
||||
|
||||
// FADE_OUT
|
||||
const std::array<int, 2> DURS = {
|
||||
cfg.fade_out_ms,
|
||||
cfg.black_hold_ms,
|
||||
};
|
||||
while (t.phase < static_cast<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) {
|
||||
t.elapsed_ms -= DURS[t.phase];
|
||||
++t.phase;
|
||||
}
|
||||
if (t.phase >= static_cast<int>(DURS.size())) { return State::DONE; }
|
||||
return current;
|
||||
}
|
||||
|
||||
void cancelContinuePrompt(State current, Transition& t) {
|
||||
if (current != State::CONTINUE_PROMPT) { return; }
|
||||
if (t.phase >= 2) { return; } // ja en exit anim, no-op
|
||||
t.phase = 2;
|
||||
t.elapsed_ms = 0;
|
||||
}
|
||||
|
||||
void skipToNextCountdown(State current, Transition& t, const Config& cfg) {
|
||||
if (current != State::CONTINUE_PROMPT) { return; }
|
||||
if (t.phase != 1) { return; }
|
||||
const int START = std::max(0, cfg.continue_count_start);
|
||||
const int STEP_COUNT = START + 1;
|
||||
const int STEP_MS = std::max(1, cfg.continue_text_hold_ms / STEP_COUNT);
|
||||
const int CONSUMED = t.elapsed_ms / STEP_MS;
|
||||
const int CURRENT = std::clamp(START - CONSUMED, 0, START);
|
||||
// No saltem si ja som al 0: el següent "número" és la transició
|
||||
// a GAME_OVER, que `advance` ja gestiona quan el hold s'esgota.
|
||||
if (CURRENT <= 0) { return; }
|
||||
t.elapsed_ms = (CONSUMED + 1) * STEP_MS;
|
||||
}
|
||||
|
||||
auto countdownSecondsRemaining(State current, const Transition& t, const Config& cfg) -> int {
|
||||
if (current != State::CONTINUE_PROMPT) { return 0; }
|
||||
if (t.phase != 1) { return 0; }
|
||||
// La sèrie és [start, start-1, ..., 1, 0]: en total `start+1`
|
||||
// graons. El primer número visible és `start` i l'últim 0 es
|
||||
// manté durant el seu graó abans que la fase 1 acabi i la FSM
|
||||
// salti a GAME_OVER. Si `start == 0` només es mostra "0"
|
||||
// durant tot el hold.
|
||||
const int START = std::max(0, cfg.continue_count_start);
|
||||
const int STEP_COUNT = START + 1;
|
||||
const int STEP_MS = std::max(1, cfg.continue_text_hold_ms / STEP_COUNT);
|
||||
const int CONSUMED = t.elapsed_ms / STEP_MS;
|
||||
return std::clamp(START - CONSUMED, 0, START);
|
||||
}
|
||||
|
||||
auto stateName(State s) -> const char* {
|
||||
switch (s) {
|
||||
case State::OFF:
|
||||
return "OFF";
|
||||
case State::CONTINUE_PROMPT:
|
||||
return "CONTINUE_PROMPT";
|
||||
case State::GAME_OVER:
|
||||
return "GAME_OVER";
|
||||
case State::FADE_OUT:
|
||||
return "FADE_OUT";
|
||||
case State::DONE:
|
||||
return "DONE";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
} // namespace GameOverFsm
|
||||
@@ -0,0 +1,150 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
// FSM secundària de la GameScene que governa el flux "tots els jugadors
|
||||
// fora de partida" — CONTINUE? amb countdown → GAME OVER → fade-to-black
|
||||
// → transició a LOGO. Viu separada de la `RoomFsm` (que gestiona
|
||||
// intro/outro de cada sala) perquè els seus disparadors són ortogonals
|
||||
// i la composició entre les dues queda al GameScene.
|
||||
//
|
||||
// Lògica pura: no toca SDL ni Audio, només temporitzadors. La part
|
||||
// visual (text + panel + fade) viu al GameScene reaprofitant la mateixa
|
||||
// infraestructura del room text.
|
||||
namespace GameOverFsm {
|
||||
|
||||
// Estats canònics. Phase indices per estat:
|
||||
// OFF — joc normal; el GameScene transita a
|
||||
// CONTINUE_PROMPT quan detecta 0 jugadors actius.
|
||||
// CONTINUE_PROMPT — phases: 0=text_enter, 1=text_hold (countdown
|
||||
// visible), 2=text_exit (només si l'usuari
|
||||
// cancel·la pulsant START). Si la fase 1 acaba
|
||||
// sense cancel·lació → salt a GAME_OVER amb el
|
||||
// panel encara amunt (sense exit anim).
|
||||
// GAME_OVER — phases: 0=text_hold (apareix sense enter
|
||||
// anim, panel ja amunt), 1=text_exit, 2=wait
|
||||
// (otro tiempo más abans del fade).
|
||||
// FADE_OUT — phases: 0=fade, 1=black_hold (perquè el
|
||||
// negre quedi un instant abans de cedir el
|
||||
// pas a la LogoScene).
|
||||
// DONE — sentinel: el caller (GameScene) ha de
|
||||
// demanar la transició cap a LOGO.
|
||||
enum class State : std::uint8_t {
|
||||
OFF,
|
||||
CONTINUE_PROMPT,
|
||||
GAME_OVER,
|
||||
FADE_OUT,
|
||||
DONE,
|
||||
};
|
||||
|
||||
// Sub-fase i temps acumulat dins de l'estat actual. Es reseteja a
|
||||
// {0, 0} quan es canvia d'estat (`GameScene::setGameOverState`).
|
||||
struct Transition {
|
||||
int phase{0};
|
||||
int elapsed_ms{0};
|
||||
};
|
||||
|
||||
// Timings llegits de `data/scenes/game/game.yaml` (secció
|
||||
// `game_over:`). Si el fitxer no porta cap d'aquests camps, els
|
||||
// valors per defecte coincideixen amb els del .yaml versionat.
|
||||
struct Config {
|
||||
// CONTINUE? — animació d'entrada/sortida idèntica al room text.
|
||||
int continue_text_enter_ms{500};
|
||||
// Hold = duració total del countdown. Es divideix en
|
||||
// (continue_count_start + 1) graons iguals; el cas canònic és
|
||||
// 10000 ms / 10 graons d'1 s cadascun.
|
||||
int continue_text_hold_ms{10000};
|
||||
int continue_text_exit_ms{500};
|
||||
// Primer número visible al countdown. La sèrie és
|
||||
// [continue_count_start, ..., 1, 0] — sempre acaba en 0 i el
|
||||
// valor 0 es manté visible durant l'últim graó abans del salt
|
||||
// a GAME_OVER. 9 → "9, 8, ..., 0" (10 graons).
|
||||
int continue_count_start{9};
|
||||
|
||||
// GAME OVER — apareix sense enter anim (el panel ja és amunt
|
||||
// del CONTINUE?) i s'esvaeix amb la mateixa exit anim que el
|
||||
// room text. `wait_ms` és el "otro tiempo más" abans del fade.
|
||||
int game_over_text_hold_ms{2000};
|
||||
int game_over_text_exit_ms{500};
|
||||
int game_over_wait_ms{500};
|
||||
|
||||
// Fade final cap a LOGO. `black_hold_ms` és el temps amb el
|
||||
// canvas negre opac abans que el GameScene demani la transició.
|
||||
int fade_out_ms{500};
|
||||
int black_hold_ms{500};
|
||||
|
||||
// Multiplicador de velocitat de la música mentre dura el
|
||||
// CONTINUE_PROMPT. 1.0 = sense canvi, >1 = més ràpid (i amb el
|
||||
// to més agut: és l'efecte arcade clàssic dels comptes enrere).
|
||||
// Es restableix a 1.0 al sortir del prompt (revive o GAME_OVER).
|
||||
float continue_music_speed{1.5F};
|
||||
|
||||
// Pista de música que sona durant l'estat GAME_OVER (sense loop).
|
||||
// Si la pista no està carregada al Resource::Cache, s'omet la
|
||||
// reproducció i la fase manté els timings configurats. La durada
|
||||
// de la pista determina implícitament `game_over_text_hold_ms`
|
||||
// (vegeu `GameScene::setGameOverState`).
|
||||
std::string game_over_music_track{"game_over.ogg"};
|
||||
|
||||
// So que sona cada vegada que apareix un nou número del
|
||||
// countdown (al primer cop que es mostra el `start`, cada
|
||||
// canvi de número, i quan apareix el 0). NO sona en la
|
||||
// transició posterior cap a GAME_OVER. Buit = sense so.
|
||||
std::string countdown_sound_key{"countdown.wav"};
|
||||
|
||||
// Llindar a partir del qual qualsevol botó de salt o atac (de
|
||||
// qualsevol jugador) salta al següent número del countdown.
|
||||
// Exemple: 7 → mentre el número visible és 7, 6, 5, ..., 1
|
||||
// (no a 0, que ja no té "següent número") un botó d'acció
|
||||
// avança al següent. Si el valor és més gran que `start` el
|
||||
// skip queda desactivat efectivament. -1 = mai.
|
||||
int skip_below_or_equal{7};
|
||||
};
|
||||
|
||||
// Llegeix la configuració des del YAML. Si el fitxer manca o falta
|
||||
// alguna clau, els camps no presents conserven els seus fallbacks.
|
||||
[[nodiscard]] auto loadConfig(const std::string& yaml_path) -> Config;
|
||||
|
||||
// Avança la transició consumint `delta_ms` des del temps real.
|
||||
// Retorna l'estat resultant. Si retorna un estat diferent, el
|
||||
// caller ha de resetejar la `Transition` a {0, 0} abans del següent
|
||||
// frame. Funció pura sobre l'argument.
|
||||
//
|
||||
// Particularitats:
|
||||
// - CONTINUE_PROMPT phase 1 (hold) arribar a la fi → GAME_OVER
|
||||
// (NO reprodueix exit anim; el caller ja té el panel amunt).
|
||||
// - CONTINUE_PROMPT phase 2 (exit) arribar a la fi → OFF (cas
|
||||
// "cancel·lació via START": el GameScene ha forçat la fase 2
|
||||
// amb `cancelContinuePrompt`).
|
||||
// - GAME_OVER phase 2 (wait) arribar a la fi → FADE_OUT.
|
||||
// - FADE_OUT phase 1 (black_hold) arribar a la fi → DONE.
|
||||
// - DONE i OFF no avancen ni canvien d'estat (sumiders).
|
||||
[[nodiscard]] auto advance(State current, Transition& t, const Config& cfg, int delta_ms) -> State;
|
||||
|
||||
// Salta a la fase 2 (text_exit) del CONTINUE_PROMPT. No fa res si
|
||||
// l'estat no és CONTINUE_PROMPT o ja som a la fase 2 (idempotent).
|
||||
// Disparat pel GameScene quan un jugador pulsa START dins del
|
||||
// countdown — el join real també el fa GameScene; aquesta funció
|
||||
// només dispara la sortida visual del rètol.
|
||||
void cancelContinuePrompt(State current, Transition& t);
|
||||
|
||||
// Quants segons "queden" del countdown de CONTINUE?. Retorna un
|
||||
// enter en el rang [0, continue_count_steps] perquè es renderitzi
|
||||
// com a segona línia del rètol durant la fase 1 (hold). Fora de
|
||||
// CONTINUE_PROMPT phase 1, retorna 0 (no s'hauria de pintar).
|
||||
[[nodiscard]] auto countdownSecondsRemaining(State current, const Transition& t, const Config& cfg) -> int;
|
||||
|
||||
// Salta al següent número del countdown (decrementa el valor
|
||||
// visible en una unitat). Només actua dins de CONTINUE_PROMPT
|
||||
// phase 1 i quan el valor actual és > 0 — al 0 ja no hi ha
|
||||
// "següent número", la fase ha d'esgotar-se de manera natural i
|
||||
// el caller es recolza en `advance` per al salt a GAME_OVER. La
|
||||
// GameScene comprova també el llindar `skip_below_or_equal`
|
||||
// abans de cridar; aquesta funció, per simplicitat, no en sap.
|
||||
void skipToNextCountdown(State current, Transition& t, const Config& cfg);
|
||||
|
||||
// Nom textual per a logs i tests.
|
||||
[[nodiscard]] auto stateName(State s) -> const char*;
|
||||
|
||||
} // namespace GameOverFsm
|
||||
@@ -31,10 +31,14 @@
|
||||
#include "game/entities/entity_factory.hpp"
|
||||
#include "game/entities/entity_loader.hpp"
|
||||
#include "game/entities/event_bus.hpp"
|
||||
#include "game/entities/hazard.hpp"
|
||||
#include "game/entities/player.hpp"
|
||||
#include "game/gameplay_config.hpp"
|
||||
#include "game/levels/level_manager.hpp"
|
||||
#include "game/map/map.hpp"
|
||||
#include "game/options.hpp"
|
||||
#include "game/scenes/scene_flow.hpp"
|
||||
#include "game/scenes/start_input.hpp"
|
||||
#include "game/ui/hud.hpp"
|
||||
#include "game/ui/service_menu.hpp"
|
||||
#include "utils/defaults.hpp"
|
||||
@@ -108,6 +112,11 @@ namespace {
|
||||
auto* a = ps[0];
|
||||
auto* b = ps[1];
|
||||
if (a == nullptr || b == nullptr) { return; }
|
||||
// Tweak `players.collide_with_each_other`: amb el flag desactivat
|
||||
// saltem TOT el resolver — head-on, push i bounce aeri queden
|
||||
// anul·lats. La col·lisió contra el món continua intacta perquè
|
||||
// viu a `Player::collidesWithWorld`/`tryMove`, no aquí.
|
||||
if (!GameplayConfig::get().playersCollide()) { return; }
|
||||
|
||||
const bool A_AIR = a->isAirborne();
|
||||
const bool B_AIR = b->isAirborne();
|
||||
@@ -262,7 +271,7 @@ GameScene::GameScene(Config config)
|
||||
|
||||
const bool USE_P1 = (config_.starting_players == DebugConfig::StartingPlayers::P1 || config_.starting_players == DebugConfig::StartingPlayers::BOTH);
|
||||
const bool USE_P2 = (config_.starting_players == DebugConfig::StartingPlayers::P2 || config_.starting_players == DebugConfig::StartingPlayers::BOTH);
|
||||
players_starts_active_ = {USE_P1, USE_P2};
|
||||
players_active_ = {USE_P1, USE_P2};
|
||||
|
||||
// Carreguem assets per als DOS jugadors sempre, encara que algun no
|
||||
// arranque actiu: l'slot existeix igualment al vector com a DISABLED
|
||||
@@ -305,6 +314,13 @@ GameScene::GameScene(Config config)
|
||||
entity_manager_.getBus().subscribe("player_killed", [this](const Event& ev) {
|
||||
dispatchPlayerKilled(ev.player_id);
|
||||
});
|
||||
// `player_disabled` el publica el Player al final del cicle DYING
|
||||
// quan `lives_remaining_` arriba a 0. La feina viu a
|
||||
// `onPlayerDisabled` per evitar pujar la complexitat del ctor; el
|
||||
// HUD ja s'apunta al mateix event per banda.
|
||||
entity_manager_.getBus().subscribe("player_disabled", [this](const Event& ev) {
|
||||
onPlayerDisabled(ev.player_id);
|
||||
});
|
||||
|
||||
// Polvet: precarreguem el surface del Dust una sola vegada perquè
|
||||
// els spawns runtime de l'entitat no facin disc al primer salt.
|
||||
@@ -427,7 +443,7 @@ GameScene::GameScene(Config config)
|
||||
// tots els jugadors *actius* han travessat la porta. Els slots DISABLED
|
||||
// permanents (single-player) NO compten — d'altra manera la sala mai
|
||||
// es completaria, perquè un slot DISABLED no pot eixir per la porta.
|
||||
const int ACTIVE_COUNT = std::ranges::count(players_starts_active_, true);
|
||||
const int ACTIVE_COUNT = std::ranges::count(players_active_, true);
|
||||
entity_manager_.setExpectedPlayerCount(ACTIVE_COUNT);
|
||||
// El snap també empeny lateralment si l'altre jugador ocupa el carril:
|
||||
// passem el mateix factor perquè vaja al mateix ritme que el resolver.
|
||||
@@ -441,10 +457,17 @@ GameScene::GameScene(Config config)
|
||||
auto hud_font_surface = Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY);
|
||||
auto hud_font = std::make_shared<Text>(hud_font_surface, UiConfig::get().hudFont().fnt);
|
||||
hud_font->setDefaultKerning(UiConfig::get().hudFont().kerning);
|
||||
hud_ = std::make_unique<Hud>(entity_manager_.getBus(), hud_font);
|
||||
// Si hi ha P2 actiu, activem el seu bloc (per defecte el HUD naix amb
|
||||
// només P1). Mentre els events del bus no porten `player_id`, els hits
|
||||
// segueixen aplicant-se a P1 (veure TODO a Hud::onPlayerHit).
|
||||
// Vides inicials del HUD = `initial_lives` del YAML del jugador
|
||||
// (`Player::loadFrom` ja ha omplert `death_config` abans d'aquí).
|
||||
// Sense això, el marcador es queda fixe a 3 i el YAML perd efecte.
|
||||
hud_ = std::make_unique<Hud>(entity_manager_.getBus(), hud_font, Player::getInitialLives());
|
||||
// Activem el bloc HUD per als slots que arranquen en partida. Els
|
||||
// inactius queden mostrant el prompt "POLSA START / PER JUGAR" via
|
||||
// `refreshJoinPrompt` cada frame. Tots dos s'haurien de poder
|
||||
// activar (cas BOTH) o un dels dos (cas P1 o P2 sols) — el HUD
|
||||
// naix amb tots dos slots inactius i és aquesta crida la que dóna
|
||||
// l'estat real.
|
||||
if (USE_P1) { hud_->activatePlayer(1); }
|
||||
if (USE_P2) { hud_->activatePlayer(2); }
|
||||
// Sincronització inicial dels comptadors de piràmide/sala del HUD amb
|
||||
// el LevelManager (després de cada `loadRoom` també es refresca; ací
|
||||
@@ -479,6 +502,12 @@ GameScene::GameScene(Config config)
|
||||
// Timings i corbes de la FSM de sala. Llegits del YAML una sola
|
||||
// vegada; canvis requereixen reiniciar el joc.
|
||||
transition_config_ = RoomFsm::loadConfig(Defaults::GameScene::GAME_YAML);
|
||||
// Timings de la sub-FSM de game over (CONTINUE? + GAME OVER + fade
|
||||
// final). Comparteix YAML amb la RoomFsm però viu en una secció
|
||||
// pròpia (`game_over:`).
|
||||
game_over_config_ = GameOverFsm::loadConfig(Defaults::GameScene::GAME_YAML);
|
||||
|
||||
preloadGameOverAssets();
|
||||
|
||||
// Cinemàtica d'entrada de la primera sala: arrenca la pista
|
||||
// `room_intro.ogg` i arma el callback de fi que farà spawn + salt
|
||||
@@ -488,7 +517,18 @@ GameScene::GameScene(Config config)
|
||||
enterRoomIntro();
|
||||
}
|
||||
|
||||
GameScene::~GameScene() = default;
|
||||
GameScene::~GameScene() {
|
||||
// Aturem qualsevol música que estigués sonant al sortir de la
|
||||
// GameScene. Això cobreix tres camins: (a) transició natural a
|
||||
// LOGO al final del game over (l'única audio activa pot ser la
|
||||
// pista `game_over.ogg`, que en aquell moment ja s'ha drenat);
|
||||
// (b) tancament directe via service menu o ALT+F4; (c) qualsevol
|
||||
// futura sortida no-natural. La LogoScene té la seva pròpia
|
||||
// música i no s'ha de mesclar amb la del joc.
|
||||
if (auto* audio = Audio::get(); audio != nullptr) {
|
||||
audio->stopMusic();
|
||||
}
|
||||
}
|
||||
|
||||
auto GameScene::computeSpawn() -> PlayerSpawn {
|
||||
// Centre de la columna del passadís en píxels canvas. La caixa de
|
||||
@@ -536,7 +576,7 @@ void GameScene::loadRoom(const std::string& yaml_path) {
|
||||
for (auto& p : players_) { p->setMapInfo(INFO); }
|
||||
// Tornem a fixar el comptador de "esperats" amb només els slots
|
||||
// actius — un slot DISABLED permanent (single-player) no compta.
|
||||
const int ACTIVE_COUNT = std::ranges::count(players_starts_active_, true);
|
||||
const int ACTIVE_COUNT = std::ranges::count(players_active_, true);
|
||||
entity_manager_.setExpectedPlayerCount(ACTIVE_COUNT);
|
||||
|
||||
// Tornem tots els slots a DISABLED. Els actius els reactivarà el
|
||||
@@ -583,6 +623,7 @@ void GameScene::enterRoomIntro() {
|
||||
intro_completed_ = false;
|
||||
intro_spawn_elapsed_ms_ = 0;
|
||||
players_spawned_in_intro_.fill(false);
|
||||
pending_actors_spawned_ = false;
|
||||
|
||||
// Reset visual: arrenquem el FADE_IN d'INTRO i deixem el d'OUTRO
|
||||
// inactiu (es re-armarà a `setState(ROOM_OUTRO)` i, dins d'aquell
|
||||
@@ -623,10 +664,10 @@ void GameScene::respawnPlayersInstant() {
|
||||
// mateix patró de `triggerPlayerSpawn` però cridem `respawn(x, y)`
|
||||
// (teleport instantani) en lloc de `spawnFromAir(x, y)`.
|
||||
const auto SPAWN = computeSpawn();
|
||||
const bool BOTH_ACTIVE = (players_starts_active_[0] && players_starts_active_[1]);
|
||||
const bool BOTH_ACTIVE = (players_active_[0] && players_active_[1]);
|
||||
const float SINGLE_X = SPAWN.p1_x + static_cast<float>(Map::tile_size);
|
||||
for (std::size_t i = 0; i < players_.size(); ++i) {
|
||||
if (!players_starts_active_[i]) { continue; }
|
||||
if (!players_active_[i]) { continue; }
|
||||
float x = SINGLE_X;
|
||||
if (BOTH_ACTIVE) { x = (i == 0) ? SPAWN.p1_x : SPAWN.p2_x; }
|
||||
players_[i]->setSpawnPoint(x, SPAWN.y);
|
||||
@@ -635,20 +676,26 @@ void GameScene::respawnPlayersInstant() {
|
||||
state_ = RoomFsm::State::PLAYING;
|
||||
intro_completed_ = true;
|
||||
transition_ = {};
|
||||
// El salt debug a sala curt-circuit'a la cinemàtica d'INTRO i, amb
|
||||
// ella, l'spawn diferit dels DynamicHazards que viu a la fase 4
|
||||
// d'INTRO. Si no els instanciàvem ací també, les mòmies de la sala
|
||||
// entrada per editor no apareixerien mai.
|
||||
entity_manager_.spawnPendingActors();
|
||||
pending_actors_spawned_ = true;
|
||||
Log::info("ROOM", "post-editor: spawn instantani, estat -> PLAYING");
|
||||
}
|
||||
#endif
|
||||
|
||||
void GameScene::triggerPlayerSpawn(std::size_t slot_index) {
|
||||
if (slot_index >= players_.size()) { return; }
|
||||
if (!players_starts_active_[slot_index]) { return; }
|
||||
if (!players_active_[slot_index]) { return; }
|
||||
if (players_spawned_in_intro_[slot_index]) { return; }
|
||||
players_spawned_in_intro_[slot_index] = true;
|
||||
|
||||
// Recalcula spawn per si door_col canvia entre sales (avui no, però
|
||||
// la lògica ja queda preparada per a l'itinerari).
|
||||
const auto SPAWN = computeSpawn();
|
||||
const bool BOTH_ACTIVE = (players_starts_active_[0] && players_starts_active_[1]);
|
||||
const bool BOTH_ACTIVE = (players_active_[0] && players_active_[1]);
|
||||
// Mode single-player: l'únic actiu queda centrat sota la porta. Els
|
||||
// valors de SPAWN.p1_x/p2_x estan fets per al cas BOTH (offset ±tile);
|
||||
// amb un sol actiu, el reposicionem al centre exacte.
|
||||
@@ -662,6 +709,14 @@ void GameScene::triggerPlayerSpawn(std::size_t slot_index) {
|
||||
// pot canviar de geometria de porta entre cicles.
|
||||
players_[slot_index]->setSpawnPoint(x, SPAWN.y);
|
||||
players_[slot_index]->spawnFromAir(x, SPAWN.y);
|
||||
// Spawn inicial sense gràcia: l'aparició coincideix amb el moment
|
||||
// en què comencen a aparèixer els enemics, i la finestra d'immunitat
|
||||
// que arma `respawn` deixaria al jugador immune just quan ja hauria
|
||||
// de poder ser tocat. Cancel·lem el timer recent armat. Els altres
|
||||
// camins (`joinPlayer` mid-partida i `respawnFromDying`) sí que
|
||||
// mantenen la immunitat: són situacions on el jugador apareix enmig
|
||||
// de l'acció ja en curs i necessita la finestra de gràcia.
|
||||
players_[slot_index]->setImmunity(0.0F);
|
||||
// Arma el reverb del primer LAND com a part de la cinemàtica
|
||||
// d'INTRO. Una sola vegada per spawn aeri: el respawn post-mort
|
||||
// (que no passa per ací) cau al camí sec. Si el preset està buit
|
||||
@@ -679,10 +734,116 @@ void GameScene::dispatchPlayerKilled(int player_id) {
|
||||
if (IT != players_.end()) { (*IT)->enterDying(); }
|
||||
}
|
||||
|
||||
void GameScene::onPlayerDisabled(int player_id) {
|
||||
Log::info("PLAYER DISABLED", "player_id={}", player_id);
|
||||
if (player_id < 0 || player_id >= static_cast<int>(players_active_.size())) { return; }
|
||||
players_active_[player_id] = false;
|
||||
}
|
||||
|
||||
void GameScene::preloadGameOverAssets() const {
|
||||
// Pre-load del so del countdown: el dispara `updateGameOverFlow`
|
||||
// cada vegada que canvia el número visible de CONTINUE? (incloent
|
||||
// l'aparició del 0 — però NO la transició a GAME_OVER). Sense
|
||||
// pre-load, la primera reproducció faria disc amb el countdown ja
|
||||
// en marxa.
|
||||
const auto& key = game_over_config_.countdown_sound_key;
|
||||
if (key.empty()) { return; }
|
||||
Resource::Cache::get().loadSound(
|
||||
key,
|
||||
std::string{Defaults::Paths::AUDIO_SOUNDS_DIR} + key);
|
||||
}
|
||||
|
||||
void GameScene::handleJoinInput() {
|
||||
// El "join in progress" es processa durant PLAYING (regla 1) i,
|
||||
// com a cas especial, durant CONTINUE_PROMPT — ahí un START reviu
|
||||
// el jugador i cancel·la el countdown. A partir de GAME_OVER el
|
||||
// START queda bloquejat (regla 5: ja no es pot pulsar start).
|
||||
const bool IN_PLAYING = (state_ == RoomFsm::State::PLAYING);
|
||||
const bool IN_CONTINUE = (game_over_state_ == GameOverFsm::State::CONTINUE_PROMPT);
|
||||
const bool IN_GAME_OVER = (game_over_state_ != GameOverFsm::State::OFF && game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT);
|
||||
if (IN_GAME_OVER) { return; }
|
||||
if (!IN_PLAYING && !IN_CONTINUE) { return; }
|
||||
for (std::size_t i = 0; i < players_.size(); ++i) {
|
||||
if (players_active_[i]) { continue; }
|
||||
if (!players_[i]->isDisabled()) { continue; }
|
||||
if (!Input::Detect::pressedStart(static_cast<int>(i))) { continue; }
|
||||
joinPlayer(i);
|
||||
// Si veníem del countdown de CONTINUE?, disparem la sortida
|
||||
// visual del rètol. El jugador ja apareix en escena per la
|
||||
// crida prèvia a `joinPlayer`; el text marxa amb la mateixa
|
||||
// exit anim que el room text.
|
||||
if (IN_CONTINUE) {
|
||||
GameOverFsm::cancelContinuePrompt(game_over_state_, game_over_transition_);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void GameScene::joinPlayer(std::size_t slot_index) {
|
||||
if (slot_index >= players_.size()) { return; }
|
||||
// Spawn lateral SEMPRE: cada jugador entra pel seu costat de la
|
||||
// porta de sortida. Mai el centre (en single-player la cinemàtica
|
||||
// inicial sí que centra el slot actiu, però en un join mid-partida
|
||||
// volem el costat per coherència visual amb la regla 2).
|
||||
const auto SPAWN = computeSpawn();
|
||||
const float X = (slot_index == 0) ? SPAWN.p1_x : SPAWN.p2_x;
|
||||
players_[slot_index]->setSpawnPoint(X, SPAWN.y);
|
||||
players_[slot_index]->resetForJoin();
|
||||
players_[slot_index]->spawnFromAir(X, SPAWN.y);
|
||||
players_active_[slot_index] = true;
|
||||
if (hud_) { hud_->activatePlayer(static_cast<int>(slot_index) + 1); }
|
||||
Log::info("ROOM", "join mid-game P{} -> spawn aerial at ({}, {})", slot_index + 1, X, SPAWN.y);
|
||||
}
|
||||
|
||||
auto GameScene::activePlayerCount() const -> int {
|
||||
int n = 0;
|
||||
for (const auto& p : players_) {
|
||||
if (!p) { continue; }
|
||||
if (!p->isOutOfPlay() || p->isDying()) { ++n; }
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
void GameScene::refreshJoinPrompt() {
|
||||
if (!hud_) { return; }
|
||||
// El game over substitueix la decisió purament basada en RoomFsm:
|
||||
// durant CONTINUE_PROMPT els jugadors encara poden entrar (revive),
|
||||
// a partir de GAME_OVER ja no, així que mostrem WAIT (o NONE) per
|
||||
// indicar que el START ja no farà res fins a la transició a LOGO.
|
||||
Hud::JoinPromptMode mode_inactive = Hud::JoinPromptMode::NONE;
|
||||
if (game_over_state_ == GameOverFsm::State::CONTINUE_PROMPT) {
|
||||
mode_inactive = Hud::JoinPromptMode::CAN_JOIN;
|
||||
} else if (game_over_state_ != GameOverFsm::State::OFF) {
|
||||
mode_inactive = Hud::JoinPromptMode::WAIT;
|
||||
} else {
|
||||
switch (state_) {
|
||||
case RoomFsm::State::PLAYING:
|
||||
mode_inactive = Hud::JoinPromptMode::CAN_JOIN;
|
||||
break;
|
||||
case RoomFsm::State::ROOM_INTRO:
|
||||
case RoomFsm::State::ROOM_OUTRO:
|
||||
mode_inactive = Hud::JoinPromptMode::WAIT;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (std::size_t i = 0; i < players_.size(); ++i) {
|
||||
const auto MODE = players_active_[i] ? Hud::JoinPromptMode::NONE : mode_inactive;
|
||||
hud_->setJoinPrompt(static_cast<int>(i) + 1, MODE);
|
||||
}
|
||||
}
|
||||
|
||||
void GameScene::completeIntro() {
|
||||
if (intro_completed_) { return; }
|
||||
intro_completed_ = true;
|
||||
intro_spawn_elapsed_ms_ = 0;
|
||||
// Fallback defensiu: si per qualsevol camí (config de fases amb tots
|
||||
// els intervals molt curts, salt manual) hem arribat a PLAYING sense
|
||||
// haver passat per la fase 4 que dispara l'spawn diferit, ho fem ara
|
||||
// mateix. Sense això, les mòmies pendents quedarien per sempre a
|
||||
// `pending_actors_` i la sala no tindria enemics.
|
||||
if (!pending_actors_spawned_) {
|
||||
entity_manager_.spawnPendingActors();
|
||||
pending_actors_spawned_ = true;
|
||||
}
|
||||
// Spawn dels slots amb delay <= 0 (el cas habitual de P1). Els
|
||||
// altres cauen frame a frame dins `updateRoomTransition` a mesura
|
||||
// que `intro_spawn_elapsed_ms_` supera el seu delay. Així P1 cau
|
||||
@@ -694,9 +855,9 @@ void GameScene::completeIntro() {
|
||||
|
||||
void GameScene::spawnDuePlayers() {
|
||||
if (!intro_completed_) { return; }
|
||||
const bool BOTH_ACTIVE = players_starts_active_[0] && players_starts_active_[1];
|
||||
for (std::size_t i = 0; i < players_starts_active_.size(); ++i) {
|
||||
if (!players_starts_active_[i]) { continue; }
|
||||
const bool BOTH_ACTIVE = players_active_[0] && players_active_[1];
|
||||
for (std::size_t i = 0; i < players_active_.size(); ++i) {
|
||||
if (!players_active_[i]) { continue; }
|
||||
if (players_spawned_in_intro_[i]) { continue; }
|
||||
// Single-player: el slot actiu (P1 o P2) sempre fa servir
|
||||
// `player_spawn_delay_ms`. Mode BOTH: P1 fa player_spawn_delay_ms,
|
||||
@@ -719,6 +880,7 @@ void GameScene::setState(RoomFsm::State new_state) {
|
||||
intro_completed_ = false;
|
||||
intro_spawn_elapsed_ms_ = 0;
|
||||
players_spawned_in_intro_.fill(false);
|
||||
pending_actors_spawned_ = false;
|
||||
}
|
||||
auto* audio = Audio::get();
|
||||
// PLAYING: arrenca la pista de joc en loop infinit. Netegem el
|
||||
@@ -779,6 +941,23 @@ void GameScene::updateRoomTransition(int delta_ms) {
|
||||
|
||||
const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms);
|
||||
|
||||
// ROOM_INTRO: en entrar a la fase 5 (music_tail, immediatament després
|
||||
// del text-exit) alliberem els DynamicHazards del seu estat SPAWN. Les
|
||||
// mòmies surten visualment "del terra" just després que el text desapareix
|
||||
// i abans que caiguin els jugadors — la sala arrenca amb tots els actors
|
||||
// gairebé alhora (les mòmies amb un petit head-start `spawn.duration_s`).
|
||||
// ROOM_INTRO, fase 4 = text-exit (el text està desapareixent). Instanciem
|
||||
// els actors diferits (mòmies) — `EntityManager::spawnPendingActors` els
|
||||
// crea ja en EMERGING. Visualment, "surten del terra" alhora que el text
|
||||
// surt de pantalla. Amb spawn.duration_s ~ 1.0 s, queden ACTIVE
|
||||
// coincidint amb la caiguda dels jugadors a music_tail.
|
||||
if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase >= 4 && !pending_actors_spawned_) {
|
||||
const std::size_t PENDING = entity_manager_.pendingActorCount();
|
||||
entity_manager_.spawnPendingActors();
|
||||
pending_actors_spawned_ = true;
|
||||
Log::info("ROOM", "INTRO fase 4: spawn diferit de {} actors", PENDING);
|
||||
}
|
||||
|
||||
// OUTRO: arrenquem `outro_fade_` la primera vegada que entrem a la
|
||||
// fase de fade_out (phase 4 del timeline d'OUTRO). Si l'`advance`
|
||||
// ha consumit fases buides en aquest mateix frame i ja portem
|
||||
@@ -832,11 +1011,6 @@ void GameScene::renderRoomFade() {
|
||||
}
|
||||
|
||||
void GameScene::renderRoomText() {
|
||||
// Orquestrador del rètol de transició de sala. La feina pesada
|
||||
// (animació de X, animació d'altura del panel i pintat) la fan
|
||||
// quatre helpers privats; aquesta funció només decideix quin
|
||||
// text dibuixar i amb quines fases segons l'estat.
|
||||
//
|
||||
// Mapatge per estat (només els estats amb text actiu hi entren):
|
||||
// ROOM_INTRO: phases 2..4 → enter / hold / exit; dues línies
|
||||
// (piràmide damunt, sala davall) que travessen la
|
||||
@@ -845,76 +1019,39 @@ void GameScene::renderRoomText() {
|
||||
// oposades ("fase" / "completada"). Phase 3 és el
|
||||
// fade-out i 4 la pausa en negre — cap text en cap
|
||||
// de les dues.
|
||||
if (!room_text_font_) { return; }
|
||||
|
||||
constexpr int KERNING = 0;
|
||||
// Centre vertical de la zona de joc (canvas sense la HUD). Reemplaça
|
||||
// l'antic GameCanvas::CENTER_Y per descomptar la franja del marcador
|
||||
// i tenir el rètol equilibrat sobre el playfield.
|
||||
const int PLAY_CENTER_Y = (GameCanvas::HEIGHT + Map::hud_h) / 2;
|
||||
// Colors del rètol via UiConfig (`data/config/ui.yaml > colors.room_text` i
|
||||
// `room_text_outline`). Independents dels colors de notificació.
|
||||
const Uint8 FILL = UiConfig::get().roomText();
|
||||
const Uint8 OUTLINE = UiConfig::get().roomTextOutline();
|
||||
const int LINE_HEIGHT = room_text_font_->getLineHeight();
|
||||
|
||||
if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase >= 2 && transition_.phase <= 4) {
|
||||
const int DUR_ENTER = transition_config_.intro_text_enter_ms;
|
||||
const int DUR_EXIT = transition_config_.intro_text_exit_ms;
|
||||
// Panel primer (queda sota el text gràcies a l'ordre de blits).
|
||||
drawRoomTextPanel(PLAY_CENTER_Y,
|
||||
computeRoomTextPanelHeight(2, 3, 4, DUR_ENTER, DUR_EXIT));
|
||||
|
||||
const int PHASE = transition_.phase;
|
||||
const int ELAPSED = transition_.elapsed_ms;
|
||||
if (state_ == RoomFsm::State::ROOM_INTRO && PHASE >= 2 && PHASE <= 4) {
|
||||
const auto& mgr = Levels::LevelManager::get();
|
||||
const std::string LINE_PYRAMID = RoomFsm::formatIntroPyramidLine(mgr.currentPyramid());
|
||||
const std::string LINE_ROOM = RoomFsm::formatIntroRoomLine(mgr.currentRoom());
|
||||
// Direccions oposades: piràmide entra per l'esquerra i surt per
|
||||
// la dreta; sala fa el contrari.
|
||||
const int X_PYRAMID = computeRoomTextX(room_text_font_->length(LINE_PYRAMID, KERNING),
|
||||
2,
|
||||
4,
|
||||
DUR_ENTER,
|
||||
DUR_EXIT,
|
||||
/*from_right=*/false);
|
||||
const int X_ROOM = computeRoomTextX(room_text_font_->length(LINE_ROOM, KERNING),
|
||||
2,
|
||||
4,
|
||||
DUR_ENTER,
|
||||
DUR_EXIT,
|
||||
/*from_right=*/true);
|
||||
// Bloc de dues línies centrat al voltant del PLAY_CENTER_Y. El
|
||||
// gap (`intro_text_line_gap_px`) es reparteix simètricament:
|
||||
// mig amunt a la piràmide, mig avall a la sala.
|
||||
const int GAP = transition_config_.intro_text_line_gap_px;
|
||||
drawRoomTextLine(LINE_PYRAMID, X_PYRAMID, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
|
||||
drawRoomTextLine(LINE_ROOM, X_ROOM, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
|
||||
} else if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase >= 0 && transition_.phase <= 2) {
|
||||
const int DUR_ENTER = transition_config_.outro_text_enter_ms;
|
||||
const int DUR_EXIT = transition_config_.outro_text_exit_ms;
|
||||
drawRoomTextPanel(PLAY_CENTER_Y,
|
||||
computeRoomTextPanelHeight(0, 1, 2, DUR_ENTER, DUR_EXIT));
|
||||
|
||||
const std::string LINE_1 = Locale::get().text("game.room_complete_line1");
|
||||
const std::string LINE_2 = Locale::get().text("game.room_complete_line2");
|
||||
const int X_1 = computeRoomTextX(room_text_font_->length(LINE_1, KERNING),
|
||||
0,
|
||||
2,
|
||||
DUR_ENTER,
|
||||
DUR_EXIT,
|
||||
/*from_right=*/false);
|
||||
const int X_2 = computeRoomTextX(room_text_font_->length(LINE_2, KERNING),
|
||||
0,
|
||||
2,
|
||||
DUR_ENTER,
|
||||
DUR_EXIT,
|
||||
/*from_right=*/true);
|
||||
const int GAP = transition_config_.outro_text_line_gap_px;
|
||||
drawRoomTextLine(LINE_1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (GAP / 2), FILL, OUTLINE);
|
||||
drawRoomTextLine(LINE_2, X_2, PLAY_CENTER_Y + (GAP / 2), FILL, OUTLINE);
|
||||
renderTextScreen(TextScreenSpec{
|
||||
.line1 = RoomFsm::formatIntroPyramidLine(mgr.currentPyramid()),
|
||||
.line2 = RoomFsm::formatIntroRoomLine(mgr.currentRoom()),
|
||||
.phase_enter = 2,
|
||||
.phase_hold = 3,
|
||||
.phase_exit = 4,
|
||||
.dur_enter_ms = transition_config_.intro_text_enter_ms,
|
||||
.dur_exit_ms = transition_config_.intro_text_exit_ms,
|
||||
.line_gap_px = transition_config_.intro_text_line_gap_px,
|
||||
},
|
||||
PHASE,
|
||||
ELAPSED);
|
||||
} else if (state_ == RoomFsm::State::ROOM_OUTRO && PHASE >= 0 && PHASE <= 2) {
|
||||
renderTextScreen(TextScreenSpec{
|
||||
.line1 = Locale::get().text("game.room_complete_line1"),
|
||||
.line2 = Locale::get().text("game.room_complete_line2"),
|
||||
.phase_enter = 0,
|
||||
.phase_hold = 1,
|
||||
.phase_exit = 2,
|
||||
.dur_enter_ms = transition_config_.outro_text_enter_ms,
|
||||
.dur_exit_ms = transition_config_.outro_text_exit_ms,
|
||||
.line_gap_px = transition_config_.outro_text_line_gap_px,
|
||||
},
|
||||
PHASE,
|
||||
ELAPSED);
|
||||
}
|
||||
}
|
||||
|
||||
auto GameScene::computeRoomTextX(int width, int phase_enter, int phase_exit, int dur_enter, int dur_exit, bool from_right) const -> int {
|
||||
auto GameScene::computeRoomTextX(int width, int phase, int elapsed_ms, int phase_enter, int phase_exit, int dur_enter, int dur_exit, bool from_right) const -> int {
|
||||
// X animada d'una línia: entra des de fora de pantalla, s'atura al
|
||||
// CENTER_X durant el hold (fora del rang d'aquesta funció — el caller
|
||||
// garanteix que cau a la branca return final) i surt pel costat
|
||||
@@ -924,16 +1061,16 @@ auto GameScene::computeRoomTextX(int width, int phase_enter, int phase_exit, int
|
||||
const int OFF_RIGHT = GameCanvas::WIDTH;
|
||||
const int OFF_ENTER = from_right ? OFF_RIGHT : OFF_LEFT;
|
||||
const int OFF_EXIT = from_right ? OFF_LEFT : OFF_RIGHT;
|
||||
if (transition_.phase == phase_enter) {
|
||||
if (phase == phase_enter) {
|
||||
const float T = (dur_enter > 0)
|
||||
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_enter), 0.0F, 1.0F)
|
||||
? std::clamp(static_cast<float>(elapsed_ms) / static_cast<float>(dur_enter), 0.0F, 1.0F)
|
||||
: 1.0F;
|
||||
const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear;
|
||||
return Easing::lerpInt(OFF_ENTER, GameCanvas::CENTER_X, CURVE(T));
|
||||
}
|
||||
if (transition_.phase == phase_exit) {
|
||||
if (phase == phase_exit) {
|
||||
const float T = (dur_exit > 0)
|
||||
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_exit), 0.0F, 1.0F)
|
||||
? std::clamp(static_cast<float>(elapsed_ms) / static_cast<float>(dur_exit), 0.0F, 1.0F)
|
||||
: 1.0F;
|
||||
const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear;
|
||||
return Easing::lerpInt(GameCanvas::CENTER_X, OFF_EXIT, CURVE(T));
|
||||
@@ -941,23 +1078,23 @@ auto GameScene::computeRoomTextX(int width, int phase_enter, int phase_exit, int
|
||||
return GameCanvas::CENTER_X;
|
||||
}
|
||||
|
||||
auto GameScene::computeRoomTextPanelHeight(int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int {
|
||||
auto GameScene::computeRoomTextPanelHeight(int phase, int elapsed_ms, int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int {
|
||||
// Altura animada del panel translúcid sota el rètol. Creix de 0 a
|
||||
// `text_panel_height_px` amb `text_enter_curve` durant l'enter, es
|
||||
// manté plena durant el hold i decreix amb `text_exit_curve`
|
||||
// durant l'exit. Fora d'aquestes tres fases retorna 0.
|
||||
const int MAX = transition_config_.text_panel_height_px;
|
||||
if (transition_.phase == phase_enter) {
|
||||
if (phase == phase_enter) {
|
||||
const float T = (dur_enter > 0)
|
||||
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_enter), 0.0F, 1.0F)
|
||||
? std::clamp(static_cast<float>(elapsed_ms) / static_cast<float>(dur_enter), 0.0F, 1.0F)
|
||||
: 1.0F;
|
||||
const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear;
|
||||
return Easing::lerpInt(0, MAX, CURVE(T));
|
||||
}
|
||||
if (transition_.phase == phase_hold) { return MAX; }
|
||||
if (transition_.phase == phase_exit) {
|
||||
if (phase == phase_hold) { return MAX; }
|
||||
if (phase == phase_exit) {
|
||||
const float T = (dur_exit > 0)
|
||||
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_exit), 0.0F, 1.0F)
|
||||
? std::clamp(static_cast<float>(elapsed_ms) / static_cast<float>(dur_exit), 0.0F, 1.0F)
|
||||
: 1.0F;
|
||||
const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear;
|
||||
return Easing::lerpInt(MAX, 0, CURVE(T));
|
||||
@@ -990,6 +1127,248 @@ void GameScene::drawRoomTextLine(const std::string& text, int x, int y, std::uin
|
||||
outline_color);
|
||||
}
|
||||
|
||||
void GameScene::renderTextScreen(const TextScreenSpec& spec, int phase, int elapsed_ms) const {
|
||||
// Coreografia comuna als quatre rètols (ROOM_INTRO, ROOM_OUTRO,
|
||||
// CONTINUE?, GAME OVER): panel translúcid de fons + dues línies
|
||||
// que entren/surten per costats oposats. Els quatre helpers
|
||||
// existents (`drawRoomTextPanel`, `computeRoomTextPanelHeight`,
|
||||
// `computeRoomTextX`, `drawRoomTextLine`) fan la feina pesada;
|
||||
// aquesta funció només els encadena en el mateix ordre per als
|
||||
// quatre casos. Una `lineN` buida es salta sense pintar — així
|
||||
// CONTINUE? amaga la línia del countdown fora del hold sense
|
||||
// que els callers hagin de duplicar la branca.
|
||||
if (!room_text_font_) { return; }
|
||||
|
||||
constexpr int KERNING = 0;
|
||||
const int PLAY_CENTER_Y = (GameCanvas::HEIGHT + Map::hud_h) / 2;
|
||||
const Uint8 FILL = UiConfig::get().roomText();
|
||||
const Uint8 OUTLINE = UiConfig::get().roomTextOutline();
|
||||
const int LINE_HEIGHT = room_text_font_->getLineHeight();
|
||||
|
||||
drawRoomTextPanel(PLAY_CENTER_Y,
|
||||
computeRoomTextPanelHeight(phase, elapsed_ms, spec.phase_enter, spec.phase_hold, spec.phase_exit, spec.dur_enter_ms, spec.dur_exit_ms));
|
||||
|
||||
if (!spec.line1.empty()) {
|
||||
const int X_1 = computeRoomTextX(room_text_font_->length(spec.line1, KERNING),
|
||||
phase,
|
||||
elapsed_ms,
|
||||
spec.phase_enter,
|
||||
spec.phase_exit,
|
||||
spec.dur_enter_ms,
|
||||
spec.dur_exit_ms,
|
||||
/*from_right=*/false);
|
||||
drawRoomTextLine(spec.line1, X_1, PLAY_CENTER_Y - LINE_HEIGHT - (spec.line_gap_px / 2), FILL, OUTLINE);
|
||||
}
|
||||
if (!spec.line2.empty()) {
|
||||
const int X_2 = computeRoomTextX(room_text_font_->length(spec.line2, KERNING),
|
||||
phase,
|
||||
elapsed_ms,
|
||||
spec.phase_enter,
|
||||
spec.phase_exit,
|
||||
spec.dur_enter_ms,
|
||||
spec.dur_exit_ms,
|
||||
/*from_right=*/true);
|
||||
drawRoomTextLine(spec.line2, X_2, PLAY_CENTER_Y + (spec.line_gap_px / 2), FILL, OUTLINE);
|
||||
}
|
||||
}
|
||||
|
||||
void GameScene::setGameOverState(GameOverFsm::State new_state) {
|
||||
if (new_state == game_over_state_) { return; }
|
||||
const auto PREV = game_over_state_;
|
||||
Log::info("GAMEOVER", "{} -> {}", GameOverFsm::stateName(PREV), GameOverFsm::stateName(new_state));
|
||||
game_over_state_ = new_state;
|
||||
game_over_transition_ = {};
|
||||
|
||||
auto* audio = Audio::get();
|
||||
|
||||
// Sortida de CONTINUE_PROMPT cap a OFF (revive): reinici de la
|
||||
// música de joc des del principi a velocitat normal. Reaprofitem
|
||||
// el track gravat a `config_.music_track` (sempre `playing.ogg`
|
||||
// amb loop infinit). Si la sortida és cap a GAME_OVER, la branca
|
||||
// de més avall ja talla la música i posa `game_over.ogg` — aquí
|
||||
// saltem perquè el stop+play seria redundant.
|
||||
if (PREV == GameOverFsm::State::CONTINUE_PROMPT && new_state == GameOverFsm::State::OFF && audio != nullptr) {
|
||||
audio->stopMusic();
|
||||
audio->playMusic(config_.music_track, -1);
|
||||
audio->setMusicSpeed(1.0F);
|
||||
}
|
||||
|
||||
if (new_state == GameOverFsm::State::CONTINUE_PROMPT && audio != nullptr) {
|
||||
// Acceleració arcade: stop+play perquè la pista arrenqui des
|
||||
// del principi al ritme accelerat (sense aquest reinici, el
|
||||
// canvi de velocitat sobre la posició actual queda poc
|
||||
// perceptible amb ratios subtils com 1.05). El to també puja:
|
||||
// és l'efecte clàssic dels comptes enrere.
|
||||
audio->stopMusic();
|
||||
audio->playMusic(config_.music_track, -1);
|
||||
audio->setMusicSpeed(game_over_config_.continue_music_speed);
|
||||
}
|
||||
|
||||
// Reset del tracker del so del countdown cada vegada que entrem
|
||||
// a CONTINUE_PROMPT, perquè la primera aparició del valor inicial
|
||||
// (`continue_count_start`) dispari el "tick". Fora de
|
||||
// CONTINUE_PROMPT no s'usa.
|
||||
if (new_state == GameOverFsm::State::CONTINUE_PROMPT) {
|
||||
last_countdown_value_ = -1;
|
||||
}
|
||||
|
||||
if (new_state == GameOverFsm::State::GAME_OVER && audio != nullptr) {
|
||||
// Talla la música actual i arrenca la pista de GAME OVER sense
|
||||
// loop. La fase de hold es redimensiona a la durada real de la
|
||||
// pista perquè el text es mantingui visible mentre dura la
|
||||
// música; l'exit anim, el wait i el fade arrenquen quan la
|
||||
// música ja s'ha drenat.
|
||||
audio->stopMusic();
|
||||
const auto& track = game_over_config_.game_over_music_track;
|
||||
if (!track.empty()) {
|
||||
audio->playMusic(track, 0);
|
||||
const int DUR_MS = Audio::getMusicDurationMs(track);
|
||||
if (DUR_MS > 0) {
|
||||
game_over_config_.game_over_text_hold_ms = DUR_MS;
|
||||
Log::info("GAMEOVER", "{}: durada {} ms (text_hold sincronitzat)", track, DUR_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (new_state == GameOverFsm::State::FADE_OUT) {
|
||||
// Arma el fade en el moment que entrem a la fase de fade-out.
|
||||
// Mateix patró que `outro_fade_started_` per a OUTRO de sala.
|
||||
game_over_fade_.begin(game_over_config_.fade_out_ms,
|
||||
Transitions::FadeToBlack::Direction::FADE_OUT);
|
||||
game_over_fade_started_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
void GameScene::updateGameOverFlow(int delta_ms) {
|
||||
// Tick del fade visual: és no-op si encara no s'ha armat (begin
|
||||
// sense duration → tick=no-op gràcies al clamp intern).
|
||||
game_over_fade_.tick(delta_ms);
|
||||
|
||||
// Detecció d'entrada a CONTINUE_PROMPT: tots els jugadors fora de
|
||||
// partida durant PLAYING. Només es dispara una vegada — `setGameOverState`
|
||||
// és idempotent davant del mateix estat. Es vol que la transició
|
||||
// sigui exclusiva de PLAYING perquè un join mid-INTRO/OUTRO ja no
|
||||
// és possible (vegeu `handleJoinInput`); permetent CONTINUE? fora
|
||||
// de PLAYING obriríem races amb la cinemàtica de sala.
|
||||
if (game_over_state_ == GameOverFsm::State::OFF && state_ == RoomFsm::State::PLAYING && activePlayerCount() == 0) {
|
||||
setGameOverState(GameOverFsm::State::CONTINUE_PROMPT);
|
||||
return;
|
||||
}
|
||||
if (game_over_state_ == GameOverFsm::State::OFF) { return; }
|
||||
|
||||
const auto NEXT = GameOverFsm::advance(game_over_state_, game_over_transition_, game_over_config_, delta_ms);
|
||||
if (NEXT != game_over_state_) {
|
||||
setGameOverState(NEXT);
|
||||
// En arribar a DONE, demanem la transició a LOGO. La construcció
|
||||
// de la nova escena la fa el Director llegint `exit_request_`.
|
||||
if (NEXT == GameOverFsm::State::DONE) {
|
||||
exit_request_ = ExitRequest{.next = SceneId::LOGO, .params = LogoParams{}};
|
||||
Log::info("GAMEOVER", "DONE -> requesting LOGO scene");
|
||||
}
|
||||
}
|
||||
|
||||
// Skip i tick del so del countdown viuen en helpers per acotar
|
||||
// la complexitat cognitiva d'aquesta funció. Skip primer perquè
|
||||
// el tick del so soni amb el valor nou si la pulsació ha avançat.
|
||||
handleCountdownSkip();
|
||||
emitCountdownTickIfChanged();
|
||||
}
|
||||
|
||||
void GameScene::handleCountdownSkip() {
|
||||
if (game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT) { return; }
|
||||
if (game_over_transition_.phase != 1) { return; }
|
||||
const int CURRENT = GameOverFsm::countdownSecondsRemaining(
|
||||
game_over_state_,
|
||||
game_over_transition_,
|
||||
game_over_config_);
|
||||
if (CURRENT <= 0) { return; }
|
||||
if (CURRENT > game_over_config_.skip_below_or_equal) { return; }
|
||||
const bool ANY_ACTION = Input::Detect::pressedActionButton(0) || Input::Detect::pressedActionButton(1);
|
||||
if (!ANY_ACTION) { return; }
|
||||
GameOverFsm::skipToNextCountdown(
|
||||
game_over_state_,
|
||||
game_over_transition_,
|
||||
game_over_config_);
|
||||
}
|
||||
|
||||
void GameScene::emitCountdownTickIfChanged() {
|
||||
if (game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT) { return; }
|
||||
if (game_over_transition_.phase != 1) { return; }
|
||||
const int CURRENT = GameOverFsm::countdownSecondsRemaining(
|
||||
game_over_state_,
|
||||
game_over_transition_,
|
||||
game_over_config_);
|
||||
if (CURRENT == last_countdown_value_) { return; }
|
||||
last_countdown_value_ = CURRENT;
|
||||
const auto& key = game_over_config_.countdown_sound_key;
|
||||
if (key.empty()) { return; }
|
||||
if (auto* audio = Audio::get(); audio != nullptr) {
|
||||
audio->playSound(key, Audio::Group::INTERFACE);
|
||||
}
|
||||
}
|
||||
|
||||
void GameScene::renderGameOverText() {
|
||||
// Mapatge per estat:
|
||||
// CONTINUE_PROMPT: phases 0=enter, 1=hold (countdown visible),
|
||||
// 2=exit. La línia 2 (número) només es pinta
|
||||
// durant la fase 1 perquè els números no
|
||||
// travessin la pantalla.
|
||||
// GAME_OVER: phases 0=hold, 1=exit (sense enter anim — el
|
||||
// panel ja és amunt del CONTINUE_PROMPT). Phase 2
|
||||
// (wait) sense text ni panel.
|
||||
if (game_over_state_ != GameOverFsm::State::CONTINUE_PROMPT && game_over_state_ != GameOverFsm::State::GAME_OVER) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int PHASE = game_over_transition_.phase;
|
||||
const int ELAPSED = game_over_transition_.elapsed_ms;
|
||||
|
||||
if (game_over_state_ == GameOverFsm::State::CONTINUE_PROMPT) {
|
||||
const int SECONDS = GameOverFsm::countdownSecondsRemaining(
|
||||
game_over_state_,
|
||||
game_over_transition_,
|
||||
game_over_config_);
|
||||
renderTextScreen(TextScreenSpec{
|
||||
.line1 = Locale::get().text("game.continue_prompt"),
|
||||
.line2 = (PHASE == 1) ? std::to_string(SECONDS) : std::string{},
|
||||
.phase_enter = 0,
|
||||
.phase_hold = 1,
|
||||
.phase_exit = 2,
|
||||
.dur_enter_ms = game_over_config_.continue_text_enter_ms,
|
||||
.dur_exit_ms = game_over_config_.continue_text_exit_ms,
|
||||
.line_gap_px = transition_config_.intro_text_line_gap_px,
|
||||
},
|
||||
PHASE,
|
||||
ELAPSED);
|
||||
return;
|
||||
}
|
||||
|
||||
// GAME_OVER: phase 2 (wait) sense panel ni text — només temps mort
|
||||
// abans del fade. phase_enter=-1 desactiva l'animació d'entrada
|
||||
// (el panel ja és amunt heretat del CONTINUE_PROMPT).
|
||||
if (PHASE > 1) { return; }
|
||||
renderTextScreen(TextScreenSpec{
|
||||
.line1 = Locale::get().text("game.game_over_line1"),
|
||||
.line2 = Locale::get().text("game.game_over_line2"),
|
||||
.phase_enter = -1,
|
||||
.phase_hold = 0,
|
||||
.phase_exit = 1,
|
||||
.dur_enter_ms = 0,
|
||||
.dur_exit_ms = game_over_config_.game_over_text_exit_ms,
|
||||
.line_gap_px = transition_config_.outro_text_line_gap_px,
|
||||
},
|
||||
PHASE,
|
||||
ELAPSED);
|
||||
}
|
||||
|
||||
void GameScene::renderGameOverFade() {
|
||||
// Sense `begin` previ, `tick` és no-op i `render` pinta alpha=0
|
||||
// (vegeu `Transitions::FadeToBlack`). Així el callsite és
|
||||
// incondicional sense malbaratar pixel ops fora del flux.
|
||||
if (!game_over_fade_started_) { return; }
|
||||
game_over_fade_.render();
|
||||
}
|
||||
|
||||
void GameScene::spawnDust(float feet_x_center, float feet_y, bool is_landing) {
|
||||
// Mida del sprite de Dust llegida del seu YAML. Hardcoded per
|
||||
// simplificar — si canvia, només cal tocar dust.yaml + aquesta
|
||||
@@ -1162,6 +1541,12 @@ void GameScene::update(int delta_ms) {
|
||||
// arrenque ja sigui efectiu en el mateix frame.
|
||||
updateRoomTransition(delta_ms);
|
||||
|
||||
// Sub-FSM de game over (CONTINUE? + GAME OVER + fade-to-LOGO).
|
||||
// També en temps real: el countdown de CONTINUE? no es congela
|
||||
// amb la pausa de gameplay. La detecció de "tots fora de partida"
|
||||
// s'arma dins quan estem a PLAYING.
|
||||
updateGameOverFlow(delta_ms);
|
||||
|
||||
#ifdef _DEBUG
|
||||
// Toggle de l'editor: només si no hi ha cap transició de pausa ni
|
||||
// pausa consolidada. L'editor mateix no consumeix la tecla — la
|
||||
@@ -1213,6 +1598,20 @@ void GameScene::update(int delta_ms) {
|
||||
}
|
||||
#endif
|
||||
|
||||
// Join in progress (regla 1): un slot DISABLED pot entrar a partida
|
||||
// pulsant START durant PLAYING. Es processa abans del bloc de món
|
||||
// per que el spawn aeri ja sigui efectiu en aquest mateix frame.
|
||||
// Gateat per pausa/menú/editor: no es vol que un START accidental
|
||||
// mentre l'usuari navega per un menú trigueri un join.
|
||||
#ifdef _DEBUG
|
||||
const bool EDITOR_OPEN = editor_.active();
|
||||
#else
|
||||
constexpr bool EDITOR_OPEN = false;
|
||||
#endif
|
||||
if (pause_.state == PauseState::IDLE && !MENU_FROZEN && !EDITOR_OPEN) {
|
||||
handleJoinInput();
|
||||
}
|
||||
|
||||
GlobalInputs::update(delta_ms);
|
||||
if (GlobalInputs::wantsQuit()) { wants_quit_ = true; }
|
||||
|
||||
@@ -1273,6 +1672,11 @@ void GameScene::update(int delta_ms) {
|
||||
|
||||
RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiConfig::get().renderInfoText(), UiConfig::get().renderInfoShadow()));
|
||||
Notifier::get().update(delta_ms);
|
||||
|
||||
// Refresc del prompt al HUD per a slots inactius. Es fa al final per
|
||||
// recollir els canvis d'aquest frame (joins, deaths, transició de
|
||||
// RoomFsm) abans del proper render.
|
||||
refreshJoinPrompt();
|
||||
}
|
||||
|
||||
void GameScene::render() {
|
||||
@@ -1335,6 +1739,8 @@ void GameScene::render() {
|
||||
};
|
||||
std::vector<Renderable> order;
|
||||
order.reserve(entity_manager_.entityCount() + players_.size());
|
||||
// Mergem entitats i jugadors en un sol vector temporal — el
|
||||
// `stable_sort` per `feet_y` posterior fa el z-order interleaving.
|
||||
const auto ENTITIES = entity_manager_.getEntities();
|
||||
std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) {
|
||||
return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr};
|
||||
@@ -1371,6 +1777,14 @@ void GameScene::render() {
|
||||
// Text de la transició de sala (entrada o sortida): per damunt del
|
||||
// HUD perquè és UI de transició i ha de ser sempre llegible.
|
||||
renderRoomText();
|
||||
|
||||
// Rètol de game over (CONTINUE? + countdown / GAME OVER) — comparteix
|
||||
// panel i font amb el room text però viu en una sub-FSM independent.
|
||||
renderGameOverText();
|
||||
|
||||
// Fade-to-black final del game over: tapeix tot el canvas (HUD
|
||||
// inclòs) durant la fase FADE_OUT i el black hold posterior.
|
||||
renderGameOverFade();
|
||||
}
|
||||
|
||||
#ifdef _DEBUG
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "game/entities/entity_manager.hpp"
|
||||
#include "game/map/paint_layer.hpp"
|
||||
#include "game/map/tilemap.hpp"
|
||||
#include "game/scenes/game_over_fsm.hpp"
|
||||
#include "game/scenes/room_fsm.hpp"
|
||||
|
||||
#ifdef _DEBUG
|
||||
@@ -95,17 +96,19 @@ class GameScene : public Scene {
|
||||
// Vector des del principi: AEE suportarà multijugador local; cada Player
|
||||
// té els seus propis Controls i entra al vector amb una crida extra.
|
||||
// Sempre conté **dos** entrades (P1 a [0], P2 a [1]) tant en mode BOTH
|
||||
// com en single-player: l'slot inactiu es queda en estat DISABLED de
|
||||
// manera permanent, deixant el camí preparat per al "join in progress"
|
||||
// futur (el segon jugador podrà entrar a meitat de partida sense
|
||||
// recrear estructures). Veure `players_starts_active_`.
|
||||
// com en single-player: l'slot inactiu es queda en estat DISABLED i
|
||||
// pot entrar mid-partida via "join in progress" (`handleJoinInput`).
|
||||
std::vector<std::unique_ptr<Player>> players_;
|
||||
|
||||
// Quins slots juguen aquesta partida segons `config_.starting_players`.
|
||||
// Index = id del jugador (0 = P1, 1 = P2). Determina a qui aplica el
|
||||
// spawn aeri al final de la fase 0 d'INTRO; un slot amb `false` es
|
||||
// queda DISABLED tot el cicle de sala.
|
||||
std::array<bool, 2> players_starts_active_{false, false};
|
||||
// Quins slots estan en partida ARA MATEIX (jugant o DYING). Mutable
|
||||
// durant la sala: el ctor el sembra des de `config_.starting_players`,
|
||||
// `joinPlayer` el posa a true quan un slot inactiu pulsa START, i la
|
||||
// subscripció a `player_disabled` el torna a false quan un slot
|
||||
// esgota vides. `loadRoom` recalcula `setExpectedPlayerCount` a
|
||||
// partir d'aquesta vista — un slot DISABLED al final d'una sala no
|
||||
// entra automàticament a la següent (el jugador ha de re-pulsar
|
||||
// START si vol re-entrar).
|
||||
std::array<bool, 2> players_active_{false, false};
|
||||
|
||||
// Cinemàtica de spawn d'un slot concret. Posa el slot indicat en
|
||||
// AIRBORNE des d'una altura fora de pantalla. Cridat per
|
||||
@@ -130,6 +133,19 @@ class GameScene : public Scene {
|
||||
// Extret a un mètode per acotar la complexitat cognitiva del ctor.
|
||||
void dispatchPlayerKilled(int player_id);
|
||||
|
||||
// Handler de l'event `player_disabled` (un slot acaba d'esgotar
|
||||
// vides). Apaga `players_active_[id]` perquè el `loadRoom` següent
|
||||
// no compti aquest slot i perquè el "join in progress" del frame
|
||||
// següent torni a oferir-li `setJoinPrompt(CAN_JOIN)`. Extret a
|
||||
// mètode per acotar la complexitat cognitiva del subscriber/ctor.
|
||||
void onPlayerDisabled(int player_id);
|
||||
|
||||
// Pre-load dels recursos d'àudio que s'usen mid-partida i no els
|
||||
// toca el Director (so del countdown, etc.). Si la clau és buida
|
||||
// o ja està al cache, és no-op. Extret a mètode per acotar la
|
||||
// complexitat cognitiva del ctor.
|
||||
void preloadGameOverAssets() const;
|
||||
|
||||
// Itera els slots actius i, si el delay configurat ha expirat des
|
||||
// del callback de fi de música d'INTRO, dispara `triggerPlayerSpawn`.
|
||||
// Cridada cada frame durant la fase d'spawn escalonat (entre el
|
||||
@@ -137,6 +153,33 @@ class GameScene : public Scene {
|
||||
// caigut tots o si encara no estem dins la finestra (intro_completed_).
|
||||
void spawnDuePlayers();
|
||||
|
||||
// Atén el "join in progress" (regla 1 de la FSM de joc): si un slot
|
||||
// està DISABLED i el jugador corresponent pulsa START dins de PLAYING,
|
||||
// dispara `joinPlayer`. La detecció de tecla/botó la fa
|
||||
// `Input::detect::pressedStart`. Ignorat fora de PLAYING (durant
|
||||
// INTRO/OUTRO el slot inactiu mostra "ESPERA / PARA JUGAR" al HUD).
|
||||
void handleJoinInput();
|
||||
|
||||
// Materialitza l'entrada d'un slot inactiu: spawn aeri al costat
|
||||
// corresponent de la porta (`computeSpawn().pX_x`), reset de vides
|
||||
// al valor inicial via `Player::resetForJoin`, marcatge de
|
||||
// `players_active_[i]=true` i activació del bloc del HUD. La
|
||||
// cinemàtica reaprofita `spawnFromAir` (mateixa caiguda visual que
|
||||
// l'INTRO de sala i el respawn post-mort).
|
||||
void joinPlayer(std::size_t slot_index);
|
||||
|
||||
// Recompte de jugadors "en partida" (no DISABLED ni EXITED, o bé
|
||||
// DYING — DYING encara compta com a jugant fins que es resol). Hook
|
||||
// exposat per a la futura sub-FSM CONTINUE? + GAME OVER, que vigila
|
||||
// la transició a 0 jugadors actius. No s'usa dins de la Fase 1.
|
||||
[[nodiscard]] auto activePlayerCount() const -> int;
|
||||
|
||||
// Refresca el prompt mostrat al bloc HUD de cada slot inactiu. Es
|
||||
// crida cada frame des d'`update()`. La decisió viu ací (que coneix
|
||||
// `state_` de la RoomFsm); el HUD només pinta. Per a slots actius,
|
||||
// posa el prompt a NONE per ser defensiu davant de canvis d'estat.
|
||||
void refreshJoinPrompt();
|
||||
|
||||
// Sentinel que marca el moment "música d'INTRO acabada" dins del
|
||||
// timeline. Ja no ve d'un callback d'àudio: el setja el GameScene
|
||||
// quan la FSM ha consumit l'última fase d'INTRO (la `music_tail`,
|
||||
@@ -159,6 +202,14 @@ class GameScene : public Scene {
|
||||
// i a `setState(ROOM_INTRO)` (camí defensiu no canònic).
|
||||
std::array<bool, 2> players_spawned_in_intro_{false, false};
|
||||
|
||||
// Idempotència de la crida a `entity_manager_.spawnPendingActors()`
|
||||
// dins d'un cicle d'INTRO. Es dispara una sola vegada quan
|
||||
// ROOM_INTRO entra a la subfase text-exit (fase 4) i, defensivament,
|
||||
// a `respawnPlayersInstant` i al fallback de `completeIntro` per a
|
||||
// camins no canònics. Reset a `enterRoomIntro` i a
|
||||
// `setState(ROOM_INTRO)`.
|
||||
bool pending_actors_spawned_{false};
|
||||
|
||||
// Vista plana de punters crus — la fa servir Player com a llista
|
||||
// d'obstacles mòbils a la callback de col·lisió. Es construeix un sol
|
||||
// cop al ctor (els punters són estables perquè `players_` no es
|
||||
@@ -255,18 +306,99 @@ class GameScene : public Scene {
|
||||
void renderRoomFade();
|
||||
void renderRoomText();
|
||||
|
||||
// Helpers de `renderRoomText`. Tots consulten `transition_` i
|
||||
// `transition_config_` directament a través de `this` — així les
|
||||
// signatures es queden netes i la funció pública s'orquestra amb
|
||||
// poques línies. Tres tasques separades:
|
||||
// - X animada d'una línia (entra per un costat, surt per l'altre).
|
||||
// - Altura animada del panel translúcid (creix, manté, decreix).
|
||||
// - Pintat efectiu del panel i d'una línia de text.
|
||||
[[nodiscard]] auto computeRoomTextX(int width, int phase_enter, int phase_exit, int dur_enter, int dur_exit, bool from_right) const -> int;
|
||||
[[nodiscard]] auto computeRoomTextPanelHeight(int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int;
|
||||
// Configuració, estat i transició de la sub-FSM de game over
|
||||
// (CONTINUE? + GAME OVER). Lligada al mateix `game.yaml` que la
|
||||
// RoomFsm via `GameOverFsm::loadConfig`.
|
||||
GameOverFsm::Config game_over_config_{};
|
||||
GameOverFsm::State game_over_state_{GameOverFsm::State::OFF};
|
||||
GameOverFsm::Transition game_over_transition_{};
|
||||
|
||||
// Fade final cap a LOGO. Independent del `outro_fade_` perquè el
|
||||
// game over passa per damunt de la RoomFsm i pot disparar-se durant
|
||||
// PLAYING sense haver d'arrencar un OUTRO de sala. Comença a la
|
||||
// fase 0 de FADE_OUT i es manté pintant alpha=255 durant el
|
||||
// `black_hold` final (mateix patró que `outro_fade_`).
|
||||
Transitions::FadeToBlack game_over_fade_;
|
||||
bool game_over_fade_started_{false};
|
||||
|
||||
// Últim valor del countdown reproduït amb so. -1 com a sentinel
|
||||
// perquè la primera detecció del valor inicial (`continue_count_start`)
|
||||
// dispari el "tick". Es reseteja a -1 cada vegada que entrem a
|
||||
// CONTINUE_PROMPT (vegeu `setGameOverState`).
|
||||
int last_countdown_value_{-1};
|
||||
|
||||
// Avança la sub-FSM de game over consumint `delta_ms`. Detecta la
|
||||
// transició de "tots els jugadors fora de partida" durant PLAYING
|
||||
// (entrada a CONTINUE_PROMPT), arma el fade de FADE_OUT i, en
|
||||
// arribar a DONE, demana la transició a `LOGO`.
|
||||
void updateGameOverFlow(int delta_ms);
|
||||
|
||||
// Substitueix `setState` per a la sub-FSM de game over: log
|
||||
// d'entrada/sortida i reset de la `Transition`. També arma el
|
||||
// `game_over_fade_` quan entrem a FADE_OUT, en analogia amb
|
||||
// `outro_fade_`.
|
||||
void setGameOverState(GameOverFsm::State new_state);
|
||||
|
||||
// Pintat del rètol de game over (CONTINUE? + countdown durant
|
||||
// CONTINUE_PROMPT; "GAME OVER" durant GAME_OVER). Reaprofita el
|
||||
// panel translúcid i la font del room text.
|
||||
void renderGameOverText();
|
||||
|
||||
// Pintat del fade negre final (FADE_OUT i black hold). Cridat
|
||||
// després del HUD perquè cobreixi tota la pantalla, inclòs el
|
||||
// marcador.
|
||||
void renderGameOverFade();
|
||||
|
||||
// Comprova si dins de CONTINUE_PROMPT phase 1 el número visible
|
||||
// està al rang de skip i, si un jugador pulsa salt o atac, fa
|
||||
// saltar la fase al següent número via `GameOverFsm::skipToNextCountdown`.
|
||||
// Extret a mètode separat per acotar la complexitat cognitiva
|
||||
// d'`updateGameOverFlow`.
|
||||
void handleCountdownSkip();
|
||||
|
||||
// Detecta canvis de número del countdown i dispara el so
|
||||
// configurat un cop per cada nou valor. També extret per
|
||||
// mantenir `updateGameOverFlow` per sota del llindar de tidy.
|
||||
void emitCountdownTickIfChanged();
|
||||
|
||||
// Helpers d'animació del rètol de transició. Reutilitzats pel
|
||||
// rètol de sala (`renderRoomText`) i pel de game over
|
||||
// (`renderGameOverText`). Reben `phase` i `elapsed_ms` per
|
||||
// paràmetre perquè la mateixa lògica visual serveix les dues
|
||||
// sub-FSMs sense saber a quina pertanyen — només es lliguen al
|
||||
// `transition_config_` per a corbes i geometria del panel.
|
||||
[[nodiscard]] auto computeRoomTextX(int width, int phase, int elapsed_ms, int phase_enter, int phase_exit, int dur_enter, int dur_exit, bool from_right) const -> int;
|
||||
[[nodiscard]] auto computeRoomTextPanelHeight(int phase, int elapsed_ms, int phase_enter, int phase_hold, int phase_exit, int dur_enter, int dur_exit) const -> int;
|
||||
void drawRoomTextPanel(int center_y, int panel_height) const;
|
||||
void drawRoomTextLine(const std::string& text, int x, int y, std::uint8_t fill_color, std::uint8_t outline_color) const;
|
||||
|
||||
// Especificació declarativa d'un rètol de transició de dues línies
|
||||
// amb panel translúcid. ROOM_INTRO, ROOM_OUTRO, CONTINUE? i GAME OVER
|
||||
// comparteixen la mateixa coreografia (panel + dues línies en
|
||||
// direccions oposades) i només divergeixen en el text, els índexs
|
||||
// de fase i les durades. `phase_enter = -1` desactiva l'animació
|
||||
// d'entrada (cas GAME_OVER, que apareix amb el panel ja amunt).
|
||||
// Una `lineN` buida indica que la línia no s'ha de pintar (cas
|
||||
// CONTINUE? fora del hold, on la línia 2 mostra el countdown).
|
||||
struct TextScreenSpec {
|
||||
std::string line1;
|
||||
std::string line2;
|
||||
int phase_enter{0};
|
||||
int phase_hold{0};
|
||||
int phase_exit{0};
|
||||
int dur_enter_ms{0};
|
||||
int dur_exit_ms{0};
|
||||
int line_gap_px{0};
|
||||
};
|
||||
|
||||
// Orquestrador unificat dels quatre rètols de transició. Pinta el
|
||||
// panel + dues línies a partir de `spec`, deixant la decisió de
|
||||
// QUÈ pintar (text, fases, durades) als callers `renderRoomText`
|
||||
// i `renderGameOverText`. Sense estat propi: la fase i temps
|
||||
// arriben pels paràmetres perquè la mateixa funció serveix les
|
||||
// dues sub-FSMs (RoomFsm i GameOverFsm).
|
||||
void renderTextScreen(const TextScreenSpec& spec, int phase, int elapsed_ms) const;
|
||||
|
||||
// Posició inicial dels jugadors derivada de `Map::door_col`. Tots
|
||||
// els jugadors comparteixen Y (centre del corredor exterior superior);
|
||||
// X depèn del nombre actiu: amb un jugador queda centrat sota la
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
#include "game/scenes/start_input.hpp"
|
||||
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "core/input/gamepad_bindings.hpp"
|
||||
#include "core/input/gamepad_manager.hpp"
|
||||
#include "core/system/event_buffer.hpp"
|
||||
#include "game/options.hpp"
|
||||
#include "utils/defaults.hpp"
|
||||
|
||||
namespace Input::Detect {
|
||||
|
||||
auto pressedStart(int player_id) -> bool {
|
||||
const SDL_Scancode KB = (player_id == 0) ? Defaults::KeysGame::START : Defaults::KeysGameP2::START;
|
||||
SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id);
|
||||
const SDL_JoystickID PAD_ID = (pad != nullptr) ? SDL_GetGamepadID(pad) : 0;
|
||||
const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1
|
||||
: Options::controls().gamepad_p2;
|
||||
const int START_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::START);
|
||||
|
||||
return std::ranges::any_of(EventBuffer::events, [&](const SDL_Event& ev) {
|
||||
if (ev.type == SDL_EVENT_KEY_DOWN && ev.key.scancode == KB) { return true; }
|
||||
if (ev.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && ev.gbutton.which == PAD_ID && static_cast<int>(ev.gbutton.button) == START_BTN) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
auto pressedActionButton(int player_id) -> bool {
|
||||
// Teclat: les bindings actives venen d'`Options::controls()` (les
|
||||
// que el menú de servei pot reescriure), no de `Defaults` cru —
|
||||
// així respectem el remapping de l'usuari igual que fa el Player.
|
||||
const auto& kb = (player_id == 0) ? Options::controls().keyboard_p1
|
||||
: Options::controls().keyboard_p2;
|
||||
const SDL_Scancode KB_JUMP = kb.jump;
|
||||
const SDL_Scancode KB_ATTACK = kb.attack;
|
||||
SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id);
|
||||
const SDL_JoystickID PAD_ID = (pad != nullptr) ? SDL_GetGamepadID(pad) : 0;
|
||||
const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1
|
||||
: Options::controls().gamepad_p2;
|
||||
const int JUMP_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::JUMP);
|
||||
const int ATTACK_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::ATTACK);
|
||||
|
||||
return std::ranges::any_of(EventBuffer::events, [&](const SDL_Event& ev) {
|
||||
if (ev.type == SDL_EVENT_KEY_DOWN && (ev.key.scancode == KB_JUMP || ev.key.scancode == KB_ATTACK)) {
|
||||
return true;
|
||||
}
|
||||
if (ev.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && ev.gbutton.which == PAD_ID) {
|
||||
const int BTN = static_cast<int>(ev.gbutton.button);
|
||||
if (BTN == JUMP_BTN || BTN == ATTACK_BTN) { return true; }
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace Input::Detect
|
||||
@@ -0,0 +1,28 @@
|
||||
#pragma once
|
||||
|
||||
// Helper compartit per detectar pulsacions del botó START dins del frame
|
||||
// actual per a un jugador concret. Originalment vivia al namespace anònim
|
||||
// de `title_scene.cpp` perquè era l'únic consumidor; ara la GameScene
|
||||
// també l'usa per al "join in progress" mid-partida (regla 1 de la FSM
|
||||
// del jugador), així que extraiem la funció a un namespace propi per
|
||||
// evitar duplicar la pila d'input (Defaults::Keys + GamepadManager +
|
||||
// Options + GamepadBindings + EventBuffer) en dos llocs.
|
||||
namespace Input::Detect {
|
||||
|
||||
// Cert si dins dels events SDL d'aquest frame hi ha una pulsació de
|
||||
// START per al jugador `player_id` (0 = P1, 1 = P2). Combina events
|
||||
// de teclat (scancode hardcoded a `Defaults::KeysGame[P2]::START`) i
|
||||
// de gamepad (botó remapejable via `GamepadBindings`, lligat al pad
|
||||
// assignat al jugador via `Options::controls()`).
|
||||
[[nodiscard]] auto pressedStart(int player_id) -> bool;
|
||||
|
||||
// Cert si dins dels events SDL d'aquest frame el jugador
|
||||
// `player_id` (0 = P1, 1 = P2) ha pulsat el botó de salt o el
|
||||
// d'atac (qualsevol dels dos). Usat per la GameScene per acceptar
|
||||
// qualsevol "botó d'acció" com a senyal de skip del countdown de
|
||||
// CONTINUE? un cop el número visible cau a un llindar configurable.
|
||||
// Mateix patró que `pressedStart`: combina teclat (Defaults::Keys)
|
||||
// i gamepad (GamepadBindings).
|
||||
[[nodiscard]] auto pressedActionButton(int player_id) -> bool;
|
||||
|
||||
} // namespace Input::Detect
|
||||
@@ -8,8 +8,6 @@
|
||||
#include <utility>
|
||||
|
||||
#include "core/audio/audio.hpp"
|
||||
#include "core/input/gamepad_bindings.hpp"
|
||||
#include "core/input/gamepad_manager.hpp"
|
||||
#include "core/input/global_inputs.hpp"
|
||||
#include "core/locale/locale.hpp"
|
||||
#include "core/rendering/notifier.hpp"
|
||||
@@ -18,11 +16,10 @@
|
||||
#include "core/rendering/surface.hpp"
|
||||
#include "core/rendering/text.hpp"
|
||||
#include "core/resources/resource_cache.hpp"
|
||||
#include "core/system/event_buffer.hpp"
|
||||
#include "core/ui/ui_config.hpp"
|
||||
#include "game/options.hpp"
|
||||
#include "game/scenes/scene_flow.hpp"
|
||||
#include "utils/defaults.hpp"
|
||||
#include "game/scenes/start_input.hpp"
|
||||
#include "utils/defines.hpp"
|
||||
#include "utils/easing.hpp"
|
||||
#include "utils/log.hpp"
|
||||
@@ -33,27 +30,6 @@ namespace {
|
||||
// docs/audit-core-rendering.md).
|
||||
const char* const SCENE_NAME = "TITLE";
|
||||
|
||||
// Detecta una pulsació de START dins el frame actual per al jugador
|
||||
// indicat. Combina events de teclat (scancode hardcoded a Defaults::
|
||||
// KeysGame[P2]) i events de gamepad (botó remapejable via
|
||||
// GamepadBindings, lligat al pad assignat al jugador via Options).
|
||||
auto pressedStart(int player_id) -> bool {
|
||||
const SDL_Scancode KB = (player_id == 0) ? Defaults::KeysGame::START : Defaults::KeysGameP2::START;
|
||||
SDL_Gamepad* pad = GamepadManager::get().getForPlayer(player_id);
|
||||
const SDL_JoystickID PAD_ID = (pad != nullptr) ? SDL_GetGamepadID(pad) : 0;
|
||||
const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1
|
||||
: Options::controls().gamepad_p2;
|
||||
const int START_BTN = GamepadBindings::get().getButton(slot.name, slot.path, GamepadBindings::Action::START);
|
||||
|
||||
return std::ranges::any_of(EventBuffer::events, [&](const SDL_Event& ev) {
|
||||
if (ev.type == SDL_EVENT_KEY_DOWN && ev.key.scancode == KB) { return true; }
|
||||
if (ev.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && ev.gbutton.which == PAD_ID && static_cast<int>(ev.gbutton.button) == START_BTN) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Reproducció del so de START. Centralitzat ací per disparar-lo des
|
||||
// dels punts on una pulsació té efecte real (entrada a STARTING o
|
||||
// upgrade a BOTH); l'attract timeout no l'ha de tocar.
|
||||
@@ -139,8 +115,8 @@ void TitleScene::update(int delta_ms) {
|
||||
}
|
||||
}
|
||||
|
||||
const bool P1_START = pressedStart(0);
|
||||
const bool P2_START = pressedStart(1);
|
||||
const bool P1_START = Input::Detect::pressedStart(0);
|
||||
const bool P2_START = Input::Detect::pressedStart(1);
|
||||
|
||||
switch (phase_) {
|
||||
case Phase::IDLE:
|
||||
|
||||
@@ -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);
|
||||
@@ -69,9 +88,10 @@ namespace {
|
||||
|
||||
} // namespace
|
||||
|
||||
Hud::Hud(EventBus& bus, std::shared_ptr<Text> font)
|
||||
Hud::Hud(EventBus& bus, std::shared_ptr<Text> font, int initial_lives)
|
||||
: bus_(bus),
|
||||
font_(std::move(font)) {
|
||||
font_(std::move(font)),
|
||||
initial_lives_(initial_lives) {
|
||||
// Geometria fixa dels tres blocs. Sumen exactament GameCanvas::WIDTH.
|
||||
// La durada de l'animació d'entrada ve de data/config/ui.yaml via UiConfig.
|
||||
const int ENTRY_MS = UiConfig::get().hudEntryMs();
|
||||
@@ -103,10 +123,17 @@ Hud::Hud(EventBus& bus, std::shared_ptr<Text> font)
|
||||
.entry_offset_y = 0,
|
||||
};
|
||||
|
||||
// Per defecte, P1 actiu (sempre n'hi ha al menys un); P2 inactiu fins
|
||||
// que un segon Player s'uneix mitjançant `activatePlayer(2)`.
|
||||
players_[0].active = true;
|
||||
// Naixem amb tots dos slots inactius. El GameScene crida
|
||||
// `activatePlayer(...)` per als que arrenquen en partida segons
|
||||
// `starting_players` (un, l'altre, o tots dos). Els no activats
|
||||
// mostraran el prompt "POLSA START / PER JUGAR" via `setJoinPrompt`
|
||||
// fins que el jugador faci join. Les vides s'inicialitzen al valor
|
||||
// del YAML del jugador — sense això, canviar `lives:` no es
|
||||
// reflectiria al marcador.
|
||||
players_[0].active = false;
|
||||
players_[0].lives = initial_lives_;
|
||||
players_[1].active = false;
|
||||
players_[1].lives = initial_lives_;
|
||||
|
||||
// Subscripcions al bus de l'EntityManager. De moment només `player_hit`
|
||||
// afecta el HUD visualment: decrementa vides, dispara flash i shake al
|
||||
@@ -136,6 +163,18 @@ Hud::Hud(EventBus& bus, std::shared_ptr<Text> font)
|
||||
Log::info("HUD", "level_complete (mode Win pendent)");
|
||||
}));
|
||||
|
||||
// `player_disabled`: el jugador acaba d'esgotar les seves vides al
|
||||
// final del cicle DYING. Tornem el bloc al render inactiu — el
|
||||
// prompt textual el fixa GameScene cada frame via `setJoinPrompt`,
|
||||
// així que ací només cal apagar `active`. Els camps lives/score/
|
||||
// treasures es preserven per si en una versió futura volem mostrar
|
||||
// el resultat final abans de tornar a zero.
|
||||
subs_.push_back(bus_.subscribe("player_disabled", [this](const Event& ev) {
|
||||
const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U;
|
||||
players_[IDX].active = false;
|
||||
Log::info("HUD", "player_disabled p{}", IDX + 1);
|
||||
}));
|
||||
|
||||
Log::info("HUD", "init: 3 blocks ready (entry {} ms)", ENTRY_MS);
|
||||
}
|
||||
|
||||
@@ -164,9 +203,15 @@ void Hud::activatePlayer(int player_one_based) {
|
||||
if (player_one_based < 1 || player_one_based > 2) { return; }
|
||||
auto& p = players_[static_cast<std::size_t>(player_one_based - 1)];
|
||||
p.active = true;
|
||||
p.lives = DEFAULT_LIVES;
|
||||
p.lives = initial_lives_;
|
||||
p.score = 0;
|
||||
p.treasures = 0;
|
||||
p.join_prompt = JoinPromptMode::NONE;
|
||||
}
|
||||
|
||||
void Hud::setJoinPrompt(int player_one_based, JoinPromptMode mode) {
|
||||
if (player_one_based < 1 || player_one_based > 2) { return; }
|
||||
players_[static_cast<std::size_t>(player_one_based - 1)].join_prompt = mode;
|
||||
}
|
||||
|
||||
auto Hud::computeOffset(const Block& b) -> SDL_FPoint {
|
||||
@@ -234,7 +279,37 @@ 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)];
|
||||
const Uint8 SCORE_C = (blk.flash_ms_left > 0) ? UiConfig::get().hudFlash() : UiConfig::get().hudValue();
|
||||
|
||||
// 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
|
||||
// queda com a fallback (mode NONE o slot actiu).
|
||||
if (!p.active && p.join_prompt != JoinPromptMode::NONE) {
|
||||
const bool CAN_JOIN = (p.join_prompt == JoinPromptMode::CAN_JOIN);
|
||||
const std::string L1 = Locale::get().text(
|
||||
CAN_JOIN ? "hud.join_prompt_can_join_l1" : "hud.join_prompt_wait_l1");
|
||||
const std::string L2 = Locale::get().text(
|
||||
CAN_JOIN ? "hud.join_prompt_can_join_l2" : "hud.join_prompt_wait_l2");
|
||||
const int L1_W = font_->length(L1);
|
||||
const int L2_W = font_->length(L2);
|
||||
const int X1 = x + ((w - L1_W) / 2);
|
||||
const int X2 = x + ((w - L2_W) / 2);
|
||||
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) ? ui.hudFlash() : VAL_C;
|
||||
|
||||
// --- Línia 1: "<label>-000000" centrada ---
|
||||
const std::string LBL1 = label + SEPARATOR;
|
||||
@@ -243,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;
|
||||
@@ -258,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_);
|
||||
@@ -275,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;
|
||||
@@ -286,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);
|
||||
}
|
||||
@@ -41,7 +41,25 @@ class Hud {
|
||||
WIN,
|
||||
EMPTY };
|
||||
|
||||
Hud(EventBus& bus, std::shared_ptr<Text> font);
|
||||
// Prompt mostrat al bloc d'un slot inactiu (en lloc dels zeros). El
|
||||
// GameScene fixa aquest valor cada frame segons el `RoomFsm::State`
|
||||
// perquè el missatge sigui coherent amb si el jugador pot pulsar
|
||||
// START per entrar ara mateix o si ha d'esperar al canvi de fase.
|
||||
// - NONE → render històric (zeros). Per a slots actius o per
|
||||
// quan el GameScene no vol ensenyar prompt.
|
||||
// - CAN_JOIN → "PULSA START / PARA JUGAR". Slot inactiu en PLAYING.
|
||||
// - WAIT → "ESPERA / PARA JUGAR". Slot inactiu durant
|
||||
// ROOM_INTRO/ROOM_OUTRO i, en fases futures, després
|
||||
// de GAME OVER.
|
||||
enum class JoinPromptMode : std::uint8_t { NONE,
|
||||
CAN_JOIN,
|
||||
WAIT };
|
||||
|
||||
// `initial_lives` es la cantidad de vides amb què s'activa un slot
|
||||
// (P1 al ctor i, més tard, els que arriben via "join in progress").
|
||||
// Ve del YAML del jugador (`Player::death_config.initial_lives`),
|
||||
// així el marcador segueix automàticament la configuració del joc.
|
||||
Hud(EventBus& bus, std::shared_ptr<Text> font, int initial_lives);
|
||||
~Hud();
|
||||
|
||||
Hud(const Hud&) = delete;
|
||||
@@ -71,6 +89,14 @@ class Hud {
|
||||
// arrenca amb les vides per defecte.
|
||||
void activatePlayer(int player_one_based);
|
||||
|
||||
// Fixa el prompt visible al bloc d'un slot inactiu (1-based: 1 → P1,
|
||||
// 2 → P2). Cridat des de `GameScene::update()` cada frame perquè el
|
||||
// missatge depèn de l'estat de la `RoomFsm` (vegeu enum
|
||||
// `JoinPromptMode`). Sobre un slot actiu el prompt no es renderitza
|
||||
// — el camp existeix per simetria i per evitar branques al caller.
|
||||
// Idempotent: cridar amb el mateix mode és no-op.
|
||||
void setJoinPrompt(int player_one_based, JoinPromptMode mode);
|
||||
|
||||
// Geometria pública perquè la GameScene puga calcular si el HUD
|
||||
// s'està movent encara (p. ex. retardar l'spawn fins que estiga al
|
||||
// seu lloc) — opcional, no obligatori.
|
||||
@@ -89,10 +115,6 @@ class Hud {
|
||||
static constexpr int LINE1_Y = 4;
|
||||
static constexpr int LINE2_Y = 13;
|
||||
|
||||
// Vides inicials per jugador (placeholder fins que GameScene ho
|
||||
// configure des del Config del nivell).
|
||||
static constexpr int DEFAULT_LIVES = 3;
|
||||
|
||||
struct Block {
|
||||
Mode mode{Mode::IDLE};
|
||||
// Coordenades de pantalla on viu el bloc (constants durant la
|
||||
@@ -121,12 +143,17 @@ class Hud {
|
||||
};
|
||||
|
||||
struct PlayerStats {
|
||||
int lives{DEFAULT_LIVES};
|
||||
int lives{0};
|
||||
int score{0};
|
||||
int treasures{0};
|
||||
// `false` → el bloc mostra `000000`/`0`/`00` (jugador inactiu).
|
||||
// Sempre es renderitza per mantenir el layout fix.
|
||||
// `false` → el bloc mostra el prompt actiu (`join_prompt`) o
|
||||
// bé zeros si el prompt és NONE. Sempre es renderitza per
|
||||
// mantenir el layout fix.
|
||||
bool active{false};
|
||||
// Missatge a mostrar quan `active==false`. El GameScene el
|
||||
// refresca cada frame segons l'estat de la RoomFsm (vegeu
|
||||
// `Hud::setJoinPrompt`).
|
||||
JoinPromptMode join_prompt{JoinPromptMode::NONE};
|
||||
};
|
||||
|
||||
// Pinta el bloc indicat amb el seu fons + el contingut del seu mode
|
||||
@@ -152,4 +179,8 @@ class Hud {
|
||||
std::array<PlayerStats, 2> players_;
|
||||
int pyramid_{1};
|
||||
int room_{1};
|
||||
// Vides amb què s'arrenca un slot (`activatePlayer`). Cachejat al
|
||||
// ctor des de `Player::death_config.initial_lives` per no llegir el
|
||||
// singleton estàtic en el camí calent del bus de events.
|
||||
int initial_lives_{0};
|
||||
};
|
||||
@@ -17,6 +17,7 @@
|
||||
#include "core/rendering/text.hpp"
|
||||
#include "core/system/event_buffer.hpp"
|
||||
#include "core/ui/ui_config.hpp"
|
||||
#include "game/gameplay_config.hpp"
|
||||
#include "game/options.hpp"
|
||||
#include "game/options_apply.hpp"
|
||||
#include "utils/defaults.hpp"
|
||||
@@ -692,6 +693,8 @@ auto ServiceMenu::buildRoot() -> Page {
|
||||
[]() { ServiceMenu::get().pushPage(buildAudio()); }));
|
||||
page.items.push_back(makeSubmenu("menu.items.controls_submenu",
|
||||
[]() { ServiceMenu::get().pushPage(buildControls()); }));
|
||||
page.items.push_back(makeSubmenu("menu.items.tweaks_submenu",
|
||||
[]() { ServiceMenu::get().pushPage(buildTweaks()); }));
|
||||
page.items.push_back(makeSubmenu("menu.items.system_submenu",
|
||||
[]() { ServiceMenu::get().pushPage(buildSystem()); }));
|
||||
return page;
|
||||
@@ -1155,6 +1158,42 @@ void ServiceMenu::startDefineButtons(SDL_Gamepad* pad, const std::string& name,
|
||||
define_buttons_.enable(pad, name, path);
|
||||
}
|
||||
|
||||
auto ServiceMenu::buildTweaks() -> Page {
|
||||
// Submenú "Tweaks" — flags de jugabilitat persistits a `gameplay.yaml`
|
||||
// dins la carpeta de sistema. Cada toggle muta `GameplayConfig` i
|
||||
// n'invoca `save()` immediatament: l'usuari veu el canvi reflectit al
|
||||
// joc sense esperar a un "aplicar" i, si tanca el joc, torna en el
|
||||
// mateix estat. Tres toggles ON/OFF, sense estat addicional.
|
||||
Page page;
|
||||
page.title_key = "menu.titles.tweaks";
|
||||
|
||||
page.items.push_back(makeToggle(
|
||||
"menu.items.whip_pierces_walls",
|
||||
[]() { return GameplayConfig::get().whipDamagePiercesWalls(); },
|
||||
[](bool v) {
|
||||
GameplayConfig::get().setWhipDamagePiercesWalls(v);
|
||||
GameplayConfig::get().save();
|
||||
}));
|
||||
|
||||
page.items.push_back(makeToggle(
|
||||
"menu.items.friendly_fire",
|
||||
[]() { return GameplayConfig::get().friendlyFire(); },
|
||||
[](bool v) {
|
||||
GameplayConfig::get().setFriendlyFire(v);
|
||||
GameplayConfig::get().save();
|
||||
}));
|
||||
|
||||
page.items.push_back(makeToggle(
|
||||
"menu.items.players_collide",
|
||||
[]() { return GameplayConfig::get().playersCollide(); },
|
||||
[](bool v) {
|
||||
GameplayConfig::get().setPlayersCollide(v);
|
||||
GameplayConfig::get().save();
|
||||
}));
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
auto ServiceMenu::buildSystem() -> Page {
|
||||
Page page;
|
||||
page.title_key = "menu.titles.system";
|
||||
|
||||
@@ -253,6 +253,7 @@ class ServiceMenu {
|
||||
[[nodiscard]] static auto buildKeyboardControls() -> Page;
|
||||
[[nodiscard]] static auto buildGamepadControls() -> Page;
|
||||
[[nodiscard]] static auto buildSystem() -> Page;
|
||||
[[nodiscard]] static auto buildTweaks() -> Page;
|
||||
|
||||
// Helpers privats d'items de vídeo: aïllen la lògica de
|
||||
// `shader_type` i `preset` fora de `buildVideo` perquè no exploti la
|
||||
|
||||
@@ -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`.
|
||||
@@ -508,6 +516,7 @@ namespace Defaults {
|
||||
constexpr const char* MUSIC_PLAYING = "data/audio/music/playing.ogg";
|
||||
constexpr const char* MUSIC_ROOM_INTRO = "data/audio/music/room_intro.ogg";
|
||||
constexpr const char* MUSIC_ROOM_OUTRO = "data/audio/music/room_outro.ogg";
|
||||
constexpr const char* MUSIC_GAME_OVER = "data/audio/music/game_over.ogg";
|
||||
|
||||
// Gràfics de logo / title pre-carregats pel Director.
|
||||
constexpr const char* LOGO_MINI_GIF = "data/scenes/logo/logo_jailgames_mini.gif";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace Texts {
|
||||
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte ARCADE — JailDesigner";
|
||||
constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
|
||||
constexpr const char* VERSION = "0.38.0"; // Single source of truth — Makefile el grep
|
||||
constexpr const char* VERSION = "0.43.0"; // Single source of truth — Makefile el grep
|
||||
} // namespace Texts
|
||||
|
||||
namespace GameCanvas {
|
||||
|
||||
@@ -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& {
|
||||
|
||||
@@ -69,6 +69,16 @@ namespace {
|
||||
|
||||
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
|
||||
|
||||
// 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) {
|
||||
for (int i = 0; i < 32 && mummy.isSpawning(); ++i) {
|
||||
mummy.update(0.5F);
|
||||
}
|
||||
}
|
||||
|
||||
void testFactory() {
|
||||
std::cout << "[T1] factory\n";
|
||||
auto& factory = EntityFactory::instance();
|
||||
@@ -156,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);
|
||||
}
|
||||
|
||||
@@ -198,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);
|
||||
|
||||
@@ -442,6 +456,7 @@ namespace {
|
||||
// Mateixa col·locació que testMummyStep: corredor exterior
|
||||
// superior, lliure de sarcs, prou marge per fugir cap a la dreta.
|
||||
mummy.setPixelRect({.x = 60.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F});
|
||||
exitMummySpawn(mummy);
|
||||
|
||||
// Direcció preferida: dreta. Mòmia ha d'entrar en HURT i avançar
|
||||
// a la dreta (la velocitat de hurt és més alta que la base).
|
||||
@@ -474,6 +489,7 @@ namespace {
|
||||
// la paret i fer-la sortir de HURT.
|
||||
const float RIGHT_WALL = static_cast<float>(Map::playfield_x + Map::playfield_w);
|
||||
mummy.setPixelRect({.x = RIGHT_WALL - 12.0F, .y = 40.0F, .w = 12.0F, .h = 24.0F});
|
||||
exitMummySpawn(mummy);
|
||||
|
||||
// La direcció preferida (RIGHT) està bloquejada al test
|
||||
// d'`is_open`; entrarem amb una perpendicular o quieta.
|
||||
@@ -496,6 +512,101 @@ namespace {
|
||||
REQUIRE(mummy.getState() == Mummy::State::ROAMING);
|
||||
}
|
||||
|
||||
void testMummyHurtSnapsAtPlayfieldWall() {
|
||||
std::cout << "[T18] mummy HURT amarra a la vora del playfield en topar\n";
|
||||
// Regressió: amb dt no-exacte (joc real → SDL_GetTicks ms) el drift
|
||||
// sub-pixel de la cursa HURT deixava la BOX a 0.x px dins de l'última
|
||||
// cel·la quan topava amb el mur. Com que la BOX 16×16 cabe exacta al
|
||||
// corredor 16×16, qualsevol drift fa que la cantonada perpendicular
|
||||
// sobresurti cap als sarcs adjacents — i el `chooseHurtDirection` del
|
||||
// proper fueteig classifica les perpendiculars com a bloquejades per
|
||||
// micro-overlap, retornant NONE i deixant la mòmia eternament quieta
|
||||
// amb l'animació de córrer activa. El fix és snap a la vora exacta
|
||||
// dins de `hurtStep` abans de transitar a ROAMING.
|
||||
Map::loadFrom("data/maps/pyramid.yaml");
|
||||
|
||||
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
|
||||
Mummy mummy(tmpl);
|
||||
// Caixa real del joc (YAML): collision 16×16 als peus del sprite 16×32.
|
||||
mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16});
|
||||
// Comencem prou a prop del mur perquè la cursa hi arribi abans que
|
||||
// expiri el timer (60 px/s · 0.6 s = 36 px de runway).
|
||||
mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F});
|
||||
REQUIRE(mummy.getCollisionRect().x == 40.0F);
|
||||
exitMummySpawn(mummy);
|
||||
|
||||
mummy.enterHurt(Mummy::Direction::WEST);
|
||||
REQUIRE(mummy.getState() == Mummy::State::HURT);
|
||||
REQUIRE(mummy.getDirX() == -1.0F);
|
||||
|
||||
// Avancem amb dt no-divisor de 1 (joc real) fins que topa el mur.
|
||||
for (int i = 0; i < 200 && mummy.getState() == Mummy::State::HURT; ++i) {
|
||||
mummy.update(0.0173F);
|
||||
}
|
||||
REQUIRE(mummy.getState() == Mummy::State::ROAMING);
|
||||
// El fix garanteix snap exacte a playfield_x = 24.
|
||||
REQUIRE(mummy.getCollisionRect().x == static_cast<float>(Map::playfield_x));
|
||||
}
|
||||
|
||||
void testMummyRoamingRecoversFromZeroDir() {
|
||||
std::cout << "[T19] mummy ROAMING amb dir=(0,0) re-decideix la propera step()\n";
|
||||
// Xarxa de seguretat: si per qualsevol via la mòmia entra a ROAMING
|
||||
// amb dir_x_/dir_y_ a zero, el `step()` anterior feia early return i
|
||||
// es quedava quieta per sempre. Ara re-decideix com a una intersecció
|
||||
// si troba alguna direcció vàlida.
|
||||
Map::loadFrom("data/maps/pyramid.yaml");
|
||||
|
||||
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
|
||||
Mummy mummy(tmpl);
|
||||
mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16});
|
||||
// Posicionem-la a un creuament aliniat (interior, lluny de murs i
|
||||
// sarcs problemàtics): vertical x=88 ∩ horitzontal y=80.
|
||||
mummy.setPixelRect({.x = 88.0F, .y = 64.0F, .w = 16.0F, .h = 32.0F});
|
||||
// Forcem dir=(0,0) sense passar per HURT — simula l'estat patològic.
|
||||
mummy.setDirection(0.0F, 0.0F);
|
||||
REQUIRE(mummy.getDirX() == 0.0F);
|
||||
REQUIRE(mummy.getDirY() == 0.0F);
|
||||
|
||||
// Un sol step amb dt típic ha d'assignar alguna direcció no-zero.
|
||||
mummy.step(0.0167F);
|
||||
const bool RECOVERED = mummy.getDirX() != 0.0F || mummy.getDirY() != 0.0F;
|
||||
REQUIRE(RECOVERED);
|
||||
}
|
||||
|
||||
void testMummyHurtCornerNoSoftLock() {
|
||||
std::cout << "[T20] mummy HURT contra cantonada + re-whip no fa soft-lock\n";
|
||||
// Integració: cursa HURT cap a una cantonada del playfield, després
|
||||
// fueteig immediat al mateix lloc. Amb el bug, la mòmia quedava amb
|
||||
// dir=(0,0) i no es movia per molts fueteigs que rebés. Amb el fix
|
||||
// (snap a hurtStep + recovery a step), o bé `chooseHurtDirection`
|
||||
// troba una direcció lliure des de la posició aliniada, o bé el
|
||||
// proper step la rescata. En cap cas pot quedar permanentment quieta.
|
||||
Map::loadFrom("data/maps/pyramid.yaml");
|
||||
|
||||
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
|
||||
Mummy mummy(tmpl);
|
||||
mummy.setCollisionBox({.offset_x = 0, .offset_y = 16, .width = 16, .height = 16});
|
||||
// Mateix runway que T18.
|
||||
mummy.setPixelRect({.x = 40.0F, .y = 160.0F, .w = 16.0F, .h = 32.0F});
|
||||
exitMummySpawn(mummy);
|
||||
|
||||
mummy.enterHurt(Mummy::Direction::WEST);
|
||||
for (int i = 0; i < 200 && mummy.getState() == Mummy::State::HURT; ++i) {
|
||||
mummy.update(0.0173F);
|
||||
}
|
||||
// Fueteig immediat: enterHurt al moment exacte de quedar al mur.
|
||||
mummy.enterHurt(Mummy::Direction::WEST);
|
||||
|
||||
// Després d'una desena de frames, la mòmia s'ha de moure ja sigui via
|
||||
// la fugida HURT (perpendicular trobada) o via recovery a ROAMING.
|
||||
const auto BEFORE = mummy.getPixelRect();
|
||||
for (int i = 0; i < 60; ++i) { mummy.update(0.0173F); }
|
||||
const auto AFTER = mummy.getPixelRect();
|
||||
const float DX_TOTAL = AFTER.x - BEFORE.x;
|
||||
const float DY_TOTAL = AFTER.y - BEFORE.y;
|
||||
REQUIRE((DX_TOTAL != 0.0F) || (DY_TOTAL != 0.0F));
|
||||
}
|
||||
|
||||
void testMummyWhippableFlag() {
|
||||
std::cout << "[T17] mummy YAML llig `whippable` correctament\n";
|
||||
fkyaml::node tmpl = EntityLoader::loadTemplate("mummy");
|
||||
@@ -584,6 +695,9 @@ auto main() -> int {
|
||||
testMummyStep();
|
||||
testMummyHurtState();
|
||||
testMummyHurtAgainstWall();
|
||||
testMummyHurtSnapsAtPlayfieldWall();
|
||||
testMummyRoamingRecoversFromZeroDir();
|
||||
testMummyHurtCornerNoSoftLock();
|
||||
testMummyWhippableFlag();
|
||||
testTorchAnimation();
|
||||
testExitedAggregator();
|
||||
|
||||
@@ -32,6 +32,17 @@ namespace {
|
||||
|
||||
#define REQUIRE(cond) check((cond), #cond, __FILE__, __LINE__)
|
||||
|
||||
// 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) {
|
||||
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).
|
||||
// El damage_rect del whip s'alinearà al feet box (els últims 16 px),
|
||||
// i el draw_rect respectarà els offsets de la taula del disseny.
|
||||
@@ -200,12 +211,15 @@ namespace {
|
||||
// = OY, la collision queda a [OY+12, OY+24], 8 px d'overlap.
|
||||
mummy_owned->setPixelRect({.x = OX + 24.0F, .y = OY, .w = 12.0F, .h = 24.0F});
|
||||
auto* mummy_raw = static_cast<Mummy*>(manager.add(std::move(mummy_owned)));
|
||||
exitMummySpawn(*mummy_raw);
|
||||
|
||||
auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F);
|
||||
whip->setWorldContext(&manager, nullptr);
|
||||
|
||||
// 60 ms → frame 1 (active). active_frames: [1, 2].
|
||||
whip->update(0.06F);
|
||||
// frame_ms=80 al YAML → frame 0 viu [0, 80] ms, frame 1 [80, 160] ms.
|
||||
// active_frames=[1, 2], així que cal arribar com a mínim a 80 ms
|
||||
// perquè `isFrameActive()` retorne true i `applyDamage()` corra.
|
||||
whip->update(0.09F);
|
||||
|
||||
REQUIRE(mummy_raw->getState() == Mummy::State::HURT);
|
||||
}
|
||||
@@ -258,12 +272,14 @@ namespace {
|
||||
// = OY, la collision queda a [OY+12, OY+24], 8 px d'overlap.
|
||||
mummy_owned->setPixelRect({.x = OX + 24.0F, .y = OY, .w = 12.0F, .h = 24.0F});
|
||||
auto* mummy_raw = static_cast<Mummy*>(manager.add(std::move(mummy_owned)));
|
||||
exitMummySpawn(*mummy_raw);
|
||||
|
||||
auto whip = makeWhip(WhipAttack::Direction::EAST, BOX, 48.0F);
|
||||
whip->setWorldContext(&manager, nullptr);
|
||||
|
||||
// Primera passada (frame 1, actiu) → mòmia a HURT.
|
||||
whip->update(0.06F);
|
||||
// frame_ms=80 → frame 0 [0, 80] ms; cal ≥ 80 ms per entrar al
|
||||
// frame 1 (primer dels active_frames=[1, 2]).
|
||||
whip->update(0.09F);
|
||||
REQUIRE(mummy_raw->getState() == Mummy::State::HURT);
|
||||
const float HURT_INITIAL = mummy_raw->getHurtRemainingS();
|
||||
|
||||
@@ -275,7 +291,9 @@ namespace {
|
||||
const float HURT_AFTER_MUMMY_TICK = mummy_raw->getHurtRemainingS();
|
||||
REQUIRE(HURT_AFTER_MUMMY_TICK < HURT_INITIAL);
|
||||
|
||||
whip->update(0.05F); // frame 2, encara actiu
|
||||
// Segon tick del whip: acumulat 0.09 + 0.05 = 0.14 s, encara
|
||||
// dins del frame 1 ([80, 160] ms). already_hit_ ha de bloquejar.
|
||||
whip->update(0.05F);
|
||||
REQUIRE(mummy_raw->getHurtRemainingS() == HURT_AFTER_MUMMY_TICK);
|
||||
}
|
||||
|
||||
|
||||