Compare commits

..
49 Commits
Author SHA1 Message Date
JailDesigner 3442b9b481 chore: bump v0.32.0 — Notifier configurable, F5/F6 gatejades i CRTPI passthrough 2026-05-02 15:16:40 +02:00
JailDesigner 8746ee9ec9 Merge fix/shader-keys-and-service-menu — passthrough real i F5/F6 gatejades 2026-05-02 15:13:41 +02:00
JailDesigner ef7fbf4027 fix(shader): force passthrough quan està apagat; F5/F6 ignorats sense shader actiu 2026-05-02 15:12:58 +02:00
JailDesigner 964c32dd0d Merge feat/notifier-polish — geometria i animació del Notifier al ui.yaml 2026-05-02 14:59:28 +02:00
JailDesigner 970fe2b4db feat(notifier): entrada/eixida per temps amb easing, configurable a ui.yaml 2026-05-02 14:58:25 +02:00
JailDesigner f816e75cc0 feat(input): KP_2 dispara notificació llarga per a provar multilinia 2026-05-02 14:51:29 +02:00
JailDesigner 2e8df45b38 feat(notifier): geometria al ui.yaml; +1px alçada, text 1px més avall, marge superior 3 2026-05-02 14:43:04 +02:00
JailDesigner 643e5442c3 Merge fix/lane-snap-and-door-corridor — snap a lane i corridor amb porta oberta 2026-05-02 14:31:57 +02:00
JailDesigner 82ccf65ce1 fix(player): no snap a lane si pica contra mur extern; corridor només si porta oberta 2026-05-02 14:30:09 +02:00
JailDesigner 1ae6912be5 Merge feat/entity-editor — editor d'entitats complet 2026-05-02 13:36:34 +02:00
JailDesigner c003e6665e feat(editor): mou la porta i persisteix geometry.door_col 2026-05-02 13:35:01 +02:00
JailDesigner a254fdd669 feat(editor): hand pick per base, z-sort, amaga entitats en TILE, HUD 2-files centrat 2026-05-02 12:42:47 +02:00
JailDesigner c0362abf67 fix(editor): inverteix posició des del collision_rect i restaura mòmies r1 2026-05-02 12:22:14 +02:00
JailDesigner d13313503c fix(editor): preferDisk en Debug + reload de sala al entrar i sortir 2026-05-02 12:20:16 +02:00
JailDesigner 2a78c78e77 feat(editor): franja HUD amb mode, brush/sel, cursor i recompte de constraints 2026-05-02 11:41:25 +02:00
JailDesigner 10e16d7905 feat(editor): valida 1 porta i ≥1 sarc en sortida i en borrar 2026-05-02 11:37:57 +02:00
JailDesigner ecb4625465 feat(editor): EntityWriter — persisteix el bloc entities: al sortir 2026-05-02 11:35:10 +02:00
JailDesigner b4e36944d7 feat(editor): mode mà — selecció, mou, borra i marc d'entitats 2026-05-02 11:28:41 +02:00
JailDesigner 9b09ccb765 feat(editor): col·locació d'entitats amb snap per tipus i bloqueig de solapaments 2026-05-02 11:21:05 +02:00
JailDesigner 94213f9fd6 feat(editor): entity picker amb noms del factory i flag editable per plantilla 2026-05-02 11:07:02 +02:00
JailDesigner f591aa82fd fix(whip_attack): treure entity_manager_ duplicat (ja viu a Entity) 2026-05-02 11:01:53 +02:00
JailDesigner 2ef5968e0d feat(editor): màquina d'estats Mode {TILE,ENTITY,HAND} + Picker 2026-05-02 10:58:24 +02:00
JailDesigner ca03a1307e feat(editor): bindings per E (entity picker), H (mà), Supr (borrar) 2026-05-02 10:53:50 +02:00
JailDesigner fe916d263f Merge feat/controls — sticks al menú, accept/cancel remappable, facing real 2026-05-01 10:25:06 +02:00
JailDesigner de7233a012 fix(player): facing del sprite des del desplaçament real, no de l'input 2026-05-01 10:24:13 +02:00
JailDesigner d26ecec04f feat(menu): acceptar amb ATTACK i cancel·lar amb JUMP del mando 2026-05-01 09:07:22 +02:00
JailDesigner fddea0c449 feat(menu): navegació del menú de servei amb sticks 2026-05-01 09:03:20 +02:00
JailDesigner 2c1c1a442b Merge feat/notifier-tweaks — vora externa al notificador 2026-05-01 08:57:02 +02:00
JailDesigner d1948afe4b feat(notifier): vora externa del color del text 2026-05-01 08:56:53 +02:00
JailDesigner ac06b05332 chore(audio): defaults a música 100% i so 20% 2026-04-30 20:45:03 +02:00
JailDesigner 7d11dde0b1 Merge feat/menu-pause — pausa del món i sons al menú de servei 2026-04-30 20:40:04 +02:00
JailDesigner 73add013dd art: stun de sam/morcus refinat i mòmia més baixa per saltar millor 2026-04-30 20:22:09 +02:00
JailDesigner 4e2ba827e3 Merge feat/mummy-ai — generalització del filtre de cruces de la mòmia 2026-04-30 20:06:54 +02:00
JailDesigner 9ac0c0cb74 refactor(mummy): generalitza el filtre de cruces a isBlockedAheadInDir 2026-04-30 20:05:36 +02:00
JailDesigner 11a909b58d Merge feat/hazard-hole — hazard Hole, evitació per mòmies i broadcast de decals al load 2026-04-30 19:29:57 +02:00
JailDesigner 17cf6e0636 chore: bump v0.31.0 — hazard Hole, IA d'evitació de mòmies i broadcast de decals 2026-04-30 19:29:53 +02:00
JailDesigner 7d0aca506a feat(decals): tiles de decal de sòl compten com a pintats des del load 2026-04-30 19:16:21 +02:00
JailDesigner 7cd7b4dc4f chore(hole): neteja de comentaris obsolets i notes sobre riscos latents 2026-04-30 18:52:49 +02:00
JailDesigner 6d4f606eeb feat(mummy): evita forats per detecció reactiva al canvi de tile 2026-04-30 18:30:03 +02:00
JailDesigner 1ccd444aad fix(hazard): el jugador només cau al Hole si la hit-box hi cap del tot 2026-04-30 16:56:56 +02:00
JailDesigner 3bfca35f3a feat(mummy): evita tiles amb decal trigger (Hole) en decidir direcció 2026-04-30 16:50:26 +02:00
JailDesigner 3e5217066f fix(hazard): aterrar damunt d'un Hole salta el LANDING_STUN 2026-04-30 13:53:26 +02:00
JailDesigner 4da50571eb feat(hazard): nou hazard Hole — caiguda per forat amb clip rect 2026-04-30 13:32:17 +02:00
JailDesigner d4593d0686 Merge feat/tilepicker-layout — tilepicker amb editor.yaml 2026-04-30 09:52:51 +02:00
JailDesigner a53cf4b797 feat(editor): spacing, border i centrat vertical del tilepicker via editor.yaml 2026-04-30 09:51:30 +02:00
JailDesigner bcbf990a5f feat(editor): tilepicker respecta l'amplada del tileset si hi cap 2026-04-30 09:36:24 +02:00
JailDesigner 45514ae2f4 feat(menu): sons de navegació i acceptació al menú de servei 2026-04-29 20:27:01 +02:00
JailDesigner 0847e9c544 feat(menu): congela el món mentre el menú de servei està obert 2026-04-29 20:19:20 +02:00
JailDesigner 27cf2f032d chore(audio): normalitzat el volum dels efectes 2026-04-29 20:00:07 +02:00
65 changed files with 2964 additions and 306 deletions

No files matched your search

