Compare commits

...
29 Commits
Author SHA1 Message Date
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
43 changed files with 3988 additions and 302 deletions

No files matched your search

+69 -41
View File
@@ -8,7 +8,7 @@ Checks:
- -bugprone-integer-division - -bugprone-integer-division
- -bugprone-easily-swappable-parameters - -bugprone-easily-swappable-parameters
- -bugprone-narrowing-conversions - -bugprone-narrowing-conversions
- -modernize-avoid-c-arrays,-warnings-as-errors - -modernize-avoid-c-arrays
WarningsAsErrors: '*' WarningsAsErrors: '*'
# Headers nostres (excloem source/external/ que conté dependències de tercers no editables) # Headers nostres (excloem source/external/ que conté dependències de tercers no editables)
@@ -19,61 +19,89 @@ CheckOptions:
# bugprone-empty-catch: aceptar catches vacíos marcados con @INTENTIONAL en un comentario # bugprone-empty-catch: aceptar catches vacíos marcados con @INTENTIONAL en un comentario
- { key: bugprone-empty-catch.IgnoreCatchWithKeywords, value: '@INTENTIONAL' } - { 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.VariableCase, value: lower_case }
- { key: readability-identifier-naming.LocalVariableCase, value: lower_case }
# Miembros privados en snake_case con sufijo _ # Parámetros de función
- { key: readability-identifier-naming.PrivateMemberCase, value: lower_case } - { key: readability-identifier-naming.ParameterCase, value: lower_case }
- { key: readability-identifier-naming.PrivateMemberSuffix, value: _ }
# Miembros protegidos en snake_case con sufijo _ # Variables estáticas no-const (static locales, static file-scope,
- { key: readability-identifier-naming.ProtectedMemberCase, value: lower_case } # y static members no-const de clase como el instance_ de un Singleton).
- { key: readability-identifier-naming.ProtectedMemberSuffix, value: _ } # Sufijo _ para marcar que tienen storage estático.
# 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
- { key: readability-identifier-naming.StaticVariableCase, value: lower_case } - { key: readability-identifier-naming.StaticVariableCase, value: lower_case }
- { key: readability-identifier-naming.StaticVariableSuffix, value: _ } - { 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 # Protegidos: snake_case con sufijo _
- { key: readability-identifier-naming.GlobalConstantCase, value: UPPER_CASE } - { key: readability-identifier-naming.ProtectedMemberCase, value: lower_case }
- { key: readability-identifier-naming.ProtectedMemberSuffix, value: _ }
# Variables constexpr globales en UPPER_CASE # Públicos: snake_case sin sufijo
- { key: readability-identifier-naming.ConstexprVariableCase, value: UPPER_CASE } - { key: readability-identifier-naming.PublicMemberCase, value: lower_case }
# Constantes locales en UPPER_CASE # =====================================================================
- { key: readability-identifier-naming.LocalConstantCase, value: UPPER_CASE } # TIPOS
# =====================================================================
# 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
- { key: readability-identifier-naming.ClassCase, value: CamelCase } - { key: readability-identifier-naming.ClassCase, value: CamelCase }
- { key: readability-identifier-naming.StructCase, value: CamelCase } - { key: readability-identifier-naming.StructCase, value: CamelCase }
- { key: readability-identifier-naming.EnumCase, 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 # Namespaces
- { key: readability-identifier-naming.EnumConstantCase, value: UPPER_CASE } - { 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.MethodCase, value: camelBack }
- { key: readability-identifier-naming.PrivateMethodCase, value: camelBack } - { key: readability-identifier-naming.PrivateMethodCase, value: camelBack }
- { key: readability-identifier-naming.ProtectedMethodCase, value: camelBack } - { key: readability-identifier-naming.ProtectedMethodCase, value: camelBack }
- { key: readability-identifier-naming.PublicMethodCase, value: camelBack } - { key: readability-identifier-naming.PublicMethodCase, value: camelBack }
- { key: readability-identifier-naming.ConstexprMethodCase, 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 }
+21
View File
@@ -47,6 +47,7 @@ set(ENTITY_SOURCES
source/game/entities/concrete/rolling_rock.cpp source/game/entities/concrete/rolling_rock.cpp
source/game/entities/concrete/wall_trap.cpp source/game/entities/concrete/wall_trap.cpp
source/game/entities/concrete/floor_trap.cpp source/game/entities/concrete/floor_trap.cpp
source/game/entities/concrete/dust.cpp
) )
set(APP_SOURCES set(APP_SOURCES
@@ -203,6 +204,26 @@ add_custom_command(
add_custom_target(resource_pack ALL DEPENDS ${RESOURCE_PACK}) add_custom_target(resource_pack ALL DEPENDS ${RESOURCE_PACK})
add_dependencies(${PROJECT_NAME} 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_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic)
target_compile_definitions(${PROJECT_NAME} PRIVATE target_compile_definitions(${PROJECT_NAME} PRIVATE
$<$<CONFIG:Debug>:_DEBUG> $<$<CONFIG:Debug>:_DEBUG>
+11 -1
View File
@@ -14,7 +14,7 @@ endif
CMAKE_DEFS := -DGIT_HASH=$(GIT_HASH) 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Ó # COMPILACIÓ
@@ -61,6 +61,15 @@ pack:
@cmake --build $(BUILDDIR) --target pack_resources @cmake --build $(BUILDDIR) --target pack_resources
@./$(BUILDDIR)/pack_resources data $(BUILDDIR)/resources.pack @./$(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) # CODE QUALITY (delegados a cmake)
# ============================================================================== # ==============================================================================
@@ -110,6 +119,7 @@ help:
@echo "" @echo ""
@echo " Recursos:" @echo " Recursos:"
@echo " make pack - Empaquetar data/ a $(BUILDDIR)/resources.pack (format AE26)" @echo " make pack - Empaquetar data/ a $(BUILDDIR)/resources.pack (format AE26)"
@echo " make controllerdb - Descarregar gamecontrollerdb.txt actualitzat (arrel)"
@echo "" @echo ""
@echo " Qualitat de codi:" @echo " Qualitat de codi:"
@echo " make format - Formatar codi amb clang-format" @echo " make format - Formatar codi amb clang-format"
+4
View File
@@ -4,6 +4,10 @@ sprite:
path: door.gif path: door.gif
size: [48, 32] size: [48, 32]
tile_size: [3, 2] tile_size: [3, 2]
collision:
# Porta = mur infranquejable. Igual que el sarc, alçada efectiva
# infinita per impedir que un jugador la salte per damunt.
height: 999
flags: flags:
collides: true collides: true
interactable: 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 path: floor_trap.gif
size: [16, 16] size: [16, 16]
tile_size: [1, 1] 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: flags:
collides: false collides: false
interactable: false interactable: false
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

-35
View File
@@ -1,35 +0,0 @@
name: momia
type: dynamic_hazard
sprite:
path: momia.gif
size: [16, 32]
tile_size: [1, 1]
# 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: [16, 16]
flags:
collides: true
interactable: false
# 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)
# La momia no té variants `stand_*` perquè mai no para.
animations:
walk_down: { frames: [0, 1, 2, 3], fps: 8, loop: true }
walk_up: { frames: [4, 5, 6, 7], fps: 8, loop: true }
walk: { frames: [8, 9, 10, 11], fps: 8, loop: true }
ai:
type: corridor_wanderer
direction_choice: random
initial_direction: right
stats:
speed: 30
damage: 1
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 type: dynamic_hazard
sprite: sprite:
path: mummy.gif path: mummy.gif
size: [12, 24] size: [16, 32]
tile_size: [1, 1] tile_size: [1, 1]
# La momia usa falsa perspectiva: el sprite fa 12×24 (dos 12×12: cap i # Variant 16×32 de la momia: el sprite fa 16×32 (dos 16×16: cap i peus)
# peus) però la caja de col·lisió amb el mapa és 16×16, concèntrica amb # i la caja de col·lisió amb el mapa és 16×16, coincident amb el
# el rectangle-base 12×12 dels peus (igual que fa el jugador). Així la # rectangle-base 16×16 dels peus. Com que `size` i `base_size` són
# caixa s'estén 2 px a cada costat i 2 px per sota dels peus visuals, # iguals, el padding és zero i la caja s'alinea exactament amb la
# i amb `anchor: feet` s'alinea exactament amb el tile 16×16 on naix — # silueta inferior del sprite; amb `anchor: feet` ocupa la cel·la
# la momia va pel centre del corredor i la caja ocupa la cel·la completa. # 16×16 del corredor on naix.
anchor: feet anchor: feet
collision: collision:
size: [16, 16] 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: flags:
collides: true collides: true
interactable: false 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) # 0..3 → walk_down (cara avall)
# 4..7 → walk_up (cara amunt) # 4..7 → walk_up (cara amunt)
# 8..11 → walk (lateral; flip horitzontal segons el sentit) # 8..11 → walk (lateral; flip horitzontal segons el sentit)
@@ -4,6 +4,11 @@ sprite:
path: rolling_rock.gif path: rolling_rock.gif
size: [16, 16] size: [16, 16]
tile_size: [1, 1] 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: flags:
collides: true collides: true
interactable: false interactable: false
@@ -7,6 +7,13 @@ sprite:
# són "alçada" en falsa perspectiva, sense col·lisió. Perímetre ortogonal # són "alçada" en falsa perspectiva, sense col·lisió. Perímetre ortogonal
# de 8 tiles (3 N + 3 S + 1 O + 1 E). # de 8 tiles (3 N + 3 S + 1 O + 1 E).
tile_size: [3, 1] 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: flags:
collides: true collides: true
interactable: true interactable: true
+4
View File
@@ -4,6 +4,10 @@ sprite:
path: torch.gif path: torch.gif
size: [16, 16] size: [16, 16]
tile_size: [1, 1] 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: flags:
collides: false collides: false
interactable: false interactable: false
+5
View File
@@ -4,6 +4,11 @@ sprite:
path: wall_trap.gif path: wall_trap.gif
size: [16, 16] size: [16, 16]
tile_size: [1, 1] 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: flags:
collides: false collides: false
interactable: false interactable: false
+2 -2
View File
@@ -124,7 +124,7 @@ entities:
sarc_position: [3, 4] sarc_position: [3, 4]
- type: door - type: door
position: [9, 1] position: [9, 1]
- type: momia - type: mummy
position: [10, 7] position: [10, 7]
- type: momia - type: mummy
position: [7, 9] position: [7, 9]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

+95 -20
View File
@@ -1,7 +1,7 @@
# player animation # player animation
tileSetFile: morcus.gif sprite:
frameWidth: 16 path: morcus.gif
frameHeight: 32 size: [16, 32]
# Geometria de col·lisió del jugador (independent de l'animació): # Geometria de col·lisió del jugador (independent de l'animació):
# size → costat del quadrat de col·lisió (16×16) # size → costat del quadrat de col·lisió (16×16)
@@ -15,31 +15,71 @@ collision:
# `size` es reserva per a la col·lisió amb murs/sarcòfags i per enfilar # `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. # els passadissos. Centrada en el mateix punt que la caixa base.
hit_size: 8 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: animations:
- name: stand_down stand_down: { frames: [0] }
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] }
- name: stand_up walk_down:
frames: [4]
- name: stand
frames: [8]
- name: walk_down
speed: 0.1
loopFrom: 0
frames: [0, 1, 2, 3] frames: [0, 1, 2, 3]
frame_ms: 100
- name: walk_up walk_up:
speed: 0.1
loopFrom: 0
frames: [4, 5, 6, 7] frames: [4, 5, 6, 7]
frame_ms: 100
- name: walk walk:
speed: 0.1
loopFrom: 0
frames: [8, 9, 10, 11] 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ó # Gir de 180°: mentre dura, el facing queda fixat a la direcció
# intermèdia (efecte purament visual; el moviment continua sense # intermèdia (efecte purament visual; el moviment continua sense
@@ -52,3 +92,38 @@ turn:
east_to_west: up # passa per dalt (stand_up) east_to_west: up # passa per dalt (stand_up)
north_to_south: east # passa per la dreta (stand, sense flip) north_to_south: east # passa per la dreta (stand, sense flip)
south_to_north: west # passa per l'esquerra (stand, amb 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.5 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

+95 -20
View File
@@ -1,7 +1,7 @@
# player animation # player animation
tileSetFile: sam.gif sprite:
frameWidth: 16 path: sam.gif
frameHeight: 32 size: [16, 32]
# Geometria de col·lisió del jugador (independent de l'animació): # Geometria de col·lisió del jugador (independent de l'animació):
# size → costat del quadrat de col·lisió (16×16) # size → costat del quadrat de col·lisió (16×16)
@@ -15,31 +15,71 @@ collision:
# `size` es reserva per a la col·lisió amb murs/sarcòfags i per enfilar # `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. # els passadissos. Centrada en el mateix punt que la caixa base.
hit_size: 8 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: animations:
- name: stand_down stand_down: { frames: [1] }
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] }
- name: stand_up walk_down:
frames: [5]
- name: stand
frames: [9]
- name: walk_down
speed: 0.1
loopFrom: 0
frames: [0, 1, 2, 3] frames: [0, 1, 2, 3]
frame_ms: 100
- name: walk_up walk_up:
speed: 0.1
loopFrom: 0
frames: [4, 5, 6, 7] frames: [4, 5, 6, 7]
frame_ms: 100
- name: walk walk:
speed: 0.1
loopFrom: 0
frames: [8, 9, 10, 11] 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ó # Gir de 180°: mentre dura, el facing queda fixat a la direcció
# intermèdia (efecte purament visual; el moviment continua sense # intermèdia (efecte purament visual; el moviment continua sense
@@ -52,3 +92,38 @@ turn:
east_to_west: up # passa per dalt (stand_up) east_to_west: up # passa per dalt (stand_up)
north_to_south: east # passa per la dreta (stand, sense flip) north_to_south: east # passa per la dreta (stand, sense flip)
south_to_north: west # passa per l'esquerra (stand, amb 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.
Binary file not shown.
+2232
View File
File diff suppressed because it is too large. Load diff
+26 -5
View File
@@ -149,11 +149,22 @@ void GamepadManager::init() {
<< SDL_GetError() << '\n'; << SDL_GetError() << '\n';
return; return;
} }
// `gamecontrollerdb.txt` és opcional. Si no existeix, SDL retorna -1 i // `gamecontrollerdb.txt` és opcional. Viu a l'arrel del projecte
// continuem amb les definicions internes del sistema. // (no a `data/`) perquè SDL_AddGamepadMappingsFromFile només sap
const int ADDED = SDL_AddGamepadMappingsFromFile("data/input/gamecontrollerdb.txt"); // llegir del filesystem real, no del pack. CMake el copia al costat
if (ADDED > 0) { // del binari a cada build perquè SDL_GetBasePath el trobi tant en
std::cout << "GamepadManager: " << ADDED << " mappings de gamecontrollerdb.txt\n"; // 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; int count = 0;
@@ -164,6 +175,7 @@ void GamepadManager::init() {
} }
SDL_free(ids); SDL_free(ids);
} }
std::cout << "GamepadManager: " << entries_.size() << " mando(s) detectat(s) a l'inici\n";
initialized_ = true; initialized_ = true;
} }
@@ -262,6 +274,15 @@ void GamepadManager::assignToPlayer(int player_id, const std::string& name, cons
} }
} }
} }
// 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) { void GamepadManager::unassignFromPlayer(int player_id) {
+2 -2
View File
@@ -42,7 +42,7 @@ namespace GIF {
} }
// Inicializa el diccionario LZW con los valores iniciales // 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; int size = 1 << code_length;
dictionary.resize(1 << (code_length + 1)); dictionary.resize(1 << (code_length + 1));
for (dictionary_ind = 0; dictionary_ind < size; dictionary_ind++) { for (dictionary_ind = 0; dictionary_ind < size; dictionary_ind++) {
@@ -82,7 +82,7 @@ namespace GIF {
} }
// Agrega una nueva entrada al diccionario // 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; uint8_t first_byte;
if (code == dictionary_ind) { if (code == dictionary_ind) {
first_byte = findFirstByte(dictionary, prev); first_byte = findFirstByte(dictionary, prev);
+106 -88
View File
@@ -3,6 +3,7 @@
#include <cmath> #include <cmath>
#include <iostream> #include <iostream>
#include <numeric> #include <numeric>
#include <utility>
#include "core/rendering/surface.hpp" #include "core/rendering/surface.hpp"
#include "core/resources/resource_cache.hpp" #include "core/resources/resource_cache.hpp"
@@ -24,46 +25,6 @@ namespace {
return frames; 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 { struct SheetParams {
float frame_width{0.0F}; float frame_width{0.0F};
float frame_height{0.0F}; float frame_height{0.0F};
@@ -71,16 +32,22 @@ namespace {
int max_tiles{1}; 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; SheetParams params;
if (yaml.contains("tileSetFile")) { if (!yaml.contains("sprite")) { return params; }
surface = Resource::Cache::get().getSurface(yaml["tileSetFile"].get_value<std::string>()); const auto& s = yaml["sprite"];
if (s.contains("path")) {
surface = Resource::Cache::get().getSurface(s["path"].get_value<std::string>());
} }
if (yaml.contains("frameWidth")) { if (s.contains("size")) {
params.frame_width = static_cast<float>(yaml["frameWidth"].get_value<int>()); const auto& sz = s["size"];
} if (sz.is_sequence() && sz.size() == 2) {
if (yaml.contains("frameHeight")) { params.frame_width = static_cast<float>(sz[0].get_value<int>());
params.frame_height = static_cast<float>(yaml["frameHeight"].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) { if (surface && params.frame_width > 0.0F && params.frame_height > 0.0F) {
params.frames_per_row = surface->getWidth() / static_cast<int>(params.frame_width); params.frames_per_row = surface->getWidth() / static_cast<int>(params.frame_width);
@@ -90,21 +57,82 @@ namespace {
return params; 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 } // namespace
AnimatedSprite::AnimatedSprite(const AnimationResource& cached_data) { AnimatedSprite::AnimatedSprite(const AnimationResource& cached_data) {
std::string yaml_content(cached_data.yaml_data.begin(), cached_data.yaml_data.end()); std::string yaml_content(cached_data.yaml_data.begin(), cached_data.yaml_data.end());
try { try {
auto yaml = fkyaml::node::deserialize(yaml_content); auto yaml = fkyaml::node::deserialize(yaml_content);
auto params = parseGlobalConfig(yaml, surface_); auto params = parseSpriteBlock(yaml, surface_);
animations_ = parseAnimations(yaml, params.frame_width, params.frame_height, params.frames_per_row, params.max_tiles); animations_ = parseAnimations(yaml, params);
buildNameIndex();
setWidth(params.frame_width); setWidth(params.frame_width);
setHeight(params.frame_height); setHeight(params.frame_height);
if (!animations_.empty() && !animations_[0].frames.empty()) { // Clip inicial: qualsevol frame del primer element del mapa serveix
setClip(animations_[0].frames[0]); // 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) { } catch (const fkyaml::exception& e) {
std::cerr << "YAML parsing error in animation " << cached_data.name << ": " << e.what() << '\n'; 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) { void AnimatedSprite::animate(float delta_time) {
if (animations_.empty()) { return; } if (current_animation_.empty()) { return; }
auto& anim = animations_[current_animation_]; auto it = animations_.find(current_animation_);
if (anim.speeds.empty()) { return; } if (it == animations_.end()) { return; }
auto& anim = it->second;
if (anim.speeds.empty() || anim.frames.empty()) { return; }
anim.accumulated_time += delta_time; anim.accumulated_time += delta_time;
@@ -159,26 +182,25 @@ void AnimatedSprite::animate(float delta_time) {
} }
auto AnimatedSprite::animationIsCompleted() -> bool { 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) { void AnimatedSprite::setCurrentAnimation(const std::string& name) {
auto it = animation_index_.find(name); if (current_animation_ == name) { return; }
if (it == animation_index_.end()) { auto it = animations_.find(name);
if (it == animations_.end()) {
std::cout << "** Warning: could not find \"" << name << "\" animation" << '\n'; std::cout << "** Warning: could not find \"" << name << "\" animation" << '\n';
return; return;
} }
setCurrentAnimation(it->second); current_animation_ = name;
} auto& anim = it->second;
anim.current_frame = 0;
void AnimatedSprite::setCurrentAnimation(int index) { anim.accumulated_time = 0.0F;
if (current_animation_ != index) { anim.completed = false;
current_animation_ = index; if (!anim.frames.empty()) { setClip(anim.frames[0]); }
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]);
}
} }
void AnimatedSprite::update(float delta_time) { void AnimatedSprite::update(float delta_time) {
@@ -187,15 +209,11 @@ void AnimatedSprite::update(float delta_time) {
} }
void AnimatedSprite::resetAnimation() { void AnimatedSprite::resetAnimation() {
animations_[current_animation_].current_frame = 0; if (current_animation_.empty()) { return; }
animations_[current_animation_].accumulated_time = 0.0F; auto it = animations_.find(current_animation_);
animations_[current_animation_].completed = false; if (it == animations_.end()) { return; }
} auto& anim = it->second;
anim.current_frame = 0;
void AnimatedSprite::setCurrentAnimationFrame(int num) { anim.accumulated_time = 0.0F;
if (num < 0 || num >= static_cast<int>(animations_[current_animation_].frames.size())) { anim.completed = false;
num = 0;
}
animations_[current_animation_].current_frame = num;
setClip(animations_[current_animation_].frames[animations_[current_animation_].current_frame]);
} }
@@ -11,11 +11,19 @@
class AnimatedSprite : public MovingSprite { class AnimatedSprite : public MovingSprite {
public: 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 { struct AnimationData {
std::string name;
std::vector<SDL_FRect> frames; 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; 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}; bool completed{false};
int current_frame{0}; int current_frame{0};
float accumulated_time{0.0F}; float accumulated_time{0.0F};
@@ -28,16 +36,12 @@ class AnimatedSprite : public MovingSprite {
auto animationIsCompleted() -> bool; auto animationIsCompleted() -> bool;
void setCurrentAnimation(const std::string& name = "default"); void setCurrentAnimation(const std::string& name);
void setCurrentAnimation(int index = 0);
void resetAnimation(); void resetAnimation();
void setCurrentAnimationFrame(int num);
void animate(float delta_time); void animate(float delta_time);
private: private:
void buildNameIndex(); std::unordered_map<std::string, AnimationData> animations_;
// Nom de l'animació activa. Buit fins que algú crida `setCurrentAnimation`.
std::vector<AnimationData> animations_; std::string current_animation_;
std::unordered_map<std::string, int> animation_index_;
int current_animation_{0};
}; };
+10 -5
View File
@@ -65,6 +65,16 @@ Director::Director() {
// Prepara la carpeta de sistema (~/.config/jailgames/aee_2026/) i carrega // Prepara la carpeta de sistema (~/.config/jailgames/aee_2026/) i carrega
// les opcions; si no existeix config.yaml el crea amb els defaults. // les opcions; si no existeix config.yaml el crea amb els defaults.
createSystemFolder(); 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"); Options::setConfigFile(system_folder_ + "config.yaml");
if (!Options::loadFromFile()) { if (!Options::loadFromFile()) {
std::cerr << "Director: no s'ha pogut carregar ni desar config.yaml a " << system_folder_ << '\n'; std::cerr << "Director: no s'ha pogut carregar ni desar config.yaml a " << system_folder_ << '\n';
@@ -121,11 +131,6 @@ Director::Director() {
// JA_Music_t que continga, així que no queden streams orfes. // JA_Music_t que continga, així que no queden streams orfes.
Audio::init(Options::buildAudioConfig()); 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 // Font compartida pels overlays: construïm una sola instància de Text
// i la injectem als consumidors (Notifier, RenderInfo) per a que cap // i la injectem als consumidors (Notifier, RenderInfo) per a que cap
// d'ells hagi de conèixer els noms dels recursos concrets. // d'ells hagi de conèixer els noms dels recursos concrets.
+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_;
};
-1
View File
@@ -306,4 +306,3 @@ void Mummy::onPlayerContact(Player* player) {
} }
REGISTER_ENTITY("mummy", Mummy) REGISTER_ENTITY("mummy", Mummy)
REGISTER_ENTITY_AS("momia", Mummy, Momia)
+21
View File
@@ -88,6 +88,14 @@ class Entity {
[[nodiscard]] auto collides() const -> bool { return collides_; } [[nodiscard]] auto collides() const -> bool { return collides_; }
[[nodiscard]] auto interactable() const -> bool { return interactable_; } [[nodiscard]] auto interactable() const -> bool { return interactable_; }
// 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. // Associar el bus una sola vegada (propietat externa, p.ex.
// `EntityManager`). Entitats que no publiquen events (Torch, trampes // `EntityManager`). Entitats que no publiquen events (Torch, trampes
// estàtiques de moment) el poden ignorar. // estàtiques de moment) el poden ignorar.
@@ -113,6 +121,17 @@ class Entity {
// Entitats sense animació retornen `nullopt` → frame 0 per defecte. // Entitats sense animació retornen `nullopt` → frame 0 per defecte.
[[nodiscard]] auto getAnimation() const -> const std::optional<AnimationComponent>& { return anim_; } [[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 // Caixa de col·lisió: per defecte és el `pixel_rect_` sencer (entitats
// AABB-aligned). Si s'ha configurat un `CollisionBox`, retorna el // AABB-aligned). Si s'ha configurat un `CollisionBox`, retorna el
// sub-rect dins del sprite — vida útil: validar col·lisions amb el // sub-rect dins del sprite — vida útil: validar col·lisions amb el
@@ -150,6 +169,8 @@ class Entity {
std::shared_ptr<Surface> surface_{nullptr}; std::shared_ptr<Surface> surface_{nullptr};
bool collides_{false}; bool collides_{false};
bool interactable_{false}; bool interactable_{false};
int height_{0};
bool dead_{false};
SDL_FlipMode flip_{SDL_FLIP_NONE}; SDL_FlipMode flip_{SDL_FLIP_NONE};
EventBus* bus_{nullptr}; EventBus* bus_{nullptr};
}; };
+4 -4
View File
@@ -45,10 +45,10 @@ class EntityFactory {
// La macro genera un objecte anònim dins un namespace anònim: el seu // 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 // 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 // del símbol es compon amb `TAG`, perquè així es poden registrar àlies
// per una mateixa classe (p.ex. "mummy" i "momia" → Mummy) sense que // per una mateixa classe (p.ex. dues claus diferents al YAML lligades a
// els símbols xoquen. La forma curta `REGISTER_ENTITY(NAME, CLASS)` // la mateixa classe C++) sense que els símbols xoquen. La forma curta
// reusa `CLASS` com a tag; per àlies cal usar `REGISTER_ENTITY_AS` i // `REGISTER_ENTITY(NAME, CLASS)` reusa `CLASS` com a tag; per àlies cal
// passar un identificador únic. // usar `REGISTER_ENTITY_AS` i passar un identificador únic.
// //
// IMPORTANT: perquè el registrador es cridi, el `.cpp` que el conté ha // 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 // d'estar enllaçat directament a l'executable (no dins una STATIC lib
+9
View File
@@ -64,7 +64,16 @@ auto EntityManager::addSarcophagus(std::unique_ptr<Sarcophagus> sarc) -> Sarcoph
} }
void EntityManager::update(float delta_time) { 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); } 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::notifyPlayerTilePainted(int col, int row) { void EntityManager::notifyPlayerTilePainted(int col, int row) {
@@ -63,8 +63,15 @@ void EntityManager::checkPlayerCollisions(const std::vector<Player*>& players) {
for (const auto& entity : entities_) { for (const auto& entity : entities_) {
if (!entity->collides()) { continue; } if (!entity->collides()) { continue; }
const auto BOX = entity->getCollisionRect(); const auto BOX = entity->getCollisionRect();
const int E_HEIGHT = entity->getHeight();
for (auto* p : players) { for (auto* p : players) {
if (p == nullptr) { continue; } if (p == nullptr) { 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 // Contacte actor-actor amb hit-box petita: la caixa base
// 16×16 servia per moure (xoc amb murs), però per a l'event // 16×16 servia per moure (xoc amb murs), però per a l'event
// "toca una entitat" volem precisió — que la momia mate quan // "toca una entitat" volem precisió — que la momia mate quan
@@ -109,6 +116,18 @@ namespace {
return info; 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]? }` // Bloc `collision: { size: [w, h], base_size: [bw, bh]?, offset: [x, y]? }`
// de la plantilla d'entitat. // de la plantilla d'entitat.
// - Sense `base_size` ni `offset`: la caja queda alineada a la vora // - Sense `base_size` ni `offset`: la caja queda alineada a la vora
@@ -212,6 +231,7 @@ namespace {
if (const auto BOX = readCollisionBox(tmpl, sprite_out.width, sprite_out.height); BOX.has_value()) { if (const auto BOX = readCollisionBox(tmpl, sprite_out.width, sprite_out.height); BOX.has_value()) {
entity->setCollisionBox(*BOX); entity->setCollisionBox(*BOX);
} }
entity->setHeight(readHeight(tmpl));
anchor_out = readAnchor(tmpl); anchor_out = readAnchor(tmpl);
return entity; return entity;
} }
+12 -1
View File
@@ -41,9 +41,17 @@ struct PaintedTile {
int tile_index{-1}; 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 { struct Event {
std::string name; std::string name;
using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos, PaintedTile>; using Payload = std::variant<std::monostate, int, std::string, Entity*, TilePos, PaintedTile, WorldPos>;
Payload payload; Payload payload;
// Jugador que ha originat (o provocat) l'event. -1 = sense jugador // Jugador que ha originat (o provocat) l'event. -1 = sense jugador
// associat (events del món, com `sarcophagus_opened` des de la pròpia // associat (events del món, com `sarcophagus_opened` des de la pròpia
@@ -90,6 +98,9 @@ class EventBus {
void publish(const std::string& name, PaintedTile painted) { void publish(const std::string& name, PaintedTile painted) {
publish(Event{.name = name, .payload = painted}); publish(Event{.name = name, .payload = painted});
} }
void publish(const std::string& name, WorldPos pos) {
publish(Event{.name = name, .payload = pos});
}
private: private:
struct Subscription { struct Subscription {
+714 -47
View File
@@ -3,12 +3,15 @@
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <array>
#include <cmath> #include <cmath>
#include <stdexcept> #include <stdexcept>
#include <utility> #include <utility>
#include "core/audio/audio.hpp"
#include "core/input/gamepad_manager.hpp" #include "core/input/gamepad_manager.hpp"
#include "core/input/input_state.hpp" #include "core/input/input_state.hpp"
#include "core/rendering/screen.hpp"
#include "core/resources/resource_cache.hpp" #include "core/resources/resource_cache.hpp"
#include "core/resources/resource_helper.hpp" #include "core/resources/resource_helper.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
@@ -25,6 +28,19 @@ namespace {
// repòs i no contribueix a la velocitat. // repòs i no contribueix a la velocitat.
constexpr Sint16 STICK_DEADZONE = 12000; constexpr Sint16 STICK_DEADZONE = 12000;
// Llegeix la mida del frame des del bloc `sprite: { path, size: [w, h] }`
// (mateix format que la resta de plantilles d'entitat, vegeu
// AnimatedSprite). Extracció per mantenir `Player::loadFrom` sota el
// llindar de complexitat cognitiva (25).
void readSpriteSize(const fkyaml::node& yaml, const std::string& yaml_path, int& w, int& h) {
if (!yaml.contains("sprite") || !yaml["sprite"].contains("size")) {
throw std::runtime_error("Player: manca `sprite.size` a " + yaml_path);
}
const auto& size_node = yaml["sprite"]["size"];
w = size_node[0].get_value<int>();
h = size_node[1].get_value<int>();
}
// Acumula la contribució del mando assignat al jugador (cruceta + // Acumula la contribució del mando assignat al jugador (cruceta +
// stick esquerre) sobre els acumuladors de velocitat. Extracció // stick esquerre) sobre els acumuladors de velocitat. Extracció
// d'`update()` per a mantenir la complexitat cognitiva sota llindar // d'`update()` per a mantenir la complexitat cognitiva sota llindar
@@ -49,6 +65,9 @@ namespace {
} // namespace } // namespace
Player::TurnConfig Player::turn_config; Player::TurnConfig Player::turn_config;
Player::InertiaConfig Player::inertia_config;
Player::JumpConfig Player::jump_config;
Player::HurtConfig Player::hurt_config;
void Player::loadFrom(const std::string& yaml_path) { void Player::loadFrom(const std::string& yaml_path) {
auto bytes = Resource::Helper::loadFile(yaml_path); auto bytes = Resource::Helper::loadFile(yaml_path);
@@ -59,9 +78,45 @@ void Player::loadFrom(const std::string& yaml_path) {
const auto* end = begin + bytes.size(); const auto* end = begin + bytes.size();
auto yaml = fkyaml::node::deserialize(begin, end); auto yaml = fkyaml::node::deserialize(begin, end);
sprite_w = yaml["frameWidth"].get_value<int>(); readSpriteSize(yaml, yaml_path, sprite_w, sprite_h);
sprite_h = yaml["frameHeight"].get_value<int>();
readCollisionBlock(yaml, yaml_path);
// Bloc opcional `inertia:` — si manca, `brake_time_ms` queda a 0 i
// la inèrcia de frenada no s'activa (parada instantània com abans).
inertia_config = InertiaConfig{};
if (yaml.contains("inertia")) {
const auto& i = yaml["inertia"];
if (i.contains("brake_time_ms")) { inertia_config.brake_time_ms = i["brake_time_ms"].get_value<int>(); }
if (i.contains("threshold_ms")) { inertia_config.threshold_ms = i["threshold_ms"].get_value<int>(); }
}
// Bloc opcional `turn:` — si manca, `duration_ms` queda a 0 i la
// mecànica del gir de 180° no s'activa (canvis de direcció instantanis).
turn_config = TurnConfig{};
if (yaml.contains("turn")) {
const auto& t = yaml["turn"];
if (t.contains("duration_ms")) { turn_config.duration_ms = t["duration_ms"].get_value<int>(); }
if (t.contains("transitions")) {
const auto& tr = t["transitions"];
if (tr.contains("west_to_east")) { turn_config.w_to_e = cardinalFromString(tr["west_to_east"].get_value<std::string>()); }
if (tr.contains("east_to_west")) { turn_config.e_to_w = cardinalFromString(tr["east_to_west"].get_value<std::string>()); }
if (tr.contains("north_to_south")) { turn_config.n_to_s = cardinalFromString(tr["north_to_south"].get_value<std::string>()); }
if (tr.contains("south_to_north")) { turn_config.s_to_n = cardinalFromString(tr["south_to_north"].get_value<std::string>()); }
}
}
// Bloc opcional `jump:` — si manca, `impulse_px_per_s` queda a 0 i el
// botó de salt no fa res. Extret a un mètode estàtic per acotar la
// complexitat cognitiva de `loadFrom` sota el llindar de clang-tidy.
readJumpBlock(yaml);
// Bloc opcional `hurt:` — estat post-impacte. Si manca o el speed
// és 0, l'estat HURT mai s'activa (cas degenerat).
readHurtBlock(yaml);
}
void Player::readCollisionBlock(const fkyaml::node& yaml, const std::string& yaml_path) {
if (!yaml.contains("collision")) { if (!yaml.contains("collision")) {
throw std::runtime_error("Player: manca bloc `collision:` a " + yaml_path); throw std::runtime_error("Player: manca bloc `collision:` a " + yaml_path);
} }
@@ -85,20 +140,39 @@ void Player::loadFrom(const std::string& yaml_path) {
hit_x_offset = collision_x_offset + HIT_INSET; hit_x_offset = collision_x_offset + HIT_INSET;
hit_y_offset = collision_y_offset + HIT_INSET; hit_y_offset = collision_y_offset + HIT_INSET;
// Bloc opcional `turn:` — si manca, `duration_ms` queda a 0 i la // Alçada del cos, llegida del mateix bloc per coherència amb la
// mecànica del gir de 180° no s'activa (canvis de direcció instantanis). // resta d'entitats (mummy, sarcophagus, etc.). 0 = sense filtre per
turn_config = TurnConfig{}; // altura — el salt no esquiva l'altre jugador.
if (yaml.contains("turn")) { body_height = c.contains("height") ? c["height"].get_value<int>() : 0;
const auto& t = yaml["turn"]; }
if (t.contains("duration_ms")) { turn_config.duration_ms = t["duration_ms"].get_value<int>(); }
if (t.contains("transitions")) { void Player::readJumpBlock(const fkyaml::node& yaml) {
const auto& tr = t["transitions"]; jump_config = JumpConfig{};
if (tr.contains("west_to_east")) { turn_config.w_to_e = cardinalFromString(tr["west_to_east"].get_value<std::string>()); } if (!yaml.contains("jump")) { return; }
if (tr.contains("east_to_west")) { turn_config.e_to_w = cardinalFromString(tr["east_to_west"].get_value<std::string>()); } const auto& j = yaml["jump"];
if (tr.contains("north_to_south")) { turn_config.n_to_s = cardinalFromString(tr["north_to_south"].get_value<std::string>()); } if (j.contains("impulse")) { jump_config.impulse_px_per_s = j["impulse"].get_value<float>(); }
if (tr.contains("south_to_north")) { turn_config.s_to_n = cardinalFromString(tr["south_to_north"].get_value<std::string>()); } if (j.contains("gravity")) { jump_config.gravity_px_per_s2 = j["gravity"].get_value<float>(); }
} if (j.contains("landing_stun_ms")) { jump_config.landing_stun_ms = j["landing_stun_ms"].get_value<int>(); }
if (j.contains("shadow_y_offset")) { jump_config.shadow_y_offset = j["shadow_y_offset"].get_value<int>(); }
// Sons opcionals: el nom del fitxer (relatiu a data/sounds/) val tant
// de clau al Resource::Cache com de basename per a la càrrega. Si el
// camp manca, la clau queda buida i `playSound` no es crida.
if (j.contains("sound")) {
jump_config.sound_key = j["sound"].get_value<std::string>();
Resource::Cache::get().loadSound(jump_config.sound_key, "data/sounds/" + jump_config.sound_key);
} }
if (j.contains("land_sound")) {
jump_config.land_sound_key = j["land_sound"].get_value<std::string>();
Resource::Cache::get().loadSound(jump_config.land_sound_key, "data/sounds/" + jump_config.land_sound_key);
}
}
void Player::readHurtBlock(const fkyaml::node& yaml) {
hurt_config = HurtConfig{};
if (!yaml.contains("hurt")) { return; }
const auto& h = yaml["hurt"];
if (h.contains("speed")) { hurt_config.initial_speed_px_per_s = h["speed"].get_value<float>(); }
if (h.contains("brake_time_ms")) { hurt_config.brake_time_ms = h["brake_time_ms"].get_value<int>(); }
} }
void Player::applyCardinal(Cardinal c) { void Player::applyCardinal(Cardinal c) {
@@ -261,20 +335,141 @@ auto Player::collidesWithWorld(const SDL_FRect& box) const -> bool {
// amb la hit-box actual de l'altre jugador. Així la caixa de moviment // amb la hit-box actual de l'altre jugador. Així la caixa de moviment
// (16×16) segueix topant amb murs/sarcs però els jugadors poden // (16×16) segueix topant amb murs/sarcs però els jugadors poden
// acostar-se fins al contacte real. // acostar-se fins al contacte real.
//
// Regla de col·lisió jugador↔jugador (segons el feel triat):
// - HURT a un costat o a l'altre → cap col·lisió. El jugador
// impactat es travessa net.
// - Tots dos a terra (PvP-tangibles) → col·lisió normal.
// - Un en l'aire i l'altre no → SENSE col·lisió. El saltador es
// travessa net del caminant; ningú actua de "muro" durant el
// vol, ni a la pujada ni a la baixada.
// - Tots dos en l'aire → col·lisió només si els rangs verticals
// [jump_z, jump_z + body_height] solapen. Així permetem el xoc
// mid-aire quan tots dos van un contra l'altre i estan a la
// mateixa franja d'alçada.
const SDL_FRect PROPOSED_HIT = hitBoxFromBase(box); const SDL_FRect PROPOSED_HIT = hitBoxFromBase(box);
return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT](const Player* p) { const bool MY_AIR = (state_ == State::AIRBORNE);
const bool MY_HURT = (state_ == State::HURT);
const float MY_Z = jump_z_;
return std::ranges::any_of(*all_players_, [this, &PROPOSED_HIT, MY_AIR, MY_HURT, MY_Z](const Player* p) {
if (p == nullptr || p == this) { return false; } if (p == nullptr || p == this) { return false; }
if (MY_HURT || p->isHurt()) { return false; }
if (MY_AIR != p->isAirborne()) { return false; }
// Aquí els dos estats coincideixen. Si tots dos volen, el filtre
// per altura mira que els rangs verticals encara solapin; si no,
// estan a prou distància en Y "real" i no es toquen.
if (MY_AIR && body_height > 0 && std::fabs(MY_Z - p->jump_z_) >= static_cast<float>(body_height)) {
return false;
}
const auto OTHER = p->getHitBox(); const auto OTHER = p->getHitBox();
return SDL_HasRectIntersectionFloat(&PROPOSED_HIT, &OTHER); return SDL_HasRectIntersectionFloat(&PROPOSED_HIT, &OTHER);
}); });
} }
void Player::integrateAxis(float& phys, float input, float delta_time, float motion_time_ms, bool any_input) {
// Acceleració instantània: mentre hi ha input, la velocitat salta a
// ±SPEED sense transició. Cobreix també els canvis de direcció (un
// coast cap a la dreta passa a -SPEED immediatament si es prem esquerra).
if (input != 0.0F) {
phys = input;
return;
}
if (phys == 0.0F) { return; }
// Hi ha velocitat residual i aquest eix no té input. Tres camins:
// · Input a l'altre eix (gir de 90°): parada en sec — no arrosseguem
// en diagonal mentre l'usuari està premant una altra direcció.
// · Input solta del tot i llindar superat: inèrcia (decaiment lineal).
// · Input solta del tot i llindar NO superat: parada en sec (passets).
if (any_input) {
phys = 0.0F;
return;
}
const bool INERTIA_ACTIVE = (inertia_config.brake_time_ms > 0) && (motion_time_ms >= static_cast<float>(inertia_config.threshold_ms));
if (!INERTIA_ACTIVE) {
phys = 0.0F;
return;
}
const float DECEL = SPEED_PX_PER_S / (static_cast<float>(inertia_config.brake_time_ms) / 1000.0F);
const float STEP = DECEL * delta_time;
if (std::fabs(phys) <= STEP) {
phys = 0.0F;
} else {
phys -= std::copysign(STEP, phys);
}
}
void Player::tick(float delta_time) { void Player::tick(float delta_time) {
// Reset per frame: el resolver del GameScene el tornarà a posar a // Reset per frame: el resolver del GameScene el tornarà a posar a
// true si aquest frame hi ha push actiu. Així quan el push acaba // true si aquest frame hi ha push actiu. Així quan el push acaba
// (un deixa d'empentar, l'altre comença a caminar), l'animació torna // (un deixa d'empentar, l'altre comença a caminar), l'animació torna
// a governar-se per la velocitat real sense estat romanent. // a governar-se per la velocitat real sense estat romanent.
being_pushed_ = false; being_pushed_ = false;
// En LANDING_STUN, descomptem el timer i ignorem qualsevol input
// (moviment i salt). Quan s'esgota, tornem a GROUNDED al pròxim
// frame. La velocitat queda a zero — després del stun hi tornarà
// a haver intenció si el jugador prem alguna tecla.
if (state_ == State::LANDING_STUN) {
landing_stun_remaining_ms_ -= delta_time * 1000.0F;
if (landing_stun_remaining_ms_ <= 0.0F) {
landing_stun_remaining_ms_ = 0.0F;
state_ = State::GROUNDED;
}
input_vx_ = 0.0F;
input_vy_ = 0.0F;
intended_vx_ = 0.0F;
intended_vy_ = 0.0F;
motion_time_ms_ = 0.0F;
setVelX(0.0F);
setVelY(0.0F);
// Mantenim els flancs del botó de salt actualitzats fins i tot
// durant el stun: així si l'usuari l'ha estat prement abans, no
// se'l consumeix com a salt nou en sortir del stun.
(void)consumeJumpEdge();
return;
}
// En AIRBORNE el jugador no llig input direccional: la velocitat
// horitzontal queda fixada al moment del despegue. Tampoc canvia
// facing. Sí actualitzem els flancs del botó perquè la propera
// pulsació quan torne a terra es detecte com a flanc nou.
if (state_ == State::AIRBORNE) {
(void)consumeJumpEdge();
setVelX(intended_vx_);
setVelY(intended_vy_);
return;
}
// En HURT el jugador està projectat sense control: descomptem
// velocitat per inèrcia (decaiment lineal fins a 0). Quan arriba a
// zero, tornem a GROUNDED i el frame següent ja torna a llegir input.
// El moviment real l'aplica `applyMovement` (igual que els altres
// estats), que també gestiona el xoc contra murs.
if (state_ == State::HURT) {
(void)consumeJumpEdge();
const float DECEL = (hurt_config.brake_time_ms > 0)
? hurt_config.initial_speed_px_per_s / (static_cast<float>(hurt_config.brake_time_ms) / 1000.0F)
: 0.0F;
const float STEP = DECEL * delta_time;
auto decay = [STEP](float& v) {
if (std::fabs(v) <= STEP) {
v = 0.0F;
} else {
v -= std::copysign(STEP, v);
}
};
decay(intended_vx_);
decay(intended_vy_);
if (intended_vx_ == 0.0F && intended_vy_ == 0.0F) {
state_ = State::GROUNDED;
}
input_vx_ = 0.0F;
input_vy_ = 0.0F;
setVelX(intended_vx_);
setVelY(intended_vy_);
return;
}
// Via `InputState` en lloc de `SDL_GetKeyboardState` directe: quan el // Via `InputState` en lloc de `SDL_GetKeyboardState` directe: quan el
// service menu (o qualsevol UI modal) està obert, el wrapper retorna // service menu (o qualsevol UI modal) està obert, el wrapper retorna
// un buffer tot-a-zero perquè el jugador no es mogui mentre l'usuari // un buffer tot-a-zero perquè el jugador no es mogui mentre l'usuari
@@ -292,15 +487,279 @@ void Player::tick(float delta_time) {
// els botons tornen false/0 i no contribueixen a la velocitat. // els botons tornen false/0 i no contribueixen a la velocitat.
accumulateGamepadMovement(id_, vx, vy); accumulateGamepadMovement(id_, vx, vy);
intended_vx_ = vx; input_vx_ = vx;
intended_vy_ = vy; input_vy_ = vy;
setVelX(vx);
setVelY(vy);
updateFacingAndTurn(delta_time, vx, vy); // Integració física per eix. `intended_v*` fa de memòria entre frames:
// si l'usuari té input, se sobreescriu a ±SPEED; si no, decau per
// inèrcia o es posa a 0. El `motion_time_ms_` que passem és el del
// final del frame anterior — serveix per decidir si la parada d'aquest
// frame és suau (llindar superat) o en sec. HAS_INPUT bloca la inèrcia
// en girs de 90° (un eix a 0 amb l'altre actiu no ha de "patinar").
const bool HAS_INPUT = (input_vx_ != 0.0F) || (input_vy_ != 0.0F);
integrateAxis(intended_vx_, input_vx_, delta_time, motion_time_ms_, HAS_INPUT);
integrateAxis(intended_vy_, input_vy_, delta_time, motion_time_ms_, HAS_INPUT);
// Actualitzem l'acumulador DESPRÉS de la integració. Creix mentre hi
// haja intenció (input) o moviment residual (coast); només es buida
// quan el jugador està genuïnament aturat (ni input ni velocitat). Així
// un canvi de direcció enmig del coast o un toc curt contra el coast
// no rearma mai el llindar.
const bool HAS_VELOCITY = (intended_vx_ != 0.0F) || (intended_vy_ != 0.0F);
if (HAS_INPUT || HAS_VELOCITY) {
motion_time_ms_ += delta_time * 1000.0F;
} else {
motion_time_ms_ = 0.0F;
}
setVelX(intended_vx_);
setVelY(intended_vy_);
// Important: el facing i la detecció de gir de 180° s'alimenten amb
// input CRU, no amb la velocitat física. Un coast sense input NO
// canvia el facing (la darrera orientació es preserva), i els girs
// només disparen amb intenció explícita del jugador.
updateFacingAndTurn(delta_time, input_vx_, input_vy_);
// Detecció de flanc del botó de salt. Es processa DESPRÉS d'aplicar
// el facing perquè el sprite del salt usa el facing del moment del
// despegue. `being_pushed_` no ho pot bloquejar ací (encara no s'ha
// marcat aquest frame, ho fa el resolver entre tick i applyMovement);
// ho gestionem deixant que el resolver vinga, i si en el moment del
// despegue (consumeJumpEdge → startJump) ja estem AIRBORNE, l'altre
// braç del switch no canvia res.
if (consumeJumpEdge() && jump_config.impulse_px_per_s > 0.0F) {
startJump();
}
}
auto Player::consumeJumpEdge() -> bool {
// Edge-detection independent per teclat i mando: si qualsevol dels
// dos passa de no-premut a premut aquest frame, comptem com a salt.
// Sense detecció de flanc, mantenir la tecla provocaria saltar
// immediatament en aterrar.
const bool* keys = InputState::getKeyboardState();
const bool KEY_NOW = keys[controls_.jump];
bool edge = (KEY_NOW && !jump_held_keyboard_);
jump_held_keyboard_ = KEY_NOW;
SDL_Gamepad* pad = GamepadManager::get().getForPlayer(id_);
const bool PAD_NOW = (pad != nullptr) && InputState::getGamepadButton(pad, SDL_GAMEPAD_BUTTON_SOUTH);
if (PAD_NOW && !jump_held_gamepad_) { edge = true; }
jump_held_gamepad_ = PAD_NOW;
return edge;
}
void Player::startJump() {
// No saltem si l'estat no ho permet. `being_pushed_` no és segur de
// consultar ací (es pot haver marcat o no, segons l'ordre del frame),
// així que la regla és: només GROUNDED arrenca salts. El push viu
// entre `tick` i `applyMovement`, així que si el resolver acaba
// empentant en mig d'un frame on intentàvem saltar, el flanc es perd
// — comportament acceptable: l'empenta guanya al toc curt.
if (state_ != State::GROUNDED) { return; }
// Un gir de 180° en curs es compromet immediatament: si l'usuari
// ha disparat un giro (oest→est, p.ex.) i salta abans que s'esgote
// el timer, l'animació del salt ha de mostrar la direcció final
// (jump lateral), no la intermèdia (jump_down). Aplem el pending
// ara mateix i resetem l'estat del gir. Si no hi havia gir en
// curs, és no-op.
if (turning_remaining_ms_ > 0.0F) {
applyCardinal(pending_cardinal_);
turning_remaining_ms_ = 0.0F;
pending_cardinal_ = Cardinal::NONE;
intermediate_cardinal_ = Cardinal::NONE;
}
state_ = State::AIRBORNE;
jump_z_ = 0.0F;
jump_vz_ = jump_config.impulse_px_per_s;
if (!jump_config.sound_key.empty() && Audio::get() != nullptr) {
Audio::get()->playSound(jump_config.sound_key, Audio::Group::GAME);
}
// Avisem al bus perquè el GameScene puga spawnejar el polvet de
// despegue al peu del jugador.
if (bus_ != nullptr) {
bus_->publish(Event{
.name = "player_jumped",
.payload = WorldPos{.x = getPosX() + (static_cast<float>(sprite_w) * 0.5F), .y = getGroundFeetY()},
.player_id = id_,
});
}
// Prou: la velocitat horitzontal del salt és la `intended_v*` actual
// (input del frame). El facing també queda com està. Ja no es
// tocaran fins a aterrar.
}
auto Player::deltasFor(Cardinal c) -> std::pair<float, float> {
switch (c) {
case Cardinal::EAST:
return {1.0F, 0.0F};
case Cardinal::WEST:
return {-1.0F, 0.0F};
case Cardinal::NORTH:
return {0.0F, -1.0F};
case Cardinal::SOUTH:
return {0.0F, 1.0F};
case Cardinal::NONE:
break;
}
return {0.0F, 0.0F};
}
auto Player::reachInDirection(float dx, float dy, int max_px) const -> int {
const auto BASE = getBaseBox();
for (int i = 1; i <= max_px; ++i) {
SDL_FRect probe = BASE;
probe.x += static_cast<float>(i) * dx;
probe.y += static_cast<float>(i) * dy;
if (map_.collides(probe) || !map_.inside_playfield(probe)) { return i - 1; }
}
return max_px;
}
auto Player::chooseHurtDirection(Cardinal preferred) const -> Cardinal {
constexpr int MAX_PX = 32; // 2 tiles, prou per a la cerca; la inèrcia limita el recorregut real.
// Recorregut natural del HURT (distància que recórreria sota
// decaiment lineal pur). Si la direcció preferida cabe sense topar
// abans d'aquesta distància, l'usem; d'altra manera, busquem la
// millor.
const auto REQUIRED = static_cast<int>(
0.5F * hurt_config.initial_speed_px_per_s * (static_cast<float>(hurt_config.brake_time_ms) / 1000.0F));
if (preferred != Cardinal::NONE) {
const auto [DX, DY] = deltasFor(preferred);
if (reachInDirection(DX, DY, MAX_PX) >= REQUIRED) { return preferred; }
}
constexpr std::array<Cardinal, 4> ALL{Cardinal::EAST, Cardinal::WEST, Cardinal::NORTH, Cardinal::SOUTH};
int best_reach = -1;
Cardinal best_dir = Cardinal::EAST;
for (Cardinal c : ALL) {
const auto [DX, DY] = deltasFor(c);
const int R = reachInDirection(DX, DY, MAX_PX);
if (R > best_reach) {
best_reach = R;
best_dir = c;
}
}
return best_dir;
}
void Player::enterHurt(Cardinal preferred) {
// Si la mecànica no està configurada (speed = 0), no fem res — el
// comportament queda equivalent al d'abans, sense empenta.
if (hurt_config.initial_speed_px_per_s <= 0.0F || hurt_config.brake_time_ms <= 0) { return; }
const Cardinal CHOSEN = chooseHurtDirection(preferred);
// Activem l'estat i fixem la velocitat segons la direcció triada.
state_ = State::HURT;
applyCardinal(CHOSEN);
last_input_cardinal_ = CHOSEN;
turning_remaining_ms_ = 0.0F;
pending_cardinal_ = Cardinal::NONE;
intermediate_cardinal_ = Cardinal::NONE;
being_pushed_ = false;
const float V = hurt_config.initial_speed_px_per_s;
const auto [DX, DY] = deltasFor(CHOSEN);
intended_vx_ = DX * V;
intended_vy_ = DY * V;
input_vx_ = 0.0F;
input_vy_ = 0.0F;
motion_time_ms_ = 0.0F;
}
auto Player::integrateJump(float delta_time) -> bool {
if (state_ != State::AIRBORNE) { return false; }
jump_vz_ -= jump_config.gravity_px_per_s2 * delta_time;
jump_z_ += jump_vz_ * delta_time;
if (jump_z_ <= 0.0F) {
jump_z_ = 0.0F;
jump_vz_ = 0.0F;
state_ = State::LANDING_STUN;
landing_stun_remaining_ms_ = static_cast<float>(jump_config.landing_stun_ms);
if (!jump_config.land_sound_key.empty() && Audio::get() != nullptr) {
Audio::get()->playSound(jump_config.land_sound_key, Audio::Group::GAME);
}
if (bus_ != nullptr) {
bus_->publish(Event{
.name = "player_landed",
.payload = WorldPos{.x = getPosX() + (static_cast<float>(sprite_w) * 0.5F), .y = getGroundFeetY()},
.player_id = id_,
});
}
return true;
}
return false;
}
void Player::resolveLandingOverlap() {
// Quan el saltador (this) aterra al damunt d'un altre jugador, el
// saltador NO es desplaça — es queda al lloc en LANDING_STUN. En
// canvi, qualsevol jugador amb el qual solape entra en HURT i
// surt projectat. Així el solapament inicial es resol per moviment
// del solapat, no per desplaçament forçat al lloc.
if (all_players_ == nullptr) { return; }
for (Player* other : *all_players_) {
if (other == nullptr || other == this) { continue; }
// Test amb la hit-box (8×8 de contacte precís), no la base-box.
// Coherent amb la resta de col·lisions actor-actor del joc:
// momies, fuetades futures, resolver PvP… tots usen hit-box. Un
// aterratge "rozant" (cossos solapats però els punts de contacte
// separats) no dispara HURT — el frame següent els dos estan a
// terra i la col·lisió normal els gestiona.
const SDL_FRect MY_HIT = getHitBox();
const SDL_FRect OT_HIT = other->getHitBox();
if (!SDL_HasRectIntersectionFloat(&MY_HIT, &OT_HIT)) { continue; }
// Direcció preferida del HURT: cap on l'altre està respecte a
// nosaltres (cardinal dominant del vector centre→centre this →
// other). Si caem a la dreta del seu cos, ell està a l'esquerra
// nostra → preferred = WEST → surt cap a l'esquerra. Si caem
// exactament al damunt (centres alineats), preferred queda
// NONE i `enterHurt` busca la millor direcció lliure.
const float MCX = MY_HIT.x + (MY_HIT.w * 0.5F);
const float MCY = MY_HIT.y + (MY_HIT.h * 0.5F);
const float OCX = OT_HIT.x + (OT_HIT.w * 0.5F);
const float OCY = OT_HIT.y + (OT_HIT.h * 0.5F);
const float DX = OCX - MCX;
const float DY = OCY - MCY;
Cardinal preferred = Cardinal::NONE;
if (std::fabs(DX) >= std::fabs(DY) && DX != 0.0F) {
preferred = (DX > 0.0F) ? Cardinal::EAST : Cardinal::WEST;
} else if (DY != 0.0F) {
preferred = (DY > 0.0F) ? Cardinal::SOUTH : Cardinal::NORTH;
}
other->enterHurt(preferred);
}
} }
void Player::applyMovement(float delta_time) { void Player::applyMovement(float delta_time) {
// Integració del salt: si estem AIRBORNE, avança jump_z_/jump_vz_.
// El facing i intended_v* es mantenen del moment del despegue (no es
// recalculen). Si toquem el sòl aquest frame, transicionem a
// LANDING_STUN i resolem el possible solapament amb un altre
// jugador que estigués sota — apartem-lo (o, si no es pot, ens
// apartem nosaltres).
if (integrateJump(delta_time)) {
resolveLandingOverlap();
}
if (state_ == State::HURT) {
applyHurtMovement(delta_time);
return;
}
if (state_ == State::LANDING_STUN) {
applyLandingStunMovement(delta_time);
return;
}
// El resolver del GameScene pot haver escalat `intended_vx_/vy_` // El resolver del GameScene pot haver escalat `intended_vx_/vy_`
// (cas push: velocitat * push_factor) o haver-les anul·lat (cas // (cas push: velocitat * push_factor) o haver-les anul·lat (cas
// head-on). Consumim-les aquí com a velocitat definitiva del frame. // head-on). Consumim-les aquí com a velocitat definitiva del frame.
@@ -309,9 +768,12 @@ void Player::applyMovement(float delta_time) {
// Snap al passadís: si només hi ha moviment horitzontal (o vertical) i // Snap al passadís: si només hi ha moviment horitzontal (o vertical) i
// la caixa està prou prop d'una lane perpendicular (≤ mitja caixa), la // la caixa està prou prop d'una lane perpendicular (≤ mitja caixa), la
// Y (o X) es corregeix gradualment per enfilar el passadís. // Y (o X) es corregeix gradualment per enfilar el passadís. En l'aire
const bool ONLY_H = (VX != 0.0F) && (VY == 0.0F); // no fem snap: la trajectòria queda fixada al despegue, que el snap
const bool ONLY_V = (VY != 0.0F) && (VX == 0.0F); // mid-flight desviaria.
const bool AIRBORNE = (state_ == State::AIRBORNE);
const bool ONLY_H = !AIRBORNE && (VX != 0.0F) && (VY == 0.0F);
const bool ONLY_V = !AIRBORNE && (VY != 0.0F) && (VX == 0.0F);
const float FRAME_START_X = getPosX(); const float FRAME_START_X = getPosX();
const float FRAME_START_Y = getPosY(); const float FRAME_START_Y = getPosY();
@@ -339,31 +801,161 @@ void Player::applyMovement(float delta_time) {
const bool MOVED = (getPosX() != FRAME_START_X) || (getPosY() != FRAME_START_Y); const bool MOVED = (getPosX() != FRAME_START_X) || (getPosY() != FRAME_START_Y);
// `being_pushed_` força `stand_*`: el jugador es desplaça sense input, // `being_pushed_` força `stand_*`: el jugador es desplaça sense input,
// animar-lo com a caminant faria creure que està caminant quan de // animar-lo com a caminant faria creure que està caminant quan de
// fet és arrossegat per l'altre. // fet és arrossegat per l'altre. AIRBORNE força `jump_*` (vegeu
// `animationName`), independentment de la velocitat.
const bool ANIM_AS_MOVING = MOVED && (turning_remaining_ms_ <= 0.0F) && !being_pushed_; const bool ANIM_AS_MOVING = MOVED && (turning_remaining_ms_ <= 0.0F) && !being_pushed_;
setCurrentAnimation(animationName(ANIM_AS_MOVING)); setCurrentAnimation(animationName(ANIM_AS_MOVING));
// Detecció de canvi de tile. Es calcula *després* del clamp i de les // Detecció de canvi de tile. Només publiquem en GROUNDED — en
// col·lisions perquè la posició aquí ja és la definitiva del frame; // AIRBORNE el jugador no trepitja, i en HURT/LANDING_STUN les
// així el PaintLayer no rep falsos positius mentre el Player rellisca // pròpies branques del switch ja crida la helper si toca.
// contra una paret. Si el tile ha canviat, publiquem `on_tile_entered` if (state_ == State::GROUNDED) { publishTileEnteredIfChanged(); }
// al bus amb l'índex de pintat d'aquest jugador; el PaintLayer filtra }
// pintabilitat i re-publica `tile_painted` als sarcòfags.
if (bus_ != nullptr) { void Player::applyHurtMovement(float delta_time) {
const auto [COL, ROW] = getCurrentTile(); // En HURT el jugador es desplaça segons `intended_v*` (escrit a
if (COL != last_tile_col_ || ROW != last_tile_row_) { // `enterHurt` i decremental a `tick`). Si `tryMove` no avança
last_tile_col_ = COL; // perquè topa amb un mur, el parem en sec i tornem a GROUNDED —
last_tile_row_ = ROW; // honra la regla "salvo col·lisió, llavors para".
bus_->publish(Event{ animate(delta_time);
.name = "on_tile_entered", const float FRAME_START_X = getPosX();
.payload = PaintedTile{.col = COL, .row = ROW, .tile_index = paint_tile_index_}, const float FRAME_START_Y = getPosY();
.player_id = id_, tryMove(intended_vx_ * delta_time, intended_vy_ * delta_time, [this](const SDL_FRect& box) {
}); return collidesWithWorld(box);
} });
clampToPlayfield();
const bool MOVED = (getPosX() != FRAME_START_X) || (getPosY() != FRAME_START_Y);
const bool WANTED_TO_MOVE = (intended_vx_ != 0.0F) || (intended_vy_ != 0.0F);
if (!MOVED && WANTED_TO_MOVE) {
// Bloquejat per un mur → parem en sec i recuperem el control.
intended_vx_ = 0.0F;
intended_vy_ = 0.0F;
state_ = State::GROUNDED;
} }
setCurrentAnimation(animationName(true));
publishTileEnteredIfChanged();
}
void Player::applyLandingStunMovement(float delta_time) {
// En LANDING_STUN no movem (la velocitat ja és 0). Animem per
// mantenir el frame del stun i emetem la tile d'aterratge perquè
// es pinte.
animate(delta_time);
setCurrentAnimation(animationName(false));
publishTileEnteredIfChanged();
}
void Player::publishTileEnteredIfChanged() {
if (bus_ == nullptr) { return; }
const auto [COL, ROW] = getCurrentTile();
if (COL == last_tile_col_ && ROW == last_tile_row_) { return; }
last_tile_col_ = COL;
last_tile_row_ = ROW;
bus_->publish(Event{
.name = "on_tile_entered",
.payload = PaintedTile{.col = COL, .row = ROW, .tile_index = paint_tile_index_},
.player_id = id_,
});
}
void Player::render() {
// Mentre el jugador està enlaire, el sprite es desplaça visualment
// cap amunt `jump_z_` píxels. Modifiquem `pos_.y` (la posició entera
// de render) abans de cridar el render base, i la restaurem després
// perquè l'estat seguent del frame ho recalcule des de `y_` (la font
// de veritat float). El sòl sota el jugador no es pinta des d'ací:
// això ho fa el GameScene en una passada de sombres a banda.
if (jump_z_ <= 0.0F) {
AnimatedSprite::render();
return;
}
const float SAVED_Y = pos_.y;
pos_.y = SAVED_Y - jump_z_;
AnimatedSprite::render();
pos_.y = SAVED_Y;
}
void Player::renderShadow() const {
// Sense salt actiu, no hi ha sombra: això simplifica els callsites,
// que poden cridar `renderShadow` per a tots els jugadors sense
// discriminar.
if (!isAirborne()) { return; }
// Sombra centrada al sprite, recolzada a la línia dels peus reals
// (ignorant `jump_z_`). L'amplada es redueix proporcional a l'altura
// del salt — al pic queda més estreta, donant la sensació de
// "elevació" sense necessitat de coordenades 3D reals. Si el
// jump_config té un divisor zero (cas degenerat), només dibuixem
// la sombra a mida plena per evitar nan.
const auto PEAK = (jump_config.gravity_px_per_s2 > 0.0F)
? (jump_config.impulse_px_per_s * jump_config.impulse_px_per_s) / (2.0F * jump_config.gravity_px_per_s2)
: 0.0F;
const float NORM_Z = (PEAK > 0.0F) ? std::clamp(jump_z_ / PEAK, 0.0F, 1.0F) : 0.0F;
constexpr float MAX_SHRINK = 0.4F; // Fins a un 40% més estreta al pic.
const float SHRINK = 1.0F - (NORM_Z * MAX_SHRINK);
constexpr int SHADOW_H = 3;
const auto SHADOW_W = static_cast<int>(static_cast<float>(base_size) * SHRINK);
const float CX = getPosX() + (static_cast<float>(sprite_w) * 0.5F);
const float GROUND_Y = getGroundFeetY();
const auto SX = static_cast<int>(CX - (static_cast<float>(SHADOW_W) * 0.5F));
// Pugem la sombra `shadow_y_offset` px per compensar la franja
// transparent que el sprite del jugador té sota els peus reals
// (perquè la silueta camine pel mig del passadís en falsa
// perspectiva). Sense aquest ajust, la sombra apareix per sota
// del passadís.
const auto SY = static_cast<int>(GROUND_Y) - SHADOW_H - jump_config.shadow_y_offset;
// ARGB negre amb alpha ~40%. `blendRect` mescla sobre el canvas; així
// la sombra fosqueja el sòl (pintat o no) sense tapar-lo del tot.
constexpr std::uint32_t SHADOW_ARGB = 0x00000000U;
constexpr std::uint8_t SHADOW_ALPHA = 100;
Screen::get().blendRect(SX, SY, SHADOW_W, SHADOW_H, SHADOW_ARGB, SHADOW_ALPHA);
} }
auto Player::animationName(bool moving) const -> const char* { auto Player::animationName(bool moving) const -> const char* {
// En l'aire forcem la variant `jump_*` (per direcció), independent de
// si hi ha velocitat horitzontal: per al jugador, l'estat visual del
// salt és per direcció, no per velocitat.
if (state_ == State::AIRBORNE) {
switch (facing_) {
case Facing::UP:
return "jump_up";
case Facing::DOWN:
return "jump_down";
case Facing::SIDE:
break;
}
return "jump";
}
// En LANDING_STUN, `stun_*` (per direcció): mostrem el frame
// d'aterratge fins que s'esgota el timer. La direcció ve fixada del
// moment del despegue (no es canvia mentre estem en l'aire).
if (state_ == State::LANDING_STUN) {
switch (facing_) {
case Facing::UP:
return "stun_up";
case Facing::DOWN:
return "stun_down";
case Facing::SIDE:
break;
}
return "stun";
}
// En HURT, `hurt_*` (per direcció): el jugador llisca sense
// control en la direcció triada per `enterHurt`. Mateixa estructura
// que walk per facilitat (ara mateix els frames són clons).
if (state_ == State::HURT) {
switch (facing_) {
case Facing::UP:
return "hurt_up";
case Facing::DOWN:
return "hurt_down";
case Facing::SIDE:
break;
}
return "hurt";
}
switch (facing_) { switch (facing_) {
case Facing::UP: case Facing::UP:
return moving ? "walk_up" : "stand_up"; return moving ? "walk_up" : "stand_up";
@@ -450,12 +1042,87 @@ void Player::snapToLane(
const float LANE = base + (static_cast<float>(IDX) * pitch); const float LANE = base + (static_cast<float>(IDX) * pitch);
const float DELTA = LANE - (this->*get_pos)(); const float DELTA = LANE - (this->*get_pos)();
if (std::abs(DELTA) > 0.0F && std::abs(DELTA) <= THRESHOLD) { if (std::abs(DELTA) <= 0.0F || std::abs(DELTA) > THRESHOLD) { return; }
const float STEP = std::min(std::abs(DELTA), SPEED_PX_PER_S * delta_time);
const float SAVED = (this->*get_pos)(); const float FULL_STEP = std::min(std::abs(DELTA), SPEED_PX_PER_S * delta_time);
(this->*set_pos)(SAVED + std::copysign(STEP, DELTA)); const float SAVED = (this->*get_pos)();
if (map_.collides(baseBox())) { (this->*set_pos)(SAVED); }
// Fase 1: snap a velocitat plena. Si no xoca amb res, commit — és el
// cas habitual (carril lliure) i no ha de patir la friccó de l'empenta.
(this->*set_pos)(SAVED + std::copysign(FULL_STEP, DELTA));
if (!collidesWithWorld(baseBox())) { return; }
// Mur al carril objectiu: no hi ha ningú a qui empentar, revert directe.
if (map_.collides(baseBox())) {
(this->*set_pos)(SAVED);
return;
} }
// Fase 2: sols un altre jugador al carril. Reintentem amb step escalat
// per `push_factor_` (mateixa friccó que el push horitzontal del
// resolver) i mirem d'arrossegar els blockers. Si la pushada no cap
// (contra mur o tercer jugador), el snap es revertix sencer.
const float PUSH_STEP = std::min(std::abs(DELTA), SPEED_PX_PER_S * push_factor_ * delta_time);
const float SIGNED_PUSH = std::copysign(PUSH_STEP, DELTA);
(this->*set_pos)(SAVED + SIGNED_PUSH);
if (collidesWithWorld(baseBox())) {
if (map_.collides(baseBox()) || !tryPushSnapBlockers(SIGNED_PUSH, get_pos, set_pos)) {
(this->*set_pos)(SAVED);
}
}
}
auto Player::tryPushSnapBlockers(
float signed_step,
float (Player::*get_pos)() const,
void (Player::*set_pos)(float)) -> bool {
if (all_players_ == nullptr) { return false; }
// Blockers = jugadors que ara mateix solapen la nostra hit-box (ja
// amb el snap aplicat). Mateix criteri actor-actor que `collidesWithWorld`.
std::vector<Player*> blockers;
const SDL_FRect MY_HIT = getHitBox();
for (Player* p : *all_players_) {
if (p == nullptr || p == this) { continue; }
const SDL_FRect OTHER_HIT = p->getHitBox();
if (SDL_HasRectIntersectionFloat(&MY_HIT, &OTHER_HIT)) {
blockers.push_back(p);
}
}
if (blockers.empty()) { return false; }
// Desem la posició original de cada blocker i el desplacem el mateix
// step que s'ha aplicat a `this`: com que els dos es mouen igual en el
// mateix eix, la distància relativa es conserva i, per tant, si abans
// del snap no es solapaven, després de l'empenta tampoc.
std::vector<float> saved;
saved.reserve(blockers.size());
for (Player* p : blockers) {
const float S = (p->*get_pos)();
saved.push_back(S);
(p->*set_pos)(S + signed_step);
}
// Validació: `this` ha de quedar net (ni mur ni cap jugador), i cada
// blocker també (cobreix el cas de 3+ jugadors on un blocker podria
// acabar dins d'un tercer o contra un mur).
const bool OK = !collidesWithWorld(baseBox()) && std::ranges::none_of(blockers, [](const Player* p) {
return p->collidesWithWorld(p->getBaseBox());
});
if (!OK) {
for (size_t i = 0; i < blockers.size(); ++i) {
(blockers[i]->*set_pos)(saved[i]);
}
return false;
}
// Els blockers s'han desplaçat sense input propi: mateixa convenció
// d'animació que el push horitzontal del resolver (stand_*, no walk).
for (Player* p : blockers) {
p->setBeingPushed(true);
}
return true;
} }
void Player::snapToHorizontalLane(float delta_time) { void Player::snapToHorizontalLane(float delta_time) {
+250 -1
View File
@@ -8,6 +8,7 @@
#include <vector> #include <vector>
#include "core/rendering/sprite/animated_sprite.hpp" #include "core/rendering/sprite/animated_sprite.hpp"
#include "external/fkyaml_node.hpp"
#include "game/map/map.hpp" #include "game/map/map.hpp"
class EventBus; class EventBus;
@@ -22,6 +23,27 @@ class Player : public AnimatedSprite {
SDL_Scancode down; SDL_Scancode down;
SDL_Scancode left; SDL_Scancode left;
SDL_Scancode right; SDL_Scancode right;
SDL_Scancode jump;
};
// 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,
}; };
// Geometria carregada des del YAML del player (p.ex. `data/player/sam.yaml`). // Geometria carregada des del YAML del player (p.ex. `data/player/sam.yaml`).
@@ -51,6 +73,12 @@ class Player : public AnimatedSprite {
inline static int hit_x_offset = 0; inline static int hit_x_offset = 0;
inline static int hit_y_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. // Ha de cridar-se abans d'instanciar Player; GameScene ho fa al ctor.
static void loadFrom(const std::string& yaml_path); static void loadFrom(const std::string& yaml_path);
@@ -84,6 +112,8 @@ class Player : public AnimatedSprite {
// GameScene els toca entre `tick` i `applyMovement` per implementar // GameScene els toca entre `tick` i `applyMovement` per implementar
// col·lisions jugador↔jugador (escalar a push_factor, anul·lar en // col·lisions jugador↔jugador (escalar a push_factor, anul·lar en
// head-on). Expressades en píxels/segon, no en píxels/frame. // 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 getIntendedVX() const -> float { return intended_vx_; }
[[nodiscard]] auto getIntendedVY() const -> float { return intended_vy_; } [[nodiscard]] auto getIntendedVY() const -> float { return intended_vy_; }
void setIntendedVelocity(float vx, float vy) { void setIntendedVelocity(float vx, float vy) {
@@ -91,6 +121,15 @@ class Player : public AnimatedSprite {
intended_vy_ = vy; 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 — // Llista de tots els jugadors del nivell (incloent-se ell mateix —
// la callback ignora `this`). La caixa del jugador es considera un // 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 // sòlid a la callback de col·lisió: així `tryMove` no permet que un
@@ -106,13 +145,49 @@ class Player : public AnimatedSprite {
// posar cada frame que el push s'haja de mantenir. // posar cada frame que el push s'haja de mantenir.
void setBeingPushed(bool pushed) { being_pushed_ = pushed; } 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 // 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 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 { [[nodiscard]] auto getFeetY() const -> float {
return getPosY() + static_cast<float>(collision_y_offset + collision_size); 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; }
// Tangible per a contactes jugador↔jugador: només els estats a terra
// amb cos sòlid. AIRBORNE i HURT són intangibles (es travessen).
[[nodiscard]] auto isPvpTangible() const -> bool {
return state_ == State::GROUNDED || state_ == State::LANDING_STUN;
}
// 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 // 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 // vegada que el centre de la seua caixa de col·lisió entra en una
// cel·la nova del tilemap, amb payload `PaintedTile{col,row,paint_tile_index_}`. // cel·la nova del tilemap, amb payload `PaintedTile{col,row,paint_tile_index_}`.
@@ -147,6 +222,18 @@ class Player : public AnimatedSprite {
// bloquejar el moviment). // bloquejar el moviment).
[[nodiscard]] auto collisionBox() const -> SDL_FRect override { return getBaseBox(); } [[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: private:
// Alias privat històric. Mantingut perquè viu dins de l'implementació // Alias privat històric. Mantingut perquè viu dins de l'implementació
// (snapToLane, clamp). Els consumidors externs usen `getBaseBox()`. // (snapToLane, clamp). Els consumidors externs usen `getBaseBox()`.
@@ -180,15 +267,44 @@ class Player : public AnimatedSprite {
float (Player::*get_pos)() const, float (Player::*get_pos)() const,
void (Player::*set_pos)(float)); 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}; int id_{-1};
Controls controls_; Controls controls_;
// Velocitat intencionada calculada a `tick` i aplicada a // Velocitat intencionada calculada a `tick` i aplicada a
// `applyMovement`. El GameScene pot sobreescriure-la entremig per // `applyMovement`. El GameScene pot sobreescriure-la entremig per
// resoldre interaccions jugador↔jugador (empentes, head-on). // 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_vx_{0.0F};
float intended_vy_{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 // Punter a la llista de tots els jugadors del nivell (propietat del
// GameScene). `nullptr` = jugador aïllat, sense col·lisió inter-P. // GameScene). `nullptr` = jugador aïllat, sense col·lisió inter-P.
const std::vector<Player*>* all_players_{nullptr}; const std::vector<Player*>* all_players_{nullptr};
@@ -197,6 +313,11 @@ class Player : public AnimatedSprite {
// Serveix només per a l'elecció d'animació — la posició ja la // Serveix només per a l'elecció d'animació — la posició ja la
// gestiona `intended_vx_/vy_` escalats pel resolver. // gestiona `intended_vx_/vy_` escalats pel resolver.
bool being_pushed_{false}; 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`/ // Còpia plana de la geometria del mapa + punters a `collides`/
// `inside_playfield`. POD trivial; copiada al ctor i no muta durant // `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 // la vida del Player. Vol dir que aquest Player només és vàlid per
@@ -223,6 +344,24 @@ class Player : public AnimatedSprite {
EAST, EAST,
WEST }; 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 // 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 // player. Si `duration_ms` és 0 (o el bloc no existeix) la mecànica
// queda desactivada i els canvis de direcció són instantanis. // queda desactivada i els canvis de direcció són instantanis.
@@ -237,6 +376,116 @@ class Player : public AnimatedSprite {
// per defecte de TurnConfig encara no són visibles per al compilador. // per defecte de TurnConfig encara no són visibles per al compilador.
static TurnConfig turn_config; 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/ // 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 // 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, // a `stand` encara que el moviment continuï. En esgotar-se el timer,
+5 -6
View File
@@ -176,12 +176,11 @@ namespace {
parseGamepadAssignment(node, "gamepad_p2", g_controls.gamepad_p2); parseGamepadAssignment(node, "gamepad_p2", g_controls.gamepad_p2);
} }
// Aplica les assignacions persistides al GamepadManager. Cridat // Aplica les assignacions persistides al GamepadManager. Ordre garantit
// després de `loadFromFile` perquè l'ordre és: GamepadManager::init // pel Director: GamepadManager::init (obre tots els mandos físics) →
// (obre tots els mandos físics) → Options::loadFromFile (llig YAML) // Options::loadFromFile (llig YAML) → aquesta funció (casa
// → aquesta funció (casa mandos↔jugadors) → auto-assign per ordre // mandos↔jugadors) → auto-assign per ordre els slots que hagen quedat
// els slots que hagen quedat buits (equivalent al patró de // buits (equivalent al patró de `coffee_crisis_arcade_edition`).
// `coffee_crisis_arcade_edition`).
void applyControlsToGamepadManager() { void applyControlsToGamepadManager() {
auto& gm = GamepadManager::get(); auto& gm = GamepadManager::get();
gm.assignToPlayer(0, g_controls.gamepad_p1.name, g_controls.gamepad_p1.path); gm.assignToPlayer(0, g_controls.gamepad_p1.name, g_controls.gamepad_p1.path);
+102 -3
View File
@@ -26,7 +26,9 @@
#include "core/system/event_buffer.hpp" #include "core/system/event_buffer.hpp"
#include "core/ui/ui_config.hpp" #include "core/ui/ui_config.hpp"
#include "external/fkyaml_node.hpp" #include "external/fkyaml_node.hpp"
#include "game/entities/concrete/dust.hpp"
#include "game/entities/entity.hpp" #include "game/entities/entity.hpp"
#include "game/entities/entity_loader.hpp"
#include "game/entities/event_bus.hpp" #include "game/entities/event_bus.hpp"
#include "game/entities/player.hpp" #include "game/entities/player.hpp"
#include "game/map/map.hpp" #include "game/map/map.hpp"
@@ -92,17 +94,38 @@ namespace {
// gestionarà a `applyMovement` mogent la distància parcial possible. // 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, // 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. // 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) { void resolvePlayerVsPlayer(const std::vector<Player*>& ps, float dt, float push_factor) {
if (ps.size() < 2) { return; } if (ps.size() < 2) { return; }
auto* a = ps[0]; auto* a = ps[0];
auto* b = ps[1]; auto* b = ps[1];
if (a == nullptr || b == nullptr) { return; } 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; }
// 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: // Calculem l'overlap previst en l'espai de la hit-box petita:
// els jugadors han de poder acostar-se molt (caixes de moviment // els jugadors han de poder acostar-se molt (caixes de moviment
// 16×16 quasi tocant) sense disparar el resolver; només quan el // 16×16 quasi tocant) sense disparar el resolver; només quan el
// contacte real (hit-box 8×8) s'activaria, neutralitzem o apliquem // contacte real (hit-box 8×8) s'activaria, neutralitzem o apliquem
// push. // push (a terra) o invertim velocitats (en l'aire).
const SDL_FRect BOX_A = a->getHitBox(); const SDL_FRect BOX_A = a->getHitBox();
const SDL_FRect BOX_B = b->getHitBox(); const SDL_FRect BOX_B = b->getHitBox();
const SDL_FRect NEXT_A{ const SDL_FRect NEXT_A{
@@ -119,8 +142,34 @@ namespace {
}; };
if (!rectsOverlap(NEXT_A, NEXT_B)) { return; } if (!rectsOverlap(NEXT_A, NEXT_B)) { return; }
const bool MOVING_A = (a->getIntendedVX() != 0.0F) || (a->getIntendedVY() != 0.0F); // Tots dos en l'aire (A_AIR == B_AIR == true aquí, ja descartat
const bool MOVING_B = (b->getIntendedVX() != 0.0F) || (b->getIntendedVY() != 0.0F); // 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) { if (MOVING_A && MOVING_B) {
// Head-on o encreuament amb col·lisió: cap dels dos es mou. // Head-on o encreuament amb col·lisió: cap dels dos es mou.
@@ -226,6 +275,21 @@ GameScene::GameScene(Config config)
<< " dmg=" << (dmg != nullptr ? *dmg : 0) << '\n'; << " dmg=" << (dmg != nullptr ? *dmg : 0) << '\n';
}); });
// 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 // Controls per jugador: P1 amb fletxes, P2 amb WASD. Cada Player
// escolta les seues pròpies tecles dins del seu `update`, no hi ha // escolta les seues pròpies tecles dins del seu `update`, no hi ha
// estat global d'input. // estat global d'input.
@@ -234,12 +298,14 @@ GameScene::GameScene(Config config)
.down = Defaults::KeysGame::DOWN, .down = Defaults::KeysGame::DOWN,
.left = Defaults::KeysGame::LEFT, .left = Defaults::KeysGame::LEFT,
.right = Defaults::KeysGame::RIGHT, .right = Defaults::KeysGame::RIGHT,
.jump = Defaults::KeysGame::JUMP,
}; };
const Player::Controls CONTROLS_P2{ const Player::Controls CONTROLS_P2{
.up = Defaults::KeysGameP2::UP, .up = Defaults::KeysGameP2::UP,
.down = Defaults::KeysGameP2::DOWN, .down = Defaults::KeysGameP2::DOWN,
.left = Defaults::KeysGameP2::LEFT, .left = Defaults::KeysGameP2::LEFT,
.right = Defaults::KeysGameP2::RIGHT, .right = Defaults::KeysGameP2::RIGHT,
.jump = Defaults::KeysGameP2::JUMP,
}; };
// Spawn centrat al passadís exterior superior: la caixa de col·lisió // 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 // queda dins del passadís vertical del mig (entre columnes 1 i 2) i
@@ -298,6 +364,9 @@ GameScene::GameScene(Config config)
players_view_.reserve(players_.size()); players_view_.reserve(players_.size());
std::ranges::transform(players_, std::back_inserter(players_view_), [](const auto& p) { return p.get(); }); std::ranges::transform(players_, std::back_inserter(players_view_), [](const auto& p) { return p.get(); });
for (auto& p : players_) { p->setOtherPlayers(&players_view_); } for (auto& p : players_) { p->setOtherPlayers(&players_view_); }
// 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 // Marcador (HUD) de la franja superior. Reutilitza la font 8bithud i
// la paleta "ui" ja precarregades pel Director — no toca disc des // la paleta "ui" ja precarregades pel Director — no toca disc des
@@ -330,6 +399,30 @@ GameScene::GameScene(Config config)
GameScene::~GameScene() = default; GameScene::~GameScene() = default;
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() { void GameScene::handlePauseInput() {
#ifdef _DEBUG #ifdef _DEBUG
// Mutex amb l'editor: si està actiu, ignorem la tecla de pausa. // Mutex amb l'editor: si està actiu, ignorem la tecla de pausa.
@@ -511,6 +604,12 @@ void GameScene::render() {
// explícita: l'editor edita el tilemap, no la capa de pintat). // explícita: l'editor edita el tilemap, no la capa de pintat).
if (!SKIP_ACTORS && paint_layer_) { paint_layer_->render(); } if (!SKIP_ACTORS && paint_layer_) { paint_layer_->render(); }
if (!SKIP_ACTORS) { if (!SKIP_ACTORS) {
// 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: // Z-order interleaving entitats ↔ players per `getFeetY()` ascendent:
// els actors amb els peus més amunt queden dibuixats primer (al fons), // 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 // els més avall al davant. Així un sarc situat per darrere d'un player
+6
View File
@@ -56,6 +56,12 @@ class GameScene : public Scene {
// (no escalat) perquè la transició viu en temps real. // (no escalat) perquè la transició viu en temps real.
void updatePauseTransition(int delta_ms); 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_; Config config_;
Tilemap tilemap_; Tilemap tilemap_;
+2
View File
@@ -77,6 +77,7 @@ namespace Defaults {
constexpr SDL_Scancode DOWN = SDL_SCANCODE_DOWN; constexpr SDL_Scancode DOWN = SDL_SCANCODE_DOWN;
constexpr SDL_Scancode LEFT = SDL_SCANCODE_LEFT; constexpr SDL_Scancode LEFT = SDL_SCANCODE_LEFT;
constexpr SDL_Scancode RIGHT = SDL_SCANCODE_RIGHT; constexpr SDL_Scancode RIGHT = SDL_SCANCODE_RIGHT;
constexpr SDL_Scancode JUMP = SDL_SCANCODE_M;
constexpr SDL_Scancode EXIT = SDL_SCANCODE_ESCAPE; constexpr SDL_Scancode EXIT = SDL_SCANCODE_ESCAPE;
} // namespace KeysGame } // namespace KeysGame
@@ -89,6 +90,7 @@ namespace Defaults {
constexpr SDL_Scancode DOWN = SDL_SCANCODE_S; constexpr SDL_Scancode DOWN = SDL_SCANCODE_S;
constexpr SDL_Scancode LEFT = SDL_SCANCODE_A; constexpr SDL_Scancode LEFT = SDL_SCANCODE_A;
constexpr SDL_Scancode RIGHT = SDL_SCANCODE_D; constexpr SDL_Scancode RIGHT = SDL_SCANCODE_D;
constexpr SDL_Scancode JUMP = SDL_SCANCODE_V;
} // namespace KeysGameP2 } // namespace KeysGameP2
namespace Player { namespace Player {
+1 -1
View File
@@ -3,7 +3,7 @@
namespace Texts { namespace Texts {
constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner"; constexpr const char* WINDOW_TITLE = "© 2026 Aventures en Egipte — JailDesigner";
constexpr const char* COPYRIGHT = "© 2026 JailDesigner"; constexpr const char* COPYRIGHT = "© 2026 JailDesigner";
constexpr const char* VERSION = "0.22.0"; // Single source of truth — Makefile el grep constexpr const char* VERSION = "0.23.0"; // Single source of truth — Makefile el grep
} // namespace Texts } // namespace Texts
namespace GameCanvas { namespace GameCanvas {