Compare commits

...
84 Commits
Author SHA1 Message Date
JailDesigner 645db217fa Merge branch 'feat/game-states' — FSM de sala, EXITED, bucle complet 2026-04-26 10:51:45 +02:00
JailDesigner cd961cf718 chore: bump v0.24.0 — FSM de sala, EXITED i bucle complet 2026-04-26 10:51:40 +02:00
JailDesigner c0214b233b fix(game-scene): text d'INTRO/OUTRO surt cap a la dreta després del hold 2026-04-26 10:47:41 +02:00
JailDesigner d054d1b246 fix(player): respawn reseteja facing a DOWN i input/cardinal pendents 2026-04-26 10:47:13 +02:00
JailDesigner cd5bb94223 fix(player): EXITED no participa al resolver ni a checkPlayerCollisions 2026-04-26 10:41:05 +02:00
JailDesigner 178ce486c2 test(entity-manager): smoke del comptador player_exited i level_complete agregat 2026-04-26 10:32:20 +02:00
JailDesigner 811c6c482d feat(game-scene): spawn lligat a door_col i respawn a OUTRO->INTRO 2026-04-26 10:30:31 +02:00
JailDesigner 59cd9b524c feat(entity-manager): reload del nivell + PaintLayer::clear 2026-04-26 10:26:07 +02:00
JailDesigner 28e55a0615 feat(entity-manager): comptador player_exited agrega a level_complete 2026-04-26 10:22:42 +02:00
JailDesigner 5c5cd1d5da feat(player): estat EXITED i porta oberta despawna jugadors via player_exited 2026-04-26 10:20:13 +02:00
JailDesigner 377e5d0b97 feat(player): clamp permet pujar al passadís + porta tancada bloqueja via Map::collides 2026-04-26 10:09:04 +02:00
JailDesigner 648a5932f8 feat(door): col·locació derivada de Map::door_col i caixa de col·lisió neta 2026-04-26 10:07:37 +02:00
JailDesigner b54f3fc622 feat(map): door_col a la geometria + passadís de sortida a Map 2026-04-26 10:05:32 +02:00
JailDesigner 63bb41425a test(game-scene): extracció RoomFsm i smoke test de la FSM de sala 2026-04-26 09:10:54 +02:00
JailDesigner 9a2c677153 feat(locale): claus game.room_intro / game.room_complete (ca + en) 2026-04-26 09:03:37 +02:00
JailDesigner 4a9a037cd8 feat(game-scene): ROOM_OUTRO — text de sortida i fade out del món 2026-04-26 09:02:30 +02:00
JailDesigner 67b06c0a10 feat(game-scene): ROOM_INTRO — fade in del món i text d'entrada de sala 2026-04-26 09:00:15 +02:00
JailDesigner 368f9d2d6a feat(scenes): data/scenes/game.yaml amb timings i corbes de transició 2026-04-26 08:54:45 +02:00
JailDesigner 651db50a93 feat(game-scene): FSM mínima ROOM_INTRO/PLAYING/ROOM_OUTRO sense efectes 2026-04-26 08:51:56 +02:00
JailDesigner decf9f5d90 Merge branch 'feat/player-jump' — sistema complet de salt, HURT i polvet 2026-04-25 18:06:24 +02:00
JailDesigner f921f6bf2b chore: bump v0.23.0 — sistema de salt, HURT i polvet 2026-04-25 18:06:18 +02:00
JailDesigner abf52be7e0 tune(player+dust): ajustos finals de timings i física 2026-04-25 18:06:06 +02:00
JailDesigner 3e379ce129 feat(effects): polvet transitori al despegue i a l'aterratge 2026-04-25 17:51:55 +02:00
JailDesigner c38c80753a feat(player): direcció preferida al HURT segons l'offset d'impacte 2026-04-25 15:29:48 +02:00
JailDesigner 4667e0abf9 fix(player): saltar enmig d'un gir de 180° comprometeix la direcció final 2026-04-25 15:17:45 +02:00
JailDesigner 7f78f556f3 fix(player): el disparador del HURT en aterrar usa la hit-box, no la base-box 2026-04-25 15:01:56 +02:00
JailDesigner e8407ce35e feat(player): nou estat HURT — caure damunt d'un altre el llança projectat 2026-04-25 14:56:15 +02:00
JailDesigner 188f035333 feat(player): saltar travessa el caminant; rebot mid-aire entre dos saltadors 2026-04-25 14:20:38 +02:00
JailDesigner 3646e6a4aa feat(player): efectes de so configurables al despegue i a l'aterratge 2026-04-25 13:50:30 +02:00
JailDesigner f3f5fa5e92 feat(player): animacions stun_* durant l'aterratge 2026-04-25 13:48:03 +02:00
JailDesigner 3a750822f6 fix(player): la resolució del solapament en aterrar usa la base-box, no la hit-box 2026-04-25 12:23:21 +02:00
JailDesigner 06ff11d563 fix(player): el z-order del saltador respecta la seua posició al sòl 2026-04-25 12:21:27 +02:00
JailDesigner f0a0515f28 fix(player): puja la sombra pel valor jump.shadow_y_offset del YAML 2026-04-25 12:19:51 +02:00
JailDesigner 4758d5d8a4 refactor(player): mou height de jump a collision per coherència amb les entitats 2026-04-25 12:16:03 +02:00
JailDesigner 64390f8ad7 feat(player): sombra translúcida sota el jugador mentre està en l'aire 2026-04-25 11:59:13 +02:00
JailDesigner c4caa9021e feat(player): resol el solapament en aterrar damunt d'un altre jugador 2026-04-25 11:55:04 +02:00
JailDesigner a64f33fe43 feat(player): no pinta el sòl mentre està en l'aire; aterratge sí pinta 2026-04-25 11:52:25 +02:00
JailDesigner cd50626b88 feat(collisions): filtre per altura — saltar esquiva momies, roques i altres jugadors 2026-04-25 11:49:48 +02:00
JailDesigner 7b09a076b2 feat(player): salt vertical amb física, màquina d'estats i animació jump_* 2026-04-25 11:42:07 +02:00
JailDesigner 91614fc545 fix(input): mou gamecontrollerdb.txt a l'arrel i el copia al build/ via CMake 2026-04-25 10:36:55 +02:00
JailDesigner c77f90f6b6 feat(input): carrega gamecontrollerdb i corregeix l'ordre d'assignació de mandos 2026-04-25 10:32:09 +02:00
JailDesigner 4f1a3260a5 fix(player): snapToLane respecta col·lisió PvP i empeny amb push_factor 2026-04-24 22:38:36 +02:00
JailDesigner 1af43ef052 fix(player): llegeix sprite.size al loadFrom (frameWidth/Height ja no existeixen) 2026-04-24 22:05:21 +02:00
JailDesigner d5a73a7dad chore(tidy): elimina NOLINTs de naming obsolets a gif.cpp 2026-04-24 21:44:23 +02:00
JailDesigner 578916bc58 chore(tidy): amplia regles de naming per a constants, tipus i constexpr 2026-04-24 21:42:02 +02:00
JailDesigner ae8b9d8fe6 refactor(entities): fusiona momia i mummy en un sol tipus canònic 16×32 2026-04-24 21:02:02 +02:00
JailDesigner 50aae790b1 refactor(anim): migra AnimatedSprite al format sprite:/frame_ms, animacions en mapa 2026-04-24 21:01:21 +02:00
JailDesigner ea0f6d98a9 feat(player): inèrcia de frenada amb acumulador de moviment 2026-04-24 20:20:30 +02:00
JailDesigner 291cbdc957 Merge branch 'p2-support': suport del segon jugador 2026-04-24 19:30:32 +02:00
JailDesigner 858e444842 feat(player): hit-box 8×8 per a contacte actor-actor (player, momia, trampa) 2026-04-24 19:23:30 +02:00
JailDesigner 511f8d5b09 fix(player): el jugador empentat es queda en stand, no walk 2026-04-24 19:00:04 +02:00
JailDesigner e22f3240da feat(player): col·lisió i empenta entre jugadors amb push_factor al YAML del mapa 2026-04-24 18:54:19 +02:00
JailDesigner 1cc2e28b1b refactor(player): divideix update en tick+applyMovement i exposa velocitat intencionada 2026-04-24 18:49:52 +02:00
JailDesigner 820b9f2d21 feat(ui): bloc del jugador 2 al menú de teclat (només lectura) 2026-04-24 18:18:55 +02:00
JailDesigner f3977170b7 fix(ui): trunca el value al menú amb "..." quan se sol·lapa amb la label 2026-04-24 18:15:39 +02:00
JailDesigner 996ff0053e fix(input): dedup de mandos, auto-assign per ordre i cycles sense col·lisió 2026-04-24 18:06:24 +02:00
JailDesigner e5b8d7731b feat(ui): tria del mando per jugador al menú de servei 2026-04-24 15:17:14 +02:00
JailDesigner a725b76684 feat(input): assignació mando↔jugador persistida a config.yaml 2026-04-24 15:13:44 +02:00
JailDesigner 4e9d5df872 feat(players): suport del segon jugador amb events amb player_id 2026-04-24 14:33:52 +02:00
JailDesigner 74dad71cc2 feat(player): gir de 180° amb pose intermèdia configurable al YAML 2026-04-24 13:49:17 +02:00
JailDesigner 5c70019eb5 feat(game): pausa graduada amb easing i separador al HUD 2026-04-24 11:56:38 +02:00
JailDesigner 8aa4ec3daf build: aïlla la implementació de stb_vorbis en un TU propi
Mateix patró que stb_image_write: jail_audio.cpp defineix
STB_VORBIS_HEADER_ONLY i només veu les declaracions; les definicions
viuen a source/external/stb_vorbis_impl.cpp, ja exclòs del pre-commit.
Això permet retirar també la desactivació global de
clang-analyzer-core.UndefinedBinaryOperatorResult al .clang-tidy, i el
check queda actiu arreu del nostre propi codi.
2026-04-24 11:18:59 +02:00
JailDesigner 6c1c042637 build: aïlla la implementació de stb_image_write en un TU propi
screenshot.cpp ara només inclou les declaracions de stb_image_write.h;
la macro STB_IMAGE_WRITE_IMPLEMENTATION viu a un .cpp nou dins de
source/external/, que ja està exclòs del pre-commit hook (clang-format
i clang-tidy) i cobert pel seu .clang-tidy amb Checks: '-*'. Així
podem retirar la desactivació global de clang-analyzer-optin.portability
.UnixAPI i el check queda actiu arreu del nostre propi codi.
2026-04-24 11:15:32 +02:00
JailDesigner dc8a7723e3 feat(ui): captura F9, tema a data/ui.yaml i retocs estètics
Captura de pantalla amb F9: volca el canvas ARGB a
screenshots/scr_<timestamp>.png via stb_image_write.

Tot el tema d'UI (colors, timings, fonts, flag hud.bg_enabled)
passa a data/ui.yaml via UiConfig; els valors de Defaults::UI
queden com a fallbacks si el fitxer falta o li manca alguna clau.

Retocs estètics: paleta "uzebox" per a la UI, strings del HUD
(P1/P2/V/T/PIRAMIDE/SALA) traduïts a locale, i fons del marcador
opcional amb backdrop continu de franja per evitar el trencament
visual durant el shake o l'animació d'entrada dels panells.
2026-04-24 10:58:02 +02:00
JailDesigner 82b229222f feat(hud): marcador de la franja superior amb 3 blocs i animació d'entrada 2026-04-23 23:46:41 +02:00
JailDesigner 67ecb704d2 feat(pyramid): usa variants 16×32 sam (jugador) i momia 2026-04-23 17:33:47 +02:00
JailDesigner 936adf1405 Merge branch 'feat/paint-layer'
Fase 6: pintat de tiles amb PaintLayer com a font única del pintat.
Cadena on_tile_entered → tile_painted → sarcs. Feedback visual per
jugador via player{1,2}_paint_tile al YAML del mapa.
2026-04-23 13:42:49 +02:00
JailDesigner 0d914cc6dd docs(entities): documenta la fase 6 — pintat de tiles 2026-04-23 13:41:57 +02:00
JailDesigner 1117264758 feat(pyramid): declara player1_paint_tile i player2_paint_tile 2026-04-23 13:32:15 +02:00
JailDesigner d0503ff8b1 feat(game_scene): integra PaintLayer i llegeix paint_tile del YAML del mapa 2026-04-23 13:31:13 +02:00
JailDesigner e3cf2eb773 refactor(entities): rename notifyTileEntered → notifyTilePainted i escolta tile_painted 2026-04-23 13:22:02 +02:00
JailDesigner 0f56ce7567 feat(player): publica PaintedTile amb l'índex de pintat propi 2026-04-23 13:19:01 +02:00
JailDesigner 87b08a7c7c feat(map): afegeix PaintLayer com a font única del pintat 2026-04-23 13:17:47 +02:00
JailDesigner 00eb696859 feat(map): afegeix Map::isPaintable(col, row) 2026-04-23 13:15:08 +02:00
JailDesigner 48ff1839bc feat(event_bus): afegeix PaintedTile al payload 2026-04-23 13:13:47 +02:00
JailDesigner 01145464ed fix(editor): resol ruta via Resource::Helper per guardar amb CWD arbitrari 2026-04-23 12:43:23 +02:00
JailDesigner f30bb1fb0f feat(editor): TilePicker amb scroll vertical i selecció per clic 2026-04-23 12:18:22 +02:00
JailDesigner 05cdcfa660 feat(editor): brush amb preview snap-to-grid i pintura per clic 2026-04-23 12:11:54 +02:00
JailDesigner a612a8255f feat(editor): mode editor mínim amb toggle, border blau i mutex amb pausa 2026-04-23 12:09:04 +02:00
JailDesigner 329bf692e6 feat(editor): Screen::windowToCanvas() per a input de ratòli 2026-04-23 11:58:58 +02:00
JailDesigner 219f8033eb feat(editor): Tilemap editable amb setTileAt/save (Debug-only) 2026-04-23 11:56:06 +02:00
JailDesigner 6d8423764d feat(editor): writer surgical del bloc data: al YAML del tilemap 2026-04-23 11:51:49 +02:00
JailDesigner 459d18b49c feat(game): pausa amb F11/START i contorn NSEO al Text 2026-04-23 09:47:59 +02:00
JailDesignerandClaude Opus 4.7 243ba06351 refactor(tilemap): índexs directes al YAML en lloc de llegenda ASCII
`data` passa a ser una llista de files d'enters (tile index lineal),
sense la indirecció `legend:` + símbols. Desapareix el límit implícit
de ~60 tiles distints i el grid segueix llegint-se com a matriu visual
gràcies al padding de 3 dígits. `-1` marca cel·la buida.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 09:11:21 +02:00
101 changed files with 11136 additions and 713 deletions

No files matched your search

+69 -46
View File
@@ -8,12 +8,7 @@ Checks:
- -bugprone-integer-division
- -bugprone-easily-swappable-parameters
- -bugprone-narrowing-conversions
- -modernize-avoid-c-arrays,-warnings-as-errors
# Fals positiu recurrent en codi C de tercers (source/external/stb_vorbis.c).
# HeaderFilterRegex no aplica a clang-analyzer-*, així que cal desactivar
# explícitament. El check inferència sobre variables locals de legacy C
# genera reports que no reflecteixen bugs reals.
- -clang-analyzer-core.UndefinedBinaryOperatorResult
- -modernize-avoid-c-arrays
WarningsAsErrors: '*'
# Headers nostres (excloem source/external/ que conté dependències de tercers no editables)
@@ -24,61 +19,89 @@ CheckOptions:
# bugprone-empty-catch: aceptar catches vacíos marcados con @INTENTIONAL en un comentario
- { key: bugprone-empty-catch.IgnoreCatchWithKeywords, value: '@INTENTIONAL' }
# Variables locales en snake_case
# =====================================================================
# CONSTANTES → UPPER_CASE (compile-time y runtime, en cualquier scope)
# =====================================================================
# Todo lo que sea const o constexpr se identifica visualmente en UPPER_CASE,
# sin importar si es global, local, miembro o static.
# constexpr en cualquier scope (globales y locales)
- { key: readability-identifier-naming.ConstexprVariableCase, value: UPPER_CASE }
# Constantes globales (const no-constexpr)
- { key: readability-identifier-naming.GlobalConstantCase, value: UPPER_CASE }
# Constantes locales (const en función)
- { key: readability-identifier-naming.LocalConstantCase, value: UPPER_CASE }
# Static const a nivel de archivo/namespace
- { key: readability-identifier-naming.StaticConstantCase, value: UPPER_CASE }
# Miembros static const/constexpr de clase (p.ej. static constexpr int MAX = 100;)
- { key: readability-identifier-naming.ClassConstantCase, value: UPPER_CASE }
# Miembros const no-static de clase (p.ej. const int limit;)
- { key: readability-identifier-naming.ConstantMemberCase, value: UPPER_CASE }
# Valores de enums
- { key: readability-identifier-naming.EnumConstantCase, value: UPPER_CASE }
# NOTA: Los parámetros const NO se tratan como constantes aquí.
# Un parámetro sigue siendo un parámetro aunque sea const → hereda ParameterCase.
# =====================================================================
# VARIABLES NO-CONST
# =====================================================================
# Variables locales
- { key: readability-identifier-naming.VariableCase, value: lower_case }
- { key: readability-identifier-naming.LocalVariableCase, value: lower_case }
# Miembros privados en snake_case con sufijo _
- { key: readability-identifier-naming.PrivateMemberCase, value: lower_case }
- { key: readability-identifier-naming.PrivateMemberSuffix, value: _ }
# Parámetros de función
- { key: readability-identifier-naming.ParameterCase, value: lower_case }
# Miembros protegidos en snake_case con sufijo _
- { key: readability-identifier-naming.ProtectedMemberCase, value: lower_case }
- { key: readability-identifier-naming.ProtectedMemberSuffix, value: _ }
# Miembros públicos en snake_case (sin sufijo)
- { key: readability-identifier-naming.PublicMemberCase, value: lower_case }
# Namespaces en CamelCase
- { key: readability-identifier-naming.NamespaceCase, value: CamelCase }
# Variables estáticas privadas como miembros privados
# Variables estáticas no-const (static locales, static file-scope,
# y static members no-const de clase como el instance_ de un Singleton).
# Sufijo _ para marcar que tienen storage estático.
- { key: readability-identifier-naming.StaticVariableCase, value: lower_case }
- { key: readability-identifier-naming.StaticVariableSuffix, value: _ }
# Constantes estáticas sin sufijo
- { key: readability-identifier-naming.StaticConstantCase, value: UPPER_CASE }
# =====================================================================
# MIEMBROS DE CLASE NO-CONST
# =====================================================================
# Privados: snake_case con sufijo _
- { key: readability-identifier-naming.PrivateMemberCase, value: lower_case }
- { key: readability-identifier-naming.PrivateMemberSuffix, value: _ }
# Constantes globales en UPPER_CASE
- { key: readability-identifier-naming.GlobalConstantCase, value: UPPER_CASE }
# Protegidos: snake_case con sufijo _
- { key: readability-identifier-naming.ProtectedMemberCase, value: lower_case }
- { key: readability-identifier-naming.ProtectedMemberSuffix, value: _ }
# Variables constexpr globales en UPPER_CASE
- { key: readability-identifier-naming.ConstexprVariableCase, value: UPPER_CASE }
# Públicos: snake_case sin sufijo
- { key: readability-identifier-naming.PublicMemberCase, value: lower_case }
# Constantes locales en UPPER_CASE
- { key: readability-identifier-naming.LocalConstantCase, value: UPPER_CASE }
# Constexpr miembros en UPPER_CASE (sin sufijo)
- { key: readability-identifier-naming.ConstexprMemberCase, value: UPPER_CASE }
# Constexpr miembros privados/protegidos con sufijo _
- { key: readability-identifier-naming.ConstexprMethodCase, value: UPPER_CASE }
# Clases, structs y enums en CamelCase
# =====================================================================
# TIPOS
# =====================================================================
- { key: readability-identifier-naming.ClassCase, value: CamelCase }
- { key: readability-identifier-naming.StructCase, value: CamelCase }
- { key: readability-identifier-naming.EnumCase, value: CamelCase }
- { key: readability-identifier-naming.UnionCase, value: CamelCase }
- { key: readability-identifier-naming.TypeAliasCase, value: CamelCase }
- { key: readability-identifier-naming.TypedefCase, value: CamelCase }
- { key: readability-identifier-naming.TemplateParameterCase, value: CamelCase }
# Valores de enums en UPPER_CASE
- { key: readability-identifier-naming.EnumConstantCase, value: UPPER_CASE }
# Namespaces
- { key: readability-identifier-naming.NamespaceCase, value: CamelCase }
# Métodos en camelBack (sin sufijos)
# =====================================================================
# FUNCIONES Y MÉTODOS (incluyendo constexpr)
# =====================================================================
# Un método/función constexpr es un invocable, no una constante → camelBack.
- { key: readability-identifier-naming.FunctionCase, value: camelBack }
- { key: readability-identifier-naming.ConstexprFunctionCase, value: camelBack }
- { key: readability-identifier-naming.MethodCase, value: camelBack }
- { key: readability-identifier-naming.PrivateMethodCase, value: camelBack }
- { key: readability-identifier-naming.ProtectedMethodCase, value: camelBack }
- { key: readability-identifier-naming.PublicMethodCase, value: camelBack }
# Funciones en camelBack
- { key: readability-identifier-naming.FunctionCase, value: camelBack }
# Parámetros en lower_case
- { key: readability-identifier-naming.ParameterCase, value: lower_case }
- { key: readability-identifier-naming.ConstexprMethodCase, value: camelBack }
+72
View File
@@ -47,6 +47,7 @@ set(ENTITY_SOURCES
source/game/entities/concrete/rolling_rock.cpp
source/game/entities/concrete/wall_trap.cpp
source/game/entities/concrete/floor_trap.cpp
source/game/entities/concrete/dust.cpp
)
set(APP_SOURCES
@@ -56,6 +57,7 @@ set(APP_SOURCES
source/core/audio/audio_adapter.cpp
source/core/audio/jail_audio.cpp
source/core/locale/locale.cpp
source/core/ui/ui_config.cpp
source/core/input/key_config.cpp
source/core/input/global_inputs.cpp
source/core/input/input_state.cpp
@@ -66,6 +68,9 @@ set(APP_SOURCES
source/core/rendering/notifier.cpp
source/core/rendering/render_info.cpp
source/core/rendering/gif.cpp
source/core/rendering/screenshot.cpp
source/external/stb_image_write_impl.cpp
source/external/stb_vorbis_impl.cpp
source/core/rendering/surface.cpp
source/core/rendering/text.cpp
source/core/rendering/sprite/sprite.cpp
@@ -84,14 +89,22 @@ set(APP_SOURCES
source/game/options.cpp
source/game/options_apply.cpp
source/game/ui/service_menu.cpp
source/game/ui/hud.cpp
source/game/scenes/placeholder_input.cpp
source/game/scenes/logo_scene.cpp
source/game/scenes/title_scene.cpp
source/game/scenes/intro_scene.cpp
source/game/scenes/game_scene.cpp
source/game/scenes/room_fsm.cpp
source/game/entities/player.cpp
source/game/map/map.cpp
source/game/map/tilemap.cpp
source/game/map/tilemap_writer.cpp
source/game/map/paint_layer.cpp
# 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
# arrossega cap símbol del joc final.
source/game/editor/editor.cpp
${ENTITY_SOURCES}
# Fitxer només del binari principal: `loadFromLevel` depèn de
# Resource::Cache (→ Surface → GIF → àudio). Separat per no arrossegar
@@ -192,6 +205,26 @@ add_custom_command(
add_custom_target(resource_pack ALL DEPENDS ${RESOURCE_PACK})
add_dependencies(${PROJECT_NAME} resource_pack)
# --- CÒPIA DE gamecontrollerdb.txt AL COSTAT DEL BINARI ---
# SDL_AddGamepadMappingsFromFile només llegeix del filesystem real (no del
# pack), així que el fitxer ha de viure al directori del binari. Es copia
# només si existeix per no fallar la build d'algú que encara no ha fet
# `make controllerdb`.
if(EXISTS "${CMAKE_SOURCE_DIR}/gamecontrollerdb.txt")
set(CONTROLLER_DB "${CMAKE_BINARY_DIR}/gamecontrollerdb.txt")
add_custom_command(
OUTPUT ${CONTROLLER_DB}
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${CMAKE_SOURCE_DIR}/gamecontrollerdb.txt"
"${CONTROLLER_DB}"
DEPENDS "${CMAKE_SOURCE_DIR}/gamecontrollerdb.txt"
COMMENT "Copiant gamecontrollerdb.txt → build/"
VERBATIM
)
add_custom_target(controller_db ALL DEPENDS ${CONTROLLER_DB})
add_dependencies(${PROJECT_NAME} controller_db)
endif()
target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic)
target_compile_definitions(${PROJECT_NAME} PRIVATE
$<$<CONFIG:Debug>:_DEBUG>
@@ -327,4 +360,43 @@ if(BUILD_TESTS)
add_test(NAME scene_flow
COMMAND test_scene_flow
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
# Smoke del writer surgical del tilemap (escriu el bloc `data:` sense
# tocar la resta del fitxer). Pura manipulació de text, zero
# dependències: no cal ni SDL ni el Resource::Cache.
add_executable(test_tilemap_writer
tests/test_tilemap_writer.cpp
source/game/map/tilemap_writer.cpp
)
target_include_directories(test_tilemap_writer PRIVATE
"${CMAKE_SOURCE_DIR}/source"
)
target_compile_options(test_tilemap_writer PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME tilemap_writer
COMMAND test_tilemap_writer
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
# Smoke de la FSM de sala (RoomFsm). Cobreix loadConfig (timings i
# corbes des de YAML), advance (transicions entre estats), i
# formatRoomTitle (locale + substitució `{n}`). Stand-alone: no
# arrossega Audio/Screen/Player/EntityManager — només els helpers
# purs de la FSM, Locale i UiConfig.
add_executable(test_game_fsm
tests/test_game_fsm.cpp
source/game/scenes/room_fsm.cpp
source/core/locale/locale.cpp
source/core/ui/ui_config.cpp
source/core/resources/resource_helper.cpp
source/core/resources/resource_pack.cpp
source/utils/easing.cpp
)
target_include_directories(test_game_fsm PRIVATE
"${CMAKE_SOURCE_DIR}/source"
"${CMAKE_BINARY_DIR}"
)
target_link_libraries(test_game_fsm PRIVATE SDL3::SDL3)
target_compile_options(test_game_fsm PRIVATE -Wall -Wextra -Wpedantic)
add_test(NAME game_fsm
COMMAND test_game_fsm
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
endif()
+11 -1
View File
@@ -14,7 +14,7 @@ endif
CMAKE_DEFS := -DGIT_HASH=$(GIT_HASH)
.PHONY: all debug release run run-debug clean rebuild show-version pack format format-check tidy tidy-fix cppcheck hooks-install help
.PHONY: all debug release run run-debug clean rebuild show-version pack controllerdb format format-check tidy tidy-fix cppcheck hooks-install help
# ==============================================================================
# COMPILACIÓ
@@ -61,6 +61,15 @@ pack:
@cmake --build $(BUILDDIR) --target pack_resources
@./$(BUILDDIR)/pack_resources data $(BUILDDIR)/resources.pack
# Descàrrega de la base de dades de mappings de mandos. SDL només pot
# obrir-la des del filesystem (no del pack), per això viu a l'arrel del
# projecte i CMake la copia al costat del binari a cada build.
controllerdb:
@echo "Descarregant gamecontrollerdb.txt..."
@curl -fsSL https://raw.githubusercontent.com/mdqinc/SDL_GameControllerDB/master/gamecontrollerdb.txt \
-o gamecontrollerdb.txt
@echo "gamecontrollerdb.txt actualitzat"
# ==============================================================================
# CODE QUALITY (delegados a cmake)
# ==============================================================================
@@ -110,6 +119,7 @@ help:
@echo ""
@echo " Recursos:"
@echo " make pack - Empaquetar data/ a $(BUILDDIR)/resources.pack (format AE26)"
@echo " make controllerdb - Descarregar gamecontrollerdb.txt actualitzat (arrel)"
@echo ""
@echo " Qualitat de codi:"
@echo " make format - Formatar codi amb clang-format"
+12
View File
@@ -4,6 +4,18 @@ sprite:
path: door.gif
size: [48, 32]
tile_size: [3, 2]
collision:
# Caixa de col·lisió: 16×24 centrada al sprite 48×32. Coincideix amb el
# tile central transitable (els 16 px del passadís de sortida) i es
# restringeix a la franja del mur trasser (24 px d'alçada). Així
# `checkPlayerCollisions` només dispara `onPlayerContact` quan el
# jugador entra al forat real, no als marcs flanquejants ni als 8 px
# superiors que queden tapats pel HUD.
size: [16, 24]
offset: [16, 8]
# Porta = mur infranquejable. Igual que el sarc, alçada efectiva
# infinita per impedir que un jugador la salte per damunt.
height: 999
flags:
collides: true
interactable: true
Binary file not shown.

After

Width:  |  Height:  |  Size: 306 B

+22
View File
@@ -0,0 +1,22 @@
name: dust
type: effect
sprite:
path: dust.gif
size: [40, 16]
# El polvet és purament visual: no col·lisiona, no interactua, no fa
# dany. Només viu mentre dura l'animació triada (`jump` o `land`) i
# desapareix per `markDead` quan acaba.
flags:
collides: false
interactable: false
# Dues animacions, no-loop: una per a cada moment del salt. El spawner
# (GameScene, en resposta a events del bus) instancia la variant
# apropiada via `setVariant` abans del primer tick.
# jump: frames 0..6 (7 frames) → reproduït al despegue.
# land: frames 7..12 (6 frames) → reproduït a l'aterratge.
# fps: 10 = 100 ms per frame.
animations:
jump: { frames: [0, 1, 2, 3, 4, 5, 6], fps: 15, loop: false }
land: { frames: [7, 8, 9, 10, 11, 12], fps: 15, loop: false }
+5
View File
@@ -4,6 +4,11 @@ sprite:
path: floor_trap.gif
size: [16, 16]
tile_size: [1, 1]
collision:
# Trampa al sòl: alçada 0 → qualsevol salt l'esquiva. (Avui amb
# `collides: false` la trampa no entra al filtre de contacte, però
# declarem el camp per quan es cablege la detecció via àrea.)
height: 0
flags:
collides: false
interactable: false
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

+14 -9
View File
@@ -2,22 +2,27 @@ name: mummy
type: dynamic_hazard
sprite:
path: mummy.gif
size: [12, 24]
size: [16, 32]
tile_size: [1, 1]
# La momia usa falsa perspectiva: el sprite fa 12×24 (dos 12×12: cap i
# peus) però la caja de col·lisió amb el mapa és 16×16, concèntrica amb
# el rectangle-base 12×12 dels peus (igual que fa el jugador). Així la
# caixa s'estén 2 px a cada costat i 2 px per sota dels peus visuals,
# i amb `anchor: feet` s'alinea exactament amb el tile 16×16 on naix —
# la momia va pel centre del corredor i la caja ocupa la cel·la completa.
# Variant 16×32 de la momia: el sprite fa 16×32 (dos 16×16: cap i peus)
# i la caja de col·lisió amb el mapa és 16×16, coincident amb el
# rectangle-base 16×16 dels peus. Com que `size` i `base_size` són
# iguals, el padding és zero i la caja s'alinea exactament amb la
# silueta inferior del sprite; amb `anchor: feet` ocupa la cel·la
# 16×16 del corredor on naix.
anchor: feet
collision:
size: [16, 16]
base_size: [12, 12]
base_size: [16, 16]
# Alçada del cos en píxels per al filtre de col·lisió per salt: un
# 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,
# així que 24 deixa marge per esquivar amb un salt no perfecte.
height: 24
flags:
collides: true
interactable: false
# Strip de 12 frames en graella 12×1 (mateix esquema que el jugador):
# Strip de 12 frames en graella 4×3 (mateix esquema que el jugador):
# 0..3 → walk_down (cara avall)
# 4..7 → walk_up (cara amunt)
# 8..11 → walk (lateral; flip horitzontal segons el sentit)
@@ -4,6 +4,11 @@ sprite:
path: rolling_rock.gif
size: [16, 16]
tile_size: [1, 1]
collision:
# `size`/`base_size` no calen per a la roca (tot el sprite és cos),
# però sí declarem `height` per al filtre de salt: 16 px → fàcil
# d'esquivar amb un salt curt.
height: 16
flags:
collides: true
interactable: false
@@ -7,6 +7,13 @@ sprite:
# són "alçada" en falsa perspectiva, sense col·lisió. Perímetre ortogonal
# de 8 tiles (3 N + 3 S + 1 O + 1 E).
tile_size: [3, 1]
collision:
# Sarcòfag = obstacle massís: alçada efectiva infinita (999) per al
# filtre del salt. Un jugador no el pot saltar per damunt encara que
# el salt arribe al punt àlgid. La caixa de col·lisió real (logical
# 48×16) la calcula `placeSarcophagus` a partir de la geometria del
# mapa, no des d'aquest YAML.
height: 999
flags:
collides: true
interactable: true
+4
View File
@@ -4,6 +4,10 @@ sprite:
path: torch.gif
size: [16, 16]
tile_size: [1, 1]
collision:
# Decoratiu, no col·lisiona. Camp `height` declarat per coherència
# amb la resta d'entitats; no té efecte mentre `collides: false`.
height: 0
flags:
collides: false
interactable: false
+5
View File
@@ -4,6 +4,11 @@ sprite:
path: wall_trap.gif
size: [16, 16]
tile_size: [1, 1]
collision:
# Trampa de paret: ix d'una vora alta i no es pot saltar per damunt.
# 999 = infranquejable. Igual que floor_trap, el filtre només té efecte
# quan la detecció de contacte hi entre.
height: 999
flags:
collides: false
interactable: false
+14 -5
View File
@@ -29,15 +29,24 @@ keys:
- id: toggle_vsync
code: "F8"
desc: "Activa / desactiva VSync"
- id: menu_toggle
- id: screenshot
code: "F9"
desc: "Obri / tanca el menú de servei"
desc: "Captura de pantalla (PNG)"
- id: toggle_render_info
code: "F12"
code: "F10"
desc: "Cicle de l'overlay de debug: apagat / dalt / baix"
- id: toggle_render_time
- id: pause
code: "F11"
desc: "Mostra o amaga el temps de joc dins de l'overlay"
desc: "Pausa el joc (o botó START del mando)"
- id: menu_toggle
code: "F12"
desc: "Obri / tanca el menú de servei"
- id: editor_toggle
code: "9"
desc: "Entra/surt del mode editor (només Debug)"
- id: editor_picker
code: "T"
desc: "Obri/tanca el tilepicker dins de l'editor"
- id: exit
code: "Escape"
desc: "Eixir (confirmar amb segona pulsació)"
+25
View File
@@ -14,6 +14,7 @@ input:
shader_off: "Shaders desactivats"
vsync_on: "VSync activat"
vsync_off: "VSync desactivat"
screenshot: "Captura"
shader: "Shader"
preset: "Preset"
scaling_disabled: "Escalat sense"
@@ -22,6 +23,25 @@ input:
scaling_overscan: "Escalat overscan"
scaling_integer: "Escalat enter"
press_again_exit: "Prem una altra volta per eixir"
paused: "Pausa"
editor_on: "Editor ON"
editor_off: "Editor OFF"
editor_save_error: "Editor: error guardant el mapa"
hud:
p1: "P1"
p2: "P2"
lives: "Vides"
treasures: "Tres"
pyramid: "Piramide"
room: "Sala"
# Textos de la transició d'entrada/sortida de sala (FSM de GameScene).
# `room_intro` accepta el placeholder `{n}` que es substitueix amb
# l'índex de sala actual; veure `GameScene::formatRoomTitle`.
game:
room_intro: "Sala {n}"
room_complete: "Fase completada"
menu:
titles:
@@ -34,6 +54,7 @@ menu:
system: "Sistema"
labels:
player_1: "Jugador 1"
player_2: "Jugador 2"
gamepad_unavailable: "No disponible"
items:
video_submenu: "Vídeo"
@@ -46,6 +67,8 @@ menu:
move_down: "Avall"
move_left: "Esquerra"
move_right: "Dreta"
gamepad_p1: "Comandament P1"
gamepad_p2: "Comandament P2"
zoom: "Zoom"
screen: "Pantalla completa"
vsync: "VSync"
@@ -75,3 +98,5 @@ menu:
render_info_off: "Apagat"
render_info_top: "Dalt"
render_info_bottom: "Baix"
gamepad_none: "Cap"
gamepad_disconnected: "Desconnectat"
+20
View File
@@ -14,6 +14,7 @@ input:
shader_off: "Shaders off"
vsync_on: "VSync on"
vsync_off: "VSync off"
screenshot: "Screenshot"
shader: "Shader"
preset: "Preset"
scaling_disabled: "Scaling off"
@@ -22,6 +23,25 @@ input:
scaling_overscan: "Scaling overscan"
scaling_integer: "Scaling integer"
press_again_exit: "Press again to exit"
paused: "Paused"
editor_on: "Editor ON"
editor_off: "Editor OFF"
editor_save_error: "Editor: map save failed"
hud:
p1: "P1"
p2: "P2"
lives: "Lives"
treasures: "Tre"
pyramid: "Pyramid"
room: "Room"
# Room transition texts (GameScene FSM intro/outro). The `room_intro`
# string accepts a `{n}` placeholder that is replaced with the current
# room index; see `GameScene::formatRoomTitle`.
game:
room_intro: "Room {n}"
room_complete: "Stage complete"
menu:
titles:
+45 -41
View File
@@ -3,18 +3,30 @@
# en el tilemap però només 1.5 visibles al canvas, així que la textura es
# dibuixa amb un origen de x=-8). Vertical no cal overscan: 15·16 = 240.
#
# Símbols del grid:
# W = paret (tile 99 del tileset, o (3,3) en 0-indexed)
# F = sòl (llista = un tile aleatori entre aquests per cada casella)
# . = cap tile (els sarcòfags seran actors, ací deixem transparent)
#
# Índexs lineals: els tiles van d'esquerra a dreta, de dalt a baix. Amb un
# tileset de 512×256 i tiles de 16×16 hi ha 32 tiles per fila.
# Format del tilemap:
# `data` és una llista de files; cada fila és una llista d'índexs del
# tileset (lineal esq→dre, dalt→baix). Amb un tileset de 512×256 i tiles
# de 16×16 hi ha 32 tiles per fila. Un valor negatiu (p.ex. -1) deixa
# la cel·la buida (transparent), útil per caselles que ocuparà un actor.
tileset: data/tilesets/tomb.gif
tile_size: 16
origin: [-8, 0]
# Índex de tile amb què cada jugador deixa rastre en trepitjar un sòl
# pintable. Un índex per jugador perquè a multijugador local el recorregut
# es distingeix a simple vista. Provisional fins que l'art del pintat
# estigui definit; canviar aquí l'índex és tot el que cal. Omet el camp
# per desactivar el pintat per a aquest jugador.
player1_paint_tile: 0
player2_paint_tile: 4
# Física del contacte entre jugadors. Quan un jugador està quiet i l'altre
# el topa, el segon l'empenta a aquesta fracció de la velocitat normal (i
# ell mateix es ralentitza per mantenir el grup). En head-on, ambdós
# s'anul·len (sense push, sense moviment).
push_factor: 0.30
# Geometria lògica de la piràmide (separada del render visual del tileset).
# La graella de sarcòfags, el pitch entre ells i les amplades dels murs
# decideixen els límits del jugador i les seues caixes de col·lisió. Abans
@@ -30,39 +42,29 @@ geometry:
hud_h: 24
back_wall_h: 24
side_wall_w: 24
# Columna central del tile transitable de la porta. La porta es renderitza
# com a sprite 48×32 (3 tiles d'amplada) centrat sobre aquesta columna; els
# 16 px centrals (just aquesta columna) són el "forat" del mur trasser per
# on el jugador surt de la sala. Les 2 columnes flanquejants segueixen sent
# mur visible. El spawn dels jugadors també s'alinea amb aquesta columna.
door_col: 10
legend:
W: 292
X: 169
Y: 1
Z: 202
T: 200
R: 262
G: 263
H: 230
I: 231
J: 233
A: 2
B: 34
C: 33
D: 32
data: |
YYYYYYYYYYYYYYYYYYYYY
YHJJJJJJJJJJJJJJJJJIY
YZWWWWWWWWWWWWWWWWWTY
YZDDADDBBDDADDDDBDDTY
YZDAAADAAADAAABAAADTY
YZDDDCCDDDDDADDDBDDTY
YZDAAADAAAAAAADAAADTY
YZDDCCDDDDDADDBDDDDTY
YZDAAADAAADAAADAAADTY
YZDDDDCCDDDDADDDDDDTY
YZDAAADAAADAAADAAADTY
YZDDDABDDDDADCDDBDDTY
YZDAAADAAADAAADAAADTY
YZDDBBDDADDDBCDDDDDTY
YRXXXXXXXXXXXXXXXXXGY
data:
- [ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
- [ 1, 91,105,105,105,105,105,105,105,105,105,105,105,105,105,105,105,105,105, 92, 1]
- [ 1, 98,113,113, 95,113, 94,113,113,113,113,113,113,113, 94,113,102,113,113, 96, 1]
- [ 1, 98, 8, 8, 6, 8, 17, 8, 10, 8, 17, 8, 8, 8, 17, 8, 8, 8, 8, 96, 1]
- [ 1, 98, 8, 2, 2, 2, 8, 2, 2, 2, 8, 2, 2, 2, 8, 2, 2, 2, 8, 96, 1]
- [ 1, 98, 17, 8, 9, 2, 8, 10, 8, 8, 8, 8, 10, 8, 8, 26, 8, 8, 17, 96, 1]
- [ 1, 98, 8, 2, 2, 2, 2, 2, 2, 2, 8, 2, 2, 2, 8, 2, 2, 2, 8, 96, 1]
- [ 1, 98, 17, 8, 8, 26, 8, 8, 9, 8, 8, 8, 8, 8, 10, 8, 6, 8, 17, 96, 1]
- [ 1, 98, 8, 2, 2, 2, 8, 2, 2, 2, 8, 2, 2, 2, 8, 2, 2, 2, 8, 96, 1]
- [ 1, 98, 17, 8, 10, 8, 8, 8, 2, 8, 8, 8, 9, 8, 8, 8, 10, 8, 17, 96, 1]
- [ 1, 98, 8, 2, 2, 2, 8, 2, 2, 2, 6, 2, 2, 2, 8, 2, 2, 2, 8, 96, 1]
- [ 1, 98, 17, 8, 10, 8, 8, 10, 2, 8, 8, 26, 8, 8, 8, 8, 10, 8, 17, 96, 1]
- [ 1, 98, 8, 2, 2, 2, 8, 2, 2, 2, 8, 2, 2, 2, 8, 2, 2, 2, 8, 96, 1]
- [ 1, 98, 8, 10, 6, 6, 17, 8, 8, 8, 17, 8, 9, 8, 17, 8, 2, 8, 8, 96, 1]
- [ 1, 99, 89, 89, 89, 89, 89, 89, 89, 89, 89, 89, 89, 89, 89, 89, 89, 89, 89,100, 1]
# Spawns d'entitats per a aquest nivell. Es llegeixen des de
# EntityManager::loadFromLevel al GameScene. Convencions:
@@ -75,7 +77,7 @@ data: |
#
# Dos sarcòfags en cantonades oposades → perímetres aïllats (cap tile en
# comú). La porta va al centre inferior, on el tilemap té els tiles
# d'obertura (R/G). 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.
entities:
- type: torch
@@ -127,7 +129,9 @@ entities:
- type: sarcophagus
sarc_position: [3, 4]
- type: door
position: [9, 1]
# Sense `position`: la porta sempre viu al mur trasser. La columna
# transitable la dicta `geometry.door_col`; el render i la caixa
# de col·lisió es deriven d'aquesta dada al loadFromLevel.
- type: mummy
position: [10, 7]
- type: mummy
+10 -12
View File
@@ -2,6 +2,8 @@
# per a poder carregar Map sense errors, però el seu propòsit real són
# els spawns de sota: l'smoke verifica que EntityLoader::readSpawns els
# retorna correctament i que EntityManager pot instanciar-los i wire-ar-los.
#
# Format del tilemap: vegeu pyramid.yaml.
tileset: data/tilesets/tomb.gif
tile_size: 16
@@ -18,18 +20,14 @@ geometry:
back_wall_h: 24
side_wall_w: 24
legend:
Y: 1
X: 169
data: |
YYYYYYYYYYYYYY
YXXXXXXXXXXXXY
YXXXXXXXXXXXXY
YXXXXXXXXXXXXY
YXXXXXXXXXXXXY
YXXXXXXXXXXXXY
YYYYYYYYYYYYYY
data:
- [ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
- [ 1,169,169,169,169,169,169,169,169,169,169,169,169, 1]
- [ 1,169,169,169,169,169,169,169,169,169,169,169,169, 1]
- [ 1,169,169,169,169,169,169,169,169,169,169,169,169, 1]
- [ 1,169,169,169,169,169,169,169,169,169,169,169,169, 1]
- [ 1,169,169,169,169,169,169,169,169,169,169,169,169, 1]
- [ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
entities:
- type: torch
+259
View File
@@ -0,0 +1,259 @@
JASC-PAL
0100
256
0 0 0
36 0 0
72 0 0
109 0 0
145 0 0
182 0 0
218 0 0
255 0 0
0 36 0
36 36 0
72 36 0
109 36 0
145 36 0
182 36 0
218 36 0
255 36 0
0 72 0
36 72 0
72 72 0
109 72 0
145 72 0
182 72 0
218 72 0
255 72 0
0 109 0
36 109 0
72 109 0
109 109 0
145 109 0
182 109 0
218 109 0
255 109 0
0 145 0
36 145 0
72 145 0
109 145 0
145 145 0
182 145 0
218 145 0
255 145 0
0 182 0
36 182 0
72 182 0
109 182 0
145 182 0
182 182 0
218 182 0
255 182 0
0 218 0
36 218 0
72 218 0
109 218 0
145 218 0
182 218 0
218 218 0
255 218 0
0 255 0
36 255 0
72 255 0
109 255 0
145 255 0
182 255 0
218 255 0
255 255 0
0 0 85
36 0 85
72 0 85
109 0 85
145 0 85
182 0 85
218 0 85
255 0 85
0 36 85
36 36 85
72 36 85
109 36 85
145 36 85
182 36 85
218 36 85
255 36 85
0 72 85
36 72 85
72 72 85
109 72 85
145 72 85
182 72 85
218 72 85
255 72 85
0 109 85
36 109 85
72 109 85
109 109 85
145 109 85
182 109 85
218 109 85
255 109 85
0 145 85
36 145 85
72 145 85
109 145 85
145 145 85
182 145 85
218 145 85
255 145 85
0 182 85
36 182 85
72 182 85
109 182 85
145 182 85
182 182 85
218 182 85
255 182 85
0 218 85
36 218 85
72 218 85
109 218 85
145 218 85
182 218 85
218 218 85
255 218 85
0 255 85
36 255 85
72 255 85
109 255 85
145 255 85
182 255 85
218 255 85
255 255 85
0 0 170
36 0 170
72 0 170
109 0 170
145 0 170
182 0 170
218 0 170
255 0 170
0 36 170
36 36 170
72 36 170
109 36 170
145 36 170
182 36 170
218 36 170
255 36 170
0 72 170
36 72 170
72 72 170
109 72 170
145 72 170
182 72 170
218 72 170
255 72 170
0 109 170
36 109 170
72 109 170
109 109 170
145 109 170
182 109 170
218 109 170
255 109 170
0 145 170
36 145 170
72 145 170
109 145 170
145 145 170
182 145 170
218 145 170
255 145 170
0 182 170
36 182 170
72 182 170
109 182 170
145 182 170
182 182 170
218 182 170
255 182 170
0 218 170
36 218 170
72 218 170
109 218 170
145 218 170
182 218 170
218 218 170
255 218 170
0 255 170
36 255 170
72 255 170
109 255 170
145 255 170
182 255 170
218 255 170
255 255 170
0 0 255
36 0 255
72 0 255
109 0 255
145 0 255
182 0 255
218 0 255
255 0 255
0 36 255
36 36 255
72 36 255
109 36 255
145 36 255
182 36 255
218 36 255
255 36 255
0 72 255
36 72 255
72 72 255
109 72 255
145 72 255
182 72 255
218 72 255
255 72 255
0 109 255
36 109 255
72 109 255
109 109 255
145 109 255
182 109 255
218 109 255
255 109 255
0 145 255
36 145 255
72 145 255
109 145 255
145 145 255
182 145 255
218 145 255
255 145 255
0 182 255
36 182 255
72 182 255
109 182 255
145 182 255
182 182 255
218 182 255
255 182 255
0 218 255
36 218 255
72 218 255
109 218 255
145 218 255
182 218 255
218 218 255
255 218 255
0 255 255
36 255 255
72 255 255
109 255 255
145 255 255
182 255 255
218 255 255
255 255 255
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+129
View File
@@ -0,0 +1,129 @@
# player animation
sprite:
path: morcus.gif
size: [16, 32]
# Geometria de col·lisió del jugador (independent de l'animació):
# size → costat del quadrat de col·lisió (16×16)
# base_size → costat del quadrat visual inferior del sprite (els "peus");
# s'usa per centrar la caixa de col·lisió sobre ell.
collision:
size: 16
base_size: 16
# Caixa de contacte actor-actor (jugador↔jugador, jugador↔momia/trampa).
# Més petita que `size` perquè els contactes d'acció siguin precisos;
# `size` es reserva per a la col·lisió amb murs/sarcòfags i per enfilar
# els passadissos. Centrada en el mateix punt que la caixa base.
hit_size: 8
# Alçada del cos en píxels. Funció DOBLE segons amb què col·lisione:
# · Contra entitats (mummy, rolling_rock, sarc): si `jump_z > height`
# d'aquella entitat, no hi ha col·lisió. Igual que als YAMLs
# d'entitats.
# · Contra altres jugadors: la col·lisió és binària per estat aeri.
# Si un està en l'aire i l'altre no → cap col·lisió. Si tots dos
# estan en l'aire, llavors `body_height` decideix si els rangs
# verticals solapen i s'aplica el rebot mid-aire. `0` = sense cos
# (ni rebot quan tots dos volen).
height: 12
# Animacions en mapa (clau = nom). Camps per animació:
# frames → llista d'índexs dins de l'spritesheet.
# frame_ms → durada de cada frame en ms. Escalar = uniforme; llista =
# per-frame (mateixa mida que `frames`).
# loop → true per defecte. `loop: false` = un sol tret (es queda al
# darrer frame i `animationIsCompleted()` passa a true).
# loop_from → rebota a aquest índex en passar del final (implica loop).
animations:
stand_down: { frames: [0] }
stand_up: { frames: [4] }
stand: { frames: [8] }
jump_down: { frames: [12] }
jump_up: { frames: [13] }
jump: { frames: [14] }
stun_down: { frames: [16] }
stun_up: { frames: [17] }
stun: { frames: [18] }
walk_down:
frames: [0, 1, 2, 3]
frame_ms: 100
walk_up:
frames: [4, 5, 6, 7]
frame_ms: 100
walk:
frames: [8, 9, 10, 11]
frame_ms: 100
hurt_down:
frames: [0, 1, 2, 3]
frame_ms: 100
hurt_up:
frames: [4, 5, 6, 7]
frame_ms: 100
hurt:
frames: [8, 9, 10, 11]
frame_ms: 100
# Inèrcia només de frenada. L'acceleració és sempre instantània (soltar
# el teclat + prémer una direcció = velocitat plena); en canvi, quan es
# solta la direcció el jugador decau linealment a 0 en `brake_time_ms`.
# El `threshold_ms` filtra els tocs curts: si el temps acumulat de
# moviment continu no ha arribat al llindar, la parada és instantània
# (permet "passets" d'un tile sense rellisquera). L'acumulador es
# reseteja només quan no hi ha ni input ni velocitat residual, així
# els canvis de direcció enmig del coast no rearmen el llindar.
# `brake_time_ms: 0` o absència del bloc = inèrcia desactivada.
inertia:
brake_time_ms: 150
threshold_ms: 300
# Gir de 180°: mentre dura, el facing queda fixat a la direcció
# intermèdia (efecte purament visual; el moviment continua sense
# alterar-se). Els girs de 90° no passen per cap intermedi.
turn:
duration_ms: 80
# Direcció intermèdia per a cada gir de 180° (sentit horari).
transitions:
west_to_east: down # passa per baix (stand_down)
east_to_west: up # passa per dalt (stand_up)
north_to_south: east # passa per la dreta (stand, sense flip)
south_to_north: west # passa per l'esquerra (stand, amb flip)
# Salt vertical (px verticals "visuals", no afecta la posició al món).
# impulse → velocitat vertical inicial (px/s) en deixar el sòl.
# gravity → acceleració descendent constant (px/s²).
# landing_stun_ms → temps en què el jugador queda paralitzat en aterrar
# (no es pot moure ni tornar a saltar).
# shadow_y_offset → desplaçament vertical de la sombra (px, positiu =
# puja). Compensa la franja transparent que els
# sprites del jugador tenen sota els peus reals
# perquè camine "pel mig del passadís" (3 px aquí).
# sound → fitxer .wav reproduït al despegue (relatiu a
# data/sounds/). Buit o absent = sense so.
# land_sound → fitxer .wav reproduït en aterrar.
# L'alçada del cos viu al bloc `collision:` (`height`), igual que la resta
# d'entitats: és una propietat de col·lisió, no de la física del salt.
# Amb impulse=240 i gravity=600: pic ≈ 48 px, temps total en l'aire ≈ 0.8 s.
jump:
impulse: 200
gravity: 600
landing_stun_ms: 400
shadow_y_offset: 3
sound: jump.wav
land_sound: land.wav
# Estat HURT: el jugador queda projectat sense control per un impacte
# (algú que cau al damunt; en el futur també fuetada de l'altre jugador).
# `enterHurt` tria automàticament la direcció amb més recorregut lliure
# entre els 4 cardinals, posa la velocitat inicial a `speed` i la fa
# decaure linealment fins a 0 en `brake_time_ms`. Si pel camí xoca amb
# un mur, para en sec. En tornar a velocitat 0, recupera l'estat
# GROUNDED i el control. Mentre dura, no col·lisiona amb altres jugadors.
# Amb speed=120 i brake_time=500ms ⇒ recorregut ≈ 30 px (≈2 tiles).
hurt:
speed: 120
brake_time_ms: 500
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

-37
View File
@@ -1,37 +0,0 @@
# player animation
tileSetFile: player.gif
frameWidth: 12
frameHeight: 24
# Geometria de col·lisió del jugador (independent de l'animació):
# size → costat del quadrat de col·lisió (16×16)
# base_size → costat del quadrat visual inferior del sprite (els "peus");
# s'usa per centrar la caixa de col·lisió sobre ell.
collision:
size: 16
base_size: 12
animations:
- name: stand_down
frames: [0]
- name: stand_up
frames: [4]
- name: stand
frames: [8]
- name: walk_down
speed: 0.07
loopFrom: 0
frames: [0, 1, 2, 3]
- name: walk_up
speed: 0.07
loopFrom: 0
frames: [4, 5, 6, 7]
- name: walk
speed: 0.07
loopFrom: 0
frames: [8, 9, 10, 11]
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+129
View File
@@ -0,0 +1,129 @@
# player animation
sprite:
path: sam.gif
size: [16, 32]
# Geometria de col·lisió del jugador (independent de l'animació):
# size → costat del quadrat de col·lisió (16×16)
# base_size → costat del quadrat visual inferior del sprite (els "peus");
# s'usa per centrar la caixa de col·lisió sobre ell.
collision:
size: 16
base_size: 16
# Caixa de contacte actor-actor (jugador↔jugador, jugador↔momia/trampa).
# Més petita que `size` perquè els contactes d'acció siguin precisos;
# `size` es reserva per a la col·lisió amb murs/sarcòfags i per enfilar
# els passadissos. Centrada en el mateix punt que la caixa base.
hit_size: 8
# Alçada del cos en píxels. Funció DOBLE segons amb què col·lisione:
# · Contra entitats (mummy, rolling_rock, sarc): si `jump_z > height`
# d'aquella entitat, no hi ha col·lisió. Igual que als YAMLs
# d'entitats.
# · Contra altres jugadors: la col·lisió és binària per estat aeri.
# Si un està en l'aire i l'altre no → cap col·lisió. Si tots dos
# estan en l'aire, llavors `body_height` decideix si els rangs
# verticals solapen i s'aplica el rebot mid-aire. `0` = sense cos
# (ni rebot quan tots dos volen).
height: 12
# Animacions en mapa (clau = nom). Camps per animació:
# frames → llista d'índexs dins de l'spritesheet.
# frame_ms → durada de cada frame en ms. Escalar = uniforme; llista =
# per-frame (mateixa mida que `frames`).
# loop → true per defecte. `loop: false` = un sol tret (es queda al
# darrer frame i `animationIsCompleted()` passa a true).
# loop_from → rebota a aquest índex en passar del final (implica loop).
animations:
stand_down: { frames: [1] }
stand_up: { frames: [5] }
stand: { frames: [9] }
jump_down: { frames: [12] }
jump_up: { frames: [13] }
jump: { frames: [14] }
stun_down: { frames: [16] }
stun_up: { frames: [17] }
stun: { frames: [18] }
walk_down:
frames: [0, 1, 2, 3]
frame_ms: 100
walk_up:
frames: [4, 5, 6, 7]
frame_ms: 100
walk:
frames: [8, 9, 10, 11]
frame_ms: 100
hurt_down:
frames: [0, 1, 2, 3]
frame_ms: 100
hurt_up:
frames: [4, 5, 6, 7]
frame_ms: 100
hurt:
frames: [8, 9, 10, 11]
frame_ms: 100
# Inèrcia només de frenada. L'acceleració és sempre instantània (soltar
# el teclat + prémer una direcció = velocitat plena); en canvi, quan es
# solta la direcció el jugador decau linealment a 0 en `brake_time_ms`.
# El `threshold_ms` filtra els tocs curts: si el temps acumulat de
# moviment continu no ha arribat al llindar, la parada és instantània
# (permet "passets" d'un tile sense rellisquera). L'acumulador es
# reseteja només quan no hi ha ni input ni velocitat residual, així
# els canvis de direcció enmig del coast no rearmen el llindar.
# `brake_time_ms: 0` o absència del bloc = inèrcia desactivada.
inertia:
brake_time_ms: 150
threshold_ms: 300
# Gir de 180°: mentre dura, el facing queda fixat a la direcció
# intermèdia (efecte purament visual; el moviment continua sense
# alterar-se). Els girs de 90° no passen per cap intermedi.
turn:
duration_ms: 80
# Direcció intermèdia per a cada gir de 180° (sentit horari).
transitions:
west_to_east: down # passa per baix (stand_down)
east_to_west: up # passa per dalt (stand_up)
north_to_south: east # passa per la dreta (stand, sense flip)
south_to_north: west # passa per l'esquerra (stand, amb flip)
# Salt vertical (px verticals "visuals", no afecta la posició al món).
# impulse → velocitat vertical inicial (px/s) en deixar el sòl.
# gravity → acceleració descendent constant (px/s²).
# landing_stun_ms → temps en què el jugador queda paralitzat en aterrar
# (no es pot moure ni tornar a saltar).
# shadow_y_offset → desplaçament vertical de la sombra (px, positiu =
# puja). Compensa la franja transparent que els
# sprites del jugador tenen sota els peus reals
# perquè camine "pel mig del passadís" (3 px aquí).
# sound → fitxer .wav reproduït al despegue (relatiu a
# data/sounds/). Buit o absent = sense so.
# land_sound → fitxer .wav reproduït en aterrar.
# L'alçada del cos viu al bloc `collision:` (`height`), igual que la resta
# d'entitats: és una propietat de col·lisió, no de la física del salt.
# Amb impulse=240 i gravity=600: pic ≈ 48 px, temps total en l'aire ≈ 0.8 s.
jump:
impulse: 200
gravity: 600
landing_stun_ms: 400
shadow_y_offset: 3
sound: jump.wav
land_sound: land.wav
# Estat HURT: el jugador queda projectat sense control per un impacte
# (algú que cau al damunt; en el futur també fuetada de l'altre jugador).
# `enterHurt` tria automàticament la direcció amb més recorregut lliure
# entre els 4 cardinals, posa la velocitat inicial a `speed` i la fa
# decaure linealment fins a 0 en `brake_time_ms`. Si pel camí xoca amb
# un mur, para en sec. En tornar a velocitat 0, recupera l'estat
# GROUNDED i el control. Mentre dura, no col·lisiona amb altres jugadors.
# Amb speed=120 i brake_time=500ms ⇒ recorregut ≈ 30 px (≈2 tiles).
hurt:
speed: 120
brake_time_ms: 500
+24
View File
@@ -0,0 +1,24 @@
# Configuració de la GameScene: timings i corbes de la FSM de sala
# (ROOM_INTRO i ROOM_OUTRO). Tots els valors són ms i tota la geometria
# i font del text es reutilitzen del tema UI (data/ui.yaml > pause +
# notification). Si manca el fitxer o alguna clau, GameScene cau als
# fallbacks compilats; veure `readSceneConfig` a game_scene.cpp.
room_intro:
fade_in_ms: 500 # negre opac → món visible
text_delay_ms: 500 # espera amb el món net abans de sortir el text
text_enter_ms: 500 # text esquerra → centre
text_hold_ms: 1000 # text quiet al centre
text_exit_ms: 500 # text centre → fora per l'esquerra
room_outro:
text_enter_ms: 500
text_hold_ms: 1000
text_exit_ms: 500
fade_out_ms: 500 # món visible → negre opac
text:
# Noms d'easing tal com els resol UiConfig::curveByName (vegeu
# `data/ui.yaml`). El text entra suau i surt accelerant.
enter_curve: outCubic
exit_curve: inCubic
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

+65
View File
@@ -0,0 +1,65 @@
# Tema de la UI: índexs de paleta, timings, fonts i flags visuals. Tota
# clau absent cau al valor compilat a Defaults::UI (veure defaults.hpp).
colors:
render_info_text: 58
render_info_shadow: 16
canvas_bg: 72
notification_text: 232
notification_bg: 64
exit_text: 71
exit_bg: 2
menu_bg: 2
menu_border: 232
menu_title: 237
menu_label: 58
menu_value: 232
menu_cursor: 71
menu_disabled: 16
hud_bg: 9
hud_label: 58
hud_value: 187
hud_flash: 237
timings:
notification_ms: 4000
exit_ms: 4000
fonts:
hud:
name: "8bithud"
gif: "data/fonts/8bithud.gif"
fnt: "data/fonts/8bithud.fnt"
kerning: 1
placeholder:
name: "ari-w9500-condensed-display"
gif: "data/fonts/ari-w9500-condensed-display.gif"
fnt: "data/fonts/ari-w9500-condensed-display.fnt"
kerning: 1
menu_title:
name: "ari-w9500-display"
gif: "data/fonts/ari-w9500-display.gif"
fnt: "data/fonts/ari-w9500-display.fnt"
kerning: 0
menu_items:
name: "ari-w9500-condensed-display"
gif: "data/fonts/ari-w9500-condensed-display.gif"
fnt: "data/fonts/ari-w9500-condensed-display.fnt"
kerning: 0
hud:
bg_enabled: true
entry_ms: 800 # durada de l'animació d'entrada dels tres blocs
flash_ms: 250 # durada del flash quan un jugador rep un hit
shake_ms: 300 # durada del shake que acompanya el flash
# 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
# afinar independentment, i les corbes permeten estils diferents entrant
# i sortint (p.ex. frenar suau i arrancar seca). Corbes disponibles:
# linear, outQuad, inQuad, inOutQuad, outCubic, inCubic.
pause:
enter_ms: 400
exit_ms: 250
enter_curve: outCubic
exit_curve: inCubic
+67 -3
View File
@@ -276,10 +276,74 @@ Canvi a [CMakeLists.txt](../CMakeLists.txt): la còpia de `data/` al build era u
- `Mummy::step` partit: `snapToPlayfieldEdge`, `collectDirectionPools`, `applyChoice` — per mantenir la complexitat cognitiva sota el llindar de clang-tidy (< 25).
## 8. Estat global
## 8. Fase 6 — Pintat de tiles (DONE)
- **Andamiatge, integració, render animat, falsa perspectiva, moviment, animació direccional de momia**: DONE. El `pyramid.yaml` actual (4 torches, 20 sarcs, 1 door, 2 mummies) es carrega, anima, col·loca amb l'anchor correcte, col·lisiona segons caixa lògica, el z-order interleava per peus, i la momia gira visualment a cada intersecció.
- **Pendents majors**: ENT-HAZ-01 (sistema de vides), ENT-DOOR-01 (transició d'escena), ENT-IA-02 (bias IA), PER-01 (perímetre amb blocked_predicate quan els sarcs siguin veïns).
Introdueix la mecànica central: el jugador deixa rastre al terra en trepitjar-lo, i els sarcòfags compten tiles **pintats** (no tiles *trepitjats*). Font única de veritat del pintat: una nova capa `PaintLayer` escolta el moviment del Player, filtra pintabilitat i re-publica `tile_painted`, que és el que ara observen els sarcs.
### Cadena d'events resultant
```
Player canvi de tile
→ bus: on_tile_entered { col, row, paint_tile_index }
→ PaintLayer: filtra Map::isPaintable + "primer pinta guanya"
→ buffer ARGB + bus: tile_painted { col, row }
→ EntityManager fan-out → Sarcophagus::notifyTilePainted
→ perímetre complet → OPENING → OPEN → sarcophagus_opened
→ tots oberts → Door::open
```
Dos events encadenats perquè el *pintat* no és la *presència*: es filtra per pintabilitat i per "ja pintat", i el re-publish garanteix que els sarcs no vegin falsos positius (p. ex. el jugador lliscant contra un mur, o un tile ja marcat per l'altre jugador).
### Canvis per fitxer
| Path | Canvi |
|---|---|
| [event_bus.hpp](../source/game/entities/event_bus.hpp) | Nou `struct PaintedTile { col, row, tile_index }` al `Payload` variant + overload `publish`. |
| [map.hpp](../source/game/map/map.hpp) / [.cpp](../source/game/map/map.cpp) | `Map::isPaintable(col, row)` → `true` si el tile cau dins del playfield i no s'encavalca cap sarc. |
| [map/paint_layer.hpp](../source/game/map/paint_layer.hpp) / [.cpp](../source/game/map/paint_layer.cpp) | Classe nova. Subscriu `on_tile_entered`, marca `painted_[row][col]`, blita el tile al buffer ARGB propi, publica `tile_painted`. `render()` anàleg a `Tilemap::render`. |
| [player.hpp](../source/game/entities/player.hpp) / [.cpp](../source/game/entities/player.cpp) | Camp `paint_tile_index_` al ctor (default -1). El Player publica `PaintedTile` amb el seu índex en lloc de `TilePos`. |
| [entity.hpp](../source/game/entities/entity.hpp) / [.cpp](../source/game/entities/entity.cpp) | Rename `notifyTileEntered` → `notifyTilePainted`. |
| [concrete/sarcophagus.hpp](../source/game/entities/concrete/sarcophagus.hpp) / [.cpp](../source/game/entities/concrete/sarcophagus.cpp) | Mateix rename. La lògica del `visited_` és idèntica — només canvia qui li truca el hook. |
| [entity_manager.hpp](../source/game/entities/entity_manager.hpp) / [.cpp](../source/game/entities/entity_manager.cpp) | La subscripció canvia d'`on_tile_entered` → `tile_painted`. Fan-out renombrat a `notifyPlayerTilePainted`. |
| [game_scene.hpp](../source/game/scenes/game_scene.hpp) / [.cpp](../source/game/scenes/game_scene.cpp) | Membre `std::unique_ptr<PaintLayer> paint_layer_` (declarat després d'`entity_manager_` per garantir ordre invers de destrucció). Helper local `readOptionalInt` llegeix `player1_paint_tile` del YAML del mapa i el passa al Player. `render()` crida `paint_layer_->render()` entre `tilemap_.render()` i els actors; s'amaga al mode editor. |
| [data/maps/pyramid.yaml](../data/maps/pyramid.yaml) | Camps nous `player1_paint_tile: 2` i `player2_paint_tile: 2` (índex provisional, canviable sense recompilar). |
| [tests/test_entities_smoke.cpp](../tests/test_entities_smoke.cpp) | T9 adaptat: publica `tile_painted` directament al bus per aïllar el fan-out d'EntityManager de la resta de la cadena. |
### Decisions de disseny
- **Centralitzat vs per-sarc**: l'estat "pintat" viu al `PaintLayer`, no dins cada sarc. Els sarcs només apunten els tiles del seu perímetre que han estat notificats pintats. Aquesta separació deixa preparat per a items-en-sarc (obrir quan `N` items trobats, no quan tots oberts) i per a altres productors de pintat futurs (enemics, trampes).
- **"Primer pinta guanya"**: `PaintLayer::tryPaint` retorna false si el tile ja està pintat. No se sobreescriu amb l'índex d'un altre jugador. Reduïm fonts de decisió inconsistent; el rastre visual és estable.
- **Paint tile per jugador**: l'índex l'aporta el YAML del mapa (`player1_paint_tile`, `player2_paint_tile`), no el YAML del jugador. Raó: depèn del tileset del mapa — cada nivell pot oferir rastres diferents; canviar de mapa canvia l'índex vàlid.
- **Render al mode editor**: desactivat. L'editor edita el tilemap base; la capa de pintat és d'estat de joc, no de dades del nivell. Quan `editor_.active()`, `SKIP_ACTORS=true` i el `paint_layer_->render()` se salta amb la resta d'actors.
- **Resolució**: 16×16 reutilitzant `Map::tile_size` i l'origen del tilemap. El `PaintLayer` emmagatzema `tile_size_` com a camp propi per facilitar una eventual migració a 8×8 (graella 2× més fina) sense tocar la interfície.
- **Persistència**: runtime only. No hi ha camp `painted:` al YAML. Entrar al `GameScene` reseteja el pintat.
- **No cal `is_blocked` al `sarc->place(...)`** per a aquest nivell: l'`outer_corridor` del `pyramid.yaml` és exactament 1 tile, cosa que garanteix que el perímetre de qualsevol sarc (fins i tot els de cantonades) cau íntegrament dins del playfield i és pintable. Quedaria rellevant si el disseny futur posa sarcs sense passadís al voltant — cobert per PER-01.
### Verificació
- `make debug` net, sense warnings ni de clang-tidy ni de cppcheck als fitxers nous.
- `./build/test_entities_smoke`: **ALL OK** (14/14). T9 adaptat (publica `tile_painted` en lloc d'`on_tile_entered`); la resta de tests del sistema d'entitats passen sense tocar.
- `timeout 5 xvfb-run ./build/aee_2026`: arrenca, log `PaintLayer: 21x15 tiles (336x240 px), tileset data/tilesets/tomb.gif` després de `EntityManager: 27 entitats carregades`, surt amb `Bye!` sense crash.
### Pendent derivat
| ID | Descripció | Estat |
|---|---|---|
| PAINT-01 | **Tile de pintat definitiu** quan l'art estigui madur. Ara `2` provisional al `pyramid.yaml`. Canviar l'índex no demana recompilar. | DEFERRED |
| PAINT-02 | **Resolució 8×8** si es vol més granularitat. El `PaintLayer` porta el seu propi `tile_size_`; cal propagar-lo al ctor per a no lligar-lo a `Tilemap::getTileSize()`. També cal decidir com es mapen els perímetres dels sarcs a sub-caselles. | DEFERRED |
| PAINT-03 | **Smoke unit del `PaintLayer`**: "primer pinta guanya", filtre d'`isPaintable`, publicació de `tile_painted`. Ara la cadena està coberta end-to-end via T9 (fan-out del bus) i via l'smoke d'arrencada, però el filtre i la deduplicació no tenen test directe. | DEFERRED |
| PAINT-04 | **Player 2 cablejat al `GameScene`**: la lectura del YAML i el ctor ja suporten un segon jugador amb `paint_tile_index_` propi; només cal instanciar-lo amb Controls distints (p. ex. WASD) i afegir-lo al vector. | DEFERRED |
### Memòria durable afectada
INF-02 queda parcialment cobert: el `PaintedTile` ja porta `tile_index` (per jugador), que és una forma d'identificar qui pinta sense haver de propagar un `player_id` explícit. Si en el futur cal saber *qui* (p. ex. per puntuació individual), caldrà afegir el camp; el canvi és local al struct.
---
## 9. Estat global
- **Andamiatge, integració, render animat, falsa perspectiva, moviment, animació direccional de momia, pintat de tiles**: DONE. El `pyramid.yaml` actual (4 torches, 20 sarcs, 1 door, 2 mummies) es carrega, anima, col·loca amb l'anchor correcte, col·lisiona segons caixa lògica, el z-order interleava per peus, la momia gira visualment a cada intersecció, i el jugador deixa rastre al terra.
- **Pendents majors**: ENT-HAZ-01 (sistema de vides), ENT-DOOR-01 (transició d'escena), ENT-IA-02 (bias IA), PER-01 (perímetre amb blocked_predicate quan els sarcs siguin veïns), PAINT-03 (test unit del PaintLayer), PAINT-04 (Player 2 cablejat).
- **Smoke tests**: 14/14 passant; regla durable de cobrir amb smoke tota lògica no trivial respectada a totes les fases.
El document és viu: cada tasca nova hauria d'afegir-s'hi (entrada amb ID, descripció, estat) al fer-la o ajornar-la.
+2232
View File
File diff suppressed because it is too large. Load diff
+4 -3
View File
@@ -5,9 +5,10 @@
#include <cstdio>
#include <memory>
// Implementació completa de stb_vorbis en aquest TU. HEADER_ONLY es queda
// indefinit aquí perquè el .c aporti les definicions. La resta del projecte
// només veu el forward-decl de jail_audio.hpp.
// Només declaracions de stb_vorbis: STB_VORBIS_HEADER_ONLY omet el bloc
// d'implementació. Les definicions les aporta source/external/stb_vorbis_impl.cpp
// (TU aïllat perquè clang-analyzer no dispari fals positius al nostre codi).
#define STB_VORBIS_HEADER_ONLY
// clang-format off
// NOLINTNEXTLINE(bugprone-suspicious-include)
#include "external/stb_vorbis.c"
+161 -8
View File
@@ -2,6 +2,7 @@
#include <algorithm>
#include <iostream>
#include <iterator>
#include <string>
#include <utility>
@@ -78,6 +79,17 @@ auto GamepadManager::uniqueName(const std::string& base) const -> std::string {
}
void GamepadManager::addGamepad(SDL_JoystickID jid, bool notify) {
// Dedup per `instance_id`: SDL encua un SDL_EVENT_GAMEPAD_ADDED per
// cada mando ja connectat quan s'inicialitza el subsistema, a més
// dels que ja hem obert a `init()`. Sense aquesta guarda, el mateix
// mando s'afegiria dues vegades (silenciós + notificat amb sufix
// "#2") i qualsevol referència via `getPrimary`/`entries_` seria
// ambigua.
const bool ALREADY_KNOWN = std::ranges::any_of(
entries_,
[jid](const Entry& e) { return e.instance_id == jid; });
if (ALREADY_KNOWN) { return; }
installWebStandardMapping(jid);
if (!SDL_IsGamepad(jid)) { return; }
SDL_Gamepad* pad = SDL_OpenGamepad(jid);
@@ -87,8 +99,23 @@ void GamepadManager::addGamepad(SDL_JoystickID jid, bool notify) {
return;
}
std::string name = uniqueName(prettyName(SDL_GetGamepadName(pad)));
std::cout << "GamepadManager: connectat " << name << '\n';
entries_.push_back(Entry{.pad = pad, .instance_id = jid, .pretty_name = name});
// `SDL_GetGamepadPath` pot tornar null a backends que no exposen ruta
// física (Emscripten, alguns drivers). Buit com a fallback; la
// reconnexió es resoldrà per nom.
const char* raw_path = SDL_GetGamepadPath(pad);
std::string path = (raw_path != nullptr) ? raw_path : "";
std::cout << "GamepadManager: connectat " << name
<< (path.empty() ? "" : " [" + path + "]") << '\n';
entries_.push_back(Entry{.pad = pad, .instance_id = jid, .pretty_name = name, .path = path});
// Hot-plug: si alguna assignació pendent encaixa amb aquest mando,
// la vinculem ara. Primer intentem per path exacte, després per nom.
for (auto& a : assignments_) {
if (a.pad != nullptr) { continue; }
if (a.name.empty() && a.path.empty()) { continue; }
rebindAssignment(a);
}
if (notify) {
Notification::notify(name + ' ' + Locale::get().text("notification.gamepad_connected"));
}
@@ -100,9 +127,18 @@ void GamepadManager::removeGamepad(SDL_JoystickID jid) {
[jid](const Entry& e) { return e.instance_id == jid; });
if (it == entries_.end()) { return; }
std::string name = std::move(it->pretty_name);
if (it->pad != nullptr) { SDL_CloseGamepad(it->pad); }
SDL_Gamepad* pad = it->pad;
if (pad != nullptr) { SDL_CloseGamepad(pad); }
std::cout << "GamepadManager: desconnectat " << name << '\n';
entries_.erase(it);
// Qualsevol assignació que apuntava a aquest mando queda amb `pad`
// a null però conservem nom/ruta: si el mando es torna a connectar,
// el hot-plug el re-vincularà automàticament.
for (auto& a : assignments_) {
if (a.pad == pad) { a.pad = nullptr; }
}
Notification::notify(name + ' ' + Locale::get().text("notification.gamepad_disconnected"));
}
@@ -113,11 +149,22 @@ void GamepadManager::init() {
<< SDL_GetError() << '\n';
return;
}
// `gamecontrollerdb.txt` és opcional. Si no existeix, SDL retorna -1 i
// continuem amb les definicions internes del sistema.
const int ADDED = SDL_AddGamepadMappingsFromFile("data/input/gamecontrollerdb.txt");
if (ADDED > 0) {
std::cout << "GamepadManager: " << ADDED << " mappings de gamecontrollerdb.txt\n";
// `gamecontrollerdb.txt` és opcional. Viu a l'arrel del projecte
// (no a `data/`) perquè SDL_AddGamepadMappingsFromFile només sap
// llegir del filesystem real, no del pack. CMake el copia al costat
// del binari a cada build perquè SDL_GetBasePath el trobi tant en
// dev com en release. Sempre informem del resultat per facilitar el
// diagnòstic de mandos no reconeguts.
std::string db_path = "gamecontrollerdb.txt";
if (const char* base = SDL_GetBasePath()) {
db_path = std::string(base) + "gamecontrollerdb.txt";
}
const int ADDED = SDL_AddGamepadMappingsFromFile(db_path.c_str());
if (ADDED < 0) {
std::cout << "GamepadManager: no s'ha pogut carregar " << db_path
<< ": " << SDL_GetError() << '\n';
} else {
std::cout << "GamepadManager: " << ADDED << " mappings carregats de " << db_path << '\n';
}
int count = 0;
@@ -128,6 +175,7 @@ void GamepadManager::init() {
}
SDL_free(ids);
}
std::cout << "GamepadManager: " << entries_.size() << " mando(s) detectat(s) a l'inici\n";
initialized_ = true;
}
@@ -161,3 +209,108 @@ auto GamepadManager::getCount() const -> std::size_t {
auto GamepadManager::getByPlayerIndex(std::size_t idx) const -> SDL_Gamepad* {
return idx < entries_.size() ? entries_[idx].pad : nullptr;
}
auto GamepadManager::getAll() const -> std::vector<Info> {
std::vector<Info> result;
result.reserve(entries_.size());
std::ranges::transform(entries_, std::back_inserter(result), [](const Entry& e) {
return Info{.pad = e.pad, .name = e.pretty_name, .path = e.path};
});
return result;
}
auto GamepadManager::getForPlayer(int player_id) const -> SDL_Gamepad* {
if (player_id < 0 || player_id >= MAX_PLAYERS) { return nullptr; }
return assignments_[static_cast<std::size_t>(player_id)].pad;
}
void GamepadManager::rebindAssignment(Assignment& a) {
// Primera passada: coincidència exacta per path. És el cas estable
// (mateix mando, mateix port) i no canvia res de l'assignació.
if (!a.path.empty()) {
auto it = std::ranges::find_if(
entries_,
[&](const Entry& e) { return !e.path.empty() && e.path == a.path; });
if (it != entries_.end()) {
a.pad = it->pad;
return;
}
}
// Segona passada: coincidència per nom (mando canviat de port USB).
// Acceptem la primera entrada que no estigui ja vinculada a una altra
// assignació — així dos mandos amb el mateix nom no es pisen.
if (!a.name.empty()) {
for (const auto& e : entries_) {
if (e.pretty_name != a.name) { continue; }
const bool TAKEN = std::ranges::any_of(
assignments_,
[&](const Assignment& other) { return &other != &a && other.pad == e.pad; });
if (TAKEN) { continue; }
a.pad = e.pad;
a.path = e.path; // actualitzem ruta persistida
return;
}
}
a.pad = nullptr;
}
void GamepadManager::assignToPlayer(int player_id, const std::string& name, const std::string& path) {
if (player_id < 0 || player_id >= MAX_PLAYERS) { return; }
auto& a = assignments_[static_cast<std::size_t>(player_id)];
a.name = name;
a.path = path;
a.pad = nullptr;
if (name.empty() && path.empty()) { return; }
rebindAssignment(a);
// Last-wins: si l'altre jugador tenia el mateix mando vinculat, el
// desvinculem (conservant el seu nom/path perquè el menú pugui
// mostrar l'estat "pendent" i oferir re-selecció).
if (a.pad != nullptr) {
for (auto& other : assignments_) {
if (&other != &a && other.pad == a.pad) {
std::cout << "GamepadManager: mando " << a.name
<< " reassignat (last-wins)\n";
other.pad = nullptr;
}
}
}
// Traç de l'assignació persistida (vinculada o pendent) perquè quede
// visible a la consola igual que les autoassignacions.
if (a.pad != nullptr) {
std::cout << "GamepadManager: assignat " << a.name
<< " → P" << (player_id + 1) << '\n';
} else {
std::cout << "GamepadManager: P" << (player_id + 1) << " pendent ("
<< (a.name.empty() ? a.path : a.name) << " no connectat)\n";
}
}
void GamepadManager::unassignFromPlayer(int player_id) {
if (player_id < 0 || player_id >= MAX_PLAYERS) { return; }
assignments_[static_cast<std::size_t>(player_id)] = Assignment{};
}
auto GamepadManager::autoAssignFreeSlots() -> std::vector<AutoAssigned> {
std::vector<AutoAssigned> assigned;
for (int pid = 0; pid < MAX_PLAYERS; ++pid) {
auto& slot = assignments_[static_cast<std::size_t>(pid)];
if (slot.pad != nullptr) { continue; }
// Primer mando que cap altre slot no haja reclamat. Així respectem
// qualsevol assignació persistida que ja haja vinculat un pad
// concret i no el reassignem a un jugador diferent.
for (const auto& e : entries_) {
const bool TAKEN = std::ranges::any_of(
assignments_,
[&](const Assignment& other) { return other.pad == e.pad; });
if (TAKEN) { continue; }
slot.pad = e.pad;
slot.name = e.pretty_name;
slot.path = e.path;
assigned.push_back(AutoAssigned{.player_id = pid, .name = e.pretty_name, .path = e.path});
std::cout << "GamepadManager: auto-assigned " << e.pretty_name
<< " → P" << (pid + 1) << '\n';
break;
}
}
return assigned;
}
+65 -3
View File
@@ -2,6 +2,7 @@
#include <SDL3/SDL.h>
#include <array>
#include <cstddef>
#include <string>
#include <vector>
@@ -41,22 +42,83 @@ class GamepadManager {
void handleEvent(const SDL_Event& ev);
// Primary = primer gamepad al vector. `nullptr` si no n'hi ha cap.
// Ús típic: Player 1 i navegació del ServiceMenu.
// Ús típic: navegació del ServiceMenu. Per al gameplay els jugadors
// usen `getForPlayer(id)`.
[[nodiscard]] auto getPrimary() const -> SDL_Gamepad*;
[[nodiscard]] auto getCount() const -> std::size_t;
// Retorna el mand del jugador `idx`-èsim (0-indexed) o `nullptr` si
// encara no hi ha prou mandos connectats. Reservat per a multi-jugador.
// Retorna el mand del jugador `idx`-èsim (0-indexed) en l'ordre de
// connexió. Per al gameplay, `getForPlayer` fa servir l'assignació
// persistida, no l'ordre de connexió.
[[nodiscard]] auto getByPlayerIndex(std::size_t idx) const -> SDL_Gamepad*;
// Informació exposada al menú de servei (llista de mandos
// disponibles). `path` pot estar buit si SDL no l'exposa (p. ex. a
// alguns backends web).
struct Info {
SDL_Gamepad* pad{nullptr};
std::string name;
std::string path;
};
[[nodiscard]] auto getAll() const -> std::vector<Info>;
// Retorna el mand assignat al jugador `player_id` (0 = P1, 1 = P2)
// o `nullptr` si no n'hi ha cap. L'assignació la fixa
// `assignToPlayer` (cridat per Options en carregar `config.yaml` o
// des del ServiceMenu quan l'usuari tria un mando).
[[nodiscard]] auto getForPlayer(int player_id) const -> SDL_Gamepad*;
// Assigna un mando per nom + ruta física. Si ja hi ha un mando
// connectat que coincideix per ruta (o per nom com a fallback), el
// vincula immediatament; si no, l'assignació queda pendent i
// s'aplicarà si el mando es connecta més tard (hot-plug).
// Política last-wins: si l'altre jugador tenia el mateix mando
// vinculat, se'l desvincula silenciosament.
void assignToPlayer(int player_id, const std::string& name, const std::string& path);
// Esborra l'assignació (per si l'usuari trie "Cap" al menú).
void unassignFromPlayer(int player_id);
// Per a cada slot buit, hi vincula el primer mando connectat que no
// estigui reclamat per un altre slot. Equivalent al patró de
// `coffee_crisis_arcade_edition`: les assignacions persistides tenen
// prioritat; els slots que queden buits després s'emplenen per
// ordre. Retorna la llista de parells (player_id, name, path) que
// s'han vinculat, per a que Options pugui persistir-los.
struct AutoAssigned {
int player_id;
std::string name;
std::string path;
};
auto autoAssignFreeSlots() -> std::vector<AutoAssigned>;
private:
struct Entry {
SDL_Gamepad* pad{nullptr};
SDL_JoystickID instance_id{0};
std::string pretty_name;
std::string path;
};
// Assignació persistent per jugador. `name` i `path` venen d'Options
// (o del menú de servei) i poden referenciar un mando encara no
// connectat; en aquest cas `pad` és `nullptr` i el hot-plug el
// vincularà quan aparegui.
struct Assignment {
std::string name;
std::string path;
SDL_Gamepad* pad{nullptr};
};
static constexpr int MAX_PLAYERS = 2;
std::array<Assignment, MAX_PLAYERS> assignments_{};
// Busca a `entries_` el millor match per a una assignació (primer
// per path, després per nom). Retorna l'iterador o `entries_.end()`.
// També accepta assignar `path` nou si ha coincidit per nom
// (cas mando reenchufat a un altre port USB).
void rebindAssignment(Assignment& a);
GamepadManager() = default;
~GamepadManager() = default;
+19 -9
View File
@@ -11,7 +11,9 @@
#include "core/rendering/notifier.hpp"
#include "core/rendering/render_info.hpp"
#include "core/rendering/screen.hpp"
#include "core/rendering/screenshot.hpp"
#include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp"
#include "game/options.hpp"
#include "game/options_apply.hpp"
#include "utils/defaults.hpp"
@@ -24,7 +26,7 @@ namespace GlobalInputs {
// La lliguem a la durada de la notificació EXIT perquè el missatge i
// la confirmació expiren alhora — si no, l'usuari podria veure el
// text però ja no ser dins del rang de confirmació (o al revés).
constexpr float EXIT_WINDOW_S = static_cast<float>(Notification::EXIT.time_ms) / 1000.0F;
// El valor concret viu a data/ui.yaml (timings.exit_ms) via UiConfig.
constexpr int MIN_ZOOM = 1;
constexpr int MAX_ZOOM = 6;
@@ -67,6 +69,18 @@ namespace GlobalInputs {
notifyLocale(NEW_VSYNC ? "input.vsync_on" : "input.vsync_off");
}
// Volca el canvas ARGB actual a PNG dins de `screenshots/`. Si ha
// fallat l'escriptura, el handler no mostra notificació (retorn buit).
void onScreenshot() {
const auto* buffer = Screen::get().getCanvasArgb();
const int W = Screen::get().getCanvasWidth();
const int H = Screen::get().getCanvasHeight();
const std::string FILENAME = Screenshot::save(buffer, W, H);
if (!FILENAME.empty()) {
Notification::notify(Locale::get().text("input.screenshot") + ' ' + FILENAME);
}
}
void onToggleShader() {
const bool NEW_ENABLED = !Options::video().shader.enabled;
Options::setShaderEnabled(NEW_ENABLED);
@@ -140,17 +154,13 @@ namespace GlobalInputs {
Options::setVideoRenderInfoPosition(static_cast<Defaults::RenderInfoPosition>(NEXT));
}
void onToggleRenderTime() {
Options::setVideoRenderInfoShowTime(!Options::video().render_info_show_time);
}
void onExit() {
if (exit_window_s > 0.0F) {
quit_requested = true;
return;
}
exit_window_s = EXIT_WINDOW_S;
Notification::notify(Locale::get().text("input.press_again_exit"), Notification::EXIT);
exit_window_s = static_cast<float>(UiConfig::get().exitMs()) / 1000.0F;
Notification::notify(Locale::get().text("input.press_again_exit"), Notification::exitPreset());
}
} // namespace
@@ -171,6 +181,8 @@ namespace GlobalInputs {
onToggleShader();
} else if (SC == KeyConfig::get().scancode("toggle_vsync")) {
onToggleVSync();
} else if (SC == KeyConfig::get().scancode("screenshot")) {
onScreenshot();
} else if (SC == KeyConfig::get().scancode("next_shader")) {
onNextShader();
} else if (SC == KeyConfig::get().scancode("next_preset")) {
@@ -179,8 +191,6 @@ namespace GlobalInputs {
onNextScalingMode();
} else if (SC == KeyConfig::get().scancode("toggle_render_info")) {
onCycleRenderInfo();
} else if (SC == KeyConfig::get().scancode("toggle_render_time")) {
onToggleRenderTime();
} else if (SC == KeyConfig::get().scancode("exit")) {
onExit();
}
+2 -2
View File
@@ -42,7 +42,7 @@ namespace GIF {
}
// Inicializa el diccionario LZW con los valores iniciales
inline void initializeDictionary(std::vector<DictionaryEntry>& dictionary, int code_length, int& dictionary_ind) { // NOLINT(readability-identifier-naming)
inline void initializeDictionary(std::vector<DictionaryEntry>& dictionary, int code_length, int& dictionary_ind) {
int size = 1 << code_length;
dictionary.resize(1 << (code_length + 1));
for (dictionary_ind = 0; dictionary_ind < size; dictionary_ind++) {
@@ -82,7 +82,7 @@ namespace GIF {
}
// Agrega una nueva entrada al diccionario
inline void addDictionaryEntry(std::vector<DictionaryEntry>& dictionary, int& dictionary_ind, int& code_length, int prev, int code) { // NOLINT(readability-identifier-naming)
inline void addDictionaryEntry(std::vector<DictionaryEntry>& dictionary, int& dictionary_ind, int& code_length, int prev, int code) {
uint8_t first_byte;
if (code == dictionary_ind) {
first_byte = findFirstByte(dictionary, prev);
+29 -19
View File
@@ -5,12 +5,13 @@
#include <string>
#include "core/rendering/notifier.hpp"
#include "utils/defaults.hpp"
#include "core/ui/ui_config.hpp"
// Temes per a les notificacions de pantalla. Un "preset" captura els dos
// colors (text i fons, índexs sobre la paleta "ui") i el temps de visibilitat
// en mil·lisegons. Per a llançar una notificació amb un preset concret,
// cridar Notification::notify(text, preset); si s'omet, s'usa DEFAULT.
// en mil·lisegons. Els valors concrets viuen a data/ui.yaml (carregat per
// UiConfig); per llançar una notificació amb un preset concret, cridar
// Notification::notify(text, preset). Sense preset s'usa el DEFAULT.
namespace Notification {
struct Preset {
@@ -19,27 +20,36 @@ namespace Notification {
int time_ms;
};
// Tema per defecte: text clar sobre fons fosc. Els valors concrets
// viuen a `Defaults::UI` — tocar l'aparença global es fa allà.
constexpr Preset DEFAULT{
.text_color = Defaults::UI::NOTIFICATION_TEXT,
.bg_color = Defaults::UI::NOTIFICATION_BG,
.time_ms = Defaults::UI::NOTIFICATION_TIME_MS,
};
// Preset per defecte: text clar sobre fons fosc. Es construeix en cada
// crida a partir d'UiConfig perquè els valors no són constexpr: viuen a
// data/ui.yaml i es carreguen a l'arrencada.
[[nodiscard]] inline auto defaultPreset() -> Preset {
return {
.text_color = UiConfig::get().notificationText(),
.bg_color = UiConfig::get().notificationBg(),
.time_ms = UiConfig::get().notificationMs(),
};
}
// Preset per al missatge de confirmació d'eixida (ESC doble). Color
// d'accent per a distingir-lo d'una notificació informativa; la durada
// d'accent per distingir-lo d'una notificació informativa; la durada
// coincideix amb la finestra durant la qual la segona pulsació d'ESC
// confirma el tancament — veure GlobalInputs::onExit.
constexpr Preset EXIT{
.text_color = Defaults::UI::EXIT_TEXT,
.bg_color = Defaults::UI::EXIT_BG,
.time_ms = Defaults::UI::EXIT_TIME_MS,
};
[[nodiscard]] inline auto exitPreset() -> Preset {
return {
.text_color = UiConfig::get().exitText(),
.bg_color = UiConfig::get().exitBg(),
.time_ms = UiConfig::get().exitMs(),
};
}
// Helper de conveniència: aplica un preset (per defecte DEFAULT) cridant
// la API crua de Notifier. La direcció game → core és la correcta.
inline void notify(const std::string& text, const Preset& preset = DEFAULT) {
// Helper de conveniència: aplica un preset (per defecte el DEFAULT)
// cridant la API crua de Notifier. La direcció game → core és correcta.
inline void notify(const std::string& text) {
const auto P = defaultPreset();
Notifier::get().notify(text, P.text_color, P.bg_color, P.time_ms);
}
inline void notify(const std::string& text, const Preset& preset) {
Notifier::get().notify(text, preset.text_color, preset.bg_color, preset.time_ms);
}
+29
View File
@@ -11,6 +11,7 @@
#include <ranges>
#include "core/rendering/canvas.hpp"
#include "core/rendering/sdl3gpu/viewport.hpp"
#include "utils/defines.hpp"
namespace {
@@ -265,6 +266,34 @@ void Screen::blendRect(int x, int y, int w, int h, std::uint32_t argb, std::uint
}
}
auto Screen::windowToCanvas(float window_x, float window_y) const -> SDL_FPoint {
// Path fallback: SDL ja porta un helper que desfà el
// logical_presentation automàticament. Evita que haguem de replicar
// les regles de letterbox/integer/etc.
if (renderer_ != nullptr) {
float cx = 0.0F;
float cy = 0.0F;
SDL_RenderCoordinatesFromWindow(renderer_, window_x, window_y, &cx, &cy);
return {cx, cy};
}
// Path GPU: el canvas es pinta al viewport que calcula
// `computeViewport` a partir del mode d'escalat i la mida del
// swapchain. Fem el mateix càlcul aquí per trobar la inversa.
int win_w = 0;
int win_h = 0;
SDL_GetWindowSize(window_, &win_w, &win_h);
if (win_w <= 0 || win_h <= 0) { return {-1.0F, -1.0F}; }
const auto VP = Rendering::computeViewport(config_.scaling_mode,
static_cast<std::uint32_t>(win_w),
static_cast<std::uint32_t>(win_h),
canvas_width_,
canvas_height_);
if (VP.w <= 0.0F || VP.h <= 0.0F) { return {-1.0F, -1.0F}; }
const float NX = (window_x - VP.x) / VP.w;
const float NY = (window_y - VP.y) / VP.h;
return {NX * static_cast<float>(canvas_width_), NY * static_cast<float>(canvas_height_)};
}
void Screen::setOverlayRenderer(std::function<void()> callback) {
overlay_renderer_ = std::move(callback);
}
+10
View File
@@ -86,6 +86,16 @@ class Screen {
[[nodiscard]] auto getCanvasWidth() const -> int { return canvas_width_; }
[[nodiscard]] auto getCanvasHeight() const -> int { return canvas_height_; }
// Transforma una coordenada en espai finestra (tal com venen als
// esdeveniments SDL de ratolí) a espai canvas (0..canvas_w, 0..canvas_h).
// Reverteix el zoom de la finestra i el mode d'escalat actiu: al path
// fallback SDL_Renderer es delega a `SDL_RenderCoordinatesFromWindow`;
// al path GPU es recalcula a partir del viewport actual. Si el punt
// cau fora de la zona del canvas (letterbox/integer), els valors
// retornats queden fora del rang `[0, canvas_w/h)` — el caller ha de
// fer el bounds-check i ignorar el clic en eixe cas.
[[nodiscard]] auto windowToCanvas(float window_x, float window_y) const -> SDL_FPoint;
// Mètriques per a overlays de debug (RenderInfo).
[[nodiscard]] auto getLastFPS() const -> int { return fps_.last_value; }
[[nodiscard]] auto getGPUDriver() const -> const std::string& { return gpu_driver_; }
+60
View File
@@ -0,0 +1,60 @@
#include "core/rendering/screenshot.hpp"
#include <chrono>
#include <cstdint>
#include <ctime>
#include <filesystem>
#include <string>
#include <vector>
#include "external/stb_image_write.h"
namespace Screenshot {
namespace {
// Crea la carpeta `screenshots/` si no existeix i torna la ruta
// completa al fitxer. El paràmetre `filename` rep el nom nu (sense
// directori) per poder mostrar-lo a la notificació.
auto generateFilePath(std::string& filename) -> std::string {
std::filesystem::create_directories("screenshots");
const auto NOW = std::chrono::system_clock::now();
const std::time_t TIME = std::chrono::system_clock::to_time_t(NOW);
const std::tm* tm = std::localtime(&TIME);
char timestamp[20];
std::strftime(timestamp, sizeof(timestamp), "%Y%m%d_%H%M%S", tm);
filename = std::string("scr_") + timestamp + ".png";
return "screenshots/" + filename;
}
// Converteix ARGB8888 → RGBA8888 in-place i escriu el PNG. stb_image_write
// espera RGBA en ordre byte (little-endian: R al byte baix, A al byte alt).
auto writePng(std::uint32_t* buffer, int width, int height, const std::string& filepath) -> bool {
const int TOTAL = width * height;
for (int i = 0; i < TOTAL; ++i) {
const std::uint32_t P = buffer[i];
const std::uint32_t A = (P >> 24) & 0xFF;
const std::uint32_t R = (P >> 16) & 0xFF;
const std::uint32_t G = (P >> 8) & 0xFF;
const std::uint32_t B = P & 0xFF;
buffer[i] = R | (G << 8) | (B << 16) | (A << 24);
}
return stbi_write_png(filepath.c_str(), width, height, 4, buffer, width * 4) != 0;
}
} // namespace
auto save(const std::uint32_t* buffer, int width, int height) -> std::string {
// Còpia local per no tocar el canvas original durant la conversió.
std::vector<std::uint32_t> copy(buffer, buffer + (static_cast<size_t>(width) * height));
std::string filename;
const std::string FILEPATH = generateFilePath(filename);
return writePng(copy.data(), width, height, FILEPATH) ? filename : "";
}
} // namespace Screenshot
+13
View File
@@ -0,0 +1,13 @@
#pragma once
#include <cstdint>
#include <string>
// Volca un buffer ARGB8888 a un fitxer PNG dins de la carpeta `screenshots/`
// (relativa al CWD). El nom es genera amb un timestamp i la funció retorna
// el nom del fitxer creat, o una cadena buida si l'escriptura ha fallat.
namespace Screenshot {
auto save(const std::uint32_t* buffer, int width, int height) -> std::string;
} // namespace Screenshot
+106 -88
View File
@@ -3,6 +3,7 @@
#include <cmath>
#include <iostream>
#include <numeric>
#include <utility>
#include "core/rendering/surface.hpp"
#include "core/resources/resource_cache.hpp"
@@ -24,46 +25,6 @@ namespace {
return frames;
}
auto parseAnimations(const fkyaml::node& yaml, float frame_width, float frame_height, int frames_per_row, int max_tiles) -> std::vector<AnimatedSprite::AnimationData> {
std::vector<AnimatedSprite::AnimationData> animations;
if (!yaml.contains("animations") || !yaml["animations"].is_sequence()) {
return animations;
}
for (const auto& anim_node : yaml["animations"]) {
AnimatedSprite::AnimationData animation;
if (anim_node.contains("name")) {
animation.name = anim_node["name"].get_value<std::string>();
}
if (anim_node.contains("frames") && anim_node["frames"].is_sequence()) {
animation.frames = convertYamlFramesToRects(
anim_node["frames"],
frame_width,
frame_height,
frames_per_row,
max_tiles);
}
if (anim_node.contains("speed")) {
const auto& speed_node = anim_node["speed"];
if (speed_node.is_sequence()) {
for (const auto& s : speed_node) {
animation.speeds.push_back(s.get_value<float>());
}
} else {
auto spd = speed_node.get_value<float>();
if (spd > 0.0F) { animation.speeds.assign(animation.frames.size(), spd); }
}
}
if (anim_node.contains("loopFrom")) {
animation.loop_from = anim_node["loopFrom"].get_value<int>();
}
animations.push_back(animation);
}
return animations;
}
struct SheetParams {
float frame_width{0.0F};
float frame_height{0.0F};
@@ -71,16 +32,22 @@ namespace {
int max_tiles{1};
};
auto parseGlobalConfig(const fkyaml::node& yaml, std::shared_ptr<Surface>& surface) -> SheetParams {
// Llegeix el bloc `sprite: { path, size: [w, h] }` — mateix format que
// les plantilles d'entitat. La surface es resol via Resource::Cache amb
// el basename del fitxer.
auto parseSpriteBlock(const fkyaml::node& yaml, std::shared_ptr<Surface>& surface) -> SheetParams {
SheetParams params;
if (yaml.contains("tileSetFile")) {
surface = Resource::Cache::get().getSurface(yaml["tileSetFile"].get_value<std::string>());
if (!yaml.contains("sprite")) { return params; }
const auto& s = yaml["sprite"];
if (s.contains("path")) {
surface = Resource::Cache::get().getSurface(s["path"].get_value<std::string>());
}
if (yaml.contains("frameWidth")) {
params.frame_width = static_cast<float>(yaml["frameWidth"].get_value<int>());
}
if (yaml.contains("frameHeight")) {
params.frame_height = static_cast<float>(yaml["frameHeight"].get_value<int>());
if (s.contains("size")) {
const auto& sz = s["size"];
if (sz.is_sequence() && sz.size() == 2) {
params.frame_width = static_cast<float>(sz[0].get_value<int>());
params.frame_height = static_cast<float>(sz[1].get_value<int>());
}
}
if (surface && params.frame_width > 0.0F && params.frame_height > 0.0F) {
params.frames_per_row = surface->getWidth() / static_cast<int>(params.frame_width);
@@ -90,21 +57,82 @@ namespace {
return params;
}
// `frame_ms` pot ser un escalar (temps uniforme) o una llista (per-frame).
// Convertim sempre a segons perquè `animate()` treballa amb delta_time
// en segons. Si falta o val 0, `speeds` queda buit i l'animació es
// considera estàtica (no avança).
void parseFrameTimes(const fkyaml::node& anim_node, AnimatedSprite::AnimationData& animation) {
if (!anim_node.contains("frame_ms")) { return; }
const auto& fm = anim_node["frame_ms"];
if (fm.is_sequence()) {
for (const auto& v : fm) {
animation.speeds.push_back(static_cast<float>(v.get_value<int>()) / 1000.0F);
}
} else {
const float SEC = static_cast<float>(fm.get_value<int>()) / 1000.0F;
if (SEC > 0.0F) { animation.speeds.assign(animation.frames.size(), SEC); }
}
}
// Ordre de prioritat per a la política de loop:
// 1) `loop_from: N` → rebota al frame N en passar del final (implica loop).
// 2) `loop: false` → animació d'un sol tret (es queda al darrer frame).
// 3) Res → loop complet des del frame 0 (default).
void parseLoopPolicy(const fkyaml::node& anim_node, AnimatedSprite::AnimationData& animation) {
if (anim_node.contains("loop_from")) {
animation.loop_from = anim_node["loop_from"].get_value<int>();
} else if (anim_node.contains("loop") && !anim_node["loop"].get_value<bool>()) {
animation.loop_from = -1;
} else {
animation.loop_from = 0;
}
}
auto parseAnimations(const fkyaml::node& yaml, const SheetParams& params) -> std::unordered_map<std::string, AnimatedSprite::AnimationData> {
std::unordered_map<std::string, AnimatedSprite::AnimationData> animations;
if (!yaml.contains("animations") || !yaml["animations"].is_mapping()) {
return animations;
}
const auto& anims_node = yaml["animations"];
for (auto it = anims_node.begin(); it != anims_node.end(); ++it) {
const auto NAME = it.key().get_value<std::string>();
const auto& anim_node = *it;
AnimatedSprite::AnimationData animation;
if (anim_node.contains("frames") && anim_node["frames"].is_sequence()) {
animation.frames = convertYamlFramesToRects(
anim_node["frames"],
params.frame_width,
params.frame_height,
params.frames_per_row,
params.max_tiles);
}
parseFrameTimes(anim_node, animation);
parseLoopPolicy(anim_node, animation);
animations.emplace(NAME, std::move(animation));
}
return animations;
}
} // namespace
AnimatedSprite::AnimatedSprite(const AnimationResource& cached_data) {
std::string yaml_content(cached_data.yaml_data.begin(), cached_data.yaml_data.end());
try {
auto yaml = fkyaml::node::deserialize(yaml_content);
auto params = parseGlobalConfig(yaml, surface_);
animations_ = parseAnimations(yaml, params.frame_width, params.frame_height, params.frames_per_row, params.max_tiles);
buildNameIndex();
auto params = parseSpriteBlock(yaml, surface_);
animations_ = parseAnimations(yaml, params);
setWidth(params.frame_width);
setHeight(params.frame_height);
if (!animations_.empty() && !animations_[0].frames.empty()) {
setClip(animations_[0].frames[0]);
// Clip inicial: qualsevol frame del primer element del mapa serveix
// com a placeholder. El consumidor sempre crida `setCurrentAnimation`
// abans del primer `update()`, així que el valor real el posa ell.
if (!animations_.empty()) {
const auto& first = animations_.begin()->second;
if (!first.frames.empty()) { setClip(first.frames[0]); }
}
} catch (const fkyaml::exception& e) {
std::cerr << "YAML parsing error in animation " << cached_data.name << ": " << e.what() << '\n';
@@ -115,17 +143,12 @@ AnimatedSprite::AnimatedSprite(const AnimationResource& cached_data) {
}
}
void AnimatedSprite::buildNameIndex() {
animation_index_.clear();
for (int i = 0; i < static_cast<int>(animations_.size()); ++i) {
animation_index_[animations_[i].name] = i;
}
}
void AnimatedSprite::animate(float delta_time) {
if (animations_.empty()) { return; }
auto& anim = animations_[current_animation_];
if (anim.speeds.empty()) { return; }
if (current_animation_.empty()) { return; }
auto it = animations_.find(current_animation_);
if (it == animations_.end()) { return; }
auto& anim = it->second;
if (anim.speeds.empty() || anim.frames.empty()) { return; }
anim.accumulated_time += delta_time;
@@ -159,26 +182,25 @@ void AnimatedSprite::animate(float delta_time) {
}
auto AnimatedSprite::animationIsCompleted() -> bool {
return animations_[current_animation_].completed;
if (current_animation_.empty()) { return false; }
auto it = animations_.find(current_animation_);
if (it == animations_.end()) { return false; }
return it->second.completed;
}
void AnimatedSprite::setCurrentAnimation(const std::string& name) {
auto it = animation_index_.find(name);
if (it == animation_index_.end()) {
if (current_animation_ == name) { return; }
auto it = animations_.find(name);
if (it == animations_.end()) {
std::cout << "** Warning: could not find \"" << name << "\" animation" << '\n';
return;
}
setCurrentAnimation(it->second);
}
void AnimatedSprite::setCurrentAnimation(int index) {
if (current_animation_ != index) {
current_animation_ = index;
animations_[current_animation_].current_frame = 0;
animations_[current_animation_].accumulated_time = 0.0F;
animations_[current_animation_].completed = false;
setClip(animations_[current_animation_].frames[animations_[current_animation_].current_frame]);
}
current_animation_ = name;
auto& anim = it->second;
anim.current_frame = 0;
anim.accumulated_time = 0.0F;
anim.completed = false;
if (!anim.frames.empty()) { setClip(anim.frames[0]); }
}
void AnimatedSprite::update(float delta_time) {
@@ -187,15 +209,11 @@ void AnimatedSprite::update(float delta_time) {
}
void AnimatedSprite::resetAnimation() {
animations_[current_animation_].current_frame = 0;
animations_[current_animation_].accumulated_time = 0.0F;
animations_[current_animation_].completed = false;
}
void AnimatedSprite::setCurrentAnimationFrame(int num) {
if (num < 0 || num >= static_cast<int>(animations_[current_animation_].frames.size())) {
num = 0;
}
animations_[current_animation_].current_frame = num;
setClip(animations_[current_animation_].frames[animations_[current_animation_].current_frame]);
if (current_animation_.empty()) { return; }
auto it = animations_.find(current_animation_);
if (it == animations_.end()) { return; }
auto& anim = it->second;
anim.current_frame = 0;
anim.accumulated_time = 0.0F;
anim.completed = false;
}
@@ -11,11 +11,19 @@
class AnimatedSprite : public MovingSprite {
public:
// Dades d'una animació concreta dins d'un spritesheet. Les claus del
// mapa (`animations_`) són els noms; aquí només es guarden els fotogrames
// i l'estat de reproducció.
struct AnimationData {
std::string name;
std::vector<SDL_FRect> frames;
// Durada de cada frame en segons (convertit des de `frame_ms` al YAML).
// Si té un sol valor, val per a tots els frames; altrament ha de tenir
// la mateixa mida que `frames`.
std::vector<float> speeds;
int loop_from{-1};
// -1 = no loop (es queda al darrer frame i marca `completed`).
// 0 = loop complet des del primer frame.
// N>0 = rebota al frame N en passar del final.
int loop_from{0};
bool completed{false};
int current_frame{0};
float accumulated_time{0.0F};
@@ -28,16 +36,12 @@ class AnimatedSprite : public MovingSprite {
auto animationIsCompleted() -> bool;
void setCurrentAnimation(const std::string& name = "default");
void setCurrentAnimation(int index = 0);
void setCurrentAnimation(const std::string& name);
void resetAnimation();
void setCurrentAnimationFrame(int num);
void animate(float delta_time);
private:
void buildNameIndex();
std::vector<AnimationData> animations_;
std::unordered_map<std::string, int> animation_index_;
int current_animation_{0};
std::unordered_map<std::string, AnimationData> animations_;
// Nom de l'animació activa. Buit fins que algú crida `setCurrentAnimation`.
std::string current_animation_;
};
+11
View File
@@ -150,6 +150,7 @@ void Text::writeDX(Uint8 flags, int x, int y, const std::string& text, int kerni
const bool SHADOWED = ((flags & SHADOW_FLAG) == SHADOW_FLAG);
const bool COLORED = ((flags & COLOR_FLAG) == COLOR_FLAG);
const bool STROKED = ((flags & STROKE_FLAG) == STROKE_FLAG);
const bool STROKED_4 = ((flags & STROKE4_FLAG) == STROKE4_FLAG);
if (CENTERED) { x -= (Text::length(text, kerning) / 2); }
@@ -157,6 +158,16 @@ void Text::writeDX(Uint8 flags, int x, int y, const std::string& text, int kerni
writeColored(x + shadow_distance, y + shadow_distance, text, shadow_color, kerning, length);
}
if (STROKED_4) {
const int MAX_DIST = static_cast<int>(shadow_distance);
for (int dist = 1; dist <= MAX_DIST; ++dist) {
writeColored(x + dist, y, text, shadow_color, kerning, length);
writeColored(x - dist, y, text, shadow_color, kerning, length);
writeColored(x, y + dist, text, shadow_color, kerning, length);
writeColored(x, y - dist, text, shadow_color, kerning, length);
}
}
if (STROKED) {
const int MAX_DIST = static_cast<int>(shadow_distance);
for (int dist = 1; dist <= MAX_DIST; ++dist) {
+7
View File
@@ -31,10 +31,17 @@ class Text {
};
// Flags per a writeDX (composables amb |).
// `STROKE_FLAG` dibuixa un contorn complet de 8 veïns (N/S/E/O + 4
// diagonals) al voltant del glif; `STROKE4_FLAG` només els 4 cardinals
// (N/S/E/O), amb una aparença més prima per a glifs petits on la versió
// diagonal engreixa massa. Els dos flags són mútuament excloents: si
// s'activen a la vegada, ambdós bucles s'executen i l'stroke diagonal
// queda per damunt del cardinal (no té gaire sentit combinar-los).
static constexpr Uint8 COLOR_FLAG = 1;
static constexpr Uint8 SHADOW_FLAG = 2;
static constexpr Uint8 CENTER_FLAG = 4;
static constexpr Uint8 STROKE_FLAG = 8;
static constexpr Uint8 STROKE4_FLAG = 16;
// Índex del píxel "tinta" al PNG de la font. Els glifs són monocroms
// sobre transparent i tots els píxels opacs usen aquest índex; la Surface
+43 -35
View File
@@ -25,6 +25,7 @@
#include "core/resources/resource_helper.hpp"
#include "core/scene/scene.hpp"
#include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp"
#include "game/debug_config.hpp"
#include "game/options.hpp"
#include "game/scenes/game_scene.hpp"
@@ -32,7 +33,6 @@
#include "game/scenes/logo_scene.hpp"
#include "game/scenes/title_scene.hpp"
#include "game/ui/service_menu.hpp"
#include "game/ui/ui_palette.hpp"
#include "utils/defaults.hpp"
Director::Director() {
@@ -65,6 +65,16 @@ Director::Director() {
// Prepara la carpeta de sistema (~/.config/jailgames/aee_2026/) i carrega
// les opcions; si no existeix config.yaml el crea amb els defaults.
createSystemFolder();
// Gamepad: descobreix mandos presents silenciosament. Cal fer-ho ABANS
// de `Options::loadFromFile`, ja que aquesta crida internament a
// `applyControlsToGamepadManager` (rebind d'assignacions persistides +
// auto-assign de slots buits) i necessita que `entries_` ja estiga
// poblat per a poder casar mandos↔jugadors. A partir d'ara els events
// SDL_EVENT_GAMEPAD_ADDED/REMOVED arribaran via `processGamepadEvents()`
// i dispararan notificacions.
GamepadManager::get().init();
Options::setConfigFile(system_folder_ + "config.yaml");
if (!Options::loadFromFile()) {
std::cerr << "Director: no s'ha pogut carregar ni desar config.yaml a " << system_folder_ << '\n';
@@ -97,15 +107,19 @@ Director::Director() {
// Hotkeys globals. GlobalInputs::update() les consumirà a cada frame.
KeyConfig::get().load("data/input/keys.yaml");
// Tema d'UI (colors, timings, fonts, flags). Qualsevol clau absent a
// data/ui.yaml cau al valor compilat a Defaults::UI.
UiConfig::get().load("data/ui.yaml");
// Recursos compartits d'UI (paleta + fonts). Es carreguen abans que els
// overlays perquè tots els reaprofiten sense duplicar la lògica. Els
// noms de font viuen a Defaults::UI::Font (un únic lloc per canviar-los).
Resource::Cache::get().loadPalette("ui", "data/palettes/sega-cube.pal");
const std::string HUD_FONT_GIF_KEY = std::string(Defaults::UI::Font::HUD_NAME) + ".gif";
Resource::Cache::get().loadSurface(HUD_FONT_GIF_KEY, Defaults::UI::Font::HUD_GIF);
// noms de font viuen al fitxer data/ui.yaml via UiConfig.
Resource::Cache::get().loadPalette("ui", "data/palettes/uzebox.pal");
const std::string HUD_FONT_GIF_KEY = UiConfig::get().hudFont().name + ".gif";
Resource::Cache::get().loadSurface(HUD_FONT_GIF_KEY, UiConfig::get().hudFont().gif);
Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui"));
const std::string PLACEHOLDER_FONT_GIF_KEY = std::string(Defaults::UI::Font::PLACEHOLDER_NAME) + ".gif";
Resource::Cache::get().loadSurface(PLACEHOLDER_FONT_GIF_KEY, Defaults::UI::Font::PLACEHOLDER_GIF);
const std::string PLACEHOLDER_FONT_GIF_KEY = UiConfig::get().placeholderFont().name + ".gif";
Resource::Cache::get().loadSurface(PLACEHOLDER_FONT_GIF_KEY, UiConfig::get().placeholderFont().gif);
Resource::Cache::get().getSurface(PLACEHOLDER_FONT_GIF_KEY)->setPalette(Resource::Cache::get().getPalette("ui"));
// Música de la piràmide 1 (primer i únic track de moment). Carreguem ací
@@ -117,50 +131,37 @@ Director::Director() {
// JA_Music_t que continga, així que no queden streams orfes.
Audio::init(Options::buildAudioConfig());
// Gamepad: descobreix mandos presents silenciosament. A partir d'ara
// els events SDL_EVENT_GAMEPAD_ADDED/REMOVED arribaran via
// `processGamepadEvents()` i dispararan notificacions.
GamepadManager::get().init();
// Font compartida pels overlays: construïm una sola instància de Text
// i la injectem als consumidors (Notifier, RenderInfo) per a que cap
// d'ells hagi de conèixer els noms dels recursos concrets.
auto hud_font_surface = Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY);
auto hud_text = std::make_shared<Text>(hud_font_surface, Defaults::UI::Font::HUD_FNT);
hud_text->setDefaultKerning(Defaults::UI::Font::HUD_KERNING);
auto hud_text = std::make_shared<Text>(hud_font_surface, UiConfig::get().hudFont().fnt);
hud_text->setDefaultKerning(UiConfig::get().hudFont().kerning);
const auto& ui_palette = Resource::Cache::get().getPalette("ui");
RenderInfo::get().init(Options::buildRenderInfoFrame(UiColor::RENDER_INFO_TEXT, UiColor::RENDER_INFO_SHADOW), hud_text);
RenderInfo::get().init(Options::buildRenderInfoFrame(UiConfig::get().renderInfoText(), UiConfig::get().renderInfoShadow()), hud_text);
Notifier::get().init(hud_text, ui_palette);
// Font dels placeholders (LOGO / TITLE / INTRO). És una font distinta a
// la de HUD perquè els placeholders desapareixeran amb les seues escenes
// finals; mentrestant tenen estètica pròpia.
auto placeholder_font_surface = Resource::Cache::get().getSurface(PLACEHOLDER_FONT_GIF_KEY);
auto placeholder_text = std::make_shared<Text>(placeholder_font_surface, Defaults::UI::Font::PLACEHOLDER_FNT);
placeholder_text->setDefaultKerning(Defaults::UI::Font::PLACEHOLDER_KERNING);
auto placeholder_text = std::make_shared<Text>(placeholder_font_surface, UiConfig::get().placeholderFont().fnt);
placeholder_text->setDefaultKerning(UiConfig::get().placeholderFont().kerning);
// Font del menú de servei (F9). El títol va amb la variant `display`
// i els items amb `condensed-display` (més compacte perquè caben més
// files). Els dos noms i kernings viuen a `Defaults::UI::Font`.
auto load_menu_font = [&](const char* name, const char* gif, const char* fnt, int kerning) {
const std::string KEY = std::string(name) + ".gif";
Resource::Cache::get().loadSurface(KEY, gif);
// files). Els dos noms i kernings viuen a data/ui.yaml via UiConfig.
auto load_menu_font = [&](const UiConfig::Font& font) {
const std::string KEY = font.name + ".gif";
Resource::Cache::get().loadSurface(KEY, font.gif);
Resource::Cache::get().getSurface(KEY)->setPalette(Resource::Cache::get().getPalette("ui"));
auto text = std::make_shared<Text>(Resource::Cache::get().getSurface(KEY), fnt);
text->setDefaultKerning(kerning);
auto text = std::make_shared<Text>(Resource::Cache::get().getSurface(KEY), font.fnt);
text->setDefaultKerning(font.kerning);
return text;
};
auto menu_title_text = load_menu_font(
Defaults::UI::Font::MENU_TITLE_NAME,
Defaults::UI::Font::MENU_TITLE_GIF,
Defaults::UI::Font::MENU_TITLE_FNT,
Defaults::UI::Font::MENU_TITLE_KERNING);
auto menu_items_text = load_menu_font(
Defaults::UI::Font::MENU_ITEMS_NAME,
Defaults::UI::Font::MENU_ITEMS_GIF,
Defaults::UI::Font::MENU_ITEMS_FNT,
Defaults::UI::Font::MENU_ITEMS_KERNING);
auto menu_title_text = load_menu_font(UiConfig::get().menuTitleFont());
auto menu_items_text = load_menu_font(UiConfig::get().menuItemsFont());
ServiceMenu::get().init(menu_title_text, menu_items_text, ui_palette);
// Overlay hook: Screen::present() cridarà aquesta lambda al principi,
@@ -180,12 +181,19 @@ Director::Director() {
const auto DEBUG_YAML = system_folder_ + "debug.yaml";
DebugConfig::createDefaultIfMissing(DEBUG_YAML);
const auto START = DebugConfig::resolveStartScene(DEBUG_YAML);
const auto STARTING_PLAYERS = DebugConfig::resolveStartingPlayers(DEBUG_YAML);
#else
const auto START = DebugConfig::StartScene::LOGO;
const auto STARTING_PLAYERS = DebugConfig::StartingPlayers::BOTH;
#endif
std::cout << "Director: start_scene=" << DebugConfig::toString(START) << '\n';
std::cout << "Director: start_scene=" << DebugConfig::toString(START)
<< " starting_players=" << DebugConfig::toString(STARTING_PLAYERS) << '\n';
auto make_game = []() -> std::unique_ptr<Scene> { return std::make_unique<GameScene>(); };
auto make_game = [STARTING_PLAYERS]() -> std::unique_ptr<Scene> {
auto cfg = GameScene::defaultConfig();
cfg.starting_players = STARTING_PLAYERS;
return std::make_unique<GameScene>(std::move(cfg));
};
auto make_intro = [placeholder_text, make_game]() -> std::unique_ptr<Scene> {
return std::make_unique<IntroScene>(placeholder_text, make_game);
};
+186
View File
@@ -0,0 +1,186 @@
#include "core/ui/ui_config.hpp"
#include <iostream>
#include <string>
#include <string_view>
#include <unordered_map>
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#include "utils/defaults.hpp"
#include "utils/easing.hpp"
namespace {
// Lector de camp amb fallback: si la clau existeix al node, escriu a
// `dst` el valor convertit; si no, deixa `dst` com estava (que al
// constructor ja hem inicialitzat amb Defaults::UI).
template <typename T>
void readField(const fkyaml::node& node, const char* key, T& dst) {
if (node.contains(key)) { dst = node[key].get_value<T>(); }
}
// Variant per a Uint8 — fkyaml no serialitza a Uint8 directament, cal
// passar per int i encaixar al rang 0..255 (valors fora de rang es
// trunquen i s'avisen).
void readColorIndex(const fkyaml::node& node, const char* key, Uint8& dst) {
if (!node.contains(key)) { return; }
const int VALUE = node[key].get_value<int>();
if (VALUE < 0 || VALUE > 255) {
std::cerr << "UiConfig: índex '" << key << "'=" << VALUE << " fora de rang 0..255 — ignorat\n";
return;
}
dst = static_cast<Uint8>(VALUE);
}
// Registre de corbes disponibles. Afegir-ne una nova és només una
// entrada ací: la llegeix `UiConfig::curveByName` i queda exposada al
// YAML pel seu nom. No hi ha fallback mut: si el nom no hi és, l'api
// retorna linear i avisa per stderr perquè l'error no passi en silenci.
auto curveRegistry() -> const std::unordered_map<std::string_view, UiConfig::CurveFn>& {
static const std::unordered_map<std::string_view, UiConfig::CurveFn> REGISTRY = {
{"linear", &Easing::linear},
{"outQuad", &Easing::outQuad},
{"inQuad", &Easing::inQuad},
{"inOutQuad", &Easing::inOutQuad},
{"outCubic", &Easing::outCubic},
{"inCubic", &Easing::inCubic},
};
return REGISTRY;
}
void readCurve(const fkyaml::node& node, const char* key, UiConfig::CurveFn& dst) {
if (!node.contains(key)) { return; }
const auto NAME = node[key].get_value<std::string>();
dst = UiConfig::curveByName(NAME);
}
void readFont(const fkyaml::node& node, const char* key, UiConfig::Font& dst) {
if (!node.contains(key)) { return; }
const auto& f = node[key];
readField(f, "name", dst.name);
readField(f, "gif", dst.gif);
readField(f, "fnt", dst.fnt);
readField(f, "kerning", dst.kerning);
}
} // namespace
UiConfig::UiConfig()
: render_info_text_(Defaults::UI::RENDER_INFO_TEXT),
render_info_shadow_(Defaults::UI::RENDER_INFO_SHADOW),
canvas_bg_(Defaults::UI::CANVAS_BG),
notification_text_(Defaults::UI::NOTIFICATION_TEXT),
notification_bg_(Defaults::UI::NOTIFICATION_BG),
exit_text_(Defaults::UI::EXIT_TEXT),
exit_bg_(Defaults::UI::EXIT_BG),
menu_bg_(Defaults::UI::MENU_BG),
menu_border_(Defaults::UI::MENU_BORDER),
menu_title_(Defaults::UI::MENU_TITLE),
menu_label_(Defaults::UI::MENU_LABEL),
menu_value_(Defaults::UI::MENU_VALUE),
menu_cursor_(Defaults::UI::MENU_CURSOR),
menu_disabled_(Defaults::UI::MENU_DISABLED),
hud_bg_(Defaults::UI::HUD_BG),
hud_label_(Defaults::UI::HUD_LABEL),
hud_value_(Defaults::UI::HUD_VALUE),
hud_flash_(Defaults::UI::HUD_FLASH),
notification_ms_(Defaults::UI::NOTIFICATION_TIME_MS),
exit_ms_(Defaults::UI::EXIT_TIME_MS),
hud_entry_ms_(Defaults::UI::HUD_ENTRY_MS),
hud_flash_ms_(Defaults::UI::HUD_FLASH_MS),
hud_shake_ms_(Defaults::UI::HUD_SHAKE_MS),
pause_enter_ms_(Defaults::UI::PAUSE_ENTER_MS),
pause_exit_ms_(Defaults::UI::PAUSE_EXIT_MS),
pause_enter_curve_(curveByName(Defaults::UI::PAUSE_ENTER_CURVE)),
pause_exit_curve_(curveByName(Defaults::UI::PAUSE_EXIT_CURVE)),
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},
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} {
}
auto UiConfig::get() -> UiConfig& {
static UiConfig instance_;
return instance_;
}
auto UiConfig::curveByName(const std::string& name) -> CurveFn {
const auto& reg = curveRegistry();
const auto IT = reg.find(name);
if (IT == reg.end()) {
std::cerr << "UiConfig: corba '" << name << "' desconeguda — s'usa linear\n";
return &Easing::linear;
}
return IT->second;
}
void UiConfig::load(const std::string& file_path) {
auto bytes = Resource::Helper::loadFile(file_path);
if (bytes.empty()) {
std::cerr << "UiConfig: 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("colors")) {
const auto& c = yaml["colors"];
readColorIndex(c, "render_info_text", render_info_text_);
readColorIndex(c, "render_info_shadow", render_info_shadow_);
readColorIndex(c, "canvas_bg", canvas_bg_);
readColorIndex(c, "notification_text", notification_text_);
readColorIndex(c, "notification_bg", notification_bg_);
readColorIndex(c, "exit_text", exit_text_);
readColorIndex(c, "exit_bg", exit_bg_);
readColorIndex(c, "menu_bg", menu_bg_);
readColorIndex(c, "menu_border", menu_border_);
readColorIndex(c, "menu_title", menu_title_);
readColorIndex(c, "menu_label", menu_label_);
readColorIndex(c, "menu_value", menu_value_);
readColorIndex(c, "menu_cursor", menu_cursor_);
readColorIndex(c, "menu_disabled", menu_disabled_);
readColorIndex(c, "hud_bg", hud_bg_);
readColorIndex(c, "hud_label", hud_label_);
readColorIndex(c, "hud_value", hud_value_);
readColorIndex(c, "hud_flash", hud_flash_);
}
if (yaml.contains("timings")) {
const auto& t = yaml["timings"];
readField(t, "notification_ms", notification_ms_);
readField(t, "exit_ms", exit_ms_);
}
if (yaml.contains("fonts")) {
const auto& f = yaml["fonts"];
readFont(f, "hud", hud_font_);
readFont(f, "placeholder", placeholder_font_);
readFont(f, "menu_title", menu_title_font_);
readFont(f, "menu_items", menu_items_font_);
}
if (yaml.contains("hud")) {
const auto& h = yaml["hud"];
readField(h, "bg_enabled", hud_bg_enabled_);
readField(h, "entry_ms", hud_entry_ms_);
readField(h, "flash_ms", hud_flash_ms_);
readField(h, "shake_ms", hud_shake_ms_);
}
if (yaml.contains("pause")) {
const auto& p = yaml["pause"];
readField(p, "enter_ms", pause_enter_ms_);
readField(p, "exit_ms", pause_exit_ms_);
readCurve(p, "enter_curve", pause_enter_curve_);
readCurve(p, "exit_curve", pause_exit_curve_);
}
std::cout << "UiConfig: carregat des de " << file_path << '\n';
} catch (const std::exception& e) {
std::cerr << "UiConfig: error parsejant " << file_path << ": " << e.what() << '\n';
}
}
+125
View File
@@ -0,0 +1,125 @@
#pragma once
#include <SDL3/SDL.h>
#include <string>
// Configuració del tema UI (índexs de paleta, timings, fonts i flags)
// carregada des de data/ui.yaml. Cada camp s'inicialitza amb el valor
// de Defaults::UI com a fallback: si el fitxer falta o li manca una
// clau, el joc s'executa amb el tema compilat.
//
// Patró idèntic a Locale i KeyConfig: Meyers singleton + load(path).
// No hi ha hot-reload — els canvis al YAML requereixen reiniciar.
class UiConfig {
public:
struct Font {
std::string name;
std::string gif;
std::string fnt;
int kerning;
};
static auto get() -> UiConfig&;
UiConfig(const UiConfig&) = delete;
UiConfig(UiConfig&&) = delete;
auto operator=(const UiConfig&) -> UiConfig& = delete;
auto operator=(UiConfig&&) -> UiConfig& = delete;
void load(const std::string& file_path);
// Colors — índexs sobre la paleta "ui" (Resource::Cache).
[[nodiscard]] auto renderInfoText() const -> Uint8 { return render_info_text_; }
[[nodiscard]] auto renderInfoShadow() const -> Uint8 { return render_info_shadow_; }
[[nodiscard]] auto canvasBg() const -> Uint8 { return canvas_bg_; }
[[nodiscard]] auto notificationText() const -> Uint8 { return notification_text_; }
[[nodiscard]] auto notificationBg() const -> Uint8 { return notification_bg_; }
[[nodiscard]] auto exitText() const -> Uint8 { return exit_text_; }
[[nodiscard]] auto exitBg() const -> Uint8 { return exit_bg_; }
[[nodiscard]] auto menuBg() const -> Uint8 { return menu_bg_; }
[[nodiscard]] auto menuBorder() const -> Uint8 { return menu_border_; }
[[nodiscard]] auto menuTitle() const -> Uint8 { return menu_title_; }
[[nodiscard]] auto menuLabel() const -> Uint8 { return menu_label_; }
[[nodiscard]] auto menuValue() const -> Uint8 { return menu_value_; }
[[nodiscard]] auto menuCursor() const -> Uint8 { return menu_cursor_; }
[[nodiscard]] auto menuDisabled() const -> Uint8 { return menu_disabled_; }
[[nodiscard]] auto hudBg() const -> Uint8 { return hud_bg_; }
[[nodiscard]] auto hudLabel() const -> Uint8 { return hud_label_; }
[[nodiscard]] auto hudValue() const -> Uint8 { return hud_value_; }
[[nodiscard]] auto hudFlash() const -> Uint8 { return hud_flash_; }
// 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).
[[nodiscard]] auto notificationMs() const -> int { return notification_ms_; }
[[nodiscard]] auto exitMs() const -> int { return exit_ms_; }
[[nodiscard]] auto hudEntryMs() const -> int { return hud_entry_ms_; }
[[nodiscard]] auto hudFlashMs() const -> int { return hud_flash_ms_; }
[[nodiscard]] auto hudShakeMs() const -> int { return hud_shake_ms_; }
[[nodiscard]] auto pauseEnterMs() const -> int { return pause_enter_ms_; }
[[nodiscard]] auto pauseExitMs() const -> int { return pause_exit_ms_; }
// Corbes d'easing triades al YAML. Si el nom no és conegut, s'usa
// Easing::linear com a fallback.
[[nodiscard]] auto pauseEnterCurve() const -> CurveFn { return pause_enter_curve_; }
[[nodiscard]] auto pauseExitCurve() const -> CurveFn { return pause_exit_curve_; }
// Resol un nom de corba a la seua funció. Pública per permetre a
// futurs camps d'UI triar corbes pel mateix mecanisme (ex.: HUD
// entry, notifications slide...). Si el nom no està registrat,
// retorna Easing::linear i deixa un warning al log.
[[nodiscard]] static auto curveByName(const std::string& name) -> CurveFn;
// Fonts de UI.
[[nodiscard]] auto hudFont() const -> const Font& { return hud_font_; }
[[nodiscard]] auto placeholderFont() const -> const Font& { return placeholder_font_; }
[[nodiscard]] auto menuTitleFont() const -> const Font& { return menu_title_font_; }
[[nodiscard]] auto menuItemsFont() const -> const Font& { return menu_items_font_; }
// Flags visuals.
[[nodiscard]] auto hudBgEnabled() const -> bool { return hud_bg_enabled_; }
private:
UiConfig();
~UiConfig() = default;
Uint8 render_info_text_;
Uint8 render_info_shadow_;
Uint8 canvas_bg_;
Uint8 notification_text_;
Uint8 notification_bg_;
Uint8 exit_text_;
Uint8 exit_bg_;
Uint8 menu_bg_;
Uint8 menu_border_;
Uint8 menu_title_;
Uint8 menu_label_;
Uint8 menu_value_;
Uint8 menu_cursor_;
Uint8 menu_disabled_;
Uint8 hud_bg_;
Uint8 hud_label_;
Uint8 hud_value_;
Uint8 hud_flash_;
int notification_ms_;
int exit_ms_;
int hud_entry_ms_;
int hud_flash_ms_;
int hud_shake_ms_;
int pause_enter_ms_;
int pause_exit_ms_;
CurveFn pause_enter_curve_;
CurveFn pause_exit_curve_;
Font hud_font_;
Font placeholder_font_;
Font menu_title_font_;
Font menu_items_font_;
bool hud_bg_enabled_{false};
};
+1724
View File
File diff suppressed because it is too large. Load diff
+13
View File
@@ -0,0 +1,13 @@
// Unitat de compilació aïllada per a la implementació de stb_image_write.
// Viu dins de source/external/ perquè el `.clang-tidy` d'aquesta carpeta
// desactiva tots els checks (com fa per stb_vorbis.c) i el pre-commit hook
// ja filtra aquesta ruta de clang-format / clang-tidy. Així els fals
// positius de clang-analyzer-* dins de codi de tercers no afecten el
// nostre codi, que continua tenint tots els checks actius.
//
// La resta del codi inclou només el header (sense la macro d'implementació),
// que queda només amb declaracions — clang-analyzer no pot trobar cap bug
// dins d'una declaració, així que l'inclusió és innòcua.
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "external/stb_image_write.h"
+13
View File
@@ -0,0 +1,13 @@
// Unitat de compilació aïllada per a la implementació de stb_vorbis.
// Viu dins de source/external/ perquè el `.clang-tidy` d'aquesta carpeta
// desactiva tots els checks (com fa per stb_image_write_impl.cpp) i el
// pre-commit hook ja filtra aquesta ruta de clang-format / clang-tidy.
// Així els fals positius de clang-analyzer-* dins de codi C de tercers
// no afecten el nostre codi, que continua tenint tots els checks actius.
//
// jail_audio.cpp defineix STB_VORBIS_HEADER_ONLY abans d'incloure el .c,
// així només en veu les declaracions; les definicions les aporta aquest
// TU i l'enllaçador les resol.
// NOLINTNEXTLINE(bugprone-suspicious-include)
#include "external/stb_vorbis.c"
+52 -2
View File
@@ -33,6 +33,17 @@ namespace DebugConfig {
return name == "LOGO" || name == "TITLE" || name == "INTRO" || name == "GAME";
}
auto playersFromName(const std::string& name) -> StartingPlayers {
if (name == "P1") { return StartingPlayers::P1; }
if (name == "P2") { return StartingPlayers::P2; }
if (name == "BOTH") { return StartingPlayers::BOTH; }
return StartingPlayers::BOTH;
}
auto isKnownPlayers(const std::string& name) -> bool {
return name == "P1" || name == "P2" || name == "BOTH";
}
} // namespace
auto parseStartScene(const std::string& yaml_content) -> StartScene {
@@ -67,6 +78,31 @@ namespace DebugConfig {
return parseStartScene(CONTENT);
}
auto parseStartingPlayers(const std::string& yaml_content) -> StartingPlayers {
if (yaml_content.empty()) { return StartingPlayers::BOTH; }
try {
auto yaml = fkyaml::node::deserialize(yaml_content);
if (!yaml.contains("starting_players")) { return StartingPlayers::BOTH; }
const auto RAW = yaml["starting_players"].get_value<std::string>();
if (!isKnownPlayers(RAW)) {
std::cerr << "debug.yaml: starting_players \"" << RAW << "\" invàlid, arrancant amb BOTH\n";
return StartingPlayers::BOTH;
}
return playersFromName(RAW);
} catch (const std::exception& e) {
std::cerr << "debug.yaml: error parsejant starting_players: " << e.what() << " — arrancant amb BOTH\n";
return StartingPlayers::BOTH;
}
}
auto resolveStartingPlayers(const std::string& file_path) -> StartingPlayers {
std::error_code error;
if (!std::filesystem::exists(file_path, error)) { return StartingPlayers::BOTH; }
const auto CONTENT = readFile(file_path);
if (CONTENT.empty()) { return StartingPlayers::BOTH; }
return parseStartingPlayers(CONTENT);
}
auto toString(StartScene scene) -> const char* {
switch (scene) {
case StartScene::LOGO:
@@ -81,6 +117,18 @@ namespace DebugConfig {
return "GAME";
}
auto toString(StartingPlayers players) -> const char* {
switch (players) {
case StartingPlayers::P1:
return "P1";
case StartingPlayers::P2:
return "P2";
case StartingPlayers::BOTH:
return "BOTH";
}
return "BOTH";
}
void createDefaultIfMissing(const std::string& file_path) {
std::error_code error;
if (std::filesystem::exists(file_path, error)) { return; }
@@ -90,9 +138,11 @@ namespace DebugConfig {
return;
}
file << "# debug.yaml — ajust personal de dev (no versionat).\n";
file << "# Valors vàlids: LOGO, TITLE, INTRO, GAME.\n";
file << "# start_scene: LOGO | TITLE | INTRO | GAME\n";
file << "# starting_players: P1 | P2 | BOTH\n";
file << "start_scene: GAME\n";
std::cout << "debug.yaml: creat a " << file_path << " amb start_scene=GAME\n";
file << "starting_players: BOTH\n";
std::cout << "debug.yaml: creat a " << file_path << " amb start_scene=GAME starting_players=BOTH\n";
}
} // namespace DebugConfig
+14
View File
@@ -14,6 +14,13 @@ namespace DebugConfig {
INTRO,
GAME };
// Quins jugadors arrenquen a la GameScene. BOTH és el cas de joc real;
// P1 i P2 són només punts d'entrada de dev per provar cada personatge
// aïllat. Llegit de `debug.yaml` (clau `starting_players`).
enum class StartingPlayers : std::uint8_t { P1,
P2,
BOTH };
// Parseja un contingut YAML en cru. Si no conté `start_scene`, el valor és
// desconegut o el YAML és invàlid, retorna GAME. Pura: no fa I/O.
[[nodiscard]] auto parseStartScene(const std::string& yaml_content) -> StartScene;
@@ -24,8 +31,15 @@ namespace DebugConfig {
// GAME.
[[nodiscard]] auto resolveStartScene(const std::string& file_path) -> StartScene;
// Paral·lel a parseStartScene / resolveStartScene per al camp
// `starting_players`. El valor per defecte (tant si el fitxer no
// existeix com si li manca la clau) és BOTH, que és el cas de joc real.
[[nodiscard]] auto parseStartingPlayers(const std::string& yaml_content) -> StartingPlayers;
[[nodiscard]] auto resolveStartingPlayers(const std::string& file_path) -> StartingPlayers;
// Nom canònic de l'enum (útil per a logs): "LOGO" / "TITLE" / "INTRO" / "GAME".
[[nodiscard]] auto toString(StartScene scene) -> const char*;
[[nodiscard]] auto toString(StartingPlayers players) -> const char*;
// Si el fitxer no existeix, el crea amb `start_scene: GAME` (el valor
// per defecte pensat per al dia a dia de dev). Si ja existeix, no el
+245
View File
@@ -0,0 +1,245 @@
#include "game/editor/editor.hpp"
#ifdef _DEBUG
#include <SDL3/SDL.h>
#include <algorithm>
#include "core/input/key_config.hpp"
#include "core/locale/locale.hpp"
#include "core/rendering/notification_presets.hpp"
#include "core/rendering/screen.hpp"
#include "core/rendering/surface.hpp"
#include "core/resources/resource_cache.hpp"
#include "core/resources/resource_helper.hpp"
#include "core/system/event_buffer.hpp"
#include "game/map/tilemap.hpp"
#include "utils/defaults.hpp"
#include "utils/defines.hpp"
namespace {
// Geometria del TilePicker en espai canvas. `cols × rows_visible` tiles
// encapsulats dins d'un rectangle amb `PICKER_MARGIN` per vora. Com que
// el tileset real (p.ex. 32×16 tiles de 16 px = 512×256) no cabria a
// 1× dins del canvas 336×240, linealitzem els índexs i els repartim
// en una graella que SÍ cap, amb scroll vertical per veure la resta.
struct PickerLayout {
int x{0};
int y{0};
int w{0};
int h{0};
int cols{0};
int rows_visible{0};
int tile_size{0};
int total_tiles{0};
int max_scroll{0};
};
auto layoutPicker(const Tilemap& tm) -> PickerLayout {
PickerLayout lay{};
lay.tile_size = tm.getTileSize();
const int M = Defaults::Editor::PICKER_MARGIN;
const int INNER_W = GameCanvas::WIDTH - (2 * M);
const int INNER_H = GameCanvas::HEIGHT - (2 * M);
if (lay.tile_size <= 0) { return lay; }
lay.cols = INNER_W / lay.tile_size;
lay.rows_visible = INNER_H / lay.tile_size;
lay.x = M;
lay.y = M;
lay.w = lay.cols * lay.tile_size;
lay.h = lay.rows_visible * lay.tile_size;
const auto TILESET = Resource::Cache::get().getSurface(tm.getTilesetPath());
if (TILESET != nullptr) {
const int TCOLS = TILESET->getWidth() / lay.tile_size;
const int TROWS = TILESET->getHeight() / lay.tile_size;
lay.total_tiles = TCOLS * TROWS;
}
const int TOTAL_ROWS = (lay.cols > 0) ? ((lay.total_tiles + lay.cols - 1) / lay.cols) : 0;
lay.max_scroll = std::max(0, TOTAL_ROWS - lay.rows_visible);
return lay;
}
} // namespace
void Editor::enter() {
if (active_) { return; }
active_ = true;
Notification::notify(Locale::get().text("input.editor_on"));
}
// cppcheck-suppress constParameterReference
// Tilemap& mutable per simetria amb `toggle()` i per futures
// invalidacions de caches d'edició, encara que `save()` sigui const.
void Editor::exit(Tilemap& tilemap) {
if (!active_) { return; }
active_ = false;
picker_open_ = false;
// Resolem la ruta via Resource::Helper perquè en Debug el root de
// dades és AEE_SOURCE_DIR (ruta absoluta al repo), no el CWD. Sense
// això, el writer obriria `data/maps/pyramid.yaml` relatiu al CWD
// i fallaria sempre excepte si el binari es llança des del repo root.
const auto ABS_PATH = Resource::Helper::resolve(Defaults::Editor::SAVE_PATH);
const bool SAVED = tilemap.save(ABS_PATH);
Notification::notify(Locale::get().text(SAVED ? "input.editor_off" : "input.editor_save_error"));
}
void Editor::toggle(Tilemap& tilemap) {
if (active_) {
exit(tilemap);
} else {
enter();
}
}
void Editor::update(int /*delta_ms*/, Tilemap& tilemap) {
if (!active_) { return; }
// Posició del ratolí en coordenades del canvas (independent del
// zoom de finestra i del mode d'escalat).
float mouse_x = 0.0F;
float mouse_y = 0.0F;
SDL_GetMouseState(&mouse_x, &mouse_y);
const auto CP = Screen::get().windowToCanvas(mouse_x, mouse_y);
const int CX = static_cast<int>(CP.x);
const int CY = static_cast<int>(CP.y);
// Consumim tecles pròpies del picker abans que cap altre handler
// (GlobalInputs) les vegi. ESC només es consumeix si el picker està
// obert — fora d'ell segueix arribant a GlobalInputs per la seva
// acció de confirmar-eixida.
const SDL_Scancode PICKER_SC = KeyConfig::get().scancode("editor_picker");
for (auto it = EventBuffer::events.begin(); it != EventBuffer::events.end();) {
bool consumed = false;
if (it->type == SDL_EVENT_KEY_DOWN) {
if (PICKER_SC != SDL_SCANCODE_UNKNOWN && it->key.scancode == PICKER_SC) {
picker_open_ = !picker_open_;
if (picker_open_) { picker_scroll_ = 0; }
consumed = true;
} else if (picker_open_ && it->key.scancode == SDL_SCANCODE_ESCAPE) {
picker_open_ = false;
consumed = true;
}
}
if (consumed) {
it = EventBuffer::events.erase(it);
} else {
++it;
}
}
if (picker_open_) {
updatePicker(tilemap, CX, CY);
} else {
updateMap(tilemap, CX, CY);
}
}
void Editor::updateMap(Tilemap& tilemap, int canvas_x, int canvas_y) {
// Cursor → cel·la del tilemap. Fora del mapa deixem -1/-1 perquè
// `render()` sàpiga no dibuixar preview i `setTileAt` ignori el clic.
const int TS = tilemap.getTileSize();
const int MX = canvas_x - tilemap.getOriginX();
const int MY = canvas_y - tilemap.getOriginY();
cursor_col_ = -1;
cursor_row_ = -1;
if (TS > 0 && MX >= 0 && MY >= 0) {
const int C = MX / TS;
const int R = MY / TS;
if (C < tilemap.getCols() && R < tilemap.getRows()) {
cursor_col_ = C;
cursor_row_ = R;
}
}
for (const auto& ev : EventBuffer::events) {
if (ev.type != SDL_EVENT_MOUSE_BUTTON_DOWN) { continue; }
if (ev.button.button == SDL_BUTTON_LEFT) {
if (brush_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) {
tilemap.setTileAt(cursor_col_, cursor_row_, brush_);
}
} else if (ev.button.button == SDL_BUTTON_RIGHT) {
brush_ = -1;
}
}
}
void Editor::updatePicker(const Tilemap& tilemap, int canvas_x, int canvas_y) {
const auto LAY = layoutPicker(tilemap);
picker_hover_ = -1;
if (canvas_x >= LAY.x && canvas_x < LAY.x + LAY.w && canvas_y >= LAY.y && canvas_y < LAY.y + LAY.h && LAY.tile_size > 0) {
const int VC = (canvas_x - LAY.x) / LAY.tile_size;
const int VR = (canvas_y - LAY.y) / LAY.tile_size;
const int IDX = ((picker_scroll_ + VR) * LAY.cols) + VC;
if (IDX < LAY.total_tiles) { picker_hover_ = IDX; }
}
for (const auto& ev : EventBuffer::events) {
if (ev.type == SDL_EVENT_MOUSE_WHEEL) {
picker_scroll_ -= static_cast<int>(ev.wheel.y);
picker_scroll_ = std::clamp(picker_scroll_, 0, LAY.max_scroll);
} else if (ev.type == SDL_EVENT_MOUSE_BUTTON_DOWN && ev.button.button == SDL_BUTTON_LEFT) {
if (picker_hover_ >= 0) {
brush_ = picker_hover_;
picker_open_ = false;
}
}
}
}
void Editor::render(const Tilemap& tilemap) const {
if (!active_) { return; }
auto& screen = Screen::get();
constexpr int W = GameCanvas::WIDTH;
constexpr int H = GameCanvas::HEIGHT;
if (picker_open_) {
renderPicker(tilemap);
} else if (brush_ >= 0 && cursor_col_ >= 0 && cursor_row_ >= 0) {
// Preview del brush sota el cursor (només quan el picker és tancat).
const int PX = tilemap.getOriginX() + (cursor_col_ * tilemap.getTileSize());
const int PY = tilemap.getOriginY() + (cursor_row_ * tilemap.getTileSize());
tilemap.renderTilePreview(PX, PY, brush_);
}
constexpr auto BORDER = Defaults::Editor::BORDER_ARGB;
screen.fillRect(0, 0, W, 1, BORDER);
screen.fillRect(0, H - 1, W, 1, BORDER);
screen.fillRect(0, 0, 1, H, BORDER);
screen.fillRect(W - 1, 0, 1, H, BORDER);
}
void Editor::renderPicker(const Tilemap& tilemap) const {
const auto LAY = layoutPicker(tilemap);
auto& screen = Screen::get();
screen.fillRect(LAY.x, LAY.y, LAY.w, LAY.h, Defaults::Editor::PICKER_BG_ARGB);
for (int vr = 0; vr < LAY.rows_visible; ++vr) {
const int LOGICAL_ROW = picker_scroll_ + vr;
for (int vc = 0; vc < LAY.cols; ++vc) {
const int IDX = (LOGICAL_ROW * LAY.cols) + vc;
if (IDX >= LAY.total_tiles) { break; }
const int PX = LAY.x + (vc * LAY.tile_size);
const int PY = LAY.y + (vr * LAY.tile_size);
tilemap.renderTilePreview(PX, PY, IDX);
}
}
if (picker_hover_ >= 0 && LAY.cols > 0) {
const int VR = (picker_hover_ / LAY.cols) - picker_scroll_;
const int VC = picker_hover_ % LAY.cols;
if (VR >= 0 && VR < LAY.rows_visible) {
const int PX = LAY.x + (VC * LAY.tile_size);
const int PY = LAY.y + (VR * LAY.tile_size);
const int TS = LAY.tile_size;
constexpr auto C = Defaults::Editor::PICKER_HOVER_ARGB;
screen.fillRect(PX, PY, TS, 1, C);
screen.fillRect(PX, PY + TS - 1, TS, 1, C);
screen.fillRect(PX, PY, 1, TS, C);
screen.fillRect(PX + TS - 1, PY, 1, TS, C);
}
}
}
#endif // _DEBUG
+65
View File
@@ -0,0 +1,65 @@
#pragma once
#ifdef _DEBUG
class Tilemap;
// Mode editor modal del GameScene. Només disponible en Debug — en Release
// aquesta classe no existeix (el .cpp es compila a un object buit i cap
// codi del joc final en fa referència).
//
// 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
// (mútuament excloents). L'editor s'encarrega de:
// - Estat propi (actiu/no actiu, brush, picker obert).
// - Input del ratolí i tecles internes (`T`, `ESC`).
// - Render del border blau + brush preview + picker.
// - Persistència del tilemap al YAML del repo en sortir.
//
// GameScene, quan `active()` és true, salta el tick de jugadors/entitats
// i no els pinta; el tilemap sí es pinta perquè és el que s'està editant.
class Editor {
public:
// Entra al mode editor: notifica i marca `active=true`. La congelació
// d'entitats / jugador la gestiona GameScene consultant `active()`.
void enter();
// Surt del mode editor: persisteix el tilemap al YAML (writer surgical)
// i notifica. Si la persistència falla, emet la notificació d'error.
void exit(Tilemap& tilemap);
// Helper: enter() si no actiu, exit() si actiu. Útil des de GameScene
// quan detecta la tecla de toggle.
void toggle(Tilemap& tilemap);
[[nodiscard]] auto active() const -> bool { return active_; }
// Processa input propi (brush, picker). No-op si no actiu.
void update(int delta_ms, Tilemap& tilemap);
// Pinta el border blau i, més endavant, brush preview + picker.
// No-op si no actiu.
void render(const Tilemap& tilemap) const;
private:
// Les branques d'update / render segons el mode (picker obert o
// pintura al mapa) queden separades per claredat; no formen part
// de l'API pública perquè només té sentit cridar-les des d'`update`.
void updateMap(Tilemap& tilemap, int canvas_x, int canvas_y);
void updatePicker(const Tilemap& tilemap, int canvas_x, int canvas_y);
void renderPicker(const Tilemap& tilemap) const;
bool active_{false};
// Estat de l'edició (brush + cursor + picker). Es poblen al pas
// següent; els declarem ara perquè `update()` pugui mutar-los i les
// 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};
bool picker_open_{false};
int picker_scroll_{0}; // Fila superior visible del picker (linealitzat).
int picker_hover_{-1}; // Índex del tile sota el cursor dins del picker.
};
#endif // _DEBUG
+25 -4
View File
@@ -5,6 +5,8 @@
#include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp"
#include "game/map/map.hpp"
namespace {
@@ -63,16 +65,35 @@ void Door::update(float delta_time) {
}
}
void Door::onPlayerContact(Player* /*player*/) {
if (state_ != State::OPEN || level_complete_emitted_) { return; }
if (bus_ != nullptr) { bus_->publish("level_complete"); }
level_complete_emitted_ = true;
void Door::onPlayerContact(Player* player) {
// Només la porta OPEN despawna jugadors. CLOSED/OPENING ignoren el
// contacte (i a més, en CLOSED, el `Map::collides` ja bloqueja el
// moviment, així que el contacte no es dóna en condicions normals).
if (state_ != State::OPEN || player == nullptr || player->isExited()) { return; }
// Marquem el jugador com a EXITED i publiquem l'event d'agregació.
// El check `isExited` de la guarda anterior garanteix que només
// emetem l'event en la primera transició — `EntityManager` compta
// emissions per decidir quan publicar `level_complete`.
player->exit();
if (bus_ != nullptr) {
bus_->publish(Event{
.name = "player_exited",
.payload = std::monostate{},
.player_id = player->getId(),
});
}
}
void Door::open() {
if (state_ != State::CLOSED) { return; }
state_ = State::OPENING;
setAnimation(anim_opening_);
// Marquem el passadís com a transitable: a partir d'aquest tic
// `Map::collides` deixa de bloquejar el rect del passadís. La
// transició d'animació OPENING → OPEN encara dura el seu temps,
// però la geometria ja és permeable — coherent amb el feel de
// "la porta s'obri i el jugador pot avançar al moment".
Map::door_open = true;
// Edge case: si `opening` té ≤1 frame i és loop=true, mai es
// completarà. Per força, forcem loop=false del frame únic aquí. Els
// placeholders defineixen `loop: false` explícitament al YAML, així
+7 -5
View File
@@ -7,11 +7,14 @@
#include "game/entities/interactable.hpp"
// Porta de sortida del nivell. Tres estats:
// CLOSED → inicial; bloqueja el pas.
// CLOSED → inicial; el passadís de Map bloqueja el pas via `Map::collides`.
// OPENING → animació d'obertura en curs. Quan el component acaba →
// transita a OPEN.
// OPEN → accessible. Al contacte amb el jugador publica
// `level_complete` al bus (si n'hi ha).
// transita a OPEN. El passadís ja és transitable (s'allibera
// a `open()` amb `Map::door_open = true`).
// OPEN → accessible. Al contacte amb un jugador, crida `Player::exit()`
// — el jugador queda despawnat i emet `player_exited` al bus.
// L'agregació "tots els jugadors han eixit → `level_complete`"
// viu a EntityManager.
//
// El que decideix quan s'obri és extern (`EntityManager` comptant sarcs
// oberts) via `open()`. L'animació `opening` es fa servir com a
@@ -38,7 +41,6 @@ class Door : public Interactable {
void setAnimation(const AnimationComponent& anim);
State state_{State::CLOSED};
bool level_complete_emitted_{false};
// Les 3 animacions precalculades al ctor des del YAML. L'activa
// s'assigna a `anim_` (heretat d'Entity) segons l'estat.
+57
View File
@@ -0,0 +1,57 @@
#include "game/entities/concrete/dust.hpp"
#include <string>
#include <vector>
#include "game/entities/entity_factory.hpp"
namespace {
// Llig una animació opcional del bloc `animations` del YAML, amb
// valors per defecte segurs (1 frame, fps=1, sense loop). Si la
// clau no existeix, retornem una animació "ja completada" — així,
// un Dust spawnejat amb una variant inexistent al YAML mor al
// pròxim frame sense petar.
auto buildAnim(const fkyaml::node& yaml, const std::string& key) -> AnimationComponent {
std::vector<int> frames = {0};
float fps = 1.0F;
bool loop = false;
if (yaml.contains("animations") && yaml["animations"].contains(key)) {
const auto& anim = yaml["animations"][key];
if (anim.contains("frames")) {
frames.clear();
for (const auto& f : anim["frames"]) {
// cppcheck-suppress useStlAlgorithm
frames.push_back(f.get_value<int>());
}
if (frames.empty()) { frames.push_back(0); }
}
if (anim.contains("fps")) { fps = anim["fps"].get_value<float>(); }
if (anim.contains("loop")) { loop = anim["loop"].get_value<bool>(); }
}
return {frames, fps, loop};
}
} // namespace
Dust::Dust(const fkyaml::node& yaml)
: anim_jump_(buildAnim(yaml, "jump")),
anim_land_(buildAnim(yaml, "land")) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("dust");
collides_ = false;
interactable_ = false;
// Per defecte JUMP; el spawner pot canviar-la abans del primer tick.
anim_ = anim_jump_;
}
void Dust::setVariant(Variant v) {
anim_ = (v == Variant::JUMP) ? anim_jump_ : anim_land_;
if (anim_.has_value()) { anim_->reset(); }
}
void Dust::update(float delta_time) {
Entity::update(delta_time);
if (anim_.has_value() && anim_->completed()) { markDead(); }
}
REGISTER_ENTITY("dust", Dust)
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <cstdint>
#include "external/fkyaml_node.hpp"
#include "game/entities/animation_component.hpp"
#include "game/entities/entity.hpp"
// Polvet transitori: efecte purament visual que viu una animació i
// s'autoelimina (`markDead`) quan acaba. Spawneja el `GameScene` com a
// reacció a events `player_jumped` i `player_landed` del bus. Les dues
// variants (despegue / aterratge) viuen al mateix YAML amb dues
// animacions distintes (`jump` i `land`); l'instància tria una via
// `setVariant` just després de construir-se.
class Dust : public Entity {
public:
enum class Variant : std::uint8_t {
JUMP,
LAND,
};
explicit Dust(const fkyaml::node& yaml);
// Tria quina animació es reproduirà. Cal cridar-lo abans del primer
// `update`; per defecte la variant és JUMP.
void setVariant(Variant v);
// Tickeja l'animació activa. Quan arriba a `completed()`, marquem
// l'entitat morta perquè l'`EntityManager` la purgue al final del
// frame.
void update(float delta_time) override;
private:
AnimationComponent anim_jump_;
AnimationComponent anim_land_;
};
+9 -2
View File
@@ -4,6 +4,7 @@
#include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp"
FloorTrap::FloorTrap(const fkyaml::node& yaml) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("floor_trap");
@@ -21,9 +22,15 @@ FloorTrap::FloorTrap(const fkyaml::node& yaml) {
}
}
void FloorTrap::onPlayerContact(Player* /*player*/) {
void FloorTrap::onPlayerContact(Player* player) {
if (!isActive()) { return; }
if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); }
if (bus_ != nullptr) {
bus_->publish(Event{
.name = "player_hit",
.payload = getDamage(),
.player_id = (player != nullptr) ? player->getId() : -1,
});
}
}
REGISTER_ENTITY("floor_trap", FloorTrap)
+9 -2
View File
@@ -9,6 +9,7 @@
#include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp"
#include "game/map/map.hpp"
namespace {
@@ -294,8 +295,14 @@ void Mummy::applyChoice(Direction dir) {
}
}
void Mummy::onPlayerContact(Player* /*player*/) {
if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); }
void Mummy::onPlayerContact(Player* player) {
if (bus_ != nullptr) {
bus_->publish(Event{
.name = "player_hit",
.payload = getDamage(),
.player_id = (player != nullptr) ? player->getId() : -1,
});
}
}
REGISTER_ENTITY("mummy", Mummy)
@@ -4,6 +4,7 @@
#include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp"
#include "game/map/map.hpp"
namespace {
@@ -60,8 +61,14 @@ void RollingRock::step(float delta_time) {
setDirection(0.0F, 0.0F);
}
void RollingRock::onPlayerContact(Player* /*player*/) {
if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); }
void RollingRock::onPlayerContact(Player* player) {
if (bus_ != nullptr) {
bus_->publish(Event{
.name = "player_hit",
.payload = getDamage(),
.player_id = (player != nullptr) ? player->getId() : -1,
});
}
}
REGISTER_ENTITY("rolling_rock", RollingRock)
@@ -112,7 +112,7 @@ void Sarcophagus::update(float delta_time) {
}
}
void Sarcophagus::notifyTileEntered(int col, int row) {
void Sarcophagus::notifyTilePainted(int col, int row) {
if (state_ != State::CLOSED) { return; }
const TileKey KEY{.col = col, .row = row};
if (perimeter_tiles_.find(KEY) == perimeter_tiles_.end()) { return; }
@@ -42,7 +42,7 @@ class Sarcophagus : public Interactable {
void place(int anchor_col, int anchor_row, const BlockedPredicate& is_blocked);
void update(float delta_time) override;
void notifyTileEntered(int col, int row) override;
void notifyTilePainted(int col, int row) override;
[[nodiscard]] auto getState() const -> State { return state_; }
[[nodiscard]] auto getPerimeterSize() const -> std::size_t { return perimeter_tiles_.size(); }
+9 -2
View File
@@ -4,6 +4,7 @@
#include "game/entities/entity_factory.hpp"
#include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp"
WallTrap::WallTrap(const fkyaml::node& yaml) {
name_ = yaml.contains("name") ? yaml["name"].get_value<std::string>() : std::string("wall_trap");
@@ -21,9 +22,15 @@ WallTrap::WallTrap(const fkyaml::node& yaml) {
}
}
void WallTrap::onPlayerContact(Player* /*player*/) {
void WallTrap::onPlayerContact(Player* player) {
if (!isActive()) { return; }
if (bus_ != nullptr) { bus_->publish("player_hit", getDamage()); }
if (bus_ != nullptr) {
bus_->publish(Event{
.name = "player_hit",
.payload = getDamage(),
.player_id = (player != nullptr) ? player->getId() : -1,
});
}
}
REGISTER_ENTITY("wall_trap", WallTrap)
+1 -1
View File
@@ -23,7 +23,7 @@ void Entity::onPlayerContact(Player* /*player*/) {
// No-op per defecte.
}
void Entity::notifyTileEntered(int /*col*/, int /*row*/) {
void Entity::notifyTilePainted(int /*col*/, int /*row*/) {
// No-op per defecte; només `Sarcophagus` l'usa.
}
+25 -3
View File
@@ -77,9 +77,10 @@ class Entity {
virtual void onPlayerContact(Player* player);
// Hook que el `EntityManager` invoca per a cada entitat cada vegada
// que el jugador entra en un tile nou. Només `Sarcophagus` ho consumeix
// (per marcar perímetre visitat); per defecte no-op.
virtual void notifyTileEntered(int col, int row);
// que un tile es pinta (via PaintLayer, com a conseqüència d'un
// jugador trepitjant un sòl pintable). Només `Sarcophagus` ho consumeix
// (per marcar perímetre); per defecte no-op.
virtual void notifyTilePainted(int col, int row);
[[nodiscard]] auto getName() const -> const std::string& { return name_; }
[[nodiscard]] auto getTilePos() const -> TilePos { return tile_; }
@@ -87,6 +88,14 @@ class Entity {
[[nodiscard]] auto collides() const -> bool { return collides_; }
[[nodiscard]] auto interactable() const -> bool { return interactable_; }
// Marca per a entitats transitòries: efectes (polvet, espurnes,
// explosions...) que viuen una sola animació i s'autoeliminen quan
// acaba. L'`EntityManager` purga totes les entitats amb `dead_=true`
// al final de cada `update`. Les entitats persistents (sarcs, momies,
// portes, jugadors) mai no la posen i hi viuen sempre.
[[nodiscard]] auto isDead() const -> bool { return dead_; }
void markDead() { dead_ = true; }
// Associar el bus una sola vegada (propietat externa, p.ex.
// `EntityManager`). Entitats que no publiquen events (Torch, trampes
// estàtiques de moment) el poden ignorar.
@@ -112,6 +121,17 @@ class Entity {
// Entitats sense animació retornen `nullopt` → frame 0 per defecte.
[[nodiscard]] auto getAnimation() const -> const std::optional<AnimationComponent>& { return anim_; }
// Alçada del cos de l'entitat en píxels. La consulten els filtres
// de col·lisió per altura: si un jugador en l'aire passa per damunt
// d'aquest valor (jump_z_ > height), no col·lisiona amb l'entitat —
// és la mecànica del salt per esquivar momies, roques, etc. Valor
// 0 = sense altura (no es pot esquivar saltant; per a entitats amb
// collides=false el camp és irrellevant). Per a obstacles que
// representen "murs" (sarcs, portes), el YAML els pot donar un
// valor molt alt (p.ex. 999) per fer-los infranquejables.
[[nodiscard]] auto getHeight() const -> int { return height_; }
void setHeight(int h) { height_ = h; }
// Caixa de col·lisió: per defecte és el `pixel_rect_` sencer (entitats
// AABB-aligned). Si s'ha configurat un `CollisionBox`, retorna el
// sub-rect dins del sprite — vida útil: validar col·lisions amb el
@@ -149,6 +169,8 @@ class Entity {
std::shared_ptr<Surface> surface_{nullptr};
bool collides_{false};
bool interactable_{false};
int height_{0};
bool dead_{false};
SDL_FlipMode flip_{SDL_FLIP_NONE};
EventBus* bus_{nullptr};
};
+20 -15
View File
@@ -43,23 +43,28 @@ class EntityFactory {
};
// La macro genera un objecte anònim dins un namespace anònim: el seu
// constructor s'executa abans de `main` i registra la factory. El nom del
// símbol és únic per `CLASS` perquè el preprocessor el concatena; així
// dues entitats diferents no xoquen.
// constructor s'executa abans de `main` i registra la factory. El nom
// del símbol es compon amb `TAG`, perquè així es poden registrar àlies
// per una mateixa classe (p.ex. dues claus diferents al YAML lligades a
// la mateixa classe C++) sense que els símbols xoquen. La forma curta
// `REGISTER_ENTITY(NAME, CLASS)` reusa `CLASS` com a tag; per àlies cal
// usar `REGISTER_ENTITY_AS` i passar un identificador únic.
//
// IMPORTANT: perquè el registrador es cridi, el `.cpp` que el conté ha
// d'estar enllaçat directament a l'executable (no dins una STATIC lib
// sense `--whole-archive`). Tots els `concrete/*.cpp` entren a APP_SOURCES.
#define REGISTER_ENTITY(NAME, CLASS) \
namespace { \
struct CLASS##_Registrar { \
CLASS##_Registrar() { \
EntityFactory::instance().registerCreator( \
NAME, \
[](const fkyaml::node& yaml) -> std::unique_ptr<Entity> { \
return std::make_unique<CLASS>(yaml); \
}); \
} \
}; \
const CLASS##_Registrar g_##CLASS##_registrar; /* NOLINT(cert-err58-cpp) */ \
#define REGISTER_ENTITY_AS(NAME, CLASS, TAG) \
namespace { \
struct TAG##_Registrar { \
TAG##_Registrar() { \
EntityFactory::instance().registerCreator( \
NAME, \
[](const fkyaml::node& yaml) -> std::unique_ptr<Entity> { \
return std::make_unique<CLASS>(yaml); \
}); \
} \
}; \
const TAG##_Registrar g_##TAG##_registrar; /* NOLINT(cert-err58-cpp) */ \
}
#define REGISTER_ENTITY(NAME, CLASS) REGISTER_ENTITY_AS(NAME, CLASS, CLASS)
+43 -7
View File
@@ -24,18 +24,45 @@ EntityManager::EntityManager() {
}
});
// Subscripció al moviment del jugador: quan publica `on_tile_entered`
// amb payload `TilePos`, fem fan-out a totes les entitats perquè els
// sarcòfags puguin actualitzar el seu `visited_`.
tile_sub_id_ = bus_.subscribe("on_tile_entered", [this](const Event& ev) {
// Subscripció al pintat: el PaintLayer publica `tile_painted` amb
// payload `TilePos` un cop ha filtrat pintabilitat i aplicat la regla
// "primer que pinta guanya". Fem fan-out a totes les entitats perquè
// els sarcòfags puguin actualitzar el seu `visited_`.
tile_sub_id_ = bus_.subscribe("tile_painted", [this](const Event& ev) {
const auto* tile = std::get_if<TilePos>(&ev.payload);
if (tile != nullptr) { notifyPlayerEnteredTile(tile->col, tile->row); }
if (tile != nullptr) { notifyPlayerTilePainted(tile->col, tile->row); }
});
// Subscripció a `player_exited` (publicat per Door en travessar la
// porta oberta amb un jugador): comptem una vegada per jugador i,
// quan tots els jugadors actius han eixit, emetem `level_complete`
// — l'event d'agregat que GameScene escolta per disparar la
// transició FSM a ROOM_OUTRO. La idempotència de Door (check
// `isExited`) garanteix que cada jugador suma com a màxim un cop.
exit_sub_id_ = bus_.subscribe("player_exited", [this](const Event& /*ev*/) {
++exited_players_;
if (level_complete_emitted_) { return; }
if (expected_players_ > 0 && exited_players_ >= expected_players_) {
level_complete_emitted_ = true;
bus_.publish(Event{
.name = "level_complete",
.payload = std::monostate{},
.player_id = -1,
});
}
});
}
EntityManager::~EntityManager() {
bus_.unsubscribe(sarc_sub_id_);
bus_.unsubscribe(tile_sub_id_);
bus_.unsubscribe(exit_sub_id_);
}
void EntityManager::setExpectedPlayerCount(int n) {
expected_players_ = n;
exited_players_ = 0;
level_complete_emitted_ = false;
}
auto EntityManager::add(std::unique_ptr<Entity> entity) -> Entity* {
@@ -63,11 +90,20 @@ auto EntityManager::addSarcophagus(std::unique_ptr<Sarcophagus> sarc) -> Sarcoph
}
void EntityManager::update(float delta_time) {
// ATENCIÓ: si en el futur una entitat publica al bus events que
// resulten en `entity_manager_.add()` (push_back a `entities_`), açò
// invalidaria l'iterador de la ranged-for. Avui no passa: els spawns
// runtime venen de fora del cicle d'`update` (p.ex. el polvet del
// saltador es spawneja durant `Player::tick`, abans d'aquest mètode).
for (auto& e : entities_) { e->update(delta_time); }
// Purga d'entitats marcades mortes aquest frame (efectes acabats,
// p.ex. polvet d'aterratge). Les persistents (sarcs, momies, etc.)
// mai no es marquen.
std::erase_if(entities_, [](const auto& e) { return e->isDead(); });
}
void EntityManager::notifyPlayerEnteredTile(int col, int row) {
for (auto& e : entities_) { e->notifyTileEntered(col, row); }
void EntityManager::notifyPlayerTilePainted(int col, int row) {
for (auto& e : entities_) { e->notifyTilePainted(col, row); }
}
auto EntityManager::getEntities() const -> std::vector<Entity*> {
+32 -6
View File
@@ -17,9 +17,9 @@ class Player;
// 2. `update(dt)` propaga el tick a totes.
// 3. Comptador de sarcòfags oberts — quan iguala el total registrat,
// crida `door_->open()` automàticament.
// 4. `notifyPlayerEnteredTile(col, row)` — fan-out del hook per a tots
// 4. `notifyPlayerTilePainted(col, row)` — fan-out del hook per a tots
// els sarcòfags (i qualsevol entitat futura que s'hi subscrigui via
// `notifyTileEntered`).
// `notifyTilePainted`).
//
// NO és un singleton: es crea una instància per nivell. De moment no
// s'usa encara al GameScene (andamiatge pur).
@@ -74,10 +74,11 @@ class EntityManager {
// reflecteixi l'estat del frame actual.
void checkPlayerCollisions(const std::vector<Player*>& players);
// Fan-out del hook de moviment de jugador: per a cada sarcòfag,
// cridar notifyTileEntered. Les entitats no-sarcòfag ho ignoren per
// defecte (veure Entity::notifyTileEntered).
void notifyPlayerEnteredTile(int col, int row);
// Fan-out del hook de pintat: per a cada sarcòfag, cridar
// notifyTilePainted. Les entitats no-sarcòfag ho ignoren per defecte
// (veure Entity::notifyTilePainted). Invocat com a reacció a
// `tile_painted` al bus (re-publicat pel PaintLayer un cop filtrat).
void notifyPlayerTilePainted(int col, int row);
// Carrega els spawns d'un YAML de nivell i els afegeix al manager:
// 1. Llegeix el bloc `entities:` via EntityLoader.
@@ -89,10 +90,27 @@ class EntityManager {
// `Map::tile_size`, `tilemap_origin_*` i `getSarcRect`).
void loadFromLevel(const std::string& level_yaml_path);
// Reset complet del nivell: destrueix totes les entitats, reseteja
// els comptadors interns (sarcòfags oberts, total, exited, flag de
// level_complete) i torna a cridar `loadFromLevel` per regenerar les
// entitats des del YAML. El bus i les seues subscripcions
// sobreviuen — això inclou les subscripcions externes (paint_layer_,
// hud_, players, GameScene). Cridat des de GameScene a la transició
// ROOM_OUTRO → ROOM_INTRO. `setExpectedPlayerCount` cal cridar-lo de
// nou després si vols agregació de level_complete al nou cicle.
void reload(const std::string& level_yaml_path);
// Fixa quants jugadors actius es consideren al recompte d'EXITED.
// Cridat per GameScene un cop instanciats els jugadors. Si manca,
// `expected_players_ = 0` i mai s'emet `level_complete` automàticament.
// Cridar-lo de nou al respawn (futur reload) reseteja el comptador.
void setExpectedPlayerCount(int n);
[[nodiscard]] auto getBus() -> EventBus& { return bus_; }
[[nodiscard]] auto entityCount() const -> std::size_t { return entities_.size(); }
[[nodiscard]] auto sarcophagusCount() const -> std::size_t { return total_sarcs_; }
[[nodiscard]] auto openedSarcophagusCount() const -> std::size_t { return opened_sarcs_; }
[[nodiscard]] auto exitedPlayerCount() const -> std::size_t { return exited_players_; }
private:
EventBus bus_;
@@ -100,6 +118,14 @@ class EntityManager {
Door* door_{nullptr};
std::size_t total_sarcs_{0};
std::size_t opened_sarcs_{0};
// Recompte d'`player_exited`: incrementat a la subscripció al bus,
// resetejat per `setExpectedPlayerCount`. Quan `exited_players_` arriba
// a `expected_players_` (i tots dos > 0), publica `level_complete`
// amb `player_id = -1` (event d'agregat, no atribuible a un jugador).
int expected_players_{0};
int exited_players_{0};
bool level_complete_emitted_{false};
EventBus::SubId sarc_sub_id_{0};
EventBus::SubId tile_sub_id_{0};
EventBus::SubId exit_sub_id_{0};
};
@@ -63,9 +63,20 @@ void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) {
for (const auto& entity : entities_) {
if (!entity->collides()) { continue; }
const auto BOX = entity->getCollisionRect();
const int E_HEIGHT = entity->getHeight();
for (auto* p : players) {
if (p == nullptr) { continue; }
const auto PBOX = p->getBaseBox();
if (p == nullptr || p->isExited()) { continue; }
// Filtre per altura: si el jugador està en l'aire i està per
// damunt de l'alçada del cos de l'entitat, no hi ha contacte.
// És el mecanisme del salt per esquivar momies, roques, etc.
// Quan height és 0 (entitat sense altura declarada), el filtre
// no es dispara mai i la col·lisió funciona com abans.
if (p->isAirborne() && E_HEIGHT > 0 && p->getJumpZ() > static_cast<float>(E_HEIGHT)) { continue; }
// Contacte actor-actor amb hit-box petita: la caixa base
// 16×16 servia per moure (xoc amb murs), però per a l'event
// "toca una entitat" volem precisió — que la momia mate quan
// els píxels centrals es solapen, no quan els caps es freguen.
const auto PBOX = p->getHitBox();
const bool OVERLAP = PBOX.x + PBOX.w > BOX.x && BOX.x + BOX.w > PBOX.x && PBOX.y + PBOX.h > BOX.y && BOX.y + BOX.h > PBOX.y;
if (OVERLAP) { entity->onPlayerContact(p); }
}
@@ -105,6 +116,18 @@ namespace {
return info;
}
// Llig `collision.height` (alçada del cos en píxels) de la plantilla.
// 0 si no està declarada — ho usen els filtres de col·lisió per altura
// del salt: un jugador per damunt d'aquest valor no col·lisiona amb
// l'entitat. Per a obstacles "tipus mur" (sarc, porta) el YAML els
// dóna un valor molt alt per fer-los infranquejables.
auto readHeight(const fkyaml::node& tmpl) -> int {
if (!tmpl.contains("collision")) { return 0; }
const auto& c = tmpl["collision"];
if (!c.contains("height")) { return 0; }
return c["height"].get_value<int>();
}
// Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }`
// de la plantilla d'entitat.
// - Sense `base_size` ni `offset`: la caja queda alineada a la vora
@@ -208,6 +231,7 @@ namespace {
if (const auto BOX = readCollisionBox(tmpl, sprite_out.width, sprite_out.height); BOX.has_value()) {
entity->setCollisionBox(*BOX);
}
entity->setHeight(readHeight(tmpl));
anchor_out = readAnchor(tmpl);
return entity;
}
@@ -267,6 +291,34 @@ namespace {
return true;
}
// Col·loca la porta al mur trasser segons `Map::door_col`. La porta
// viu sempre al `wall_north`; només la columna varia per nivell. El
// sprite (típicament 48×32) es centra horitzontalment sobre la
// columna transitable i s'alinea verticalment de manera que la seua
// vora inferior coincideixi amb la base del strip del mur trasser
// (= `playfield_y`). Amb sprite 48×32 i `back_wall_h = 24` això vol
// dir que els 8 px superiors del sprite queden dins del HUD i hi
// queden tapats — comportament intencional.
auto placeDoor(EntityManager& manager, std::unique_ptr<Entity> entity, const SpriteInfo& sprite) -> bool {
if (Map::door_col <= 0 || Map::tile_size <= 0) {
std::cerr << "EntityManager: door requereix `geometry.door_col` definit al mapa\n";
return false;
}
const float CENTER_X = static_cast<float>((Map::door_col * Map::tile_size) + Map::tilemap_origin_x) + (static_cast<float>(Map::tile_size) / 2.0F);
const SDL_FRect RENDER{
.x = CENTER_X - (sprite.width / 2.0F),
.y = static_cast<float>(Map::playfield_y) - sprite.height,
.w = sprite.width,
.h = sprite.height,
};
entity->setPixelRect(RENDER);
auto* door_ptr = dynamic_cast<Door*>(entity.get());
manager.add(std::move(entity));
if (door_ptr != nullptr) { manager.setDoor(door_ptr); }
return true;
}
auto placeGeneric(EntityManager& manager, std::unique_ptr<Entity> entity, const std::string& type, const SpriteInfo& sprite, const std::string& anchor, const fkyaml::node& spawn) -> bool {
if (!spawn.contains("position")) {
std::cerr << "EntityManager: " << type << " sense position\n";
@@ -298,17 +350,30 @@ namespace {
}
entity->setPixelRect({.x = rect_x, .y = rect_y, .w = sprite.width, .h = sprite.height});
if (type == "door") {
auto* door_ptr = dynamic_cast<Door*>(entity.get());
manager.add(std::move(entity));
if (door_ptr != nullptr) { manager.setDoor(door_ptr); }
} else {
manager.add(std::move(entity));
}
manager.add(std::move(entity));
return true;
}
} // namespace
void EntityManager::reload(const std::string& level_yaml_path) {
// Destrucció ordenada: les entitats moren aquí; les seues
// subscripcions implícites desapareixen amb la seua vida (cap d'elles
// se subscriu directament al bus — només publiquen). El bus i les
// subscripcions externes (PaintLayer, HUD, GameScene) no es toquen.
entities_.clear();
door_ = nullptr;
total_sarcs_ = 0;
opened_sarcs_ = 0;
// No reset de `expected_players_`: GameScene el tornarà a fixar
// (amb el mateix valor o un de nou) després del reload, com a part
// del respawn dels jugadors. Reseteig dels comptadors d'EXITED i
// del flag d'emissió perquè el cicle següent pugui reagregar net.
exited_players_ = 0;
level_complete_emitted_ = false;
loadFromLevel(level_yaml_path);
}
void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
const auto SPAWNS = EntityLoader::readSpawns(level_yaml_path);
int loaded = 0;
@@ -322,9 +387,15 @@ void EntityManager::loadFromLevel(const std::string& level_yaml_path) {
auto entity = buildEntity(TYPE, sprite, anchor);
if (entity == nullptr) { continue; }
const bool OK = (TYPE == "sarcophagus")
? placeSarcophagus(*this, std::move(entity), sprite, spawn)
: placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn);
bool ok = false;
if (TYPE == "sarcophagus") {
ok = placeSarcophagus(*this, std::move(entity), sprite, spawn);
} else if (TYPE == "door") {
ok = placeDoor(*this, std::move(entity), sprite);
} else {
ok = placeGeneric(*this, std::move(entity), TYPE, sprite, anchor, spawn);
}
const bool OK = ok;
if (OK) { ++loaded; }
}
+33 -3
View File
@@ -23,17 +23,41 @@ class Entity;
// consumir events tipats amb `std::get_if`. Si apareixen més tipus de
// payload, s'amplia aquí sense tocar cridants existents.
// Tile (col, row) — payload de `on_tile_entered`. No és un `pair<int,int>`
// per evitar alias ambigus al variant (dos ints sense nom).
// Tile (col, row) — payload de `tile_painted` (i altres events de tile sense
// metadada extra). No és un `pair<int,int>` per evitar alias ambigus al
// variant (dos ints sense nom).
struct TilePos {
int col{0};
int row{0};
};
// Tile pintat pel jugador — payload de `on_tile_entered`. Porta l'índex
// de tile amb què el jugador pinta (cada Player pot pintar amb un tile
// diferent, llegit del YAML del mapa). El PaintLayer filtra pintabilitat,
// marca i re-publica `tile_painted` amb només la posició.
struct PaintedTile {
int col{0};
int row{0};
int tile_index{-1};
};
// Coordenades en píxels al canvas. Payload genèric per a events que
// volen indicar "una cosa ha passat aquí" — actualment `player_jumped`
// i `player_landed`, que el GameScene escolta per spawnejar polvet.
struct WorldPos {
float x{0.0F};
float y{0.0F};
};
struct Event {
std::string name;
using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos>;
using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos, PaintedTile, WorldPos>;
Payload payload;
// Jugador que ha originat (o provocat) l'event. -1 = sense jugador
// associat (events del món, com `sarcophagus_opened` des de la pròpia
// entitat). Usat per al HUD (dispatch a P1/P2) i per a regles futures
// de puntuació que atribueixin accions al jugador concret.
int player_id{-1};
};
class EventBus {
@@ -71,6 +95,12 @@ class EventBus {
void publish(const std::string& name, TilePos tile) {
publish(Event{.name = name, .payload = tile});
}
void publish(const std::string& name, PaintedTile painted) {
publish(Event{.name = name, .payload = painted});
}
void publish(const std::string& name, WorldPos pos) {
publish(Event{.name = name, .payload = pos});
}
private:
struct Subscription {
File diff suppressed because it is too large. Load diff
+438 -6
View File
@@ -5,8 +5,10 @@
#include <cstdint>
#include <string>
#include <utility>
#include <vector>
#include "core/rendering/sprite/animated_sprite.hpp"
#include "external/fkyaml_node.hpp"
#include "game/map/map.hpp"
class EventBus;
@@ -21,9 +23,37 @@ class Player : public AnimatedSprite {
SDL_Scancode down;
SDL_Scancode left;
SDL_Scancode right;
SDL_Scancode jump;
};
// Geometria carregada des de `data/player/player.yaml`. `sprite_*` són
// Estat d'alt nivell del jugador. Ortogonal a `Facing`/`Cardinal`
// (direcció) i a `being_pushed_` (flag d'animació). Determina què pot
// fer el jugador aquest frame:
// GROUNDED → input normal, pot saltar.
// AIRBORNE → física del salt; ignora input direccional, sense
// col·lisió amb caminants però sí amb altres
// saltadors a la mateixa franja d'alçada (rebot).
// LANDING_STUN → 200ms post-aterratge, sense input ni moviment.
// HURT → projectat sense control (cau d'algú al damunt o,
// en el futur, fuetada): velocitat inicial alta
// que decau per inèrcia fins a parar (~2 tiles).
// No col·lisiona amb cap altre jugador. Quan
// s'atura, torna a GROUNDED.
enum class State : std::uint8_t {
GROUNDED,
AIRBORNE,
LANDING_STUN,
HURT,
// Jugador que ha eixit per la porta de la sala. Despawn lògic:
// no es renderitza, no processa input, no es mou, no col·lisiona
// amb res ni rep contacte d'entitats. Idempotent: les crides
// posteriors a `exit()` són no-op. El reset el porta `respawn()`
// (cridada per GameScene en transició OUTRO → INTRO).
EXITED,
};
// Geometria carregada des del YAML del player (p.ex. `data/player/sam.yaml`).
// `sprite_*` són
// la mida del frame (sprite 12×24 amb perspectiva falsa: visualment, els
// 12 píxels superiors són el cap i els 12 inferiors la base).
// La caixa de col·lisió és un quadrat `collision_size` concèntric amb el
@@ -38,23 +68,167 @@ class Player : public AnimatedSprite {
inline static int collision_x_offset = 0;
inline static int collision_y_offset = 0;
// Caixa de "contacte" més petita que la caixa de col·lisió: s'usa per
// a les interaccions actor-actor (player↔player, player↔momia/trampa),
// on volem que el contacte sigui precís i no dispari a "distància"
// de la vora del sprite. La caixa de col·lisió gran continua usant-se
// per xocar amb murs i sarcòfags (per mantenir el carril del
// passadís). Sense `hit_size` al YAML, s'iguala a `collision_size` i
// el comportament és idèntic a l'anterior.
inline static int hit_size = 0;
inline static int hit_x_offset = 0;
inline static int hit_y_offset = 0;
// Alçada del cos del jugador en píxels (camp `collision.height` del
// YAML, mateixa convenció que entitats com mummy o rolling_rock).
// L'usen els filtres de col·lisió per altura: si un altre actor ens
// creua mentre `jump_z_` és superior a aquest valor, no col·lisionem.
inline static int body_height = 0;
// Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor.
static void loadFrom(const std::string& yaml_path);
Player(const std::string& anim_key, float x, float y, const Controls& controls, const Map::Info& map_info);
Player(const std::string& anim_key, int id, float x, float y, const Controls& controls, const Map::Info& map_info, int paint_tile_index = -1);
// Update en una sola crida: manté l'API anterior per als tests i
// escenaris amb un sol jugador. Internament és equivalent a cridar
// `tick(dt)` seguit d'`applyMovement(dt)`.
void update(float delta_time) override;
// Fase 1: llig l'input d'aquest frame i emmagatzema la velocitat
// intencionada a `intended_vx_` / `intended_vy_`. No mou el jugador.
// Separada d'`applyMovement` perquè el GameScene puga resoldre
// interaccions entre jugadors (empentes, neutralitzacions head-on)
// abans que ningú consume la posició.
void tick(float delta_time);
// Fase 2: consumeix `intended_vx_`/`intended_vy_` (possiblement
// modificats pel resolver), crida `tryMove` contra el mapa i els
// altres jugadors, fa snap al passadís, clamp al playfield, avança
// l'animació i publica `on_tile_entered` si ha canviat de tile.
void applyMovement(float delta_time);
// Identificador local (0 = P1, 1 = P2…). S'usa als events del bus
// perquè els consumidors (HUD, futures regles de puntuació) sàpiguen
// quin jugador ha originat l'event. No té cap vinculació amb la
// xarxa — és únic per dins del procés.
[[nodiscard]] auto getId() const -> int { return id_; }
// Accés i mutació de la velocitat intencionada d'aquest frame. El
// GameScene els toca entre `tick` i `applyMovement` per implementar
// col·lisions jugador↔jugador (escalar a push_factor, anul·lar en
// head-on). Expressades en píxels/segon, no en píxels/frame.
// Amb inèrcia activada, `intended_v*` pot ser no-zero encara que
// l'usuari no estiga prement res (coast residual).
[[nodiscard]] auto getIntendedVX() const -> float { return intended_vx_; }
[[nodiscard]] auto getIntendedVY() const -> float { return intended_vy_; }
void setIntendedVelocity(float vx, float vy) {
intended_vx_ = vx;
intended_vy_ = vy;
}
// Velocitat crua d'input d'aquest frame (-SPEED, 0 o +SPEED per eix).
// A diferència de `intended_v*`, no té en compte la inèrcia: un jugador
// que coasta sense prémer res té `input_v* == 0`. El resolver PvP del
// GameScene l'usa per decidir "parat vs en moviment" (criteri: un
// coast sense input compta com parat — l'empenta i el head-on només
// es disparen quan hi ha intenció explícita del jugador).
[[nodiscard]] auto getInputVX() const -> float { return input_vx_; }
[[nodiscard]] auto getInputVY() const -> float { return input_vy_; }
// Llista de tots els jugadors del nivell (incloent-se ell mateix —
// la callback ignora `this`). La caixa del jugador es considera un
// sòlid a la callback de col·lisió: així `tryMove` no permet que un
// jugador travesse l'altre, i els desplaçaments parcials (push
// contra mur) s'emparen automàticament.
void setOtherPlayers(const std::vector<Player*>* all) { all_players_ = all; }
// Marcat pel resolver jugador↔jugador quan aquest frame el jugador
// és arrossegat per una empenta (no té input propi; es desplaça
// perquè l'altre l'empeny). Determina que l'animació del frame siga
// `stand_*` encara que la posició canvie. El flag es reseteja a
// l'inici de cada `tick`, així que el resolver l'ha de tornar a
// posar cada frame que el push s'haja de mantenir.
void setBeingPushed(bool pushed) { being_pushed_ = pushed; }
// Factor d'escala d'empenta jugador↔jugador (viu al YAML del mapa).
// L'usa el resolver de GameScene per al push horitzontal, i també
// `snapToLane` quan descobreix que un altre jugador li bloqueja el
// carril objectiu: per coherència, la pushada del snap va al mateix
// ritme que l'horitzontal. Si no es crida, el valor per defecte 1.0
// manté el snap a velocitat plena (comportament pre-multiplayer).
void setPushFactor(float factor) { push_factor_ = factor; }
// Y de la base del jugador (cara inferior de la caixa de colisió). Usada
// per al Z-order contra els sarcs: peus per sobre d'un sarc → darrere;
// per sota → davant.
// per sota → davant. NO restem `jump_z_`: el saltador no canvia de
// tile lògica, només s'eleva visualment, i el sorting ha de respectar
// la posició al món (un sarc al nord segueix darrere encara que el
// sprite del jugador estigui amunt). Veure també `getGroundFeetY`,
// que coincideix amb aquest mètode i s'usa específicament per
// marcar la posició de la sombra a nivell de sòl.
[[nodiscard]] auto getFeetY() const -> float {
return getPosY() + static_cast<float>(collision_y_offset + collision_size);
}
// Alies semàntic: els peus del jugador al sòl. Coincideix amb
// `getFeetY` i s'usa al callsite del render de la sombra per fer
// explícit que la sombra es pinta al pla del sòl, no a la posició
// visual del sprite.
[[nodiscard]] auto getGroundFeetY() const -> float { return getFeetY(); }
// Estat actual de la màquina d'estats. L'usa el render (animació
// jump_*), el resolver de col·lisions (filtre per altura) i el
// GameScene (per saber si renderitzar la sombra).
[[nodiscard]] auto getState() const -> State { return state_; }
[[nodiscard]] auto isAirborne() const -> bool { return state_ == State::AIRBORNE; }
[[nodiscard]] auto isHurt() const -> bool { return state_ == State::HURT; }
[[nodiscard]] auto isExited() const -> bool { return state_ == State::EXITED; }
// Tangible per a contactes jugador↔jugador: només els estats a terra
// amb cos sòlid. AIRBORNE i HURT són intangibles (es travessen).
// EXITED tampoc — el jugador ha eixit lògicament i ja no és al món.
[[nodiscard]] auto isPvpTangible() const -> bool {
return state_ == State::GROUNDED || state_ == State::LANDING_STUN;
}
// Despawn lògic: el jugador acaba de creuar la porta de sortida.
// Idempotent — només la primera crida fa la transició; les
// posteriors són no-op. La publicació del `player_exited` al bus
// la fa el caller (típicament `Door::onPlayerContact`) per
// mantenir aquesta capçalera lleugera (sense incloure event_bus).
// Definit inline perquè els consumidors de baix nivell (test
// d'entitats) puguen enllaçar Door sense arrossegar player.cpp.
void exit() {
if (state_ == State::EXITED) { return; }
state_ = State::EXITED;
intended_vx_ = 0.0F;
intended_vy_ = 0.0F;
// Important: també resetegem `input_v*` perquè el resolver
// jugador↔jugador (`resolvePlayerVsPlayer`) consulta
// `getInputVX/Y` per decidir si un jugador "es mou". Un EXITED
// amb input fantasma del moment d'entrar a la porta dispararia
// la branca head-on i bloquejaria l'altre jugador en sec.
input_vx_ = 0.0F;
input_vy_ = 0.0F;
being_pushed_ = false;
}
// Reset complet a l'estat inicial: posició a (x, y), velocitats a 0,
// estat GROUNDED, qualsevol flag de control reinicialitzat. Cridat
// per GameScene a la transició ROOM_OUTRO → ROOM_INTRO. Idempotent
// (es pot cridar des de qualsevol estat).
void respawn(float x, float y);
// Alçada actual del salt (píxels visuals per damunt del sòl).
// 0 quan està a terra. L'usa el render i el sistema de col·lisions
// per altura.
[[nodiscard]] auto getJumpZ() const -> float { return jump_z_; }
// Injecta el bus del nivell. El Player publica `on_tile_entered` cada
// vegada que el centre de la seua caixa de col·lisió entra en una
// cel·la nova del tilemap. Si el bus no s'injecta, no emet res.
// cel·la nova del tilemap, amb payload `PaintedTile{col,row,paint_tile_index_}`.
// El PaintLayer filtra pintabilitat i re-publica `tile_painted` a qui
// correspongui (sarcòfags). Si el bus no s'injecta, no emet res.
void setEventBus(EventBus* bus) { bus_ = bus; }
// Tile actual del jugador (centre de `baseBox()` convertit a
@@ -63,21 +237,49 @@ class Player : public AnimatedSprite {
[[nodiscard]] auto getCurrentTile() const -> std::pair<int, int>;
// Rectangle de col·lisió del jugador, en coordenades del canvas. Usat
// pel mapa de col·lisions i per les entitats que comproven contacte
// amb el jugador (momies, trampes, porta).
// pel mapa de col·lisions (tryMove contra murs/sarcòfags).
[[nodiscard]] auto getBaseBox() const -> SDL_FRect;
// Rectangle de contacte actor-actor (hit-box), més petit que la caixa
// base i concèntric amb ella. L'usen `checkPlayerCollisions`
// (momies, trampes, porta) i el resolver jugador↔jugador per
// decidir quan es toquen.
[[nodiscard]] auto getHitBox() const -> SDL_FRect;
// Versió estàtica: deriva la hit-box a partir d'una caixa base
// arbitrària (p. ex. la posició proposada per `tryMove` abans
// d'aplicar-la). Evita recalcular `hit_x_offset`/`hit_y_offset` fora
// de Player.
[[nodiscard]] static auto hitBoxFromBase(const SDL_FRect& base) -> SDL_FRect;
// Override del hook de Sprite: `MovingSprite::tryMove` envia aquesta
// caixa al callback de col·lisió. La base del Player és més petita
// que el sprite (el cap pot sobresortir per murs/sarcòfags sense
// bloquejar el moviment).
[[nodiscard]] auto collisionBox() const -> SDL_FRect override { return getBaseBox(); }
// Render del jugador. Mentre està en l'aire, el sprite es desplaça
// visualment cap amunt `jump_z_` píxels (el cos del jugador, no la
// sombra al sòl, que la pinta el GameScene a banda). La col·lisió i
// la posició al món no s'alteren.
void render() override;
// Pinta una sombra translúcida sota el jugador a nivell de sòl
// (sense aplicar `jump_z_`). El GameScene la crida en una passada
// pre-actors, així queda per sota dels actors i del propi jugador.
// No-op si el jugador no està en l'aire.
void renderShadow() const;
private:
// Alias privat històric. Mantingut perquè viu dins de l'implementació
// (snapToLane, clamp). Els consumidors externs usen `getBaseBox()`.
[[nodiscard]] auto baseBox() const -> SDL_FRect { return getBaseBox(); }
// Callback de col·lisió per a `tryMove`: true si la caixa proposada
// xoca amb el mapa o amb un altre jugador. Membre per mantenir la
// complexitat cognitiva d'`applyMovement` sota el llindar.
[[nodiscard]] auto collidesWithWorld(const SDL_FRect& box) const -> bool;
// Clamp de la caixa de col·lisió al playfield (no al sprite sencer:
// el cap pot sobresortir pel mur superior sense afectar el joc).
void clampToPlayfield();
@@ -101,7 +303,57 @@ class Player : public AnimatedSprite {
float (Player::*get_pos)() const,
void (Player::*set_pos)(float));
// Quan el snap lateral trobaria un altre jugador al carril objectiu,
// 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
// contracte que el push horitzontal però disparat pel snap. Retorna
// true si la push ha quedat neta (ningú xoca amb el mapa ni amb un
// tercer jugador); false fa que el caller revertisca el propi snap.
// Marca els blockers amb `being_pushed_` per a la seua animació.
auto tryPushSnapBlockers(
float signed_step,
float (Player::*get_pos)() const,
void (Player::*set_pos)(float)) -> bool;
int id_{-1};
Controls controls_;
// Velocitat intencionada calculada a `tick` i aplicada a
// `applyMovement`. El GameScene pot sobreescriure-la entremig per
// resoldre interaccions jugador↔jugador (empentes, head-on).
// També actua com a estat físic persistent entre frames: amb inèrcia
// activada, si l'usuari solta l'input, aquí queda la velocitat
// residual que `tick` anirà decaient linealment cap a 0.
float intended_vx_{0.0F};
float intended_vy_{0.0F};
// Velocitat desitjada per input aquest frame (teclat + gamepad). Cada
// eix és -SPEED, 0 o +SPEED. És la "intenció del jugador"; la física
// resultant viu a `intended_v*` i pot diferir (coast sense input).
float input_vx_{0.0F};
float input_vy_{0.0F};
// Acumulador de temps en moviment continu. Creix cada frame amb
// `|input_v*| > 0` o `|intended_v*| > 0`, i es reseteja a 0 només
// quan NO hi ha ni input ni velocitat residual. Activa la inèrcia
// quan passa el llindar configurat al YAML (`threshold_ms`); no es
// buida mai enmig d'un coast, ni tan sols en canvis de direcció,
// així un toc curt en sentit oposat no "frena en sec" un coast llarg.
float motion_time_ms_{0.0F};
// Punter a la llista de tots els jugadors del nivell (propietat del
// GameScene). `nullptr` = jugador aïllat, sense col·lisió inter-P.
const std::vector<Player*>* all_players_{nullptr};
// Frame actual el jugador està sent empentat (no té input propi).
// Serveix només per a l'elecció d'animació — la posició ja la
// gestiona `intended_vx_/vy_` escalats pel resolver.
bool being_pushed_{false};
// Escala d'empenta usada per `snapToLane` quan descobreix un blocker
// (mateixa del resolver). 1.0 = sense escalar (comportament si
// GameScene no ha cridat setPushFactor).
float push_factor_{1.0F};
// Còpia plana de la geometria del mapa + punters a `collides`/
// `inside_playfield`. POD trivial; copiada al ctor i no muta durant
// la vida del Player. Vol dir que aquest Player només és vàlid per
@@ -119,6 +371,180 @@ class Player : public AnimatedSprite {
DOWN };
Facing facing_{Facing::SIDE};
// Cardinal complet (N/S/E/W) usat per a la detecció de girs de 180°.
// El parell (Facing, flip) només té 3+1 estats; aquesta enum n'afegeix
// la 4a sense reorganitzar el sistema d'animacions.
enum class Cardinal : std::uint8_t { NONE,
NORTH,
SOUTH,
EAST,
WEST };
// Configuració d'inèrcia llegida del bloc `inertia:` del YAML. Amb
// `brake_time_ms == 0` (o bloc absent) la inèrcia està desactivada i
// el comportament és instantani: soltar input para el jugador en sec.
struct InertiaConfig {
int brake_time_ms{0}; // Temps en ms perquè la velocitat caiga de ±SPEED a 0.
int threshold_ms{0}; // Temps mínim en moviment continu per activar la inèrcia.
};
static InertiaConfig inertia_config;
// Integra un eix. Si hi ha input en aquest eix, la velocitat física
// salta a aquest valor (acceleració instantània). Si no, i hi ha
// input en l'altre eix (`any_input == true`), la velocitat d'aquest
// eix es posa a 0 en sec: el jugador està fent un gir de 90°, no
// soltant l'input — no volem que arrossegue en diagonal. Només quan
// l'usuari deixa anar tot l'input (`any_input == false`) s'activa la
// inèrcia de frenada (si `motion_time_ms` ha passat el llindar).
static void integrateAxis(float& phys, float input, float delta_time, float motion_time_ms, bool any_input);
// Configuració del gir de 180°, llegida del bloc `turn:` del YAML del
// player. Si `duration_ms` és 0 (o el bloc no existeix) la mecànica
// queda desactivada i els canvis de direcció són instantanis.
struct TurnConfig {
int duration_ms{0};
Cardinal w_to_e{Cardinal::SOUTH};
Cardinal e_to_w{Cardinal::NORTH};
Cardinal n_to_s{Cardinal::EAST};
Cardinal s_to_n{Cardinal::WEST};
};
// Definida out-of-class al .cpp: dins de la classe, els initializers
// per defecte de TurnConfig encara no són visibles per al compilador.
static TurnConfig turn_config;
// Configuració del salt llegida del bloc `jump:` del YAML del player.
// Si `impulse_px_per_s == 0` (o el bloc no existeix) la mecànica del
// salt queda desactivada — el botó no té efecte.
struct JumpConfig {
float impulse_px_per_s{0.0F}; // Velocitat vertical inicial.
float gravity_px_per_s2{0.0F}; // Acceleració descendent constant.
int landing_stun_ms{0}; // Stun en aterrar (no es mou ni salta).
int shadow_y_offset{0}; // Px que puja la sombra (compensa transparències al peu del sprite).
std::string sound_key; // Clau al Resource::Cache del so de despegue (buit = silenci).
std::string land_sound_key; // Clau del so d'aterratge (buit = silenci).
};
static JumpConfig jump_config;
// Llig el bloc `collision:` del YAML i n'omple les caixes (size/base/
// hit) + `body_height`. Llança si el bloc no existeix. Membre estàtic
// per accedir als estàtics privats sense exposar-los.
static void readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path);
// Llig el bloc `jump:` del YAML i n'omple `jump_config`. Si el bloc
// manca, deixa la mecànica del salt desactivada.
static void readJumpBlock(const fkyaml::node& yaml);
// Configuració de l'estat HURT (estat post-impacte). Si
// `initial_speed_px_per_s == 0` el jugador entra i surt
// instantàniament (cap moviment).
struct HurtConfig {
float initial_speed_px_per_s{0.0F}; // Velocitat amb què surt projectat (px/s).
int brake_time_ms{0}; // Temps en que la velocitat decau a 0.
};
static HurtConfig hurt_config;
// Llig el bloc `hurt:` del YAML i n'omple `hurt_config`.
static void readHurtBlock(const fkyaml::node& yaml);
// Activa l'estat HURT: el jugador queda projectat amb velocitat
// inicial `hurt_config.initial_speed_px_per_s`. La direcció es tria
// així:
// - Si `preferred != NONE` i hi cap el recorregut natural complet
// (sense topar amb mur abans d'esgotar la inèrcia), s'usa
// `preferred`.
// - Altrament (o si `preferred == NONE`), prova els 4 cardinals
// i tria el que dóna més recorregut lliure.
// Decelera linealment fins a parar i, en eixe instant, torna a
// GROUNDED. Sense col·lisió amb altres jugadors mentre dura.
void enterHurt(Cardinal preferred = Cardinal::NONE);
// Tria la direcció definitiva del HURT. Si `preferred` cabe el
// recorregut natural complet, la retorna; d'altra manera tria la
// direcció amb més recorregut lliure entre els 4 cardinals.
[[nodiscard]] auto chooseHurtDirection(Cardinal preferred) const -> Cardinal;
// Recorregut lliure (en píxels, fins a `max_px`) en la direcció
// (dx, dy) abans de topar amb un mur o eixir del playfield. La
// base-box es projecta `i` píxels i es comprova; retorna `i-1` al
// primer xoc.
[[nodiscard]] auto reachInDirection(float dx, float dy, int max_px) const -> int;
// Vectors unitaris dels 4 cardinals. `NONE` retorna (0, 0).
[[nodiscard]] static auto deltasFor(Cardinal c) -> std::pair<float, float>;
// Estat de la màquina d'estats. Mutat per `tick`/`applyMovement`. Per
// defecte GROUNDED al spawn.
State state_{State::GROUNDED};
// Física del salt (només té sentit en AIRBORNE; ambdós a 0 a terra).
// jump_z_ → altura visual del jugador per damunt del sòl, en píxels.
// jump_vz_ → velocitat vertical actual (positiva = puja, negativa = cau).
float jump_z_{0.0F};
float jump_vz_{0.0F};
// Temps restant de stun d'aterratge (ms). Mentre > 0, l'estat és
// LANDING_STUN i tot l'input queda bloquejat.
float landing_stun_remaining_ms_{0.0F};
// Estat anterior del botó de salt per fer detecció de flanc (saltem
// només a la transició no-premut → premut, no mentre es manté). Es
// manté un per teclat i un per mando perquè poden conviure.
bool jump_held_keyboard_{false};
bool jump_held_gamepad_{false};
// Llig el botó de salt aquest frame (teclat + mando assignat) i,
// amb edge-detection, retorna true si s'ha de despegar el salt.
[[nodiscard]] auto consumeJumpEdge() -> bool;
// Inicia un salt: copia `intended_v*` com a velocitat fixada del salt,
// posa `jump_vz_ = impulse`, transiciona a AIRBORNE.
void startJump();
// Integra la física del salt aquest frame i, si toca el sòl, marca el
// canvi a LANDING_STUN. Retorna true si aquest frame ha aterrat (perquè
// `applyMovement` puga disparar la resolució del solapament PvP).
auto integrateJump(float delta_time) -> bool;
// En aterrar, si la base-box del jugador solapa amb la d'un altre,
// l'altre entra en `HURT` i surt projectat per `enterHurt`. El
// saltador (this) es queda al lloc — el moviment per separar-los
// el fa el HURT en cours, no una empenta forçada.
void resolveLandingOverlap();
// Branques de `applyMovement` extretes per acotar la complexitat
// cognitiva. Cada estat especial gestiona el seu propi cicle
// (animació, tryMove o no, publicació de tile).
void applyHurtMovement(float delta_time);
void applyLandingStunMovement(float delta_time);
// Publica `on_tile_entered` al bus si el tile ha canviat respecte a
// l'últim publicat. Comú a totes les branques de `applyMovement` que
// pinten el sòl (GROUNDED, HURT, LANDING_STUN; en AIRBORNE no).
void publishTileEnteredIfChanged();
// Estat del gir en curs. Mentre `turning_remaining_ms_ > 0`, el facing/
// flip es manté forçat a `intermediate_cardinal_` i l'animació es força
// a `stand` encara que el moviment continuï. En esgotar-se el timer,
// s'aplica `pending_cardinal_`. `last_input_cardinal_` recorda l'últim
// cardinal d'input no nul: serveix per disparar el gir només quan
// l'usuari canvia d'opinió, no la primera vegada que prem una tecla
// des de l'estat idle.
float turning_remaining_ms_{0.0F};
Cardinal pending_cardinal_{Cardinal::NONE};
Cardinal intermediate_cardinal_{Cardinal::NONE};
Cardinal last_input_cardinal_{Cardinal::NONE};
// Aplica un cardinal a (`facing_`, flip). NONE no fa res — preserva el
// facing actual (cas idle: l'última orientació es manté).
void applyCardinal(Cardinal c);
// Actualitza facing_/flip i l'estat del gir a partir de l'input del frame.
// Extret de `update` per acotar la complexitat cognitiva (clang-tidy).
void updateFacingAndTurn(float delta_time, float vx, float vy);
static auto cardinalFromInput(float vx, float vy) -> Cardinal;
static auto cardinalFromString(const std::string& s) -> Cardinal;
static auto opposite(Cardinal c) -> Cardinal;
static auto intermediateFor(Cardinal target) -> Cardinal;
// Bus del nivell (opcional). Propietat externa (EntityManager); el
// Player només en guarda un punter per publicar events.
EventBus* bus_{nullptr};
@@ -127,4 +553,10 @@ class Player : public AnimatedSprite {
// el primer frame en què el bus estigui connectat ja emeti un event.
int last_tile_col_{-9999};
int last_tile_row_{-9999};
// Índex de tile del tileset amb què aquest jugador pinta. Llegit del
// YAML del mapa (`player1_paint_tile` / `player2_paint_tile`) i passat
// al ctor per GameScene. -1 desactiva el pintat (p.ex. al test
// d'arrencada o en un escenari que no vulgui feedback visual).
int paint_tile_index_{-1};
};
+48
View File
@@ -75,9 +75,24 @@ namespace Map {
playfield_w = board_w + (2 * outer_corridor);
playfield_h = board_h + (2 * outer_corridor);
// Porta i passadís de sortida. `door_col` és la columna central
// transitable; el passadís són exactament els 16 px d'eixa columna
// dins del strip del mur trasser. Si manca la clau, `door_col=0`
// i el passadís queda a x=0 amb mida zero — pràcticament inactiu
// (el bloqueig del clamp continua sent el rectangle del playfield).
door_col = g.contains("door_col") ? g["door_col"].get_value<int>() : 0;
door_corridor_x = (door_col * tile_size) + tilemap_origin_x;
door_corridor_y = hud_h;
door_corridor_w = tile_size;
door_corridor_h = back_wall_h;
// Cada càrrega de nivell deixa la porta tancada; l'entitat Door
// canviarà el flag a true quan transiti a OPEN.
door_open = false;
std::cout << "Map: geometria " << grid_cols << "x" << grid_rows
<< " sarcs " << sarc_w << "x" << sarc_h
<< " pitch " << pitch_x << "x" << pitch_y
<< " door_col " << door_col
<< " des de " << yaml_path << '\n';
}
@@ -99,6 +114,18 @@ namespace Map {
if (overlapStrict(base_box, getSarcRect(col, row))) { return true; }
}
}
// Porta tancada: el passadís de sortida actua com a paret. Quan la
// porta passa a OPEN, l'entitat Door posa `door_open = true` i
// aquesta clàusula deixa de bloquejar — el jugador pot creuar.
if (!door_open && door_corridor_w > 0 && door_corridor_h > 0) {
const SDL_FRect CORRIDOR{
.x = static_cast<float>(door_corridor_x),
.y = static_cast<float>(door_corridor_y),
.w = static_cast<float>(door_corridor_w),
.h = static_cast<float>(door_corridor_h),
};
if (overlapStrict(base_box, CORRIDOR)) { return true; }
}
return false;
}
@@ -106,6 +133,23 @@ namespace Map {
return box.x >= static_cast<float>(playfield_x) && box.y >= static_cast<float>(playfield_y) && box.x + box.w <= static_cast<float>(playfield_x + playfield_w) && box.y + box.h <= static_cast<float>(playfield_y + playfield_h);
}
auto isPaintable(int col, int row) -> bool {
// Construïm la caixa píxel del tile a partir de l'origen del tilemap.
// El grid és 16-alineat respecte a `tilemap_origin_*`; per a un tile
// (col,row) la cantonada superior-esquerra cau a:
// x = tilemap_origin_x + col * tile_size
// y = tilemap_origin_y + row * tile_size
const SDL_FRect BOX{
.x = static_cast<float>(tilemap_origin_x + (col * tile_size)),
.y = static_cast<float>(tilemap_origin_y + (row * tile_size)),
.w = static_cast<float>(tile_size),
.h = static_cast<float>(tile_size),
};
if (!insidePlayfield(BOX)) { return false; }
if (collides(BOX)) { return false; }
return true;
}
auto makeInfo() -> Info {
return {
.playfield_x = playfield_x,
@@ -119,6 +163,10 @@ namespace Map {
.tile_size = tile_size,
.tilemap_origin_x = tilemap_origin_x,
.tilemap_origin_y = tilemap_origin_y,
.door_corridor_x = door_corridor_x,
.door_corridor_y = door_corridor_y,
.door_corridor_w = door_corridor_w,
.door_corridor_h = door_corridor_h,
.collides = &collides,
.inside_playfield = &insidePlayfield,
};
+31
View File
@@ -62,6 +62,24 @@ namespace Map {
inline int playfield_w = 0;
inline int playfield_h = 0;
// Porta de sortida del nivell. La porta sempre viu al mur trasser
// (`wall_north`); només la columna varia per nivell. `door_col` és la
// columna central transitable: el sprite 48×32 es dibuixa centrat
// sobre aquesta columna i els 16 px centrals (just `door_col`) són
// l'únic forat del mur trasser per on el jugador surt. Derivat de
// `geometry.door_col` al YAML.
inline int door_col = 0;
inline int door_corridor_x = 0;
inline int door_corridor_y = 0;
inline int door_corridor_w = 0;
inline int door_corridor_h = 0;
// Estat del passadís: si la porta està oberta, el `door_corridor` no
// bloqueja el moviment del jugador (vegeu `Map::collides`). Qui muta
// aquest flag és l'entitat `Door` quan transita CLOSED → OPEN; el
// reset el porta `Map::loadFrom` (cada càrrega de nivell el torna a
// posar a false).
inline bool door_open = false;
// Carrega el bloc `geometry:` del YAML i recalcula tots els derivats.
// Ha de cridar-se abans d'instanciar `Player` o consultar `collides()`:
// GameScene ho fa al ctor.
@@ -81,6 +99,12 @@ namespace Map {
// entitats dinàmiques per validar posicions candidates del seu step.
[[nodiscard]] auto insidePlayfield(const SDL_FRect& box) -> bool;
// true si el tile (col, row) — coordenades del tilemap, amb origen
// `tilemap_origin_*` — és "sòl transitable" i per tant pintable: cau
// completament dins del playfield i no s'encavalca cap sarcòfag. Ho
// usa el `PaintLayer` per filtrar quines cel·les accepten pintat.
[[nodiscard]] auto isPaintable(int col, int row) -> bool;
// Snapshot plà de la geometria del mapa + punters a `collides` /
// `insidePlayfield`. Pensat per ser passat per valor (POD petit,
// trivialment copiable). Un cop construïda, llegeix els valors
@@ -98,6 +122,13 @@ namespace Map {
int tile_size{0};
int tilemap_origin_x{0};
int tilemap_origin_y{0};
// Passadís de sortida (forat del mur trasser a la columna de la
// porta). El Player l'usa des del clamp per permetre pujar al
// strip del mur quan la base box queda dins del rang X.
int door_corridor_x{0};
int door_corridor_y{0};
int door_corridor_w{0};
int door_corridor_h{0};
bool (*collides)(const SDL_FRect&){nullptr};
bool (*inside_playfield)(const SDL_FRect&){nullptr};
};
+148
View File
@@ -0,0 +1,148 @@
#include "game/map/paint_layer.hpp"
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <variant>
#include "core/rendering/screen.hpp"
#include "core/rendering/surface.hpp"
#include "core/resources/resource_cache.hpp"
#include "game/map/map.hpp"
#include "game/map/tilemap.hpp"
namespace {
// Blit d'un tile del tileset sobre un buffer ARGB de destí, respectant
// l'índex transparent del GIF. Mateixa convenció que la versió a
// `tilemap.cpp`: el tileset guarda índexs + paleta, i la destinació
// és un buffer ARGB32 lineal. Duplicat intencionat per no acoblar
// Tilemap i PaintLayer; si en el futur un tercer consumidor el vol
// reutilitzar, es promou a un helper compartit.
void blitTile(const Surface& tileset, int tile_size, int src_col, int src_row, std::vector<std::uint32_t>& dest, int dest_w, int dest_x, int dest_y) {
const int SRC_X0 = src_col * tile_size;
const int SRC_Y0 = src_row * tile_size;
const auto TRANSPARENT = tileset.getTransparentColor();
for (int y = 0; y < tile_size; ++y) {
for (int x = 0; x < tile_size; ++x) {
const auto IDX = tileset.getPixel(SRC_X0 + x, SRC_Y0 + y);
if (IDX == TRANSPARENT) { continue; }
const auto ARGB = tileset.getPaletteColor(IDX);
const auto DST_OFFSET =
(static_cast<std::size_t>(dest_y + y) * static_cast<std::size_t>(dest_w)) + static_cast<std::size_t>(dest_x + x);
dest[DST_OFFSET] = ARGB;
}
}
}
} // namespace
PaintLayer::PaintLayer(const Tilemap& tilemap, EventBus* bus)
: bus_(bus),
cols_(tilemap.getCols()),
rows_(tilemap.getRows()),
tile_size_(tilemap.getTileSize()),
origin_x_(tilemap.getOriginX()),
origin_y_(tilemap.getOriginY()),
width_px_(tilemap.getWidth()),
height_px_(tilemap.getHeight()),
tileset_cols_(tilemap.getTilesetCols()),
tileset_path_(tilemap.getTilesetPath()) {
painted_.assign(static_cast<std::size_t>(rows_), std::vector<int>(static_cast<std::size_t>(cols_), -1));
pixels_.assign(
static_cast<std::size_t>(width_px_) * static_cast<std::size_t>(height_px_),
0U);
if (bus_ != nullptr) {
// Subscripció a `on_tile_entered`: el Player publica aquest event
// amb `PaintedTile{col, row, tile_index}`. Filtrem pintabilitat
// (sòl transitable) via `Map::isPaintable` i, si aplica, marquem
// i re-publiquem `tile_painted` amb només la posició.
on_tile_sub_ = bus_->subscribe("on_tile_entered", [this](const Event& ev) {
// Re-propaguem el `player_id` de qui ha trepitjat la cel·la, perquè
// `tile_painted` (que consumeixen els sarcòfags i el HUD futur)
// sàpiga quin jugador ha pintat.
const auto* paint = std::get_if<PaintedTile>(&ev.payload);
if (paint != nullptr) { tryPaint(paint->col, paint->row, paint->tile_index, ev.player_id); }
});
}
std::cout << "PaintLayer: " << cols_ << "x" << rows_ << " tiles ("
<< width_px_ << "x" << height_px_ << " px), tileset "
<< tileset_path_ << '\n';
}
PaintLayer::~PaintLayer() {
if (bus_ != nullptr && on_tile_sub_ != 0) { bus_->unsubscribe(on_tile_sub_); }
}
auto PaintLayer::isPainted(int col, int row) const -> bool {
if (col < 0 || col >= cols_ || row < 0 || row >= rows_) { return false; }
return painted_[static_cast<std::size_t>(row)][static_cast<std::size_t>(col)] >= 0;
}
void PaintLayer::clear() {
// Mateixa geometria que al ctor: matriu de tiles a -1 (no pintat) i
// buffer ARGB a zero (alpha=0 = transparent → render no escriu res).
painted_.assign(static_cast<std::size_t>(rows_), std::vector<int>(static_cast<std::size_t>(cols_), -1));
std::ranges::fill(pixels_, 0U);
}
auto PaintLayer::tryPaint(int col, int row, int tile_index, int player_id) -> bool {
if (col < 0 || col >= cols_ || row < 0 || row >= rows_) { return false; }
if (tile_index < 0) { return false; }
if (painted_[static_cast<std::size_t>(row)][static_cast<std::size_t>(col)] >= 0) {
// Primer que pinta guanya: no sobreescrivim.
return false;
}
if (!Map::isPaintable(col, row)) { return false; }
painted_[static_cast<std::size_t>(row)][static_cast<std::size_t>(col)] = tile_index;
blitPaintTile(col, row, tile_index);
if (bus_ != nullptr) {
bus_->publish(Event{
.name = "tile_painted",
.payload = TilePos{.col = col, .row = row},
.player_id = player_id,
});
}
return true;
}
void PaintLayer::blitPaintTile(int col, int row, int tile_index) {
const auto TILESET = Resource::Cache::get().getSurface(tileset_path_);
if (TILESET == nullptr) { return; }
const int DX = col * tile_size_;
const int DY = row * tile_size_;
const int SRC_COL = tile_index % tileset_cols_;
const int SRC_ROW = tile_index / tileset_cols_;
blitTile(*TILESET, tile_size_, SRC_COL, SRC_ROW, pixels_, width_px_, DX, DY);
}
void PaintLayer::render() const {
// Còpia ARGB al canvas, anàloga a `Tilemap::render`. Píxels amb
// alpha=0 (tiles no pintats) es salten per deixar visible el tilemap
// base per sota.
auto& screen = Screen::get();
auto* canvas = screen.getCanvasArgb();
const int CANVAS_W = screen.getCanvasWidth();
const int CANVAS_H = screen.getCanvasHeight();
const int Y_START = std::max(0, -origin_y_);
const int Y_END = std::min(height_px_, CANVAS_H - origin_y_);
const int X_START = std::max(0, -origin_x_);
const int X_END = std::min(width_px_, CANVAS_W - origin_x_);
constexpr std::uint32_t ALPHA_MASK = 0xFF000000U;
for (int y = Y_START; y < Y_END; ++y) {
const auto* src_row = &pixels_[static_cast<std::size_t>(y) * static_cast<std::size_t>(width_px_)];
auto* dst_row =
&canvas[(static_cast<std::size_t>(origin_y_ + y) * static_cast<std::size_t>(CANVAS_W)) + static_cast<std::size_t>(origin_x_)];
for (int x = X_START; x < X_END; ++x) {
const auto PIXEL = src_row[x];
if ((PIXEL & ALPHA_MASK) != 0U) { dst_row[x] = PIXEL; }
}
}
}
+79
View File
@@ -0,0 +1,79 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include "game/entities/event_bus.hpp"
class Tilemap;
// Capa visual de "pintat" sobre el tilemap base. Quan el jugador (o
// qualsevol altre actor futur) publica `on_tile_entered` amb payload
// `PaintedTile{col, row, tile_index}`, el PaintLayer filtra per
// `Map::isPaintable`, marca el tile i re-publica `tile_painted` amb
// només `TilePos` — abstracció del "el tile s'ha pintat" independent de
// qui ho ha fet. Els sarcòfags escolten aquest segon event per comptar
// el seu perímetre.
//
// Font única de veritat del pintat: evita duplicar l'estat dins de cada
// sarcòfag i permet que el render mostri l'efecte al instant.
//
// Geometria: hereta `tile_size`, origen i dimensions del Tilemap
// (mateixa graella). Reutilitza el tileset del Tilemap per blitejar els
// tiles pintats a un buffer ARGB propi, anàleg a `Tilemap::pixels_`.
//
// Política de repintat: el primer que pinta un tile guanya. Intents
// posteriors (amb el mateix índex o un altre) no tenen efecte —
// garanteix coherència visual i evita notificar dos cops al mateix sarc.
class PaintLayer {
public:
PaintLayer(const Tilemap& tilemap, EventBus* bus);
~PaintLayer();
PaintLayer(const PaintLayer&) = delete;
PaintLayer(PaintLayer&&) = delete;
auto operator=(const PaintLayer&) -> PaintLayer& = delete;
auto operator=(PaintLayer&&) -> PaintLayer& = delete;
// Còpia del buffer ARGB sobre el canvas del Screen, respectant alpha
// (píxels no pintats queden al buffer amb alpha=0 i no s'escriuen).
// Pensat per cridar entre `Tilemap::render()` i el bloc d'actors.
void render() const;
// Intent de pintat directe (exposat per a tests i actors que pinten
// sense passar pel bus). Retorna true si el tile és pintable i no
// estava ja pintat; en aquest cas, publica `tile_painted`.
auto tryPaint(int col, int row, int tile_index, int player_id = -1) -> bool;
[[nodiscard]] auto isPainted(int col, int row) const -> bool;
// Buida el pintat acumulat. Marca tots els tiles com a no pintats i
// reseteja el buffer ARGB a alpha=0 (transparent). Cridat per
// GameScene a la transició ROOM_OUTRO → ROOM_INTRO. La subscripció
// al bus es manté — el PaintLayer continuarà reaccionant a
// `on_tile_entered` un cop els jugadors hagin reaparegut.
void clear();
private:
void blitPaintTile(int col, int row, int tile_index);
EventBus* bus_{nullptr};
EventBus::SubId on_tile_sub_{0};
int cols_{0};
int rows_{0};
int tile_size_{0};
int origin_x_{0};
int origin_y_{0};
int width_px_{0};
int height_px_{0};
int tileset_cols_{0};
std::string tileset_path_;
// `-1` = no pintat. Altres valors = índex de tile del tileset usat
// per pintar. No cal per al render (el buffer `pixels_` ja el conté),
// però facilita consultes ràpides d'estat i futures extensions.
std::vector<std::vector<int>> painted_;
std::vector<std::uint32_t> pixels_;
};
+92 -54
View File
@@ -2,11 +2,10 @@
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <sstream>
#include <stdexcept>
#include <unordered_map>
#include <utility>
#include <vector>
#include "core/rendering/screen.hpp"
#include "core/rendering/surface.hpp"
@@ -14,37 +13,12 @@
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
#ifdef _DEBUG
#include "game/map/tilemap_writer.hpp"
#endif
namespace {
// Cada símbol del grid es mapeja a (col, fila) dins del tileset.
using TileCoord = std::pair<int, int>;
using Legend = std::unordered_map<char, TileCoord>;
auto splitLines(const std::string& text) -> std::vector<std::string> {
std::vector<std::string> lines;
std::istringstream iss(text);
std::string line;
while (std::getline(iss, line)) {
if (!line.empty()) { lines.push_back(line); }
}
return lines;
}
// La llegenda usa índexs lineals del tileset (esq→dre, dalt→baix).
// Els convertim a (col, fila) amb el nombre de tiles per fila del tileset.
auto parseLegend(const fkyaml::node& node, int tileset_cols) -> Legend {
Legend legend;
for (auto it = node.begin(); it != node.end(); ++it) {
const auto KEY_STR = it.key().get_value<std::string>();
if (KEY_STR.empty()) { continue; }
const auto INDEX = it.value().get_value<int>();
const int COL = INDEX % tileset_cols;
const int ROW = INDEX / tileset_cols;
legend[KEY_STR.front()] = {COL, ROW};
}
return legend;
}
// Copia un tile (col, fila) del tileset al buffer ARGB a la posició de
// destí (dest_x, dest_y). Els píxels amb l'índex de transparència del
// tileset es deixen com estan al buffer (alpha=0 per defecte).
@@ -75,8 +49,8 @@ Tilemap::Tilemap(const std::string& yaml_path) {
const auto* end = begin + bytes.size();
auto yaml = fkyaml::node::deserialize(begin, end);
const auto TILESET_PATH = yaml["tileset"].get_value<std::string>();
const auto TILE_SIZE = yaml["tile_size"].get_value<int>();
tileset_path_ = yaml["tileset"].get_value<std::string>();
tile_size_ = yaml["tile_size"].get_value<int>();
if (yaml.contains("origin")) {
origin_x_ = yaml["origin"][0].get_value<int>();
origin_y_ = yaml["origin"][1].get_value<int>();
@@ -84,37 +58,101 @@ Tilemap::Tilemap(const std::string& yaml_path) {
// Carreguem el tileset al cache (idempotent) i el pillem per accedir
// als seus píxels i paleta. La paleta del tileset ve del GIF mateix.
// Ens cal abans de la llegenda per saber quants tiles per fila té.
const auto& cache_name = TILESET_PATH;
Resource::Cache::get().loadSurface(cache_name, TILESET_PATH);
const auto TILESET = Resource::Cache::get().getSurface(cache_name);
const int TILESET_COLS = TILESET->getWidth() / TILE_SIZE;
// Ens cal abans de llegir `data` per saber quants tiles per fila té.
Resource::Cache::get().loadSurface(tileset_path_, tileset_path_);
const auto TILESET = Resource::Cache::get().getSurface(tileset_path_);
tileset_cols_ = TILESET->getWidth() / tile_size_;
const auto LEGEND = parseLegend(yaml["legend"], TILESET_COLS);
const auto LINES = splitLines(yaml["data"].get_value<std::string>());
const int ROWS = static_cast<int>(LINES.size());
const int COLS = ROWS > 0 ? static_cast<int>(LINES.front().size()) : 0;
width_px_ = COLS * TILE_SIZE;
height_px_ = ROWS * TILE_SIZE;
// `data` és una seqüència de seqüències d'enters: cada fila és una
// llista d'índexs lineals al tileset (esq→dre, dalt→baix). Un valor
// negatiu (per convenció -1) deixa la cel·la buida (alpha=0).
const auto& data = yaml["data"];
if (!data.is_sequence() || data.empty()) {
throw std::runtime_error("Tilemap: `data` buit o amb format incorrecte a " + yaml_path);
}
rows_ = static_cast<int>(data.size());
cols_ = static_cast<int>(data[0].size());
width_px_ = cols_ * tile_size_;
height_px_ = rows_ * tile_size_;
pixels_.assign(
static_cast<std::size_t>(width_px_) * static_cast<std::size_t>(height_px_),
0U);
for (int row = 0; row < ROWS; ++row) {
const auto& line = LINES[row];
for (int col = 0; col < COLS && col < static_cast<int>(line.size()); ++col) {
const char SYMBOL = line[col];
const auto IT = LEGEND.find(SYMBOL);
if (IT == LEGEND.end()) { continue; }
blitTile(*TILESET, TILE_SIZE, IT->second.first, IT->second.second, pixels_, width_px_, col * TILE_SIZE, row * TILE_SIZE);
#ifdef _DEBUG
data_.assign(static_cast<std::size_t>(rows_), std::vector<int>(static_cast<std::size_t>(cols_), -1));
#endif
for (int row = 0; row < rows_; ++row) {
const auto& line = data[row];
const int LINE_COLS = static_cast<int>(line.size());
for (int col = 0; col < cols_ && col < LINE_COLS; ++col) {
const int INDEX = line[col].get_value<int>();
#ifdef _DEBUG
data_[static_cast<std::size_t>(row)][static_cast<std::size_t>(col)] = INDEX;
#endif
if (INDEX < 0) { continue; }
const int SRC_COL = INDEX % tileset_cols_;
const int SRC_ROW = INDEX / tileset_cols_;
blitTile(*TILESET, tile_size_, SRC_COL, SRC_ROW, pixels_, width_px_, col * tile_size_, row * tile_size_);
}
}
std::cout << "Tilemap: " << COLS << "x" << ROWS << " tiles ("
std::cout << "Tilemap: " << cols_ << "x" << rows_ << " tiles ("
<< width_px_ << "x" << height_px_ << " px) des de " << yaml_path << '\n';
}
#ifdef _DEBUG
void Tilemap::setTileAt(int col, int row, int index) {
if (col < 0 || col >= cols_ || row < 0 || row >= rows_) { return; }
if (index < 0) { return; } // El mapa no admet cel·les buides (política de projecte).
// No validem l'índex contra el total de tiles del tileset: la
// invariant la garanteix el TilePicker, que només ofereix índexs en
// rang.
data_[static_cast<std::size_t>(row)][static_cast<std::size_t>(col)] = index;
// Netegem el rectangle del tile al buffer de píxels i re-blitegem.
const int DX = col * tile_size_;
const int DY = row * tile_size_;
for (int y = 0; y < tile_size_; ++y) {
auto* dst = &pixels_[(static_cast<std::size_t>(DY + y) * static_cast<std::size_t>(width_px_)) + static_cast<std::size_t>(DX)];
for (int x = 0; x < tile_size_; ++x) { dst[x] = 0U; }
}
const auto TILESET = Resource::Cache::get().getSurface(tileset_path_);
const int SRC_COL = index % tileset_cols_;
const int SRC_ROW = index / tileset_cols_;
blitTile(*TILESET, tile_size_, SRC_COL, SRC_ROW, pixels_, width_px_, DX, DY);
}
auto Tilemap::save(const std::string& yaml_path) const -> bool {
return TilemapWriter::saveDataBlock(yaml_path, data_);
}
void Tilemap::renderTilePreview(int canvas_x, int canvas_y, int tile_index) const {
if (tile_index < 0) { return; }
const auto TILESET = Resource::Cache::get().getSurface(tileset_path_);
if (TILESET == nullptr) { return; }
auto& screen = Screen::get();
auto* buf = screen.getCanvasArgb();
const int CW = screen.getCanvasWidth();
const int CH = screen.getCanvasHeight();
const auto TRANSPARENT = TILESET->getTransparentColor();
const int SX0 = (tile_index % tileset_cols_) * tile_size_;
const int SY0 = (tile_index / tileset_cols_) * tile_size_;
for (int y = 0; y < tile_size_; ++y) {
const int DY = canvas_y + y;
if (DY < 0 || DY >= CH) { continue; }
for (int x = 0; x < tile_size_; ++x) {
const int DX = canvas_x + x;
if (DX < 0 || DX >= CW) { continue; }
const auto IDX = TILESET->getPixel(SX0 + x, SY0 + y);
if (IDX == TRANSPARENT) { continue; }
buf[(static_cast<std::size_t>(DY) * static_cast<std::size_t>(CW)) + static_cast<std::size_t>(DX)] = TILESET->getPaletteColor(IDX);
}
}
}
#endif
void Tilemap::render() const {
auto& screen = Screen::get();
auto* canvas = screen.getCanvasArgb();
+45
View File
@@ -27,10 +27,55 @@ class Tilemap {
[[nodiscard]] auto getOriginX() const -> int { return origin_x_; }
[[nodiscard]] auto getOriginY() const -> int { return origin_y_; }
// Metadades per a consumidors que necessiten conèixer la geometria
// lògica del tilemap (p.ex. l'editor per a la conversió px→cel·la).
// Sempre disponibles (en Debug i Release) perquè són barates i no
// lliguen amb cap codi específic de l'editor.
[[nodiscard]] auto getTileSize() const -> int { return tile_size_; }
[[nodiscard]] auto getCols() const -> int { return cols_; }
[[nodiscard]] auto getRows() const -> int { return rows_; }
[[nodiscard]] auto getTilesetPath() const -> const std::string& { return tileset_path_; }
[[nodiscard]] auto getTilesetCols() const -> int { return tileset_cols_; }
#ifdef _DEBUG
// API d'edició (només Debug). L'editor és absent en Release, així que
// aquestes operacions no formen part de l'API pública del joc final.
//
// `setTileAt` valida els límits i reemplaça el tile de (col,row) per
// `index`. Internament neteja el rectangle `tile_size×tile_size` del
// buffer de píxels i re-blita el tile nou; el render al pròxim frame
// ja reflectirà el canvi.
//
// `save` persisteix `data_` al YAML indicat via `TilemapWriter`, que
// només toca el bloc `data:` i preserva la resta byte a byte.
void setTileAt(int col, int row, int index);
[[nodiscard]] auto save(const std::string& yaml_path) const -> bool;
// Pinta un únic tile del tileset sobre el canvas del Screen a la
// posició (canvas_x, canvas_y). Els píxels transparents del tileset
// es respecten (deixen visible el que hi ha sota). Usat per l'editor
// com a preview del brush sota el cursor, sense tocar `pixels_` ni
// `data_` fins que l'usuari fa click.
void renderTilePreview(int canvas_x, int canvas_y, int tile_index) const;
#endif
private:
int width_px_{0};
int height_px_{0};
int origin_x_{0};
int origin_y_{0};
int tile_size_{0};
int cols_{0};
int rows_{0};
int tileset_cols_{0};
std::string tileset_path_;
std::vector<std::uint32_t> pixels_;
#ifdef _DEBUG
// Graella en memòria només necessària per a l'editor: guardar-la en
// Release gastaria ~1 KB sense retorn, perquè el `pixels_` ja té tot
// el que cal per renderitzar. En Debug la usem com a font de veritat
// de l'edició i com a payload de `save()`.
std::vector<std::vector<int>> data_;
#endif
};
+118
View File
@@ -0,0 +1,118 @@
#include "game/map/tilemap_writer.hpp"
#include <algorithm>
#include <cstddef>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <string>
#include <system_error>
namespace TilemapWriter {
namespace {
// Retorna la posició de l'inici de la línia que conté "data:" a
// columna 0. La detecció requereix que els 5 caràcters siguin al
// principi d'una línia i que el següent siga un terminador típic
// (fi de línia, espai, tabulador o comentari). Així evitem trigger
// amb claus com "data_version:" o valors escalars que comencin
// amb "data:".
auto findDataLine(const std::string& content) -> std::size_t {
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, 5, "data:") == 0) {
const std::size_t AFTER = pos + 5;
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). S'atura al
// primer caràcter a columna 0 (nova clau top-level) o davant una
// línia buida (separadors de seccions). Així els comentaris i el
// blanc que precedeixen a la pròxima secció queden fora del bloc.
auto advancePastIndented(const std::string& content, std::size_t pos) -> std::size_t {
while (pos < content.size()) {
const char C = content[pos];
if (C != ' ' && C != '\t') { break; }
const auto NL = content.find('\n', pos);
pos = (NL == std::string::npos) ? content.size() : NL + 1;
}
return pos;
}
auto buildBlock(const std::vector<std::vector<int>>& data) -> std::string {
int max_width = 1;
for (const auto& row : data) {
for (const int V : row) {
const int W = static_cast<int>(std::to_string(V).size());
max_width = std::max(max_width, W);
}
}
std::string out;
for (const auto& row : data) {
out += " - [";
for (std::size_t i = 0; i < row.size(); ++i) {
if (i > 0) { out += ","; }
const auto S = std::to_string(row[i]);
const int PAD = max_width - static_cast<int>(S.size());
out.append(static_cast<std::size_t>(PAD), ' ');
out += S;
}
out += "]\n";
}
return out;
}
} // namespace
auto saveDataBlock(const std::string& path, const std::vector<std::vector<int>>& data) -> 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 DATA_POS = findDataLine(CONTENT);
if (DATA_POS == std::string::npos) { return false; }
const auto LINE_END = CONTENT.find('\n', DATA_POS);
const auto AFTER_DATA = (LINE_END == std::string::npos) ? CONTENT.size() : LINE_END + 1;
const auto BLOCK_END = advancePastIndented(CONTENT, AFTER_DATA);
const std::string NEW_CONTENT =
CONTENT.substr(0, AFTER_DATA) + buildBlock(data) + CONTENT.substr(BLOCK_END);
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 TilemapWriter
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <string>
#include <vector>
// Reescriu NOMÉS el bloc `data:` d'un YAML del tilemap, preservant byte a
// byte la resta del fitxer: comentaris, ordre de claus, format de
// `geometry:` i `entities:`, encoding i line endings entre línies no
// tocades. Pensat exclusivament per a l'editor (Debug): només la graella
// canvia durant l'edició; el preàmbul i l'epíleg mai.
//
// Semàntica del bloc:
// - Línia de capçalera: primera aparició de "data:" a columna 0.
// - Contingut: línies següents indentades (espai o tab). Una línia buida
// o una línia a columna 0 marquen la fi del bloc.
//
// Format de sortida:
// - Cada fila en flow style: ` - [v0, v1, v2, ...]`, valors separats
// per `,` i alineats a dreta dins de `max_width` caràcters (calculat
// a partir del valor més llarg de la graella), cadascun precedit
// d'espais de farciment per mantenir columnes visuals.
// - Line endings: '\n' al bloc generat (el preàmbul i l'epíleg es
// copien byte a byte, així que no els toquem).
//
// Escriptura atòmica: via `<path>.tmp` + `std::filesystem::rename`, així
// un crash a mitja escriptura mai deixa el YAML corrupte.
//
// Retorna false si el fitxer no es pot obrir, no conté "data:" a columna
// 0, o l'escriptura/renombrament atòmic falla.
namespace TilemapWriter {
auto saveDataBlock(const std::string& path, const std::vector<std::vector<int>>& data) -> bool;
} // namespace TilemapWriter
+73 -2
View File
@@ -9,6 +9,7 @@
#include <string_view>
#include <utility>
#include "core/input/gamepad_manager.hpp"
#include "external/fkyaml_node.hpp"
#include "utils/defines.hpp"
@@ -26,6 +27,7 @@ namespace {
Options::Window g_window{};
Options::Video g_video{};
Options::Audio g_audio{};
Options::Controls g_controls{};
std::vector<Options::PostFXPreset> g_postfx_presets{};
std::vector<Options::CrtPiPreset> g_crtpi_presets{};
@@ -160,6 +162,42 @@ namespace {
readField(node, "volume", g_audio.sound.volume);
}
void parseGamepadAssignment(const fkyaml::node& parent, const char* key, Options::GamepadAssignment& dst) {
if (!parent.contains(key)) { return; }
const auto& node = parent[key];
readField(node, "name", dst.name);
readField(node, "path", dst.path);
}
void parseControls(const fkyaml::node& yaml) {
if (!yaml.contains("controls")) { return; }
const auto& node = yaml["controls"];
parseGamepadAssignment(node, "gamepad_p1", g_controls.gamepad_p1);
parseGamepadAssignment(node, "gamepad_p2", g_controls.gamepad_p2);
}
// Aplica les assignacions persistides al GamepadManager. Ordre garantit
// pel Director: GamepadManager::init (obre tots els mandos físics) →
// Options::loadFromFile (llig YAML) → aquesta funció (casa
// mandos↔jugadors) → auto-assign per ordre els slots que hagen quedat
// buits (equivalent al patró de `coffee_crisis_arcade_edition`).
void applyControlsToGamepadManager() {
auto& gm = GamepadManager::get();
gm.assignToPlayer(0, g_controls.gamepad_p1.name, g_controls.gamepad_p1.path);
gm.assignToPlayer(1, g_controls.gamepad_p2.name, g_controls.gamepad_p2.path);
const auto AUTO = gm.autoAssignFreeSlots();
if (AUTO.empty()) { return; }
// Sincronitzem Options amb el que el manager ha vinculat per
// ordre, per persistir-ho al config.yaml. No cridem els setters
// individuals perquè cada un dispararia un saveToFile; preferim
// fer un sol save al final.
for (const auto& a : AUTO) {
auto& slot = (a.player_id == 0) ? g_controls.gamepad_p1 : g_controls.gamepad_p2;
slot.name = a.name;
slot.path = a.path;
}
}
void parseAudio(const fkyaml::node& yaml) {
if (!yaml.contains("audio")) { return; }
const auto& node = yaml["audio"];
@@ -442,6 +480,7 @@ namespace Options {
auto window() -> const Window& { return g_window; }
auto video() -> const Video& { return g_video; }
auto audio() -> const Audio& { return g_audio; }
auto controls() -> const Controls& { return g_controls; }
auto locale() -> const std::string& { return g_locale; }
auto configVersion() -> const std::string& { return g_config_version; }
auto postfxPresets() -> const std::vector<PostFXPreset>& { return g_postfx_presets; }
@@ -513,6 +552,15 @@ namespace Options {
saveToFile();
}
void setGamepadAssignment(int player_id, const std::string& name, const std::string& path) {
if (player_id < 0 || player_id > 1) { return; }
auto& slot = (player_id == 0) ? g_controls.gamepad_p1 : g_controls.gamepad_p2;
slot.name = name;
slot.path = path;
GamepadManager::get().assignToPlayer(player_id, name, path);
saveToFile();
}
void setConfigFile(const std::string& path) {
g_config_file_path = path;
}
@@ -522,7 +570,11 @@ namespace Options {
const std::string CONTENT = readFileOrEmpty(g_config_file_path);
if (CONTENT.empty()) {
// Primera execució: creem el fitxer amb els valors per defecte.
// Primera execució: fem l'auto-assign inicial dels mandos
// (per a que P1 obtingui el primer mando connectat sense que
// l'usuari haja de passar pel menú) i després escrivim el
// fitxer amb els valors resultants.
applyControlsToGamepadManager();
return saveToFile();
}
@@ -548,6 +600,17 @@ namespace Options {
parseWindow(yaml);
parseVideo(yaml);
parseAudio(yaml);
parseControls(yaml);
// Captura les assignacions inicials per detectar si
// l'auto-assign (dins d'`applyControls...`) les ha tocat i
// cal reescriure el fitxer amb els valors nous.
const auto BEFORE_P1 = g_controls.gamepad_p1;
const auto BEFORE_P2 = g_controls.gamepad_p2;
applyControlsToGamepadManager();
const bool CHANGED =
(BEFORE_P1.name != g_controls.gamepad_p1.name || BEFORE_P1.path != g_controls.gamepad_p1.path ||
BEFORE_P2.name != g_controls.gamepad_p2.name || BEFORE_P2.path != g_controls.gamepad_p2.path);
if (CHANGED) { saveToFile(); }
return true;
} catch (const std::exception& e) {
std::cout << "Options: error parsejant config.yaml: " << e.what() << '\n';
@@ -594,7 +657,15 @@ namespace Options {
out << " volume: " << g_audio.music.volume << "\n";
out << " sound:\n";
out << " enabled: " << (g_audio.sound.enabled ? "true" : "false") << "\n";
out << " volume: " << g_audio.sound.volume << "\n";
out << " volume: " << g_audio.sound.volume << "\n\n";
out << "controls:\n";
out << " gamepad_p1:\n";
out << " name: \"" << g_controls.gamepad_p1.name << "\"\n";
out << " path: \"" << g_controls.gamepad_p1.path << "\"\n";
out << " gamepad_p2:\n";
out << " name: \"" << g_controls.gamepad_p2.name << "\"\n";
out << " path: \"" << g_controls.gamepad_p2.path << "\"\n";
return writeFile(g_config_file_path, out.str());
}
+20
View File
@@ -104,10 +104,26 @@ namespace Options {
Sound sound{};
};
// Assignació d'un mando a un jugador: nom visible (el que retorna
// `GamepadManager::Info::name`) + ruta física (de `SDL_GetGamepadPath`).
// Tots dos buits = sense mando. La ruta serveix per re-vincular
// automàticament el mateix mando al mateix port; el nom és el
// fallback quan l'usuari el canvia de port USB.
struct GamepadAssignment {
std::string name;
std::string path;
};
struct Controls {
GamepadAssignment gamepad_p1{};
GamepadAssignment gamepad_p2{};
};
// --- Getters de l'estat (read-only) ---
[[nodiscard]] auto window() -> const Window&;
[[nodiscard]] auto video() -> const Video&;
[[nodiscard]] auto audio() -> const Audio&;
[[nodiscard]] auto controls() -> const Controls&;
[[nodiscard]] auto locale() -> const std::string&;
[[nodiscard]] auto configVersion() -> const std::string&;
[[nodiscard]] auto postfxPresets() -> const std::vector<PostFXPreset>&;
@@ -132,6 +148,10 @@ namespace Options {
void setAudioMusicVolume(float value);
void setAudioSoundEnabled(bool value);
void setAudioSoundVolume(float value);
// Fixa l'assignació mando→jugador. Persisteix a `config.yaml` i
// propaga a `GamepadManager::assignToPlayer`. `name` i `path` buits
// = desassignació. `player_id`: 0 = P1, 1 = P2.
void setGamepadAssignment(int player_id, const std::string& name, const std::string& path);
// --- API pública: càrrega/desament de fitxers ---
void setConfigFile(const std::string& path);
+789 -74
View File
@@ -3,7 +3,9 @@
#include <SDL3/SDL.h>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <iostream>
#include <iterator>
@@ -12,19 +14,30 @@
#include <vector>
#include "core/audio/audio.hpp"
#include "core/input/gamepad_manager.hpp"
#include "core/input/global_inputs.hpp"
#include "core/input/key_config.hpp"
#include "core/locale/locale.hpp"
#include "core/rendering/notifier.hpp"
#include "core/rendering/render_info.hpp"
#include "core/rendering/screen.hpp"
#include "core/rendering/text.hpp"
#include "core/resources/resource_cache.hpp"
#include "core/resources/resource_helper.hpp"
#include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp"
#include "external/fkyaml_node.hpp"
#include "game/entities/concrete/dust.hpp"
#include "game/entities/entity.hpp"
#include "game/entities/entity_loader.hpp"
#include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp"
#include "game/map/map.hpp"
#include "game/options.hpp"
#include "game/ui/ui_palette.hpp"
#include "game/ui/hud.hpp"
#include "utils/defaults.hpp"
#include "utils/defines.hpp"
#include "utils/easing.hpp"
namespace {
@@ -37,6 +50,174 @@ namespace {
return Tilemap(yaml_path);
}
// Llegeix un camp sencer opcional del YAML del mapa. Si no hi és,
// retorna `fallback` (per defecte -1, que al Player indica "no pinta").
// S'usa per `player1_paint_tile` i `player2_paint_tile`: cada jugador
// pinta amb l'índex declarat al mapa, i un mapa sense aquests camps
// simplement no deixa rastre visual.
auto readOptionalInt(const std::string& yaml_path, const std::string& key, int fallback = -1) -> int {
auto bytes = Resource::Helper::loadFile(yaml_path);
if (bytes.empty()) { return fallback; }
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(key)) { return fallback; }
return yaml[key].get_value<int>();
}
auto readOptionalFloat(const std::string& yaml_path, const std::string& key, float fallback) -> float {
auto bytes = Resource::Helper::loadFile(yaml_path);
if (bytes.empty()) { return fallback; }
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(key)) { return fallback; }
return yaml[key].get_value<float>();
}
// Comprova si `a` i `b` se solapen amb una tolerància positiva al
// contacte perfecte (cantos que es toquen = no-solapament). SDL3 ja
// ofereix `SDL_HasRectIntersectionFloat`, però aquí volem la mateixa
// semàntica que al Player::collidesWithWorld.
auto rectsOverlap(const SDL_FRect& a, const SDL_FRect& b) -> bool {
return SDL_HasRectIntersectionFloat(&a, &b);
}
// Resolver jugador↔jugador. Opera sobre les velocitats intencionades
// (escrites per `Player::tick`, abans d'`applyMovement`) per a
// implementar les regles:
// - Sense overlap previst → no toca res.
// - Ambdós amb input que els faria col·lidir → head-on: velocitats
// anul·lades per als dos.
// - Un quiet, l'altre empentant-lo → push: ambdues velocitats
// escalades a `push_factor` i el pushed hereta la direcció del
// pusher. Si el pushed no pot avançar (mur/sarc), `tryMove` ja ho
// gestionarà a `applyMovement` mogent la distància parcial possible.
// Amb dos jugadors només és un parell; si en un futur n'hi ha més,
// la mateixa lògica es pot aplicar pairwise.
// Important: el criteri "mou-se vs parat" consulta INPUT cru (no la
// velocitat física), així un jugador que coasta per inèrcia sense
// input compta com a parat — ni dispara head-on ni empeny l'altre.
// L'overlap predictiu sí que usa velocitat física (intended), perquè
// és el desplaçament real d'aquest frame.
void resolvePlayerVsPlayer(const std::vector<Player*>& ps, float dt, float push_factor) {
if (ps.size() < 2) { return; }
auto* a = ps[0];
auto* b = ps[1];
if (a == nullptr || b == nullptr) { return; }
const bool A_AIR = a->isAirborne();
const bool B_AIR = b->isAirborne();
// HURT a un costat o l'altre: el resolver no s'activa — el HURT
// surt projectat sense control i els dos es travessen. Coincideix
// amb la regla d'`Player::collidesWithWorld`.
if (a->isHurt() || b->isHurt()) { return; }
// EXITED: el jugador despawnat no és tangible. Si entrés al
// resolver, la seua presència física frenaria l'altre (per
// overlap predictiu) tot i que `applyMovement` és no-op. Sortir
// d'aquí garanteix que el segon jugador pot creuar la porta
// sense interferència del primer ja eixit.
if (a->isExited() || b->isExited()) { return; }
// Aire ↔ tierra: el saltador es travessa net del caminant. Sense
// resolver, sense head-on, sense push. Els dos continuen amb la
// velocitat que portaven. Important: això NO és el filtre per
// altura del cos — és binari. Coincideix amb la regla d'`Player::
// collidesWithWorld`: durant un salt no hi ha mai col·lisió amb
// un caminant, ni baix ni alt.
if (A_AIR != B_AIR) { return; }
// Calculem l'overlap previst en l'espai de la hit-box petita:
// els jugadors han de poder acostar-se molt (caixes de moviment
// 16×16 quasi tocant) sense disparar el resolver; només quan el
// contacte real (hit-box 8×8) s'activaria, neutralitzem o apliquem
// push (a terra) o invertim velocitats (en l'aire).
const SDL_FRect BOX_A = a->getHitBox();
const SDL_FRect BOX_B = b->getHitBox();
const SDL_FRect NEXT_A{
.x = BOX_A.x + (a->getIntendedVX() * dt),
.y = BOX_A.y + (a->getIntendedVY() * dt),
.w = BOX_A.w,
.h = BOX_A.h,
};
const SDL_FRect NEXT_B{
.x = BOX_B.x + (b->getIntendedVX() * dt),
.y = BOX_B.y + (b->getIntendedVY() * dt),
.w = BOX_B.w,
.h = BOX_B.h,
};
if (!rectsOverlap(NEXT_A, NEXT_B)) { return; }
// Tots dos en l'aire (A_AIR == B_AIR == true aquí, ja descartat
// l'altre cas a dalt): rebot mid-aire si els rangs verticals
// [z, z+body_height] solapen. Si la diferència d'alçades és
// massa gran, ja no hi ha contacte real (un està molt més amunt
// que l'altre) i no fem res. Si solapen, invertim ambdues
// velocitats horitzontals i els deixem rebotar fins a aterrar
// — els dos cauen en stun, com a càstig per abusar del salt.
if (A_AIR) {
// cppcheck-suppress knownConditionTrueFalse
const auto BODY_H = static_cast<float>(Player::body_height);
// cppcheck-suppress knownConditionTrueFalse
if (BODY_H <= 0.0F) { return; }
if (std::fabs(a->getJumpZ() - b->getJumpZ()) >= BODY_H) { return; }
// Saltadors estàtics horitzontalment (cas degenerat: tots dos
// jumpejant verticals al mateix x): no hi ha velocitat a
// invertir, deixem-los — el predictiu no tornarà a disparar
// perquè en el següent frame ja no hi haurà velocitat
// d'acostament.
const float REL_VX = a->getIntendedVX() - b->getIntendedVX();
const float REL_VY = a->getIntendedVY() - b->getIntendedVY();
if (REL_VX == 0.0F && REL_VY == 0.0F) { return; }
a->setIntendedVelocity(-a->getIntendedVX(), -a->getIntendedVY());
b->setIntendedVelocity(-b->getIntendedVX(), -b->getIntendedVY());
return;
}
const bool MOVING_A = (a->getInputVX() != 0.0F) || (a->getInputVY() != 0.0F);
const bool MOVING_B = (b->getInputVX() != 0.0F) || (b->getInputVY() != 0.0F);
if (MOVING_A && MOVING_B) {
// Head-on o encreuament amb col·lisió: cap dels dos es mou.
a->setIntendedVelocity(0.0F, 0.0F);
b->setIntendedVelocity(0.0F, 0.0F);
return;
}
auto* pusher = MOVING_A ? a : b;
auto* pushed = MOVING_A ? b : a;
const float VX = pusher->getIntendedVX() * push_factor;
const float VY = pusher->getIntendedVY() * push_factor;
pusher->setIntendedVelocity(VX, VY);
pushed->setIntendedVelocity(VX, VY);
// El pushed es mourà sense input propi: marquem-ho perquè
// `applyMovement` no active la variant `walk` de l'animació.
pushed->setBeingPushed(true);
}
// Ordre d'`applyMovement`: en push, el pushed ha de moure's abans
// perquè quan el pusher faça `tryMove` veja el pushed a la seua
// posició nova i no li bloqueje. Heurística barata: el jugador amb
// `intended*dt` que entra a l'altre és el pusher; però com a totes
// les configuracions push els dos tenen la mateixa velocitat resultant
// i comparteixen direcció, usem el producte (dot) del vector d'acció
// i el vector cap a l'altre jugador: positiu = pusher.
auto orderedForMovement(const std::vector<Player*>& ps) -> std::vector<Player*> {
std::vector<Player*> out = ps;
if (out.size() < 2 || out[0] == nullptr || out[1] == nullptr) { return out; }
const auto* a = out[0];
const auto* b = out[1];
const SDL_FRect BOX_A = a->getBaseBox();
const SDL_FRect BOX_B = b->getBaseBox();
const float DX = (BOX_B.x + (BOX_B.w / 2.0F)) - (BOX_A.x + (BOX_A.w / 2.0F));
const float DY = (BOX_B.y + (BOX_B.h / 2.0F)) - (BOX_A.y + (BOX_A.h / 2.0F));
// dot(v_a, dir_a→b): positiu = a empenta a b → b es mou primer.
const float DOT_A = (a->getIntendedVX() * DX) + (a->getIntendedVY() * DY);
if (DOT_A > 0.0F) { std::swap(out[0], out[1]); }
return out;
}
} // namespace
auto GameScene::defaultConfig() -> Config {
@@ -44,7 +225,10 @@ auto GameScene::defaultConfig() -> Config {
.map_yaml = Defaults::Pyramid::MAP_YAML,
.player_sprite = Defaults::Pyramid::PLAYER_SPRITE,
.player_yaml = Defaults::Pyramid::PLAYER_YAML,
.player2_sprite = Defaults::Pyramid::PLAYER_SPRITE_P2,
.player2_yaml = Defaults::Pyramid::PLAYER_YAML_P2,
.music_track = Defaults::Pyramid::MUSIC_TRACK,
.starting_players = DebugConfig::StartingPlayers::BOTH,
};
}
@@ -53,15 +237,32 @@ GameScene::GameScene(Config config)
tilemap_(makeTilemap(config_.map_yaml)) {
// Convenció: el Resource::Cache indexa per basename del fitxer, perquè
// la mateixa clau l'usa el parseador d'animació (YAML > tileSetFile).
const auto SPRITE_KEY = std::filesystem::path(config_.player_sprite).filename().string();
const auto ANIM_KEY = std::filesystem::path(config_.player_yaml).filename().string();
Resource::Cache::get().loadSurface(SPRITE_KEY, config_.player_sprite);
Resource::Cache::get().loadAnimation(ANIM_KEY, config_.player_yaml);
// Lambda perquè tenim dos conjunts d'assets (sam/morcus) i l'alta al
// cache és idèntica per als dos; la clau d'animació ens la quedem per
// passar-la al ctor del Player corresponent.
auto load_player_assets = [](const std::string& sprite, const std::string& yaml) -> std::string {
const auto SPRITE_KEY = std::filesystem::path(sprite).filename().string();
const auto ANIM_KEY = std::filesystem::path(yaml).filename().string();
Resource::Cache::get().loadSurface(SPRITE_KEY, sprite);
Resource::Cache::get().loadAnimation(ANIM_KEY, yaml);
return ANIM_KEY;
};
const bool USE_P1 = (config_.starting_players == DebugConfig::StartingPlayers::P1 || config_.starting_players == DebugConfig::StartingPlayers::BOTH);
const bool USE_P2 = (config_.starting_players == DebugConfig::StartingPlayers::P2 || config_.starting_players == DebugConfig::StartingPlayers::BOTH);
std::string anim_p1;
std::string anim_p2;
if (USE_P1) { anim_p1 = load_player_assets(config_.player_sprite, config_.player_yaml); }
if (USE_P2) { anim_p2 = load_player_assets(config_.player2_sprite, config_.player2_yaml); }
// Geometria de col·lisió del jugador (caixa 16×16, base 12): carregada
// del mateix YAML que l'animació. Ha d'estar feta abans d'instanciar
// Player perquè el spawn consulta `collision_*`.
Player::loadFrom(config_.player_yaml);
// Player perquè el spawn consulta `collision_*`. Els membres de Player
// són `static inline`, compartits per tots els jugadors: cridem
// `loadFrom` amb el YAML del primer jugador actiu (P1 o P2). Els dos
// personatges usen la mateixa geometria (morcus és un repaletat de sam).
Player::loadFrom(USE_P1 ? config_.player_yaml : config_.player2_yaml);
// Carrega les entitats del nivell (antorxa, sarcòfags, porta, momia,
// etc.). Requereix que Map::loadFrom ja s'hagi cridat (ho fa
@@ -69,78 +270,514 @@ GameScene::GameScene(Config config)
// Les surfaces de les entitats es carreguen al Resource::Cache aquí.
entity_manager_.loadFromLevel(config_.map_yaml);
// Subscripcions bàsiques al bus del nivell: de moment només logs, sense
// tocar ni la vida del jugador ni l'estat de la escena. Serà la base
// sobre la qual cablejar HUD i fi de nivell més endavant.
entity_manager_.getBus().subscribe("level_complete", [](const Event& /*ev*/) {
std::cout << "[LEVEL COMPLETE]" << '\n';
// Subscripcions bàsiques al bus del nivell. `level_complete` també
// dispara la transició a ROOM_OUTRO si encara estem a PLAYING; les
// re-emissions es filtren per estat per no re-entrar mai des d'una
// transició ja en marxa.
entity_manager_.getBus().subscribe("level_complete", [this](const Event& ev) {
std::cout << "[LEVEL COMPLETE] player_id=" << ev.player_id << '\n';
if (state_ == RoomFsm::State::PLAYING) { setState(RoomFsm::State::ROOM_OUTRO); }
});
entity_manager_.getBus().subscribe("player_hit", [](const Event& ev) {
const auto* dmg = std::get_if<int>(&ev.payload);
std::cout << "[PLAYER HIT] dmg=" << (dmg != nullptr ? *dmg : 0) << '\n';
std::cout << "[PLAYER HIT] player_id=" << ev.player_id
<< " dmg=" << (dmg != nullptr ? *dmg : 0) << '\n';
});
// Jugador 0: fletxes del teclat (Defaults::KeysGame). Instanciar-ne un
// segon només requereix afegir una altra entrada amb uns Controls
// distints (p.ex. WASD).
// Polvet: precarreguem el surface del Dust una sola vegada perquè
// els spawns runtime de l'entitat no facin disc al primer salt.
// També ens subscrivim a `player_jumped` i `player_landed` per
// spawnejar el polvet de despegue / aterratge a la posició dels
// peus que el Player ens dóna al payload.
Resource::Cache::get().loadSurface("dust.gif", "data/entities/dust/dust.gif");
entity_manager_.getBus().subscribe("player_jumped", [this](const Event& ev) {
const auto* pos = std::get_if<WorldPos>(&ev.payload);
if (pos != nullptr) { spawnDust(pos->x, pos->y, false); }
});
entity_manager_.getBus().subscribe("player_landed", [this](const Event& ev) {
const auto* pos = std::get_if<WorldPos>(&ev.payload);
if (pos != nullptr) { spawnDust(pos->x, pos->y, true); }
});
// Controls per jugador: P1 amb fletxes, P2 amb WASD. Cada Player
// escolta les seues pròpies tecles dins del seu `update`, no hi ha
// estat global d'input.
const Player::Controls CONTROLS_P1{
.up = Defaults::KeysGame::UP,
.down = Defaults::KeysGame::DOWN,
.left = Defaults::KeysGame::LEFT,
.right = Defaults::KeysGame::RIGHT,
.jump = Defaults::KeysGame::JUMP,
};
// Spawn centrat al passadís exterior superior: la caixa de col·lisió
// queda dins del passadís vertical del mig (entre columnes 1 i 2) i
// centrada en Y al passadís exterior superior. Així no naix dins de
// cap sarcòfag.
constexpr auto CORRIDOR_CENTER_X = static_cast<float>(GameCanvas::CENTER_X);
const auto CORRIDOR_CENTER_Y =
static_cast<float>(Map::playfield_y) + (static_cast<float>(Map::outer_corridor) / 2.0F);
const auto SPAWN_X = CORRIDOR_CENTER_X - (static_cast<float>(Player::sprite_w) / 2.0F);
const auto SPAWN_Y = CORRIDOR_CENTER_Y - static_cast<float>(Player::collision_y_offset) - (static_cast<float>(Player::collision_size) / 2.0F);
players_.emplace_back(std::make_unique<Player>(ANIM_KEY, SPAWN_X, SPAWN_Y, CONTROLS_P1, Map::makeInfo()));
const Player::Controls CONTROLS_P2{
.up = Defaults::KeysGameP2::UP,
.down = Defaults::KeysGameP2::DOWN,
.left = Defaults::KeysGameP2::LEFT,
.right = Defaults::KeysGameP2::RIGHT,
.jump = Defaults::KeysGameP2::JUMP,
};
// Spawn lligat a la porta: tots els jugadors apareixen davant del
// passadís de sortida. `computeSpawn` deriva les coordenades de
// `Map::door_col` perquè canviar la columna de la porta al YAML
// moga automàticament l'spawn — no hi ha cap valor hardcoded.
const auto SPAWN = computeSpawn();
const bool BOTH = (USE_P1 && USE_P2);
// Connectem el Player al bus perquè emeti `on_tile_entered` i els
// sarcòfags rebin les notificacions de pas per tile.
players_.back()->setEventBus(&entity_manager_.getBus());
// Índex de tile amb què pinta cada jugador — llegit del YAML del mapa
// (camps opcionals `player1_paint_tile` / `player2_paint_tile`). Si no
// hi són, `paint_tile_index_ = -1` i el Player no deixa rastre.
// Ids canònics dels jugadors: 0 = P1, 1 = P2. Els events del bus els
// porten per permetre dispatch correcte al HUD i a futures regles de
// puntuació (veure `Event::player_id`).
constexpr int ID_P1 = 0;
constexpr int ID_P2 = 1;
if (USE_P1) {
const int P1_PAINT = readOptionalInt(config_.map_yaml, "player1_paint_tile", -1);
const float X = BOTH ? SPAWN.p1_x : SPAWN.p1_x + static_cast<float>(Map::tile_size);
players_.emplace_back(std::make_unique<Player>(anim_p1, ID_P1, X, SPAWN.y, CONTROLS_P1, Map::makeInfo(), P1_PAINT));
}
if (USE_P2) {
const int P2_PAINT = readOptionalInt(config_.map_yaml, "player2_paint_tile", -1);
const float X = BOTH ? SPAWN.p2_x : SPAWN.p2_x - static_cast<float>(Map::tile_size);
players_.emplace_back(std::make_unique<Player>(anim_p2, ID_P2, X, SPAWN.y, CONTROLS_P2, Map::makeInfo(), P2_PAINT));
}
// PaintLayer: font única del pintat del nivell. Es crea després de
// `loadFromLevel` perquè l'EntityManager ja ha de tenir els sarcòfags
// registrats (tot i que el sarc escolta via fan-out, no directament
// al bus; l'ordre de subscripcions aquí és irrellevant per la
// correcció, però sí pel log d'arrencada). Connectat al bus del
// manager per subscriure's a `on_tile_entered` i publicar `tile_painted`.
paint_layer_ = std::make_unique<PaintLayer>(tilemap_, &entity_manager_.getBus());
// Connectem tots els jugadors al bus perquè emeten `on_tile_entered`;
// el PaintLayer (subscrit ja) processa el pintat i el fan-out cap als
// sarcòfags passa per `tile_painted`.
for (auto& p : players_) { p->setEventBus(&entity_manager_.getBus()); }
// 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
// `players_view_` i el `setOtherPlayers` s'han de fer ara perquè a
// cada `applyMovement` la callback de col·lisió consulti els altres
// jugadors com a sòlids.
push_factor_ = readOptionalFloat(config_.map_yaml, "push_factor", Defaults::Player::PUSH_FACTOR);
players_view_.reserve(players_.size());
std::ranges::transform(players_, std::back_inserter(players_view_), [](const auto& p) { return p.get(); });
for (auto& p : players_) { p->setOtherPlayers(&players_view_); }
// Comptador d'EXITED a EntityManager: publica `level_complete` quan
// tots els jugadors actius han travessat la porta. Cap setExpectedPlayerCount
// ha d'inicialitzar-se abans del primer `player_exited`; ho fem aquí
// perquè ja sabem el nombre real d'instàncies.
entity_manager_.setExpectedPlayerCount(static_cast<int>(players_.size()));
// El snap també empeny lateralment si l'altre jugador ocupa el carril:
// passem el mateix factor perquè vaja al mateix ritme que el resolver.
for (auto& p : players_) { p->setPushFactor(push_factor_); }
// Marcador (HUD) de la franja superior. Reutilitza la font 8bithud i
// la paleta "ui" ja precarregades pel Director — no toca disc des
// d'aquí. Subscrit al bus per reaccionar a `player_hit`,
// `level_complete`, etc. amb shake/flash i futurs canvis de mode.
const std::string HUD_FONT_GIF_KEY = UiConfig::get().hudFont().name + ".gif";
auto hud_font_surface = Resource::Cache::get().getSurface(HUD_FONT_GIF_KEY);
auto hud_font = std::make_shared<Text>(hud_font_surface, UiConfig::get().hudFont().fnt);
hud_font->setDefaultKerning(UiConfig::get().hudFont().kerning);
hud_ = std::make_unique<Hud>(entity_manager_.getBus(), hud_font);
// Si hi ha P2 actiu, activem el seu bloc (per defecte el HUD naix amb
// només P1). Mentre els events del bus no porten `player_id`, els hits
// segueixen aplicant-se a P1 (veure TODO a Hud::onPlayerHit).
if (USE_P2) { hud_->activatePlayer(2); }
// Música de fons en bucle infinit. El track ja està al Resource::Cache
// (preload al Director), així que playMusic només mou el punter cap a
// jail_audio i obri l'stream. Audio::get() està garantit no-null:
// Director::init crida Audio::init abans d'instanciar GameScene.
Audio::get()->playMusic(config_.music_track, -1);
// Font de l'overlay de pausa: reutilitzem la Surface ja cachejada al
// Director (PLACEHOLDER_NAME + ".gif"). Text només parseja el .fnt a
// l'arrencada de l'escena, un sol cop.
const std::string PAUSE_FONT_KEY = UiConfig::get().placeholderFont().name + ".gif";
auto pause_surface = Resource::Cache::get().getSurface(PAUSE_FONT_KEY);
pause_font_ = std::make_shared<Text>(pause_surface, UiConfig::get().placeholderFont().fnt);
pause_font_->setDefaultKerning(UiConfig::get().placeholderFont().kerning);
// Timings i corbes de la FSM de sala. Llegits del YAML una sola
// vegada; canvis requereixen reiniciar el joc.
transition_config_ = RoomFsm::loadConfig(Defaults::GameScene::CONFIG_YAML);
// Log d'estat inicial: la FSM arrenca a ROOM_INTRO. En aquesta fita
// les transicions són instantànies (sense fades ni text) i el primer
// tick d'`updateRoomTransition` ja saltarà a PLAYING.
std::cout << "[ROOM] state=ROOM_INTRO (initial)" << '\n';
}
GameScene::~GameScene() = default;
auto GameScene::computeSpawn() -> PlayerSpawn {
// Centre de la columna del passadís en píxels canvas. La caixa de
// col·lisió del jugador (12 px) cap dins del passadís (16 px) amb 2
// px de marge a banda i banda; alinear el sprite al centre de la
// columna garanteix que la base del jugador i la del passadís
// coincideixin verticalment.
const float DOOR_CENTER_X = static_cast<float>((Map::door_col * Map::tile_size) + Map::tilemap_origin_x) + (static_cast<float>(Map::tile_size) / 2.0F);
const float CENTER_TL_X = DOOR_CENTER_X - (static_cast<float>(Player::sprite_w) / 2.0F);
// Y: centre del corredor exterior superior (mateix valor que abans;
// el corredor és simètric respecte a `playfield_y`).
const float CORRIDOR_CENTER_Y = static_cast<float>(Map::playfield_y) + (static_cast<float>(Map::outer_corridor) / 2.0F);
const float SPAWN_Y = CORRIDOR_CENTER_Y - static_cast<float>(Player::collision_y_offset) - (static_cast<float>(Player::collision_size) / 2.0F);
// Mode BOTH: P1 a -tile_size, P2 a +tile_size respecte al centre.
// Els callsites individuals decideixen si usar aquests valors o si
// estem en mode single-player (un sol jugador → centrat exactament).
return {
.p1_x = CENTER_TL_X - static_cast<float>(Map::tile_size),
.p2_x = CENTER_TL_X + static_cast<float>(Map::tile_size),
.y = SPAWN_Y,
};
}
void GameScene::resetRoom() {
// Recarregar les entitats des del YAML del mapa: la porta torna a
// CLOSED (amb l'open=false a Map al loadFrom), els sarcs tornen a
// CLOSED amb visited buit, momies i altres actors a la posició inicial.
Map::loadFrom(config_.map_yaml);
entity_manager_.reload(config_.map_yaml);
entity_manager_.setExpectedPlayerCount(static_cast<int>(players_.size()));
if (paint_layer_) { paint_layer_->clear(); }
// Respawn dels jugadors. Recalcular spawn per si door_col canvia
// (avui el mapa no canvia entre cicles, però la lògica ja queda
// preparada per a l'itinerari futur).
const auto SPAWN = computeSpawn();
const bool BOTH = (players_.size() >= 2);
// Mode single-player: l'únic jugador queda centrat (= SPAWN.p1_x +
// tile_size, perquè SPAWN.p1_x és el centre menys un tile).
const float SINGLE_X = SPAWN.p1_x + static_cast<float>(Map::tile_size);
for (std::size_t i = 0; i < players_.size(); ++i) {
float x = SINGLE_X;
if (BOTH) { x = (i == 0) ? SPAWN.p1_x : SPAWN.p2_x; }
players_[i]->respawn(x, SPAWN.y);
}
std::cout << "[ROOM] reset: entities reloaded, paint cleared, "
<< players_.size() << " player(s) respawned\n";
}
void GameScene::setState(RoomFsm::State new_state) {
if (new_state == state_) { return; }
std::cout << "[ROOM] " << RoomFsm::stateName(state_) << " -> " << RoomFsm::stateName(new_state) << '\n';
const auto PREV = state_;
state_ = new_state;
transition_ = {};
// En tornar a ROOM_INTRO des de ROOM_OUTRO (placeholder de
// "torna a la mateixa sala"), reseteja completament l'estat del
// nivell. Si la transició és la inicial (PREV també és ROOM_INTRO,
// imposible amb el guard de dalt) o ve d'un altre estat, no fa res.
if (new_state == RoomFsm::State::ROOM_INTRO && PREV == RoomFsm::State::ROOM_OUTRO) {
resetRoom();
}
}
void GameScene::updateRoomTransition(int delta_ms) {
const auto NEXT = RoomFsm::advance(state_, transition_, transition_config_, delta_ms);
if (NEXT != state_) { setState(NEXT); }
}
void GameScene::renderRoomFade() {
// Fade negre sobre el món, pintat un cop els actors ja estan al
// canvas i abans del HUD. Dos casos:
// ROOM_INTRO/fase 0: 255 → 0 (entrada de sala, món emergent)
// ROOM_OUTRO/fase 3: 0 → 255 (sortida de sala, món s'apaga)
int dur = 0;
float t = 0.0F;
bool fade_in = false;
if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase == 0) {
dur = transition_config_.intro_fade_in_ms;
fade_in = true;
} else if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase == 3) {
dur = transition_config_.outro_fade_out_ms;
fade_in = false;
} else {
return;
}
t = (dur > 0)
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur), 0.0F, 1.0F)
: 1.0F;
const float ALPHA_F = fade_in ? (1.0F - t) : t;
const auto ALPHA = static_cast<std::uint8_t>(255.0F * ALPHA_F);
if (ALPHA == 0) { return; }
Screen::get().blendRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, 0x00000000U, ALPHA);
}
void GameScene::renderRoomText() {
// Text de la transició de sala. Reusa la font cachejada per a la
// pausa (mateix tipus de lletra, mateix índex de color), i fa el
// mateix patró esquerra → centre → esquerra que el text de pausa
// però amb les durades pròpies de la transició.
//
// Mapatge per estat (només els estats amb text actiu hi entren):
// ROOM_INTRO: phases 2..4 → enter / hold / exit; text = sala N
// ROOM_OUTRO: phases 0..2 → enter / hold / exit; text = "fase completada"
if (!pause_font_) { return; }
std::string text;
int phase_enter = 0;
int phase_exit = 0;
int dur_enter = 0;
int dur_exit = 0;
if (state_ == RoomFsm::State::ROOM_INTRO && transition_.phase >= 2 && transition_.phase <= 4) {
text = RoomFsm::formatRoomTitle(room_index_);
phase_enter = 2;
phase_exit = 4;
dur_enter = transition_config_.intro_text_enter_ms;
dur_exit = transition_config_.intro_text_exit_ms;
} else if (state_ == RoomFsm::State::ROOM_OUTRO && transition_.phase >= 0 && transition_.phase <= 2) {
text = Locale::get().text("game.room_complete");
phase_enter = 0;
phase_exit = 2;
dur_enter = transition_config_.outro_text_enter_ms;
dur_exit = transition_config_.outro_text_exit_ms;
} else {
return;
}
constexpr int KERNING = 0;
const int W = pause_font_->length(text, KERNING);
// El text entra des de l'esquerra (off-screen amb el seu cantó dret
// a x=0) i, després del hold, surt cap a la dreta (off-screen amb
// el seu cantó esquerre a x=WIDTH). Així el text "travessa" la
// pantalla en una sola direcció en compte de tornar enrere.
const int OFF_LEFT = -W;
const int OFF_RIGHT = GameCanvas::WIDTH;
int x = GameCanvas::CENTER_X;
if (transition_.phase == phase_enter) {
const float T = (dur_enter > 0)
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_enter), 0.0F, 1.0F)
: 1.0F;
const auto CURVE = (transition_config_.text_enter_curve != nullptr) ? transition_config_.text_enter_curve : &Easing::linear;
x = Easing::lerpInt(OFF_LEFT, GameCanvas::CENTER_X, CURVE(T));
} else if (transition_.phase == phase_exit) {
const float T = (dur_exit > 0)
? std::clamp(static_cast<float>(transition_.elapsed_ms) / static_cast<float>(dur_exit), 0.0F, 1.0F)
: 1.0F;
const auto CURVE = (transition_config_.text_exit_curve != nullptr) ? transition_config_.text_exit_curve : &Easing::linear;
x = Easing::lerpInt(GameCanvas::CENTER_X, OFF_RIGHT, CURVE(T));
}
constexpr Uint8 OUTLINE_WHITE = 255;
constexpr Uint8 STROKE_DIST = 1;
const int Y = GameCanvas::CENTER_Y - (pause_font_->getLineHeight() / 2);
pause_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG,
x,
Y,
text,
KERNING,
UiConfig::get().notificationText(),
STROKE_DIST,
OUTLINE_WHITE);
}
void GameScene::spawnDust(float feet_x_center, float feet_y, bool is_landing) {
// Mida del sprite de Dust llegida del seu YAML. Hardcoded per
// simplificar — si canvia, només cal tocar dust.yaml + aquesta
// constant. La caixa de render es centra horitzontalment al peu i
// s'alinea al sòl amb un pixel de marge cap amunt perquè la sombra
// quede pintada per davant del polvet i el polvet, en l'y-sort,
// entre per davant del jugador (mateixa lògica que la sombra).
constexpr float DUST_W = 40.0F;
constexpr float DUST_H = 16.0F;
auto dust = std::make_unique<Dust>(EntityLoader::loadTemplate("dust"));
dust->setVariant(is_landing ? Dust::Variant::LAND : Dust::Variant::JUMP);
dust->setSurface(Resource::Cache::get().getSurface("dust.gif"));
// Recolzat al sòl, amb la base del rect 1 px per sobre del peu del
// jugador → en l'y-sort surt amb feet_y un pèl menor i, per
// estabilitat de stable_sort, queda darrere del jugador.
dust->setPixelRect({
.x = feet_x_center - (DUST_W * 0.5F),
.y = feet_y - DUST_H - 1.0F,
.w = DUST_W,
.h = DUST_H,
});
entity_manager_.add(std::move(dust));
}
void GameScene::handlePauseInput() {
#ifdef _DEBUG
// Mutex amb l'editor: si està actiu, ignorem la tecla de pausa.
if (editor_.active()) { return; }
#endif
const SDL_Scancode PAUSE_SC = KeyConfig::get().scancode("pause");
SDL_Gamepad* pad = GamepadManager::get().getPrimary();
for (const auto& event : EventBuffer::events) {
const bool KEY_HIT = (event.type == SDL_EVENT_KEY_DOWN && PAUSE_SC != SDL_SCANCODE_UNKNOWN && event.key.scancode == PAUSE_SC);
const bool PAD_HIT = (event.type == SDL_EVENT_GAMEPAD_BUTTON_DOWN && pad != nullptr && event.gbutton.which == SDL_GetGamepadID(pad) && event.gbutton.button == SDL_GAMEPAD_BUTTON_START);
if (!KEY_HIT && !PAD_HIT) { continue; }
// Determinem cap a quin estat anem. Si estem quiets (IDLE o
// PAUSED) arranquem la transició contrària. Si estem en marxa
// (ENTERING o EXITING) invertim: la `scale` actual es manté com
// a `from_scale` nou i la durada es recalcula proporcional a la
// distància restant — així mai es produeix un salt de velocitat.
const bool GOING_TO_PAUSE = (pause_.state == PauseState::IDLE || pause_.state == PauseState::EXITING);
// Reprenem l'stream ABANS de començar a pujar el volum: quan
// venim de PAUSED l'àudio està aturat de debò (ho fem al final
// d'ENTERING, veure updatePauseTransition); reprenem just al
// moment de començar EXITING per no perdre el primer frame
// d'stream. A les inversions mid-flight (ENTERING → EXITING
// sense tocar PAUSED) l'stream no s'ha aturat mai, així que no
// cal reprendre.
if (!GOING_TO_PAUSE && pause_.state == PauseState::PAUSED) {
Audio::get()->resumeMusic();
}
pause_.from_scale = pause_.scale;
pause_.to_scale = GOING_TO_PAUSE ? 0.0F : 1.0F;
const int TARGET_MS = GOING_TO_PAUSE ? UiConfig::get().pauseEnterMs() : UiConfig::get().pauseExitMs();
const float DISTANCE = std::fabs(pause_.to_scale - pause_.from_scale);
pause_.duration_ms = std::max(1, static_cast<int>(static_cast<float>(TARGET_MS) * DISTANCE));
pause_.elapsed_ms = 0;
pause_.curve = GOING_TO_PAUSE ? UiConfig::get().pauseEnterCurve() : UiConfig::get().pauseExitCurve();
pause_.state = GOING_TO_PAUSE ? PauseState::ENTERING : PauseState::EXITING;
std::cout << "[PAUSE] " << (GOING_TO_PAUSE ? "entering" : "exiting") << " from_scale=" << pause_.from_scale << " duration_ms=" << pause_.duration_ms << '\n';
}
}
void GameScene::updatePauseTransition(int delta_ms) {
if (pause_.state != PauseState::ENTERING && pause_.state != PauseState::EXITING) { return; }
pause_.elapsed_ms += delta_ms;
bool just_reached_pause = false;
if (pause_.elapsed_ms >= pause_.duration_ms) {
// Transició completa: enganxem al valor objectiu exacte i
// promocionem l'estat al terminal corresponent.
pause_.scale = pause_.to_scale;
const PauseState NEXT = (pause_.to_scale == 0.0F) ? PauseState::PAUSED : PauseState::IDLE;
just_reached_pause = (NEXT == PauseState::PAUSED);
pause_.state = NEXT;
} else {
const float T = static_cast<float>(pause_.elapsed_ms) / static_cast<float>(pause_.duration_ms);
const auto CURVE = (pause_.curve != nullptr) ? pause_.curve : &Easing::linear;
pause_.scale = std::lerp(pause_.from_scale, pause_.to_scale, CURVE(T));
}
// Fade del volum de música durant la transició. La base és el que
// l'usuari ha configurat a Options; multipliquem per la `scale` per
// obtenir el volum efectiu aquest frame.
const float BASE_VOLUME = Options::audio().music.volume;
Audio::get()->setMusicVolume(BASE_VOLUME * pause_.scale);
// En arribar a PAUSED aturem l'stream: així quan despausem la música
// tornarà des d'on s'ha quedat, no des d'on hauria anat a parar
// durant el temps que el joc ha estat en pausa. El `resumeMusic` es
// fa a `handlePauseInput` al començar l'EXITING, perquè la música
// sone ja mentre el volum puja des de 0.
if (just_reached_pause) {
Audio::get()->pauseMusic();
}
}
#ifdef _DEBUG
namespace {
auto detectEditorToggle() -> bool {
const SDL_Scancode SC = KeyConfig::get().scancode("editor_toggle");
if (SC == SDL_SCANCODE_UNKNOWN) { return false; }
return std::ranges::any_of(EventBuffer::events, [SC](const SDL_Event& ev) {
return ev.type == SDL_EVENT_KEY_DOWN && ev.key.scancode == SC;
});
}
} // namespace
#endif
void GameScene::update(int delta_ms) {
handlePauseInput();
updatePauseTransition(delta_ms);
// Transició de sala: viu en temps real (no escalada per la pausa)
// perquè és UI. Avança avans del bloc de món perquè quan PLAYING
// arrenque ja sigui efectiu en el mateix frame.
updateRoomTransition(delta_ms);
#ifdef _DEBUG
// Toggle de l'editor: només si no hi ha cap transició de pausa ni
// pausa consolidada. L'editor mateix no consumeix la tecla — la
// llegim ací per decidir la coexistència.
if (pause_.state == PauseState::IDLE && detectEditorToggle()) {
editor_.toggle(tilemap_);
}
#endif
GlobalInputs::update(delta_ms);
if (GlobalInputs::wantsQuit()) { wants_quit_ = true; }
const float DT = static_cast<float>(delta_ms) / 1000.0F;
for (auto& player : players_) { player->update(DT); }
// El tick d'entitats DESPRÉS del tick dels jugadors: si el jugador ha
// emès `on_tile_entered` aquest frame, l'EntityManager ja l'ha encaminat
// als sarcòfags via la subscripció del bus, i aquest `update` pot
// aplicar la transició OPENING → OPEN en el mateix frame.
entity_manager_.update(DT);
// El món avança amb `delta_ms * pause_.scale`: scale=1 corre a velocitat
// real, scale=0 el congela. Quan l'editor de debug està actiu també
// es salta completament (no l'escalem, el saltem). Notifier,
// RenderInfo i HUD reben el delta real, per això viuen fora d'aquest
// bloc i no es ralenteixen amb el dial de pausa.
#ifdef _DEBUG
const bool EDITOR_FROZEN = editor_.active();
#else
constexpr bool EDITOR_FROZEN = false;
#endif
const int SCALED_DELTA_MS = static_cast<int>(static_cast<float>(delta_ms) * pause_.scale);
const bool GAMEPLAY_ACTIVE = (state_ == RoomFsm::State::PLAYING);
if (!EDITOR_FROZEN && SCALED_DELTA_MS > 0) {
const float DT = static_cast<float>(SCALED_DELTA_MS) / 1000.0F;
// Detecció de contacte player↔entitat després que totes les entitats
// hagin mogut aquest frame. La porta (si està oberta) emet
// `level_complete`; la momia/trampa emet `player_hit`. Els sarcs, encara
// que tinguen collides=true, tenen la caixa lògica fora del camí del
// jugador (gràcies a Map::collides al Player::update), així que aquí
// no generen falsos contactes.
std::vector<Player*> player_ptrs;
player_ptrs.reserve(players_.size());
std::ranges::transform(players_, std::back_inserter(player_ptrs), [](const auto& p) { return p.get(); });
entity_manager_.checkPlayerCollisions(player_ptrs);
RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiColor::RENDER_INFO_TEXT, UiColor::RENDER_INFO_SHADOW));
// Durant ROOM_INTRO/ROOM_OUTRO els jugadors no s'actualitzen ni
// poden col·lisionar amb entitats. Les entitats sí, perquè el
// disseny demana que el món continui animant-se durant les
// transicions de sala.
if (GAMEPLAY_ACTIVE) {
// Pipeline de moviment en tres passes per poder intercalar la
// resolució de contactes jugador↔jugador entre input i posició:
// 1) tick: cada jugador calcula la velocitat intencionada
// (teclat + mando + facing).
// 2) resolve: si els dos es trobarien, anul·lem velocitats
// (head-on) o escalem a push_factor (empenta).
// 3) applyMovement: en l'ordre "pushed primer" perquè quan el
// pusher faça `tryMove`, el pushed ja estiga fora de la
// seua caixa. La callback també tracta l'altre jugador com
// a sòlid: xarxa de seguretat davant de qualsevol inexactitud.
for (auto& player : players_) { player->tick(DT); }
resolvePlayerVsPlayer(players_view_, DT, push_factor_);
for (auto* player : orderedForMovement(players_view_)) { player->applyMovement(DT); }
}
// El tick d'entitats DESPRÉS del tick dels jugadors: si el jugador ha
// emès `on_tile_entered` aquest frame, l'EntityManager ja l'ha encaminat
// als sarcòfags via la subscripció del bus, i aquest `update` pot
// aplicar la transició OPENING → OPEN en el mateix frame.
entity_manager_.update(DT);
// Detecció de contacte player↔entitat després que totes les entitats
// hagin mogut aquest frame. Reutilitzem la vista plana que ja
// manté el GameScene per al resolver player↔player.
if (GAMEPLAY_ACTIVE) {
entity_manager_.checkPlayerCollisions(players_view_);
}
}
#ifdef _DEBUG
editor_.update(delta_ms, tilemap_);
#endif
// El HUD continua avançant les seues animacions encara que el món
// estigui congelat (pausa o editor): així l'animació d'entrada acaba
// sola i quan es despausa ja està al seu lloc, igual que el Notifier.
if (hud_) { hud_->update(delta_ms); }
RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiConfig::get().renderInfoText(), UiConfig::get().renderInfoShadow()));
Notifier::get().update(delta_ms);
}
void GameScene::render() {
const auto BG_ARGB = Resource::Cache::get().getPalette("ui")[UiColor::CANVAS_BG];
const auto BG_ARGB = Resource::Cache::get().getPalette("ui")[UiConfig::get().canvasBg()];
Screen::get().fillRect(0, 0, GameCanvas::WIDTH, GameCanvas::HEIGHT, BG_ARGB);
// Mapa visual: textura prerenderitzada del tilemap. Els sarcòfags
@@ -149,33 +786,111 @@ void GameScene::render() {
// col·lisions del jugador.
tilemap_.render();
// Z-order interleaving entitats ↔ players per `getFeetY()` ascendent:
// els actors amb els peus més amunt queden dibuixats primer (al fons),
// els més avall al davant. Així un sarc situat per darrere d'un player
// queda tapat per ell, i un sarc a davant el tapa a ell.
struct Renderable {
float feet_y;
Entity* entity; // nullable; si és nullptr, s'usa `player`.
Player* player; // nullable; si és nullptr, s'usa `entity`.
};
std::vector<Renderable> order;
order.reserve(entity_manager_.entityCount() + players_.size());
const auto ENTITIES = entity_manager_.getEntities();
std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) {
return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr};
});
std::ranges::transform(players_, std::back_inserter(order), [](const auto& p) {
return Renderable{.feet_y = p->getFeetY(), .entity = nullptr, .player = p.get()};
});
std::ranges::stable_sort(order, [](const Renderable& a, const Renderable& b) {
return a.feet_y < b.feet_y;
});
for (const auto& r : order) {
if (r.entity != nullptr) {
EntityManager::renderEntity(*r.entity);
} else if (r.player != nullptr) {
r.player->render();
#ifdef _DEBUG
// En mode editor amaguem entitats i jugador: el tilemap queda net
// per a poder clicar cel·la per cel·la sense interferència visual.
// El HUD futur (quan s'afegeixi) també farà cas d'aquest flag.
const bool SKIP_ACTORS = editor_.active();
#else
constexpr bool SKIP_ACTORS = false;
#endif
// 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ó
// explícita: l'editor edita el tilemap, no la capa de pintat).
if (!SKIP_ACTORS && paint_layer_) { paint_layer_->render(); }
if (!SKIP_ACTORS) {
// Durant ROOM_INTRO/ROOM_OUTRO els jugadors no es veuen ni es mouen.
// Les entitats sí que es renderitzen perquè el disseny demana que
// el món continui animant-se durant el fade i el text.
const bool RENDER_PLAYERS = (state_ == RoomFsm::State::PLAYING);
if (RENDER_PLAYERS) {
// Passada de sombres dels jugadors saltant. Va per davant del
// pintat (al damunt) però per darrere de TOTS els actors —
// entitats i jugadors, inclòs el saltador mateix— així la sombra
// queda al sòl sense tapar res. `renderShadow` és no-op si el
// jugador està a terra, així que no cal filtrar al callsite.
for (const auto& p : players_) { p->renderShadow(); }
}
// Z-order interleaving entitats ↔ players per `getFeetY()` ascendent:
// els actors amb els peus més amunt queden dibuixats primer (al fons),
// els més avall al davant. Així un sarc situat per darrere d'un player
// queda tapat per ell, i un sarc a davant el tapa a ell.
struct Renderable {
float feet_y;
Entity* entity; // nullable; si és nullptr, s'usa `player`.
Player* player; // nullable; si és nullptr, s'usa `entity`.
};
std::vector<Renderable> order;
order.reserve(entity_manager_.entityCount() + (RENDER_PLAYERS ? players_.size() : 0));
const auto ENTITIES = entity_manager_.getEntities();
std::ranges::transform(ENTITIES, std::back_inserter(order), [](Entity* e) {
return Renderable{.feet_y = e->getFeetY(), .entity = e, .player = nullptr};
});
if (RENDER_PLAYERS) {
std::ranges::transform(players_, std::back_inserter(order), [](const auto& p) {
return Renderable{.feet_y = p->getFeetY(), .entity = nullptr, .player = p.get()};
});
}
std::ranges::stable_sort(order, [](const Renderable& a, const Renderable& b) {
return a.feet_y < b.feet_y;
});
for (const auto& r : order) {
if (r.entity != nullptr) {
EntityManager::renderEntity(*r.entity);
} else if (r.player != nullptr) {
r.player->render();
}
}
// Fade del món: pintat ENTRE actors i HUD perquè el marcador, les
// notificacions i el service menu queden per damunt sense afectar-se.
// Inactiu si l'estat és PLAYING.
renderRoomFade();
// HUD: a sobre dels actors (i del fade) perquè la franja superior
// tape qualsevol sprite que (durant l'animació d'entrada) puga
// sortir més amunt del playfield. S'amaga en mode editor però
// segueix actualitzant-se per fora.
if (hud_) { hud_->render(); }
// Text de la transició de sala (entrada o sortida): per damunt del
// HUD perquè és UI de transició i ha de ser sempre llegible.
renderRoomText();
}
#ifdef _DEBUG
editor_.render(tilemap_);
#endif
// Overlay de pausa: text amb stroke blanc (índex 255) centrat amb X
// depenent de l'estat del dial. Entra lliscant des de l'esquerra
// (ENTERING), es queda al centre (PAUSED) i surt cap a la dreta
// (EXITING). La X es deriva directament de `pause_.scale`, que ja
// porta la corba aplicada — així text i món frenen alhora.
if (pause_.state != PauseState::IDLE && pause_font_) {
constexpr Uint8 OUTLINE_WHITE = 255;
constexpr Uint8 STROKE_DIST = 1;
constexpr int PAUSE_KERNING = 0;
const std::string TEXT = Locale::get().text("input.paused");
const int TEXT_WIDTH = pause_font_->length(TEXT, PAUSE_KERNING);
const int OFF_LEFT = -TEXT_WIDTH;
const int OFF_RIGHT = GameCanvas::WIDTH + TEXT_WIDTH;
int x = GameCanvas::CENTER_X;
if (pause_.state == PauseState::ENTERING) {
x = Easing::lerpInt(OFF_LEFT, GameCanvas::CENTER_X, 1.0F - pause_.scale);
} else if (pause_.state == PauseState::EXITING) {
x = Easing::lerpInt(GameCanvas::CENTER_X, OFF_RIGHT, pause_.scale);
}
const int Y = GameCanvas::CENTER_Y - (pause_font_->getLineHeight() / 2);
pause_font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG | Text::STROKE4_FLAG,
x,
Y,
TEXT,
PAUSE_KERNING,
UiConfig::get().notificationText(),
STROKE_DIST,
OUTLINE_WHITE);
}
Notifier::get().render();
+155 -2
View File
@@ -1,25 +1,40 @@
#pragma once
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
#include "core/scene/scene.hpp"
#include "core/ui/ui_config.hpp"
#include "game/debug_config.hpp"
#include "game/entities/entity_manager.hpp"
#include "game/map/paint_layer.hpp"
#include "game/map/tilemap.hpp"
#include "game/scenes/room_fsm.hpp"
#ifdef _DEBUG
#include "game/editor/editor.hpp"
#endif
class Hud;
class Player;
class Text;
class GameScene : public Scene {
public:
// Conjunt de rutes que defineixen una piràmide concreta (mapa + tilemap
// + sprites del jugador + música). Cada piràmide serà un Config distint;
// `defaultConfig()` retorna la primera (Defaults::Pyramid).
// + sprites dels jugadors + música). Cada piràmide serà un Config distint;
// `defaultConfig()` retorna la primera (Defaults::Pyramid). `starting_players`
// controla quins jugadors instancia el ctor: només P1, només P2 o els dos.
struct Config {
std::string map_yaml;
std::string player_sprite;
std::string player_yaml;
std::string player2_sprite;
std::string player2_yaml;
std::string music_track;
DebugConfig::StartingPlayers starting_players{DebugConfig::StartingPlayers::BOTH};
};
[[nodiscard]] static auto defaultConfig() -> Config;
@@ -31,6 +46,34 @@ class GameScene : public Scene {
void render() override;
private:
// Punt centralitzat per canviar d'estat de sala. Imprimeix la
// transició i reinicialitza els acumuladors de la `RoomFsm::Transition`.
// Cap estat s'ha de canviar fora d'aquí.
void setState(RoomFsm::State new_state);
// Avança la transició activa via `RoomFsm::advance`. S'executa cada
// frame amb delta real (no escalat per la pausa) perquè la transició
// viu en temps de UI. Si l'estat retornat és diferent de l'actual,
// crida `setState` (que reseteja la `Transition`).
void updateRoomTransition(int delta_ms);
// Consumeix events d'aquest frame (F11 del KeyConfig "pause" o botó
// START del gamepad primari) per disparar les transicions de pausa.
// No alterna un flag binari: inicia (o inverteix mid-flight) un
// "dial" de temps que frena el món amb easing.
void handlePauseInput();
// Avança la transició activa, recalcula `pause_.scale` i aplica el
// fade de volum de música. S'executa cada frame amb el delta real
// (no escalat) perquè la transició viu en temps real.
void updatePauseTransition(int delta_ms);
// Spawna un Dust transitori centrat a (`feet_x_center`, `feet_y`).
// Cridat per la subscripció del bus a `player_jumped` /
// `player_landed` per pintar el polvet de despegue / aterratge.
// `is_landing` selecciona la variant de l'animació al `Dust`.
void spawnDust(float feet_x_center, float feet_y, bool is_landing);
Config config_;
Tilemap tilemap_;
@@ -42,4 +85,114 @@ class GameScene : public Scene {
// Vector des del principi: AEE suportarà multijugador local; cada Player
// té els seus propis Controls i entra al vector amb una crida extra.
std::vector<std::unique_ptr<Player>> players_;
// Vista plana de punters crus — la fa servir Player com a llista
// d'obstacles mòbils a la callback de col·lisió. Es construeix un sol
// cop al ctor (els punters són estables perquè `players_` no es
// redimensiona dins de la vida de l'escena).
std::vector<Player*> players_view_;
// Factor multiplicatiu del push inter-jugador. Carregat de la clau
// `push_factor` del YAML del mapa; 0.30 per defecte (veure
// `Defaults::Player::PUSH_FACTOR`). Constant dins de la vida de
// l'escena — una piràmide, una física.
float push_factor_{0.30F};
// Capa de "pintat" del terra. Escolta `on_tile_entered` al bus,
// filtra pintabilitat (sòl transitable) i re-publica `tile_painted`
// per als sarcòfags. `unique_ptr` per controlar l'ordre de
// construcció/destrucció: es crea al ctor un cop `entity_manager_` i
// `tilemap_` estan llestos, i es destrueix abans que `entity_manager_`
// (on viu el bus al qual està subscrit). Declarat després d'aquests
// dos membres per garantir l'ordre invers a la destrucció.
std::unique_ptr<PaintLayer> paint_layer_;
// Marcador de la franja superior (24 px). `unique_ptr` per controlar
// l'ordre de construcció/destrucció igual que `paint_layer_`: es crea
// al ctor un cop `entity_manager_` ja té el bus, i es destrueix abans
// que `entity_manager_` (on viu el bus al qual està subscrit).
// Declarat després d'`entity_manager_` per garantir l'ordre invers a
// la destrucció.
std::unique_ptr<Hud> hud_;
// Pausa de gameplay com a "dial" de temps. En lloc d'un interruptor,
// `pause_.scale` va de 1 (món corrent) a 0 (món congelat) amb una
// corba d'easing durant ENTERING, i al revés durant EXITING. El
// `delta_ms` del món (players + EntityManager) es multiplica per
// aquesta escala; HUD, Notifier i GlobalInputs reben sempre el
// delta real. Les inversions mid-flight capturen la `scale` actual
// com a `from_scale` i escalen la durada a la distància restant per
// mantenir la sensació de velocitat. La font és la mateixa que
// abans — ja cachejada al Director.
std::shared_ptr<Text> pause_font_;
enum class PauseState : std::uint8_t {
IDLE, // Joc corrent; cap transició en marxa. scale == 1.
ENTERING, // Frenant cap a la pausa. scale decreix a 0.
PAUSED, // Món congelat. scale == 0.
EXITING, // Accelerant de tornada. scale creix a 1.
};
struct PauseTransition {
PauseState state{PauseState::IDLE};
float scale{1.0F}; // 0 = parat, 1 = corrent.
float from_scale{1.0F}; // Valor de `scale` al començar la transició.
float to_scale{1.0F}; // Valor objectiu (0.0 o 1.0).
int elapsed_ms{0}; // Temps transcorregut dins la transició.
int duration_ms{0}; // Durada total (escalada a la distància).
UiConfig::CurveFn curve{nullptr}; // Corba activa.
} pause_;
// Configuració de la FSM de sala (intro/outro). Carregada al ctor des
// de `data/scenes/game.yaml`; valors per defecte si manca el YAML.
RoomFsm::Config transition_config_{};
// Estat actual de la FSM de sala. S'inicialitza a ROOM_INTRO al ctor
// perquè cada vegada que entres al joc passes per la presentació de
// la sala. La transició a PLAYING l'orquestra `updateRoomTransition`.
RoomFsm::State state_{RoomFsm::State::ROOM_INTRO};
// Sub-fase i temps acumulat dins de l'estat actual. `setState` el
// reseteja a {0, 0}.
RoomFsm::Transition transition_{};
// Índex de sala que es passa com a paràmetre {n} al text d'intro.
// Avui és sempre 1 (no hi ha itinerari de sales encara). Quan
// arribi l'itinerari, és el camp que es modificarà al transicionar
// de ROOM_OUTRO → ROOM_INTRO per saltar a la pròxima.
int room_index_{1};
// Píxels del fade negre i del text de la transició de sala.
// Cridats des de `render()` entre el bloc d'actors i el HUD —
// així marcador, notificacions i service menu queden per damunt.
void renderRoomFade();
void renderRoomText();
// Posició inicial dels jugadors derivada de `Map::door_col`. Tots
// els jugadors comparteixen Y (centre del corredor exterior superior);
// X depèn del nombre actiu: amb un jugador queda centrat sota la
// porta; amb dos, separats ±tile_size respecte al centre. Estructura
// exposada perquè els tests puguen verificar-la sense entrar al ctor
// sencer.
struct PlayerSpawn {
float p1_x{0.0F};
float p2_x{0.0F};
float y{0.0F};
};
[[nodiscard]] static auto computeSpawn() -> PlayerSpawn;
// Reset complet de la sala: reload de l'EntityManager (sarcs/porta/
// momies des de zero), neteja del PaintLayer i respawn dels jugadors
// a la posició calculada per `computeSpawn`. Cridat des de
// `setState(ROOM_INTRO)` quan la transició anterior era OUTRO.
void resetRoom();
#ifdef _DEBUG
// Editor modal del tilemap. Mútuament excloent amb la pausa: si la
// pausa no està IDLE la tecla de toggle de l'editor s'ignora, i
// viceversa. Quan `editor_.active()` és true, saltem entitats i
// jugador (update i render) i deixem el tilemap visible perquè es
// pugui editar.
Editor editor_;
#endif
};
+4 -4
View File
@@ -14,9 +14,9 @@
#include "core/rendering/screen.hpp"
#include "core/rendering/text.hpp"
#include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp"
#include "game/options.hpp"
#include "game/scenes/placeholder_input.hpp"
#include "game/ui/ui_palette.hpp"
#include "utils/defaults.hpp"
#include "utils/defines.hpp"
@@ -56,7 +56,7 @@ void IntroScene::update(int delta_ms) {
}
Notifier::get().update(delta_ms);
RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiColor::RENDER_INFO_TEXT, UiColor::RENDER_INFO_SHADOW));
RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiConfig::get().renderInfoText(), UiConfig::get().renderInfoShadow()));
}
void IntroScene::render() {
@@ -70,13 +70,13 @@ void IntroScene::render() {
GameCanvas::CENTER_Y + NAME_Y_OFFSET,
SCENE_NAME,
1,
Defaults::UI::NOTIFICATION_TEXT);
UiConfig::get().notificationText());
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
GameCanvas::CENTER_X,
GameCanvas::CENTER_Y + TIMER_Y_OFFSET,
std::to_string(SECS_LEFT),
1,
Defaults::UI::NOTIFICATION_TEXT);
UiConfig::get().notificationText());
}
Notifier::get().render();
+4 -4
View File
@@ -14,9 +14,9 @@
#include "core/rendering/screen.hpp"
#include "core/rendering/text.hpp"
#include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp"
#include "game/options.hpp"
#include "game/scenes/placeholder_input.hpp"
#include "game/ui/ui_palette.hpp"
#include "utils/defaults.hpp"
#include "utils/defines.hpp"
@@ -57,7 +57,7 @@ void LogoScene::update(int delta_ms) {
}
Notifier::get().update(delta_ms);
RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiColor::RENDER_INFO_TEXT, UiColor::RENDER_INFO_SHADOW));
RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiConfig::get().renderInfoText(), UiConfig::get().renderInfoShadow()));
}
void LogoScene::render() {
@@ -71,13 +71,13 @@ void LogoScene::render() {
GameCanvas::CENTER_Y + NAME_Y_OFFSET,
SCENE_NAME,
1,
Defaults::UI::NOTIFICATION_TEXT);
UiConfig::get().notificationText());
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
GameCanvas::CENTER_X,
GameCanvas::CENTER_Y + TIMER_Y_OFFSET,
std::to_string(SECS_LEFT),
1,
Defaults::UI::NOTIFICATION_TEXT);
UiConfig::get().notificationText());
}
Notifier::get().render();
+131
View File
@@ -0,0 +1,131 @@
#include "game/scenes/room_fsm.hpp"
#include <array>
#include <iostream>
#include <string_view>
#include "core/locale/locale.hpp"
#include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp"
namespace {
// Llegeix un sub-camp sencer d'un node YAML. Si manca, retorna el
// fallback. Dissenyat per al consum puntual de `loadConfig`.
auto readChildInt(const fkyaml::node& parent, const char* key, int fallback) -> int {
if (!parent.contains(key)) { return fallback; }
return parent[key].get_value<int>();
}
} // namespace
namespace RoomFsm {
auto loadConfig(const std::string& yaml_path) -> Config {
Config cfg{};
cfg.text_enter_curve = UiConfig::curveByName("outCubic");
cfg.text_exit_curve = UiConfig::curveByName("inCubic");
auto bytes = Resource::Helper::loadFile(yaml_path);
if (bytes.empty()) {
std::cout << "RoomFsm: " << yaml_path << " absent — uso fallbacks compilats\n";
return cfg;
}
const auto* begin = reinterpret_cast<const char*>(bytes.data());
const auto* end = begin + bytes.size();
auto yaml = fkyaml::node::deserialize(begin, end);
if (yaml.contains("room_intro")) {
const auto& n = yaml["room_intro"];
cfg.intro_fade_in_ms = readChildInt(n, "fade_in_ms", cfg.intro_fade_in_ms);
cfg.intro_text_delay_ms = readChildInt(n, "text_delay_ms", cfg.intro_text_delay_ms);
cfg.intro_text_enter_ms = readChildInt(n, "text_enter_ms", cfg.intro_text_enter_ms);
cfg.intro_text_hold_ms = readChildInt(n, "text_hold_ms", cfg.intro_text_hold_ms);
cfg.intro_text_exit_ms = readChildInt(n, "text_exit_ms", cfg.intro_text_exit_ms);
}
if (yaml.contains("room_outro")) {
const auto& n = yaml["room_outro"];
cfg.outro_text_enter_ms = readChildInt(n, "text_enter_ms", cfg.outro_text_enter_ms);
cfg.outro_text_hold_ms = readChildInt(n, "text_hold_ms", cfg.outro_text_hold_ms);
cfg.outro_text_exit_ms = readChildInt(n, "text_exit_ms", cfg.outro_text_exit_ms);
cfg.outro_fade_out_ms = readChildInt(n, "fade_out_ms", cfg.outro_fade_out_ms);
}
if (yaml.contains("text")) {
const auto& n = yaml["text"];
if (n.contains("enter_curve")) {
cfg.text_enter_curve = UiConfig::curveByName(n["enter_curve"].get_value<std::string>());
}
if (n.contains("exit_curve")) {
cfg.text_exit_curve = UiConfig::curveByName(n["exit_curve"].get_value<std::string>());
}
}
std::cout << "RoomFsm: timings carregats des de " << yaml_path
<< " (intro fade " << cfg.intro_fade_in_ms
<< "ms, outro fade " << cfg.outro_fade_out_ms << "ms)\n";
return cfg;
}
auto advance(State current, Transition& t, const Config& cfg, int delta_ms) -> State {
if (current == State::PLAYING) { return current; }
t.elapsed_ms += delta_ms;
if (current == State::ROOM_INTRO) {
const std::array<int, 5> DURS = {
cfg.intro_fade_in_ms,
cfg.intro_text_delay_ms,
cfg.intro_text_enter_ms,
cfg.intro_text_hold_ms,
cfg.intro_text_exit_ms,
};
while (t.phase < static_cast<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) {
t.elapsed_ms -= DURS[t.phase];
++t.phase;
}
if (t.phase >= static_cast<int>(DURS.size())) { return State::PLAYING; }
return current;
}
// ROOM_OUTRO
const std::array<int, 4> DURS = {
cfg.outro_text_enter_ms,
cfg.outro_text_hold_ms,
cfg.outro_text_exit_ms,
cfg.outro_fade_out_ms,
};
while (t.phase < static_cast<int>(DURS.size()) && t.elapsed_ms >= DURS[t.phase]) {
t.elapsed_ms -= DURS[t.phase];
++t.phase;
}
// Placeholder fins a l'itinerari de sales: l'OUTRO recau a INTRO.
// TODO(itinerari): aquest mateix retorn sortirà cap a una nova
// sala (incrementant `room_index_` i recreant tilemap/entitats al
// GameScene) abans de tornar a ROOM_INTRO.
if (t.phase >= static_cast<int>(DURS.size())) { return State::ROOM_INTRO; }
return current;
}
auto formatRoomTitle(int n) -> std::string {
auto tpl = Locale::get().text("game.room_intro");
constexpr std::string_view PLACEHOLDER = "{n}";
auto pos = tpl.find(PLACEHOLDER);
if (pos != std::string::npos) {
tpl.replace(pos, PLACEHOLDER.size(), std::to_string(n));
}
return tpl;
}
auto stateName(State s) -> const char* {
switch (s) {
case State::ROOM_INTRO:
return "ROOM_INTRO";
case State::PLAYING:
return "PLAYING";
case State::ROOM_OUTRO:
return "ROOM_OUTRO";
}
return "?";
}
} // namespace RoomFsm
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include <cstdint>
#include <string>
#include "core/ui/ui_config.hpp"
// FSM de sala (intro/outro) extreta de GameScene per a testabilitat. La
// lògica que viu aquí és pura: no toca SDL, ni Audio, ni cap singleton
// excepte `Locale::get()` (per a la formatació del text). El consumidor
// actual és `GameScene`, que cridarà `RoomFsm::advance` cada frame.
namespace RoomFsm {
enum class State : std::uint8_t {
ROOM_INTRO, // Entrada de sala: fade in + text d'entrada.
PLAYING, // Joc normal.
ROOM_OUTRO, // Sortida de sala: text de sortida + fade out.
};
// Sub-fase i temps acumulat dins de l'estat actual. Es reseteja a
// {0, 0} quan es canvia d'estat. Significat de `phase` per estat:
// ROOM_INTRO: 0=fade_in, 1=text_delay, 2=text_enter, 3=text_hold, 4=text_exit
// ROOM_OUTRO: 0=text_enter, 1=text_hold, 2=text_exit, 3=fade_out
// PLAYING: no s'usa.
struct Transition {
int phase{0};
int elapsed_ms{0};
};
// Timings i corbes carregats de `data/scenes/game.yaml`. Els valors
// per defecte coincideixen amb els del YAML versionat: si manca el
// fitxer, la FSM funciona igualment amb aquests fallbacks.
struct Config {
int intro_fade_in_ms{500};
int intro_text_delay_ms{500};
int intro_text_enter_ms{500};
int intro_text_hold_ms{1000};
int intro_text_exit_ms{500};
int outro_text_enter_ms{500};
int outro_text_hold_ms{1000};
int outro_text_exit_ms{500};
int outro_fade_out_ms{500};
UiConfig::CurveFn text_enter_curve{nullptr};
UiConfig::CurveFn text_exit_curve{nullptr};
};
// Llegeix la configuració des del YAML. Si el fitxer manca o falten
// claus, els camps no presents conserven els seus fallbacks. Les
// corbes es resolen amb `UiConfig::curveByName` (mateix mecanisme
// que la pausa); si el nom és desconegut cau a `Easing::linear`.
[[nodiscard]] auto loadConfig(const std::string& yaml_path) -> Config;
// Avança la transició consumint `delta_ms` des del temps real.
// Retorna l'estat resultant (potser el mateix que l'entrant si
// encara queden sub-fases). Si retorna un estat diferent, el caller
// ha de resetejar la `Transition` a {0, 0} abans del següent frame
// (això ho fa `GameScene::setState`). Funció pura sobre l'argument.
[[nodiscard]] auto advance(State current, Transition& t, const Config& cfg, int delta_ms) -> State;
// Substitueix `{n}` de la clau "game.room_intro" del Locale per
// l'enter passat. Si la clau no està traduïda, retorna la pròpia
// clau (fallback de Locale::text).
[[nodiscard]] auto formatRoomTitle(int n) -> std::string;
// Nom textual per a logs i tests. No es fa servir per a lògica.
[[nodiscard]] auto stateName(State s) -> const char*;
} // namespace RoomFsm
+4 -4
View File
@@ -14,9 +14,9 @@
#include "core/rendering/screen.hpp"
#include "core/rendering/text.hpp"
#include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp"
#include "game/options.hpp"
#include "game/scenes/placeholder_input.hpp"
#include "game/ui/ui_palette.hpp"
#include "utils/defaults.hpp"
#include "utils/defines.hpp"
@@ -56,7 +56,7 @@ void TitleScene::update(int delta_ms) {
}
Notifier::get().update(delta_ms);
RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiColor::RENDER_INFO_TEXT, UiColor::RENDER_INFO_SHADOW));
RenderInfo::get().update(delta_ms, Options::buildRenderInfoFrame(UiConfig::get().renderInfoText(), UiConfig::get().renderInfoShadow()));
}
void TitleScene::render() {
@@ -70,13 +70,13 @@ void TitleScene::render() {
GameCanvas::CENTER_Y + NAME_Y_OFFSET,
SCENE_NAME,
1,
Defaults::UI::NOTIFICATION_TEXT);
UiConfig::get().notificationText());
font_->writeDX(Text::CENTER_FLAG | Text::COLOR_FLAG,
GameCanvas::CENTER_X,
GameCanvas::CENTER_Y + TIMER_Y_OFFSET,
std::to_string(SECS_LEFT),
1,
Defaults::UI::NOTIFICATION_TEXT);
UiConfig::get().notificationText());
}
Notifier::get().render();
+293
View File
@@ -0,0 +1,293 @@
#include "game/ui/hud.hpp"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <string>
#include <utility>
#include <variant>
#include "core/locale/locale.hpp"
#include "core/rendering/screen.hpp"
#include "core/rendering/text.hpp"
#include "core/resources/resource_cache.hpp"
#include "core/ui/ui_config.hpp"
#include "utils/defaults.hpp"
#include "utils/defines.hpp"
#include "utils/easing.hpp"
namespace {
// Els colors del HUD viuen a data/ui.yaml (colors.hud_label/value/flash)
// via UiConfig. Les etiquetes ("P1", "V", "T") usen un to apagat per
// crear jerarquia visual respecte als valors; el flash temporal usa un
// to càlid que contrasta amb el valor normal.
// Amplada nominal d'una cel·la de dígit. Imposem monoespaiat als
// comptadors per evitar jitter visible quan canvien valors (Notifier
// ho fa igual amb `writeMonoDigits`).
constexpr int MONO_DIGIT_W = 6;
// Separador que s'enganxa al final de l'etiqueta abans del valor
// ("P1 000000", "Vides 3"...). Canviant aquesta cadena aquí es
// controla el look-and-feel de totes les línies del HUD.
constexpr const char* SEPARATOR = " ";
// Separació extra entre camps dins d'una mateixa línia.
constexpr int FIELD_GAP = 6; // px entre "Vides 3" i "Tres. 00"
// Format zero-padded a 6 dígits per a la puntuació.
auto formatScore(int v) -> std::string {
const int CLAMPED = std::clamp(v, 0, 999999);
std::string s = std::to_string(CLAMPED);
return std::string(6 - s.size(), '0') + s;
}
// Format zero-padded a 2 dígits per a comptadors petits (tresors,
// sales, vides quan en tenim moltes).
auto formatTwoDigits(int v) -> std::string {
const int CLAMPED = std::clamp(v, 0, 99);
std::string s = std::to_string(CLAMPED);
return std::string(2 - s.size(), '0') + s;
}
// Vibració suau per al shake: dues sinusoides desfasades amb
// freqüències lleugerament diferents per a evitar el patró regular
// que faria una sola sinusoide. La fase ve dels ms restants — així
// queda determinista per frame, sense necessitat d'un PRNG.
auto shakeOffset(float intensity, int phase_ms) -> SDL_FPoint {
const float ANG = static_cast<float>(phase_ms) * 0.07F;
return SDL_FPoint{
.x = std::cos(ANG) * intensity,
.y = std::sin(ANG * 1.31F) * intensity,
};
}
} // namespace
Hud::Hud(EventBus& bus, std::shared_ptr<Text> font)
: bus_(bus),
font_(std::move(font)) {
// Geometria fixa dels tres blocs. Sumen exactament GameCanvas::WIDTH.
// La durada de l'animació d'entrada ve de data/ui.yaml via UiConfig.
const int ENTRY_MS = UiConfig::get().hudEntryMs();
blocks_[0] = Block{
.x = 0,
.y = 0,
.w = LEFT_W,
.h = HEIGHT_PX,
.entry_ms_left = ENTRY_MS,
.entry_offset_x = -LEFT_W,
.entry_offset_y = 0,
};
blocks_[1] = Block{
.x = LEFT_W,
.y = 0,
.w = CENTER_W,
.h = HEIGHT_PX,
.entry_ms_left = ENTRY_MS,
.entry_offset_x = 0,
.entry_offset_y = -HEIGHT_PX,
};
blocks_[2] = Block{
.x = LEFT_W + CENTER_W,
.y = 0,
.w = RIGHT_W,
.h = HEIGHT_PX,
.entry_ms_left = ENTRY_MS,
.entry_offset_x = RIGHT_W,
.entry_offset_y = 0,
};
// Per defecte, P1 actiu (sempre n'hi ha al menys un); P2 inactiu fins
// que un segon Player s'uneix mitjançant `activatePlayer(2)`.
players_[0].active = true;
players_[1].active = false;
// Subscripcions al bus de l'EntityManager. De moment només `player_hit`
// afecta el HUD visualment: decrementa vides, dispara flash i shake al
// bloc del jugador. Quan els events evolucionin per portar `player_id`
// i puntuacions, ampliarem aquí sense tocar callers.
subs_.push_back(bus_.subscribe("player_hit", [this](const Event& ev) {
const auto* dmg = std::get_if<int>(&ev.payload);
// Dispatch pel `player_id` de l'event. 0 = P1 (bloc LEFT), 1 = P2
// (bloc RIGHT). Si l'event arriba sense id (-1, event world-wide)
// fem fallback a P1 per no perdre'l — a la pràctica tots els
// publishers de `player_hit` actuals sí que porten id.
const std::size_t IDX = (ev.player_id == 1) ? 1U : 0U;
if (!players_[IDX].active) { return; }
if (players_[IDX].lives > 0) { --players_[IDX].lives; }
const std::size_t BLK_IDX = (IDX == 0) ? static_cast<std::size_t>(BlockId::LEFT)
: static_cast<std::size_t>(BlockId::RIGHT);
auto& blk = blocks_[BLK_IDX];
blk.flash_ms_left = UiConfig::get().hudFlashMs();
blk.shake_ms_left = UiConfig::get().hudShakeMs();
blk.shake_intensity = 1.5F;
std::cout << "[HUD] player_hit p" << (IDX + 1) << " dmg=" << (dmg != nullptr ? *dmg : 0)
<< " lives=" << players_[IDX].lives << '\n';
}));
subs_.push_back(bus_.subscribe("level_complete", [](const Event& /*ev*/) {
// TODO: capturar `this` i cridar setMode(*, Mode::Win) per als
// tres blocs quan el mode Win estiga implementat.
std::cout << "[HUD] level_complete (mode Win pendent)\n";
}));
std::cout << "[HUD] init: 3 blocks ready (entry " << ENTRY_MS << " ms)\n";
}
Hud::~Hud() {
for (auto id : subs_) { bus_.unsubscribe(id); }
}
void Hud::update(int delta_ms) {
for (auto& b : blocks_) {
if (b.entry_ms_left > 0) { b.entry_ms_left = std::max(0, b.entry_ms_left - delta_ms); }
if (b.shake_ms_left > 0) { b.shake_ms_left = std::max(0, b.shake_ms_left - delta_ms); }
if (b.flash_ms_left > 0) { b.flash_ms_left = std::max(0, b.flash_ms_left - delta_ms); }
}
}
void Hud::setMode(BlockId block, Mode mode) {
blocks_[static_cast<std::size_t>(block)].mode = mode;
}
void Hud::setPyramidRoom(int pyramid, int room) {
pyramid_ = pyramid;
room_ = room;
}
void Hud::activatePlayer(int player_one_based) {
if (player_one_based < 1 || player_one_based > 2) { return; }
auto& p = players_[static_cast<std::size_t>(player_one_based - 1)];
p.active = true;
p.lives = DEFAULT_LIVES;
p.score = 0;
p.treasures = 0;
}
auto Hud::computeOffset(const Block& b) -> SDL_FPoint {
SDL_FPoint off{.x = 0.0F, .y = 0.0F};
// Animació d'entrada: t va de 0 (recent iniciat) a 1 (acabada). Amb
// outCubic, el bloc arrenca ràpid i s'atura suaument al seu lloc.
if (b.entry_ms_left > 0) {
const float T = 1.0F - (static_cast<float>(b.entry_ms_left) / static_cast<float>(UiConfig::get().hudEntryMs()));
const float E = Easing::outCubic(T);
const float REMAIN = 1.0F - E; // 1 al principi → 0 al final
off.x += static_cast<float>(b.entry_offset_x) * REMAIN;
off.y += static_cast<float>(b.entry_offset_y) * REMAIN;
}
// Shake: amplitud decau linealment amb el temps restant.
if (b.shake_ms_left > 0) {
const float DECAY = static_cast<float>(b.shake_ms_left) / static_cast<float>(UiConfig::get().hudShakeMs());
const auto JITTER = shakeOffset(b.shake_intensity * DECAY, b.shake_ms_left);
off.x += JITTER.x;
off.y += JITTER.y;
}
return off;
}
void Hud::render() const {
// Backdrop de la franja del HUD. Es pinta abans dels tres blocs perquè,
// quan un bloc es mou (entry slide o shake), la zona que deixa al
// descobert segueix mostrant el mateix color — en lloc del tilemap de
// sota, que causaria un trencament visual.
if (UiConfig::get().hudBgEnabled()) {
const auto& pal = Resource::Cache::get().getPalette("ui");
Screen::get().fillRect(0, 0, LEFT_W + CENTER_W + RIGHT_W, HEIGHT_PX, pal[UiConfig::get().hudBg()]);
}
renderBlock(BlockId::LEFT);
renderBlock(BlockId::CENTER);
renderBlock(BlockId::RIGHT);
}
void Hud::renderBlock(BlockId id) const {
const auto IDX = static_cast<std::size_t>(id);
const auto& b = blocks_[IDX];
const auto OFF = computeOffset(b);
const int X = b.x + static_cast<int>(std::round(OFF.x));
const int Y = b.y + static_cast<int>(std::round(OFF.y));
switch (b.mode) {
case Mode::IDLE:
if (id == BlockId::LEFT) {
renderIdleSide(X, Y, b.w, b, 0, Locale::get().text("hud.p1"));
} else if (id == BlockId::RIGHT) {
renderIdleSide(X, Y, b.w, b, 1, Locale::get().text("hud.p2"));
} else {
renderIdleCenter(X, Y, b.w);
}
break;
case Mode::DYING: // TODO: animació de "vida perduda".
case Mode::COUNTDOWN: // TODO: 3-2-1 abans de respawn.
case Mode::WIN: // TODO: celebració al `level_complete`.
case Mode::EMPTY: // Deixa el bloc amb només el fons.
break;
}
}
void Hud::renderIdleSide(int x, int y, int w, const Block& blk, int player_index, const std::string& label) const {
const auto& p = players_[static_cast<std::size_t>(player_index)];
const Uint8 SCORE_C = (blk.flash_ms_left > 0) ? UiConfig::get().hudFlash() : UiConfig::get().hudValue();
// --- Línia 1: "<label>-000000" centrada ---
const std::string LBL1 = label + SEPARATOR;
const std::string SCORE = formatScore(p.score);
const int LBL_W = font_->length(LBL1);
const int SC_W = font_->lengthMonoDigits(SCORE, MONO_DIGIT_W);
const int LINE1_W = LBL_W + SC_W;
int xx = x + ((w - LINE1_W) / 2);
font_->writeColored(xx, y + LINE1_Y, LBL1, UiConfig::get().hudLabel());
xx += LBL_W;
font_->writeMonoDigits(xx, y + LINE1_Y, SCORE, SCORE_C, MONO_DIGIT_W);
// --- Línia 2: "<vides>-<n> <tresors>-<n>" centrada ---
const std::string V_LBL = Locale::get().text("hud.lives") + SEPARATOR;
const std::string T_LBL = Locale::get().text("hud.treasures") + SEPARATOR;
const std::string V_VAL = std::to_string(std::clamp(p.lives, 0, 9));
const std::string T_VAL = formatTwoDigits(p.treasures);
const int V_LW = font_->length(V_LBL);
const int V_VW = font_->lengthMonoDigits(V_VAL, MONO_DIGIT_W);
const int T_LW = font_->length(T_LBL);
const int T_VW = font_->lengthMonoDigits(T_VAL, MONO_DIGIT_W);
const int LINE2_W = V_LW + V_VW + FIELD_GAP + T_LW + T_VW;
xx = x + ((w - LINE2_W) / 2);
font_->writeColored(xx, y + LINE2_Y, V_LBL, UiConfig::get().hudLabel());
xx += V_LW;
font_->writeMonoDigits(xx, y + LINE2_Y, V_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W);
xx += V_VW + FIELD_GAP;
font_->writeColored(xx, y + LINE2_Y, T_LBL, UiConfig::get().hudLabel());
xx += T_LW;
font_->writeMonoDigits(xx, y + LINE2_Y, T_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W);
}
void Hud::renderIdleCenter(int x, int y, int w) const {
// --- Línia 1: "<piramide>-<n>" centrada ---
const std::string PIR_LBL = Locale::get().text("hud.pyramid") + SEPARATOR;
const std::string PIR_VAL = std::to_string(pyramid_);
const int PL_W = font_->length(PIR_LBL);
const int PV_W = font_->lengthMonoDigits(PIR_VAL, MONO_DIGIT_W);
const int LINE1_W = PL_W + PV_W;
int xx = x + ((w - LINE1_W) / 2);
font_->writeColored(xx, y + LINE1_Y, PIR_LBL, UiConfig::get().hudLabel());
xx += PL_W;
font_->writeMonoDigits(xx, y + LINE1_Y, PIR_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W);
// --- Línia 2: "<sala>-<r>" centrada ---
const std::string SALA_LBL = Locale::get().text("hud.room") + SEPARATOR;
const std::string SALA_VAL = std::to_string(room_);
const int SL_W = font_->length(SALA_LBL);
const int SV_W = font_->lengthMonoDigits(SALA_VAL, MONO_DIGIT_W);
const int LINE2_W = SL_W + SV_W;
xx = x + ((w - LINE2_W) / 2);
font_->writeColored(xx, y + LINE2_Y, SALA_LBL, UiConfig::get().hudLabel());
xx += SL_W;
font_->writeMonoDigits(xx, y + LINE2_Y, SALA_VAL, UiConfig::get().hudValue(), MONO_DIGIT_W);
}
+155
View File
@@ -0,0 +1,155 @@
#pragma once
#include <SDL3/SDL.h>
#include <array>
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
#include "game/entities/event_bus.hpp"
class Text;
// Marcador (HUD) de la GameScene. Ocupa la franja superior de 24 px del
// canvas i es divideix en tres "blocs" simètrics (P1 · COMÚ · P2) que
// funcionen com a panells independents — estil panell de pinball: cada un
// té el seu propi mode visible, animació d'entrada, shake i flash.
//
// Vida: instanciat per `GameScene` via `unique_ptr` (mateix patró que
// `PaintLayer`). Cada partida nova crea un HUD net; el destructor
// desubscriu del bus automàticament. **No és singleton** — és estat de
// partida, no configuració.
//
// Comunicació: subscrit al `EventBus` de l'`EntityManager`. Reacciona a
// events del joc (`player_hit`, `level_complete`, ...) sense que les
// entitats hagin de saber que existeix.
class Hud {
public:
// Identificador semàntic de bloc (també índex dins `blocks_`).
enum class BlockId : std::uint8_t { LEFT = 0,
CENTER = 1,
RIGHT = 2 };
// Mode actual d'un bloc. Tret d'`IDLE`, els altres modes són
// *placeholders* per a la primera versió (el render fa res); la API
// ja està fixada perquè afegir-los més endavant no toqui callers.
enum class Mode : std::uint8_t { IDLE,
DYING,
COUNTDOWN,
WIN,
EMPTY };
Hud(EventBus& bus, std::shared_ptr<Text> font);
~Hud();
Hud(const Hud&) = delete;
Hud(Hud&&) = delete;
auto operator=(const Hud&) -> Hud& = delete;
auto operator=(Hud&&) -> Hud& = delete;
// Cada frame: avança les animacions actives (entrada, shake, flash).
void update(int delta_ms);
// Pinta els tres blocs sobre el canvas en la seva posició actual.
// El caller decideix l'ordre respecte a la resta de capes (en general
// cal cridar-lo a sobre del tilemap perquè quedi visible).
void render() const;
// Substitueix el mode d'un bloc (commutar el que es veu sense canviar
// la geometria — útil per a Dying/Countdown/Win quan estiguin fets).
void setMode(BlockId block, Mode mode);
// Actualitza l'identificador de piràmide i sala (apareix al bloc
// central). El caller en farà ús quan canvi de pantalla amb scroll o
// entre sales.
void setPyramidRoom(int pyramid, int room);
// Activa el bloc d'un jugador (1-based: 1 → P1, 2 → P2). Mentre està
// inactiu, el bloc mostra zeros i no rep flash/shake; quan s'activa,
// arrenca amb les vides per defecte.
void activatePlayer(int player_one_based);
// Geometria pública perquè la GameScene puga calcular si el HUD
// s'està movent encara (p. ex. retardar l'spawn fins que estiga al
// seu lloc) — opcional, no obligatori.
static constexpr int HEIGHT_PX = 24;
private:
// Amplades dels tres blocs. Sumen exactament `GameCanvas::WIDTH` (320).
// Centrats: el bloc del mig agafa els píxels sobrants (108) i els
// laterals queden idèntics (106 cadascun) per garantir simetria.
static constexpr int LEFT_W = 106;
static constexpr int CENTER_W = 108;
static constexpr int RIGHT_W = 106;
// Files de text dins del bloc (y relatiu al bloc), per a una font de
// 8 px d'alçada amb un petit padding visual a dalt i a baix.
static constexpr int LINE1_Y = 4;
static constexpr int LINE2_Y = 13;
// Vides inicials per jugador (placeholder fins que GameScene ho
// configure des del Config del nivell).
static constexpr int DEFAULT_LIVES = 3;
struct Block {
Mode mode{Mode::IDLE};
// Coordenades de pantalla on viu el bloc (constants durant la
// partida; només varien els offsets transitoris d'entrada/shake).
int x{0};
int y{0};
int w{0};
int h{0};
// Animació d'entrada: ms restants fins a quedar-se a la posició
// final. Quan val 0 el bloc està quiet. El Hud ctor l'inicialitza
// amb la durada viva a data/ui.yaml (hud.entry_ms).
int entry_ms_left{0};
// Vector inicial des d'on entra: {-LEFT_W,0} per al bloc esquerre,
// {0,-HEIGHT_PX} per al central i {+RIGHT_W,0} per al dret.
int entry_offset_x{0};
int entry_offset_y{0};
// Shake actiu: amplitud i ms restants. Decau linealment.
int shake_ms_left{0};
float shake_intensity{0.0F};
// Flash actiu: mentre val >0, el text del valor es pinta amb el
// color de flash en compte del de valor.
int flash_ms_left{0};
};
struct PlayerStats {
int lives{DEFAULT_LIVES};
int score{0};
int treasures{0};
// `false` → el bloc mostra `000000`/`0`/`00` (jugador inactiu).
// Sempre es renderitza per mantenir el layout fix.
bool active{false};
};
// Pinta el bloc indicat amb el seu fons + el contingut del seu mode
// actual. Aplica l'offset (entrada + shake) abans de dibuixar.
void renderBlock(BlockId id) const;
// Subrutines del mode `Idle` per als tres blocs. Reben el rectangle
// ja desplaçat (x, y) on han d'escriure.
void renderIdleSide(int x, int y, int w, const Block& blk, int player_index, const std::string& label) const;
void renderIdleCenter(int x, int y, int w) const;
// Calcula l'offset transitori (entrada + shake) en píxels per a un
// bloc. Static perquè depèn només dels camps del propi `Block` i de
// les constants de classe (no toca cap altre membre).
[[nodiscard]] static auto computeOffset(const Block& b) -> SDL_FPoint;
EventBus& bus_;
std::vector<EventBus::SubId> subs_;
std::shared_ptr<Text> font_;
std::array<Block, 3> blocks_;
std::array<PlayerStats, 2> players_;
int pyramid_{1};
int room_{1};
};
+160 -29
View File
@@ -7,6 +7,7 @@
#include <utility>
#include "core/audio/audio.hpp"
#include "core/input/gamepad_manager.hpp"
#include "core/input/input_state.hpp"
#include "core/input/key_config.hpp"
#include "core/locale/locale.hpp"
@@ -14,9 +15,9 @@
#include "core/rendering/screen.hpp"
#include "core/rendering/text.hpp"
#include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp"
#include "game/options.hpp"
#include "game/options_apply.hpp"
#include "game/ui/ui_palette.hpp"
#include "utils/defaults.hpp"
#include "utils/defines.hpp"
#include "version.h"
@@ -34,6 +35,10 @@ namespace {
constexpr int PAD_X = 10;
constexpr int GAP_Y = 6;
constexpr int ITEM_GAP_Y = 2;
// Mínim buit entre la label (a l'esquerra) i el value (a la dreta).
// Si amb el value sencer no queda aquest gap, truncam amb "..." des
// del final del value.
constexpr int MIN_LABEL_VALUE_GAP = 6;
// Les fonts display reserven ~2 px de descendent al fons de la seua
// caixa (per a `g`, `p`, `q`...). Quan el títol no porta cap d'aquests
// glifs, eixos 2 px queden buits i el gap visual sota el títol sembla
@@ -731,15 +736,14 @@ auto ServiceMenu::buildKeyboardControls() -> Page {
Page page;
page.title_key = "menu.titles.keyboard";
// Etiqueta divisòria per al jugador 1. Quan s'afegeixi el jugador 2,
// només s'ha d'apilar un segon bloc "Jugador 2" amb les seves tecles
// per sota, sense tocar cap altra cosa.
// Etiqueta divisòria per al jugador 1.
page.items.push_back(makeLabel(Locale::get().text("menu.labels.player_1")));
// Vinculacions actuals: les llegim directament de `Defaults::KeysGame`.
// Encara no hi ha setter, per tant els items surten com a informatius
// (no seleccionables). La lògica de captura i persistència arribarà en
// una segona iteració amb `Options::keysGameP1()`.
// Vinculacions actuals: les llegim directament de `Defaults::KeysGame`
// (P1) i `Defaults::KeysGameP2` (P2). Encara no hi ha setter, per
// tant els items surten com a informatius (no seleccionables). La
// lògica de captura i persistència arribarà en una segona iteració
// amb `Options::keysGameP1()` / `keysGameP2()`.
page.items.push_back(makeKeyBind("menu.items.move_up",
[]() { return Defaults::KeysGame::UP; }));
page.items.push_back(makeKeyBind("menu.items.move_down",
@@ -749,17 +753,149 @@ auto ServiceMenu::buildKeyboardControls() -> Page {
page.items.push_back(makeKeyBind("menu.items.move_right",
[]() { return Defaults::KeysGame::RIGHT; }));
// Bloc del jugador 2: mateixa forma, llegint de KeysGameP2.
page.items.push_back(makeLabel(Locale::get().text("menu.labels.player_2")));
page.items.push_back(makeKeyBind("menu.items.move_up",
[]() { return Defaults::KeysGameP2::UP; }));
page.items.push_back(makeKeyBind("menu.items.move_down",
[]() { return Defaults::KeysGameP2::DOWN; }));
page.items.push_back(makeKeyBind("menu.items.move_left",
[]() { return Defaults::KeysGameP2::LEFT; }));
page.items.push_back(makeKeyBind("menu.items.move_right",
[]() { return Defaults::KeysGameP2::RIGHT; }));
return page;
}
namespace {
// Trunca `text` amb "..." perquè la seua amplada renderitzada no
// supere `max_w` píxels amb la font i el kerning donats. Port del
// helper de coffee_crisis_arcade_edition: estimació inicial a partir
// de l'amplada mitjana per caràcter i ajust iteratiu mesurant amb
// `Text::length`, per a fonts d'amplada variable on l'estimació
// només és aproximada.
auto truncateToFit(const Text& font, const std::string& text, int max_w, int kerning) -> std::string {
if (font.length(text, kerning) <= max_w) { return text; }
static constexpr const char* ELLIPSIS = "...";
const int ELLIPSIS_W = font.length(ELLIPSIS, kerning);
if (max_w <= ELLIPSIS_W || text.empty()) { return ELLIPSIS; }
const auto TOTAL_W = static_cast<float>(font.length(text, kerning));
const auto AVG_CHAR_W = TOTAL_W / static_cast<float>(text.size());
auto keep = static_cast<std::size_t>(static_cast<float>(max_w - ELLIPSIS_W) / AVG_CHAR_W);
keep = std::min(keep, text.size());
std::string out = text.substr(0, keep) + ELLIPSIS;
while (keep > 0 && font.length(out, kerning) > max_w) {
--keep;
out = text.substr(0, keep) + ELLIPSIS;
}
return out;
}
// Dibuixa el value d'un item a la dreta del box, truncant-lo amb
// "..." si no cap a l'esquerra de la label. Extret de `renderPage`
// per a mantenir la complexitat cognitiva sota el llindar de
// clang-tidy.
void drawItemValue(Text& font, int box_x, int box_w, int cursor_y, int cursor_width, const std::string& label, const std::string& value, int kerning, Uint8 color) {
const int LABEL_W = font.length(label, kerning);
const int AVAILABLE_W = box_w - (2 * PAD_X) - cursor_width - LABEL_W - MIN_LABEL_VALUE_GAP;
if (AVAILABLE_W <= 0) { return; }
const std::string DISPLAY = truncateToFit(font, value, AVAILABLE_W, kerning);
if (DISPLAY.empty()) { return; }
const int VALUE_W = font.length(DISPLAY, kerning);
font.writeDX(Text::COLOR_FLAG,
box_x + box_w - PAD_X - VALUE_W,
cursor_y,
DISPLAY,
kerning,
color);
}
// Mandos visibles al cycle d'un jugador: tots els connectats
// EXCEPTE els reclamats per l'altre jugador. Així l'usuari mai pot
// assignar el mateix mando a dos jugadors des de la UI — per
// reassignar-lo ha d'alliberar-lo primer posant-lo a "Cap" a l'altre
// slot. L'ordre de la llista manté el de `getAll()`.
auto visiblePads(int player_id) -> std::vector<GamepadManager::Info> {
const auto ALL = GamepadManager::get().getAll();
const auto& other = (player_id == 0) ? Options::controls().gamepad_p2
: Options::controls().gamepad_p1;
std::vector<GamepadManager::Info> out;
out.reserve(ALL.size());
for (const auto& p : ALL) {
const bool OWNED_BY_OTHER =
(!other.path.empty() && p.path == other.path) ||
(other.path.empty() && !other.name.empty() && p.name == other.name);
if (!OWNED_BY_OTHER) { out.push_back(p); }
}
return out;
}
// Índex del cycle dins de la llista `visiblePads(player_id)`.
// 0 = "Cap" (desassignat o sense coincidència amb cap mando visible);
// 1..N = mando `idx-1`. Primer match per ruta, fallback per nom.
auto currentPadIndex(int player_id) -> std::size_t {
const auto PADS = visiblePads(player_id);
const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1
: Options::controls().gamepad_p2;
if (slot.name.empty() && slot.path.empty()) { return 0; }
for (std::size_t i = 0; i < PADS.size(); ++i) {
if (!slot.path.empty() && PADS[i].path == slot.path) { return i + 1; }
}
for (std::size_t i = 0; i < PADS.size(); ++i) {
if (!slot.name.empty() && PADS[i].name == slot.name) { return i + 1; }
}
return 0;
}
auto padLabel(int player_id, std::size_t idx) -> std::string {
if (idx == 0) { return Locale::get().text("menu.values.gamepad_none"); }
const auto PADS = visiblePads(player_id);
const std::size_t POS = idx - 1;
if (POS < PADS.size()) { return PADS[POS].name; }
// Fora de rang: l'assignació guardada no és visible ara (p. ex.
// el mando s'acaba de desconnectar). Mostrem el nom persistit
// amb un sufix per no deixar l'usuari sense feedback.
const auto& slot = (player_id == 0) ? Options::controls().gamepad_p1
: Options::controls().gamepad_p2;
return slot.name + " (" + Locale::get().text("menu.values.gamepad_disconnected") + ')';
}
void applyPadSelection(int player_id, std::size_t idx) {
if (idx == 0) {
Options::setGamepadAssignment(player_id, "", "");
return;
}
const auto PADS = visiblePads(player_id);
const std::size_t POS = idx - 1;
if (POS >= PADS.size()) { return; }
Options::setGamepadAssignment(player_id, PADS[POS].name, PADS[POS].path);
}
} // namespace
auto ServiceMenu::buildGamepadControls() -> Page {
Page page;
page.title_key = "menu.titles.gamepad";
page.center_items = true;
// Placeholder: un sol LABEL que indica que la pàgina encara no té
// contingut real. Quan s'afegeixi el suport de gamepad, aquesta
// pàgina es substituirà pel llistat real de botons mapejables.
page.items.push_back(makeLabel(Locale::get().text("menu.labels.gamepad_unavailable")));
// Un cycle per jugador. L'opció 0 és sempre "Cap"; la resta es
// generen dinàmicament a partir de `GamepadManager::getAll()` cada
// frame, així que un mando acabat de connectar ja apareix sense
// haver de tornar a obrir el submenú.
page.items.push_back(makeCycle(
"menu.items.gamepad_p1",
[]() { return currentPadIndex(0); },
[]() { return visiblePads(0).size() + 1; },
[](std::size_t idx) { applyPadSelection(0, idx); },
[](std::size_t idx) { return padLabel(0, idx); }));
page.items.push_back(makeCycle(
"menu.items.gamepad_p2",
[]() { return currentPadIndex(1); },
[]() { return visiblePads(1).size() + 1; },
[](std::size_t idx) { applyPadSelection(1, idx); },
[](std::size_t idx) { return padLabel(1, idx); }));
return page;
}
@@ -844,8 +980,8 @@ auto ServiceMenu::targetBoxHeight(const Page& page) const -> int {
}
void ServiceMenu::renderBox(int box_x, int box_y, int box_w, int box_h, float eased) const {
const std::uint32_t BG_ARGB = ui_palette_[UiColor::MENU_BG];
const std::uint32_t BORDER_ARGB = ui_palette_[UiColor::MENU_BORDER];
const std::uint32_t BG_ARGB = ui_palette_[UiConfig::get().menuBg()];
const std::uint32_t BORDER_ARGB = ui_palette_[UiConfig::get().menuBorder()];
const auto ALPHA = static_cast<std::uint8_t>(std::lround(BG_ALPHA * eased));
// Fons semi-transparent (el joc queda visible apagat per darrere).
@@ -877,7 +1013,7 @@ void ServiceMenu::renderPage(const Page& page, int box_x, int box_y, int box_w)
cursor_y,
Locale::get().text(page.title_key),
TITLE_KERNING,
UiColor::MENU_TITLE);
UiConfig::get().menuTitle());
cursor_y += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM;
if (!page.subtitle.empty()) {
@@ -886,11 +1022,11 @@ void ServiceMenu::renderPage(const Page& page, int box_x, int box_y, int box_w)
cursor_y,
page.subtitle,
TITLE_KERNING,
UiColor::MENU_TITLE);
UiConfig::get().menuTitle());
cursor_y += TITLE_H + GAP_Y - TITLE_DESCENDER_TRIM;
}
Screen::get().fillRect(box_x + PAD_X, cursor_y, box_w - (2 * PAD_X), 1, ui_palette_[UiColor::MENU_BORDER]);
Screen::get().fillRect(box_x + PAD_X, cursor_y, box_w - (2 * PAD_X), 1, ui_palette_[UiConfig::get().menuBorder()]);
cursor_y += 1 + GAP_Y;
bool first_item = true;
@@ -907,14 +1043,14 @@ void ServiceMenu::renderPage(const Page& page, int box_x, int box_y, int box_w)
: Locale::get().text(item.label_key);
const std::string VALUE = formatValue(item);
Uint8 label_color = UiColor::MENU_LABEL;
Uint8 label_color = UiConfig::get().menuLabel();
if (item.kind == Item::Kind::LABEL) {
// Mateix to que el títol de la pàgina: les LABELs actuen com
// a subtítol-divisor (p.ex. "Jugador 1" a Teclat, "ver.X.Y" a
// Sistema) i han de tenir la mateixa presència visual.
label_color = UiColor::MENU_TITLE;
label_color = UiConfig::get().menuTitle();
} else if (SELECTED) {
label_color = UiColor::MENU_CURSOR;
label_color = UiConfig::get().menuCursor();
}
int label_x = LABEL_X;
@@ -931,7 +1067,7 @@ void ServiceMenu::renderPage(const Page& page, int box_x, int box_y, int box_w)
cursor_y,
"> ",
ITEM_KERNING,
UiColor::MENU_CURSOR);
UiConfig::get().menuCursor());
}
items_font_->writeDX(Text::COLOR_FLAG,
@@ -945,13 +1081,8 @@ void ServiceMenu::renderPage(const Page& page, int box_x, int box_y, int box_w)
// només tenen ACTION / LABEL); els Toggle/Cycle viuen en pàgines
// d'opcions amb layout esquerra-dreta.
if (!page.center_items && !VALUE.empty()) {
const int VALUE_W = items_font_->length(VALUE, ITEM_KERNING);
items_font_->writeDX(Text::COLOR_FLAG,
box_x + box_w - PAD_X - VALUE_W,
cursor_y,
VALUE,
ITEM_KERNING,
SELECTED ? UiColor::MENU_CURSOR : UiColor::MENU_VALUE);
const Uint8 VALUE_COLOR = SELECTED ? UiConfig::get().menuCursor() : UiConfig::get().menuValue();
drawItemValue(*items_font_, box_x, box_w, cursor_y, CURSOR_WIDTH, LABEL_TEXT, VALUE, ITEM_KERNING, VALUE_COLOR);
}
cursor_y += ITEM_H;
-22
View File
@@ -1,22 +0,0 @@
#pragma once
#include "utils/defaults.hpp"
// Alias semàntics als índexs de paleta UI (viuen a `Defaults::UI`). Canviar
// l'aparença d'un component només requereix tocar-los allà; ací només
// reexportem amb els noms que consumeix el codi del joc.
namespace UiColor {
constexpr auto RENDER_INFO_TEXT = Defaults::UI::RENDER_INFO_TEXT;
constexpr auto RENDER_INFO_SHADOW = Defaults::UI::RENDER_INFO_SHADOW;
constexpr auto CANVAS_BG = Defaults::UI::CANVAS_BG;
constexpr auto MENU_BG = Defaults::UI::MENU_BG;
constexpr auto MENU_BORDER = Defaults::UI::MENU_BORDER;
constexpr auto MENU_TITLE = Defaults::UI::MENU_TITLE;
constexpr auto MENU_LABEL = Defaults::UI::MENU_LABEL;
constexpr auto MENU_VALUE = Defaults::UI::MENU_VALUE;
constexpr auto MENU_CURSOR = Defaults::UI::MENU_CURSOR;
constexpr auto MENU_DISABLED = Defaults::UI::MENU_DISABLED;
} // namespace UiColor
+97 -21
View File
@@ -77,18 +77,36 @@ namespace Defaults {
constexpr SDL_Scancode DOWN = SDL_SCANCODE_DOWN;
constexpr SDL_Scancode LEFT = SDL_SCANCODE_LEFT;
constexpr SDL_Scancode RIGHT = SDL_SCANCODE_RIGHT;
constexpr SDL_Scancode JUMP = SDL_SCANCODE_M;
constexpr SDL_Scancode EXIT = SDL_SCANCODE_ESCAPE;
} // namespace KeysGame
// Tecles del segon jugador local (WASD). Simètriques a KeysGame però
// mapejades a la mà esquerra del teclat. Cada Player instancia amb els
// seus propis Controls, així que aquests són un recull de fàbrica:
// canviar-los ací canvia el mapatge per a qualsevol escena que els use.
namespace KeysGameP2 {
constexpr SDL_Scancode UP = SDL_SCANCODE_W;
constexpr SDL_Scancode DOWN = SDL_SCANCODE_S;
constexpr SDL_Scancode LEFT = SDL_SCANCODE_A;
constexpr SDL_Scancode RIGHT = SDL_SCANCODE_D;
constexpr SDL_Scancode JUMP = SDL_SCANCODE_V;
} // namespace KeysGameP2
namespace Player {
constexpr float SPEED_PX_PER_S = 60.0F; // Velocitat base del jugador (sense sprint / slow-zones).
// Factor multiplicatiu aplicat a la velocitat quan un jugador empenta
// l'altre (0.30 = el pusher es ralentitza al 30% i el pushed és
// arrossegat a la mateixa velocitat). Sobrescrivible des del YAML
// del mapa amb la clau `push_factor`.
constexpr float PUSH_FACTOR = 0.30F;
} // namespace Player
// Índexs de paleta (sega-cube.pal) i temps en ms per als components
// de UI. Centralitzats ací perquè un "tema" viu en un únic lloc: tocar
// els colors o durades de l'overlay/notificador no requereix buscar
// pel codi. Si algun dia es volen temes múltiples, es pot moure a YAML
// sense més impacte que dos accessors extra.
// Fallbacks del tema d'UI: índexs de paleta, timings (ms) i fonts.
// El tema en viu és a data/ui.yaml, carregat per UiConfig a
// l'arrencada; aquests valors només s'apliquen si el fitxer falta o
// li manca alguna clau. Ací només cal tocar-los si es vol canviar el
// fallback "de fàbrica".
namespace UI {
// Fonts d'UI centralitzades: un únic lloc per conèixer la carpeta
// (data/fonts/), el nom-clau al Resource::Cache i els fitxers .gif /
@@ -128,41 +146,99 @@ namespace Defaults {
} // namespace Font
// Overlay de RenderInfo (F12).
constexpr Uint8 RENDER_INFO_TEXT = 47;
constexpr Uint8 RENDER_INFO_SHADOW = 34;
constexpr Uint8 RENDER_INFO_TEXT = 58;
constexpr Uint8 RENDER_INFO_SHADOW = 16;
// Fons del canvas (zona fora del tilemap en letterbox/integer).
constexpr Uint8 CANVAS_BG = 65;
constexpr Uint8 CANVAS_BG = 72;
// Preset per defecte del Notifier.
constexpr Uint8 NOTIFICATION_TEXT = 197;
constexpr Uint8 NOTIFICATION_BG = 96;
constexpr Uint8 NOTIFICATION_TEXT = 232;
constexpr Uint8 NOTIFICATION_BG = 64;
constexpr int NOTIFICATION_TIME_MS = 4000;
// 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 confirmació expiren alhora (veure GlobalInputs::onExit).
constexpr Uint8 EXIT_TEXT = 88;
constexpr Uint8 EXIT_BG = 40;
constexpr Uint8 EXIT_TEXT = 71;
constexpr Uint8 EXIT_BG = 2;
constexpr int EXIT_TIME_MS = 4000;
// Índexs de paleta per al menú de servei (F9).
constexpr Uint8 MENU_BG = 40; // Fons de la caixa.
constexpr Uint8 MENU_BORDER = 197; // Vora.
constexpr Uint8 MENU_TITLE = 213; // Títol i subtítol.
constexpr Uint8 MENU_LABEL = 47; // Etiquetes dels items.
constexpr Uint8 MENU_VALUE = 197; // Valor actual.
constexpr Uint8 MENU_CURSOR = 88; // Item seleccionat.
constexpr Uint8 MENU_DISABLED = 34; // Label en gris quan no aplica.
constexpr Uint8 MENU_BG = 2; // Fons de la caixa.
constexpr Uint8 MENU_BORDER = 232; // Vora.
constexpr Uint8 MENU_TITLE = 237; // Títol i subtítol.
constexpr Uint8 MENU_LABEL = 58; // Etiquetes dels items.
constexpr Uint8 MENU_VALUE = 232; // Valor actual.
constexpr Uint8 MENU_CURSOR = 71; // Item seleccionat.
constexpr Uint8 MENU_DISABLED = 16; // Label en gris quan no aplica.
// Índexs de paleta per al marcador de joc (HUD de la GameScene).
// Arranca reciclant els colors del menú per coherència visual;
// separant els noms a constants pròpies, tocar-los a posteriori
// no afecta el menú.
constexpr Uint8 HUD_BG = 0; // Fons de cada bloc.
constexpr Uint8 HUD_LABEL = 58; // Etiquetes ("P1", "V", "T").
constexpr Uint8 HUD_VALUE = 187; // Valors (score, vides, tresors).
constexpr Uint8 HUD_FLASH = 237; // Color temporal del valor en flash.
// Timings d'animació del HUD. L'entrada són els tres blocs lliscant
// a la seua posició; flash i shake acompanyen un `player_hit`.
constexpr int HUD_ENTRY_MS = 400;
constexpr int HUD_FLASH_MS = 250;
constexpr int HUD_SHAKE_MS = 300;
// Pausa graduada de la GameScene: el món no para d'un colp, es
// frena amb una corba i torna a arrancar amb una altra. Els noms
// de corba han de coincidir amb els registrats a
// UiConfig::curveByName (linear / outQuad / inQuad / inOutQuad /
// outCubic / inCubic). Si un nom no es reconeix, es cau a
// Easing::linear.
constexpr int PAUSE_ENTER_MS = 400;
constexpr int PAUSE_EXIT_MS = 250;
constexpr const char* PAUSE_ENTER_CURVE = "outCubic";
constexpr const char* PAUSE_EXIT_CURVE = "inCubic";
} // namespace UI
#ifdef _DEBUG
// Constants de l'editor de tilemap. Només presents en Debug — en
// Release l'editor no existeix, així que ni les constants tenen sentit.
namespace Editor {
// Color del rectangle 1 px que envolta el canvas durant l'edició.
constexpr std::uint32_t BORDER_ARGB = 0xFF2244FFU;
// Ruta de persistència: writer surgical sobre la font del repo.
constexpr const char* SAVE_PATH = "data/maps/pyramid.yaml";
// Píxels entre el canvas i el rectangle del TilePicker per totes
// les vores. Prou perquè el border blau de l'editor no es
// superpose amb la graella del picker.
constexpr int PICKER_MARGIN = 8;
// Fons del TilePicker (blau fosc, prou contrastat amb els tiles
// de color clar i clarament diferent del canvas).
constexpr std::uint32_t PICKER_BG_ARGB = 0xFF101824U;
// Vora 1 px del tile sota el cursor al picker.
constexpr std::uint32_t PICKER_HOVER_ARGB = 0xFFFFFFFFU;
} // namespace Editor
#endif
// Configuració de l'escena de joc (timings i corbes de la FSM de sala).
// Independent de la piràmide concreta — quan un dia hi haja diferents
// ritmes per piràmide, aquest YAML pot adoptar variants per nivell.
namespace GameScene {
constexpr const char* CONFIG_YAML = "data/scenes/game.yaml";
} // namespace GameScene
// Rutes d'assets per piràmide. Centralitzats ací perquè `GameScene` no
// en contingui cap literal; afegir una piràmide nova serà un altre
// conjunt de constants + un Config distint passat al ctor.
namespace Pyramid {
constexpr const char* MAP_YAML = "data/maps/pyramid.yaml";
constexpr const char* PLAYER_SPRITE = "data/player/player.gif";
constexpr const char* PLAYER_YAML = "data/player/player.yaml";
constexpr const char* PLAYER_SPRITE = "data/player/sam.gif";
constexpr const char* PLAYER_YAML = "data/player/sam.yaml";
// Personatge del segon jugador. Clonat de sam amb repaletat (roig→blau);
// la geometria del sprite i les animacions coincideixen amb les de P1,
// així que Player::loadFrom només cal cridar-la amb un dels dos YAMLs.
constexpr const char* PLAYER_SPRITE_P2 = "data/player/morcus.gif";
constexpr const char* PLAYER_YAML_P2 = "data/player/morcus.yaml";
constexpr const char* MUSIC_TRACK = "pyramid_1.ogg";
} // namespace Pyramid
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Texts {
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner";
constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
constexpr const char* VERSION = "0.21.0"; // Single source of truth — Makefile el grep
constexpr const char* VERSION = "0.24.0"; // Single source of truth — Makefile el grep
} // namespace Texts
namespace GameCanvas {
+69 -22
View File
@@ -4,10 +4,12 @@
// T1 factory — tots els tipus es registren i es creen.
// T2 loader — llegeix `data/maps/test_entities.yaml` i retorna els spawns.
// T3 event bus — subscribe + publish + payload (entity*, int).
// T4 sarcòfag — perímetre computat correctament; notifyTileEntered
// T4 sarcòfag — perímetre computat correctament; notifyTilePainted
// passa a OPENING quan es visiten tots els tiles.
// T5 manager — 2 sarcs + 1 door: forçant l'obertura dels sarcs, la porta
// s'obri sola; contactar la porta OPEN emet `level_complete`.
// s'obri sola. El contacte de la porta amb un jugador
// nul·l és no-op segur (la porta ja no emet `level_complete`
// directament; ho fa EntityManager comptant `player_exited`).
// T6 animation — 3 frames @ 10 fps, `tick(0.15)` → frame índex == 1.
// T7 plantilles — carrega els 7 YAML d'entitat sense excepcions.
// T8 decoration — Torch no col·lisiona ni és interactable.
@@ -20,6 +22,10 @@
// sprite sencer (pixel_rect).
// T13 mummy step — camina en línia recta pel playfield i gira en topar
// un mur (insidePlayfield=false) o sarc (Map::collides).
// T14 exited aggregator — EntityManager compta `player_exited` i emet
// `level_complete` quan iguala `expected_players`. Verifica
// idempotència (3r event no re-emet) i reset del comptador
// via `setExpectedPlayerCount` (cicle següent torna a emetre).
//
// Execució (des de la ruta del repo):
// make build && cd build && ctest --output-on-failure
@@ -138,14 +144,14 @@ namespace {
REQUIRE(sarc->getState() == Sarcophagus::State::CLOSED);
// Visitem un tile que NO és al perímetre: res canvia.
sarc->notifyTileEntered(5, 5);
sarc->notifyTilePainted(5, 5);
REQUIRE(sarc->getVisitedCount() == 0);
// Visitem tots els del perímetre.
const std::vector<std::pair<int, int>> PERIM = {
{1, 0}, {2, 0}, {3, 0}, {1, 2}, {2, 2}, {3, 2}, {0, 1}, {4, 1},
};
for (const auto& [c, r] : PERIM) { sarc->notifyTileEntered(c, r); }
for (const auto& [c, r] : PERIM) { sarc->notifyTilePainted(c, r); }
REQUIRE(sarc->getVisitedCount() == 8);
REQUIRE(sarc->getState() == Sarcophagus::State::OPENING);
@@ -173,12 +179,6 @@ namespace {
manager.add(std::move(door));
manager.setDoor(door_ptr);
// Subscriptor local per capturar `level_complete`.
bool level_complete_fired = false;
manager.getBus().subscribe("level_complete", [&](const Event&) {
level_complete_fired = true;
});
// Forcem l'obertura dels dos sarcs visitant els seus perímetres.
auto forceOpen = [](Sarcophagus* s) {
const int COL0 = s->getTilePos().col;
@@ -186,12 +186,12 @@ namespace {
const int W = s->getTileWidth();
const int H = s->getTileHeight();
for (int dc = 0; dc < W; ++dc) {
s->notifyTileEntered(COL0 + dc, ROW0 - 1);
s->notifyTileEntered(COL0 + dc, ROW0 + H);
s->notifyTilePainted(COL0 + dc, ROW0 - 1);
s->notifyTilePainted(COL0 + dc, ROW0 + H);
}
for (int dr = 0; dr < H; ++dr) {
s->notifyTileEntered(COL0 - 1, ROW0 + dr);
s->notifyTileEntered(COL0 + W, ROW0 + dr);
s->notifyTilePainted(COL0 - 1, ROW0 + dr);
s->notifyTilePainted(COL0 + W, ROW0 + dr);
}
};
forceOpen(s1);
@@ -206,12 +206,16 @@ namespace {
REQUIRE(manager.openedSarcophagusCount() == 2);
REQUIRE(door_ptr->getState() == Door::State::OPEN);
// Contacte amb la porta oberta → level_complete.
door_ptr->onPlayerContact(nullptr);
REQUIRE(level_complete_fired);
// Un segon contacte NO torna a disparar (idempotent).
level_complete_fired = false;
// Contacte sense jugador (nullptr): no-op segur. La porta ja no
// emet `level_complete` directament — ara és EntityManager qui
// l'emet quan tots els jugadors han eixit. La cobertura
// d'aquest agregat viu al test específic d'EntityManager
// (test_game_fsm) perquè requereix instanciar Player, fora
// d'abast d'aquest smoke purament d'entitats.
bool level_complete_fired = false;
manager.getBus().subscribe("level_complete", [&](const Event&) {
level_complete_fired = true;
});
door_ptr->onPlayerContact(nullptr);
REQUIRE(!level_complete_fired);
}
@@ -310,7 +314,9 @@ namespace {
REQUIRE(ANCHOR_ROW == 4);
// 4) Fan-out del bus: un sarcòfag ancorat a (3,4) mida 3×1 rep via
// on_tile_entered una visita sobre el seu perímetre → state canvia.
// tile_painted una notificació sobre cada tile del seu perímetre
// → state canvia. Publiquem directament `tile_painted` (sense
// passar pel PaintLayer) per aïllar el fan-out de l'EntityManager.
EntityManager manager;
auto sarc = std::make_unique<Sarcophagus>(EntityLoader::loadTemplate("sarcophagus"));
sarc->place(3, 4, {});
@@ -323,7 +329,7 @@ namespace {
{2, 4}, {6, 4},
};
for (const auto& [c, r] : PERIM) {
manager.getBus().publish("on_tile_entered", TilePos{.col = c, .row = r});
manager.getBus().publish("tile_painted", TilePos{.col = c, .row = r});
}
REQUIRE(sarc_raw->getState() == Sarcophagus::State::OPENING);
}
@@ -447,6 +453,46 @@ namespace {
REQUIRE(torch.getAnimation()->currentFrame() == 2);
}
void testExitedAggregator() {
std::cout << "[T14] EntityManager: player_exited → level_complete\n";
EntityManager manager;
// Comptador inicialitzat per a 2 jugadors. Sense aquesta crida,
// l'agregador no faria res (expected_players_ = 0).
manager.setExpectedPlayerCount(2);
int level_complete_count = 0;
manager.getBus().subscribe("level_complete", [&](const Event& ev) {
++level_complete_count;
// Event d'agregat: no atribuïble a un jugador concret.
REQUIRE(ev.player_id == -1);
});
// Primer player_exited: comptador a 1 < 2, no emet encara.
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 0});
REQUIRE(manager.exitedPlayerCount() == 1);
REQUIRE(level_complete_count == 0);
// Segon player_exited: comptador a 2 == expected, emet una vegada.
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 1});
REQUIRE(manager.exitedPlayerCount() == 2);
REQUIRE(level_complete_count == 1);
// Un tercer player_exited (no hauria de passar amb 2 jugadors,
// però defensa contra publicacions extra): no torna a emetre.
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 0});
REQUIRE(level_complete_count == 1);
// setExpectedPlayerCount torna a obrir el cicle (això simularia
// el que fa GameScene després d'un reload de sala). El comptador
// d'EXITED es reinicia a 0 i el flag d'emissió també.
manager.setExpectedPlayerCount(2);
REQUIRE(manager.exitedPlayerCount() == 0);
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 0});
manager.getBus().publish(Event{.name = "player_exited", .payload = std::monostate{}, .player_id = 1});
REQUIRE(level_complete_count == 2); // segon cicle ha emès una vegada més
}
} // namespace
auto main() -> int {
@@ -465,6 +511,7 @@ auto main() -> int {
testCollisionBox();
testMummyStep();
testTorchAnimation();
testExitedAggregator();
if (g_failures == 0) {
std::cout << "ALL OK\n";
+240
View File
@@ -0,0 +1,240 @@
// Smoke test de la FSM de sala (RoomFsm) extreta de GameScene. Cobreix:
// T1 loadConfig — fitxer real → tots els camps llegits.
// T2 loadConfig — fitxer absent → fallbacks compilats; corbes default
// resoltes per UiConfig::curveByName.
// T3 loadConfig — claus parcials → camps absents conserven fallback.
// T4 advance — ROOM_INTRO consumeix les 5 sub-fases en ordre.
// T5 advance — ROOM_INTRO desemboca a PLAYING quan acaba l'última fase.
// T6 advance — PLAYING és sumidor: no avança res ni canvia d'estat.
// T7 advance — ROOM_OUTRO consumeix les 4 sub-fases i recau a ROOM_INTRO.
// T8 advance — un delta gran salta més d'una sub-fase en un sol pas.
// T9 formatRoomTitle — substitueix `{n}` quan el Locale té la clau.
// T10 formatRoomTitle — sense Locale, retorna la pròpia clau (fallback).
// T11 stateName — strings esperats per a cada State.
//
// El render no es testeja: la FSM és pura i no pinta res. Les regles de
// gating d'input dels jugadors viuen a `GameScene::update` i estan
// cobertes pel smoke run del binari (xvfb-run) — la part testable és
// que `advance` retorna l'estat correcte, que és el senyal que activa
// el gating al callsite.
//
// Execució:
// make build && cd build && ctest --output-on-failure
// O bé:
// ./build/test_game_fsm
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <string>
#include "core/locale/locale.hpp"
#include "core/ui/ui_config.hpp"
#include "game/scenes/room_fsm.hpp"
namespace {
int g_failures = 0;
void check(bool cond, const char* msg, const char* file, int line) {
if (!cond) {
std::cerr << " FAIL: " << msg << " (" << file << ":" << line << ")\n";
++g_failures;
}
}
#define CHECK(cond) check((cond), #cond, __FILE__, __LINE__)
auto writeTemp(const std::string& path, const std::string& content) -> std::string {
std::ofstream file(path);
file << content;
return path;
}
void testLoadConfigFull() {
std::cout << "T1 loadConfig — fitxer real complet\n";
const auto PATH = (std::filesystem::temp_directory_path() / "aee_test_full.yaml").string();
writeTemp(PATH,
"room_intro:\n"
" fade_in_ms: 100\n"
" text_delay_ms: 200\n"
" text_enter_ms: 300\n"
" text_hold_ms: 400\n"
" text_exit_ms: 500\n"
"room_outro:\n"
" text_enter_ms: 600\n"
" text_hold_ms: 700\n"
" text_exit_ms: 800\n"
" fade_out_ms: 900\n"
"text:\n"
" enter_curve: outQuad\n"
" exit_curve: inQuad\n");
const auto CFG = RoomFsm::loadConfig(PATH);
CHECK(CFG.intro_fade_in_ms == 100);
CHECK(CFG.intro_text_delay_ms == 200);
CHECK(CFG.intro_text_enter_ms == 300);
CHECK(CFG.intro_text_hold_ms == 400);
CHECK(CFG.intro_text_exit_ms == 500);
CHECK(CFG.outro_text_enter_ms == 600);
CHECK(CFG.outro_text_hold_ms == 700);
CHECK(CFG.outro_text_exit_ms == 800);
CHECK(CFG.outro_fade_out_ms == 900);
CHECK(CFG.text_enter_curve == UiConfig::curveByName("outQuad"));
CHECK(CFG.text_exit_curve == UiConfig::curveByName("inQuad"));
std::filesystem::remove(PATH);
}
void testLoadConfigMissing() {
std::cout << "T2 loadConfig — fitxer absent (fallbacks)\n";
const auto CFG = RoomFsm::loadConfig("/tmp/aee_2026_does_not_exist.yaml");
CHECK(CFG.intro_fade_in_ms == 500);
CHECK(CFG.intro_text_delay_ms == 500);
CHECK(CFG.intro_text_enter_ms == 500);
CHECK(CFG.intro_text_hold_ms == 1000);
CHECK(CFG.intro_text_exit_ms == 500);
CHECK(CFG.outro_text_enter_ms == 500);
CHECK(CFG.outro_text_hold_ms == 1000);
CHECK(CFG.outro_text_exit_ms == 500);
CHECK(CFG.outro_fade_out_ms == 500);
CHECK(CFG.text_enter_curve != nullptr);
CHECK(CFG.text_exit_curve != nullptr);
}
void testLoadConfigPartial() {
std::cout << "T3 loadConfig — claus parcials (mantenir fallback)\n";
const auto PATH = (std::filesystem::temp_directory_path() / "aee_test_partial.yaml").string();
writeTemp(PATH,
"room_intro:\n"
" fade_in_ms: 42\n");
const auto CFG = RoomFsm::loadConfig(PATH);
CHECK(CFG.intro_fade_in_ms == 42);
CHECK(CFG.intro_text_delay_ms == 500); // fallback
CHECK(CFG.outro_fade_out_ms == 500); // fallback
std::filesystem::remove(PATH);
}
auto makeShortConfig() -> RoomFsm::Config {
// Durades curtes per fer els tests d'`advance` deterministes i
// independents dels valors per defecte.
RoomFsm::Config cfg{};
cfg.intro_fade_in_ms = 10;
cfg.intro_text_delay_ms = 20;
cfg.intro_text_enter_ms = 30;
cfg.intro_text_hold_ms = 40;
cfg.intro_text_exit_ms = 50;
cfg.outro_text_enter_ms = 11;
cfg.outro_text_hold_ms = 22;
cfg.outro_text_exit_ms = 33;
cfg.outro_fade_out_ms = 44;
return cfg;
}
void testAdvanceIntroPhases() {
std::cout << "T4 advance — ROOM_INTRO consumeix sub-fases en ordre\n";
const auto CFG = makeShortConfig();
RoomFsm::Transition t{};
auto s = RoomFsm::State::ROOM_INTRO;
s = RoomFsm::advance(s, t, CFG, 5); // dins fase 0
CHECK(s == RoomFsm::State::ROOM_INTRO && t.phase == 0 && t.elapsed_ms == 5);
s = RoomFsm::advance(s, t, CFG, 10); // surt de fase 0, entra a fase 1 (5 ms dins)
CHECK(s == RoomFsm::State::ROOM_INTRO && t.phase == 1 && t.elapsed_ms == 5);
s = RoomFsm::advance(s, t, CFG, 20); // surt de fase 1 (15 dins), entra a fase 2 amb 5 ms (5+20-20=5)
CHECK(s == RoomFsm::State::ROOM_INTRO && t.phase == 2 && t.elapsed_ms == 5);
}
void testAdvanceIntroToPlaying() {
std::cout << "T5 advance — ROOM_INTRO acaba a PLAYING\n";
const auto CFG = makeShortConfig();
RoomFsm::Transition t{};
const int TOTAL = CFG.intro_fade_in_ms + CFG.intro_text_delay_ms +
CFG.intro_text_enter_ms + CFG.intro_text_hold_ms +
CFG.intro_text_exit_ms;
const auto S = RoomFsm::advance(RoomFsm::State::ROOM_INTRO, t, CFG, TOTAL);
CHECK(S == RoomFsm::State::PLAYING);
}
void testAdvancePlayingNoOp() {
std::cout << "T6 advance — PLAYING és sumidor\n";
const auto CFG = makeShortConfig();
RoomFsm::Transition t{42, 999};
const auto S = RoomFsm::advance(RoomFsm::State::PLAYING, t, CFG, 1000);
CHECK(S == RoomFsm::State::PLAYING);
CHECK(t.phase == 42); // intacte
CHECK(t.elapsed_ms == 999);
}
void testAdvanceOutroToIntro() {
std::cout << "T7 advance — ROOM_OUTRO recau a ROOM_INTRO\n";
const auto CFG = makeShortConfig();
RoomFsm::Transition t{};
const int TOTAL = CFG.outro_text_enter_ms + CFG.outro_text_hold_ms +
CFG.outro_text_exit_ms + CFG.outro_fade_out_ms;
const auto S = RoomFsm::advance(RoomFsm::State::ROOM_OUTRO, t, CFG, TOTAL);
CHECK(S == RoomFsm::State::ROOM_INTRO);
}
void testAdvanceLargeDeltaJumps() {
std::cout << "T8 advance — un delta gran salta múltiples sub-fases\n";
const auto CFG = makeShortConfig();
RoomFsm::Transition t{};
// 10+20+5 = 35 ms → consumeix fase 0 i 1, queda a fase 2 amb 5 ms.
const auto S = RoomFsm::advance(RoomFsm::State::ROOM_INTRO, t, CFG, 35);
CHECK(S == RoomFsm::State::ROOM_INTRO);
CHECK(t.phase == 2);
CHECK(t.elapsed_ms == 5);
}
void testFormatRoomTitleWithLocale() {
std::cout << "T9 formatRoomTitle — substitució amb Locale\n";
const auto PATH = (std::filesystem::temp_directory_path() / "aee_test_locale.yaml").string();
writeTemp(PATH, "game:\n room_intro: \"Sala {n}\"\n");
Locale::get().load(PATH);
CHECK(RoomFsm::formatRoomTitle(1) == "Sala 1");
CHECK(RoomFsm::formatRoomTitle(7) == "Sala 7");
std::filesystem::remove(PATH);
}
void testFormatRoomTitleNoLocale() {
std::cout << "T10 formatRoomTitle — fallback sense traducció\n";
const auto PATH = (std::filesystem::temp_directory_path() / "aee_test_locale_empty.yaml").string();
writeTemp(PATH, "other: foo\n");
Locale::get().load(PATH);
// Sense la clau "game.room_intro", Locale retorna la pròpia clau,
// i com que no porta `{n}`, la substitució no canvia res.
CHECK(RoomFsm::formatRoomTitle(3) == "game.room_intro");
std::filesystem::remove(PATH);
}
void testStateName() {
std::cout << "T11 stateName — strings esperats\n";
CHECK(std::string(RoomFsm::stateName(RoomFsm::State::ROOM_INTRO)) == "ROOM_INTRO");
CHECK(std::string(RoomFsm::stateName(RoomFsm::State::PLAYING)) == "PLAYING");
CHECK(std::string(RoomFsm::stateName(RoomFsm::State::ROOM_OUTRO)) == "ROOM_OUTRO");
}
} // namespace
auto main() -> int {
testLoadConfigFull();
testLoadConfigMissing();
testLoadConfigPartial();
testAdvanceIntroPhases();
testAdvanceIntroToPlaying();
testAdvancePlayingNoOp();
testAdvanceOutroToIntro();
testAdvanceLargeDeltaJumps();
testFormatRoomTitleWithLocale();
testFormatRoomTitleNoLocale();
testStateName();
if (g_failures == 0) {
std::cout << "OK: tots els tests de RoomFsm passen\n";
return 0;
}
std::cerr << g_failures << " tests han fallat\n";
return 1;
}
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff. Show more