+11 -1
View File
@@ -50,6 +50,7 @@ set(ENTITY_SOURCES_BASE
source/game/entities/concrete/rolling_rock.cpp source/game/entities/concrete/rolling_rock.cpp
source/game/entities/concrete/wall_trap.cpp source/game/entities/concrete/wall_trap.cpp
source/game/entities/concrete/floor_trap.cpp source/game/entities/concrete/floor_trap.cpp
source/game/entities/concrete/hole.cpp
source/game/entities/concrete/dust.cpp source/game/entities/concrete/dust.cpp
) )
@@ -115,12 +116,19 @@ set(APP_SOURCES
source/game/map/map.cpp source/game/map/map.cpp
source/game/map/tilemap.cpp source/game/map/tilemap.cpp
source/game/map/tilemap_writer.cpp source/game/map/tilemap_writer.cpp
# Writer surgical de claus dins de `geometry:` (door_col), paral·lel
# al TilemapWriter. Només Debug.
source/game/map/map_writer.cpp
source/game/map/paint_layer.cpp source/game/map/paint_layer.cpp
source/game/levels/level_manager.cpp source/game/levels/level_manager.cpp
# Mòdul editor (només activa contingut en Debug via `#ifdef _DEBUG`). # Mòdul editor (només activa contingut en Debug via `#ifdef _DEBUG`).
# En Release el fitxer compila a un object buit i l'enllaçador no # En Release els fitxers compilen a object buit i l'enllaçador no
# arrossega cap símbol del joc final. # arrossega cap símbol del joc final.
source/game/editor/editor.cpp source/game/editor/editor.cpp
source/game/editor/editor_config.cpp
# Writer del bloc `entities:` del YAML del nivell, paral·lel al
# TilemapWriter pero per a la persistència de l'editor d'entitats.
source/game/entities/entity_writer.cpp
${ENTITY_SOURCES} ${ENTITY_SOURCES}
# Fitxer només del binari principal: `loadFromLevel` depèn de # Fitxer només del binari principal: `loadFromLevel` depèn de
# Resource::Cache (→ Surface → GIF → àudio). Separat per no arrossegar # Resource::Cache (→ Surface → GIF → àudio). Separat per no arrossegar
@@ -348,6 +356,7 @@ option(BUILD_TESTS "Build smoke tests" ON)
if(BUILD_TESTS) if(BUILD_TESTS)
add_executable(test_entities_smoke add_executable(test_entities_smoke
tests/test_entities_smoke.cpp tests/test_entities_smoke.cpp
tests/hole_player_stubs.cpp
source/core/resources/resource_helper.cpp source/core/resources/resource_helper.cpp
source/core/resources/resource_pack.cpp source/core/resources/resource_pack.cpp
source/game/map/map.cpp source/game/map/map.cpp
@@ -371,6 +380,7 @@ if(BUILD_TESTS)
add_executable(test_whip_attack add_executable(test_whip_attack
tests/test_whip_attack.cpp tests/test_whip_attack.cpp
tests/whip_attack_player_stubs.cpp tests/whip_attack_player_stubs.cpp
tests/hole_player_stubs.cpp
source/core/resources/resource_helper.cpp source/core/resources/resource_helper.cpp
source/core/resources/resource_pack.cpp source/core/resources/resource_pack.cpp
source/game/map/map.cpp source/game/map/map.cpp
+13
View File
@@ -0,0 +1,13 @@
# Tema visual de l'editor de tilemap (només Debug). Carregat per
# EditorConfig a l'arrencada; les claus absents cauen als valors compilats
# a Defaults::Editor (veure source/utils/defaults.hpp).
picker:
margin: 8 # px entre canvas i caixa del picker
spacing: 1 # px de separació entre tiles dins del picker
# Colors ARGB en hex. Accepta enter (0xFF2244FF) o cadena ("#FF2244FF").
colors:
border: "0xFF2244FF" # vora 1 px que envolta canvas i picker
picker_bg: "0xFF101824" # fons del picker
picker_hover: "0xFFFFFFFF" # vora 1 px del tile sota el cursor
+1
View File
@@ -1,5 +1,6 @@
name: door name: door
type: interactable type: interactable
editable: true
sprite: sprite:
path: door.gif path: door.gif
size: [48, 32] size: [48, 32]
Binary file not shown.

After

Width:  |  Height:  |  Size: 182 B

+27
View File
@@ -0,0 +1,27 @@
name: hole
type: static_hazard
editable: true
sprite:
path: hole.gif
size: [16, 16]
tile_size: [1, 1]
collision:
# Forat: ocupa el tile sencer (16×16). `height: 0` deixa que un salt
# AIRBORNE el creui sense disparar el contacte (el filtre d'altura de
# `checkPlayerCollisions` el descarta quan `jump_z_ > height`). El
# bucle de contacte admet l'entitat per `flags.triggers_on_overlap`,
# encara que `flags.collides` sigui false.
size: [16, 16]
height: 0
flags:
collides: false
interactable: false
# Avui el loader no llig el bloc `flags:` (és declaratiu); el flag
# real es fixa al constructor C++ de `Hole`. Mantenim el camp per
# coherència amb la resta de plantilles i per quan es cablege la
# lectura genèrica de flags.
triggers_on_overlap: true
animations:
idle: { frames: [0], fps: 1, loop: true }
stats:
damage: 0
+2 -1
View File
@@ -1,5 +1,6 @@
name: mummy name: mummy
type: dynamic_hazard type: dynamic_hazard
editable: true
sprite: sprite:
path: mummy.gif path: mummy.gif
size: [16, 32] size: [16, 32]
@@ -18,7 +19,7 @@ collision:
# jugador amb jump_z > height no col·lisiona amb la momia (la salta # jugador amb jump_z > height no col·lisiona amb la momia (la salta
# per damunt). Amb impulse=240 + gravity=600 el pic ronda els 48 px, # per damunt). Amb impulse=240 + gravity=600 el pic ronda els 48 px,
# així que 24 deixa marge per esquivar amb un salt no perfecte. # així que 24 deixa marge per esquivar amb un salt no perfecte.
height: 24 height: 12
flags: flags:
collides: true collides: true
interactable: false interactable: false
@@ -1,5 +1,6 @@
name: sarcophagus name: sarcophagus
type: interactable type: interactable
editable: true
sprite: sprite:
path: sarcophagus.gif path: sarcophagus.gif
size: [48, 32] size: [48, 32]
+1
View File
@@ -1,5 +1,6 @@
name: torch name: torch
type: decoration type: decoration
editable: true
sprite: sprite:
path: torch.gif path: torch.gif
size: [16, 16] size: [16, 16]
+10 -1
View File
@@ -44,9 +44,18 @@ keys:
- id: editor_toggle - id: editor_toggle
code: "9" code: "9"
desc: "Entra/surt del mode editor (només Debug)" desc: "Entra/surt del mode editor (només Debug)"
- id: editor_picker - id: editor_tile_picker
code: "T" code: "T"
desc: "Obri/tanca el tilepicker dins de l'editor" desc: "Obri/tanca el tilepicker dins de l'editor"
- id: editor_entity_picker
code: "E"
desc: "Obri/tanca l'entity picker dins de l'editor"
- id: editor_hand
code: "H"
desc: "Activa el mode mà de l'editor (selecciona/mou/borra entitats)"
- id: editor_delete
code: "Delete"
desc: "Esborra l'entitat seleccionada en mode mà"
- id: prev_room - id: prev_room
code: "[" code: "["
desc: "Salta a la sala anterior del levels.yaml (només Debug)" desc: "Salta a la sala anterior del levels.yaml (només Debug)"
+3
View File
@@ -27,6 +27,9 @@ input:
editor_on: "Editor ON" editor_on: "Editor ON"
editor_off: "Editor OFF" editor_off: "Editor OFF"
editor_save_error: "Editor: error guardant el mapa" editor_save_error: "Editor: error guardant el mapa"
editor_keep_door: "Editor: cal almenys una porta"
editor_keep_sarc: "Editor: cal almenys un sarcòfag"
editor_invalid_level: "Editor: nivell incomplet (porta i sarcs)"
hud: hud:
p1: "P1" p1: "P1"
+3
View File
@@ -27,6 +27,9 @@ input:
editor_on: "Editor ON" editor_on: "Editor ON"
editor_off: "Editor OFF" editor_off: "Editor OFF"
editor_save_error: "Editor: map save failed" editor_save_error: "Editor: map save failed"
editor_keep_door: "Editor: at least one door required"
editor_keep_sarc: "Editor: at least one sarcophagus required"
editor_invalid_level: "Editor: level incomplete (door and sarcs)"
hud: hud:
p1: "P1" p1: "P1"
+11 -10
View File
@@ -49,7 +49,7 @@ geometry:
# 16 px centrals (just aquesta columna) són el "forat" del mur trasser per # 16 px centrals (just aquesta columna) són el "forat" del mur trasser per
# on el jugador surt de la sala. Les 2 columnes flanquejants segueixen sent # on el jugador surt de la sala. Les 2 columnes flanquejants segueixen sent
# mur visible. El spawn dels jugadors també s'alinea amb aquesta columna. # mur visible. El spawn dels jugadors també s'alinea amb aquesta columna.
door_col: 10 door_col: 11
data: data:
- [11,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,13] - [11,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,12,13]
@@ -82,8 +82,6 @@ data:
# d'obertura (262/263). La momia al corredor central. L'antorxa al costat # d'obertura (262/263). La momia al corredor central. L'antorxa al costat
# superior esquerre per verificar render d'una entitat decorativa. # superior esquerre per verificar render d'una entitat decorativa.
entities: entities:
- type: torch
position: [4, 2]
- type: torch - type: torch
position: [8, 2] position: [8, 2]
- type: torch - type: torch
@@ -130,11 +128,14 @@ entities:
sarc_position: [2, 4] sarc_position: [2, 4]
- type: sarcophagus - type: sarcophagus
sarc_position: [3, 4] sarc_position: [3, 4]
- type: hole
position: [4, 7]
- type: hole
position: [8, 11]
- type: hole
position: [15, 11]
- type: hole
position: [15, 5]
- type: hole
position: [8, 5]
- type: door - type: door
# Sense `position`: la porta sempre viu al mur trasser. La columna
# transitable la dicta `geometry.door_col`; el render i la caixa
# de col·lisió es deriven d'aquesta dada al loadFromLevel.
- type: mummy
position: [10, 7]
- type: mummy
position: [7, 9]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

+7
View File
@@ -73,6 +73,13 @@ animations:
frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280] frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280]
loop: false loop: false
# Mort per caiguda a un agujero (DeathCause::HOLE). `loop: false` perquè
# `animationIsCompleted()` dispari el respawn timer determinísticament.
death_hole:
frames: [15]
frame_ms: 60
loop: false
# Inèrcia de frenada en soltar la direcció. `threshold_ms` filtra # Inèrcia de frenada en soltar la direcció. `threshold_ms` filtra
# tocs curts (passets d'un tile sense rellisquera). # tocs curts (passets d'un tile sense rellisquera).
inertia: inertia:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

+7
View File
@@ -73,6 +73,13 @@ animations:
frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280] frame_ms: [60, 80, 100, 120, 140, 160, 180, 200, 220, 240, 260, 280]
loop: false loop: false
# Mort per caiguda a un agujero (DeathCause::HOLE). `loop: false` perquè
# `animationIsCompleted()` dispari el respawn timer determinísticament.
death_hole:
frames: [15]
frame_ms: 60
loop: false
# Inèrcia de frenada en soltar la direcció. `threshold_ms` filtra # Inèrcia de frenada en soltar la direcció. `threshold_ms` filtra
# tocs curts (passets d'un tile sense rellisquera). # tocs curts (passets d'un tile sense rellisquera).
inertia: inertia:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+23
View File
@@ -65,6 +65,22 @@ hud:
flash_ms: 250 # durada del flash quan un jugador rep un hit flash_ms: 250 # durada del flash quan un jugador rep un hit
shake_ms: 300 # durada del shake que acompanya el flash shake_ms: 300 # durada del shake que acompanya el flash
# 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
# propòsit: padding_top > padding_bottom puja una mica el text dins la caixa.
# Corbes disponibles: linear, outQuad, inQuad, inOutQuad, outCubic, inCubic.
notifier:
padding_h: 6
padding_top: 4
padding_bottom: 3
line_gap: 1
margin_top: 3
border_px: 1
slide_ms: 300
slide_in_curve: outCubic
slide_out_curve: inCubic
# Pausa graduada de la GameScene: el món no para d'un colp, s'atura amb # Pausa graduada de la GameScene: el món no para d'un colp, s'atura amb
# easing i torna a arrancar amb una altra corba. Els timings es poden # easing i torna a arrancar amb una altra corba. Els timings es poden
# afinar independentment, i les corbes permeten estils diferents entrant # afinar independentment, i les corbes permeten estils diferents entrant
@@ -75,3 +91,10 @@ pause:
exit_ms: 250 exit_ms: 250
enter_curve: outCubic enter_curve: outCubic
exit_curve: inCubic exit_curve: inCubic
# Sons del menú de servei. Els fitxers viuen a data/sounds/. SELECT
# acompanya navegació (amunt/avall) i back; ACCEPT acompanya modificació
# d'opció (esquerra/dreta/retorn) o entrada a un submenú.
sounds:
menu_select: "menu_select.wav"
menu_accept: "menu_accept.wav"
+16
View File
@@ -89,6 +89,9 @@ namespace GlobalInputs {
} }
void onNextShader() { void onNextShader() {
// F5/F6 només tenen sentit quan els shaders estan actius. Si no,
// ignorem la pulsació en silenci — l'usuari ha de prémer F4 abans.
if (!Options::video().shader.enabled) { return; }
const std::string NEW_NAME = (Options::video().shader.current_shader == "crtpi") ? "postfx" : "crtpi"; const std::string NEW_NAME = (Options::video().shader.current_shader == "crtpi") ? "postfx" : "crtpi";
Options::setActiveShaderName(NEW_NAME); Options::setActiveShaderName(NEW_NAME);
OptionsApply::reapplyShader(); OptionsApply::reapplyShader();
@@ -98,6 +101,7 @@ namespace GlobalInputs {
// Busca l'entrada actual al vector de presets i passa a la següent // Busca l'entrada actual al vector de presets i passa a la següent
// (wrap-around). Si no en troba cap s'evita el canvi silenciosament. // (wrap-around). Si no en troba cap s'evita el canvi silenciosament.
void onNextPreset() { void onNextPreset() {
if (!Options::video().shader.enabled) { return; }
const bool IS_CRTPI = Options::video().shader.current_shader == "crtpi"; const bool IS_CRTPI = Options::video().shader.current_shader == "crtpi";
std::string new_preset; std::string new_preset;
if (IS_CRTPI) { if (IS_CRTPI) {
@@ -154,6 +158,14 @@ namespace GlobalInputs {
Options::setVideoRenderInfoPosition(static_cast<Defaults::RenderInfoPosition>(NEXT)); Options::setVideoRenderInfoPosition(static_cast<Defaults::RenderInfoPosition>(NEXT));
} }
// Debug helper: dispara una notificació prou llarga perquè
// `wrapToWidth` la parteixi en (almenys) dues línies. Útil per a
// verificar visualment el padding asimètric, el line_gap i la caixa
// multilinia després de tocar la geometria a `data/ui.yaml`.
void onDebugLongNotification() {
Notification::notify("Notificació de prova llarga per a comprovar que el text es parteix correctament en dues línies dins la caixa.");
}
void onExit() { void onExit() {
if (exit_window_s > 0.0F) { if (exit_window_s > 0.0F) {
quit_requested = true; quit_requested = true;
@@ -193,6 +205,10 @@ namespace GlobalInputs {
onCycleRenderInfo(); onCycleRenderInfo();
} else if (SC == KeyConfig::get().scancode("exit")) { } else if (SC == KeyConfig::get().scancode("exit")) {
onExit(); onExit();
} else if (SC == SDL_SCANCODE_KP_2) {
// Tecla de debug fora del KeyConfig: dispara una notificació
// multilinia per a inspeccionar el layout de la caixa.
onDebugLongNotification();
} }
} }
} }
+50 -18
View File
@@ -6,6 +6,7 @@
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
#include "core/rendering/text.hpp" #include "core/rendering/text.hpp"
#include "core/ui/ui_config.hpp"
#include "utils/defines.hpp" #include "utils/defines.hpp"
namespace { namespace {
@@ -67,26 +68,39 @@ void Notifier::init(std::shared_ptr<Text> text, const Palette& ui_palette) {
} }
void Notifier::update(int delta_ms) { void Notifier::update(int delta_ms) {
const float DT = static_cast<float>(delta_ms) / 1000.0F; const auto& geo = UiConfig::get().notifier();
switch (status_) { switch (status_) {
case Status::RISING: case Status::RISING: {
y_ += SLIDE_SPEED * DT; slide_elapsed_ms_ += delta_ms;
if (y_ >= static_cast<float>(MARGIN_TOP)) { if (slide_elapsed_ms_ >= geo.slide_ms) {
y_ = static_cast<float>(MARGIN_TOP); y_ = slide_y_on_;
status_ = Status::ACTIVE; status_ = Status::ACTIVE;
} else {
const float T = static_cast<float>(slide_elapsed_ms_) / static_cast<float>(geo.slide_ms);
const float K = geo.slide_in_curve(T);
y_ = slide_y_off_ + ((slide_y_on_ - slide_y_off_) * K);
} }
break; break;
}
case Status::ACTIVE: case Status::ACTIVE:
remaining_ms_ -= delta_ms; remaining_ms_ -= delta_ms;
if (remaining_ms_ <= 0) { status_ = Status::VANISHING; } if (remaining_ms_ <= 0) {
break; slide_elapsed_ms_ = 0;
case Status::VANISHING: status_ = Status::VANISHING;
y_ -= SLIDE_SPEED * DT;
if (y_ <= static_cast<float>(-current_box_h_)) {
y_ = static_cast<float>(-current_box_h_ - MARGIN_TOP);
status_ = Status::HIDDEN;
} }
break; break;
case Status::VANISHING: {
slide_elapsed_ms_ += delta_ms;
if (slide_elapsed_ms_ >= geo.slide_ms) {
y_ = slide_y_off_;
status_ = Status::HIDDEN;
} else {
const float T = static_cast<float>(slide_elapsed_ms_) / static_cast<float>(geo.slide_ms);
const float K = geo.slide_out_curve(T);
y_ = slide_y_on_ + ((slide_y_off_ - slide_y_on_) * K);
}
break;
}
default: default:
break; break;
} }
@@ -95,6 +109,7 @@ void Notifier::update(int delta_ms) {
void Notifier::render() { void Notifier::render() {
if (status_ == Status::HIDDEN || text_ == nullptr || current_lines_.empty()) { return; } if (status_ == Status::HIDDEN || text_ == nullptr || current_lines_.empty()) { return; }
const auto& geo = UiConfig::get().notifier();
const int BOX_X = (GameCanvas::WIDTH - current_box_w_) / 2; const int BOX_X = (GameCanvas::WIDTH - current_box_w_) / 2;
const int BOX_Y = static_cast<int>(y_); const int BOX_Y = static_cast<int>(y_);
@@ -102,8 +117,17 @@ void Notifier::render() {
// (ARGB directe al canvas, sense tocar cap surface paletada). // (ARGB directe al canvas, sense tocar cap surface paletada).
Screen::get().fillRect(BOX_X, BOX_Y, current_box_w_, current_box_h_, ui_palette_[current_bg_color_]); Screen::get().fillRect(BOX_X, BOX_Y, current_box_w_, current_box_h_, ui_palette_[current_bg_color_]);
const int LINE_STEP = Rendering::Hud::FONT_SIZE_PX + LINE_GAP; // Vora externa: 4 línies d'1 px del color del text, paral·leles a cada
int line_y = BOX_Y + PADDING_V; // costat i una mica fora de la caixa. Cada segment es queda dins del
// rang del costat corresponent perquè les cantonades no es cobrisquen.
const std::uint32_t BORDER_ARGB = ui_palette_[current_text_color_];
Screen::get().fillRect(BOX_X, BOX_Y - geo.border_px, current_box_w_, geo.border_px, BORDER_ARGB);
Screen::get().fillRect(BOX_X, BOX_Y + current_box_h_, current_box_w_, geo.border_px, BORDER_ARGB);
Screen::get().fillRect(BOX_X - geo.border_px, BOX_Y, geo.border_px, current_box_h_, BORDER_ARGB);
Screen::get().fillRect(BOX_X + current_box_w_, BOX_Y, geo.border_px, current_box_h_, BORDER_ARGB);
const int LINE_STEP = Rendering::Hud::FONT_SIZE_PX + geo.line_gap;
int line_y = BOX_Y + geo.padding_top;
for (const auto& line : current_lines_) { for (const auto& line : current_lines_) {
text_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG, text_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
GameCanvas::CENTER_X, GameCanvas::CENTER_X,
@@ -120,18 +144,26 @@ void Notifier::notify(const std::string& text, Uint8 text_color, Uint8 bg_color,
// gestionar-ne una cua, canviar ací per una FIFO quan calga. // gestionar-ne una cua, canviar ací per una FIFO quan calga.
if (text_ == nullptr) { return; } if (text_ == nullptr) { return; }
const auto& geo = UiConfig::get().notifier();
const int MAX_TEXT_WIDTH = GameCanvas::WIDTH - (2 * SIDE_MARGIN) - (2 * geo.padding_h);
current_lines_ = wrapToWidth(text, MAX_TEXT_WIDTH, *text_, 1); current_lines_ = wrapToWidth(text, MAX_TEXT_WIDTH, *text_, 1);
// Amplada = max de les línies + padding. Alçada = N*glif + (N-1)*gap + padding. // Amplada = max de les línies + padding. Alçada = N*glif + (N-1)*gap + padding asimètric.
const int MAX_LINE_W = std::accumulate(current_lines_.begin(), current_lines_.end(), 0, [this](int acc, const std::string& s) { return std::max(acc, text_->length(s, 1)); }); const int MAX_LINE_W = std::accumulate(current_lines_.begin(), current_lines_.end(), 0, [this](int acc, const std::string& s) { return std::max(acc, text_->length(s, 1)); });
current_box_w_ = MAX_LINE_W + (2 * PADDING_H); current_box_w_ = MAX_LINE_W + (2 * geo.padding_h);
const int N = static_cast<int>(current_lines_.size()); const int N = static_cast<int>(current_lines_.size());
current_box_h_ = (N * Rendering::Hud::FONT_SIZE_PX) + ((N - 1) * LINE_GAP) + (2 * PADDING_V); current_box_h_ = (N * Rendering::Hud::FONT_SIZE_PX) + ((N - 1) * geo.line_gap) + geo.padding_top + geo.padding_bottom;
current_text_color_ = text_color; current_text_color_ = text_color;
current_bg_color_ = bg_color; current_bg_color_ = bg_color;
remaining_ms_ = time_ms; remaining_ms_ = time_ms;
y_ = static_cast<float>(-current_box_h_ - MARGIN_TOP);
// Posicions extremes en y (top-left de la caixa). Off = totalment fora
// del canvas per dalt; On = posició final amb la vora respectada.
slide_y_off_ = static_cast<float>(-current_box_h_ - geo.border_px - geo.margin_top);
slide_y_on_ = static_cast<float>(geo.margin_top + geo.border_px);
slide_elapsed_ms_ = 0;
y_ = slide_y_off_;
status_ = Status::RISING; status_ = Status::RISING;
} }
+12 -9
View File
@@ -43,15 +43,11 @@ class Notifier {
void notify(const std::string& text, Uint8 text_color, Uint8 bg_color, int time_ms); void notify(const std::string& text, Uint8 text_color, Uint8 bg_color, int time_ms);
static constexpr int PADDING_H = 6; // Marge lateral entre la caixa i les vores esquerra/dreta del canvas.
static constexpr int PADDING_V = 3; // Limita l'amplada disponible al text en multilinia (combinat amb el
static constexpr int LINE_GAP = 1; // Separació vertical entre línies multilinia. // padding horitzontal llegit d'UiConfig). No està exposat al YAML
static constexpr int MARGIN_TOP = 2; // Separació amb la vora superior del canvas. // perquè en pantalles 240 px no té sentit retallar més.
static constexpr float SLIDE_SPEED = 120.0F; static constexpr int SIDE_MARGIN = 10;
// Amplada màxima (sense comptar padding) que pot ocupar el text abans que
// `wrapToWidth` el parteixi en línies. Deixem marge respecte a la vora del
// canvas perquè la caixa no toqui els laterals.
static constexpr int MAX_TEXT_WIDTH = GameCanvas::WIDTH - 20 - (2 * PADDING_H);
private: private:
enum class Status : std::uint8_t { HIDDEN, enum class Status : std::uint8_t { HIDDEN,
@@ -72,4 +68,11 @@ class Notifier {
Uint8 current_text_color_{0}; Uint8 current_text_color_{0};
Uint8 current_bg_color_{0}; Uint8 current_bg_color_{0};
int remaining_ms_{0}; int remaining_ms_{0};
// Animació d'entrada/eixida per temps. `slide_elapsed_ms_` avança
// amb el delta dins de RISING/VANISHING; els extrems s'avaluen a
// `notify()` perquè depenen de la mida de la caixa.
int slide_elapsed_ms_{0};
float slide_y_off_{0.0F};
float slide_y_on_{0.0F};
}; };
+45 -6
View File
@@ -15,12 +15,18 @@ namespace Resource::Helper {
Pack g_pack; Pack g_pack;
bool g_pack_loaded = false; bool g_pack_loaded = false;
bool g_fallback_enabled = true; bool g_fallback_enabled = true;
// Inverteix la prioritat (disc abans que pack). Activat en Debug
// per defecte; veure setPreferDisk al header.
bool g_prefer_disk = false;
auto readFromDisk(const std::string& filepath) -> std::vector<std::uint8_t> { auto readFromDisk(const std::string& filepath, bool verbose = true) -> std::vector<std::uint8_t> {
const auto FULL = resolve(filepath); const auto FULL = resolve(filepath);
std::ifstream file(FULL, std::ios::binary | std::ios::ate); std::ifstream file(FULL, std::ios::binary | std::ios::ate);
if (!file) { if (!file) {
std::cerr << "Resource::Helper: cannot open " << FULL << '\n'; // Silenciem si veníem de prefer_disk: aleshores caurem
// al pack i no és cap error real que el fitxer no estigui
// al disc.
if (verbose) { std::cerr << "Resource::Helper: cannot open " << FULL << '\n'; }
return {}; return {};
} }
std::streamsize size = file.tellg(); std::streamsize size = file.tellg();
@@ -37,6 +43,11 @@ namespace Resource::Helper {
auto initializeResourceSystem(const std::string& pack_file, bool enable_fallback) -> bool { auto initializeResourceSystem(const std::string& pack_file, bool enable_fallback) -> bool {
g_fallback_enabled = enable_fallback; g_fallback_enabled = enable_fallback;
// En el flux Debug típic (fallback actiu, source tree disponible)
// preferim sempre disc: el desenvolupador edita YAML/GIF a `data/`
// i ho veu reflectit al següent reload sense re-packejar. En
// Release el fallback està desactivat i aquest flag és inert.
g_prefer_disk = enable_fallback;
g_pack_loaded = g_pack.loadPack(pack_file); g_pack_loaded = g_pack.loadPack(pack_file);
if (g_pack_loaded) { if (g_pack_loaded) {
@@ -76,17 +87,45 @@ namespace Resource::Helper {
} }
auto loadFile(const std::string& filepath) -> std::vector<std::uint8_t> { auto loadFile(const std::string& filepath) -> std::vector<std::uint8_t> {
if (g_pack_loaded) { // Helper local: lookup al pack normalitzant la clau (els callers
// El pack està indexat relatiu a `data/` (la carpeta que // passen "data/foo.yaml" però l'índex del pack és relatiu a la
// s'empaqueta), però els callers passen "data/foo.yaml" amb // carpeta `data/`).
// prefix. Fem el strip ací perquè no hagin de tenir-ho en compte. const auto FROM_PACK = [&filepath]() -> std::vector<std::uint8_t> {
if (!g_pack_loaded) { return {}; }
constexpr std::string_view PREFIX = "data/"; constexpr std::string_view PREFIX = "data/";
std::string key = filepath; std::string key = filepath;
if (key.starts_with(PREFIX)) { key.erase(0, PREFIX.size()); } if (key.starts_with(PREFIX)) { key.erase(0, PREFIX.size()); }
if (g_pack.hasResource(key)) { return g_pack.getResource(key); } if (g_pack.hasResource(key)) { return g_pack.getResource(key); }
return {};
};
if (g_prefer_disk && g_fallback_enabled) {
// En Debug volem que el disc guanyi: així les edicions del
// editor in-game (i les manuals del repo) es vegen al pròxim
// reload sense rebuild del pack. Si el fitxer no és al disc,
// caiem al pack — útil per a recursos que només s'han
// empaquetat (cas estrany). El silenci a `readFromDisk` evita
// emplenar el log amb "cannot open" en aquest camí esperat.
auto bytes = readFromDisk(filepath, false);
if (!bytes.empty()) { return bytes; }
return FROM_PACK();
} }
// Política original: pack primer; disc com a fallback si el
// recurs no està empaquetat (típic d'arrencada en Debug abans
// del primer pack).
auto packed = FROM_PACK();
if (!packed.empty()) { return packed; }
if (g_fallback_enabled) { return readFromDisk(filepath); } if (g_fallback_enabled) { return readFromDisk(filepath); }
return {}; return {};
} }
void setPreferDisk(bool prefer) {
g_prefer_disk = prefer;
}
auto preferDisk() -> bool {
return g_prefer_disk;
}
} // namespace Resource::Helper } // namespace Resource::Helper
+11 -1
View File
@@ -32,7 +32,17 @@ namespace Resource::Helper {
// Carrega un recurs pel seu camí relatiu (p.ex. "fonts/hud.gif", // Carrega un recurs pel seu camí relatiu (p.ex. "fonts/hud.gif",
// "input/keys.yaml"). Prova primer el pack; si no hi és i el fallback // "input/keys.yaml"). Prova primer el pack; si no hi és i el fallback
// està actiu, cau a disc (data root + ruta). Retorna vector buit si // està actiu, cau a disc (data root + ruta). Retorna vector buit si
// no el troba. // no el troba. Si `preferDisk()` és true (Debug), inverteix la
// prioritat: prova disc abans del pack.
auto loadFile(const std::string& filepath) -> std::vector<std::uint8_t>; auto loadFile(const std::string& filepath) -> std::vector<std::uint8_t>;
// Inverteix la prioritat de `loadFile` (disc abans que pack). En Debug
// s'activa per defecte al `initializeResourceSystem` perquè cada
// canvi al YAML/GIF al source tree es vegi sense re-packejar — és el
// que necessita l'editor in-game per veure les seues edicions
// immediatament al recarregar la sala. En Release no té efecte
// (el fallback està desactivat).
void setPreferDisk(bool prefer);
[[nodiscard]] auto preferDisk() -> bool;
} // namespace Resource::Helper } // namespace Resource::Helper
+17
View File
@@ -29,6 +29,9 @@
#include "core/system/event_buffer.hpp" #include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp" #include "core/ui/ui_config.hpp"
#include "game/debug_config.hpp" #include "game/debug_config.hpp"
#ifdef _DEBUG
#include "game/editor/editor_config.hpp"
#endif
#include "game/levels/level_manager.hpp" #include "game/levels/level_manager.hpp"
#include "game/options.hpp" #include "game/options.hpp"
#include "game/scenes/game_scene.hpp" #include "game/scenes/game_scene.hpp"
@@ -119,6 +122,12 @@ Director::Director() {
// data/ui.yaml cau al valor compilat a Defaults::UI. // data/ui.yaml cau al valor compilat a Defaults::UI.
UiConfig::get().load("data/ui.yaml"); UiConfig::get().load("data/ui.yaml");
#ifdef _DEBUG
// Tema visual de l'editor de tilemap (Debug-only). Claus absents
// cauen als valors compilats a Defaults::Editor.
EditorConfig::get().load("data/editor/editor.yaml");
#endif
// Recursos compartits d'UI (paleta + fonts). Es carreguen abans que els // Recursos compartits d'UI (paleta + fonts). Es carreguen abans que els
// overlays perquè tots els reaprofiten sense duplicar la lògica. Els // overlays perquè tots els reaprofiten sense duplicar la lògica. Els
// noms de font viuen al fitxer data/ui.yaml via UiConfig. // noms de font viuen al fitxer data/ui.yaml via UiConfig.
@@ -145,6 +154,14 @@ Director::Director() {
Resource::Cache::get().loadMusic("playing.ogg", "data/music/playing.ogg"); Resource::Cache::get().loadMusic("playing.ogg", "data/music/playing.ogg");
Resource::Cache::get().loadMusic("room_outro.ogg", "data/music/room_outro.ogg"); Resource::Cache::get().loadMusic("room_outro.ogg", "data/music/room_outro.ogg");
// Sons del menú de servei. Els noms venen de `data/ui.yaml` (amb
// fallback a `Defaults::UI::MENU_*_SOUND`); el ServiceMenu els
// dispara via Audio::Group::INTERFACE.
const auto& menu_select = UiConfig::get().menuSelectSound();
const auto& menu_accept = UiConfig::get().menuAcceptSound();
Resource::Cache::get().loadSound(menu_select, "data/sounds/" + menu_select);
Resource::Cache::get().loadSound(menu_accept, "data/sounds/" + menu_accept);
// Catàleg de presets d'efectes per als sons. Es carrega abans d'Audio // Catàleg de presets d'efectes per als sons. Es carrega abans d'Audio
// perquè els primers playSoundWith* (al començament del joc) ja // perquè els primers playSoundWith* (al començament del joc) ja
// troben els presets disponibles. // troben els presets disponibles.
+33 -1
View File
@@ -96,11 +96,24 @@ UiConfig::UiConfig()
pause_exit_ms_(Defaults::UI::PAUSE_EXIT_MS), pause_exit_ms_(Defaults::UI::PAUSE_EXIT_MS),
pause_enter_curve_(curveByName(Defaults::UI::PAUSE_ENTER_CURVE)), pause_enter_curve_(curveByName(Defaults::UI::PAUSE_ENTER_CURVE)),
pause_exit_curve_(curveByName(Defaults::UI::PAUSE_EXIT_CURVE)), pause_exit_curve_(curveByName(Defaults::UI::PAUSE_EXIT_CURVE)),
menu_select_sound_(Defaults::UI::MENU_SELECT_SOUND),
menu_accept_sound_(Defaults::UI::MENU_ACCEPT_SOUND),
hud_font_{.name = Defaults::UI::Font::HUD_NAME, .gif = Defaults::UI::Font::HUD_GIF, .fnt = Defaults::UI::Font::HUD_FNT, .kerning = Defaults::UI::Font::HUD_KERNING}, hud_font_{.name = Defaults::UI::Font::HUD_NAME, .gif = Defaults::UI::Font::HUD_GIF, .fnt = Defaults::UI::Font::HUD_FNT, .kerning = Defaults::UI::Font::HUD_KERNING},
placeholder_font_{.name = Defaults::UI::Font::PLACEHOLDER_NAME, .gif = Defaults::UI::Font::PLACEHOLDER_GIF, .fnt = Defaults::UI::Font::PLACEHOLDER_FNT, .kerning = Defaults::UI::Font::PLACEHOLDER_KERNING}, placeholder_font_{.name = Defaults::UI::Font::PLACEHOLDER_NAME, .gif = Defaults::UI::Font::PLACEHOLDER_GIF, .fnt = Defaults::UI::Font::PLACEHOLDER_FNT, .kerning = Defaults::UI::Font::PLACEHOLDER_KERNING},
room_text_font_{.name = Defaults::UI::Font::ROOM_TEXT_NAME, .gif = Defaults::UI::Font::ROOM_TEXT_GIF, .fnt = Defaults::UI::Font::ROOM_TEXT_FNT, .kerning = Defaults::UI::Font::ROOM_TEXT_KERNING}, room_text_font_{.name = Defaults::UI::Font::ROOM_TEXT_NAME, .gif = Defaults::UI::Font::ROOM_TEXT_GIF, .fnt = Defaults::UI::Font::ROOM_TEXT_FNT, .kerning = Defaults::UI::Font::ROOM_TEXT_KERNING},
menu_title_font_{.name = Defaults::UI::Font::MENU_TITLE_NAME, .gif = Defaults::UI::Font::MENU_TITLE_GIF, .fnt = Defaults::UI::Font::MENU_TITLE_FNT, .kerning = Defaults::UI::Font::MENU_TITLE_KERNING}, menu_title_font_{.name = Defaults::UI::Font::MENU_TITLE_NAME, .gif = Defaults::UI::Font::MENU_TITLE_GIF, .fnt = Defaults::UI::Font::MENU_TITLE_FNT, .kerning = Defaults::UI::Font::MENU_TITLE_KERNING},
menu_items_font_{.name = Defaults::UI::Font::MENU_ITEMS_NAME, .gif = Defaults::UI::Font::MENU_ITEMS_GIF, .fnt = Defaults::UI::Font::MENU_ITEMS_FNT, .kerning = Defaults::UI::Font::MENU_ITEMS_KERNING} { menu_items_font_{.name = Defaults::UI::Font::MENU_ITEMS_NAME, .gif = Defaults::UI::Font::MENU_ITEMS_GIF, .fnt = Defaults::UI::Font::MENU_ITEMS_FNT, .kerning = Defaults::UI::Font::MENU_ITEMS_KERNING},
notifier_{
.padding_h = Defaults::UI::Notifier::PADDING_H,
.padding_top = Defaults::UI::Notifier::PADDING_TOP,
.padding_bottom = Defaults::UI::Notifier::PADDING_BOTTOM,
.line_gap = Defaults::UI::Notifier::LINE_GAP,
.margin_top = Defaults::UI::Notifier::MARGIN_TOP,
.border_px = Defaults::UI::Notifier::BORDER_PX,
.slide_ms = Defaults::UI::Notifier::SLIDE_MS,
.slide_in_curve = curveByName(Defaults::UI::Notifier::SLIDE_IN_CURVE),
.slide_out_curve = curveByName(Defaults::UI::Notifier::SLIDE_OUT_CURVE),
} {
} }
auto UiConfig::get() -> UiConfig& { auto UiConfig::get() -> UiConfig& {
@@ -185,6 +198,25 @@ void UiConfig::load(const std::string& file_path) {
readCurve(p, "exit_curve", pause_exit_curve_); readCurve(p, "exit_curve", pause_exit_curve_);
} }
if (yaml.contains("sounds")) {
const auto& s = yaml["sounds"];
readField(s, "menu_select", menu_select_sound_);
readField(s, "menu_accept", menu_accept_sound_);
}
if (yaml.contains("notifier")) {
const auto& n = yaml["notifier"];
readField(n, "padding_h", notifier_.padding_h);
readField(n, "padding_top", notifier_.padding_top);
readField(n, "padding_bottom", notifier_.padding_bottom);
readField(n, "line_gap", notifier_.line_gap);
readField(n, "margin_top", notifier_.margin_top);
readField(n, "border_px", notifier_.border_px);
readField(n, "slide_ms", notifier_.slide_ms);
readCurve(n, "slide_in_curve", notifier_.slide_in_curve);
readCurve(n, "slide_out_curve", notifier_.slide_out_curve);
}
std::cout << "UiConfig: carregat des de " << file_path << '\n'; std::cout << "UiConfig: carregat des de " << file_path << '\n';
} catch (const std::exception& e) { } catch (const std::exception& e) {
std::cerr << "UiConfig: error parsejant " << file_path << ": " << e.what() << '\n'; std::cerr << "UiConfig: error parsejant " << file_path << ": " << e.what() << '\n';
+34 -5
View File
@@ -20,6 +20,27 @@ class UiConfig {
int kerning; int kerning;
}; };
// Signatura d'una funció d'easing: t ∈ [0, 1] → valor ∈ [0, 1].
// Permet triar corbes des de data/ui.yaml per nom (cadena) i guardar
// el punter a la corresponent funció de `utils/easing.hpp`.
using CurveFn = auto (*)(float) -> float;
// Geometria del Notifier (popup superior). Carregada des de
// data/ui.yaml secció `notifier:`; els valors per defecte vénen de
// Defaults::UI::Notifier. El moviment és per temps (slide_ms) i no per
// velocitat, perquè una caixa més gran no triga més a entrar/eixir.
struct Notifier {
int padding_h;
int padding_top;
int padding_bottom;
int line_gap;
int margin_top;
int border_px;
int slide_ms;
CurveFn slide_in_curve;
CurveFn slide_out_curve;
};
static auto get() -> UiConfig&; static auto get() -> UiConfig&;
UiConfig(const UiConfig&) = delete; UiConfig(const UiConfig&) = delete;
@@ -51,11 +72,6 @@ class UiConfig {
[[nodiscard]] auto roomText() const -> Uint8 { return room_text_; } [[nodiscard]] auto roomText() const -> Uint8 { return room_text_; }
[[nodiscard]] auto roomTextOutline() const -> Uint8 { return room_text_outline_; } [[nodiscard]] auto roomTextOutline() const -> Uint8 { return room_text_outline_; }
// Signatura d'una funció d'easing: t ∈ [0, 1] → valor ∈ [0, 1].
// Permet triar corbes des de data/ui.yaml per nom (cadena) i guardar
// el punter a la corresponent funció de `utils/easing.hpp`.
using CurveFn = auto (*)(float) -> float;
// Timings (ms). // Timings (ms).
[[nodiscard]] auto notificationMs() const -> int { return notification_ms_; } [[nodiscard]] auto notificationMs() const -> int { return notification_ms_; }
[[nodiscard]] auto exitMs() const -> int { return exit_ms_; } [[nodiscard]] auto exitMs() const -> int { return exit_ms_; }
@@ -76,6 +92,11 @@ class UiConfig {
// retorna Easing::linear i deixa un warning al log. // retorna Easing::linear i deixa un warning al log.
[[nodiscard]] static auto curveByName(const std::string& name) -> CurveFn; [[nodiscard]] static auto curveByName(const std::string& name) -> CurveFn;
// Sons del menú de servei. Claus de Resource::Cache::loadSound (els
// fitxers viuen a `data/sounds/`).
[[nodiscard]] auto menuSelectSound() const -> const std::string& { return menu_select_sound_; }
[[nodiscard]] auto menuAcceptSound() const -> const std::string& { return menu_accept_sound_; }
// Fonts de UI. // Fonts de UI.
[[nodiscard]] auto hudFont() const -> const Font& { return hud_font_; } [[nodiscard]] auto hudFont() const -> const Font& { return hud_font_; }
[[nodiscard]] auto placeholderFont() const -> const Font& { return placeholder_font_; } [[nodiscard]] auto placeholderFont() const -> const Font& { return placeholder_font_; }
@@ -83,6 +104,9 @@ class UiConfig {
[[nodiscard]] auto menuTitleFont() const -> const Font& { return menu_title_font_; } [[nodiscard]] auto menuTitleFont() const -> const Font& { return menu_title_font_; }
[[nodiscard]] auto menuItemsFont() const -> const Font& { return menu_items_font_; } [[nodiscard]] auto menuItemsFont() const -> const Font& { return menu_items_font_; }
// Geometria del Notifier.
[[nodiscard]] auto notifier() const -> const Notifier& { return notifier_; }
// Flags visuals. // Flags visuals.
[[nodiscard]] auto hudBgEnabled() const -> bool { return hud_bg_enabled_; } [[nodiscard]] auto hudBgEnabled() const -> bool { return hud_bg_enabled_; }
@@ -121,11 +145,16 @@ class UiConfig {
CurveFn pause_enter_curve_; CurveFn pause_enter_curve_;
CurveFn pause_exit_curve_; CurveFn pause_exit_curve_;
std::string menu_select_sound_;
std::string menu_accept_sound_;
Font hud_font_; Font hud_font_;
Font placeholder_font_; Font placeholder_font_;
Font room_text_font_; Font room_text_font_;
Font menu_title_font_; Font menu_title_font_;
Font menu_items_font_; Font menu_items_font_;
Notifier notifier_;
bool hud_bg_enabled_{false}; bool hud_bg_enabled_{false};
}; };
File diff suppressed because it is too large. Load diff
+158 -28
View File
@@ -2,6 +2,15 @@
#ifdef _DEBUG #ifdef _DEBUG
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
class Entity;
class EntityManager;
class Surface;
class Text;
class Tilemap; class Tilemap;
// Mode editor modal del GameScene. Només disponible en Debug — en Release // Mode editor modal del GameScene. Només disponible en Debug — en Release
@@ -11,55 +20,176 @@ class Tilemap;
// L'editor NO gestiona la tecla de toggle pròpia (`editor_toggle`): això // L'editor NO gestiona la tecla de toggle pròpia (`editor_toggle`): això
// ho fa GameScene per poder decidir la coexistència amb la pausa F11 // ho fa GameScene per poder decidir la coexistència amb la pausa F11
// (mútuament excloents). L'editor s'encarrega de: // (mútuament excloents). L'editor s'encarrega de:
// - Estat propi (actiu/no actiu, brush, picker obert). // - Estat propi (mode actiu, brush per mode, picker obert).
// - Input del ratolí i tecles internes (`T`, `ESC`). // - Input del ratolí i tecles internes (`T`, `E`, `H`, `Supr`, `ESC`).
// - Render del border blau + brush preview + picker. // - Render del border blau + brush preview + picker.
// - Persistència del tilemap al YAML del repo en sortir. // - Persistència del tilemap i de les entitats al YAML del repo en sortir.
// //
// GameScene, quan `active()` és true, salta el tick de jugadors/entitats // Modes del cursor (`Mode`):
// i no els pinta; el tilemap sí es pinta perquè és el que s'està editant. // - TILE : el clic esquerre pinta el `brush_tile_` al tilemap.
// - ENTITY : el clic esquerre col·loca una instància de `brush_entity_`.
// - HAND : selecciona/mou/borra entitats existents.
//
// Pickers (`Picker`):
// - TILE_PICKER (`T`) : selecciona índex de tile.
// - ENTITY_PICKER (`E`) : selecciona tipus d'entitat (nom registrat al
// factory).
// Mútuament excloents: obrir un tanca l'altre.
class Editor { class Editor {
public: public:
// Entra al mode editor: notifica i marca `active=true`. La congelació // Entra al mode editor: notifica i marca `active=true`. La congelació
// d'entitats / jugador la gestiona GameScene consultant `active()`. // d'entitats / jugador la gestiona GameScene consultant `active()`.
void enter(); void enter();
// Surt del mode editor: persisteix el tilemap al YAML (writer surgical) // Surt del mode editor: persisteix el tilemap i les entitats al YAML
// i notifica. Si la persistència falla, emet la notificació d'error. // (writers surgicals) i notifica. Si la persistència falla, emet la
void exit(Tilemap& tilemap); // notificació d'error.
void exit(Tilemap& tilemap, const EntityManager& entities);
// Helper: enter() si no actiu, exit() si actiu. Útil des de GameScene // Helper: enter() si no actiu, exit() si actiu. Útil des de GameScene
// quan detecta la tecla de toggle. // quan detecta la tecla de toggle.
void toggle(Tilemap& tilemap); void toggle(Tilemap& tilemap, const EntityManager& entities);
[[nodiscard]] auto active() const -> bool { return active_; } [[nodiscard]] auto active() const -> bool { return active_; }
// Processa input propi (brush, picker). No-op si no actiu. // Cert quan, en mode editor, GameScene ha de pintar les entitats
void update(int delta_ms, Tilemap& tilemap); // de la sala. Falses en mode TILE (volem veure el tilemap pelat
// per pintar-lo) i quan hi ha un picker obert (la caixa del picker
// ja tapa la pantalla i el soroll visual addicional sobra).
[[nodiscard]] auto shouldShowEntities() const -> bool {
return active_ && mode_ != Mode::TILE && picker_ == Picker::NONE;
}
// Pinta el border blau i, més endavant, brush preview + picker. // Processa input propi (brush, picker). No-op si no actiu.
// No-op si no actiu. // `entities` cal perquè els modes ENTITY i HAND modifiquen el manager
void render(const Tilemap& tilemap) const; // en directe (col·loca/mou/borra entitats); en TILE no s'usa.
void update(int delta_ms, Tilemap& tilemap, EntityManager& entities);
// Pinta el border blau i, segons mode i picker, brush preview, marc
// d'entitat seleccionada o el picker. No-op si no actiu.
// `entities` és necessari per al marc d'entitat hovered/seleccionada
// en Mode::HAND; en altres modes no s'usa.
void render(const Tilemap& tilemap, const EntityManager& entities) const;
private: private:
// Les branques d'update / render segons el mode (picker obert o // Mode del cursor: determina què fa el clic esquerre sobre el mapa.
// pintura al mapa) queden separades per claredat; no formen part enum class Mode : std::uint8_t { TILE,
// de l'API pública perquè només té sentit cridar-les des d'`update`. ENTITY,
void updateMap(Tilemap& tilemap, int canvas_x, int canvas_y); HAND };
void updatePicker(const Tilemap& tilemap, int canvas_x, int canvas_y);
void renderPicker(const Tilemap& tilemap) const; // Quin picker està obert (si n'hi ha). Mútuament excloents.
enum class Picker : std::uint8_t { NONE,
TILE,
ENTITY };
// Consum de hotkeys pròpies (T, E, H, ESC). Modifica `mode_` / `picker_`.
// Es crida al principi d'`update` perquè altres handlers no vegin
// tecles ja interpretades pel propi editor.
void handleHotkeys();
// Aplica una tecla i retorna true si l'editor l'ha consumit. Aïllat
// per mantenir baixa la complexitat cognitiva de `handleHotkeys` —
// l'iteració del buffer queda neta i la lògica de transició viu en
// un sol lloc.
auto applyHotkey(int scancode) -> bool;
// Branques d'update segons (mode, picker). Només té sentit cridar-les
// des d'`update`.
void updateTileBrush(Tilemap& tilemap, int canvas_x, int canvas_y);
void updateEntityBrush(EntityManager& entities, int canvas_x, int canvas_y);
void updateHand(const Tilemap& tilemap, EntityManager& entities, int canvas_x, int canvas_y);
void updateTilePicker(const Tilemap& tilemap, int canvas_x, int canvas_y);
void updateEntityPicker(int canvas_x, int canvas_y);
// Intent de col·locació segons brush_entity_ a la cel·la del cursor.
// Construeix un node spawn sintètic, fa el snap concret per tipus
// (sarc → rejilla 4×5; door → fila del mur i, de moment, columna
// canònica) i comprova solapaments. No-op si la cel·la està ocupada
// o el snap no és vàlid. Retorna true si s'ha col·locat.
auto tryPlaceEntity(EntityManager& entities) -> bool;
// Mou l'entitat seleccionada a la cel·la del cursor: borra l'antiga
// i crida `addSpawn` amb la nova posició. Reusa la mateixa lògica
// de snap/solapament que la col·locació nova, excloent l'entitat
// que es mou del check de solapament. Cas especial per a la porta:
// el sprite ocupa 3 cols (col-1, col, col+1) sobre el mur superior;
// per tant la columna central vàlida és [1, cols-2] — `tilemap` és
// necessari per saber `cols`.
auto tryMoveSelected(const Tilemap& tilemap, EntityManager& entities) const -> bool;
// Sub-handlers de `updateHand`. Aïllats per mantenir baixa la
// complexitat cognitiva del bucle d'esdeveniments.
void handleHandLeftClick(const Tilemap& tilemap, EntityManager& entities);
void handleHandRightClick();
// Borra l'entitat seleccionada. La validació de constraints
// (1 puerta, ≥1 sarc) es fa al pas 8.
void deleteSelected(EntityManager& entities);
// Render del marc d'una entitat: 1 px sobre el seu pixel_rect.
static void renderEntityFrame(const Entity& e, std::uint32_t argb);
// HUD d'edició: franja superior amb mode, brush, cursor i comptadors
// de constraints. Substitueix el HUD del joc mentre l'editor és actiu.
void renderEditorHud(const EntityManager& entities) const;
// Carrega la font 8bithud del cache. Lazy: al primer enter().
void ensureHudFont();
void renderTilePicker(const Tilemap& tilemap) const;
void renderEntityPicker(const Tilemap& tilemap) const;
bool active_{false}; bool active_{false};
Mode mode_{Mode::TILE};
Picker picker_{Picker::NONE};
// Estat de l'edició (brush + cursor + picker). Es poblen al pas // Cursor en cel·les del tilemap. -1/-1 = fora del mapa.
// següent; els declarem ara perquè `update()` pugui mutar-los i les int cursor_col_{-1};
// signatures queden estables entre hitos.
int brush_{-1}; // Índex de tile al tileset; -1 = sense brush.
int cursor_col_{-1}; // Cel·la del mapa sota el cursor (-1 = fora).
int cursor_row_{-1}; int cursor_row_{-1};
bool picker_open_{false};
int picker_scroll_{0}; // Fila superior visible del picker (linealitzat). // Brush actiu per Mode::TILE (índex al tileset; -1 = sense brush).
int picker_hover_{-1}; // Índex del tile sota el cursor dins del picker. int brush_tile_{-1};
// Estat del TilePicker.
int tile_picker_scroll_{0};
int tile_picker_hover_{-1};
// Brush actiu per Mode::ENTITY (nom registrat al factory; "" = cap).
std::string brush_entity_;
// Estat de l'EntityPicker.
int entity_picker_scroll_{0};
int entity_picker_hover_{-1}; // Índex a la llista de noms.
// Entrada editable del picker. Cada item recorda el nom (clau de
// factory + YAML), el surface del sprite (frame 0 com a icona) i la
// mida de cada frame al GIF — necessària per clipar el primer frame
// quan el GIF és un strip multi-frame.
struct EditableEntity {
std::string name;
std::shared_ptr<Surface> sprite;
int frame_w{0};
int frame_h{0};
};
// Construïda lazy al primer enter() i preservada entre sessions:
// el cost és I/O + parse de N plantilles YAML, però `EntityLoader`
// ja les memoritza, així que rellegir només resoldria per sortida i
// entrada al editor — no val la pena.
void ensureEditableEntities();
std::vector<EditableEntity> editable_entities_;
// Font compartida amb el Notifier/HUD: el surface el carrega Director
// a l'arrencada, ací només prenem la referència del cache. shared_ptr
// perquè `Text` accepta el surface per shared_ptr i així no copiem.
std::shared_ptr<Text> hud_font_;
// Selecció en Mode::HAND. Treballem amb índexs sobre el snapshot de
// `EntityManager::getEntities()` perquè és el que ens dóna l'API
// pública. Després de cada mutació (move/delete) ho recalculem; com
// que l'editor congela el joc, el snapshot només canvia per accions
// pròpies.
int hand_hover_idx_{-1};
int hand_selected_idx_{-1};
}; };
#endif // _DEBUG #endif // _DEBUG
+85
View File
@@ -0,0 +1,85 @@
#include "game/editor/editor_config.hpp"
#ifdef _DEBUG
#include <cstdint>
#include <iostream>
#include <stdexcept>
#include <string>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#include "utils/defaults.hpp"
namespace {
template <typename T>
void readField(const fkyaml::node& node, const char* key, T& dst) {
if (node.contains(key)) { dst = node[key].get_value<T>(); }
}
// Llegeix un color ARGB. Accepta enter (0xFF2244FF al YAML) o cadena
// ("0xFF2244FF" / "#FF2244FF"). std::stoul amb base=0 detecta el prefix
// "0x" automàticament; si el valor és invàlid, deixem `dst` intacte i
// avisem perquè l'error no passi en silenci.
void readArgb(const fkyaml::node& node, const char* key, std::uint32_t& dst) {
if (!node.contains(key)) { return; }
try {
const auto& v = node[key];
if (v.is_integer()) {
dst = static_cast<std::uint32_t>(v.get_value<std::uint64_t>());
return;
}
auto s = v.get_value<std::string>();
if (!s.empty() && s.front() == '#') { s.erase(0, 1); }
dst = static_cast<std::uint32_t>(std::stoul(s, nullptr, 0));
} catch (const std::exception& e) {
std::cerr << "EditorConfig: color '" << key << "' invàlid (" << e.what() << ") — ignorat\n";
}
}
} // namespace
EditorConfig::EditorConfig()
: picker_margin_(Defaults::Editor::PICKER_MARGIN),
picker_spacing_(Defaults::Editor::PICKER_SPACING),
border_argb_(Defaults::Editor::BORDER_ARGB),
picker_bg_argb_(Defaults::Editor::PICKER_BG_ARGB),
picker_hover_argb_(Defaults::Editor::PICKER_HOVER_ARGB) {
}
auto EditorConfig::get() -> EditorConfig& {
static EditorConfig instance_;
return instance_;
}
void EditorConfig::load(const std::string& file_path) {
auto bytes = Resource::Helper::loadFile(file_path);
if (bytes.empty()) {
std::cerr << "EditorConfig: no s'ha pogut obrir " << file_path << " — s'usaran els defaults compilats\n";
return;
}
try {
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("picker")) {
const auto& p = yaml["picker"];
readField(p, "margin", picker_margin_);
readField(p, "spacing", picker_spacing_);
}
if (yaml.contains("colors")) {
const auto& c = yaml["colors"];
readArgb(c, "border", border_argb_);
readArgb(c, "picker_bg", picker_bg_argb_);
readArgb(c, "picker_hover", picker_hover_argb_);
}
std::cout << "EditorConfig: carregat des de " << file_path << '\n';
} catch (const std::exception& e) {
std::cerr << "EditorConfig: error parsejant " << file_path << ": " << e.what() << '\n';
}
}
#endif // _DEBUG
+39
View File
@@ -0,0 +1,39 @@
#pragma once
#ifdef _DEBUG
#include <cstdint>
#include <string>
// Configuració visual de l'editor de tilemap (Debug-only). Carregada des
// de data/editor/editor.yaml; les claus absents cauen als valors compilats
// a Defaults::Editor. Mateix patró que UiConfig (Meyers singleton + load).
class EditorConfig {
public:
static auto get() -> EditorConfig&;
EditorConfig(const EditorConfig&) = delete;
EditorConfig(EditorConfig&&) = delete;
auto operator=(const EditorConfig&) -> EditorConfig& = delete;
auto operator=(EditorConfig&&) -> EditorConfig& = delete;
void load(const std::string& file_path);
[[nodiscard]] auto pickerMargin() const -> int { return picker_margin_; }
[[nodiscard]] auto pickerSpacing() const -> int { return picker_spacing_; }
[[nodiscard]] auto borderArgb() const -> std::uint32_t { return border_argb_; }
[[nodiscard]] auto pickerBgArgb() const -> std::uint32_t { return picker_bg_argb_; }
[[nodiscard]] auto pickerHoverArgb() const -> std::uint32_t { return picker_hover_argb_; }
private:
EditorConfig();
~EditorConfig() = default;
int picker_margin_;
int picker_spacing_;
std::uint32_t border_argb_;
std::uint32_t picker_bg_argb_;
std::uint32_t picker_hover_argb_;
};
#endif // _DEBUG
@@ -10,6 +10,10 @@ FloorTrap::FloorTrap(const fkyaml::node& yaml) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("floor_trap"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("floor_trap");
collides_ = false; collides_ = false;
interactable_ = false; interactable_ = false;
// Trigger d'àrea: la trampa no bloqueja el pas però vol rebre
// `onPlayerContact` quan el jugador la solapa. Tanca el gap latent
// que la deixava muda (`checkPlayerCollisions` filtrava pel `collides`).
triggers_on_overlap_ = true;
if (yaml.contains("cycle")) { if (yaml.contains("cycle")) {
const auto& c = yaml["cycle"]; const auto& c = yaml["cycle"];
+54
View File
@@ -0,0 +1,54 @@
#include "game/entities/concrete/hole.hpp"
#include <SDL3/SDL.h>
#include <string>
#include "game/entities/entity_factory.hpp"
#include "game/entities/player.hpp"
Hole::Hole(const fkyaml::node& yaml) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("hole");
// Sense xoc físic ni interacció — només trigger d'àrea. El bucle de
// contacte ens admet via `triggers_on_overlap_=true`. Damage queda a
// 0 (no publiquem `player_hit`); la mort la dispara `integrateJump`
// del jugador quan supera `HOLE_FALL_DEPTH`.
collides_ = false;
interactable_ = false;
triggers_on_overlap_ = true;
// Decal de sòl: `Entity::getFeetY()` torna sentinel mínim si aquest
// flag està actiu — així el forat es pinta abans que qualsevol actor.
is_floor_decal_ = true;
setDamage(0);
}
void Hole::onPlayerContact(Player* player) {
if (player == nullptr) { return; }
// Només mossega quan el jugador trepitja el forat de peu (GROUNDED).
// En l'aire, el filtre d'altura del bucle ja l'ha rebutjat (height_=0
// i jump_z_>0 prou per superar-ho); però mantenim el gat per defensa
// davant de futurs canvis al filtre.
if (player->getState() != Player::State::GROUNDED) { return; }
// Re-verificació estricta: la hit-box interna del jugador (8×8) ha
// d'estar COMPLETAMENT continguda dins del rect del forat (16×16). El
// bucle de col·lisió del manager ja ens ha cridat per qualsevol solape;
// ací filtrem perquè la caiguda només es dispari quan els "dos peus"
// estan dins del forat, no quan només la vora frega — sinó es percep
// com un imant que succiona el jugador al centre.
const SDL_FRect HIT = player->getHitBox();
const SDL_FRect BOX = getCollisionRect();
const bool CONTAINED = HIT.x >= BOX.x && HIT.y >= BOX.y && HIT.x + HIT.w <= BOX.x + BOX.w && HIT.y + HIT.h <= BOX.y + BOX.h;
if (!CONTAINED) { return; }
// Snap al centre XY del forat + Y de la base del tile (per al clip
// rect del sprite mentre cau). `enterHoleFall` rep coordenades
// absolutes en el canvas — el player s'encarrega de convertir a la
// seua convenció (cantó superior-esquerra del sprite).
const float CENTER_X = BOX.x + (BOX.w * 0.5F);
const float CENTER_Y = BOX.y + (BOX.h * 0.5F);
const float FLOOR_Y = BOX.y + BOX.h;
player->enterHoleFall(CENTER_X, CENTER_Y, FLOOR_Y);
}
REGISTER_ENTITY("hole", Hole)
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include "external/fkyaml_node.hpp"
#include "game/entities/hazard.hpp"
// Forat al sòl: hazard estàtic de 16×16 que NO bloqueja el pas però
// detecta quan el jugador hi cau a sobre. Característiques:
// - `collides_=false` + `triggers_on_overlap_=true`: rep `onPlayerContact`
// pel bucle de `EntityManager::checkPlayerCollisions` sense bloquejar
// el moviment del jugador.
// - `height_=0`: el filtre per altura del bucle (`jump_z > height`) NO
// s'activa amb height a zero, així que el bucle de contacte SÍ crida
// `onPlayerContact` també per a jugadors AIRBORNE. Qui rebutja els
// estats distints de GROUNDED (saltant, caient, HURT, DYING...) és el
// guard explícit dins de `Hole::onPlayerContact`. El filtre d'altura
// és una optimització ortogonal del bucle, no la defensa principal.
// - Damage 0: el contacte no publica `player_hit`. En lloc d'això,
// `onPlayerContact` invoca `Player::enterHoleFall`, que transiciona
// el jugador a AIRBORNE amb subestat `falling_through_hole_`.
// - Verificació amb hit-box interna: `onPlayerContact` re-verifica
// l'overlap amb `Player::getHitBox()` (8×8 centrada al sprite),
// així només es dispara quan el centre del jugador entra al tile.
// - Z-order: `is_floor_decal_=true` (heretat) — `Entity::getFeetY()`
// retorna sentinel mínim, així el forat es pinta abans que qualsevol
// actor i les momies passen per davant. L'efecte "el sòl es traga el
// jugador" no es fa amb Y-sort: el `Player::render` aplica un clip
// rect mentre `falling_through_hole_=true` que retalla el sprite per
// sota de la base del tile del forat.
class Hole : public StaticHazard {
public:
explicit Hole(const fkyaml::node& yaml);
// No publica `player_hit`. En el seu lloc, dispara
// `Player::enterHoleFall(center_x, center_y, floor_y)` perquè el
// jugador armi el clip rect a la Y de la base del forat.
void onPlayerContact(Player* player) override;
};
// TODO(hole-feature): afegir reset de `falling_through_hole_` i `hole_clip_bottom_y_` a `Player::disable()` per simetria.
// TODO(hole-feature): afegir tests dedicats del comportament de la mòmia amb forats (no requereixen Player real).
+176 -20
View File
@@ -2,12 +2,14 @@
#include <algorithm> #include <algorithm>
#include <array> #include <array>
#include <cmath>
#include <optional> #include <optional>
#include <random> #include <random>
#include <string> #include <string>
#include <utility> #include <utility>
#include "game/entities/entity_factory.hpp" #include "game/entities/entity_factory.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp" #include "game/entities/player.hpp"
#include "game/map/map.hpp" #include "game/map/map.hpp"
@@ -197,7 +199,15 @@ void Mummy::snapToPlayfieldEdge(float dx, float dy) {
} }
void Mummy::step(float delta_time) { void Mummy::step(float delta_time) {
// Desplaçament nominal en aquest frame segons la direcció actual. // Detecció reactiva al canvi de tile: si la mòmia ha entrat aquest
// tick a un tile nou amb un decal trigger (Hole), dispara mitja volta
// abans de calcular el desplaçament — així el frame ja consumeix la
// nova direcció sense esperar al següent. La primera crida només
// registra el tile de spawn (lazy init).
reactToCurrentTile();
// Desplaçament nominal en aquest frame segons la direcció actual
// (potser ja invertida per `reactToCurrentTile`).
const float DX = dir_x_ * speed_ * delta_time; const float DX = dir_x_ * speed_ * delta_time;
const float DY = dir_y_ * speed_ * delta_time; const float DY = dir_y_ * speed_ * delta_time;
@@ -211,6 +221,9 @@ void Mummy::step(float delta_time) {
// centre; el remanent queda per al pròxim frame, ja amb la (potser // centre; el remanent queda per al pròxim frame, ja amb la (potser
// nova) direcció aplicada. Així la caixa 16×16 queda sempre alineada // nova) direcció aplicada. Així la caixa 16×16 queda sempre alineada
// a la graella de corredors i els girs no xoquen per drift sub-píxel. // a la graella de corredors i els girs no xoquen per drift sub-píxel.
// L'evitació de forats en pasillos rectes la cobreix
// `reactToCurrentTile` (al començament d'aquest mètode), no aquesta
// branca — aquí només es decideix entre direccions geomètriques.
if (const auto SNAP = detectLaneCrossing(DX, DY); SNAP.has_value()) { if (const auto SNAP = detectLaneCrossing(DX, DY); SNAP.has_value()) {
if (DX != 0.0F) { if (DX != 0.0F) {
pixel_rect_.x += *SNAP; pixel_rect_.x += *SNAP;
@@ -233,14 +246,86 @@ void Mummy::step(float delta_time) {
// Bloqueig per la vora del playfield: amarrem la caja a la vora exacta. // Bloqueig per la vora del playfield: amarrem la caja a la vora exacta.
if (!Map::insidePlayfield(CANDIDATE)) { snapToPlayfieldEdge(DX, DY); } if (!Map::insidePlayfield(CANDIDATE)) { snapToPlayfieldEdge(DX, DY); }
// Cal girar. Preferim perpendiculars; només fem U-turn si no hi ha cap // Cal girar. Cascada de tres nivells:
// altra opció (comportament típic d'Oh Mummy!: no es recula sense motiu). // Nivell 1 — perpendiculars sense decal (preferides; el comportament
const auto POOLS = collectDirectionPools(speed_ * delta_time); // "natural" d'Oh Mummy!: girar abans que recular).
const auto& pool = !POOLS.perp.empty() ? POOLS.perp : POOLS.uturn; // Nivell 2 — mitja volta sense decal (rescat: callejón sense salida o
applyChoice(chooseDirection(pool)); // tots els girs porten a forat). Preferim recular abans
// que caure al forat — un forat al costat NO ha de fer
// que la mòmia salti per damunt seu cap a una perpendicular
// amb forat només perquè la mitja volta era l'única neta.
// Nivell 3 — pool sense filtre de decals (cas degenerat: rodejada de
// forats). Permet caure al forat com a últim recurs; les
// mòmies no moren pels forats, així que és comportament
// tolerable en sales mal dissenyades.
// TODO(refactor): mateixa cascada a `decideAtIntersection` amb la
// variant "afegir recta a Nivell 1". Factoritzable
// quan tinga sentit (3+ callsites).
auto pools = collectDirectionPools(speed_ * delta_time, /*avoid_blocked=*/true);
const std::vector<Direction>* chosen = nullptr;
if (!pools.perp.empty()) {
chosen = &pools.perp;
} else if (!pools.uturn.empty()) {
chosen = &pools.uturn;
}
DirectionPools pools_unfiltered;
if (chosen == nullptr) {
pools_unfiltered = collectDirectionPools(speed_ * delta_time, /*avoid_blocked=*/false);
if (!pools_unfiltered.perp.empty()) {
chosen = &pools_unfiltered.perp;
} else if (!pools_unfiltered.uturn.empty()) {
chosen = &pools_unfiltered.uturn;
}
}
applyChoice((chosen != nullptr) ? chooseDirection(*chosen) : Direction::NONE);
// No avancem aquest frame: la direcció nova s'aplicarà al pròxim step. // No avancem aquest frame: la direcció nova s'aplicarà al pròxim step.
} }
void Mummy::reactToCurrentTile() {
// Calculem el tile que toca la **vora davantera** de la BOX en la
// direcció de marxa, no el centre. Així la mitja volta es dispara
// tan aviat com el primer píxel del cos de la mòmia entra al tile-
// forat ("asoma el peu"), no quan ja hi ha mig cos a dins. Per a una
// direcció positiva en un eix, la vora davantera és el costat major
// de l'AABB (`x + w - 1`, exclusive→inclusive convention en píxels);
// per a una direcció negativa, el menor (`x`); per a zero, qualsevol
// (no hi ha avenç en aquest eix).
const auto BOX = getCollisionRect();
const float LEAD_X = (dir_x_ > 0.0F) ? (BOX.x + BOX.w - 1.0F) : BOX.x;
const float LEAD_Y = (dir_y_ > 0.0F) ? (BOX.y + BOX.h - 1.0F) : BOX.y;
const auto [CUR_COL, CUR_ROW] = Map::pixelToTile(LEAD_X, LEAD_Y);
// Sense canvi de tile no hi ha res a fer — això filtra també tots els
// ticks intra-tile (la majoria, ~16 frames per tile a velocitats típiques).
if (CUR_COL == last_tile_col_ && CUR_ROW == last_tile_row_) { return; }
// Primera crida després del spawn: registrem el tile sense reaccionar.
// Així no disparem mitja volta espuria si la mòmia spawneja sobre un
// decal (defensa contra mapes mal dissenyats) o si pixel_rect_ encara
// no estava posat al constructor.
const bool FIRST_TIME = (last_tile_col_ == INT_MIN || last_tile_row_ == INT_MIN);
last_tile_col_ = CUR_COL;
last_tile_row_ = CUR_ROW;
if (FIRST_TIME) { return; }
// Sense manager injectat (smoke tests), no podem consultar decals.
if (entity_manager_ == nullptr) { return; }
// Si el tile nou conté un decal trigger (Hole o futur pinchos plans,
// plaques, etc.), mitja volta immediata. `changeDirection` també
// selecciona l'animació cardinal corresponent.
const auto TS = static_cast<float>(Map::tile_size);
const SDL_FRect TILE_RECT{
.x = (static_cast<float>(CUR_COL) * TS) + static_cast<float>(Map::tilemap_origin_x),
.y = (static_cast<float>(CUR_ROW) * TS) + static_cast<float>(Map::tilemap_origin_y),
.w = TS,
.h = TS,
};
if (entity_manager_->findFloorTriggerAt(TILE_RECT) != nullptr) {
changeDirection(-dir_x_, -dir_y_);
}
}
auto Mummy::detectLaneCrossing(float dx, float dy) const -> std::optional<float> { auto Mummy::detectLaneCrossing(float dx, float dy) const -> std::optional<float> {
const auto BOX = getCollisionRect(); const auto BOX = getCollisionRect();
if (dx != 0.0F) { if (dx != 0.0F) {
@@ -258,26 +343,54 @@ auto Mummy::currentDirection() const -> Direction {
} }
void Mummy::decideAtIntersection(float step_px) { void Mummy::decideAtIntersection(float step_px) {
// Pool base: perpendiculars lliures (collectDirectionPools ja exclou la // Cascada de tres nivells (paral·lela a la branca de bloqueig de
// direcció actual i la mitja volta va al seu propi calaix, que ací // `step`). Aquí el Nivell 1 inclou la recta com a candidata addicional
// ignorem — mai fem U-turn a un creuament). // (a un creuament la mòmia pot continuar recta si segueix lliure i no
auto pools = collectDirectionPools(step_px); // apunta a un decal); el Nivell 2 admet la mitja volta — abans no es
std::vector<Direction> pool = std::move(pools.perp); // permetia mai en creuaments, però és preferible recular que caure al
// forat quan totes les altres surten a decal.
// TODO(refactor): cascada paral·lela a la branca de bloqueig de `step`.
auto pools = collectDirectionPools(step_px, /*avoid_blocked=*/true);
// Afegim la recta si segueix essent lliure. A un creuament "net" ho és // Nivell 1: perpendiculars (filtrades) + recta si lliure i sense decal.
// sempre (la mòmia hi ha arribat per aquest corredor), però als creua- std::vector<Direction> level1 = std::move(pools.perp);
// ments de la vora del playfield la recta pot estar al límit i no
// permetre un pas més — en aquest cas la descartem.
const auto STRAIGHT_BOX = candidateBox(dir_x_ * step_px, dir_y_ * step_px); const auto STRAIGHT_BOX = candidateBox(dir_x_ * step_px, dir_y_ * step_px);
if (Map::insidePlayfield(STRAIGHT_BOX) && !Map::collides(STRAIGHT_BOX)) { const bool STRAIGHT_OPEN = Map::insidePlayfield(STRAIGHT_BOX) && !Map::collides(STRAIGHT_BOX);
pool.push_back(currentDirection()); if (STRAIGHT_OPEN && !isBlockedAheadInDir(dir_x_, dir_y_)) {
level1.push_back(currentDirection());
} }
if (pool.empty()) { return; } // Nivell 2: mitja volta sense decal — rescat quan Nivell 1 ja no té cap
applyChoice(chooseDirection(pool)); // opció neta. Un forat al final del passadís ha de fer recular la
// mòmia, no convertir-se en l'opció triada per defecte.
const std::vector<Direction>* chosen = nullptr;
if (!level1.empty()) {
chosen = &level1;
} else if (!pools.uturn.empty()) {
chosen = &pools.uturn;
}
// Nivell 3: cas degenerat (rodejada de forats). Pool sense filtre amb
// perpendiculars, recta si lliure i mitja volta. Permet caure al forat
// com a últim recurs.
DirectionPools pools_unfiltered;
std::vector<Direction> level3;
if (chosen == nullptr) {
pools_unfiltered = collectDirectionPools(step_px, /*avoid_blocked=*/false);
level3 = std::move(pools_unfiltered.perp);
if (STRAIGHT_OPEN) { level3.push_back(currentDirection()); }
if (!level3.empty()) {
chosen = &level3;
} else if (!pools_unfiltered.uturn.empty()) {
chosen = &pools_unfiltered.uturn;
}
}
if (chosen == nullptr) { return; }
applyChoice(chooseDirection(*chosen));
} }
auto Mummy::collectDirectionPools(float step) const -> DirectionPools { auto Mummy::collectDirectionPools(float step, bool avoid_blocked) const -> DirectionPools {
struct Candidate { struct Candidate {
float dx; float dx;
float dy; float dy;
@@ -297,6 +410,11 @@ auto Mummy::collectDirectionPools(float step) const -> DirectionPools {
if (IS_CURRENT) { continue; } if (IS_CURRENT) { continue; }
const auto TEST = candidateBox(c.dx, c.dy); const auto TEST = candidateBox(c.dx, c.dy);
if (!Map::insidePlayfield(TEST) || Map::collides(TEST)) { continue; } if (!Map::insidePlayfield(TEST) || Map::collides(TEST)) { continue; }
// Filtre de decals (Hole i futurs decals trigger): comprova el
// tile cardinal contigu; si conté un trigger, descartem la
// direcció. El caller pot demanar `avoid_blocked=false` com a
// fallback quan totes les filtrades estan bloquejades.
if (avoid_blocked && isBlockedAheadInDir(c.dx, c.dy)) { continue; }
const bool IS_UTURN = (c.dx * dir_x_ < 0.0F) || (c.dy * dir_y_ < 0.0F); const bool IS_UTURN = (c.dx * dir_x_ < 0.0F) || (c.dy * dir_y_ < 0.0F);
if (IS_UTURN) { if (IS_UTURN) {
pools.uturn.push_back(c.dir); pools.uturn.push_back(c.dir);
@@ -307,6 +425,44 @@ auto Mummy::collectDirectionPools(float step) const -> DirectionPools {
return pools; return pools;
} }
auto Mummy::isBlockedAheadInDir(float dx_signed, float dy_signed) const -> bool {
if (dx_signed == 0.0F && dy_signed == 0.0F) { return false; }
// El rect a consultar és el AABB del tile cardinal contigu (mida
// `Map::tile_size`), ancorat al tile actual de la mòmia. Calculem el
// tile a partir del **centre** de la BOX, no de la cantonada superior-
// esquerra: amb una BOX de mida `tile_size`, la cantonada esquerra
// entra al tile següent un sub-píxel abans que el cos de la mòmia hi
// sigui majoritàriament. Si féssim servir `BOX.x` directament, una
// mòmia a `BOX.x = 199.5` (cobrint [199.5, 215.5], 0.5 px al tile 12
// i 15.5 px al tile 13) seria classificada al tile 12 i giraria un
// tile abans del veritablement adjacent al decal.
const auto BOX = getCollisionRect();
const auto TS = static_cast<float>(Map::tile_size);
const auto SIGN_OF = [](float v) -> float {
if (v > 0.0F) { return 1.0F; }
if (v < 0.0F) { return -1.0F; }
return 0.0F;
};
const float CENTER_X = BOX.x + (BOX.w * 0.5F);
const float CENTER_Y = BOX.y + (BOX.h * 0.5F);
const auto [CUR_COL, CUR_ROW] = Map::pixelToTile(CENTER_X, CENTER_Y);
const float TILE_X = (static_cast<float>(CUR_COL) * TS) + static_cast<float>(Map::tilemap_origin_x);
const float TILE_Y = (static_cast<float>(CUR_ROW) * TS) + static_cast<float>(Map::tilemap_origin_y);
const SDL_FRect NEXT{
.x = TILE_X + (SIGN_OF(dx_signed) * TS),
.y = TILE_Y + (SIGN_OF(dy_signed) * TS),
.w = TS,
.h = TS,
};
// Ordre per cost creixent: comparació de coordenades, després iteració
// sobre sarcs i porta, i finalment cerca a l'index d'entitats. Sense
// manager (smoke tests) saltem només l'últim filtre.
if (!Map::insidePlayfield(NEXT)) { return true; }
if (Map::collides(NEXT)) { return true; }
if (entity_manager_ != nullptr && entity_manager_->findFloorTriggerAt(NEXT) != nullptr) { return true; }
return false;
}
void Mummy::applyChoice(Direction dir) { void Mummy::applyChoice(Direction dir) {
switch (dir) { switch (dir) {
case Direction::NORTH: case Direction::NORTH:
+40 -2
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <climits>
#include <cstdint> #include <cstdint>
#include <optional> #include <optional>
#include <vector> #include <vector>
@@ -85,12 +86,31 @@ class Mummy : public DynamicHazard {
// Recull les direccions alternatives viables des de la posició actual: // Recull les direccions alternatives viables des de la posició actual:
// `perpendiculars` (girs) i `uturn` (inversa). La direcció actual s'exclou // `perpendiculars` (girs) i `uturn` (inversa). La direcció actual s'exclou
// perquè ja sabem que està bloquejada. Cada candidata es valida amb // perquè ja sabem que està bloquejada. Cada candidata es valida amb
// `Map::insidePlayfield` + `Map::collides` sobre la caja resultant. // `Map::insidePlayfield` + `Map::collides` sobre la caja resultant. Si
// `avoid_blocked` és true i hi ha `EntityManager` injectat, també es
// descarten les direccions que apunten a un tile amb un decal trigger
// (Hole o futurs pinchos plans/charcos). El caller és responsable de
// re-cridar amb `avoid_blocked=false` com a fallback si totes les
// direccions filtrades estan bloquejades.
struct DirectionPools { struct DirectionPools {
std::vector<Direction> perp; std::vector<Direction> perp;
std::vector<Direction> uturn; std::vector<Direction> uturn;
}; };
[[nodiscard]] auto collectDirectionPools(float step) const -> DirectionPools; [[nodiscard]] auto collectDirectionPools(float step, bool avoid_blocked = true) const -> DirectionPools;
// True si el tile cardinal contigu en la direcció (signe de dx, signe
// de dy) està bloquejat per qualsevol motiu: fora del playfield, ocupat
// per geometria estàtica (sarcs, door_corridor tancat) via `Map::collides`,
// o conté un decal trigger (`is_floor_decal_ && triggers_on_overlap_`)
// segons `EntityManager::findFloorTriggerAt`. Captura `Hole` i qualsevol
// futur decal trigger sense hardcodejar tipus, i evita que la mòmia
// triï a un creuament una direcció que el frame següent toparia (cas
// típic: a la intersecció anterior al límit nord del playfield, on el
// pas immediat encara hi cap però el tile sencer següent ja està fora).
// Si `entity_manager_` no està injectat (smoke tests sense manager),
// el filtre de decals és silenciós però els altres dos (playfield i
// collides) segueixen actius.
[[nodiscard]] auto isBlockedAheadInDir(float dx_signed, float dy_signed) const -> bool;
// Canvi de direcció + selecció de l'animació corresponent (un cardinal // Canvi de direcció + selecció de l'animació corresponent (un cardinal
// per branch). Tots els camins que trien nova direcció (ctor amb // per branch). Tots els camins que trien nova direcció (ctor amb
@@ -130,6 +150,16 @@ class Mummy : public DynamicHazard {
// totes ho estan, retorna `NONE` (la mòmia queda quieta en HURT). // totes ho estan, retorna `NONE` (la mòmia queda quieta en HURT).
[[nodiscard]] auto chooseHurtDirection(Direction preferred) const -> Direction; [[nodiscard]] auto chooseHurtDirection(Direction preferred) const -> Direction;
// Detecció reactiva al canvi de tile (substitueix el lookahead sub-tile
// entre creuaments). Es crida al principi de cada `step()`. Si el tile
// que ocupa la mòmia (calculat des del centre de la BOX) ha canviat
// respecte a l'última crida, comprova si el tile nou conté un decal
// trigger; si el conté, dispara mitja volta immediata. La primera crida
// (last_tile_col_ == INT_MIN) només registra el tile actual sense
// reaccionar — evita un falç positiu si la mòmia spawneja sobre un
// decal o si el setup encara no és estable.
void reactToCurrentTile();
// Animacions carregades del YAML, una per cada cardinal. La variant // Animacions carregades del YAML, una per cada cardinal. La variant
// `walk_west` reaprofita els frames de l'est però amb `flip_h: true` // `walk_west` reaprofita els frames de l'est però amb `flip_h: true`
// declarat al YAML — el motor d'animació aplica el flip al sprite // declarat al YAML — el motor d'animació aplica el flip al sprite
@@ -153,4 +183,12 @@ class Mummy : public DynamicHazard {
// bloc `stats.kills_outright` del YAML; default true per coherència // bloc `stats.kills_outright` del YAML; default true per coherència
// amb el comportament d'Oh Mummy! (les mòmies maten). // amb el comportament d'Oh Mummy! (les mòmies maten).
bool kills_outright_{true}; bool kills_outright_{true};
// Tile que ocupava la mòmia a l'última crida a `reactToCurrentTile`.
// Inicialitzats a `INT_MIN` perquè la primera crida (lazy init: no
// podem fer-ho al constructor perquè `pixel_rect_` encara no té
// posició; setPixelRect del manager arriba després) registra el tile
// de spawn sense reaccionar.
int last_tile_col_{INT_MIN};
int last_tile_row_{INT_MIN};
}; };
@@ -10,6 +10,9 @@ WallTrap::WallTrap(const fkyaml::node& yaml) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("wall_trap"); name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("wall_trap");
collides_ = false; collides_ = false;
interactable_ = false; interactable_ = false;
// Trigger d'àrea: igual que FloorTrap. Tanca el gap latent del filtre
// de `checkPlayerCollisions` que ho deixava mut.
triggers_on_overlap_ = true;
if (yaml.contains("cycle")) { if (yaml.contains("cycle")) {
const auto& c = yaml["cycle"]; const auto& c = yaml["cycle"];
@@ -152,7 +152,7 @@ class WhipAttack : public Attack {
// del nostre `getFeetY()` per delegar el bucket d'y-sort al jugador. // del nostre `getFeetY()` per delegar el bucket d'y-sort al jugador.
float owner_feet_y_{0.0F}; float owner_feet_y_{0.0F};
EntityManager* entity_manager_{nullptr}; // `entity_manager_` viu a Entity (protected): l'utilitzem d'allà.
const std::vector<Player*>* players_{nullptr}; const std::vector<Player*>* players_{nullptr};
DirSprite sprite_north_; DirSprite sprite_north_;
+7
View File
@@ -1,5 +1,6 @@
#include "game/entities/entity.hpp" #include "game/entities/entity.hpp"
#include <limits>
#include <utility> #include <utility>
Entity::Entity(std::string name, TilePos tile, SDL_FRect pixel_rect) Entity::Entity(std::string name, TilePos tile, SDL_FRect pixel_rect)
@@ -38,6 +39,12 @@ auto Entity::getCollisionRect() const -> SDL_FRect {
} }
auto Entity::getFeetY() const -> float { auto Entity::getFeetY() const -> float {
// Decal de sòl: sentinel mínim per quedar al principi del Y-sort
// ascendent de `GameScene` (stable_sort) i així pintar-se ABANS de
// qualsevol actor. Sense passades de render extra.
if (is_floor_decal_) {
return std::numeric_limits<float>::lowest();
}
const auto RECT = getCollisionRect(); const auto RECT = getCollisionRect();
return RECT.y + RECT.h; return RECT.y + RECT.h;
} }
+36
View File
@@ -12,9 +12,13 @@
// `source/game/entities/player.hpp` i només es referencia per referència // `source/game/entities/player.hpp` i només es referencia per referència
// dins `onPlayerContact`; `EventBus` és opcional a cada entitat i viu al // dins `onPlayerContact`; `EventBus` és opcional a cada entitat i viu al
// seu header propi. `Surface` és el backend de render paletitzat del motor. // seu header propi. `Surface` és el backend de render paletitzat del motor.
// `EntityManager` només es referencia per punter (injectat opcionalment
// per a entitats que necessiten consultar el món d'entitats — p.ex. la
// mòmia per saber si el pròxim tile té un decal trigger tipus Hole).
class Player; class Player;
class EventBus; class EventBus;
class Surface; class Surface;
class EntityManager;
// Entitat genèrica del món. Actua com a dada en comú per a tots els actors // 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é // del joc (decoratius, interactables, perills mòbils i estàtics): manté
@@ -88,6 +92,27 @@ class Entity {
[[nodiscard]] auto collides() const -> bool { return collides_; } [[nodiscard]] auto collides() const -> bool { return collides_; }
[[nodiscard]] auto interactable() const -> bool { return interactable_; } [[nodiscard]] auto interactable() const -> bool { return interactable_; }
// Trigger d'àrea: l'entitat NO bloqueja el moviment (`collides_=false`)
// però sí vol rebre `onPlayerContact` quan el jugador la solapa. Pensat
// per a hazards "no físics" (forats al sòl, picos retràctils, plaques
// de pressió). El bucle de contacte (`checkPlayerCollisions`) admet
// l'entitat si compleix `collides_ || triggers_on_overlap_`. El filtre
// d'altura per salt (`height_`) i la caixa de col·lisió funcionen igual.
[[nodiscard]] auto triggersOnOverlap() const -> bool { return triggers_on_overlap_; }
void setTriggersOnOverlap(bool v) { triggers_on_overlap_ = v; }
// Decal de sòl: hazards estàtics sense presència vertical (forats,
// plaques de pressió, charcos). Es pinten ABANS de qualsevol actor
// dins del Y-sort de `GameScene` perquè conceptualment són "pintura
// sobre el tile". Implementació: `getFeetY()` retorna un sentinel
// mínim (`std::numeric_limits<float>::lowest()`) per quedar al
// principi del `stable_sort` ascendent — així no calen passades de
// render extra. Les subclasses (p.ex. `Hole`) poden sobreescriure
// `getFeetY` per saltar-se el sentinel en moments concrets (p.ex.
// un forat que està tragant un jugador i ha de pintar-se al damunt).
[[nodiscard]] auto isFloorDecal() const -> bool { return is_floor_decal_; }
void setFloorDecal(bool v) { is_floor_decal_ = v; }
// Marca per a entitats transitòries: efectes (polvet, espurnes, // Marca per a entitats transitòries: efectes (polvet, espurnes,
// explosions...) que viuen una sola animació i s'autoeliminen quan // explosions...) que viuen una sola animació i s'autoeliminen quan
// acaba. L'`EntityManager` purga totes les entitats amb `dead_=true` // acaba. L'`EntityManager` purga totes les entitats amb `dead_=true`
@@ -101,6 +126,14 @@ class Entity {
// estàtiques de moment) el poden ignorar. // estàtiques de moment) el poden ignorar.
void setEventBus(EventBus* bus) { bus_ = bus; } void setEventBus(EventBus* bus) { bus_ = bus; }
// Injecció opcional del manager d'entitats (mateix patró que
// `setEventBus`: el manager s'auto-injecta a `add`/`setDoor`/
// `addSarcophagus`). El consumeixen entitats que volen consultar el
// món d'entitats — avui la mòmia, per saber si el tile següent
// conté un decal trigger (Hole) i evitar caure-hi. Entitats que no
// l'usen el poden ignorar.
void setEntityManager(EntityManager* manager) { entity_manager_ = manager; }
// Assigna la caixa de render en píxels. La invoca `EntityManager` durant // Assigna la caixa de render en píxels. La invoca `EntityManager` durant
// `loadFromLevel` un cop sap la posició definitiva de l'entitat al mapa. // `loadFromLevel` un cop sap la posició definitiva de l'entitat al mapa.
void setPixelRect(SDL_FRect rect) { pixel_rect_ = rect; } void setPixelRect(SDL_FRect rect) { pixel_rect_ = rect; }
@@ -172,8 +205,11 @@ class Entity {
std::shared_ptr<Surface> surface_{nullptr}; std::shared_ptr<Surface> surface_{nullptr};
bool collides_{false}; bool collides_{false};
bool interactable_{false}; bool interactable_{false};
bool triggers_on_overlap_{false};
bool is_floor_decal_{false};
int height_{0}; int height_{0};
bool dead_{false}; bool dead_{false};
SDL_FlipMode flip_{SDL_FLIP_NONE}; SDL_FlipMode flip_{SDL_FLIP_NONE};
EventBus* bus_{nullptr}; EventBus* bus_{nullptr};
EntityManager* entity_manager_{nullptr};
}; };
+39 -1
View File
@@ -72,6 +72,7 @@ void EntityManager::forceOpenDoor() {
auto EntityManager::add(std::unique_ptr<Entity> entity) -> Entity* { auto EntityManager::add(std::unique_ptr<Entity> entity) -> Entity* {
if (entity == nullptr) { return nullptr; } if (entity == nullptr) { return nullptr; }
entity->setEventBus(&bus_); entity->setEventBus(&bus_);
entity->setEntityManager(this);
Entity* raw = entity.get(); Entity* raw = entity.get();
entities_.push_back(std::move(entity)); entities_.push_back(std::move(entity));
// cppcheck-suppress returnDanglingLifetime // cppcheck-suppress returnDanglingLifetime
@@ -80,12 +81,31 @@ auto EntityManager::add(std::unique_ptr<Entity> entity) -> Entity* {
void EntityManager::setDoor(Door* door) { void EntityManager::setDoor(Door* door) {
door_ = door; door_ = door;
if (door_ != nullptr) { door_->setEventBus(&bus_); } if (door_ != nullptr) {
door_->setEventBus(&bus_);
door_->setEntityManager(this);
}
}
auto EntityManager::removeEntity(Entity* observer) -> bool {
if (observer == nullptr) { return false; }
const bool WAS_DOOR = (door_ == observer);
const bool WAS_SARC = (observer->getName() == "sarcophagus");
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.
return true;
} }
auto EntityManager::addSarcophagus(std::unique_ptr<Sarcophagus> sarc) -> Sarcophagus* { auto EntityManager::addSarcophagus(std::unique_ptr<Sarcophagus> sarc) -> Sarcophagus* {
if (sarc == nullptr) { return nullptr; } if (sarc == nullptr) { return nullptr; }
sarc->setEventBus(&bus_); sarc->setEventBus(&bus_);
sarc->setEntityManager(this);
Sarcophagus* raw = sarc.get(); Sarcophagus* raw = sarc.get();
entities_.push_back(std::move(sarc)); entities_.push_back(std::move(sarc));
++total_sarcs_; ++total_sarcs_;
@@ -110,6 +130,24 @@ void EntityManager::notifyPlayerTilePainted(int col, int row) {
for (auto& e : entities_) { e->notifyTilePainted(col, row); } 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.
for (const auto& entity : entities_) {
if (!entity->triggersOnOverlap()) { continue; }
if (!entity->isFloorDecal()) { continue; }
const auto BOX = entity->getCollisionRect();
const bool OVERLAP = rect.x + rect.w > BOX.x && BOX.x + BOX.w > rect.x && rect.y + rect.h > BOX.y && BOX.y + BOX.h > rect.y;
if (OVERLAP) { return entity.get(); }
}
return nullptr;
}
auto EntityManager::getEntities() const -> std::vector<Entity*> { auto EntityManager::getEntities() const -> std::vector<Entity*> {
std::vector<Entity*> out; std::vector<Entity*> out;
out.reserve(entities_.size()); out.reserve(entities_.size());
+29
View File
@@ -1,10 +1,13 @@
#pragma once #pragma once
#include <SDL3/SDL.h>
#include <cstddef> #include <cstddef>
#include <memory> #include <memory>
#include <string> #include <string>
#include <vector> #include <vector>
#include "external/fkyaml_node.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
class Entity; class Entity;
@@ -41,6 +44,13 @@ class EntityManager {
// una. Si ja n'hi ha una assignada, la substitueix (útil a tests). // una. Si ja n'hi ha una assignada, la substitueix (útil a tests).
void setDoor(Door* door); void setDoor(Door* door);
// Treu i destrueix l'entitat observada. Si era la porta o un sarc,
// ajusta `door_` / `total_sarcs_` en conseqüència. Pensat per al
// editor: no toca `opened_sarcs_` ni publica events — el joc està
// congelat i, en sortir del editor, GameScene torna a fer reload(),
// així que l'estat es deriva net.
auto removeEntity(Entity* observer) -> bool;
// Marca una entitat com a sarcòfag — si no es passa per `add` amb // Marca una entitat com a sarcòfag — si no es passa per `add` amb
// type=sarcophagus no es compta al total. Útil en combinació amb // type=sarcophagus no es compta al total. Útil en combinació amb
// `add` quan es fa addSarcophagus(unique_ptr<Sarcophagus>). // `add` quan es fa addSarcophagus(unique_ptr<Sarcophagus>).
@@ -74,6 +84,16 @@ class EntityManager {
// reflecteixi l'estat del frame actual. // reflecteixi l'estat del frame actual.
void checkPlayerCollisions(const std::vector<Player*>& players); void checkPlayerCollisions(const std::vector<Player*>& players);
// Cerca la primera entitat que (a) sigui decal de sòl
// (`is_floor_decal_=true`), (b) admeti triggers d'àrea
// (`triggers_on_overlap_=true`), i (c) la seva caixa de col·lisió
// solape amb el rect donat. Pensat per a `Player::integrateJump`,
// que la consulta a l'aterratge per detectar caigudes sobre Hole
// (i en el futur pinchos plans, plaques de pressió, charcos...) i
// saltar-se el LANDING_STUN normal. Retorna `nullptr` si no hi ha
// cap match — el caller usa el camí d'aterratge habitual.
[[nodiscard]] auto findFloorTriggerAt(const SDL_FRect& rect) const -> const Entity*;
// Fan-out del hook de pintat: per a cada sarcòfag, cridar // Fan-out del hook de pintat: per a cada sarcòfag, cridar
// notifyTilePainted. Les entitats no-sarcòfag ho ignoren per defecte // notifyTilePainted. Les entitats no-sarcòfag ho ignoren per defecte
// (veure Entity::notifyTilePainted). Invocat com a reacció a // (veure Entity::notifyTilePainted). Invocat com a reacció a
@@ -90,6 +110,15 @@ class EntityManager {
// `Map::tile_size`, `tilemap_origin_*` i `getSarcRect`). // `Map::tile_size`, `tilemap_origin_*` i `getSarcRect`).
void loadFromLevel(const std::string& level_yaml_path); void loadFromLevel(const std::string& level_yaml_path);
// Crea i afegeix una entitat a partir d'un node spawn (mateix format
// que els items del bloc `entities:` del YAML del nivell). Retorna el
// punter observacional a l'entitat creada, o `nullptr` si la plantilla
// no es pot carregar, el tipus no està registrat o el spawn no té els
// camps requerits (`position`, `sarc_position`, etc. segons el tipus).
// Pensat per al editor de Debug: construeix un node sintètic des
// d'una posició del cursor i el passa.
auto addSpawn(const fkyaml::node& spawn) -> Entity*;
// Reset complet del nivell: destrueix totes les entitats, reseteja // Reset complet del nivell: destrueix totes les entitats, reseteja
// els comptadors interns (sarcòfags oberts, total, exited, flag de // els comptadors interns (sarcòfags oberts, total, exited, flag de
// level_complete) i torna a cridar `loadFromLevel` per regenerar les // level_complete) i torna a cridar `loadFromLevel` per regenerar les
@@ -61,7 +61,12 @@ void EntityManager::renderAll() {
void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) { void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) {
if (players.empty()) { return; } if (players.empty()) { return; }
for (const auto& entity : entities_) { for (const auto& entity : entities_) {
if (!entity->collides()) { continue; } // 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; }
const auto BOX = entity->getCollisionRect(); const auto BOX = entity->getCollisionRect();
const int E_HEIGHT = entity->getHeight(); const int E_HEIGHT = entity->getHeight();
for (auto* p : players) { for (auto* p : players) {
@@ -69,6 +74,19 @@ void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) {
// contacte amb hazards (un segon toc de mòmia mentre la roda // contacte amb hazards (un segon toc de mòmia mentre la roda
// ja és no-op; cap altra entitat l'ha de fer transicionar). // ja és no-op; cap altra entitat l'ha de fer transicionar).
if (p == nullptr || p->isOutOfPlay() || p->isDying()) { continue; } 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 // 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. // damunt de l'alçada del cos de l'entitat, no hi ha contacte.
// És el mecanisme del salt per esquivar momies, roques, etc. // És el mecanisme del salt per esquivar momies, roques, etc.
@@ -377,31 +395,58 @@ void EntityManager::reload(const std::string& level_yaml_path) {
loadFromLevel(level_yaml_path); loadFromLevel(level_yaml_path);
} }
auto EntityManager::addSpawn(const fkyaml::node& spawn) -> Entity* {
if (!spawn.contains("type")) { return nullptr; }
const auto TYPE = spawn["type"].get_value<std::string>();
SpriteInfo sprite;
std::string anchor;
auto entity = buildEntity(TYPE, sprite, anchor);
if (entity == nullptr) { return nullptr; }
Entity* observer = entity.get();
bool ok = false;
if (TYPE == "sarcophagus") {
ok = placeSarcophagus(*this, std::move(entity), sprite, spawn);
} else if (TYPE == "door") {
ok = placeDoor(*this, std::move(entity), sprite);
} else {
ok = placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn);
}
return ok ? observer : nullptr;
}
void EntityManager::loadFromLevel(const std::string& level_yaml_path) { void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path); const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path);
int loaded = 0; const int LOADED = static_cast<int>(std::ranges::count_if(SPAWNS,
[this](const fkyaml::node& spawn) { return addSpawn(spawn) != nullptr; }));
for (const auto& spawn : SPAWNS) { // Broadcast inicial: marca com a "pintats" tots els tiles ocupats per
if (!spawn.contains("type")) { continue; } // decals de sòl (Hole, i futurs pinchos plans, plaques, charcos...).
const auto TYPE = spawn["type"].get_value<std::string>(); // Imprescindible perquè el jugador no pot trepitjar aquests tiles
// (cauria al forat) i els sarcòfags vecins no podrien completar el
SpriteInfo sprite; // perímetre — la sala seria incompletable. Usa el canal semàntic
std::string anchor; // `tile_painted` (lògica de joc), no `on_tile_entered` (presència
auto entity = buildEntity(TYPE, sprite, anchor); // física del jugador). Ho fem aquí, després del bucle de spawns,
if (entity == nullptr) { continue; } // perquè tots els sarcòfags ja estan registrats al fan-out (la
// subscripció del manager és al ctor, anterior a qualsevol load).
bool ok = false; int decal_broadcast = 0;
if (TYPE == "sarcophagus") { for (const auto& entity : entities_) {
ok = placeSarcophagus(*this, std::move(entity), sprite, spawn); if (!entity->isFloorDecal()) { continue; }
} else if (TYPE == "door") { const auto BOX = entity->getCollisionRect();
ok = placeDoor(*this, std::move(entity), sprite); const auto [COL, ROW] = Map::pixelToTile(BOX.x, BOX.y);
} else { bus_.publish(Event{
ok = placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn); .name = "tile_painted",
} .payload = TilePos{.col = COL, .row = ROW},
const bool OK = ok; .player_id = -1,
if (OK) { ++loaded; } });
++decal_broadcast;
}
if (decal_broadcast > 0) {
std::cout << "EntityManager: " << decal_broadcast
<< " tile_painted broadcast inicial (decals de sòl)\n";
} }
std::cout << "EntityManager: " << loaded << " entitats carregades des de " std::cout << "EntityManager: " << LOADED << " entitats carregades des de "
<< level_yaml_path << '\n'; << level_yaml_path << '\n';
} }
+115
View File
@@ -0,0 +1,115 @@
#include "game/entities/entity_writer.hpp"
#ifdef _DEBUG
#include <cstddef>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
namespace EntityWriter {
namespace {
// Cerca la línia que comença per `<key>:` a columna 0. Mateix
// patró que TilemapWriter::findDataLine però parametritzat. Si
// un dia paga la pena, podem extreure'l a un helper compartit.
auto findKeyLine(const std::string& content, const std::string& key) -> std::size_t {
const std::string PREFIX = key + ":";
std::size_t pos = 0;
while (pos < content.size()) {
const bool AT_LINE_START = (pos == 0 || content[pos - 1] == '\n');
if (AT_LINE_START && content.compare(pos, PREFIX.size(), PREFIX) == 0) {
const std::size_t AFTER = pos + PREFIX.size();
if (AFTER >= content.size() || content[AFTER] == '\n' || content[AFTER] == '\r' || content[AFTER] == ' ' || content[AFTER] == '\t' || content[AFTER] == '#') {
return pos;
}
}
const auto NL = content.find('\n', pos);
if (NL == std::string::npos) { break; }
pos = NL + 1;
}
return std::string::npos;
}
// Avança mentre trobe línies indentades (espai/tab) o línies buides.
// A diferència del TilemapWriter, ací incloem línies buides dins
// del bloc: l'editor de YAML deixa sovint blanc entre grups
// d'entitats, i volem absorbir-lo per no deixar restes orfes
// entre el bloc nou i el següent top-level.
auto advancePastBlock(const std::string& content, std::size_t pos) -> std::size_t {
while (pos < content.size()) {
const char C = content[pos];
const bool IS_INDENT = (C == ' ' || C == '\t');
const bool IS_BLANK = (C == '\n' || C == '\r');
if (!IS_INDENT && !IS_BLANK) { break; }
const auto NL = content.find('\n', pos);
pos = (NL == std::string::npos) ? content.size() : NL + 1;
}
return pos;
}
auto buildBlock(const std::vector<Spawn>& spawns) -> std::string {
std::string out;
for (const auto& s : spawns) {
out += " - type: " + s.type + "\n";
if (s.sarc_col.has_value() && s.sarc_row.has_value()) {
out += " sarc_position: [" + std::to_string(*s.sarc_col) + ", " + std::to_string(*s.sarc_row) + "]\n";
} else if (s.col.has_value() && s.row.has_value()) {
out += " position: [" + std::to_string(*s.col) + ", " + std::to_string(*s.row) + "]\n";
}
}
return out;
}
} // namespace
auto saveEntitiesBlock(const std::string& path, const std::vector<Spawn>& spawns) -> bool {
std::ifstream in(path, std::ios::binary);
if (!in) { return false; }
std::ostringstream ss;
ss << in.rdbuf();
const std::string CONTENT = ss.str();
in.close();
const auto KEY_POS = findKeyLine(CONTENT, "entities");
if (KEY_POS == std::string::npos) { return false; }
const auto LINE_END = CONTENT.find('\n', KEY_POS);
const auto AFTER_KEY = (LINE_END == std::string::npos) ? CONTENT.size() : LINE_END + 1;
const auto BLOCK_END = advancePastBlock(CONTENT, AFTER_KEY);
// Mantenim una línia en blanc darrere del bloc si abans n'hi
// havia: ajuda a separar visualment el `entities:` de la pròxima
// secció. Si no n'hi havia, no afegim res.
const std::string TAIL = CONTENT.substr(BLOCK_END);
const std::string NEW_CONTENT =
CONTENT.substr(0, AFTER_KEY) + buildBlock(spawns) + TAIL;
const auto TMP_PATH = path + ".tmp";
{
std::ofstream out(TMP_PATH, std::ios::binary | std::ios::trunc);
if (!out) { return false; }
out.write(NEW_CONTENT.data(), static_cast<std::streamsize>(NEW_CONTENT.size()));
out.close();
if (!out) {
std::error_code ec;
std::filesystem::remove(TMP_PATH, ec);
return false;
}
}
std::error_code ec;
std::filesystem::rename(TMP_PATH, path, ec);
if (ec) {
std::filesystem::remove(TMP_PATH, ec);
return false;
}
return true;
}
} // namespace EntityWriter
#endif // _DEBUG
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#ifdef _DEBUG
#include <optional>
#include <string>
#include <vector>
// Reescriu NOMÉS el bloc `entities:` d'un YAML de nivell, preservant byte
// a byte la resta del fitxer: comentaris, ordre de claus, format del
// `geometry:` i `data:`, encoding i line endings entre línies no tocades.
// Pensat exclusivament per a l'editor (Debug).
//
// Format de sortida (estable, llegible a l'editor de text):
// entities:
// - type: torch
// position: [4, 2]
// - type: sarcophagus
// sarc_position: [0, 0]
// - type: door
//
// Semàntica del bloc origen:
// - Línia de capçalera: primera aparició de "entities:" a columna 0.
// Si manca, el writer FALLA i no toca el fitxer.
// - Contingut: línies indentades (espai o tab). Una línia buida o una
// línia a columna 0 marquen la fi del bloc.
//
// Si el fitxer original no té el bloc `entities:`, el writer no inventa
// ubicació: retorna false per que el caller decidisca (mostrar error,
// avortar, etc.) — no és funció del writer escollir l'estil del YAML.
//
// Escriptura atòmica: via `<path>.tmp` + `std::filesystem::rename`. Si
// l'escriptura falla a meitat, el YAML original queda intacte.
namespace EntityWriter {
// Cada spawn duplica l'estructura mínima del que el lector espera:
// type sempre, i un dels (position, sarc_position) o cap (cas door).
struct Spawn {
std::string type;
std::optional<int> col;
std::optional<int> row;
std::optional<int> sarc_col;
std::optional<int> sarc_row;
};
auto saveEntitiesBlock(const std::string& path, const std::vector<Spawn>& spawns) -> bool;
} // namespace EntityWriter
#endif // _DEBUG
+247 -46
View File
@@ -12,10 +12,13 @@
#include "core/input/gamepad_bindings.hpp" #include "core/input/gamepad_bindings.hpp"
#include "core/input/gamepad_manager.hpp" #include "core/input/gamepad_manager.hpp"
#include "core/input/input_state.hpp" #include "core/input/input_state.hpp"
#include "core/rendering/canvas.hpp"
#include "core/rendering/screen.hpp" #include "core/rendering/screen.hpp"
#include "core/resources/resource_cache.hpp" #include "core/resources/resource_cache.hpp"
#include "core/resources/resource_helper.hpp" #include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
#include "game/entities/entity.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
#include "game/options.hpp" #include "game/options.hpp"
#include "utils/defaults.hpp" #include "utils/defaults.hpp"
@@ -327,15 +330,18 @@ void Player::updateFacingAndTurn(float delta_time, float vx, float vy) {
intermediate_cardinal_ = intermediateFor(INPUT_CARD); intermediate_cardinal_ = intermediateFor(INPUT_CARD);
turning_remaining_ms_ = static_cast<float>(turn_config.duration_ms); turning_remaining_ms_ = static_cast<float>(turn_config.duration_ms);
applyCardinal(intermediate_cardinal_); applyCardinal(intermediate_cardinal_);
} else if (vx != 0.0F) {
// Tria de facing: prevalença lateral, EAST/WEST segons el signe
// de vx; verticals purs van a NORTH/SOUTH. En aturar-se
// conservem `facing_` per a l'animació stand.
facing_ = (vx < 0.0F) ? Facing::WEST : Facing::EAST;
} else if (vy < 0.0F) { } else if (vy < 0.0F) {
// Tria de facing amb prevalença vertical: si hi ha component
// vertical, el sprite mira NORTH/SOUTH encara que també hi
// haja horitzontal. Així una diagonal (down+right, up+left,
// etc.) mostra el sprite mirant amunt o avall, no de costat.
// Només quan el moviment és purament horitzontal anem a
// EAST/WEST. En aturar-se conservem `facing_` per a stand.
facing_ = Facing::NORTH; facing_ = Facing::NORTH;
} else if (vy > 0.0F) { } else if (vy > 0.0F) {
facing_ = Facing::SOUTH; facing_ = Facing::SOUTH;
} else if (vx != 0.0F) {
facing_ = (vx < 0.0F) ? Facing::WEST : Facing::EAST;
} }
} }
@@ -910,7 +916,40 @@ auto Player::integrateJump(float delta_time) -> bool {
if (state_ != State::AIRBORNE) { return false; } if (state_ != State::AIRBORNE) { return false; }
jump_vz_ -= jump_config.gravity_px_per_s2 * delta_time; jump_vz_ -= jump_config.gravity_px_per_s2 * delta_time;
jump_z_ += jump_vz_ * delta_time; jump_z_ += jump_vz_ * delta_time;
// Caiguda per agujero: el sòl ja no és present sota el jugador, així
// que NO clampem `jump_z_` a 0. Deixem baixar a negatius (visualment
// el sprite "s'enfonsa" sota el sòl) i, en superar `HOLE_FALL_DEPTH`,
// disparem la mort. La cinemàtica de respawn (`enterDying →
// spawnFromAir`) reseteja el flag i la posició al spawn point.
if (falling_through_hole_) {
if (jump_z_ <= -HOLE_FALL_DEPTH) {
enterDying(DeathCause::HOLE);
}
return false;
}
if (jump_z_ <= 0.0F) { if (jump_z_ <= 0.0F) {
// Aterratge sobre decal-trigger (Hole, futurs pinchos plans...):
// saltem el LANDING_STUN i transicionem directament a la caiguda
// pel forat. Sense aquesta detecció, l'aterratge entraria a
// LANDING_STUN (~200 ms flotant sobre el forat) i només quan
// tornés a GROUNDED `Hole::onPlayerContact` ho dispararia. La
// hit-box (8×8 centrada al sprite) és la mateixa que `Hole`
// usa al seu re-check; coherent. Si el manager no s'ha
// injectat (tests stand-alone), el camí d'aterratge segueix sent
// el habitual.
if (entity_manager_ != nullptr) {
const Entity* trigger = entity_manager_->findFloorTriggerAt(getHitBox());
if (trigger != nullptr) {
const auto BOX = trigger->getCollisionRect();
const float CENTER_X = BOX.x + (BOX.w * 0.5F);
const float CENTER_Y = BOX.y + (BOX.h * 0.5F);
const float FLOOR_Y = BOX.y + BOX.h;
enterHoleFall(CENTER_X, CENTER_Y, FLOOR_Y);
// No retornem `true`: no és un aterratge "normal" i
// `applyMovement` no ha de cridar `resolveLandingOverlap`.
return false;
}
}
jump_z_ = 0.0F; jump_z_ = 0.0F;
jump_vz_ = 0.0F; jump_vz_ = 0.0F;
state_ = State::LANDING_STUN; state_ = State::LANDING_STUN;
@@ -1061,6 +1100,31 @@ void Player::applyMovement(float delta_time) {
clampToPlayfield(); clampToPlayfield();
// Refinem el `facing_` amb el desplaçament REAL del frame (post
// col·lisió/snap), no amb la velocitat d'entrada: si l'usuari prem
// diagonal en un pasillo, `tryMove` ja haurà anul·lat l'eix tapat
// per la paret, així que el sprite mira en la direcció realment
// efectuada (la d'input cru no ho sap). En AIRBORNE el facing es
// preserva des del despegue (vegeu el comentari de `integrateJump`),
// i durant un gir actiu el cardinal intermedi mana, així que els
// saltem. Si DX==DY==0 (parat o totalment bloquejat) preservem el
// facing previ per a l'animació stand. La prioritat vertical en
// diagonals lliures és intranscendent en pasillos (un dels dos eixos
// serà sempre 0); manté coherència amb la tria d'`updateFacingAndTurn`.
if (!AIRBORNE && turning_remaining_ms_ <= 0.0F) {
const float DX = getPosX() - FRAME_START_X;
const float DY = getPosY() - FRAME_START_Y;
if (DY < 0.0F) {
facing_ = Facing::NORTH;
} else if (DY > 0.0F) {
facing_ = Facing::SOUTH;
} else if (DX < 0.0F) {
facing_ = Facing::WEST;
} else if (DX > 0.0F) {
facing_ = Facing::EAST;
}
}
const bool MOVED = (getPosX() != FRAME_START_X) || (getPosY() != FRAME_START_Y); const bool MOVED = (getPosX() != FRAME_START_X) || (getPosY() != FRAME_START_Y);
// `being_pushed_` força `stand_*`: el jugador es desplaça sense input, // `being_pushed_` força `stand_*`: el jugador es desplaça sense input,
// animar-lo com a caminant faria creure que està caminant quan de // animar-lo com a caminant faria creure que està caminant quan de
@@ -1075,7 +1139,7 @@ void Player::applyMovement(float delta_time) {
if (state_ == State::GROUNDED) { publishTileEnteredIfChanged(); } if (state_ == State::GROUNDED) { publishTileEnteredIfChanged(); }
} }
void Player::enterDying() { void Player::enterDying(DeathCause cause) {
// Idempotent. Un segon contacte de momia mentre el jugador ja està // Idempotent. Un segon contacte de momia mentre el jugador ja està
// en DYING no reinicia l'animació ni el comptador. Tampoc disparem // en DYING no reinicia l'animació ni el comptador. Tampoc disparem
// el cicle si el jugador està fora de joc (EXITED al passar la // el cicle si el jugador està fora de joc (EXITED al passar la
@@ -1091,6 +1155,8 @@ void Player::enterDying() {
being_pushed_ = false; being_pushed_ = false;
jump_z_ = 0.0F; jump_z_ = 0.0F;
jump_vz_ = 0.0F; jump_vz_ = 0.0F;
falling_through_hole_ = false;
hole_clip_bottom_y_ = 0.0F;
landing_stun_remaining_ms_ = 0.0F; landing_stun_remaining_ms_ = 0.0F;
attacking_remaining_ms_ = 0.0F; attacking_remaining_ms_ = 0.0F;
turning_remaining_ms_ = 0.0F; turning_remaining_ms_ = 0.0F;
@@ -1102,7 +1168,18 @@ void Player::enterDying() {
death_animation_complete_ = false; death_animation_complete_ = false;
respawn_remaining_ms_ = 0.0F; respawn_remaining_ms_ = 0.0F;
setCurrentAnimation("death_spinning"); // Presentació segons la causa. Cada `DeathCause` té la seua animació
// dedicada al YAML del player; `applyDyingMovement` arma el respawn
// timer quan `animationIsCompleted()` flanqueja, igual per a totes.
switch (cause) {
case DeathCause::HOLE:
setCurrentAnimation("death_hole");
break;
case DeathCause::GENERIC:
default:
setCurrentAnimation("death_spinning");
break;
}
// So del cop letal: reaprofitem `hurt.wav` (ja precarregat per // So del cop letal: reaprofitem `hurt.wav` (ja precarregat per
// `readHurtBlock`). El bloc `death:` no té camp de so propi — // `readHurtBlock`). El bloc `death:` no té camp de so propi —
@@ -1204,19 +1281,46 @@ void Player::render() {
if (state_ == State::DYING && death_animation_complete_) { return; } if (state_ == State::DYING && death_animation_complete_) { return; }
// Mentre el jugador està enlaire, el sprite es desplaça visualment // Mentre el jugador està enlaire, el sprite es desplaça visualment
// cap amunt `jump_z_` píxels. Modifiquem `pos_.y` (la posició entera // `jump_z_` píxels: cap amunt si és positiu (salt), cap avall si és
// negatiu (caiguda per `Hole`, que deixa baixar `jump_z_` a negatius
// fins a `-HOLE_FALL_DEPTH`). Modifiquem `pos_.y` (la posició entera
// de render) abans de cridar el render base, i la restaurem després // de render) abans de cridar el render base, i la restaurem després
// perquè l'estat seguent del frame ho recalcule des de `y_` (la font // perquè l'estat seguent del frame ho recalcule des de `y_` (la font
// de veritat float). El sòl sota el jugador no es pinta des d'ací: // de veritat float). El sòl sota el jugador no es pinta des d'ací:
// això ho fa el GameScene en una passada de sombres a banda. // això ho fa el GameScene en una passada de sombres a banda. El cas
if (jump_z_ <= 0.0F) { // `jump_z_ == 0` (a terra) surt per l'early-return per evitar tocar
AnimatedSprite::render(); // `pos_.y` innecessàriament.
return; // Caiguda per agujero: el sprite es "menja" pel sòl. Fixem un clip
// global a la franja [0, hole_clip_bottom_y_] del canvas (el blitter
// de `Surface` respecta `Rendering::activeClip()`); a mesura que
// `pos_.y - jump_z_` baixa, els píxels del sprite que creuen la
// línia deixen de pintar-se. Fem el push/pop al voltant de tot el
// render del sprite per cobrir tant el cas `jump_z_==0` (caiguda
// just iniciada) com els frames posteriors. `pushClip` ja clamp
// a la mida del canvas, així que un `h` negatiu es traduiria en
// una franja buida i el sprite quedaria invisible — comportament
// correcte si el clip ja ha "tapat" tota la pantalla.
const bool CLIP_HOLE = falling_through_hole_;
if (CLIP_HOLE) {
const auto CANVAS = Rendering::activeCanvas();
Rendering::pushClip({
.x = 0,
.y = 0,
.w = CANVAS.width,
.h = static_cast<int>(hole_clip_bottom_y_),
});
} }
const float SAVED_Y = pos_.y;
pos_.y = SAVED_Y - jump_z_; if (jump_z_ == 0.0F) {
AnimatedSprite::render(); AnimatedSprite::render();
pos_.y = SAVED_Y; } else {
const float SAVED_Y = pos_.y;
pos_.y = SAVED_Y - jump_z_;
AnimatedSprite::render();
pos_.y = SAVED_Y;
}
if (CLIP_HOLE) { Rendering::popClip(); }
} }
void Player::renderShadow() const { void Player::renderShadow() const {
@@ -1224,6 +1328,10 @@ void Player::renderShadow() const {
// que poden cridar `renderShadow` per a tots els jugadors sense // que poden cridar `renderShadow` per a tots els jugadors sense
// discriminar. // discriminar.
if (!isAirborne()) { return; } if (!isAirborne()) { return; }
// Caient per un agujero: el cos s'enfonsa sota el sòl, dibuixar una
// sombra al ras del sòl trenca la il·lusió. Suprimim la sombra fins
// que `enterDying` netegi el flag.
if (falling_through_hole_) { return; }
// Sombra centrada al sprite, recolzada a la línia dels peus reals // Sombra centrada al sprite, recolzada a la línia dels peus reals
// (ignorant `jump_z_`). L'amplada es redueix proporcional a l'altura // (ignorant `jump_z_`). L'amplada es redueix proporcional a l'altura
@@ -1289,10 +1397,9 @@ auto Player::statePrefix(State state, bool moving, bool in_attack_swing) -> cons
case State::ATTACKING: case State::ATTACKING:
return in_attack_swing ? "attack" : "recover"; return in_attack_swing ? "attack" : "recover";
case State::DYING: case State::DYING:
// L'animació concreta (`death_spinning`) la fixa `enterDying`; // L'animació concreta (`death_spinning` o `death_hole`) la
// `applyDyingMovement` no torna a cridar `setCurrentAnimation`, // selecciona `enterDying` segons `DeathCause`; aquest retorn
// així que aquest valor no s'usa mai. El retornem només per // només cobreix exhaustivitat del switch i no s'usa al runtime.
// exhaustivitat del switch.
return "death"; return "death";
case State::GROUNDED: case State::GROUNDED:
case State::EXITED: case State::EXITED:
@@ -1359,6 +1466,8 @@ void Player::respawn(float x, float y) {
being_pushed_ = false; being_pushed_ = false;
jump_z_ = 0.0F; jump_z_ = 0.0F;
jump_vz_ = 0.0F; jump_vz_ = 0.0F;
falling_through_hole_ = false;
hole_clip_bottom_y_ = 0.0F;
landing_stun_remaining_ms_ = 0.0F; landing_stun_remaining_ms_ = 0.0F;
attacking_remaining_ms_ = 0.0F; attacking_remaining_ms_ = 0.0F;
turning_remaining_ms_ = 0.0F; turning_remaining_ms_ = 0.0F;
@@ -1396,6 +1505,57 @@ void Player::disable() {
last_input_cardinal_ = Cardinal::NONE; last_input_cardinal_ = Cardinal::NONE;
} }
void Player::enterHoleFall(float center_x, float center_y, float floor_y) {
// Cap guard de state ací: la responsabilitat la té el caller perquè
// hi ha dos callers vàlids amb states distints:
// - `Hole::onPlayerContact` (state GROUNDED, el jugador camina i
// trepitja el tile del forat).
// - `Player::integrateJump` (state AIRBORNE, en aterrar damunt
// d'un decal-trigger en lloc d'entrar a LANDING_STUN).
// Posar `if (state_ != GROUNDED) return` ací bloquejaria el segon
// cas i obligaria a hacks tipus "forçar GROUNDED abans de cridar".
// Snap del centre de la hit-box a la coordenada central del forat.
// Resolem `pos = center - (hit_offset + hit_size/2)` per a cada eix:
// així el centre exacte de la caja de contacte queda alineat amb
// (center_x, center_y), independentment de la geometria interna del
// sprite. `setPosX/Y` és l'API canònica per teleportar el jugador
// (la usen `respawn` i `spawnFromAir`).
const float HIT_HALF = static_cast<float>(hit_size) * 0.5F;
setPosX(center_x - static_cast<float>(hit_x_offset) - HIT_HALF);
setPosY(center_y - static_cast<float>(hit_y_offset) - HIT_HALF);
// Frenem qualsevol inèrcia prèvia: la caiguda ha de ser estàtica al
// centre del forat.
intended_vx_ = 0.0F;
intended_vy_ = 0.0F;
input_vx_ = 0.0F;
input_vy_ = 0.0F;
motion_time_ms_ = 0.0F;
being_pushed_ = false;
setVelX(0.0F);
setVelY(0.0F);
jump_z_ = 0.0F;
jump_vz_ = 0.0F;
state_ = State::AIRBORNE;
falling_through_hole_ = true;
// Línia visual on el clip rect ha de retallar el sprite. Restem
// `SPRITE_FLOOR_OFFSET` perquè la base del sprite té píxels
// transparents — el retall ha de coincidir amb la silueta visible,
// no amb la vora del rect del sprite.
hole_clip_bottom_y_ = floor_y - SPRITE_FLOOR_OFFSET;
// Animació de caiguda: respectem el `facing_` que ja portava el jugador
// (no té sentit reorientar-lo cap al sud només perquè caigui en un
// forat). Com `state_` ja és AIRBORNE, `animationName(false)` resol el
// prefix `jump_` i hi afegeix el sufix cardinal correcte —
// `jump_north`, `jump_south`, `jump_east` o `jump_west` segons el
// facing actual. Si demà cal una animació de "cara de pànic", només
// cal canviar aquí pel `setCurrentAnimation` corresponent.
setCurrentAnimation(animationName(false));
}
void Player::spawnFromAir(float x, float y) { void Player::spawnFromAir(float x, float y) {
// Net total: equival a un `respawn` seguit d'una entrada en l'aire. // Net total: equival a un `respawn` seguit d'una entrada en l'aire.
// Així el flag de gir, l'animació, els flancs del salt i les // Així el flag de gir, l'animació, els flancs del salt i les
@@ -1457,24 +1617,13 @@ auto Player::hitBoxFromBase(const SDL_FRect& base) -> SDL_FRect {
}; };
} }
void Player::snapToLane( void Player::snapTowardsLane(
float delta_time, float delta_time,
float base, float target_lane,
float pitch,
int max_idx,
float (Player::*get_pos)() const, float (Player::*get_pos)() const,
void (Player::*set_pos)(float)) { void (Player::*set_pos)(float)) {
// Lanes en pos_axis: base + i·pitch per a i ∈ [0, max_idx]. Correspon a
// la caixa de col·lisió ocupant exactament un passadís perpendicular.
const auto THRESHOLD = static_cast<float>(collision_size) / 2.0F; const auto THRESHOLD = static_cast<float>(collision_size) / 2.0F;
const float DELTA = target_lane - (this->*get_pos)();
const auto IDX = std::clamp(
static_cast<int>(std::round(((this->*get_pos)() - base) / pitch)),
0,
max_idx);
const float LANE = base + (static_cast<float>(IDX) * pitch);
const float DELTA = LANE - (this->*get_pos)();
if (std::abs(DELTA) <= 0.0F || std::abs(DELTA) > THRESHOLD) { return; } if (std::abs(DELTA) <= 0.0F || std::abs(DELTA) > THRESHOLD) { return; }
const float FULL_STEP = std::min(std::abs(DELTA), SPEED_PX_PER_S * delta_time); const float FULL_STEP = std::min(std::abs(DELTA), SPEED_PX_PER_S * delta_time);
@@ -1559,20 +1708,72 @@ auto Player::tryPushSnapBlockers(
return true; return true;
} }
auto Player::wouldHitOuterWall(float dx, float dy) const -> bool {
SDL_FRect probe = getBaseBox();
probe.x += dx;
probe.y += dy;
if (Map::insidePlayfield(probe)) { return false; }
// Excepció: corridor de la porta OBERTA quan l'usuari prem cap
// amunt (dy < 0). No demanem que la probe ja estigui dins del
// corridor — encara no ho està, perquè el snap és justament qui
// ha d'aliniar-la. En lloc d'això comprovem si la base box està
// prou prop del centre del corridor com per a que el snap
// (mateix THRESHOLD = collision_size/2) puga completar-lo.
if (Map::door_open && dy < 0.0F && map_.door_corridor_w > 0) {
const float CORRIDOR_TARGET = static_cast<float>(map_.door_corridor_x) + (static_cast<float>(map_.door_corridor_w - collision_size) / 2.0F);
const auto THRESHOLD = static_cast<float>(collision_size) / 2.0F;
if (std::abs(CORRIDOR_TARGET - probe.x) <= THRESHOLD) { return false; }
}
return true;
}
void Player::snapToHorizontalLane(float delta_time) { void Player::snapToHorizontalLane(float delta_time) {
snapToLane(delta_time, // Si el moviment principal del frame (eix X) està blocat per un mur
static_cast<float>(map_.playfield_y - collision_y_offset), // extern, no fem snap perpendicular: l'usuari està picant contra el
static_cast<float>(map_.pitch_y), // mur i no té sentit arrossegar-lo a un carril. Step probe d'1 px.
map_.grid_rows, if (intended_vx_ != 0.0F && wouldHitOuterWall(std::copysign(1.0F, intended_vx_), 0.0F)) {
&Player::getPosY, return;
&Player::setPosY); }
const auto BASE_Y = static_cast<float>(map_.playfield_y - collision_y_offset);
const auto PITCH_Y = static_cast<float>(map_.pitch_y);
const int IDX = std::clamp(
static_cast<int>(std::round((getPosY() - BASE_Y) / PITCH_Y)),
0,
map_.grid_rows);
const float LANE_REG = BASE_Y + (static_cast<float>(IDX) * PITCH_Y);
snapTowardsLane(delta_time, LANE_REG, &Player::getPosY, &Player::setPosY);
} }
void Player::snapToVerticalLane(float delta_time) { void Player::snapToVerticalLane(float delta_time) {
snapToLane(delta_time, // Mateixa idea que la versió horitzontal: si el moviment principal
static_cast<float>(map_.playfield_x - collision_x_offset), // (eix Y) està blocat per un mur extern, no aliniem en X. La
static_cast<float>(map_.pitch_x), // excepció del corridor de la porta hi aplica naturalment dins
map_.grid_cols, // `wouldHitOuterWall` (només quan és OBERT).
&Player::getPosX, if (intended_vy_ != 0.0F && wouldHitOuterWall(0.0F, std::copysign(1.0F, intended_vy_))) {
&Player::setPosX); return;
}
// Candidat 1: lane regular del laberint.
const auto BASE_X = static_cast<float>(map_.playfield_x - collision_x_offset);
const auto PITCH_X = static_cast<float>(map_.pitch_x);
const int IDX = std::clamp(
static_cast<int>(std::round((getPosX() - BASE_X) / PITCH_X)),
0,
map_.grid_cols);
const float LANE_REG = BASE_X + (static_cast<float>(IDX) * PITCH_X);
// Candidat 2: corridor de la porta. NOMÉS si la porta està oberta —
// si està tancada no té sentit arrossegar el jugador cap a un
// forat per on no pot passar.
float best_lane = LANE_REG;
if (Map::door_open && map_.door_corridor_w > 0) {
const float CORRIDOR_CENTER = static_cast<float>(map_.door_corridor_x) + (static_cast<float>(map_.door_corridor_w - collision_size) / 2.0F);
const float LANE_DOOR = CORRIDOR_CENTER - static_cast<float>(collision_x_offset);
const float DELTA_REG = LANE_REG - getPosX();
const float DELTA_DOOR = LANE_DOOR - getPosX();
if (std::abs(DELTA_DOOR) < std::abs(DELTA_REG)) { best_lane = LANE_DOOR; }
}
snapTowardsLane(delta_time, best_lane, &Player::getPosX, &Player::setPosX);
} }
+95 -5
View File
@@ -12,6 +12,7 @@
#include "game/map/map.hpp" #include "game/map/map.hpp"
class EventBus; class EventBus;
class EntityManager;
class Player : public AnimatedSprite { class Player : public AnimatedSprite {
public: public:
@@ -40,6 +41,16 @@ class Player : public AnimatedSprite {
// que decau per inèrcia fins a parar (~2 tiles). // que decau per inèrcia fins a parar (~2 tiles).
// No col·lisiona amb cap altre jugador. Quan // No col·lisiona amb cap altre jugador. Quan
// s'atura, torna a GROUNDED. // s'atura, torna a GROUNDED.
// Causa de la mort actual. La passa el caller a `enterDying(cause)` per
// triar la presentació visual (`death_<key>` al YAML del player). El
// default `GENERIC` cobreix tots els callsites antics (mòmies, etc.)
// sense haver de tocar-los; els nous (caiguda per `Hole`, en el futur
// pinchos / timeout / etc.) passen la seua causa explícita.
enum class DeathCause : std::uint8_t {
GENERIC, // mort estàndard — animació `death_spinning`
HOLE, // caiguda per agujero — animació `death_hole`
};
enum class State : std::uint8_t { enum class State : std::uint8_t {
GROUNDED, GROUNDED,
AIRBORNE, AIRBORNE,
@@ -168,6 +179,13 @@ class Player : public AnimatedSprite {
// contra mur) s'emparen automàticament. // contra mur) s'emparen automàticament.
void setOtherPlayers(const std::vector<Player*>* all) { all_players_ = all; } void setOtherPlayers(const std::vector<Player*>* all) { all_players_ = all; }
// Refresca la còpia local de `Map::Info`. Necessari quan el mapa
// (geometria, p.ex. `door_col`) canvia entre carregaments —
// típicament després del editor in-game. Sense aquest refresc, el
// `clampToPlayfield` segueix usant el corridor antic i el jugador
// no troba la nova "obertura" del mur trasser.
void setMapInfo(const Map::Info& info) { map_ = info; }
// Marcat pel resolver jugador↔jugador quan aquest frame el jugador // Marcat pel resolver jugador↔jugador quan aquest frame el jugador
// és arrossegat per una empenta (no té input propi; es desplaça // és arrossegat per una empenta (no té input propi; es desplaça
// perquè l'altre l'empeny). Determina que l'animació del frame siga // perquè l'altre l'empeny). Determina que l'animació del frame siga
@@ -226,6 +244,29 @@ class Player : public AnimatedSprite {
return state_ == State::GROUNDED || state_ == State::LANDING_STUN; return state_ == State::GROUNDED || state_ == State::LANDING_STUN;
} }
// El jugador està caient per un agujero (`Hole`): subestat dins
// d'AIRBORNE amb gravetat aplicada SENSE clamp a 0 — `jump_z_` baixa
// a negatius fins a `HOLE_FALL_DEPTH`, llavors `enterDying()`. Mentre
// dura, `checkPlayerCollisions` salta aquest jugador (cap més hazard
// l'ha de tocar). Es neteja a `enterDying()` i a `respawn()`.
[[nodiscard]] auto isFallingThroughHole() const -> bool { return falling_through_hole_; }
// Activa la caída per agujero: snap del jugador al centre (cx, cy) en
// coordenades absolutes del canvas, transició a AIRBORNE amb
// `jump_z_=0` i `falling_through_hole_=true`, i arma el clip rect
// visual a partir de `floor_y` (Y de la base del tile del forat,
// canvas píxels). Desactiva input direccional reusant la branca
// AIRBORNE existent. No-op si el jugador no està a GROUNDED.
//
// CONTRACTE: `floor_y` ha de correspondre a la vora inferior de
// l'AABB del decal trigger (`hole.y + hole.h`). Els dos callers
// actuals — `Hole::onPlayerContact` i `Player::integrateJump` —
// passen exactament aquest valor. Qualsevol caller futur ha de
// respectar la convenció: el clip rect del render es calcula com
// `floor_y - SPRITE_FLOOR_OFFSET`, i un valor incorrecte desplaçaria
// visualment el "sòl" sobre el qual el sprite del jugador desapareix.
void enterHoleFall(float center_x, float center_y, float floor_y);
// Despawn lògic: el jugador acaba de creuar la porta de sortida. // Despawn lògic: el jugador acaba de creuar la porta de sortida.
// Idempotent — només la primera crida fa la transició; les // Idempotent — només la primera crida fa la transició; les
// posteriors són no-op. La publicació del `player_exited` al bus // posteriors són no-op. La publicació del `player_exited` al bus
@@ -254,7 +295,7 @@ class Player : public AnimatedSprite {
// momia mentre la roda no la reinicia). Quan l'animació acaba i // momia mentre la roda no la reinicia). Quan l'animació acaba i
// `respawn_delay_ms` expira, `respawn(spawn_x_, spawn_y_)` torna a // `respawn_delay_ms` expira, `respawn(spawn_x_, spawn_y_)` torna a
// GROUNDED i `lives_remaining_` decreix en 1 (pot anar a negatius). // GROUNDED i `lives_remaining_` decreix en 1 (pot anar a negatius).
void enterDying(); void enterDying(DeathCause cause = DeathCause::GENERIC);
// Coordenades del spawn de respawn (cantó superior-esquerra del // Coordenades del spawn de respawn (cantó superior-esquerra del
// sprite, mateixa convenció que `respawn(x, y)`). El GameScene les // sprite, mateixa convenció que `respawn(x, y)`). El GameScene les
@@ -320,6 +361,14 @@ class Player : public AnimatedSprite {
// correspongui (sarcòfags). Si el bus no s'injecta, no emet res. // correspongui (sarcòfags). Si el bus no s'injecta, no emet res.
void setEventBus(EventBus* bus) { bus_ = bus; } void setEventBus(EventBus* bus) { bus_ = bus; }
// Injecta el manager d'entitats del nivell. L'usa `integrateJump` per
// detectar aterratges sobre decals-trigger (forats, plaques de
// pressió...) i disparar la transició corresponent en lloc del
// LANDING_STUN normal. Propietat externa (mateixa convenció que
// `setEventBus`); si no s'injecta, l'aterratge segueix el camí
// normal (els tests stand-alone del Player no necessiten injectar-lo).
void setEntityManager(EntityManager* manager) { entity_manager_ = manager; }
// Tile actual del jugador (centre de `baseBox()` convertit a // Tile actual del jugador (centre de `baseBox()` convertit a
// coordenades de tile del tilemap, usant Map::tile_size i // coordenades de tile del tilemap, usant Map::tile_size i
// Map::tilemap_origin_*). Retorna (col, row). // Map::tilemap_origin_*). Retorna (col, row).
@@ -387,14 +436,24 @@ class Player : public AnimatedSprite {
// attack/recover), el sufix del cardinal (`_north`/`_south`/`_east`/ // attack/recover), el sufix del cardinal (`_north`/`_south`/`_east`/
// `_west`). // `_west`).
[[nodiscard]] auto animationName(bool moving) const -> std::string; [[nodiscard]] auto animationName(bool moving) const -> std::string;
void snapToLane( // Aplica el snap cap a una lane concreta amb la mecànica de fases
// 1/2 + push de blockers. Els wrappers (`snapToHorizontalLane` /
// `snapToVerticalLane`) decideixen la lane diana (grid regular, o
// corridor de la porta com a candidata especial).
void snapTowardsLane(
float delta_time, float delta_time,
float base, float target_lane,
float pitch,
int max_idx,
float (Player::*get_pos)() const, float (Player::*get_pos)() const,
void (Player::*set_pos)(float)); void (Player::*set_pos)(float));
// Cert si un step (dx, dy) trauria la base box fora del playfield
// (mur extern). Excepció: si el moviment és cap amunt (dy<0), la
// porta està OBERTA i la box probe queda dins del rang X del
// corridor, no és bloqueig — el jugador hi pot sortir. S'usa per
// suprimir el snap a lane quan el jugador no pot avançar perquè
// pica contra un mur exterior.
[[nodiscard]] auto wouldHitOuterWall(float dx, float dy) const -> bool;
// Quan el snap lateral trobaria un altre jugador al carril objectiu, // Quan el snap lateral trobaria un altre jugador al carril objectiu,
// l'empentem el mateix step en el mateix eix. Així `this` pot entrar // l'empentem el mateix step en el mateix eix. Així `this` pot entrar
// al passadís encara que l'altre estiga estacionat al mitj — mateix // al passadís encara que l'altre estiga estacionat al mitj — mateix
@@ -655,6 +714,31 @@ class Player : public AnimatedSprite {
float jump_z_{0.0F}; float jump_z_{0.0F};
float jump_vz_{0.0F}; float jump_vz_{0.0F};
// Subestat dins d'AIRBORNE: `enterHoleFall` el posa a true; mentre dura,
// `integrateJump` no clampa `jump_z_` a 0 — deixa baixar a negatius i
// dispara `enterDying()` quan creua `HOLE_FALL_DEPTH`. Es neteja a
// `enterDying()` i `respawn()`.
bool falling_through_hole_{false};
// Y absoluta (canvas píxels) per sota de la qual el `Player::render`
// retalla el sprite mentre `falling_through_hole_=true`. La fixa
// `enterHoleFall` a `floor_y - SPRITE_FLOOR_OFFSET` i la neteja
// `respawn`. Sense ús quan no estem caient per un forat.
float hole_clip_bottom_y_{0.0F};
// Profunditat (en píxels per sota del sòl) que ha de superar el jugador
// mentre cau per un agujero abans que `integrateJump` el mate. Constant
// per ara (ajustable a posteriori si el feel no és bo).
static constexpr float HOLE_FALL_DEPTH = 20.0F;
// Píxels transparents a la base del sprite del jugador (16×32). El
// sprite "camina pel mig del passadís en falsa perspectiva", així que
// la silueta no toca la vora inferior del rect del sprite — hi queda
// un marge transparent. Quan armem el clip rect del forat, restem
// aquest offset a la `floor_y` perquè la línia de retall coincideixi
// visualment amb la base del tile, no amb la vora del sprite.
static constexpr float SPRITE_FLOOR_OFFSET = 3.0F;
// Temps restant de stun d'aterratge (ms). Mentre > 0, l'estat és // Temps restant de stun d'aterratge (ms). Mentre > 0, l'estat és
// LANDING_STUN i tot l'input queda bloquejat. // LANDING_STUN i tot l'input queda bloquejat.
float landing_stun_remaining_ms_{0.0F}; float landing_stun_remaining_ms_{0.0F};
@@ -765,6 +849,12 @@ class Player : public AnimatedSprite {
// Player només en guarda un punter per publicar events. // Player només en guarda un punter per publicar events.
EventBus* bus_{nullptr}; EventBus* bus_{nullptr};
// Manager d'entitats del nivell (opcional). Propietat externa
// (GameScene); l'usa `integrateJump` per consultar
// `findFloorTriggerAt` a l'aterratge i decidir si el frame del LAND
// ha de disparar `enterHoleFall` en lloc d'un LANDING_STUN normal.
EntityManager* entity_manager_{nullptr};
// Últim tile publicat. Inicialitzats a un sentinel fora de rang perquè // Últim tile publicat. Inicialitzats a un sentinel fora de rang perquè
// el primer frame en què el bus estigui connectat ja emeti un event. // el primer frame en què el bus estigui connectat ja emeti un event.
int last_tile_col_{-9999}; int last_tile_col_{-9999};
+8
View File
@@ -1,5 +1,6 @@
#include "game/map/map.hpp" #include "game/map/map.hpp"
#include <cmath>
#include <iostream> #include <iostream>
#include <stdexcept> #include <stdexcept>
@@ -150,6 +151,13 @@ namespace Map {
return true; return true;
} }
auto pixelToTile(float px, float py) -> std::pair<int, int> {
const auto TS = static_cast<float>(tile_size);
const int COL = static_cast<int>(std::floor((px - static_cast<float>(tilemap_origin_x)) / TS));
const int ROW = static_cast<int>(std::floor((py - static_cast<float>(tilemap_origin_y)) / TS));
return {COL, ROW};
}
auto makeInfo() -> Info { auto makeInfo() -> Info {
return { return {
.playfield_x = playfield_x, .playfield_x = playfield_x,
+12
View File
@@ -3,6 +3,7 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <string> #include <string>
#include <utility>
// Mapa d'una piràmide: sala rectangular amb marcador superior, muros pels // Mapa d'una piràmide: sala rectangular amb marcador superior, muros pels
// quatre costats i graella de sarcòfags envoltada per un passadís a cada // quatre costats i graella de sarcòfags envoltada per un passadís a cada
@@ -105,6 +106,17 @@ namespace Map {
// usa el `PaintLayer` per filtrar quines cel·les accepten pintat. // usa el `PaintLayer` per filtrar quines cel·les accepten pintat.
[[nodiscard]] auto isPaintable(int col, int row) -> bool; [[nodiscard]] auto isPaintable(int col, int row) -> bool;
// Converteix una coordenada píxel del canvas en (col, row) del
// tilemap. Assumeix `tile_size > 0`; si la coordenada cau fora del
// playfield, retorna el tile encara que sigui negatiu o estigui fora
// de rang — el caller valida si li cal. El càlcul és el típic
// `floor((p - origin) / tile_size)` que apareixia en diversos llocs;
// factoritzat aquí per coherència. Consumidors que tinguen un
// snapshot de `Map::Info` (p.ex. el Player, decoupling COUP-02) no
// han d'usar aquesta funció — han de seguir treballant amb el
// snapshot per no re-acoblar-se al namespace global.
[[nodiscard]] auto pixelToTile(float px, float py) -> std::pair<int, int>;
// Snapshot plà de la geometria del mapa + punters a `collides` / // Snapshot plà de la geometria del mapa + punters a `collides` /
// `insidePlayfield`. Pensat per ser passat per valor (POD petit, // `insidePlayfield`. Pensat per ser passat per valor (POD petit,
// trivialment copiable). Un cop construïda, llegeix els valors // trivialment copiable). Un cop construïda, llegeix els valors
+123
View File
@@ -0,0 +1,123 @@
#include "game/map/map_writer.hpp"
#ifdef _DEBUG
#include <cstddef>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
namespace MapWriter {
namespace {
// Cerca la línia que comença per `<key>:` a columna 0 (top-level)
// o, si `min_indent>0`, a una indentació mínima donada — això ens
// permet trobar tant `geometry:` com una clau interna del bloc.
auto findKeyLine(const std::string& content, const std::string& key, std::size_t start_pos, std::size_t min_indent) -> std::size_t {
const std::string PREFIX = key + ":";
std::size_t pos = start_pos;
while (pos < content.size()) {
// Compta la indentació de la línia actual.
std::size_t indent = 0;
while (pos + indent < content.size() && (content[pos + indent] == ' ' || content[pos + indent] == '\t')) {
++indent;
}
const bool INDENT_OK = (min_indent == 0) ? (indent == 0) : (indent >= min_indent);
if (INDENT_OK && pos + indent + PREFIX.size() <= content.size() && content.compare(pos + indent, PREFIX.size(), PREFIX) == 0) {
const std::size_t AFTER = pos + indent + PREFIX.size();
if (AFTER >= content.size() || content[AFTER] == '\n' || content[AFTER] == '\r' || content[AFTER] == ' ' || content[AFTER] == '\t' || content[AFTER] == '#') {
return pos + indent;
}
}
const auto NL = content.find('\n', pos);
if (NL == std::string::npos) { break; }
pos = NL + 1;
// Si la línia actual és de columna 0 i no és la que busquem,
// i estem dins d'un bloc indentat, sortim (no és part del bloc).
if (min_indent > 0 && indent == 0 && content.compare(pos - 1, 1, "\n") == 0) {
// No usem aquest break agressiu perquè línies en blanc
// (indent=0) no han de tallar la cerca dins del bloc.
// Tractades implícitament pel control superior.
}
}
return std::string::npos;
}
auto atomicWrite(const std::string& path, const std::string& content) -> bool {
const auto TMP_PATH = path + ".tmp";
{
std::ofstream out(TMP_PATH, std::ios::binary | std::ios::trunc);
if (!out) { return false; }
out.write(content.data(), static_cast<std::streamsize>(content.size()));
out.close();
if (!out) {
std::error_code ec;
std::filesystem::remove(TMP_PATH, ec);
return false;
}
}
std::error_code ec;
std::filesystem::rename(TMP_PATH, path, ec);
if (ec) {
std::filesystem::remove(TMP_PATH, ec);
return false;
}
return true;
}
} // namespace
auto saveDoorCol(const std::string& path, int door_col) -> bool {
std::ifstream in(path, std::ios::binary);
if (!in) { return false; }
std::ostringstream ss;
ss << in.rdbuf();
const std::string CONTENT = ss.str();
in.close();
// 1) Localitza el bloc `geometry:` a columna 0.
const auto GEO_POS = findKeyLine(CONTENT, "geometry", 0, 0);
if (GEO_POS == std::string::npos) { return false; }
const auto GEO_LINE_END = CONTENT.find('\n', GEO_POS);
if (GEO_LINE_END == std::string::npos) { return false; }
const std::size_t BLOCK_START = GEO_LINE_END + 1;
// 2) Dins del bloc, busca `door_col:` indentada (>=1 espai/tab).
const auto KEY_POS = findKeyLine(CONTENT, "door_col", BLOCK_START, 1);
const std::string NEW_LINE = " door_col: " + std::to_string(door_col);
if (KEY_POS != std::string::npos) {
// Substitueix la línia sencera (manté indentació original
// recalculant el start de la línia).
std::size_t line_start = KEY_POS;
while (line_start > 0 && CONTENT[line_start - 1] != '\n') { --line_start; }
const auto LINE_END = CONTENT.find('\n', KEY_POS);
const auto END = (LINE_END == std::string::npos) ? CONTENT.size() : LINE_END;
const std::string NEW_CONTENT =
CONTENT.substr(0, line_start) + NEW_LINE + CONTENT.substr(END);
return atomicWrite(path, NEW_CONTENT);
}
// Si manca, l'afegim al final del bloc geometry (just abans
// de la primera línia top-level que segueix). Trobem la fi
// del bloc avançant mentre les línies són indentades o buides.
std::size_t pos = BLOCK_START;
while (pos < CONTENT.size()) {
const char C = CONTENT[pos];
const bool INDENTED = (C == ' ' || C == '\t');
const bool BLANK = (C == '\n' || C == '\r');
if (!INDENTED && !BLANK) { break; }
const auto NL = CONTENT.find('\n', pos);
pos = (NL == std::string::npos) ? CONTENT.size() : NL + 1;
}
const std::string NEW_CONTENT =
CONTENT.substr(0, pos) + NEW_LINE + "\n" + CONTENT.substr(pos);
return atomicWrite(path, NEW_CONTENT);
}
} // namespace MapWriter
#endif // _DEBUG
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#ifdef _DEBUG
#include <string>
// Writer surgical de claus dins del bloc `geometry:` d'un YAML de nivell.
// Reescriu un escalar (p.ex. `door_col: N`) preservant byte a byte la
// resta del fitxer: comentaris, ordre de claus, format i indentació de
// la resta del `geometry:` i la resta de seccions.
//
// Pensat per al editor (Debug): quan el dissenyador mou la porta, només
// canvia `door_col`; tota la resta del YAML segueix igual.
//
// Si `geometry:` no existeix al fitxer, retorna false. Si la clau no
// existeix dins del bloc, l'afegeix al final del bloc, indentada com
// les altres entrades del mateix nivell.
//
// Escriptura atòmica via `<path>.tmp` + `std::filesystem::rename`.
namespace MapWriter {
auto saveDoorCol(const std::string& path, int door_col) -> bool;
} // namespace MapWriter
#endif // _DEBUG
+21
View File
@@ -12,6 +12,27 @@
#include "game/map/map.hpp" #include "game/map/map.hpp"
#include "game/map/tilemap.hpp" #include "game/map/tilemap.hpp"
// CONTRACTE BUS DE PINTAT
// -----------------------
// `on_tile_entered` — senyal FÍSICA: un jugador acaba d'entrar a un tile.
// La publica `Player::publishTileEnteredIfChanged`. La consumeix aquest
// `PaintLayer` per decidir si renderitzar el tile com a pintat (filtra
// per `Map::isPaintable` i aplica la regla "primer que pinta guanya").
//
// `tile_painted` — senyal LÒGICA: un tile compta com a pintat per a la
// lògica de joc (sarcòfags i, en el futur, HUD %completat, events
// derivats). La publica el `PaintLayer` quan confirma un pintat físic,
// i també la publica `EntityManager::loadFromLevel` com a broadcast
// inicial per als tiles ocupats per decals de sòl (Hole, etc.) — que
// el jugador mai podrà trepitjar perquè cauria al forat i els
// sarcòfags vecins necessiten que aquests tiles comptin com a pintats
// per completar el perímetre.
//
// REGLA: NO connectar `PaintLayer` al canal `tile_painted` per
// renderitzar. Si ho féssim, els broadcasts inicials de decals pintarien
// visualment els tiles d'agujero, incoherent amb la representació
// gràfica del decal.
namespace { namespace {
// Blit d'un tile del tileset sobre un buffer ARGB de destí, respectant // Blit d'un tile del tileset sobre un buffer ARGB de destí, respectant
+6
View File
@@ -808,6 +808,12 @@ namespace Options {
} }
auto buildActiveShader() -> Rendering::ShaderType { auto buildActiveShader() -> Rendering::ShaderType {
// Si el shader està deshabilitat, forcem POSTFX. El backend tracta
// POSTFX amb params a zero com a passthrough; CRTPI, en canvi, manté
// el seu pipeline encara amb params buits (no és passthrough), així
// que mai s'ha d'arribar al pipeline de CRTPI quan l'usuari ha apagat
// els shaders, ni des d'F4 ni des del menú de servei.
if (!g_video.shader.enabled) { return Rendering::ShaderType::POSTFX; }
if (g_video.shader.current_shader == "crtpi") { if (g_video.shader.current_shader == "crtpi") {
return Rendering::ShaderType::CRTPI; return Rendering::ShaderType::CRTPI;
} }
+96 -13
View File
@@ -38,6 +38,7 @@
#include "game/map/map.hpp" #include "game/map/map.hpp"
#include "game/options.hpp" #include "game/options.hpp"
#include "game/ui/hud.hpp" #include "game/ui/hud.hpp"
#include "game/ui/service_menu.hpp"
#include "utils/defaults.hpp" #include "utils/defaults.hpp"
#include "utils/defines.hpp" #include "utils/defines.hpp"
#include "utils/easing.hpp" #include "utils/easing.hpp"
@@ -407,6 +408,10 @@ GameScene::GameScene(Config config)
// el PaintLayer (subscrit ja) processa el pintat i el fan-out cap als // el PaintLayer (subscrit ja) processa el pintat i el fan-out cap als
// sarcòfags passa per `tile_painted`. // sarcòfags passa per `tile_painted`.
for (auto& p : players_) { p->setEventBus(&entity_manager_.getBus()); } for (auto& p : players_) { p->setEventBus(&entity_manager_.getBus()); }
// També injectem el manager perquè `integrateJump` pugui consultar
// `findFloorTriggerAt` a l'aterratge (caure dins d'un Hole en lloc
// del LANDING_STUN normal).
for (auto& p : players_) { p->setEntityManager(&entity_manager_); }
// Física del contacte entre jugadors. El factor viu al YAML del mapa // Física del contacte entre jugadors. El factor viu al YAML del mapa
// com a propietat de la piràmide (0.30 per defecte). La vista plana // com a propietat de la piràmide (0.30 per defecte). La vista plana
@@ -523,6 +528,12 @@ void GameScene::loadRoom(const std::string& yaml_path) {
tilemap_ = Tilemap(yaml_path); tilemap_ = Tilemap(yaml_path);
paint_layer_ = std::make_unique<PaintLayer>(tilemap_, &entity_manager_.getBus()); paint_layer_ = std::make_unique<PaintLayer>(tilemap_, &entity_manager_.getBus());
entity_manager_.reload(yaml_path); entity_manager_.reload(yaml_path);
// Refresquem la còpia local de Map::Info als players: la geometria
// pot haver canviat (p.ex. door_col del editor), i sense això
// `clampToPlayfield` continuaria usant el corridor antic i el
// jugador no podria sortir per la nova posició de la porta.
const auto INFO = Map::makeInfo();
for (auto& p : players_) { p->setMapInfo(INFO); }
// Tornem a fixar el comptador de "esperats" amb només els slots // Tornem a fixar el comptador de "esperats" amb només els slots
// actius — un slot DISABLED permanent (single-player) no compta. // 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_starts_active_, true);
@@ -602,6 +613,30 @@ void GameScene::enterRoomIntro() {
} }
} }
#ifdef _DEBUG
void GameScene::respawnPlayersInstant() {
// Variant sense cinemàtica de l'spawn de l'INTRO: per a l'editor
// volem que la sala torni a un estat jugable d'immediat (sense
// música de sala, sense caiguda aèria). Reusem `computeSpawn` i el
// 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 float SINGLE_X = SPAWN.p1_x + static_cast<float>(Map::tile_size);
for (std::size_t i = 0; i < players_.size(); ++i) {
if (!players_starts_active_[i]) { continue; }
float x = SINGLE_X;
if (BOTH_ACTIVE) { x = (i == 0) ? SPAWN.p1_x : SPAWN.p2_x; }
players_[i]->setSpawnPoint(x, SPAWN.y);
players_[i]->respawn(x, SPAWN.y);
}
state_ = RoomFsm::State::PLAYING;
intro_completed_ = true;
transition_ = {};
std::cout << "[ROOM] post-editor: spawn instantani, estat -> PLAYING\n";
}
#endif
void GameScene::triggerPlayerSpawn(std::size_t slot_index) { void GameScene::triggerPlayerSpawn(std::size_t slot_index) {
if (slot_index >= players_.size()) { return; } if (slot_index >= players_.size()) { return; }
if (!players_starts_active_[slot_index]) { return; } if (!players_starts_active_[slot_index]) { return; }
@@ -1116,7 +1151,17 @@ namespace {
void GameScene::update(int delta_ms) { void GameScene::update(int delta_ms) {
handlePauseInput(); handlePauseInput();
updatePauseTransition(delta_ms);
// Congelació instantània mentre el menú de servei està obert. A
// diferència de la pausa de gameplay (que és un dial amb easing i
// fade de música), aquesta és binària: el món no avança gens i la
// música no es toca. També posem en pausa el dial de la pausa
// regular si estava a mig camí, perquè no continui mentre l'usuari
// navega el menú.
const bool MENU_FROZEN = ServiceMenu::get().isOpen();
if (!MENU_FROZEN) {
updatePauseTransition(delta_ms);
}
// Transició de sala: viu en temps real (no escalada per la pausa) // Transició de sala: viu en temps real (no escalada per la pausa)
// perquè és UI. Avança avans del bloc de món perquè quan PLAYING // perquè és UI. Avança avans del bloc de món perquè quan PLAYING
@@ -1126,9 +1171,29 @@ void GameScene::update(int delta_ms) {
#ifdef _DEBUG #ifdef _DEBUG
// Toggle de l'editor: només si no hi ha cap transició de pausa ni // 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 // pausa consolidada. L'editor mateix no consumeix la tecla — la
// llegim ací per decidir la coexistència. // llegim ací per decidir la coexistència. Tant a l'entrada com a la
// sortida fem `loadRoom` complet per partir sempre del YAML del
// disc: les entitats tornen a la posició inicial (momies, sarcs),
// el paint layer es buida i els jugadors es desactiven. Així
// l'editor sempre veu l'estat-base de la sala (no el d'una partida
// a meitat) i el joc, en sortir, arrenca fresc amb les edicions ja
// persistides al YAML.
if (pause_.state == PauseState::IDLE && detectEditorToggle()) { if (pause_.state == PauseState::IDLE && detectEditorToggle()) {
editor_.toggle(tilemap_); const bool WAS_ACTIVE = editor_.active();
editor_.toggle(tilemap_, entity_manager_);
const bool NOW_ACTIVE = editor_.active();
if (!WAS_ACTIVE && NOW_ACTIVE) {
// Acabem d'entrar: carreguem fresh des del disc. Els
// jugadors es queden DISABLED (l'editor no els pinta de
// totes maneres) i es reactivaran amb el spawn instantani
// a la sortida.
loadRoom(Levels::LevelManager::get().currentPath());
} else if (WAS_ACTIVE && !NOW_ACTIVE) {
// Acabem de sortir: el writer ja ha persistit el YAML.
// Carreguem fresh i fem spawn instantani sense cinemàtica.
loadRoom(Levels::LevelManager::get().currentPath());
respawnPlayersInstant();
}
} }
// Salts debug entre sales (`[` / `]`). Mútuament excloents amb la // Salts debug entre sales (`[` / `]`). Mútuament excloents amb la
@@ -1168,7 +1233,7 @@ void GameScene::update(int delta_ms) {
constexpr bool EDITOR_FROZEN = false; constexpr bool EDITOR_FROZEN = false;
#endif #endif
const int SCALED_DELTA_MS = static_cast<int>(static_cast<float>(delta_ms) * pause_.scale); const int SCALED_DELTA_MS = static_cast<int>(static_cast<float>(delta_ms) * pause_.scale);
if (!EDITOR_FROZEN && SCALED_DELTA_MS > 0) { if (!EDITOR_FROZEN && !MENU_FROZEN && SCALED_DELTA_MS > 0) {
const float DT = static_cast<float>(SCALED_DELTA_MS) / 1000.0F; const float DT = static_cast<float>(SCALED_DELTA_MS) / 1000.0F;
// Pipeline de moviment en tres passes per poder intercalar la // Pipeline de moviment en tres passes per poder intercalar la
@@ -1204,7 +1269,7 @@ void GameScene::update(int delta_ms) {
} }
#ifdef _DEBUG #ifdef _DEBUG
editor_.update(delta_ms, tilemap_); editor_.update(delta_ms, tilemap_, entity_manager_);
#endif #endif
// El HUD continua avançant les seues animacions encara que el món // El HUD continua avançant les seues animacions encara que el món
@@ -1227,18 +1292,36 @@ void GameScene::render() {
tilemap_.render(); tilemap_.render();
#ifdef _DEBUG #ifdef _DEBUG
// En mode editor amaguem entitats i jugador: el tilemap queda net // En mode editor amaguem jugadors, sombres, paint layer, fades, HUD
// per a poder clicar cel·la per cel·la sense interferència visual. // i textos de transició: el tilemap queda net per editar i només es
// El HUD futur (quan s'afegeixi) també farà cas d'aquest flag. // pinten les entitats (congelades, sense tick) per que el dissenyador
const bool SKIP_ACTORS = editor_.active(); // veja on estan.
const bool EDITOR_ACTIVE = editor_.active();
#else #else
constexpr bool SKIP_ACTORS = false; constexpr bool EDITOR_ACTIVE = false;
#endif #endif
#ifdef _DEBUG
const bool SHOW_ENTITIES_IN_EDITOR = editor_.shouldShowEntities();
#else
constexpr bool SHOW_ENTITIES_IN_EDITOR = false;
#endif
if (SHOW_ENTITIES_IN_EDITOR) {
// Render de les entitats amb el mateix y-sort que el joc normal
// (sense jugadors): així una mòmia situada davant d'un sarc en
// falsa perspectiva queda dibuixada per damunt — i no a l'inrevés
// segons l'ordre del YAML. Reusem `EntityManager::renderEntity`
// (estàtic) i sortegem per `getFeetY()` ascendent.
auto sorted = entity_manager_.getEntities();
std::ranges::stable_sort(sorted, [](const Entity* a, const Entity* b) {
return a->getFeetY() < b->getFeetY();
});
for (const auto* e : sorted) { EntityManager::renderEntity(*e); }
}
// Capa de pintat: va entre el tilemap base i els actors, perquè els // Capa de pintat: va entre el tilemap base i els actors, perquè els
// sarcòfags i el jugador la tapen. S'amaga al mode editor (decisió // sarcòfags i el jugador la tapen. S'amaga al mode editor (decisió
// explícita: l'editor edita el tilemap, no la capa de pintat). // explícita: l'editor edita el tilemap, no la capa de pintat).
if (!SKIP_ACTORS && paint_layer_) { paint_layer_->render(); } if (!EDITOR_ACTIVE && paint_layer_) { paint_layer_->render(); }
if (!SKIP_ACTORS) { if (!EDITOR_ACTIVE) {
// Passada de sombres dels jugadors saltant. Va per davant del // Passada de sombres dels jugadors saltant. Va per davant del
// pintat (al damunt) però per darrere de TOTS els actors — // pintat (al damunt) però per darrere de TOTS els actors —
// entitats i jugadors, inclòs el saltador mateix— així la sombra // entitats i jugadors, inclòs el saltador mateix— així la sombra
@@ -1297,7 +1380,7 @@ void GameScene::render() {
} }
#ifdef _DEBUG #ifdef _DEBUG
editor_.render(tilemap_); editor_.render(tilemap_, entity_manager_);
#endif #endif
// Overlay de pausa: text amb stroke blanc (índex 255) centrat amb X // Overlay de pausa: text amb stroke blanc (índex 255) centrat amb X
+7
View File
@@ -288,6 +288,13 @@ class GameScene : public Scene {
// Idempotent quan ja s'estava a INTRO — útil per als salts debug. // Idempotent quan ja s'estava a INTRO — útil per als salts debug.
void enterRoomIntro(); void enterRoomIntro();
#ifdef _DEBUG
// Spawn instantani sense cinemàtica: usat al sortir del editor per
// fer un "fresh start" de la sala (jugadors al spawn, sense la
// cinemàtica `room_intro.ogg`). Posa l'estat directament a PLAYING.
void respawnPlayersInstant();
#endif
#ifdef _DEBUG #ifdef _DEBUG
// Editor modal del tilemap. Mútuament excloent amb la pausa: si la // Editor modal del tilemap. Mútuament excloent amb la pausa: si la
// pausa no està IDLE la tecla de toggle de l'editor s'ignora, i // pausa no està IDLE la tecla de toggle de l'editor s'ignora, i
+106 -16
View File
@@ -445,47 +445,127 @@ void ServiceMenu::activateCurrent(int direction) {
namespace { namespace {
// Resol el botó SERVICE configurat per al mando que ha emès aquest // Resol el botó configurat per a una acció concreta (SERVICE / ATTACK
// event. -1 si no troba cap match (mando desconegut o desconnectat // / JUMP) al mando que ha emès aquest event. -1 si no troba cap match
// mentrestant). // (mando desconegut o desconnectat mentrestant).
auto serviceButtonForEvent(SDL_JoystickID jid) -> int { auto actionButtonForEvent(SDL_JoystickID jid, GamepadBindings::Action action) -> int {
for (const auto& info : GamepadManager::get().getAll()) { for (const auto& info : GamepadManager::get().getAll()) {
if (info.pad == nullptr) { continue; } if (info.pad == nullptr) { continue; }
if (SDL_GetJoystickID(SDL_GetGamepadJoystick(info.pad)) != jid) { continue; } if (SDL_GetJoystickID(SDL_GetGamepadJoystick(info.pad)) != jid) { continue; }
return GamepadBindings::get().getButton(info.name, info.path, GamepadBindings::Action::SERVICE); return GamepadBindings::get().getButton(info.name, info.path, action);
} }
return -1; return -1;
} }
} // namespace } // namespace
void ServiceMenu::playSelectSound() {
Audio::get()->playSound(UiConfig::get().menuSelectSound(), Audio::Group::INTERFACE);
}
void ServiceMenu::playAcceptSound() {
Audio::get()->playSound(UiConfig::get().menuAcceptSound(), Audio::Group::INTERFACE);
}
void ServiceMenu::handleGamepadAxisMotion(const SDL_GamepadAxisEvent& ev) {
// Llindars amb histèresi: cal travessar STICK_THRESHOLD per "entrar" a
// un extrem, però només es torna a la zona neutra quan baixa de
// STICK_RELEASE. Així evitem que petits tremolors al voltant del
// llindar facin spam d'events. Valors triats per donar la mateixa
// sensació que el stick del jugador (deadzone=12000) però una mica
// més estrictes per a la navegació discreta del menú.
constexpr Sint16 STICK_THRESHOLD = 16000;
constexpr Sint16 STICK_RELEASE = 8000;
std::int8_t* zone = nullptr;
bool horizontal = false;
switch (ev.axis) {
case SDL_GAMEPAD_AXIS_LEFTX:
zone = &lx_zone_;
horizontal = true;
break;
case SDL_GAMEPAD_AXIS_LEFTY:
zone = &ly_zone_;
horizontal = false;
break;
case SDL_GAMEPAD_AXIS_RIGHTX:
zone = &rx_zone_;
horizontal = true;
break;
case SDL_GAMEPAD_AXIS_RIGHTY:
zone = &ry_zone_;
horizontal = false;
break;
default:
return; // Ignorem L2/R2 i la resta.
}
// Càlcul de la zona nova: si el valor està més enllà del llindar fort,
// entra a l'extrem; si està dins de la zona morta, torna a 0; altrament
// manté la zona prèvia (banda d'histèresi).
std::int8_t new_zone = *zone;
if (ev.value > STICK_THRESHOLD) {
new_zone = +1;
} else if (ev.value < -STICK_THRESHOLD) {
new_zone = -1;
} else if (std::abs(ev.value) < STICK_RELEASE) {
new_zone = 0;
}
if (new_zone == *zone) { return; }
*zone = new_zone;
if (new_zone == 0) { return; } // Soltar el stick no dispara res.
if (horizontal) {
activateCurrent(new_zone);
playAcceptSound();
} else {
moveCursor(new_zone);
playSelectSound();
}
}
void ServiceMenu::handleGamepadButtonDown(const SDL_GamepadButtonEvent& ev) { void ServiceMenu::handleGamepadButtonDown(const SDL_GamepadButtonEvent& ev) {
// El botó SERVICE (per defecte BACK; remapejable per dispositiu via // Tots tres botons rellevants (SERVICE/ATTACK/JUMP) són remapejables per
// `GamepadBindings`) tanca el menú sencer. Cada mando pot tenir el // dispositiu via `GamepadBindings`. Resolem-los una sola vegada a partir
// seu propi mapeig — resolem-lo a partir del joystick instance ID. // del joystick instance ID i comparem; els defaults (WEST=ATTACK,
const int SERVICE_BTN = serviceButtonForEvent(ev.which); // SOUTH=JUMP, BACK=SERVICE) preserven el comportament històric.
if (SERVICE_BTN >= 0 && static_cast<int>(ev.button) == SERVICE_BTN) { const int SERVICE_BTN = actionButtonForEvent(ev.which, GamepadBindings::Action::SERVICE);
const int ACCEPT_BTN = actionButtonForEvent(ev.which, GamepadBindings::Action::ATTACK);
const int CANCEL_BTN = actionButtonForEvent(ev.which, GamepadBindings::Action::JUMP);
const int BTN = static_cast<int>(ev.button);
if (SERVICE_BTN >= 0 && BTN == SERVICE_BTN) {
close(); close();
playSelectSound();
return; return;
} }
if (ACCEPT_BTN >= 0 && BTN == ACCEPT_BTN) {
activateCurrent(+1);
playAcceptSound();
return;
}
if (CANCEL_BTN >= 0 && BTN == CANCEL_BTN) {
popPage();
playSelectSound();
return;
}
switch (ev.button) { switch (ev.button) {
case SDL_GAMEPAD_BUTTON_DPAD_UP: case SDL_GAMEPAD_BUTTON_DPAD_UP:
moveCursor(-1); moveCursor(-1);
playSelectSound();
break; break;
case SDL_GAMEPAD_BUTTON_DPAD_DOWN: case SDL_GAMEPAD_BUTTON_DPAD_DOWN:
moveCursor(+1); moveCursor(+1);
playSelectSound();
break; break;
case SDL_GAMEPAD_BUTTON_DPAD_LEFT: case SDL_GAMEPAD_BUTTON_DPAD_LEFT:
activateCurrent(-1); activateCurrent(-1);
playAcceptSound();
break; break;
case SDL_GAMEPAD_BUTTON_DPAD_RIGHT: case SDL_GAMEPAD_BUTTON_DPAD_RIGHT:
case SDL_GAMEPAD_BUTTON_EAST:
case SDL_GAMEPAD_BUTTON_NORTH:
activateCurrent(+1); activateCurrent(+1);
break; playAcceptSound();
case SDL_GAMEPAD_BUTTON_WEST:
case SDL_GAMEPAD_BUTTON_SOUTH:
popPage();
break; break;
default: default:
break; break;
@@ -556,6 +636,11 @@ auto ServiceMenu::handleEvent(const SDL_Event& ev) -> bool {
return true; return true;
} }
if (ev.type == SDL_EVENT_GAMEPAD_AXIS_MOTION) {
handleGamepadAxisMotion(ev.gaxis);
return true;
}
if (ev.type != SDL_EVENT_KEY_DOWN) { return false; } if (ev.type != SDL_EVENT_KEY_DOWN) { return false; }
const auto SC = ev.key.scancode; const auto SC = ev.key.scancode;
@@ -567,21 +652,26 @@ auto ServiceMenu::handleEvent(const SDL_Event& ev) -> bool {
switch (SC) { switch (SC) {
case SDL_SCANCODE_UP: case SDL_SCANCODE_UP:
moveCursor(-1); moveCursor(-1);
playSelectSound();
break; break;
case SDL_SCANCODE_DOWN: case SDL_SCANCODE_DOWN:
moveCursor(+1); moveCursor(+1);
playSelectSound();
break; break;
case SDL_SCANCODE_LEFT: case SDL_SCANCODE_LEFT:
activateCurrent(-1); activateCurrent(-1);
playAcceptSound();
break; break;
case SDL_SCANCODE_RIGHT: case SDL_SCANCODE_RIGHT:
case SDL_SCANCODE_RETURN: case SDL_SCANCODE_RETURN:
case SDL_SCANCODE_KP_ENTER: case SDL_SCANCODE_KP_ENTER:
activateCurrent(+1); activateCurrent(+1);
playAcceptSound();
break; break;
case SDL_SCANCODE_BACKSPACE: case SDL_SCANCODE_BACKSPACE:
case SDL_SCANCODE_ESCAPE: case SDL_SCANCODE_ESCAPE:
popPage(); popPage();
playSelectSound();
break; break;
default: default:
break; break;
+20
View File
@@ -207,6 +207,18 @@ class ServiceMenu {
// mètode propi per a mantenir la complexitat cognitiva d'`handleEvent` // mètode propi per a mantenir la complexitat cognitiva d'`handleEvent`
// sota el llindar de clang-tidy. // sota el llindar de clang-tidy.
void handleGamepadButtonDown(const SDL_GamepadButtonEvent& ev); void handleGamepadButtonDown(const SDL_GamepadButtonEvent& ev);
// Navegació via sticks analògics. Treballa per edge-detection: cada
// eix recorda en quina "zona" està (negativa / neutra / positiva) i
// sols dispara una acció quan creua de neutra a un extrem, igual que
// un botó del DPAD. Aplicada tant al stick esquerre com al dret.
void handleGamepadAxisMotion(const SDL_GamepadAxisEvent& ev);
// Sons d'UI: SELECT acompanya navegació (amunt/avall) i back; ACCEPT
// acompanya modificació d'opció (esquerra/dreta/retorn). Els noms de
// recurs venen d'`UiConfig` i el preload el fa el Director.
static void playSelectSound();
static void playAcceptSound();
[[nodiscard]] auto top() -> Page&; [[nodiscard]] auto top() -> Page&;
[[nodiscard]] auto topConst() const -> const Page&; [[nodiscard]] auto topConst() const -> const Page&;
[[nodiscard]] static auto isItemVisible(const Item& item) -> bool; [[nodiscard]] static auto isItemVisible(const Item& item) -> bool;
@@ -264,6 +276,14 @@ class ServiceMenu {
float transition_progress_{0.0F}; // 0 → 1 durant la transició de pàgina. float transition_progress_{0.0F}; // 0 → 1 durant la transició de pàgina.
bool capturing_{false}; // True mentre esperem una tecla per a un KEY_BIND. bool capturing_{false}; // True mentre esperem una tecla per a un KEY_BIND.
// Estat per eix del stick per a edge-detection: -1, 0, +1 segons la
// zona on viu el valor actual. Un canvi de zona dispara una acció
// (només quan entra a un extrem); reset a 0 quan torna al centre.
std::int8_t lx_zone_{0};
std::int8_t ly_zone_{0};
std::int8_t rx_zone_{0};
std::int8_t ry_zone_{0};
// Sub-mòdul de captura de botons del mando. Quan està actiu absorbeix // Sub-mòdul de captura de botons del mando. Quan està actiu absorbeix
// tots els events de mando (BUTTON_DOWN/UP, AXIS_MOTION) i, en acabar, // tots els events de mando (BUTTON_DOWN/UP, AXIS_MOTION) i, en acabar,
// persisteix la nova configuració via `GamepadBindings`. Replica el // persisteix la nova configuració via `GamepadBindings`. Replica el
+32 -2
View File
@@ -62,9 +62,9 @@ namespace Defaults {
constexpr bool ENABLED = true; constexpr bool ENABLED = true;
constexpr float VOLUME = 1.0F; constexpr float VOLUME = 1.0F;
constexpr bool MUSIC_ENABLED = true; constexpr bool MUSIC_ENABLED = true;
constexpr float MUSIC_VOLUME = 0.8F; constexpr float MUSIC_VOLUME = 1.0F;
constexpr bool SOUND_ENABLED = true; constexpr bool SOUND_ENABLED = true;
constexpr float SOUND_VOLUME = 1.0F; constexpr float SOUND_VOLUME = 0.2F;
constexpr float VOLUME_STEP = 0.05F; // Pas estàndard per a la UI (5%). constexpr float VOLUME_STEP = 0.05F; // Pas estàndard per a la UI (5%).
constexpr int FREQUENCY = 48000; // Freqüència de mostreig del dispositiu (Hz). constexpr int FREQUENCY = 48000; // Freqüència de mostreig del dispositiu (Hz).
constexpr int CROSSFADE_MS = 1500; // Durada per defecte del crossfade entre pistes (ms). constexpr int CROSSFADE_MS = 1500; // Durada per defecte del crossfade entre pistes (ms).
@@ -168,6 +168,26 @@ namespace Defaults {
constexpr Uint8 NOTIFICATION_BG = 64; constexpr Uint8 NOTIFICATION_BG = 64;
constexpr int NOTIFICATION_TIME_MS = 4000; constexpr int NOTIFICATION_TIME_MS = 4000;
// Geometria del Notifier (en píxels del canvas). Asimètric a
// propòsit: PADDING_TOP > PADDING_BOTTOM puja una mica el text
// dins la caixa i deixa la base més arrapada al vora inferior.
// MARGIN_TOP és la separació entre la vora superior del canvas i
// la línia exterior de la caixa. SLIDE_MS és la durada de l'entrada
// i de l'eixida (independent de la mida de la caixa, perquè el
// moviment va per temps i no per velocitat). Les corbes són noms
// del registre d'`Easing` (veure UiConfig::curveByName).
namespace Notifier {
constexpr int PADDING_H = 6;
constexpr int PADDING_TOP = 4;
constexpr int PADDING_BOTTOM = 3;
constexpr int LINE_GAP = 1;
constexpr int MARGIN_TOP = 3;
constexpr int BORDER_PX = 1;
constexpr int SLIDE_MS = 400;
constexpr const char* SLIDE_IN_CURVE = "outCubic";
constexpr const char* SLIDE_OUT_CURVE = "inCubic";
} // namespace Notifier
// Preset per a la confirmació d'eixida (ESC doble). La durada ha // Preset per a la confirmació d'eixida (ESC doble). La durada ha
// de coincidir amb la del Notifier perquè el missatge i la finestra // de coincidir amb la del Notifier perquè el missatge i la finestra
// de confirmació expiren alhora (veure GlobalInputs::onExit). // de confirmació expiren alhora (veure GlobalInputs::onExit).
@@ -216,6 +236,12 @@ namespace Defaults {
constexpr int PAUSE_EXIT_MS = 250; constexpr int PAUSE_EXIT_MS = 250;
constexpr const char* PAUSE_ENTER_CURVE = "outCubic"; constexpr const char* PAUSE_ENTER_CURVE = "outCubic";
constexpr const char* PAUSE_EXIT_CURVE = "inCubic"; constexpr const char* PAUSE_EXIT_CURVE = "inCubic";
// Sons del menú de servei. SELECT acompanya navegació (amunt/avall)
// i back; ACCEPT acompanya modificació d'opció (esq/dret/retorn).
// Els fitxers viuen a `data/sounds/`; només es referencia el nom.
constexpr const char* MENU_SELECT_SOUND = "menu_select.wav";
constexpr const char* MENU_ACCEPT_SOUND = "menu_accept.wav";
} // namespace UI } // namespace UI
#ifdef _DEBUG #ifdef _DEBUG
@@ -228,6 +254,10 @@ namespace Defaults {
// les vores. Prou perquè el border blau de l'editor no es // les vores. Prou perquè el border blau de l'editor no es
// superpose amb la graella del picker. // superpose amb la graella del picker.
constexpr int PICKER_MARGIN = 8; constexpr int PICKER_MARGIN = 8;
// Píxels de separació entre tiles dins de la graella del picker.
// 1 px deixa veure clarament on acaba un tile i on comença l'altre,
// útil sobretot quan dos tiles veïns tenen colors molt similars.
constexpr int PICKER_SPACING = 1;
// Fons del TilePicker (blau fosc, prou contrastat amb els tiles // Fons del TilePicker (blau fosc, prou contrastat amb els tiles
// de color clar i clarament diferent del canvas). // de color clar i clarament diferent del canvas).
constexpr std::uint32_t PICKER_BG_ARGB = 0xFF101824U; constexpr std::uint32_t PICKER_BG_ARGB = 0xFF101824U;
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Texts { namespace Texts {
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner"; constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner";
constexpr const char* COPYRIGHT = "© 2026 JailDesigner"; constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
constexpr const char* VERSION = "0.30.0"; // Single source of truth — Makefile el grep constexpr const char* VERSION = "0.32.0"; // Single source of truth — Makefile el grep
} // namespace Texts } // namespace Texts
namespace GameCanvas { namespace GameCanvas {
+18
View File
@@ -0,0 +1,18 @@
// Stubs de Player per als binaris `test_entities_smoke` i `test_whip_attack`.
//
// `Hole` referencia `Player::getHitBox()` i `Player::enterHoleFall()` dins
// de `onPlayerContact` (camí no exercit pels tests stand-alone). Linkar
// `player.cpp` arrossegaria audio/gamepad/input/screen/cache, una explosió
// de dependències que els smokes intenten evitar — mateixa decisió que
// `whip_attack_player_stubs.cpp`. Aquests stubs proporcionen els símbols
// com a no-op perquè T1/T7 puguin fer `factory.create("hole", ...)`.
#include <SDL3/SDL.h>
#include "game/entities/player.hpp"
auto Player::getHitBox() const -> SDL_FRect {
return SDL_FRect{0.0F, 0.0F, 0.0F, 0.0F};
}
void Player::enterHoleFall(float /*center_x*/, float /*center_y*/, float /*floor_y*/) {
}
+3 -2
View File
@@ -44,6 +44,7 @@
#include "game/entities/animation_component.hpp" #include "game/entities/animation_component.hpp"
#include "game/entities/concrete/door.hpp" #include "game/entities/concrete/door.hpp"
#include "game/entities/concrete/floor_trap.hpp" #include "game/entities/concrete/floor_trap.hpp"
#include "game/entities/concrete/hole.hpp"
#include "game/entities/concrete/mummy.hpp" #include "game/entities/concrete/mummy.hpp"
#include "game/entities/concrete/rolling_rock.hpp" #include "game/entities/concrete/rolling_rock.hpp"
#include "game/entities/concrete/sarcophagus.hpp" #include "game/entities/concrete/sarcophagus.hpp"
@@ -74,7 +75,7 @@ namespace {
const std::vector<std::string> TYPES = { const std::vector<std::string> TYPES = {
"torch", "sarcophagus", "door", "mummy", "torch", "sarcophagus", "door", "mummy",
"rolling_rock", "wall_trap", "floor_trap", "rolling_rock", "wall_trap", "floor_trap", "hole",
}; };
for (const auto& type : TYPES) { for (const auto& type : TYPES) {
fkyaml::node tmpl = EntityLoader::loadTemplate(type); fkyaml::node tmpl = EntityLoader::loadTemplate(type);
@@ -251,7 +252,7 @@ namespace {
std::cout << "[T7] plantilles YAML\n"; std::cout << "[T7] plantilles YAML\n";
const std::vector<std::string> TYPES = { const std::vector<std::string> TYPES = {
"torch", "sarcophagus", "door", "mummy", "torch", "sarcophagus", "door", "mummy",
"rolling_rock", "wall_trap", "floor_trap", "rolling_rock", "wall_trap", "floor_trap", "hole",
}; };
for (const auto& type : TYPES) { for (const auto& type : TYPES) {
try { try {